@edgehero/pi-dispatch 4.3.0 → 5.0.0
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.
- package/.env.example +94 -4
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +199 -30
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +740 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1554 -167
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +296 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +782 -40
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
package/src/up.mjs
CHANGED
|
@@ -25,6 +25,13 @@
|
|
|
25
25
|
* come from this shell when it sets them and otherwise from the deployment's `.env`, the file `service install`
|
|
26
26
|
* reads, so the two entry points decide the venue from the same place (`deploymentVenueEnv`).
|
|
27
27
|
*
|
|
28
|
+
* The openshell venues (issue #707): when PI_BACKENDS lists `openshell` or `openshell-bind`, `up` first checks the
|
|
29
|
+
* OpenShell CLI and its gateway (read-only), offers to START a gateway that is installed but not answering and to
|
|
30
|
+
* REGISTER one that answers unregistered, each asked like every other step, and never installs OpenShell (it prints the
|
|
31
|
+
* pinned release instead). Then the job image goes into the gateway's engine (PI_OPENSHELL_ENGINE, docker by default)
|
|
32
|
+
* and, where no `local` or `podman` venue already decides it, Valkey runs on that engine (`openshellSetupOf`). No egress
|
|
33
|
+
* proxy is ever started for it: the gateway's policy layer is that venue's egress control.
|
|
34
|
+
*
|
|
28
35
|
* Converge-style, not transactional: a declined or failed step is reported and the pass continues, so
|
|
29
36
|
* one flaky pull does not hide the doctor report that says what else is missing. Exit code mirrors
|
|
30
37
|
* doctor's: 0 unless the docker daemon was unreachable up front (nothing else can be probed, so up
|
|
@@ -40,14 +47,18 @@ import { homedir, networkInterfaces, userInfo } from "node:os";
|
|
|
40
47
|
import { dirname, join, posix, resolve, win32 } from "node:path";
|
|
41
48
|
import { fileURLToPath } from "node:url";
|
|
42
49
|
import { PODMAN_BOOT_REFUSING_CAUSES, makePodmanInfoReader, decidePodmanJobUser, podmanJobUserRefusal } from "./backend-podman.mjs";
|
|
43
|
-
import { venuesOf } from "./backends.mjs";
|
|
50
|
+
import { openshellSetupOf, venuesOf } from "./backends.mjs";
|
|
51
|
+
import { GATEWAY_TOML_WHERE, OPENSHELL_BREW_INSTALL, OPENSHELL_DEFAULT_GATEWAY_NAME, OPENSHELL_GATEWAY_UNIT, OPENSHELL_LOCAL_GATEWAY_HOST, OPENSHELL_LOCAL_GATEWAY_PORT, OPENSHELL_LOCAL_GATEWAY_URL, OPENSHELL_NO_WINDOWS, OPENSHELL_RELEASE_URL, OPENSHELL_TESTED_VERSION, gatewayTomlFor, judgeGatewayStatus } from "./openshell-cli.mjs";
|
|
44
52
|
import { jobImageFrom, logsDirPath, settingsFilePath } from "./config.mjs";
|
|
45
53
|
import { jobImageFix, pullOffered, registryQualified } from "./image-ref.mjs";
|
|
46
54
|
import { DEFAULT_EGRESS_PROXY, egressArmed as egressArmedFn, egressProxyName } from "./egress.mjs";
|
|
47
55
|
import { ENV_WRITER_FS, ENV_VALUE_UNWRITABLE, envKeyIsBlank, envValueShown, readEnvAssignments, updateEnvFile } from "./env-file.mjs";
|
|
48
56
|
import { COMPOSE_FILE, COMPOSE_VALKEY_OVERRIDE, OWNER_MARKER_KEY, VALKEY_PASSWORD_KEY, VALKEY_PORT_KEY, OWNER_CHECK_CONTAINER, OWNER_CHECK_EXEC, VALKEY_VOLUME_RECORD, adoptVolumeQuestion, composeArgs, ownerCheckAnswer, readVolumeRecord, valkeyOwnerCheckArgs, volumeRecordMatches, volumeRecordText, composeProjectName, foreignContainerSentence, foreignMarkerRefusal, foreignVolumeLabelRefusal, foreignVolumeRefusal, foreignVolumeUsers, isLoopbackHost, newValkeyPassword, unadoptedVolumeRefusal, valkeyContainerOwner, valkeyDockerRunArgs, valkeyPasswordDecision, valkeyPortEnvDecision, valkeyVolumeCreateArgs, valkeyVolumeOwner } from "./valkey-auth.mjs";
|
|
57
|
+
import { olderValkeyContainerFinding } from "./older-state.mjs";
|
|
49
58
|
import { deploymentValkeyEnv, deploymentVenueEnv } from "./deployment-venue.mjs";
|
|
50
59
|
import { resolveServiceEnv, serviceEnvFileOf, serviceEnvLoader } from "./service-env.mjs";
|
|
60
|
+
import { operatorCount, readTokensFile } from "./console-tokens.mjs";
|
|
61
|
+
import { consoleTokensPathFor, installedConsoleUnitPath } from "./service.mjs";
|
|
51
62
|
import { PACKAGED_EGRESS_PROXY_CONF, judgeProxyConfCopy, packageCopyName, readPackagedProxyConf, replaceProxyConfCopy } from "./egress-conf-copy.mjs";
|
|
52
63
|
import { EGRESS_PROXY_IMAGE, MODEL_ENDPOINTS_TARGET, PROXY_STATE_FORMAT, jobNetworksOf, parseProxyState, rulesIncludeEndpoints, shippedProxyDrift } from "./egress-proxy-state.mjs";
|
|
53
64
|
import { endpointsDeclaredIn, rulesFileIncludes, rulesPredateEndpointsLine } from "./egress-cli.mjs";
|
|
@@ -93,7 +104,7 @@ const VALKEY_RM_ARGS = ["rm", "pi-dispatch-valkey"];
|
|
|
93
104
|
//
|
|
94
105
|
// The per-job networks are NOT here: the worker creates one per job and attaches this container to it for
|
|
95
106
|
// the life of that run. This is only the proxy and its way out.
|
|
96
|
-
const EGRESS_NETWORK_ARGS = ["network", "create", "pi-dispatch-egress-out"];
|
|
107
|
+
export const EGRESS_NETWORK_ARGS = ["network", "create", "pi-dispatch-egress-out"];
|
|
97
108
|
// A stopped CURRENT shipped proxy is started (a paused one unpaused) as it is; a STALE one is removed and run again from
|
|
98
109
|
// the argv below (issue #453; the reasons are at step e2).
|
|
99
110
|
const EGRESS_START_ARGS = ["start", "pi-dispatch-egress-proxy"];
|
|
@@ -105,7 +116,7 @@ const EGRESS_RM_ARGS = ["rm", "-f", "-v", "pi-dispatch-egress-proxy"];
|
|
|
105
116
|
const EGRESS_RESTART_ARGS = ["restart", "pi-dispatch-egress-proxy"];
|
|
106
117
|
// The deployment folder's files the shipped proxy mounts, each checked before a start or a recreate.
|
|
107
118
|
const PROXY_FOLDER_FILES = Object.freeze(["egress-allowlist.conf", "deploy/egress-proxy.conf", MODEL_ENDPOINTS_INCLUDE_NAME]);
|
|
108
|
-
const EGRESS_RUN_ARGS = [
|
|
119
|
+
export const EGRESS_RUN_ARGS = [
|
|
109
120
|
"run",
|
|
110
121
|
"-d",
|
|
111
122
|
"--name",
|
|
@@ -151,7 +162,22 @@ function underFolder(p, folder, platform) {
|
|
|
151
162
|
return a === b || a.startsWith(`${b}/`);
|
|
152
163
|
}
|
|
153
164
|
|
|
165
|
+
/**
|
|
166
|
+
* The operator tokens the console SERVICE of the deployment in `dir` would find (issue #708): the file
|
|
167
|
+
* `service install --console` judges (`consoleTokensPathFor`: the deployment `.env`'s PI_CONSOLE_TOKENS_FILE, else
|
|
168
|
+
* console-tokens.json in `dir`), read and judged as `serve` reads it. 0 for a file the console could not use, which is
|
|
169
|
+
* also no reason to offer the service. One helper with the install, so the line never offers an install that refuses.
|
|
170
|
+
*/
|
|
171
|
+
function defaultConsoleOperators(dir, platform) {
|
|
172
|
+
const read = readTokensFile({ path: consoleTokensPathFor(dir, platform) });
|
|
173
|
+
return read.refusal ? 0 : operatorCount(read.file);
|
|
174
|
+
}
|
|
175
|
+
|
|
154
176
|
export async function runUp(argv = [], deps = {}) {
|
|
177
|
+
if (argv.includes("--console")) {
|
|
178
|
+
const { runConsoleUp } = await import("./console-bootstrap.mjs");
|
|
179
|
+
return runConsoleUp(argv, { ...deps, runUpFn: deps.runUpFn ?? runUp });
|
|
180
|
+
}
|
|
155
181
|
const {
|
|
156
182
|
env = process.env,
|
|
157
183
|
spawn = nodeSpawn,
|
|
@@ -216,9 +242,23 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
216
242
|
// the clock the backup's name is stamped with.
|
|
217
243
|
readPackagedConf = readPackagedProxyConf,
|
|
218
244
|
now = Date.now,
|
|
245
|
+
// Issue #707: how long `up` waits for a gateway it just started to answer, polled every second (`sleep` above).
|
|
246
|
+
gatewayWaitMs = 30_000,
|
|
247
|
+
// How long one read of the openshell CLI may take before it is killed (review round 1 of #715).
|
|
248
|
+
openshellReadTimeoutMs = 15_000,
|
|
249
|
+
// Issue #708: how many operator tokens the console's tokens file holds (0 when it cannot be used), for the closing
|
|
250
|
+
// "Next:" line that offers the console as a service. A seam so a test need not make an owner-only file.
|
|
251
|
+
consoleOperators = (dir) => defaultConsoleOperators(dir, platform),
|
|
252
|
+
// Issue #708 (round 1 review): whether a console unit is already installed for this folder (service.mjs' own
|
|
253
|
+
// lookup), so the line is not offered twice. On win32 the service is nssm's and is not asked here (no spawn for a
|
|
254
|
+
// hint), so the line follows the tokens alone there.
|
|
255
|
+
consoleUnitInstalled = (dir) => platform !== "win32" && installedConsoleUnitPath({ platform, home, cwd: dir }) !== null,
|
|
219
256
|
} = deps;
|
|
220
257
|
let { runInitFn, runDoctorFn } = deps;
|
|
221
258
|
const yes = argv.includes("--yes");
|
|
259
|
+
const adoptVolume = argv.includes("--adopt-volume");
|
|
260
|
+
const restartProxy = argv.includes("--restart-proxy");
|
|
261
|
+
const refreshProxyRules = argv.includes("--refresh-proxy-rules");
|
|
222
262
|
const summary = [];
|
|
223
263
|
// env-internal USER: the login running `up`, only ever read on the podman path (see the `user` seam above).
|
|
224
264
|
const userName = () => user ?? (env.USER || userInfoFn().username);
|
|
@@ -243,12 +283,17 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
243
283
|
// list WITHOUT `local` never touches docker at all: such a host may have no docker, and asking it would only fail.
|
|
244
284
|
const venues = venuesOf(venueEnv);
|
|
245
285
|
const dockerUsed = venues.localUsed;
|
|
286
|
+
// Issue #707: the openshell venues' setup, by the one rule `service install`, `init` and doctor follow: the engine the
|
|
287
|
+
// image goes into, and the engine Valkey runs on where no `local` or `podman` venue decides it. `dockerUsed` stays the
|
|
288
|
+
// `local` venue's: every squid step keeps that gate, so an openshell-on-docker host starts no proxy.
|
|
289
|
+
const openshell = openshellSetupOf(venues, venueEnv);
|
|
290
|
+
const dockerValkeyUsed = dockerUsed || openshell.valkeyEngine === "docker";
|
|
246
291
|
// Issue #464: which Valkey the SERVICE's worker will use, and whether PI_VALKEY_SHARED lets it be one another uid
|
|
247
292
|
// holds, read from `.env` where `service install` reads them, with this shell's value only where the file sets none.
|
|
248
293
|
// Asked only where the podman stack decides its Valkey (docker's Valkey is the queue wherever `local` is blessed),
|
|
249
294
|
// and before anything runs, as the venue keys are.
|
|
250
295
|
let valkeyKeys = { env: {}, fromFile: {}, notes: [] };
|
|
251
|
-
if (venues.podmanUsed && !venues.localUsed) {
|
|
296
|
+
if ((venues.podmanUsed && !venues.localUsed) || openshell.valkeyEngine === "podman") {
|
|
252
297
|
valkeyKeys = deploymentValkeyEnv({ env, fs, envPath: join(cwd, ".env"), platform });
|
|
253
298
|
if (valkeyKeys.error) {
|
|
254
299
|
out(`✗ ${valkeyKeys.error}\n\nup: cannot tell which Valkey this deployment's worker uses: fix the above, then re-run \`pi-dispatch up\`.\n`);
|
|
@@ -259,7 +304,8 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
259
304
|
//
|
|
260
305
|
// The docker lines below keep their original text and indentation, the `else` and the unbraced block included, so
|
|
261
306
|
// that the diff of issue #430 shows the docker path as the byte-identical thing its tests pin it to be.
|
|
262
|
-
if (
|
|
307
|
+
if (openshell.used) out(`pi-dispatch up: one pass over the quickstart for ${venueWords(venues, openshell)}; every ${toolWords(venues, openshell, platform)} action asks first (--yes accepts)\n\n`);
|
|
308
|
+
else if (!dockerUsed) out("pi-dispatch up: one pass over the quickstart for the podman venue; every podman and systemctl action asks first (--yes accepts)\n\n");
|
|
263
309
|
else
|
|
264
310
|
out("pi-dispatch up — one pass over the quickstart; every docker action asks first (--yes accepts)\n\n");
|
|
265
311
|
|
|
@@ -269,6 +315,25 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
269
315
|
if (jobImage.skip) out(`⚠ ${jobImage.skip}\n`);
|
|
270
316
|
if (jobImage.note) out(`⚠ ${jobImage.note}\n`);
|
|
271
317
|
|
|
318
|
+
// (a0) the openshell venues' gateway (issue #707), before anything is pulled or started: read-only unless the operator
|
|
319
|
+
// accepts an offer. A deployment with no other venue stops here when it is not usable, as the docker gate below does;
|
|
320
|
+
// beside another venue the other venue's steps still run, and `up` exits non-zero at the end.
|
|
321
|
+
let openshellReady = false;
|
|
322
|
+
let openshellFailed = false;
|
|
323
|
+
if (openshell.used) {
|
|
324
|
+
const gate = await openshellGatewayStep({ openshell, venues, spawn, out, yes, prompt, platform, probeTcp, sleep, now, waitMs: gatewayWaitMs, readTimeoutMs: openshellReadTimeoutMs });
|
|
325
|
+
if (gate.ok) openshellReady = true;
|
|
326
|
+
else {
|
|
327
|
+
openshellFailed = true;
|
|
328
|
+
summary.push(["openshell", `NOT ready: ${gate.why}`]);
|
|
329
|
+
if (!dockerUsed && !venues.podmanUsed) {
|
|
330
|
+
out("\nup: cannot continue without a usable OpenShell gateway: fix the above, then re-run `pi-dispatch up`.\n");
|
|
331
|
+
return 1;
|
|
332
|
+
}
|
|
333
|
+
out(" the other venues' steps below are unaffected; the openshell steps are skipped\n");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
272
337
|
if (dockerUsed) {
|
|
273
338
|
// (a) docker binary + daemon, before anything is offered: every mutation below runs through the
|
|
274
339
|
// docker CLI, so with the daemon down the prompts would only collect consent for failures.
|
|
@@ -282,31 +347,7 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
282
347
|
out("✓ Docker daemon reachable\n");
|
|
283
348
|
|
|
284
349
|
// (b) the default job image. Presence first, so the happy path re-run prompts for nothing.
|
|
285
|
-
|
|
286
|
-
else if (!jobImage.isDefault) await overrideImageStep({ bin: "docker", jobImage, spawn, out, yes, prompt, summary });
|
|
287
|
-
else if (await runCmd(spawn, "docker", ["image", "inspect", LOCAL_IMAGE]) === 0) {
|
|
288
|
-
out(`✓ Job image present (${LOCAL_IMAGE})\n`);
|
|
289
|
-
summary.push(["job image", `already present (${LOCAL_IMAGE})`]);
|
|
290
|
-
} else {
|
|
291
|
-
const accepted = await consent(
|
|
292
|
-
`The default job image (${LOCAL_IMAGE}) is not on this host. up would run:`,
|
|
293
|
-
[`docker ${PULL_ARGS.join(" ")}`, `docker ${TAG_ARGS.join(" ")}`],
|
|
294
|
-
{ yes, out, prompt },
|
|
295
|
-
);
|
|
296
|
-
if (!accepted) {
|
|
297
|
-
out("skipped — pull it later with the two commands above (or build image/Dockerfile yourself)\n");
|
|
298
|
-
summary.push(["job image", "skipped (declined) — jobs run with --pull=never, so nothing fetches it later"]);
|
|
299
|
-
} else if (await runStreamed(spawn, "docker", PULL_ARGS, out) !== 0) {
|
|
300
|
-
out("✗ docker pull failed — continuing; doctor below will re-check the image\n");
|
|
301
|
-
summary.push(["job image", "pull FAILED — re-run `pi-dispatch up`, or pull by hand"]);
|
|
302
|
-
} else if (await runStreamed(spawn, "docker", TAG_ARGS, out) !== 0) {
|
|
303
|
-
out("✗ docker tag failed — continuing; doctor below will re-check the image\n");
|
|
304
|
-
summary.push(["job image", `pulled, but tagging as ${LOCAL_IMAGE} FAILED — re-run the tag command by hand`]);
|
|
305
|
-
} else {
|
|
306
|
-
out(`✓ pulled and tagged ${LOCAL_IMAGE}\n`);
|
|
307
|
-
summary.push(["job image", `pulled ${UPSTREAM_IMAGE} and tagged it ${LOCAL_IMAGE}`]);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
350
|
+
await dockerImageStep({ jobImage, spawn, out, yes, prompt, summary });
|
|
310
351
|
|
|
311
352
|
}
|
|
312
353
|
|
|
@@ -329,6 +370,16 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
329
370
|
}
|
|
330
371
|
}
|
|
331
372
|
|
|
373
|
+
// (b3) the openshell venues' job image (issue #707), into the store the gateway runs sandboxes from: the engine
|
|
374
|
+
// PI_OPENSHELL_ENGINE names. The docker and podman steps above are reused as they are, without `podmanGate`, whose
|
|
375
|
+
// rootless job-user rule is the podman venue's and not this one's; where `local` or `podman` already put the image into
|
|
376
|
+
// that same store (`imageEngine` null) nothing is pulled twice. Then what the image declares, as doctor reads it.
|
|
377
|
+
if (openshellReady) {
|
|
378
|
+
if (openshell.imageEngine === "docker") await dockerImageStep({ jobImage, spawn, out, yes, prompt, summary });
|
|
379
|
+
else if (openshell.imageEngine === "podman") await podmanImageStep({ jobImage, spawn, out, yes, prompt, summary });
|
|
380
|
+
await openshellImageCapsStep({ engine: openshell.engine, venues, jobImage, spawn, out, summary });
|
|
381
|
+
}
|
|
382
|
+
|
|
332
383
|
// (d) init — always, and unconditionally safe to re-run: its never-clobber is contractual, so an
|
|
333
384
|
// existing file is only ever reported, never overwritten. No consent gate for the same reason —
|
|
334
385
|
// nothing the operator wrote can be lost here.
|
|
@@ -516,9 +567,10 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
516
567
|
// (e0) VALKEY_PASSWORD (issue #468), where docker's Valkey is the queue: generated into .env when the deployment has
|
|
517
568
|
// none and runs a Valkey of its own on this host (`valkeyPasswordDecision`), never over a value, the file narrowed to
|
|
518
569
|
// this account, the value never printed. The podman venue's Valkey gets its password in the stack step below.
|
|
519
|
-
const dockerValkeyPassword =
|
|
570
|
+
const dockerValkeyPassword = dockerValkeyUsed ? dockerValkeyPasswordStep({ fs, envPath, platform, out, summary, newPassword }) : null;
|
|
520
571
|
|
|
521
|
-
|
|
572
|
+
// Issue #707: and where an openshell venue on docker is the deployment's only venue, its Valkey too (`valkeyEngine`).
|
|
573
|
+
if (dockerValkeyUsed) {
|
|
522
574
|
// (c) Valkey. A bare TCP probe of the default bind, not a redis PING: dependency-free, and the
|
|
523
575
|
// honest claim is only "something is listening". If our own compose-named container is up, docker
|
|
524
576
|
// can say so; if a listener exists that we cannot name, up must NOT offer a second Valkey; the
|
|
@@ -588,7 +640,7 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
588
640
|
}
|
|
589
641
|
if (v.unlabelled && !servedByOurs) {
|
|
590
642
|
out("\n");
|
|
591
|
-
if (!/^y(es)?$/i.test(String((await prompt(adoptVolumeQuestion(deployment))) ?? "").trim())) return refuse(unadoptedVolumeRefusal());
|
|
643
|
+
if (!adoptVolume && !/^y(es)?$/i.test(String((await prompt(`${adoptVolumeQuestion(deployment)} (or pass --adopt-volume)`)) ?? "").trim())) return refuse(unadoptedVolumeRefusal());
|
|
592
644
|
// Whose queue it holds, read BEFORE any Valkey on it is published (round-cap re-review: a wrong `y` ran this
|
|
593
645
|
// deployment's Valkey on another's data for a second): a Valkey with no network loads it, and its marker is read
|
|
594
646
|
// through `docker exec`.
|
|
@@ -671,6 +723,9 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
671
723
|
// a folder /dispatch setup copied it into). A folder init made has none, and there the way is this command again.
|
|
672
724
|
const laterValkey = () => (composeHere(cwd, fs) ? `start it later with \`${shownCompose([...composeBase, "up", "-d"])}\`${composeNote}` : "start it later by running `pi-dispatch up` again and accepting");
|
|
673
725
|
let existing = null;
|
|
726
|
+
// Issue #761: the verdict on the Valkey container up leaves as it is, whose image the summary holds against the older
|
|
727
|
+
// ones below when up runs that container itself (`upValkeyContainerImage`, older-state.mjs, doctor's rule too).
|
|
728
|
+
let keptValkey = null;
|
|
674
729
|
if (dockerValkey.error) {
|
|
675
730
|
valkeyRefused = "url";
|
|
676
731
|
out(`✗ ${dockerValkey.error}\n`);
|
|
@@ -730,6 +785,7 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
730
785
|
summary.push(["valkey", `port ${vport} has a listener that answers WITHOUT a password, left alone: restart it with ${VALKEY_PASSWORD_KEY}`]);
|
|
731
786
|
} else {
|
|
732
787
|
summary.push(["valkey", ours ? `container ${ourName} already running` : `port ${vport} already has a listener, left alone`]);
|
|
788
|
+
keptValkey = who;
|
|
733
789
|
}
|
|
734
790
|
} else if (handedOver) {
|
|
735
791
|
// The wizard handed this folder's Valkey to compose (PR #475's review): up starts THAT one, compose's valkey on
|
|
@@ -763,6 +819,7 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
763
819
|
if (existing.ours) {
|
|
764
820
|
out(`⚠ pi-dispatch-valkey is this deployment's and is not listening on ${vport}: \`docker start pi-dispatch-valkey\` starts it as it is\n`);
|
|
765
821
|
summary.push(["valkey", "pi-dispatch-valkey exists (this deployment's) and is not running; `docker start pi-dispatch-valkey`"]);
|
|
822
|
+
keptValkey = existing;
|
|
766
823
|
} else {
|
|
767
824
|
valkeyRefused = "volume";
|
|
768
825
|
const why = existing.unknown ? `whether pi-dispatch-valkey is this deployment's could not be read (${existing.unknown})` : foreignContainerSentence("pi-dispatch-valkey", existing.owner);
|
|
@@ -791,6 +848,12 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
791
848
|
summary.push(["valkey", "started container pi-dispatch-valkey (durable: --appendonly yes, restart unless-stopped)"]);
|
|
792
849
|
}
|
|
793
850
|
}
|
|
851
|
+
// Issue #761: a container an older `up` started keeps the floating `valkey/valkey:8` until it is recreated, and up
|
|
852
|
+
// leaves a Valkey it finds on the image it has (doctor's `state.older` says the same, by the same rule: this
|
|
853
|
+
// deployment's container, in a folder not handed to compose). Read off the inspect the ownership rule already made, so
|
|
854
|
+
// this asks docker nothing more; an image of the operator's own choosing is no finding.
|
|
855
|
+
const olderValkey = keptValkey ? await olderValkeyContainerFinding({ valkeyContainer: () => keptValkey, handedOver }) : null;
|
|
856
|
+
if (olderValkey) summary.push(["valkey image", `${olderValkey.label}. ${olderValkey.fix}`]);
|
|
794
857
|
}
|
|
795
858
|
|
|
796
859
|
// (e2) the egress policy's proxy, and ONLY when the operator has already armed it. up never invents
|
|
@@ -833,7 +896,7 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
833
896
|
},
|
|
834
897
|
handled: false,
|
|
835
898
|
};
|
|
836
|
-
const confRefreshed = dockerUsed && egressArmedFn(venueEnv) && proxyName === DEFAULT_EGRESS_PROXY ? await proxyConfRefreshStep({ fs, cwd, out, prompt, summary, readPackagedConf, now, yes, coupled }) : false;
|
|
899
|
+
const confRefreshed = dockerUsed && egressArmedFn(venueEnv) && proxyName === DEFAULT_EGRESS_PROXY ? await proxyConfRefreshStep({ fs, cwd, out, prompt, summary, readPackagedConf, now, yes, refreshProxyRules, restartProxy, coupled }) : false;
|
|
837
900
|
if (dockerUsed && egressArmedFn(venueEnv) && proxyName !== DEFAULT_EGRESS_PROXY) {
|
|
838
901
|
// PI_EGRESS_PROXY names the operator's own proxy (issue #430). This step used to look for, and offer to start, the
|
|
839
902
|
// shipped name whatever that key said, so it could report a proxy present that no job attaches to, or start one
|
|
@@ -927,9 +990,9 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
927
990
|
if (yes && attached.length > 0) out("--yes does not cover restarting a proxy that jobs are using: answer below, or stop the worker and re-run\n");
|
|
928
991
|
if (
|
|
929
992
|
await consent(
|
|
930
|
-
`up would ${paused ? "unpause and then restart" : "restart"} it so squid reads the refreshed deploy/egress-proxy.conf${attached.length > 0 ? `. It is attached to ${attached.join(", ")}: those jobs lose their egress while it restarts` : ""}:`,
|
|
993
|
+
`up would ${paused ? "unpause and then restart" : "restart"} it (or pass --restart-proxy) so squid reads the refreshed deploy/egress-proxy.conf${attached.length > 0 ? `. It is attached to ${attached.join(", ")}: those jobs lose their egress while it restarts` : ""}:`,
|
|
931
994
|
shown,
|
|
932
|
-
{ yes: yes && attached.length === 0, out, prompt },
|
|
995
|
+
{ yes: restartProxy || yes && attached.length === 0, out, prompt },
|
|
933
996
|
)
|
|
934
997
|
) {
|
|
935
998
|
let failed = null;
|
|
@@ -971,11 +1034,11 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
971
1034
|
summary.push(["egress", `stale proxy left as it is: ${noFile} to recreate it from; its policy is not this deployment's`]);
|
|
972
1035
|
} else if (
|
|
973
1036
|
await consent(
|
|
974
|
-
`up would replace it with the shipped proxy (the same semantics as deploy/docker-compose.yml --profile egress)${attached.length > 0 ? `. It is attached to ${attached.join(", ")}: removing it cuts those jobs off from their egress mid-run, so stop the worker first (and let running jobs finish)` : ""}:`,
|
|
1037
|
+
`up would replace it (or pass --restart-proxy) with the shipped proxy (the same semantics as deploy/docker-compose.yml --profile egress)${attached.length > 0 ? `. It is attached to ${attached.join(", ")}: removing it cuts those jobs off from their egress mid-run, so stop the worker first (and let running jobs finish)` : ""}:`,
|
|
975
1038
|
[`docker ${EGRESS_RM_ARGS.join(" ")}`, ...(await egressNetworkLines(spawn, network)), `docker ${quoteArgs(EGRESS_RUN_ARGS)}`],
|
|
976
1039
|
// `--yes` does not cover cutting live jobs off (gate round 3): with a job network attached to a RUNNING
|
|
977
1040
|
// proxy the lines are printed and a person must answer, so an unattended `up --yes` never does it.
|
|
978
|
-
{ yes: yes && !(attached.length > 0 && state.status === "running"), out, prompt },
|
|
1041
|
+
{ yes: restartProxy || yes && !(attached.length > 0 && state.status === "running"), out, prompt },
|
|
979
1042
|
)
|
|
980
1043
|
) {
|
|
981
1044
|
await runStreamed(spawn, "docker", EGRESS_RM_ARGS, out);
|
|
@@ -1040,6 +1103,17 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
1040
1103
|
await podmanStackStep({ env: venueEnv, valkeyKeys: valkeyKeys.env, state: stackState, venues, spawn, out, yes, prompt, summary, fs, probeTcp, lookup, interfaces, runSync, cwd, home, user: userName(), euid, newPassword, platform, templatesDir, readTemplate, mkdir, realpath });
|
|
1041
1104
|
valkeyRefused = stackState.valkeyRefused;
|
|
1042
1105
|
}
|
|
1106
|
+
// (e4) an openshell venue on podman with no `local` or `podman` venue (issue #707): its Valkey alone, through the same
|
|
1107
|
+
// Quadlet installer, and no egress proxy or keeper (`egress: false`), since the gateway's policy is the venue's egress.
|
|
1108
|
+
if (openshellReady && openshell.valkeyEngine === "podman" && platform !== "linux") {
|
|
1109
|
+
// Quadlet is systemd's, so off Linux there is no unit to install it as: said, never a silent skip.
|
|
1110
|
+
out(`\n⚠ PI_OPENSHELL_ENGINE=podman and no local or podman venue: this deployment's Valkey would run on Podman as a Quadlet unit, which is Linux only. Run a Valkey yourself where VALKEY_URL points\n`);
|
|
1111
|
+
summary.push(["valkey", "not added: Valkey on Podman is a Quadlet unit, Linux only; run one yourself where VALKEY_URL points"]);
|
|
1112
|
+
} else if (openshellReady && openshell.valkeyEngine === "podman") {
|
|
1113
|
+
const stackState = { valkeyRefused: false };
|
|
1114
|
+
await podmanStackStep({ env: venueEnv, valkeyKeys: valkeyKeys.env, state: stackState, venues, spawn, out, yes, prompt, summary, fs, probeTcp, lookup, interfaces, runSync, cwd, home, user: userName(), euid, newPassword, platform, templatesDir, readTemplate, mkdir, realpath, egress: false });
|
|
1115
|
+
valkeyRefused = stackState.valkeyRefused;
|
|
1116
|
+
}
|
|
1043
1117
|
|
|
1044
1118
|
// (f) doctor — always, verbatim: up converges what it can, doctor is the judge of what remains
|
|
1045
1119
|
// (provider key, forge env, overlay …), and its verdict is up's exit code.
|
|
@@ -1071,7 +1145,9 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
1071
1145
|
for (const [key, value] of Object.entries(venue.fromFile)) {
|
|
1072
1146
|
if (typeof env[key] !== "string") layered[key] = value;
|
|
1073
1147
|
}
|
|
1074
|
-
|
|
1148
|
+
// Published like any doctor run (issue #712): `up` is how most hosts are set up, and its doctor is the first result
|
|
1149
|
+
// the console can show.
|
|
1150
|
+
const doctorCode = await runDoctorFn(layered, { out, publish: true });
|
|
1075
1151
|
|
|
1076
1152
|
// (g) the summary: what ran, what was skipped, what was already there — then the two commands
|
|
1077
1153
|
// that actually start work, so "up is green" flows straight into the first job.
|
|
@@ -1093,6 +1169,17 @@ Next:
|
|
|
1093
1169
|
if (venues.podmanUsed) {
|
|
1094
1170
|
out(" (podman venue: a worker started by hand reads only its shell, so export the same PI_BACKENDS there first, or run it as a service with `pi-dispatch service install`, which reads .env)\n");
|
|
1095
1171
|
}
|
|
1172
|
+
// Issue #708: the console as a service, offered only where it could start (an operator token exists): without one,
|
|
1173
|
+
// `service install --console` refuses, and a "Next:" line that leads to a refusal is not a next step.
|
|
1174
|
+
let consoleReady = false;
|
|
1175
|
+
try {
|
|
1176
|
+
consoleReady = consoleOperators(cwd) > 0 && !consoleUnitInstalled(cwd);
|
|
1177
|
+
} catch {}
|
|
1178
|
+
if (consoleReady) out(" pi-dispatch service install --console\n keep the operator console running as a service (open it with /dispatch console)\n");
|
|
1179
|
+
// Issue #707: the openshell venues' own next step, said only where one is blessed.
|
|
1180
|
+
if (openshell.used) {
|
|
1181
|
+
out(` pi-dispatch service install\n the worker as a ${platform === "darwin" ? "LaunchAgent (the gateway runs in this login session: `brew services start openshell`, the service of OpenShell's release formula, keeps it there)" : `user service that pulls in the gateway's own unit (${OPENSHELL_GATEWAY_UNIT})`}; then \`pi-dispatch doctor\` reads the gateway, its engine and the image back. A worker started by hand reads only its shell, so export the same PI_BACKENDS (and PI_OPENSHELL_ENGINE) there first\n`);
|
|
1182
|
+
}
|
|
1096
1183
|
// Issue #464: a Valkey this pass refused (another account's, or one no one could name) is a failed `up` whatever doctor
|
|
1097
1184
|
// says: a worker started now would take that queue's jobs, and an exit 0 read by a script or the wizard says ready.
|
|
1098
1185
|
if (valkeyRefused) {
|
|
@@ -1112,6 +1199,12 @@ Next:
|
|
|
1112
1199
|
);
|
|
1113
1200
|
return doctorCode !== 0 ? doctorCode : 1;
|
|
1114
1201
|
}
|
|
1202
|
+
// Issue #707: an openshell gateway this pass could not make usable is a failed `up` whatever doctor says, as a refused
|
|
1203
|
+
// Valkey is: said above, and named in the summary.
|
|
1204
|
+
if (openshellFailed) {
|
|
1205
|
+
out("\nup: the openshell venue is not usable yet (above); fix it, then re-run `pi-dispatch up`.\n");
|
|
1206
|
+
return doctorCode !== 0 ? doctorCode : 1;
|
|
1207
|
+
}
|
|
1115
1208
|
return doctorCode;
|
|
1116
1209
|
}
|
|
1117
1210
|
|
|
@@ -1147,6 +1240,243 @@ async function podmanGate({ readPodmanInfo, platform, euid, egid, out }) {
|
|
|
1147
1240
|
return { ok: false, why };
|
|
1148
1241
|
}
|
|
1149
1242
|
|
|
1243
|
+
/** The venues this pass sets up, in words, for the banner (issue #707): never "not docker means podman". */
|
|
1244
|
+
function venueWords(venues, openshell) {
|
|
1245
|
+
const parts = [];
|
|
1246
|
+
if (venues.localUsed) parts.push("the docker venue (local)");
|
|
1247
|
+
if (venues.podmanUsed) parts.push("the podman venue");
|
|
1248
|
+
const modes = [venues.openshellCopyUsed ? "openshell" : null, venues.openshellBindUsed ? "openshell-bind" : null].filter(Boolean).join(" and ");
|
|
1249
|
+
parts.push(`${modes} on ${openshell.engine}`);
|
|
1250
|
+
return parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}` : parts[0];
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** The tools whose actions this pass may ask about, for the banner (issue #707). */
|
|
1254
|
+
function toolWords(venues, openshell, platform) {
|
|
1255
|
+
const tools = new Set();
|
|
1256
|
+
if (venues.localUsed || openshell.engine === "docker") tools.add("docker");
|
|
1257
|
+
if (venues.podmanUsed || openshell.engine === "podman") tools.add("podman");
|
|
1258
|
+
tools.add("openshell");
|
|
1259
|
+
if (platform === "darwin") tools.add("brew");
|
|
1260
|
+
else tools.add("systemctl");
|
|
1261
|
+
const list = [...tools];
|
|
1262
|
+
return list.length > 1 ? `${list.slice(0, -1).join(", ")} and ${list.at(-1)}` : list[0];
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* The openshell venues' gate (issue #707): the engine, the CLI and the gateway, judged as the worker's boot judges them
|
|
1267
|
+
* (`openshellBootVersions`, the CLI and the gateway at `OPENSHELL_TESTED_VERSION`; `judgeGatewayStatus`, connected and on
|
|
1268
|
+
* this host). Read-only unless the operator accepts one of two offers, each shown and asked like every other step
|
|
1269
|
+
* (`--yes` accepts): START a gateway that is installed and does not answer (its user unit on Linux, Homebrew's service
|
|
1270
|
+
* on macOS), then wait a bounded time for it; and REGISTER a gateway that answers on loopback while the CLI has none,
|
|
1271
|
+
* which is exactly what the conformance lane runs. Never an install: no CLI, or another release, is the pinned
|
|
1272
|
+
* release's URL and a refusal, since an installer is the operator's to choose. Never an edit of the gateway's config:
|
|
1273
|
+
* bind mode's settings are printed. `{ ok }` or `{ ok: false, why }`, the reason said above it with its fix.
|
|
1274
|
+
*
|
|
1275
|
+
* Every spawn goes through `spawn`, the pass's one seam, so `openshellBootVersions` is handed an `exec` over it rather
|
|
1276
|
+
* than its own `execFile`.
|
|
1277
|
+
*/
|
|
1278
|
+
async function openshellGatewayStep({ openshell, venues, spawn, out, yes, prompt, platform, probeTcp, sleep, now = Date.now, waitMs = 30_000, readTimeoutMs = 15_000 }) {
|
|
1279
|
+
const refuse = (label, fix, why = label) => {
|
|
1280
|
+
out(`✗ ${label}\n → ${fix}\n`);
|
|
1281
|
+
return { ok: false, why };
|
|
1282
|
+
};
|
|
1283
|
+
if (openshell.engineError) return refuse(`openshell venue: ${openshell.engineError}`, "set PI_OPENSHELL_ENGINE in .env to docker or podman (unset is docker), the engine the gateway's compute driver runs on");
|
|
1284
|
+
if (platform === "win32") return refuse("openshell venue not usable here", OPENSHELL_NO_WINDOWS, "OpenShell ships no Windows gateway");
|
|
1285
|
+
// The worker's own boot refusal (`openshellFrom`), said here before anything is offered for a venue that cannot boot.
|
|
1286
|
+
if (venues.openshellBindUsed && openshell.engine === "podman") return refuse("openshell-bind with PI_OPENSHELL_ENGINE=podman: the worker refuses this at boot", "bind mode was measured on OpenShell's Docker driver only, and a rootless Podman maps the sandbox's uid away from the worker's: set PI_OPENSHELL_ENGINE=docker, or run the copy venue (openshell)", "openshell-bind runs on the docker engine only");
|
|
1287
|
+
const install = `install OpenShell ${OPENSHELL_TESTED_VERSION} (${OPENSHELL_RELEASE_URL}), the release this venue was measured on; up installs nothing, so the installer is yours to run`;
|
|
1288
|
+
// The engine first, where no venue's own gate below asks it: the gateway's sandboxes and (without another venue) the
|
|
1289
|
+
// queue run on it, so with it down every offer below would only collect consent for failures.
|
|
1290
|
+
const engine = openshell.engine;
|
|
1291
|
+
if (!(engine === "docker" && venues.localUsed) && !(engine === "podman" && venues.podmanUsed)) {
|
|
1292
|
+
// Bounded like every read here (review round 2 of #715): a wedged daemon answers nothing, and `up` says so.
|
|
1293
|
+
const probed = await runCmdQuery(spawn, engine, ["version"], { timeoutMs: readTimeoutMs });
|
|
1294
|
+
if (probed.timedOut) return refuse(`${engine} did not answer \`${engine} version\` within ${Math.round(readTimeoutMs / 1000)} s (PI_OPENSHELL_ENGINE=${engine}, the gateway's engine)`, `check that ${engine} answers as this account, then re-run \`pi-dispatch up\``);
|
|
1295
|
+
const code = probed.code;
|
|
1296
|
+
if (code !== 0) return refuse(code === null ? `${engine} not found (PI_OPENSHELL_ENGINE=${engine}, the gateway's engine)` : `${engine} not responding (PI_OPENSHELL_ENGINE=${engine}, the gateway's engine)`, code === null ? `install ${engine}: \`${engine}\` was not found on PATH` : `start ${engine} (\`${engine} version\` did not answer)`);
|
|
1297
|
+
out(`✓ ${engine} answering (the openshell gateway's engine)\n`);
|
|
1298
|
+
}
|
|
1299
|
+
const gw = openshell.gateway ? ["--gateway", openshell.gateway] : [];
|
|
1300
|
+
// Every read of the CLI is bounded (review round 1 of #715): a gateway that accepts the connection and never finishes its
|
|
1301
|
+
// TLS handshake would otherwise hold `up` at a status read forever, before it printed what it was waiting for.
|
|
1302
|
+
const ask = (args) => runCmdQuery(spawn, "openshell", [...gw, ...args], { timeoutMs: readTimeoutMs });
|
|
1303
|
+
const cli = await ask(["--version"]);
|
|
1304
|
+
const cliVersion = cli.code === 0 ? (/openshell (\S+)/.exec(cli.stdout)?.[1] ?? null) : null;
|
|
1305
|
+
if (cliVersion === null) return refuse("OpenShell CLI not found (`openshell --version` did not answer)", install, "the openshell CLI is not installed");
|
|
1306
|
+
if (cliVersion !== OPENSHELL_TESTED_VERSION) return refuse(`OpenShell CLI ${cliVersion} is not the tested release (${OPENSHELL_TESTED_VERSION})`, `${install}: the venue drives interfaces OpenShell marks experimental, so another release is untested`, `the openshell CLI is ${cliVersion}, not ${OPENSHELL_TESTED_VERSION}`);
|
|
1307
|
+
out(`✓ OpenShell CLI ${cliVersion}, the tested release\n`);
|
|
1308
|
+
// `status -o json` exits 0 with the gateway down (measured), so its own `status` decides; `server` is null where the
|
|
1309
|
+
// CLI has no gateway registered (or none active) to ask.
|
|
1310
|
+
const readStatus = async (timeoutMs = readTimeoutMs) => {
|
|
1311
|
+
const r = await runCmdQuery(spawn, "openshell", [...gw, "status", "-o", "json"], { timeoutMs });
|
|
1312
|
+
let doc = null;
|
|
1313
|
+
try {
|
|
1314
|
+
doc = JSON.parse(r.stdout);
|
|
1315
|
+
} catch {}
|
|
1316
|
+
return judgeGatewayStatus(doc);
|
|
1317
|
+
};
|
|
1318
|
+
const portAnswers = () => probeTcp(OPENSHELL_LOCAL_GATEWAY_HOST, OPENSHELL_LOCAL_GATEWAY_PORT);
|
|
1319
|
+
let status = await readStatus();
|
|
1320
|
+
if (!status.connected && !(await portAnswers())) {
|
|
1321
|
+
const start = platform === "darwin" ? ["brew", ["services", "start", "openshell"]] : ["systemctl", ["--user", "start", OPENSHELL_GATEWAY_UNIT.replace(/\.service$/, "")]];
|
|
1322
|
+
const shown = `${start[0]} ${start[1].join(" ")}`;
|
|
1323
|
+
// Offered only where there is something to start (review round 1 of #715), asked read-only first: on Linux the user
|
|
1324
|
+
// unit OpenShell's deb and rpm ship, on macOS a Homebrew service, which only NVIDIA's release formula defines
|
|
1325
|
+
// (homebrew-core's `openshell` has no `service do` block). Without one the offer would be a command that fails.
|
|
1326
|
+
const probe = platform === "darwin" ? ["brew", ["services", "info", "openshell"]] : ["systemctl", ["--user", "cat", OPENSHELL_GATEWAY_UNIT]];
|
|
1327
|
+
const there = await runCmdQuery(spawn, probe[0], probe[1], { timeoutMs: readTimeoutMs });
|
|
1328
|
+
// No user manager to ask (a `su`/`sudo -u` shell, no XDG_RUNTIME_DIR) is not "no unit" (review round 2 of #715).
|
|
1329
|
+
if (platform !== "darwin" && there.code !== 0 && /Failed to connect to (the )?bus|\$DBUS_SESSION_BUS_ADDRESS|XDG_RUNTIME_DIR/i.test(`${there.stderr} ${there.stdout}`)) {
|
|
1330
|
+
return refuse("the OpenShell gateway is not answering, and this shell reaches no user manager to ask for its unit (`systemctl --user` could not connect to the bus)", "run `pi-dispatch up` from a login of the account that runs the gateway (or with its XDG_RUNTIME_DIR set: `sudo machinectl shell <account>@` gives one), then re-run it", "no user manager reachable from this shell");
|
|
1331
|
+
}
|
|
1332
|
+
if (there.code !== 0) {
|
|
1333
|
+
return refuse(
|
|
1334
|
+
platform === "darwin" ? "the OpenShell gateway is not answering, and Homebrew has no openshell service to start (`brew services info openshell` failed)" : `the OpenShell gateway is not answering, and this user manager has no ${OPENSHELL_GATEWAY_UNIT} to start (\`systemctl --user cat ${OPENSHELL_GATEWAY_UNIT}\` failed)`,
|
|
1335
|
+
platform === "darwin" ? `install the release formula, which defines the service (\`${OPENSHELL_BREW_INSTALL}\`; homebrew-core's openshell formula has none), or start the gateway yourself, then re-run \`pi-dispatch up\`` : `install OpenShell's deb or rpm (${OPENSHELL_RELEASE_URL}), which ships that user unit, or start the gateway yourself, then re-run \`pi-dispatch up\``,
|
|
1336
|
+
"no OpenShell gateway service to start",
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
const accepted = await consent(`The OpenShell gateway is not answering (nothing listens on ${OPENSHELL_LOCAL_GATEWAY_HOST}:${OPENSHELL_LOCAL_GATEWAY_PORT}). up would start it${platform === "darwin" ? " (the Homebrew service OpenShell's release formula defines)" : " (the user unit OpenShell's package ships)"}:`, [shown], { yes, out, prompt });
|
|
1340
|
+
if (!accepted) return refuse("the OpenShell gateway is not running (declined the start)", `start it with \`${shown}\`, then re-run \`pi-dispatch up\``);
|
|
1341
|
+
const code = await runStreamed(spawn, start[0], start[1], out);
|
|
1342
|
+
if (code !== 0) return refuse(`\`${shown}\` failed (${code === null ? `${start[0]} not found` : `exit ${code}`})`, platform === "darwin" ? "`brew services info openshell` says why" : `\`systemctl --user status ${OPENSHELL_GATEWAY_UNIT}\` says why`, "the OpenShell gateway could not be started");
|
|
1343
|
+
// Bounded: a registered gateway is waited for until it reads connected; an unregistered one until its port answers,
|
|
1344
|
+
// since the CLI cannot ask it anything before it is registered. By a wall-clock deadline (review round 2 of #715),
|
|
1345
|
+
// each read capped by the time left, as well as by a count of one-second polls.
|
|
1346
|
+
const steps = Math.max(1, Math.ceil(waitMs / 1000));
|
|
1347
|
+
const deadline = now() + waitMs;
|
|
1348
|
+
let answered = false;
|
|
1349
|
+
for (let i = 0; i < steps && !answered && (i === 0 || now() < deadline); i++) {
|
|
1350
|
+
status = await readStatus(Math.max(1, Math.min(readTimeoutMs, deadline - now())));
|
|
1351
|
+
answered = status.connected || (status.server === null && (await portAnswers()));
|
|
1352
|
+
if (!answered) await sleep(1000);
|
|
1353
|
+
}
|
|
1354
|
+
if (!answered) return refuse(`the OpenShell gateway was started and did not answer within ${Math.round(waitMs / 1000)} s`, platform === "darwin" ? "`brew services info openshell` and the gateway's log say why" : `\`journalctl --user -u ${OPENSHELL_GATEWAY_UNIT}\` says why`, "the OpenShell gateway did not answer after its start");
|
|
1355
|
+
out("✓ the OpenShell gateway answers\n");
|
|
1356
|
+
}
|
|
1357
|
+
if (!status.connected) {
|
|
1358
|
+
// It answers, and the CLI does not reach it. Registered elsewhere (a `server` the CLI names): never re-registered
|
|
1359
|
+
// over, since that registration is the operator's. None registered: the one offer is the conformance lane's own.
|
|
1360
|
+
if (status.server !== null) return refuse(`the openshell CLI's gateway (${status.server}) does not answer as connected, while something listens on ${OPENSHELL_LOCAL_GATEWAY_HOST}:${OPENSHELL_LOCAL_GATEWAY_PORT}`, `\`openshell ${[...gw, "status"].join(" ")}\` says why; \`openshell gateway list\` shows the registrations (up never re-registers over one)`, "the openshell CLI's gateway does not answer");
|
|
1361
|
+
const name = openshell.gateway ?? OPENSHELL_DEFAULT_GATEWAY_NAME;
|
|
1362
|
+
const add = ["gateway", "add", OPENSHELL_LOCAL_GATEWAY_URL, "--local", "--name", name];
|
|
1363
|
+
const shown = `openshell ${add.join(" ")}`;
|
|
1364
|
+
// Offered ONLY where the CLI provably has no gateway registered at all (review round 1 of #715), read-only and
|
|
1365
|
+
// without `--gateway`: what 0.1.4's `gateway add` does beside an existing registration, or over one of the same
|
|
1366
|
+
// name, is not measured, so any registration, or a list this cannot read, gets the command as a fix instead.
|
|
1367
|
+
const registered = registrationCount(await runCmdQuery(spawn, "openshell", ["gateway", "list", "-o", "json"], { timeoutMs: readTimeoutMs }));
|
|
1368
|
+
if (registered !== 0) {
|
|
1369
|
+
return refuse(
|
|
1370
|
+
registered === null ? `the OpenShell gateway answers on ${OPENSHELL_LOCAL_GATEWAY_HOST}:${OPENSHELL_LOCAL_GATEWAY_PORT}, and whether the openshell CLI has a gateway registered could not be read (\`openshell gateway list -o json\`)` : `the OpenShell gateway answers on ${OPENSHELL_LOCAL_GATEWAY_HOST}:${OPENSHELL_LOCAL_GATEWAY_PORT}, and the openshell CLI has ${registered} gateway${registered === 1 ? "" : "s"} registered, none of which it reads connected${openshell.gateway ? ` as ${openshell.gateway} (PI_OPENSHELL_GATEWAY)` : ""}`,
|
|
1371
|
+
`\`openshell gateway list\` shows the registrations; up never registers beside one. Register this gateway yourself if it is the one (\`${shown}\`)${openshell.gateway ? "" : `, and set PI_OPENSHELL_GATEWAY=${name} in .env unless it becomes the CLI's active gateway`}, then re-run \`pi-dispatch up\``,
|
|
1372
|
+
"the gateway is not registered as the one the worker addresses",
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
const accepted = await consent(`The OpenShell gateway answers on ${OPENSHELL_LOCAL_GATEWAY_HOST}:${OPENSHELL_LOCAL_GATEWAY_PORT}, and the openshell CLI has no gateway registered. up would register it, on loopback, as ${name}${openshell.gateway ? ", the name PI_OPENSHELL_GATEWAY gives" : ""}:`, [shown], { yes, out, prompt });
|
|
1376
|
+
if (!accepted) return refuse("the OpenShell gateway is not registered (declined the registration)", `register it with \`${shown}\`, then re-run \`pi-dispatch up\``);
|
|
1377
|
+
const code = await runStreamed(spawn, "openshell", add, out);
|
|
1378
|
+
if (code !== 0) return refuse(`\`${shown}\` failed (exit ${code})`, "its own output above says why", "the gateway registration failed");
|
|
1379
|
+
status = await readStatus();
|
|
1380
|
+
if (!status.connected) return refuse(`the gateway was registered as ${name}, and \`openshell status\` still does not read it connected`, `\`openshell ${[...gw, "status"].join(" ")}\` says why${openshell.gateway ? "" : "; if another gateway is the CLI's active one, set PI_OPENSHELL_GATEWAY=" + name + " in .env"}`, "the registered gateway does not answer");
|
|
1381
|
+
}
|
|
1382
|
+
if (!status.local) return refuse(`the openshell CLI's gateway (${status.server ?? "of unknown address"}) is not on this host`, "the venue holds the job's credentials in its gateway, so it runs only on one this deployment runs itself (CONST-VENUE-EGRESS-LAYER): register the local gateway on loopback", "the gateway is not on this host");
|
|
1383
|
+
// The worker's own boot judgement, over this pass's spawn seam, so `up` passes no host the worker then refuses.
|
|
1384
|
+
const { openshellBootVersions } = await import("./backend-openshell.mjs");
|
|
1385
|
+
const exec = async (bin, args) => {
|
|
1386
|
+
const r = await runCmdQuery(spawn, bin, args, { timeoutMs: readTimeoutMs });
|
|
1387
|
+
if (r.code !== 0) throw Object.assign(new Error(`${bin} exited ${r.code}`), r);
|
|
1388
|
+
return r;
|
|
1389
|
+
};
|
|
1390
|
+
const boot = await openshellBootVersions({ gateway: openshell.gateway, exec });
|
|
1391
|
+
if (boot.refusal) return refuse("the openshell venue would be refused at the worker's boot", boot.refusal, boot.refusal);
|
|
1392
|
+
if (boot.gatewayDown) return refuse("the OpenShell gateway stopped answering", "`openshell status` says why; re-run `pi-dispatch up`", "the gateway stopped answering");
|
|
1393
|
+
out(`✓ OpenShell gateway ${status.version}, the tested release, on this host (${status.server})\n`);
|
|
1394
|
+
if (venues.openshellBindUsed) {
|
|
1395
|
+
// Printed, never written: the gateway's config is the operator's, and doctor below asks the gateway whether it admits them.
|
|
1396
|
+
out(`⚠ openshell-bind: the gateway must admit bind mounts, and up never edits its configuration. It needs, in ${GATEWAY_TOML_WHERE}, then a restart of the gateway:\n${gatewayTomlFor(engine).replace(/^/gm, " ").replace(/ +$/gm, "")}doctor below asks the gateway whether it does\n`);
|
|
1397
|
+
}
|
|
1398
|
+
return { ok: true };
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* How many gateways `openshell gateway list -o json` names (an array, or an object holding one under `gateways`), or null
|
|
1403
|
+
* when it did not answer or answered in a shape this does not read, which the registration offer treats as "not none".
|
|
1404
|
+
*/
|
|
1405
|
+
function registrationCount(read) {
|
|
1406
|
+
if (read.code !== 0) return null;
|
|
1407
|
+
let doc;
|
|
1408
|
+
try {
|
|
1409
|
+
doc = JSON.parse(read.stdout);
|
|
1410
|
+
} catch {
|
|
1411
|
+
return null;
|
|
1412
|
+
}
|
|
1413
|
+
const list = Array.isArray(doc) ? doc : Array.isArray(doc?.gateways) ? doc.gateways : null;
|
|
1414
|
+
return list === null ? null : list.length;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* What the job image declares, read from the gateway's engine as doctor reads it (issue #707): `sandboxLayout` is copy
|
|
1419
|
+
* mode's requirement (its runner finds the job under /sandbox), `transcriptStderr` its live transcript. Bind mode reads
|
|
1420
|
+
* the `mounts` layout and tails the host's session file, so for it only the image's presence is said.
|
|
1421
|
+
*/
|
|
1422
|
+
async function openshellImageCapsStep({ engine, venues, jobImage, spawn, out, summary }) {
|
|
1423
|
+
if (jobImage.skip) return;
|
|
1424
|
+
const image = jobImage.image;
|
|
1425
|
+
const read = await runCmdQuery(spawn, engine, ["image", "inspect", "--format", '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}', image]);
|
|
1426
|
+
if (read.code !== 0) {
|
|
1427
|
+
out(`✗ the job image (${image}) is not in ${engine}'s store, the store the OpenShell gateway runs sandboxes from\n`);
|
|
1428
|
+
summary.push(["openshell image", `${image} NOT in ${engine}'s store: pull or build it there`]);
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
if (!venues.openshellCopyUsed) {
|
|
1432
|
+
out(`✓ the job image (${image}) is in ${engine}'s store, the gateway's\n`);
|
|
1433
|
+
summary.push(["openshell image", `${image} in ${engine}'s store`]);
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
const caps = read.stdout.trim().split(",").map((c) => c.trim());
|
|
1437
|
+
if (!caps.includes("sandboxLayout")) {
|
|
1438
|
+
out(`✗ the job image (${image}) does not declare sandboxLayout, which the openshell venue needs\n → use a job image built after issue #683 (ghcr.io/edgehero/pi-job:latest, or rebuild yours FROM it): its runner finds the job under /sandbox and carries pi-landlock\n`);
|
|
1439
|
+
summary.push(["openshell image", `${image} lacks sandboxLayout: every openshell job is refused before spend`]);
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
if (!caps.includes("transcriptStderr")) {
|
|
1443
|
+
out(`⚠ the job image (${image}) declares sandboxLayout and not transcriptStderr: openshell jobs run, with no live transcript\n`);
|
|
1444
|
+
summary.push(["openshell image", `${image} declares sandboxLayout (no transcriptStderr: no live transcript)`]);
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
out(`✓ the job image (${image}) declares sandboxLayout and transcriptStderr (in ${engine}'s store, the gateway's)\n`);
|
|
1448
|
+
summary.push(["openshell image", `${image} declares sandboxLayout and transcriptStderr`]);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/** The default job image into docker's store (step b), shared by the `local` venue and an openshell venue on docker (issue #707). */
|
|
1452
|
+
async function dockerImageStep({ jobImage, spawn, out, yes, prompt, summary }) {
|
|
1453
|
+
if (jobImage.skip) summary.push(["job image", "not checked: which image the worker runs is unknown (see above)"]);
|
|
1454
|
+
else if (!jobImage.isDefault) await overrideImageStep({ bin: "docker", jobImage, spawn, out, yes, prompt, summary });
|
|
1455
|
+
else if (await runCmd(spawn, "docker", ["image", "inspect", LOCAL_IMAGE]) === 0) {
|
|
1456
|
+
out(`✓ Job image present (${LOCAL_IMAGE})\n`);
|
|
1457
|
+
summary.push(["job image", `already present (${LOCAL_IMAGE})`]);
|
|
1458
|
+
} else {
|
|
1459
|
+
const accepted = await consent(
|
|
1460
|
+
`The default job image (${LOCAL_IMAGE}) is not on this host. up would run:`,
|
|
1461
|
+
[`docker ${PULL_ARGS.join(" ")}`, `docker ${TAG_ARGS.join(" ")}`],
|
|
1462
|
+
{ yes, out, prompt },
|
|
1463
|
+
);
|
|
1464
|
+
if (!accepted) {
|
|
1465
|
+
out("skipped — pull it later with the two commands above (or build image/Dockerfile yourself)\n");
|
|
1466
|
+
summary.push(["job image", "skipped (declined) — jobs run with --pull=never, so nothing fetches it later"]);
|
|
1467
|
+
} else if (await runStreamed(spawn, "docker", PULL_ARGS, out) !== 0) {
|
|
1468
|
+
out("✗ docker pull failed — continuing; doctor below will re-check the image\n");
|
|
1469
|
+
summary.push(["job image", "pull FAILED — re-run `pi-dispatch up`, or pull by hand"]);
|
|
1470
|
+
} else if (await runStreamed(spawn, "docker", TAG_ARGS, out) !== 0) {
|
|
1471
|
+
out("✗ docker tag failed — continuing; doctor below will re-check the image\n");
|
|
1472
|
+
summary.push(["job image", `pulled, but tagging as ${LOCAL_IMAGE} FAILED — re-run the tag command by hand`]);
|
|
1473
|
+
} else {
|
|
1474
|
+
out(`✓ pulled and tagged ${LOCAL_IMAGE}\n`);
|
|
1475
|
+
summary.push(["job image", `pulled ${UPSTREAM_IMAGE} and tagged it ${LOCAL_IMAGE}`]);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1150
1480
|
/**
|
|
1151
1481
|
* The job image the worker will run as its deployment default (issue #523), as `up` must judge it:
|
|
1152
1482
|
* `{ image, isDefault, from, skip }`. The worker's own rule (`jobImageFrom`: `PI_JOB_IMAGE || "pi-job:latest"`, then
|
|
@@ -1291,8 +1621,9 @@ async function podmanImageStep({ jobImage, spawn, out, yes, prompt, summary }) {
|
|
|
1291
1621
|
* alone, a proxy that exists is left alone), then plans the Quadlet files with `planStack`, shows `plan.actions`, and on
|
|
1292
1622
|
* consent hands those same objects to `applyStack`: what runs is literally what was shown.
|
|
1293
1623
|
*/
|
|
1294
|
-
async function podmanStackStep({ env, valkeyKeys = {}, state = {}, venues, spawn, out, yes, prompt, summary, fs, probeTcp, lookup, interfaces, runSync, cwd, home, user, euid, templatesDir, readTemplate, mkdir, realpath, newPassword = newValkeyPassword, platform = "linux" }) {
|
|
1295
|
-
|
|
1624
|
+
async function podmanStackStep({ env, valkeyKeys = {}, state = {}, venues, spawn, out, yes, prompt, summary, fs, probeTcp, lookup, interfaces, runSync, cwd, home, user, euid, templatesDir, readTemplate, mkdir, realpath, newPassword = newValkeyPassword, platform = "linux", egress = true }) {
|
|
1625
|
+
// `egress: false` (issue #707): the openshell venue's Valkey alone, with no proxy and no keeper, whatever PI_EGRESS says.
|
|
1626
|
+
const armed = egress ? egressArmedFn(env) : false;
|
|
1296
1627
|
let includeValkey = false;
|
|
1297
1628
|
let valkeyPort = DEFAULT_VALKEY_PORT;
|
|
1298
1629
|
if (!venues.localUsed) {
|
|
@@ -1554,7 +1885,7 @@ export function valkeyContainerPublishes(line, port) {
|
|
|
1554
1885
|
* bytes are kept beside it either way (`replaceProxyConfCopy`), since an edit lost to a wrong "y" is still the
|
|
1555
1886
|
* operator's.
|
|
1556
1887
|
*/
|
|
1557
|
-
async function proxyConfRefreshStep({ fs, cwd, out, prompt, summary, readPackagedConf, now, yes, coupled = null }) {
|
|
1888
|
+
async function proxyConfRefreshStep({ fs, cwd, out, prompt, summary, readPackagedConf, now, yes, refreshProxyRules = false, restartProxy = false, coupled = null }) {
|
|
1558
1889
|
const rel = "deploy/egress-proxy.conf";
|
|
1559
1890
|
const path = join(cwd, rel);
|
|
1560
1891
|
// A directory there is the proxy step's to say (it starts nothing on one), so it is not read as a copy here.
|
|
@@ -1574,13 +1905,13 @@ async function proxyConfRefreshStep({ fs, cwd, out, prompt, summary, readPackage
|
|
|
1574
1905
|
if (coupled && rulesIncludeEndpoints(judged.packaged) && !rulesFileIncludes(path, fs)) {
|
|
1575
1906
|
const state = await coupled.inspect();
|
|
1576
1907
|
if (state && !state.mounts.some((m) => m.type === "bind" && m.destination === MODEL_ENDPOINTS_TARGET)) {
|
|
1577
|
-
return coupledRefreshStep({ fs, path, rel, judged, state, coupled, out, prompt, summary, now });
|
|
1908
|
+
return coupledRefreshStep({ fs, path, rel, judged, state, coupled, out, prompt, summary, now, refreshProxyRules, restartProxy });
|
|
1578
1909
|
}
|
|
1579
1910
|
}
|
|
1580
1911
|
const accepted = await consent(
|
|
1581
|
-
`up would replace it with ${packageCopyName()}, keeping yours beside it:`,
|
|
1912
|
+
`up would replace it with ${packageCopyName()}, keeping yours beside it (or pass --refresh-proxy-rules):`,
|
|
1582
1913
|
[`cp ${rel} ${rel}.bak-<timestamp>`, `write ${PACKAGED_EGRESS_PROXY_CONF}'s content beside ${rel}, then rename it over ${rel}`],
|
|
1583
|
-
{ yes:
|
|
1914
|
+
{ yes: refreshProxyRules, out, prompt },
|
|
1584
1915
|
);
|
|
1585
1916
|
if (!accepted) {
|
|
1586
1917
|
out(`skipped: ${rel} is left as it is; \`pi-dispatch up\` offers this again\n`);
|
|
@@ -1609,7 +1940,7 @@ async function proxyConfRefreshStep({ fs, cwd, out, prompt, summary, readPackage
|
|
|
1609
1940
|
* rules are in place and the proxy step below judges the proxy as it always does (start a stopped one, unpause a
|
|
1610
1941
|
* paused one, offer a stale one's replace, name declared endpoints the old rules cannot reach).
|
|
1611
1942
|
*/
|
|
1612
|
-
async function coupledRefreshStep({ fs, path, rel, judged, state, coupled, out, prompt, summary, now }) {
|
|
1943
|
+
async function coupledRefreshStep({ fs, path, rel, judged, state, coupled, out, prompt, summary, now, refreshProxyRules = false, restartProxy = false }) {
|
|
1613
1944
|
const why = "the new rules include model-endpoints.conf, which this proxy does not mount, so they go in only together with a proxy that mounts it";
|
|
1614
1945
|
const blocked = coupled.blocked(state);
|
|
1615
1946
|
if (blocked) {
|
|
@@ -1621,9 +1952,9 @@ async function coupledRefreshStep({ fs, path, rel, judged, state, coupled, out,
|
|
|
1621
1952
|
const network = { missing: false };
|
|
1622
1953
|
const lines = [`cp ${rel} ${rel}.bak-<timestamp>`, `write ${PACKAGED_EGRESS_PROXY_CONF}'s content beside ${rel}, then rename it over ${rel}`, ...(await coupled.lines(network))];
|
|
1623
1954
|
const accepted = await consent(
|
|
1624
|
-
`up would replace ${rel} with ${packageCopyName()}, keeping yours beside it, and replace the proxy (${state.status}) with the shipped one in the same step: ${why}${attached.length > 0 ? `. It is attached to ${attached.join(", ")}: removing it cuts those jobs off from their egress mid-run, so stop the worker first (and let running jobs finish)` : ""}:`,
|
|
1955
|
+
`up would replace ${rel} with ${packageCopyName()} (--refresh-proxy-rules${attached.length > 0 ? " and --restart-proxy" : ""}), keeping yours beside it, and replace the proxy (${state.status}) with the shipped one in the same step: ${why}${attached.length > 0 ? `. It is attached to ${attached.join(", ")}: removing it cuts those jobs off from their egress mid-run, so stop the worker first (and let running jobs finish)` : ""}:`,
|
|
1625
1956
|
lines,
|
|
1626
|
-
{ yes:
|
|
1957
|
+
{ yes: refreshProxyRules && (attached.length === 0 || restartProxy), out, prompt },
|
|
1627
1958
|
);
|
|
1628
1959
|
if (!accepted) {
|
|
1629
1960
|
out(`skipped: ${rel} and the proxy are left as they are; \`pi-dispatch up\` offers this again\n`);
|
|
@@ -1855,7 +2186,7 @@ function runStreamed(spawn, cmd, args, out, { env } = {}) {
|
|
|
1855
2186
|
* Like runCmdCapture with the streams SEPARATE, for a podman query whose answer is stdout alone (round 2, E2): podman
|
|
1856
2187
|
* prints warnings on stderr on ordinary accounts, and a merged capture made `true` and our own label unequal.
|
|
1857
2188
|
*/
|
|
1858
|
-
function runCmdQuery(spawn, cmd, args) {
|
|
2189
|
+
function runCmdQuery(spawn, cmd, args, { timeoutMs = null } = {}) {
|
|
1859
2190
|
return new Promise((resolve) => {
|
|
1860
2191
|
let child;
|
|
1861
2192
|
try {
|
|
@@ -1866,10 +2197,26 @@ function runCmdQuery(spawn, cmd, args) {
|
|
|
1866
2197
|
}
|
|
1867
2198
|
let stdout = "";
|
|
1868
2199
|
let stderr = "";
|
|
2200
|
+
let settled = false;
|
|
2201
|
+
// `timeoutMs` (issue #707): a read that does not finish in time is killed and answers as one that could not run.
|
|
2202
|
+
const timer = timeoutMs === null ? null : setTimeout(() => {
|
|
2203
|
+
if (settled) return;
|
|
2204
|
+
settled = true;
|
|
2205
|
+
try {
|
|
2206
|
+
child.kill("SIGKILL");
|
|
2207
|
+
} catch {}
|
|
2208
|
+
resolve({ code: null, stdout, stderr, timedOut: true });
|
|
2209
|
+
}, timeoutMs);
|
|
2210
|
+
const done = (result) => {
|
|
2211
|
+
if (settled) return;
|
|
2212
|
+
settled = true;
|
|
2213
|
+
if (timer) clearTimeout(timer);
|
|
2214
|
+
resolve(result);
|
|
2215
|
+
};
|
|
1869
2216
|
child.stdout?.on("data", (d) => (stdout += d));
|
|
1870
2217
|
child.stderr?.on("data", (d) => (stderr += d));
|
|
1871
|
-
child.on("error", () =>
|
|
1872
|
-
child.on("close", (code) =>
|
|
2218
|
+
child.on("error", () => done({ code: null, stdout, stderr }));
|
|
2219
|
+
child.on("close", (code) => done({ code, stdout, stderr }));
|
|
1873
2220
|
});
|
|
1874
2221
|
}
|
|
1875
2222
|
|