@ultimat3/cli 24.0.0 → 25.1.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 +15 -8
- package/README.md +30 -5
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +3 -1
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +3 -3
- package/src/app-env.ts +21 -14
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +2 -2
- package/src/app-mcp.ts +25 -27
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +2 -8
- package/src/app-permissions-site.ts +31 -7
- 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 +115 -121
- package/src/cdp-shot-driver.ts +17 -0
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth.ts +2 -2
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +89 -9
- package/src/cmd-db-backfill.ts +1 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +5 -2
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -357
- package/src/cmd-doctor.ts +16 -66
- package/src/cmd-env.ts +29 -14
- package/src/cmd-generate.ts +12 -2
- package/src/cmd-i18n.ts +11 -8
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +18 -137
- package/src/cmd-mcp.ts +25 -7
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +9 -0
- package/src/cmd-routes.ts +3 -3
- package/src/cmd-shot-matrix.ts +21 -4
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +35 -39
- package/src/cmd-test.ts +1 -1
- 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 +19 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +1 -1
- package/src/dev-dashboard.ts +5 -1
- package/src/dev-lock.ts +96 -24
- 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 +14 -7
- package/src/doctor-auth.ts +4 -4
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +2 -2
- package/src/drift.ts +29 -6
- package/src/error-codes.ts +6 -0
- package/src/error-page-csp.ts +14 -15
- 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.ts +13 -2
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-kinds.ts +3 -17
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +50 -1
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +21 -8
- 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 +1 -3
- package/src/island-bundle.ts +54 -64
- 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 +32 -16
- package/src/local-cli-handoff.ts +90 -0
- package/src/mcp-errors.ts +8 -1
- package/src/mcp-host.ts +6 -5
- 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 +27 -16
- package/src/metrics-endpoint.ts +3 -2
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +18 -0
- package/src/page-speculation.ts +14 -11
- 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-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +63 -98
- package/src/pwa-artifacts.ts +8 -1
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/retry-memo.ts +1 -1
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +110 -31
- package/src/role-start.ts +66 -87
- package/src/role-sync.ts +65 -6
- package/src/role-wake.ts +2 -3
- 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 +15 -2
- package/src/runtime-bindings.ts +23 -3
- package/src/runtime-cache.ts +8 -8
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +2 -2
- package/src/runtime-jobs.ts +78 -18
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +2 -2
- package/src/runtime-overrides.ts +3 -3
- package/src/runtime-purge.ts +1 -1
- package/src/runtime-queue.ts +19 -30
- package/src/runtime-realtime.ts +5 -3
- package/src/runtime-render.ts +77 -82
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +36 -15
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +3 -3
- package/src/schema-diff.ts +1 -1
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +2 -2
- package/src/serve-boot.ts +22 -11
- package/src/serve-drain.ts +3 -1
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +19 -8
- 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 -15
- 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-seo.ts +6 -4
- package/src/source-files.ts +13 -3
- package/src/static-report.ts +5 -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 +1 -1
- package/src/templates/action.ts +4 -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/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 +23 -8
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +2 -1
- 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.ts +12 -6
- 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-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-templates.ts +74 -18
- package/src/templates/scaffold-helm.ts +31 -4
- 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 +18 -15
- package/src/templates/scaffold-shell.ts +17 -2
- package/src/test-only-shipped.ts +193 -0
- package/src/test-select.ts +2 -1
- package/src/test-shards.ts +1 -4
- package/src/theme-boot.ts +2 -2
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/verify-checks.ts +12 -2
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +87 -10
- package/src/verify-merge.ts +48 -16
- package/src/verify-part-step.ts +10 -2
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +3 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +0 -1
- 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/jobs-drain.ts +0 -230
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
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:?…}`, `${APP_URL:?…}` and `${POSTGRES_PASSWORD:?…}` from
|
|
180
|
-
// the shell and `--env-file` only — never from a service's `env_file:`. Without this an
|
|
181
|
-
// operator who put them in `.env.production`, the one file the compose file tells them to
|
|
182
|
-
// fill, had every 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
|
],
|