pinecall-runtime 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
pinecall/runner/_kube.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""The cluster as the runner drives it: one gVisor pod a host, over the Kubernetes API."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
import ssl
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
|
14
|
+
|
|
15
|
+
from pinecall.domain.errors import UpstreamFailed
|
|
16
|
+
from pinecall.domain.names import Json, JsonObject
|
|
17
|
+
|
|
18
|
+
# Every pod the runner starts carries these, so a runner that restarts finds what is its own. Two
|
|
19
|
+
# runners, one per world, share the namespace: each lists and touches only its world's.
|
|
20
|
+
WORLD_LABEL = "pinecall.io/world"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# A label's value takes no `/`: the app is its stamp in the label, its name in an annotation.
|
|
24
|
+
APP_LABEL = "pinecall.io/app"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
APP_ANNOTATION = "pinecall.io/app"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
RELEASE_LABEL = "pinecall.io/release"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# The spike's measure (72 MB an idle agent under gVisor) with room for a tool's burst.
|
|
34
|
+
MEMORY = "256Mi"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
CPU = "500m"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# The install runs the org's own scripts: the same sandbox, more room, and a clock.
|
|
41
|
+
INSTALL_MEMORY = "1Gi"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
INSTALL_WITHIN_S = 300
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# The one writable place an app has besides its install: TMPDIR points here, a tmpfs.
|
|
48
|
+
SCRATCH = "/scratch"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
SCRATCH_SIZE = "64Mi"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Public resolvers: nothing of the cluster's is an app's to ask.
|
|
55
|
+
RESOLVERS = ("1.1.1.1", "8.8.8.8")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# What a drain needs: ten seconds for the gateway, thirty for the slowest tool.
|
|
59
|
+
DRAIN_S = 45
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
A_VERB_WITHIN_S = 30.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Where a container finds what it is started with: a secret the runner wrote, mounted read-only.
|
|
66
|
+
ENVIRONMENT = "/run/pinecall"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# The user every container of an org's code runs as: never root, the same in both containers.
|
|
70
|
+
APP_USER = 1000
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
IN_CLUSTER = "https://kubernetes.default.svc"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
NOT_A_VARIABLE = "{name!r} is not an environment variable's name"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# What deleting a pod or a secret already gone answers: it is gone, as asked.
|
|
80
|
+
NOT_THERE = 404
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# What creating a host's secret answers when its pod went with a node and the secret stayed.
|
|
84
|
+
ALREADY_THERE = 409
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
REFUSED = "the cluster {verb}: {status} {detail}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
NO_ANSWER = "the cluster {verb}: no answer in {seconds:.0f}s"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# The release's sources, fetched from this world's runner by their digest, then its dependencies
|
|
94
|
+
# installed the way its lockfile says; npm resolves what package.json names when there is none.
|
|
95
|
+
INSTALL = (
|
|
96
|
+
"node -e 'fetch(process.argv[1]).then(async (r) => { if (!r.ok) throw new Error(r.status);"
|
|
97
|
+
' process.stdout.write(Buffer.from(await r.arrayBuffer())); })\' "$PINECALL_SOURCE"'
|
|
98
|
+
" | tar -xz -C /app && cd /app && "
|
|
99
|
+
"if [ -f pnpm-lock.yaml ]; then corepack pnpm install --frozen-lockfile --prod; "
|
|
100
|
+
"elif [ -f package-lock.json ]; then npm ci --omit=dev --no-audit --no-fund; "
|
|
101
|
+
"else npm install --omit=dev --no-audit --no-fund; fi"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Where a pod's service account finds what it knocks the cluster's API with.
|
|
106
|
+
SERVICE_ACCOUNT = Path("/var/run/secrets/kubernetes.io/serviceaccount")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# What a pod whose process is over is in; a pod still installing or starting has not exited.
|
|
110
|
+
ENDED = frozenset({"Succeeded", "Failed"})
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
A_VARIABLE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class Engine:
|
|
118
|
+
"""Where every app runs: its image, its runtime class, its namespace, where sources are."""
|
|
119
|
+
|
|
120
|
+
image: str
|
|
121
|
+
runtime_class: str
|
|
122
|
+
namespace: str
|
|
123
|
+
# This runner's own address as an app's pod reaches it: `<url>/sources/<sha256>`.
|
|
124
|
+
sources_url: str
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class Container:
|
|
129
|
+
"""A pod the runner started: its name (the host), its app and release, how it is."""
|
|
130
|
+
|
|
131
|
+
name: str
|
|
132
|
+
app: str
|
|
133
|
+
release: int
|
|
134
|
+
state: str
|
|
135
|
+
started_at: float
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def has_exited(self) -> bool:
|
|
139
|
+
"""Whether its process ended; one still installing or starting has not."""
|
|
140
|
+
return self.state in ENDED
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class Launch:
|
|
145
|
+
"""One host to start: whose, which release and its digest, what it starts with."""
|
|
146
|
+
|
|
147
|
+
world: str
|
|
148
|
+
app: str
|
|
149
|
+
host: str
|
|
150
|
+
release: int
|
|
151
|
+
sha256: str
|
|
152
|
+
command: Sequence[str]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class _Read(BaseModel):
|
|
156
|
+
"""What a listing says, the fields read of it and nothing else."""
|
|
157
|
+
|
|
158
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _Running(_Read):
|
|
162
|
+
started_at: str | None = Field(None, alias="startedAt")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class _State(_Read):
|
|
166
|
+
running: _Running | None = None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _ContainerStatus(_Read):
|
|
170
|
+
name: str
|
|
171
|
+
state: _State = Field(default_factory=_State)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class _Status(_Read):
|
|
175
|
+
phase: str = "Pending"
|
|
176
|
+
containers: list[_ContainerStatus] = Field(
|
|
177
|
+
default_factory=list[_ContainerStatus], alias="containerStatuses"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class _Metadata(_Read):
|
|
182
|
+
name: str
|
|
183
|
+
labels: dict[str, str] = Field(default_factory=dict[str, str])
|
|
184
|
+
annotations: dict[str, str] = Field(default_factory=dict[str, str])
|
|
185
|
+
created: str = Field("", alias="creationTimestamp")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class _Pod(_Read):
|
|
189
|
+
metadata: _Metadata
|
|
190
|
+
status: _Status = Field(default_factory=_Status)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class _Knock:
|
|
195
|
+
"""One request to the cluster's API: what it is called in a refusal, and what it sends."""
|
|
196
|
+
|
|
197
|
+
verb: str
|
|
198
|
+
method: str
|
|
199
|
+
path: str
|
|
200
|
+
body: JsonObject | None = None
|
|
201
|
+
params: Mapping[str, str] | None = None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class Cluster:
|
|
206
|
+
"""The cluster's API, knocked as the runner's own service account."""
|
|
207
|
+
|
|
208
|
+
http: httpx.AsyncClient
|
|
209
|
+
engine: Engine
|
|
210
|
+
token: Path = SERVICE_ACCOUNT / "token"
|
|
211
|
+
|
|
212
|
+
async def pods(self, world: str) -> list[Container]:
|
|
213
|
+
"""Every pod this world's runner started, running or not; never another world's."""
|
|
214
|
+
selector = {"labelSelector": f"{APP_LABEL},{WORLD_LABEL}={world}"}
|
|
215
|
+
listing = await self._required(_Knock("list", "GET", self._at("pods"), params=selector))
|
|
216
|
+
return containers_in(listing.json().get("items", []))
|
|
217
|
+
|
|
218
|
+
async def start(self, launch: Launch, environment: Mapping[str, str]) -> None:
|
|
219
|
+
"""The host's environment as a secret, then its pod: installing first, then running."""
|
|
220
|
+
secret = environment_secret(launch.host, environment)
|
|
221
|
+
made = await self._request(_Knock("create", "POST", self._at("secrets"), body=secret))
|
|
222
|
+
if made.status_code == ALREADY_THERE:
|
|
223
|
+
path = self._at(f"secrets/{_secret_of(launch.host)}")
|
|
224
|
+
await self._required(_Knock("replace", "PUT", path, body=secret))
|
|
225
|
+
elif not made.is_success:
|
|
226
|
+
raise UpstreamFailed(_refusal("create", made))
|
|
227
|
+
await self._required(
|
|
228
|
+
_Knock("create", "POST", self._at("pods"), body=pod(self.engine, launch))
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
async def stop(self, name: str, grace_s: int = DRAIN_S) -> None:
|
|
232
|
+
"""SIGTERM, then grace_s (the drain's, unless said) before the kill; its environment too."""
|
|
233
|
+
body: JsonObject = {"gracePeriodSeconds": grace_s}
|
|
234
|
+
for path in (f"pods/{name}", f"secrets/{_secret_of(name)}"):
|
|
235
|
+
answer = await self._request(_Knock("delete", "DELETE", self._at(path), body=body))
|
|
236
|
+
if not answer.is_success and answer.status_code != NOT_THERE:
|
|
237
|
+
raise UpstreamFailed(_refusal("delete", answer))
|
|
238
|
+
|
|
239
|
+
async def logs(self, name: str, lines: int) -> str:
|
|
240
|
+
"""The last lines the host printed; its install's, when it never got to run."""
|
|
241
|
+
for container in ("app", "install"):
|
|
242
|
+
query = {"container": container, "tailLines": str(lines)}
|
|
243
|
+
answer = await self._request(
|
|
244
|
+
_Knock("logs", "GET", self._at(f"pods/{name}/log"), params=query)
|
|
245
|
+
)
|
|
246
|
+
if answer.is_success and answer.text.strip():
|
|
247
|
+
return answer.text
|
|
248
|
+
return ""
|
|
249
|
+
|
|
250
|
+
async def _required(self, knock: _Knock) -> httpx.Response:
|
|
251
|
+
answer = await self._request(knock)
|
|
252
|
+
if not answer.is_success:
|
|
253
|
+
raise UpstreamFailed(_refusal(knock.verb, answer))
|
|
254
|
+
return answer
|
|
255
|
+
|
|
256
|
+
# The token is read on every knock: the kubelet rotates it under the runner.
|
|
257
|
+
async def _request(self, knock: _Knock) -> httpx.Response:
|
|
258
|
+
headers = {"Authorization": f"Bearer {self.token.read_text().strip()}"}
|
|
259
|
+
try:
|
|
260
|
+
return await self.http.request(
|
|
261
|
+
knock.method,
|
|
262
|
+
knock.path,
|
|
263
|
+
headers=headers,
|
|
264
|
+
json=knock.body,
|
|
265
|
+
params=knock.params,
|
|
266
|
+
timeout=A_VERB_WITHIN_S,
|
|
267
|
+
)
|
|
268
|
+
except httpx.TimeoutException:
|
|
269
|
+
raise UpstreamFailed(
|
|
270
|
+
NO_ANSWER.format(verb=knock.verb, seconds=A_VERB_WITHIN_S)
|
|
271
|
+
) from None
|
|
272
|
+
except httpx.HTTPError as failed:
|
|
273
|
+
raise UpstreamFailed(f"the cluster {knock.verb}: {failed}") from failed
|
|
274
|
+
|
|
275
|
+
def _at(self, kind: str) -> str:
|
|
276
|
+
return f"/api/v1/namespaces/{self.engine.namespace}/{kind}"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# The process's own shell reads its environment, then becomes the command.
|
|
280
|
+
READ_THEN_RUN = f'. {ENVIRONMENT}/env && exec "$@"'
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
_PODS: TypeAdapter[list[_Pod]] = TypeAdapter(list[_Pod])
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def in_cluster(engine: Engine) -> Cluster:
|
|
287
|
+
"""The cluster's API as this pod's service account knocks it, trusting the cluster's CA."""
|
|
288
|
+
verified = ssl.create_default_context(cafile=str(SERVICE_ACCOUNT / "ca.crt"))
|
|
289
|
+
return Cluster(http=httpx.AsyncClient(base_url=IN_CLUSTER, verify=verified), engine=engine)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def app_stamp(app: str) -> str:
|
|
293
|
+
"""The app as a label's value takes it: a stamp of `org/name`."""
|
|
294
|
+
return hashlib.sha256(app.encode()).hexdigest()[:16]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# Single-quoted, so any value is itself: a key of several lines, a quote, a dollar sign.
|
|
298
|
+
def exported(environment: Mapping[str, str]) -> str:
|
|
299
|
+
"""The environment as a shell reads it: one `export NAME='value'` a variable."""
|
|
300
|
+
lines: list[str] = []
|
|
301
|
+
for name, value in sorted(environment.items()):
|
|
302
|
+
if not A_VARIABLE.match(name):
|
|
303
|
+
raise UpstreamFailed(NOT_A_VARIABLE.format(name=name))
|
|
304
|
+
quoted = value.replace("'", "'\\''")
|
|
305
|
+
lines.append(f"export {name}='{quoted}'")
|
|
306
|
+
return "\n".join(lines) + "\n"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def environment_secret(host: str, environment: Mapping[str, str]) -> JsonObject:
|
|
310
|
+
"""The host's environment as a secret: one file, `env`, the shell's to read."""
|
|
311
|
+
return {
|
|
312
|
+
"apiVersion": "v1",
|
|
313
|
+
"kind": "Secret",
|
|
314
|
+
"metadata": {"name": _secret_of(host)},
|
|
315
|
+
"type": "Opaque",
|
|
316
|
+
"stringData": {"env": exported(environment)},
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def pod(engine: Engine, launch: Launch) -> JsonObject:
|
|
321
|
+
"""One host: gVisor, no token, public resolvers, installed by its first container, then run."""
|
|
322
|
+
source = f"{engine.sources_url.rstrip('/')}/sources/{launch.sha256}"
|
|
323
|
+
return {
|
|
324
|
+
"apiVersion": "v1",
|
|
325
|
+
"kind": "Pod",
|
|
326
|
+
"metadata": {
|
|
327
|
+
"name": launch.host,
|
|
328
|
+
"labels": {
|
|
329
|
+
APP_LABEL: app_stamp(launch.app),
|
|
330
|
+
WORLD_LABEL: launch.world,
|
|
331
|
+
RELEASE_LABEL: str(launch.release),
|
|
332
|
+
},
|
|
333
|
+
"annotations": {APP_ANNOTATION: launch.app},
|
|
334
|
+
},
|
|
335
|
+
"spec": {
|
|
336
|
+
"runtimeClassName": engine.runtime_class,
|
|
337
|
+
"restartPolicy": "Never",
|
|
338
|
+
"automountServiceAccountToken": False,
|
|
339
|
+
"enableServiceLinks": False,
|
|
340
|
+
"terminationGracePeriodSeconds": DRAIN_S,
|
|
341
|
+
"hostname": launch.host[:63],
|
|
342
|
+
"dnsPolicy": "None",
|
|
343
|
+
"dnsConfig": {"nameservers": list(RESOLVERS)},
|
|
344
|
+
"securityContext": {
|
|
345
|
+
"runAsNonRoot": True,
|
|
346
|
+
"fsGroup": APP_USER,
|
|
347
|
+
"seccompProfile": {"type": "RuntimeDefault"},
|
|
348
|
+
},
|
|
349
|
+
"volumes": [
|
|
350
|
+
{"name": "app", "emptyDir": {"sizeLimit": "1Gi"}},
|
|
351
|
+
{"name": "home", "emptyDir": {"sizeLimit": "1Gi"}},
|
|
352
|
+
{"name": "scratch", "emptyDir": {"medium": "Memory", "sizeLimit": SCRATCH_SIZE}},
|
|
353
|
+
{
|
|
354
|
+
"name": "environment",
|
|
355
|
+
"secret": {"secretName": _secret_of(launch.host), "defaultMode": 0o444},
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
"initContainers": [
|
|
359
|
+
{
|
|
360
|
+
"name": "install",
|
|
361
|
+
"image": engine.image,
|
|
362
|
+
"command": ["timeout", str(INSTALL_WITHIN_S), "sh", "-c", INSTALL],
|
|
363
|
+
"env": [
|
|
364
|
+
{"name": "HOME", "value": "/home/app"},
|
|
365
|
+
{"name": "TMPDIR", "value": SCRATCH},
|
|
366
|
+
{"name": "PINECALL_SOURCE", "value": source},
|
|
367
|
+
],
|
|
368
|
+
"securityContext": _locked(),
|
|
369
|
+
"resources": _capped(INSTALL_MEMORY),
|
|
370
|
+
"volumeMounts": [
|
|
371
|
+
{"name": "app", "mountPath": "/app"},
|
|
372
|
+
{"name": "home", "mountPath": "/home/app"},
|
|
373
|
+
{"name": "scratch", "mountPath": SCRATCH},
|
|
374
|
+
],
|
|
375
|
+
}
|
|
376
|
+
],
|
|
377
|
+
"containers": [
|
|
378
|
+
{
|
|
379
|
+
"name": "app",
|
|
380
|
+
"image": engine.image,
|
|
381
|
+
"command": ["sh", "-c", READ_THEN_RUN, "sh", *launch.command],
|
|
382
|
+
"workingDir": "/app",
|
|
383
|
+
"env": [
|
|
384
|
+
{"name": "HOME", "value": SCRATCH},
|
|
385
|
+
{"name": "TMPDIR", "value": SCRATCH},
|
|
386
|
+
{"name": "NO_COLOR", "value": "1"},
|
|
387
|
+
],
|
|
388
|
+
"securityContext": _locked(),
|
|
389
|
+
"resources": _capped(MEMORY),
|
|
390
|
+
"volumeMounts": [
|
|
391
|
+
{"name": "app", "mountPath": "/app", "readOnly": True},
|
|
392
|
+
{"name": "scratch", "mountPath": SCRATCH},
|
|
393
|
+
{"name": "environment", "mountPath": ENVIRONMENT, "readOnly": True},
|
|
394
|
+
],
|
|
395
|
+
}
|
|
396
|
+
],
|
|
397
|
+
},
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# A pod that has not got to run yet is given the install's time before its registering is timed.
|
|
402
|
+
def containers_in(items: list[Json]) -> list[Container]:
|
|
403
|
+
"""The pods a listing answered, as the runner's plan reads them."""
|
|
404
|
+
found: list[Container] = []
|
|
405
|
+
for item in _PODS.validate_python(items):
|
|
406
|
+
running = next(
|
|
407
|
+
(
|
|
408
|
+
each.state.running.started_at
|
|
409
|
+
for each in item.status.containers
|
|
410
|
+
if each.name == "app" and each.state.running is not None
|
|
411
|
+
),
|
|
412
|
+
None,
|
|
413
|
+
)
|
|
414
|
+
started = (
|
|
415
|
+
_seconds(running) if running else _seconds(item.metadata.created) + INSTALL_WITHIN_S
|
|
416
|
+
)
|
|
417
|
+
found.append(
|
|
418
|
+
Container(
|
|
419
|
+
name=item.metadata.name,
|
|
420
|
+
app=item.metadata.annotations.get(APP_ANNOTATION, ""),
|
|
421
|
+
release=int(item.metadata.labels.get(RELEASE_LABEL, "0") or 0),
|
|
422
|
+
state=item.status.phase,
|
|
423
|
+
started_at=started,
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
return found
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _secret_of(host: str) -> str:
|
|
430
|
+
"""The secret a host's environment is kept in."""
|
|
431
|
+
return f"{host}-env"
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# What every container of an org's code runs under: no privilege, no root, nothing writable but
|
|
435
|
+
# its own scratch.
|
|
436
|
+
def _locked() -> JsonObject:
|
|
437
|
+
return {
|
|
438
|
+
"allowPrivilegeEscalation": False,
|
|
439
|
+
"readOnlyRootFilesystem": True,
|
|
440
|
+
"runAsNonRoot": True,
|
|
441
|
+
"runAsUser": APP_USER,
|
|
442
|
+
"runAsGroup": APP_USER,
|
|
443
|
+
"capabilities": {"drop": ["ALL"]},
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _capped(memory: str) -> JsonObject:
|
|
448
|
+
caps: JsonObject = {"memory": memory, "cpu": CPU}
|
|
449
|
+
return {"requests": caps, "limits": caps}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _seconds(stamp: str) -> float:
|
|
453
|
+
if not stamp:
|
|
454
|
+
return time.time()
|
|
455
|
+
return datetime.fromisoformat(stamp).timestamp()
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _refusal(verb: str, answer: httpx.Response) -> str:
|
|
459
|
+
return REFUSED.format(verb=verb, status=answer.status_code, detail=answer.text[:500])
|
pinecall/runner/_plan.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""What a runner does this beat: decided from what the gateway wants and what the cluster has."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from pinecall.runner._kube import Container
|
|
7
|
+
from pinecall.wire.rest.hosting import WantedApp
|
|
8
|
+
|
|
9
|
+
# From the app's process starting to the gateway seeing its agents: past it, the release failed.
|
|
10
|
+
REGISTERS_WITHIN_S = 120.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# A host that served and whose process exits is run again, this many times in this long; past
|
|
14
|
+
# it the release is failed, and said so.
|
|
15
|
+
MOST_CRASHES = 5
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
CRASH_WINDOW_S = 600.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
EXITED = "the process exited before its agents registered:\n{output}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Start:
|
|
26
|
+
"""Install what is not installed and run the host wanted."""
|
|
27
|
+
|
|
28
|
+
app: WantedApp
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Revive:
|
|
33
|
+
"""A host that served and whose process exited: its last lines kept, and run again."""
|
|
34
|
+
|
|
35
|
+
app: WantedApp
|
|
36
|
+
container: Container
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Stop:
|
|
41
|
+
"""A container nobody wants any more: drained and removed."""
|
|
42
|
+
|
|
43
|
+
container: Container
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class WentLive:
|
|
48
|
+
"""The host wanted registered: said to the gateway, once."""
|
|
49
|
+
|
|
50
|
+
app: WantedApp
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Failed:
|
|
55
|
+
"""The host wanted did not start, register or stay up: said with its last lines."""
|
|
56
|
+
|
|
57
|
+
app: WantedApp
|
|
58
|
+
why: str
|
|
59
|
+
container: Container
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
type Step = Start | Revive | Stop | WentLive | Failed
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
NEVER_REGISTERED = (
|
|
66
|
+
f"no agent registered from this release within {REGISTERS_WITHIN_S:.0f}s:\n{{output}}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
KEEPS_EXITING = (
|
|
71
|
+
f"the process exited {MOST_CRASHES} times in {CRASH_WINDOW_S / 60:.0f} minutes "
|
|
72
|
+
"and is not run again:\n{output}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def label_of(app: WantedApp) -> str:
|
|
77
|
+
"""What a container carries of whose it is: the org and the app."""
|
|
78
|
+
return f"{app.org}/{app.name}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def crashes_within(times: Sequence[float], now: float) -> list[float]:
|
|
82
|
+
"""The crashes still counted: those inside the window."""
|
|
83
|
+
return [at for at in times if now - at < CRASH_WINDOW_S]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Numbers in, steps out: nothing here touches the cluster or the gateway. The steps of one app are
|
|
87
|
+
# in the order they are to be done, and two apps' steps never depend on each other.
|
|
88
|
+
def planned(
|
|
89
|
+
wanted: Sequence[WantedApp],
|
|
90
|
+
containers: Sequence[Container],
|
|
91
|
+
crashes: Mapping[str, Sequence[float]],
|
|
92
|
+
now: float,
|
|
93
|
+
) -> dict[str, list[Step]]:
|
|
94
|
+
"""This beat's steps per app label; an app with nothing to do is not in it."""
|
|
95
|
+
steps: dict[str, list[Step]] = {}
|
|
96
|
+
for app in wanted:
|
|
97
|
+
ours = [each for each in containers if each.app == label_of(app)]
|
|
98
|
+
steps[label_of(app)] = _of_one(app, ours, crashes, now)
|
|
99
|
+
labels = {label_of(app) for app in wanted}
|
|
100
|
+
for container in containers:
|
|
101
|
+
if container.app not in labels:
|
|
102
|
+
steps.setdefault(container.app, []).append(Stop(container))
|
|
103
|
+
return {label: todo for label, todo in steps.items() if todo}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# The host that last went live serves until the one wanted registers, whatever becomes of the
|
|
107
|
+
# one wanted; every other container of the app, and a host that failed, is removed.
|
|
108
|
+
def _of_one(
|
|
109
|
+
app: WantedApp, ours: Sequence[Container], crashes: Mapping[str, Sequence[float]], now: float
|
|
110
|
+
) -> list[Step]:
|
|
111
|
+
by_name = {each.name: each for each in ours}
|
|
112
|
+
wanted = by_name.get(app.host)
|
|
113
|
+
keep = {app.host, app.live_host}
|
|
114
|
+
steps: list[Step] = []
|
|
115
|
+
if app.failed:
|
|
116
|
+
keep.discard(app.host)
|
|
117
|
+
elif wanted is None:
|
|
118
|
+
steps.append(Start(app))
|
|
119
|
+
elif wanted.has_exited:
|
|
120
|
+
steps.append(_exited(app, wanted, crashes, now))
|
|
121
|
+
elif app.registered and app.live_host != app.host:
|
|
122
|
+
steps.append(WentLive(app))
|
|
123
|
+
elif not app.registered and _never_served_in_time(app, wanted, now):
|
|
124
|
+
steps.append(Failed(app, NEVER_REGISTERED, wanted))
|
|
125
|
+
if app.registered and not app.failed:
|
|
126
|
+
keep = {app.host}
|
|
127
|
+
before = by_name.get(app.live_host or "")
|
|
128
|
+
if before is not None and before.name != app.host and before.has_exited:
|
|
129
|
+
if before.name in keep and _may_run_again(before, crashes, now):
|
|
130
|
+
steps.append(Revive(app, before))
|
|
131
|
+
else:
|
|
132
|
+
keep.discard(before.name)
|
|
133
|
+
keep -= {step.container.name for step in steps if isinstance(step, Failed)}
|
|
134
|
+
return steps + [Stop(each) for each in ours if each.name not in keep]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# One that never served is a release that does not work: failed at once. One that served is run
|
|
138
|
+
# again, until it has exited too often to be worth another try.
|
|
139
|
+
def _exited(
|
|
140
|
+
app: WantedApp, container: Container, crashes: Mapping[str, Sequence[float]], now: float
|
|
141
|
+
) -> Step:
|
|
142
|
+
if container.name != app.live_host:
|
|
143
|
+
return Failed(app, EXITED, container)
|
|
144
|
+
if not _may_run_again(container, crashes, now):
|
|
145
|
+
return Failed(app, KEEPS_EXITING, container)
|
|
146
|
+
return Revive(app, container)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _may_run_again(
|
|
150
|
+
container: Container, crashes: Mapping[str, Sequence[float]], now: float
|
|
151
|
+
) -> bool:
|
|
152
|
+
return len(crashes_within(crashes.get(container.name, ()), now)) < MOST_CRASHES
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# A host that served and is not registered this beat is between two sockets: left alone.
|
|
156
|
+
def _never_served_in_time(app: WantedApp, wanted: Container, now: float) -> bool:
|
|
157
|
+
return app.live_host != app.host and now - wanted.started_at > REGISTERS_WITHIN_S
|