@ultimat3/cli 23.0.0 → 25.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/CLAUDE.md +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// One compose role rolled start-first: the new containers come up beside the old ones, are waited
|
|
2
|
+
// on until healthy, and only then are the old ones stopped and removed. Converges on the declared
|
|
3
|
+
// replica count, so a rerun after a half-finished deploy finishes it rather than adding replicas.
|
|
4
|
+
|
|
5
|
+
import type { ExecOptions, Runner } from './exec';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bounds `--wait`: a new container that never reports healthy fails the step rather than holding
|
|
9
|
+
* the deploy open. Five minutes is past any `start_period` the scaffold declares (30s) plus a cold
|
|
10
|
+
* boot, and `--wait` already returns at once when a container turns `unhealthy` or exits.
|
|
11
|
+
*/
|
|
12
|
+
export const COMPOSE_WAIT_TIMEOUT_SECONDS = 300;
|
|
13
|
+
|
|
14
|
+
/** The label compose stamps on each container: the hash of the service definition it was made from. */
|
|
15
|
+
const CONFIG_HASH = 'com.docker.compose.config-hash';
|
|
16
|
+
const INSPECT_FORMAT = `{{.Id}} {{.Created}} {{index .Config.Labels "${CONFIG_HASH}"}}`;
|
|
17
|
+
|
|
18
|
+
/** The `up` that starts the new replicas beside the running ones; `--no-recreate` keeps the old. */
|
|
19
|
+
export function startFirstUp(
|
|
20
|
+
base: readonly string[],
|
|
21
|
+
role: string,
|
|
22
|
+
scale: number | undefined,
|
|
23
|
+
): readonly string[] {
|
|
24
|
+
const wait = ['--wait', '--wait-timeout', String(COMPOSE_WAIT_TIMEOUT_SECONDS)];
|
|
25
|
+
return scale === undefined
|
|
26
|
+
? [...base, 'up', '-d', '--no-deps', ...wait, role]
|
|
27
|
+
: [
|
|
28
|
+
...base,
|
|
29
|
+
'up',
|
|
30
|
+
'-d',
|
|
31
|
+
'--no-deps',
|
|
32
|
+
'--no-recreate',
|
|
33
|
+
...wait,
|
|
34
|
+
'--scale',
|
|
35
|
+
`${role}=${scale}`,
|
|
36
|
+
role,
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Failed {
|
|
41
|
+
readonly code: number;
|
|
42
|
+
readonly command: readonly string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type StartFirstOutcome =
|
|
46
|
+
| { readonly ok: true }
|
|
47
|
+
| ({
|
|
48
|
+
readonly ok: false;
|
|
49
|
+
/**
|
|
50
|
+
* The cleanup of an unhealthy new replica that ALSO failed — its argv names the containers
|
|
51
|
+
* still running, which the operator now owns. Absent when the cleanup ran clean.
|
|
52
|
+
*/
|
|
53
|
+
readonly cleanup?: Failed | undefined;
|
|
54
|
+
} & Failed);
|
|
55
|
+
|
|
56
|
+
const linesOf = (stdout: string): readonly string[] =>
|
|
57
|
+
stdout
|
|
58
|
+
.split('\n')
|
|
59
|
+
.map((line) => line.trim())
|
|
60
|
+
.filter((line) => line.length > 0);
|
|
61
|
+
|
|
62
|
+
/** `docker compose config --hash <role>` prints `<role> <hash>`. */
|
|
63
|
+
const hashOf = (stdout: string): string | undefined => linesOf(stdout)[0]?.split(/\s+/)[1];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The running containers split by the definition they were made from: `current` already runs the
|
|
67
|
+
* one being deployed (newest first), `stale` anything else. A container `inspect` did not describe
|
|
68
|
+
* is stale — stopping one too many is a restart, keeping one too many is the old build serving.
|
|
69
|
+
*/
|
|
70
|
+
function partition(
|
|
71
|
+
running: readonly string[],
|
|
72
|
+
inspected: string,
|
|
73
|
+
want: string,
|
|
74
|
+
): { readonly current: readonly string[]; readonly stale: readonly string[] } {
|
|
75
|
+
const described = new Map(
|
|
76
|
+
linesOf(inspected).map((line) => {
|
|
77
|
+
const [id = '', created = '', hash = ''] = line.split(/\s+/);
|
|
78
|
+
return [id, { created, hash }] as const;
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
const facts = running.map((id) => {
|
|
82
|
+
const match = [...described].find(([full]) => full === id || full.startsWith(id));
|
|
83
|
+
return { id, created: match?.[1].created ?? '', hash: match?.[1].hash };
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
current: facts
|
|
87
|
+
.filter((fact) => fact.hash === want)
|
|
88
|
+
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
|
89
|
+
.map((fact) => fact.id),
|
|
90
|
+
stale: facts.filter((fact) => fact.hash !== want).map((fact) => fact.id),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Start-first for one role. The containers running now are listed and split by compose's config
|
|
96
|
+
* hash against the definition being deployed: those already on it are kept (up to `replicas`,
|
|
97
|
+
* newest first), and only the shortfall is created BESIDE the rest (`--scale running + shortfall
|
|
98
|
+
* --no-recreate`, so compose builds the extra containers from the new definition and leaves the
|
|
99
|
+
* others alone). Everything is waited on until its healthcheck — `/readyz` for web and sync, the
|
|
100
|
+
* scrape port for the others — answers. Only then are the stale containers and any surplus stopped
|
|
101
|
+
* (SIGTERM, then the `stop_grace_period` compose stamped on each as its stop timeout) and removed.
|
|
102
|
+
*
|
|
103
|
+
* Counting the shortfall rather than `running + replicas` is what makes a rerun safe: after a
|
|
104
|
+
* deploy that brought the new containers up and then failed to stop the old, running it again
|
|
105
|
+
* creates nothing and finishes the stop.
|
|
106
|
+
*
|
|
107
|
+
* A new replica that never turns healthy is stopped and removed, and the old ones keep serving;
|
|
108
|
+
* a cleanup that fails is reported beside the failure, naming the containers left running. The
|
|
109
|
+
* same sequence `docker-rollout` performs, because `deploy.update_config.order: start-first` is a
|
|
110
|
+
* Swarm field `docker compose` ignores.
|
|
111
|
+
*/
|
|
112
|
+
export async function runStartFirst(step: {
|
|
113
|
+
readonly role: string;
|
|
114
|
+
readonly base: readonly string[];
|
|
115
|
+
readonly replicas: number;
|
|
116
|
+
readonly runner: Runner;
|
|
117
|
+
readonly options: ExecOptions;
|
|
118
|
+
}): Promise<StartFirstOutcome> {
|
|
119
|
+
const { role, base, replicas, runner, options } = step;
|
|
120
|
+
const run = async (command: readonly string[]) => {
|
|
121
|
+
const result = await runner(command, options);
|
|
122
|
+
return { result, failed: { ok: false as const, code: result.code, command } };
|
|
123
|
+
};
|
|
124
|
+
const listing = await run([...base, 'ps', '-q', role]);
|
|
125
|
+
if (!listing.result.ok) return listing.failed;
|
|
126
|
+
const running = linesOf(listing.result.stdout);
|
|
127
|
+
if (running.length === 0) {
|
|
128
|
+
const fresh = await run(startFirstUp(base, role, undefined));
|
|
129
|
+
return fresh.result.ok ? { ok: true } : fresh.failed;
|
|
130
|
+
}
|
|
131
|
+
const config = await run([...base, 'config', '--hash', role]);
|
|
132
|
+
if (!config.result.ok) return config.failed;
|
|
133
|
+
const inspect = await run(['docker', 'inspect', '--format', INSPECT_FORMAT, ...running]);
|
|
134
|
+
if (!inspect.result.ok) return inspect.failed;
|
|
135
|
+
const { current, stale } = partition(
|
|
136
|
+
running,
|
|
137
|
+
inspect.result.stdout,
|
|
138
|
+
hashOf(config.result.stdout) ?? '',
|
|
139
|
+
);
|
|
140
|
+
const shortfall = Math.max(0, replicas - current.length);
|
|
141
|
+
const up = await run(startFirstUp(base, role, running.length + shortfall));
|
|
142
|
+
if (!up.result.ok) return { ...up.failed, ...(await removeCreated(step, running)) };
|
|
143
|
+
const retire = [...stale, ...current.slice(replicas)];
|
|
144
|
+
if (retire.length === 0) return { ok: true };
|
|
145
|
+
const stopped = await run(['docker', 'stop', ...retire]);
|
|
146
|
+
if (!stopped.result.ok) return stopped.failed;
|
|
147
|
+
const removed = await run(['docker', 'rm', ...retire]);
|
|
148
|
+
return removed.result.ok ? { ok: true } : removed.failed;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* After a failed `up`: stop and remove what it created, so the containers that were serving are
|
|
153
|
+
* the role again. A listing that fails leaves the new replicas to the operator; a stop or rm that
|
|
154
|
+
* fails is returned as `cleanup`, its argv naming the containers still there.
|
|
155
|
+
*/
|
|
156
|
+
async function removeCreated(
|
|
157
|
+
step: {
|
|
158
|
+
readonly role: string;
|
|
159
|
+
readonly base: readonly string[];
|
|
160
|
+
readonly runner: Runner;
|
|
161
|
+
readonly options: ExecOptions;
|
|
162
|
+
},
|
|
163
|
+
running: readonly string[],
|
|
164
|
+
): Promise<{ readonly cleanup?: Failed }> {
|
|
165
|
+
const { role, base, runner, options } = step;
|
|
166
|
+
const after = await runner([...base, 'ps', '-q', role], options);
|
|
167
|
+
if (!after.ok) return { cleanup: { code: after.code, command: [...base, 'ps', '-q', role] } };
|
|
168
|
+
const created = linesOf(after.stdout).filter((id) => !running.includes(id));
|
|
169
|
+
if (created.length === 0) return {};
|
|
170
|
+
for (const command of [
|
|
171
|
+
['docker', 'stop', ...created],
|
|
172
|
+
['docker', 'rm', ...created],
|
|
173
|
+
]) {
|
|
174
|
+
const result = await runner(command, options);
|
|
175
|
+
if (!result.ok) return { cleanup: { code: result.code, command } };
|
|
176
|
+
}
|
|
177
|
+
return {};
|
|
178
|
+
}
|
package/src/cmd-deploy.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
7
|
import { requireAppRoot } from './app-root';
|
|
8
|
+
import { type ComposeStrategy, composeStrategies, readComposeFile } from './cmd-deploy-compose';
|
|
9
|
+
import { readHelmDrainOverrides } from './cmd-deploy-drain';
|
|
8
10
|
import {
|
|
9
11
|
type HelmTarget,
|
|
10
12
|
helmUpgradeArgs,
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
readRollout,
|
|
15
17
|
} from './cmd-deploy-helm';
|
|
16
18
|
import { deploySpec } from './cmd-deploy-spec';
|
|
19
|
+
import { runStartFirst, startFirstUp } from './cmd-deploy-start-first';
|
|
17
20
|
import type { CliCommand, CommandContext } from './command';
|
|
18
21
|
import { BadFlagError, UnknownCommandError } from './errors';
|
|
19
22
|
import { msg } from './messages';
|
|
@@ -78,17 +81,28 @@ export function readMethod(raw: string | undefined): DeployMethod {
|
|
|
78
81
|
/** The roles that run to completion and exit, as against the ones that stay up serving. */
|
|
79
82
|
const ONE_SHOT_ROLES: readonly string[] = ['migrate', 'backfill'];
|
|
80
83
|
|
|
84
|
+
/**
|
|
85
|
+
* One step. A compose serving role carries its rollout `strategy` (`cmd-deploy-compose.ts`); a
|
|
86
|
+
* start-first step's `command` is its scale-up as it runs in the steady state, where the
|
|
87
|
+
* containers running equal the declared replicas — the run counts them first.
|
|
88
|
+
*/
|
|
89
|
+
export interface DeployStep {
|
|
90
|
+
readonly role: string;
|
|
91
|
+
readonly command: readonly string[];
|
|
92
|
+
readonly strategy?: ComposeStrategy | undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
export interface DeployPlan {
|
|
82
96
|
readonly image: string;
|
|
83
97
|
/** Ordered: migrate runs to completion before any role that serves traffic starts. */
|
|
84
|
-
readonly steps: readonly
|
|
98
|
+
readonly steps: readonly DeployStep[];
|
|
85
99
|
/**
|
|
86
100
|
* The environment every step runs with — how the COMPOSE method carries the image, because
|
|
87
101
|
* `docker-compose.prod.yml` resolves each service from `${IMAGE:-ultimate-app:latest}` and
|
|
88
102
|
* `docker compose` takes no image argument. Without it `--image` decided nothing: the plan
|
|
89
103
|
* reported the reference the operator asked for while the six steps read `IMAGE` off the
|
|
90
104
|
* ambient environment, or deployed `ultimate-app:latest` where it was unset. Helm carries none
|
|
91
|
-
* — the chart reads `--set image.repository/tag`, and an env var it never looks at would be a
|
|
105
|
+
* — the chart reads `--set-string image.repository/tag`, and an env var it never looks at would be a
|
|
92
106
|
* second answer to which image is being deployed.
|
|
93
107
|
*/
|
|
94
108
|
readonly env: Readonly<Record<string, string>>;
|
|
@@ -96,7 +110,7 @@ export interface DeployPlan {
|
|
|
96
110
|
|
|
97
111
|
/**
|
|
98
112
|
* The chart declares `image` as a MAP — `repository`, `tag`, `pullPolicy` — and `_helpers.tpl`
|
|
99
|
-
* renders `
|
|
113
|
+
* renders `repository:tag` from it, the tag defaulting to `.Chart.AppVersion`.
|
|
100
114
|
* `--set image=<ref>` replaces that map with a string, so every workload template fails on
|
|
101
115
|
* `.repository` and the deploy that was asked to ship one image ships nothing. The reference is
|
|
102
116
|
* split into the two keys the chart actually reads; a reference with no tag sets only the
|
|
@@ -109,34 +123,66 @@ export function helmImageOverrides(image: string): readonly string[] {
|
|
|
109
123
|
const colon = image.lastIndexOf(':');
|
|
110
124
|
const tag = colon > image.lastIndexOf('/') ? image.slice(colon + 1) : '';
|
|
111
125
|
const repository = tag === '' ? image : image.slice(0, colon);
|
|
126
|
+
// `--set-string`, never `--set`: `--set` TYPES its value, so `image.tag=1234567` reached the chart
|
|
127
|
+
// as an int64 and rendered `app:%!s(int64=1234567)` — any all-digit tag (a build number, a date,
|
|
128
|
+
// a short SHA that happens to be digits) was an ImagePullBackOff behind `--wait`.
|
|
112
129
|
return tag === ''
|
|
113
|
-
? ['--set', `image.repository=${repository}`]
|
|
114
|
-
: ['--set', `image.repository=${repository}`, '--set', `image.tag=${tag}`];
|
|
130
|
+
? ['--set-string', `image.repository=${repository}`]
|
|
131
|
+
: ['--set-string', `image.repository=${repository}`, '--set-string', `image.tag=${tag}`];
|
|
115
132
|
}
|
|
116
133
|
|
|
134
|
+
/**
|
|
135
|
+
* The OCI reference grammar (distribution/reference), tag form only — the digest form has its own
|
|
136
|
+
* refusal in `planDeploy`. `[domain[:port]/]path[:tag]`: a domain of dot-separated host labels, path
|
|
137
|
+
* components of lower-case alphanumerics joined by `.`, `_`, `__` or dashes, a tag of word
|
|
138
|
+
* characters, `.` and `-`, at most 128. It holds none of helm's `--set-string` metacharacters (`,`
|
|
139
|
+
* `=` `\` `{` `}`), so a reference it admits never needs escaping, and one it refuses is never
|
|
140
|
+
* split into a second value.
|
|
141
|
+
*/
|
|
142
|
+
const LABEL = '[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?';
|
|
143
|
+
const COMPONENT = '[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*';
|
|
144
|
+
const OCI_REFERENCE = new RegExp(
|
|
145
|
+
`^(?:${LABEL}(?:\\.${LABEL})*(?::[0-9]+)?/)?${COMPONENT}(?:/${COMPONENT})*(?::[\\w][\\w.-]{0,127})?$`,
|
|
146
|
+
);
|
|
147
|
+
|
|
117
148
|
/** What was asked for: the method, and for helm the release it is aimed at. */
|
|
118
149
|
export type DeployRequest =
|
|
119
150
|
| { readonly method: 'compose' }
|
|
120
|
-
| ({ readonly method: 'helm' } &
|
|
151
|
+
| ({ readonly method: 'helm' } & HelmDeployTarget);
|
|
152
|
+
|
|
153
|
+
/** A helm target plus the `--set` pairs that size the chart's grace periods (`cmd-deploy-drain.ts`). */
|
|
154
|
+
export interface HelmDeployTarget extends HelmTarget {
|
|
155
|
+
readonly drain?: readonly string[] | undefined;
|
|
156
|
+
}
|
|
121
157
|
|
|
122
158
|
/**
|
|
123
159
|
* The plan for one method. A helm plan takes its target — release, namespace, timeout — because
|
|
124
160
|
* the release is read off `app.config.ts`, which only the command can import; the compose plan
|
|
125
161
|
* needs none, so its three-argument form is unchanged.
|
|
126
162
|
*/
|
|
127
|
-
export function planDeploy(
|
|
163
|
+
export function planDeploy(
|
|
164
|
+
image: string,
|
|
165
|
+
method: 'compose',
|
|
166
|
+
root: string,
|
|
167
|
+
strategies?: ReadonlyMap<string, ComposeStrategy>,
|
|
168
|
+
): DeployPlan;
|
|
128
169
|
export function planDeploy(
|
|
129
170
|
image: string,
|
|
130
171
|
method: 'helm',
|
|
131
172
|
root: string,
|
|
132
|
-
target:
|
|
173
|
+
target: HelmDeployTarget,
|
|
133
174
|
): DeployPlan;
|
|
134
175
|
export function planDeploy(
|
|
135
176
|
image: string,
|
|
136
177
|
method: DeployMethod,
|
|
137
178
|
root: string,
|
|
138
|
-
|
|
179
|
+
extra?: HelmDeployTarget | ReadonlyMap<string, ComposeStrategy>,
|
|
139
180
|
): DeployPlan {
|
|
181
|
+
const target = method === 'helm' ? (extra as HelmDeployTarget | undefined) : undefined;
|
|
182
|
+
const strategies =
|
|
183
|
+
method === 'compose' && extra instanceof Map
|
|
184
|
+
? (extra as ReadonlyMap<string, ComposeStrategy>)
|
|
185
|
+
: new Map<string, ComposeStrategy>();
|
|
140
186
|
if (method === 'helm') {
|
|
141
187
|
// A caller from plain JS can still reach this without one; `app` for every app is the
|
|
142
188
|
// defect the target exists to end, so it is refused rather than defaulted back.
|
|
@@ -159,10 +205,29 @@ export function planDeploy(
|
|
|
159
205
|
fix: `x deploy --method helm --image ${image.slice(0, image.lastIndexOf('@'))}:<tag> --json`,
|
|
160
206
|
});
|
|
161
207
|
}
|
|
208
|
+
// Helm splits a `--set-string` argument on `,` and reads `=` as key/value: an image built from
|
|
209
|
+
// a git ref name (`app,serviceAccount.create=true:1.2`) set a value nobody asked for. The value
|
|
210
|
+
// is quoted with JSON in the cause (it may hold a newline) and never echoed into the fix.
|
|
211
|
+
if (!OCI_REFERENCE.test(image)) {
|
|
212
|
+
throw new BadFlagError({
|
|
213
|
+
flag: 'image',
|
|
214
|
+
command: 'deploy',
|
|
215
|
+
reason: `${JSON.stringify(image)} is not an OCI image reference ([registry[:port]/]path[:tag], lower-case path), and helm would read its , or = as more --set-string values`,
|
|
216
|
+
fix: 'x deploy --method helm --image ghcr.io/<org>/<app>:<tag> --json',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
162
219
|
return {
|
|
163
220
|
image,
|
|
164
221
|
env: {},
|
|
165
|
-
steps: [
|
|
222
|
+
steps: [
|
|
223
|
+
{
|
|
224
|
+
role: 'all',
|
|
225
|
+
command: helmUpgradeArgs(root, target, [
|
|
226
|
+
...helmImageOverrides(image),
|
|
227
|
+
...(target.drain ?? []),
|
|
228
|
+
]),
|
|
229
|
+
},
|
|
230
|
+
],
|
|
166
231
|
};
|
|
167
232
|
}
|
|
168
233
|
return {
|
|
@@ -171,27 +236,42 @@ export function planDeploy(
|
|
|
171
236
|
// header documents `IMAGE=… docker compose …` as the way to run it by hand; this is that line,
|
|
172
237
|
// performed.
|
|
173
238
|
env: { IMAGE: image },
|
|
174
|
-
steps: DEPLOY_ROLES.map((role) => (
|
|
175
|
-
role,
|
|
176
|
-
command: [
|
|
177
|
-
'docker',
|
|
178
|
-
'compose',
|
|
179
|
-
// Compose interpolates `${SYNC_URL:?…}` and `${POSTGRES_PASSWORD:?…}` from the shell and
|
|
180
|
-
// `--env-file` only — never from a service's `env_file:`. Without this an operator who put
|
|
181
|
-
// them in `.env.production`, the one file the compose file tells them to fill, had every
|
|
182
|
-
// step die on a parse error. Global flag, so it precedes `-f`; the shell still wins over it.
|
|
183
|
-
'--env-file',
|
|
184
|
-
join(root, PROD_ENV_FILE),
|
|
185
|
-
'-f',
|
|
186
|
-
join(root, 'docker', 'docker-compose.prod.yml'),
|
|
187
|
-
ONE_SHOT_ROLES.includes(role) ? 'run' : 'up',
|
|
188
|
-
ONE_SHOT_ROLES.includes(role) ? '--rm' : '-d',
|
|
189
|
-
role,
|
|
190
|
-
],
|
|
191
|
-
})),
|
|
239
|
+
steps: DEPLOY_ROLES.map((role) => composeStep(composeBase(root), role, strategies.get(role))),
|
|
192
240
|
};
|
|
193
241
|
}
|
|
194
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Every compose command's prefix. Compose interpolates `${SYNC_URL:?…}`, `${APP_URL:?…}` and
|
|
245
|
+
* `${POSTGRES_PASSWORD:?…}` from the shell and `--env-file` only — never from a service's
|
|
246
|
+
* `env_file:`. Without it an operator who put them in `.env.production`, the one file the compose
|
|
247
|
+
* file tells them to fill, had every step die on a parse error. Global flag, so it precedes `-f`;
|
|
248
|
+
* the shell still wins over it.
|
|
249
|
+
*/
|
|
250
|
+
const composeBase = (root: string): readonly string[] => [
|
|
251
|
+
'docker',
|
|
252
|
+
'compose',
|
|
253
|
+
'--env-file',
|
|
254
|
+
join(root, PROD_ENV_FILE),
|
|
255
|
+
'-f',
|
|
256
|
+
composeFileOf(root),
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
const composeFileOf = (root: string): string => join(root, 'docker', 'docker-compose.prod.yml');
|
|
260
|
+
|
|
261
|
+
/** One compose step: a one-shot `run --rm`, a start-first scale-up, or compose's own recreate. */
|
|
262
|
+
function composeStep(
|
|
263
|
+
base: readonly string[],
|
|
264
|
+
role: string,
|
|
265
|
+
strategy: ComposeStrategy | undefined,
|
|
266
|
+
): DeployStep {
|
|
267
|
+
if (ONE_SHOT_ROLES.includes(role)) return { role, command: [...base, 'run', '--rm', role] };
|
|
268
|
+
if (strategy?.kind === 'start-first') {
|
|
269
|
+
const scale = strategy.replicas === 0 ? undefined : strategy.replicas * 2;
|
|
270
|
+
return { role, command: startFirstUp(base, role, scale), strategy };
|
|
271
|
+
}
|
|
272
|
+
return { role, command: [...base, 'up', '-d', role], strategy };
|
|
273
|
+
}
|
|
274
|
+
|
|
195
275
|
/**
|
|
196
276
|
* One step, as the line an operator would type — the environment first, then the command.
|
|
197
277
|
*
|
|
@@ -231,9 +311,23 @@ export async function readDeployRequest(
|
|
|
231
311
|
release: await readReleaseName(root, flagString(ctx.args, 'release')),
|
|
232
312
|
namespace: namespace === undefined ? undefined : readLabel('namespace', namespace),
|
|
233
313
|
timeout: readHelmTimeout(flagString(ctx.args, 'timeout')),
|
|
314
|
+
drain: await readHelmDrainOverrides(root),
|
|
234
315
|
};
|
|
235
316
|
}
|
|
236
317
|
|
|
318
|
+
/**
|
|
319
|
+
* What a failed start-first roll could not undo. The unhealthy replicas it created are stopped and
|
|
320
|
+
* removed; when that cleanup fails too, the containers it names are still running beside the old
|
|
321
|
+
* ones, and saying nothing would leave an operator rerunning a deploy over them unaware.
|
|
322
|
+
*/
|
|
323
|
+
const cleanupNote = (outcome: {
|
|
324
|
+
readonly ok: false;
|
|
325
|
+
readonly cleanup?: { readonly code: number; readonly command: readonly string[] } | undefined;
|
|
326
|
+
}): string =>
|
|
327
|
+
outcome.cleanup === undefined
|
|
328
|
+
? ''
|
|
329
|
+
: `; its cleanup \`${outcome.cleanup.command.map(quoteArg).join(' ')}\` exited ${outcome.cleanup.code}, so the containers it names are still running — stop and remove them before rerunning`;
|
|
330
|
+
|
|
237
331
|
const stepLine = (env: Readonly<Record<string, string>>, command: readonly string[]): string =>
|
|
238
332
|
[
|
|
239
333
|
...Object.entries(env).map(([name, value]) => `${name}=${quoteArg(value)}`),
|
|
@@ -255,10 +349,17 @@ export const deployCommand: CliCommand = {
|
|
|
255
349
|
// X_DEPLOY_FAILED, whose fix is the exact command to rerun.
|
|
256
350
|
const method = readMethod(flagString(ctx.args, 'method'));
|
|
257
351
|
const request = await readDeployRequest(ctx, method, root);
|
|
352
|
+
// Read before the plan, because the plan IS the rollout: which compose roles can start their
|
|
353
|
+
// new container first is a fact of the app's compose file, and a dry run reports it too.
|
|
258
354
|
const plan =
|
|
259
355
|
request.method === 'helm'
|
|
260
356
|
? planDeploy(image, 'helm', root, request)
|
|
261
|
-
: planDeploy(
|
|
357
|
+
: planDeploy(
|
|
358
|
+
image,
|
|
359
|
+
'compose',
|
|
360
|
+
root,
|
|
361
|
+
composeStrategies(await readComposeFile(composeFileOf(root))),
|
|
362
|
+
);
|
|
262
363
|
// What the helm flags resolved to — the release above all, which is read off `app.config.ts`
|
|
263
364
|
// and so is not something the operator typed. Absent on compose, which has no release.
|
|
264
365
|
const target: { readonly [key: string]: JsonValue } =
|
|
@@ -278,7 +379,12 @@ export const deployCommand: CliCommand = {
|
|
|
278
379
|
// Reported, because it is what makes `image` above true on the compose method — a dry run
|
|
279
380
|
// that names an image the steps do not carry is the defect this field closed.
|
|
280
381
|
env: { ...plan.env },
|
|
281
|
-
steps: plan.steps.map((step) => ({
|
|
382
|
+
steps: plan.steps.map((step) => ({
|
|
383
|
+
role: step.role,
|
|
384
|
+
command: step.command.join(' '),
|
|
385
|
+
...(step.strategy === undefined ? {} : { strategy: step.strategy.kind }),
|
|
386
|
+
...(step.strategy?.kind === 'stop-first' ? { why: step.strategy.why } : {}),
|
|
387
|
+
})),
|
|
282
388
|
};
|
|
283
389
|
// The roles THIS plan has, never the compose list: on `--method helm` there is one step,
|
|
284
390
|
// `all`, and naming `backfill` there promises an operator a sweep the chart cannot run.
|
|
@@ -298,10 +404,26 @@ export const deployCommand: CliCommand = {
|
|
|
298
404
|
// and revision rather than an exit code standing in for one. Compose has no such record.
|
|
299
405
|
let rollout: { readonly [key: string]: JsonValue } = {};
|
|
300
406
|
for (const step of plan.steps) {
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
407
|
+
const options = { cwd: root, env: plan.env };
|
|
408
|
+
// A start-first step is a sequence — list, scale up, wait, stop the old — and reports the
|
|
409
|
+
// command of the sub-step that failed, so the fix line below is still the one to rerun.
|
|
410
|
+
const outcome =
|
|
411
|
+
step.strategy?.kind === 'start-first'
|
|
412
|
+
? await runStartFirst({
|
|
413
|
+
role: step.role,
|
|
414
|
+
base: composeBase(root),
|
|
415
|
+
replicas: step.strategy.replicas,
|
|
416
|
+
runner: ctx.runner,
|
|
417
|
+
options,
|
|
418
|
+
})
|
|
419
|
+
: await ctx.runner(step.command, options).then((result) => {
|
|
420
|
+
if (request.method === 'helm')
|
|
421
|
+
rollout = { rollout: { ...readRollout(request, result.stdout) } };
|
|
422
|
+
return result.ok
|
|
423
|
+
? { ok: true as const }
|
|
424
|
+
: { ok: false as const, code: result.code, command: step.command };
|
|
425
|
+
});
|
|
426
|
+
if (!outcome.ok) {
|
|
305
427
|
return {
|
|
306
428
|
ok: false,
|
|
307
429
|
command: 'deploy',
|
|
@@ -309,8 +431,8 @@ export const deployCommand: CliCommand = {
|
|
|
309
431
|
findings: [
|
|
310
432
|
{
|
|
311
433
|
code: 'X_DEPLOY_FAILED',
|
|
312
|
-
cause: `role "${step.role}" step exited ${
|
|
313
|
-
fix: `${stepLine(plan.env,
|
|
434
|
+
cause: `role "${step.role}" step exited ${outcome.code}${cleanupNote(outcome)}`,
|
|
435
|
+
fix: `${stepLine(plan.env, outcome.command)} # run it directly to see the full output`,
|
|
314
436
|
docs: ERROR_DOCS_URL,
|
|
315
437
|
},
|
|
316
438
|
],
|