@caelo-cms/provisioning 0.10.55 → 0.10.58
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/dist/agent-db.js +97 -24
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +4 -1
- package/dist/cli.js.map +1 -1
- package/dist/compose.js +5 -0
- package/dist/compose.js.map +1 -1
- package/dist/converge-checkpoint.d.ts +66 -0
- package/dist/converge-checkpoint.d.ts.map +1 -0
- package/dist/converge-checkpoint.js +61 -0
- package/dist/converge-checkpoint.js.map +1 -0
- package/dist/converge-job-gcp.d.ts +46 -0
- package/dist/converge-job-gcp.d.ts.map +1 -0
- package/dist/converge-job-gcp.js +368 -0
- package/dist/converge-job-gcp.js.map +1 -0
- package/dist/converge-job-setup.d.ts +62 -0
- package/dist/converge-job-setup.d.ts.map +1 -0
- package/dist/converge-job-setup.js +228 -0
- package/dist/converge-job-setup.js.map +1 -0
- package/dist/converge-job.d.ts +141 -0
- package/dist/converge-job.d.ts.map +1 -0
- package/dist/converge-job.js +325 -0
- package/dist/converge-job.js.map +1 -0
- package/dist/gcp-names.d.ts +9 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +13 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +33 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +11 -3
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js +22 -12
- package/dist/install-job.js.map +1 -1
- package/dist/lifecycle.d.ts +13 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +115 -58
- package/dist/lifecycle.js.map +1 -1
- package/dist/operator-access.d.ts +4 -0
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +43 -7
- package/dist/operator-access.js.map +1 -1
- package/dist/release-contract.d.ts +3 -2
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +5 -2
- package/dist/release-contract.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -0
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js.map +1 -1
- package/dist/remote-mcp-edge.d.ts +114 -0
- package/dist/remote-mcp-edge.d.ts.map +1 -0
- package/dist/remote-mcp-edge.js +116 -0
- package/dist/remote-mcp-edge.js.map +1 -0
- package/dist/remote-mcp.d.ts +139 -0
- package/dist/remote-mcp.d.ts.map +1 -0
- package/dist/remote-mcp.js +434 -0
- package/dist/remote-mcp.js.map +1 -0
- package/dist/runtime-secrets.d.ts +12 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +32 -4
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/seal-key-rotation.d.ts +38 -0
- package/dist/seal-key-rotation.d.ts.map +1 -0
- package/dist/seal-key-rotation.js +206 -0
- package/dist/seal-key-rotation.js.map +1 -0
- package/dist/stack-contract.d.ts +206 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +219 -4
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +53 -1
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +124 -4
- package/dist/stack-converge.js.map +1 -1
- package/dist/state-lock-lease.d.ts +142 -0
- package/dist/state-lock-lease.d.ts.map +1 -0
- package/dist/state-lock-lease.js +347 -0
- package/dist/state-lock-lease.js.map +1 -0
- package/dist/state-store.d.ts +66 -9
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +249 -30
- package/dist/state-store.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts +36 -7
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +53 -19
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/audit-job-roll.d.ts.map +1 -1
- package/dist/upgrade/audit-job-roll.js +3 -1
- package/dist/upgrade/audit-job-roll.js.map +1 -1
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +125 -12
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +27 -10
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +46 -7
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/record-rolled.d.ts +40 -0
- package/dist/upgrade/record-rolled.d.ts.map +1 -0
- package/dist/upgrade/record-rolled.js +46 -0
- package/dist/upgrade/record-rolled.js.map +1 -0
- package/dist/upgrade/upgrade.d.ts +45 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +60 -17
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +15 -1
- package/dist/upgrader-assess.d.ts.map +1 -1
- package/dist/upgrader-assess.js +36 -6
- package/dist/upgrader-assess.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +15 -0
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +42 -7
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts +123 -0
- package/dist/upgrader-iam-preflight.d.ts.map +1 -0
- package/dist/upgrader-iam-preflight.js +414 -0
- package/dist/upgrader-iam-preflight.js.map +1 -0
- package/dist/upgrader-job.d.ts +33 -9
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +169 -27
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-records.d.ts +58 -0
- package/dist/upgrader-records.d.ts.map +1 -0
- package/dist/upgrader-records.js +87 -0
- package/dist/upgrader-records.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +3 -2
- package/dist/upgrader-reporter.d.ts.map +1 -1
- package/dist/upgrader-reporter.js +10 -2
- package/dist/upgrader-reporter.js.map +1 -1
- package/dist/upgrader-setup.d.ts +25 -3
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +123 -11
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +11 -3
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +6 -0
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +14 -2
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +106 -4
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/agent/run.test.ts +14 -1
- package/src/audit-job-setup.test.ts +7 -1
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +4 -1
- package/src/compose.ts +5 -0
- package/src/converge-checkpoint.ts +75 -0
- package/src/converge-entrypoint.test.ts +213 -0
- package/src/converge-job-gcp.test.ts +222 -0
- package/src/converge-job-gcp.ts +443 -0
- package/src/converge-job-setup.test.ts +152 -0
- package/src/converge-job-setup.ts +268 -0
- package/src/converge-job.test.ts +486 -0
- package/src/converge-job.ts +542 -0
- package/src/gateway-env-isolation.test.ts +8 -1
- package/src/gcp-names.ts +15 -0
- package/src/gcp-url-map.test.ts +9 -1
- package/src/index.ts +8 -0
- package/src/infra-contract.test.ts +8 -4
- package/src/infra-contract.ts +33 -1
- package/src/install-job.test.ts +3 -1
- package/src/install-job.ts +31 -14
- package/src/lifecycle.ts +137 -71
- package/src/operator-access.test.ts +24 -1
- package/src/operator-access.ts +44 -6
- package/src/release-contract.ts +5 -2
- package/src/release-manifest.test.ts +42 -0
- package/src/release-manifest.ts +2 -0
- package/src/remote-mcp-edge.ts +137 -0
- package/src/remote-mcp.test.ts +690 -0
- package/src/remote-mcp.ts +524 -0
- package/src/runtime-secrets.test.ts +31 -6
- package/src/runtime-secrets.ts +41 -3
- package/src/seal-key-rotation.test.ts +131 -0
- package/src/seal-key-rotation.ts +258 -0
- package/src/stack-contract-parity.test.ts +20 -3
- package/src/stack-contract.test.ts +32 -2
- package/src/stack-contract.ts +363 -4
- package/src/stack-converge-secrets.test.ts +4 -0
- package/src/stack-converge.test.ts +21 -5
- package/src/stack-converge.ts +162 -7
- package/src/state-attach.test.ts +7 -1
- package/src/state-lock-lease.test.ts +302 -0
- package/src/state-lock-lease.ts +423 -0
- package/src/state-lock.test.ts +568 -0
- package/src/state-store.ts +347 -27
- package/src/upgrade/app-roll.ts +102 -21
- package/src/upgrade/audit-job-roll.ts +3 -1
- package/src/upgrade/converge.ts +148 -10
- package/src/upgrade/gcp-lookup.test.ts +80 -0
- package/src/upgrade/gcp-lookup.ts +65 -8
- package/src/upgrade/record-rolled.test.ts +105 -0
- package/src/upgrade/record-rolled.ts +91 -0
- package/src/upgrade/upgrade.test.ts +203 -3
- package/src/upgrade/upgrade.ts +120 -24
- package/src/upgrader-assess.test.ts +92 -3
- package/src/upgrader-assess.ts +58 -9
- package/src/upgrader-gcp.ts +47 -8
- package/src/upgrader-iam-preflight.test.ts +393 -0
- package/src/upgrader-iam-preflight.ts +510 -0
- package/src/upgrader-job.test.ts +586 -15
- package/src/upgrader-job.ts +237 -53
- package/src/upgrader-records.ts +151 -0
- package/src/upgrader-reporter.test.ts +6 -0
- package/src/upgrader-reporter.ts +7 -2
- package/src/upgrader-setup.test.ts +3 -3
- package/src/upgrader-setup.ts +128 -11
- package/src/wizards/gcp-cost.ts +12 -3
- package/src/wizards/gcp-pulumi.test.ts +15 -0
- package/src/wizards/gcp-pulumi.ts +18 -2
- package/src/wizards/gcp.ts +134 -4
- package/stacks/aws/index.ts +10 -0
- package/stacks/azure/index.ts +9 -0
- package/stacks/gcp/index.ts +47 -7
- package/stacks/gcp-firebase/index.ts +38 -6
package/src/stack-converge.ts
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* - {@link planMediaVolume}: the admin's media bucket volume (gen2 + Cloud
|
|
19
19
|
* Storage volume + mount), added in the same `services update` when
|
|
20
20
|
* missing.
|
|
21
|
+
* - {@link planSecretFiles} / {@link planRequestTimeout} (#806): the
|
|
22
|
+
* gateway's secret file (the remote-MCP OAuth client) and its request
|
|
23
|
+
* timeout, in the same `services update`.
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
import { removeDefaultFirewallRules } from "./default-firewall.js";
|
|
@@ -40,11 +43,15 @@ import {
|
|
|
40
43
|
type MediaVolumeContract,
|
|
41
44
|
memoryQuantityMiB,
|
|
42
45
|
RETIRED_IAM_BINDINGS,
|
|
46
|
+
RETIRED_RUNTIME_SECRETS,
|
|
43
47
|
RETIRED_SERVICE_ENV,
|
|
44
48
|
type RetiredIamBinding,
|
|
49
|
+
type RetiredSecretFile,
|
|
45
50
|
type RuntimeEnvInputs,
|
|
51
|
+
type SecretFileContract,
|
|
46
52
|
type ServiceEnvInputs,
|
|
47
53
|
STATIC_CDN_POLICY,
|
|
54
|
+
secretFilePath,
|
|
48
55
|
stackIamInvariants,
|
|
49
56
|
staticBackendBucketPrefix,
|
|
50
57
|
} from "./stack-contract.js";
|
|
@@ -285,6 +292,12 @@ export function planContractEnv(
|
|
|
285
292
|
opts: {
|
|
286
293
|
/** Contract vars to leave exactly as the services have them. */
|
|
287
294
|
readonly leaveUntouched?: readonly string[];
|
|
295
|
+
/**
|
|
296
|
+
* Vars beyond the contract to set as well (#806: the remote-MCP identity
|
|
297
|
+
* env, resolved by the converge). A name the contract also sets is
|
|
298
|
+
* refused rather than silently doubled.
|
|
299
|
+
*/
|
|
300
|
+
readonly extraEnv?: Readonly<Partial<Record<CloudRunSlug, readonly CloudRunEnvVar[]>>>;
|
|
288
301
|
} = {},
|
|
289
302
|
):
|
|
290
303
|
| {
|
|
@@ -320,16 +333,25 @@ export function planContractEnv(
|
|
|
320
333
|
inputs = { ...runtime, provider: "gcp" };
|
|
321
334
|
}
|
|
322
335
|
const skip = new Set(opts.leaveUntouched ?? []);
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
336
|
+
const desired = (slug: CloudRunSlug, contract: readonly CloudRunEnvVar[]) => {
|
|
337
|
+
const extra = opts.extraEnv?.[slug] ?? [];
|
|
338
|
+
const clash = extra.find((e) => contract.some((c) => c.name === e.name));
|
|
339
|
+
if (clash) throw new Error(`${slug}: ${clash.name} is both in the contract and extra env`);
|
|
340
|
+
return [...contract, ...extra].filter((e) => !skip.has(e.name));
|
|
341
|
+
};
|
|
342
|
+
let adminDesired: CloudRunEnvVar[];
|
|
343
|
+
let gatewayDesired: CloudRunEnvVar[];
|
|
344
|
+
try {
|
|
345
|
+
adminDesired = desired("admin", adminEnvContract(inputs));
|
|
346
|
+
gatewayDesired = desired("gateway", gatewayEnvContract(runtime));
|
|
347
|
+
} catch (e) {
|
|
348
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
349
|
+
}
|
|
350
|
+
const admin = planEnvUpdate(services.admin.liveEnv, adminDesired, RETIRED_SERVICE_ENV.admin);
|
|
329
351
|
if (!admin.ok) return { ok: false, error: `admin: ${admin.error}` };
|
|
330
352
|
const gateway = planEnvUpdate(
|
|
331
353
|
services.gateway.liveEnv,
|
|
332
|
-
|
|
354
|
+
gatewayDesired,
|
|
333
355
|
RETIRED_SERVICE_ENV.gateway,
|
|
334
356
|
);
|
|
335
357
|
if (!gateway.ok) return { ok: false, error: `gateway: ${gateway.error}` };
|
|
@@ -662,6 +684,53 @@ export async function removeRetiredIamBindings(
|
|
|
662
684
|
return outcomes;
|
|
663
685
|
}
|
|
664
686
|
|
|
687
|
+
/**
|
|
688
|
+
* Delete the secrets older contracts created and nothing reads any more
|
|
689
|
+
* ({@link RETIRED_RUNTIME_SECRETS}) — run by upgrade AFTER both services
|
|
690
|
+
* rolled onto revisions that no longer reference them (Cloud Run would
|
|
691
|
+
* refuse a revision whose secret is gone). Deleting a secret drops its IAM
|
|
692
|
+
* bindings with it. An absent secret is `absent`; never throws for a
|
|
693
|
+
* gcloud failure.
|
|
694
|
+
*/
|
|
695
|
+
export async function deleteRetiredSecrets(
|
|
696
|
+
install: Pick<InstallTarget, "projectId" | "env">,
|
|
697
|
+
deps: {
|
|
698
|
+
run?: GcloudRunner;
|
|
699
|
+
retired?: readonly { readonly name: string; readonly why: string }[];
|
|
700
|
+
} = {},
|
|
701
|
+
): Promise<RetiredBindingOutcome[]> {
|
|
702
|
+
const run = deps.run ?? defaultGcloud;
|
|
703
|
+
const outcomes: RetiredBindingOutcome[] = [];
|
|
704
|
+
for (const s of deps.retired ?? RETIRED_RUNTIME_SECRETS) {
|
|
705
|
+
const id = `secret ${gcpSecretId(install.env, s.name)}`;
|
|
706
|
+
const project = `--project=${install.projectId}`;
|
|
707
|
+
const describe = await run([
|
|
708
|
+
"secrets",
|
|
709
|
+
"describe",
|
|
710
|
+
gcpSecretId(install.env, s.name),
|
|
711
|
+
project,
|
|
712
|
+
"--format=value(name)",
|
|
713
|
+
]);
|
|
714
|
+
if (!describe.ok && RESOURCE_MISSING.test(describe.stderr)) {
|
|
715
|
+
outcomes.push({ id, status: "absent", why: s.why });
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
const del = await run([
|
|
719
|
+
"secrets",
|
|
720
|
+
"delete",
|
|
721
|
+
gcpSecretId(install.env, s.name),
|
|
722
|
+
project,
|
|
723
|
+
"--quiet",
|
|
724
|
+
]);
|
|
725
|
+
outcomes.push(
|
|
726
|
+
del.ok || RESOURCE_MISSING.test(del.stderr)
|
|
727
|
+
? { id, status: del.ok ? "removed" : "absent", why: s.why }
|
|
728
|
+
: { id, status: "failed", why: s.why, error: del.stderr.trim() },
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
return outcomes;
|
|
732
|
+
}
|
|
733
|
+
|
|
665
734
|
/** The memory limit of the service's container (`512Mi`, `2Gi`), or null
|
|
666
735
|
* when the service declares none (Cloud Run then runs it with 512Mi). */
|
|
667
736
|
export function liveContainerMemory(serviceJson: string): string | null {
|
|
@@ -713,6 +782,11 @@ const EXECUTION_ENVIRONMENT_ANNOTATION = "run.googleapis.com/execution-environme
|
|
|
713
782
|
|
|
714
783
|
interface KnativeVolume {
|
|
715
784
|
readonly name: string;
|
|
785
|
+
/** A Secret Manager volume (#806 secret files). */
|
|
786
|
+
readonly secret?: {
|
|
787
|
+
readonly secretName?: string;
|
|
788
|
+
readonly items?: readonly { readonly key?: string; readonly path?: string }[];
|
|
789
|
+
};
|
|
716
790
|
readonly csi?: {
|
|
717
791
|
readonly driver?: string;
|
|
718
792
|
readonly readOnly?: boolean;
|
|
@@ -843,6 +917,87 @@ export function planMediaVolume(live: LiveVolumes, desired: MediaVolumeContract)
|
|
|
843
917
|
return { ok: true, flags, changes };
|
|
844
918
|
}
|
|
845
919
|
|
|
920
|
+
// ===========================================================================
|
|
921
|
+
// Secret files + request timeout (#806)
|
|
922
|
+
// ===========================================================================
|
|
923
|
+
|
|
924
|
+
export type RollAdditionsPlan =
|
|
925
|
+
| { readonly ok: true; readonly flags: string[]; readonly changes: string[] }
|
|
926
|
+
| { readonly ok: false; readonly error: string };
|
|
927
|
+
|
|
928
|
+
/** `projects/<p>/secrets/<id>` or `<id>` → `<id>`. */
|
|
929
|
+
function bareSecretId(name: string | undefined): string {
|
|
930
|
+
return (name ?? "").slice((name ?? "").lastIndexOf("/") + 1);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* The flags that mount each of `files` (stack-contract.ts
|
|
935
|
+
* SERVICE_SECRET_FILES) where the stacks mount it, adding only what is
|
|
936
|
+
* missing — `--update-secrets=<dir>/<file>=<secret>:latest`, the form
|
|
937
|
+
* cloud.google.com/run/docs/configuring/services/secrets documents (gcloud
|
|
938
|
+
* names the volume itself). A file already mounted from the same secret at
|
|
939
|
+
* `latest` is converged whatever its volume is called. Anything else at that
|
|
940
|
+
* directory is refused with what was found: replacing it could feed the
|
|
941
|
+
* gateway a different credential.
|
|
942
|
+
*/
|
|
943
|
+
export function planSecretFiles(
|
|
944
|
+
live: LiveVolumes,
|
|
945
|
+
files: readonly SecretFileContract[],
|
|
946
|
+
env: string,
|
|
947
|
+
retired: readonly RetiredSecretFile[] = [],
|
|
948
|
+
): RollAdditionsPlan {
|
|
949
|
+
const flags: string[] = [];
|
|
950
|
+
const changes: string[] = [];
|
|
951
|
+
// #914 — mounts an older contract made and nothing reads any more
|
|
952
|
+
// (RETIRED_SECRET_FILES) go in the same revision that mounts their
|
|
953
|
+
// replacement. `--remove-secrets=<dir>/<file>` drops the file, and the
|
|
954
|
+
// mount with it when it was the volume's only file (gcloud
|
|
955
|
+
// `_PruneManagedVolumeMapping`). Repeated `--remove-secrets` /
|
|
956
|
+
// `--update-secrets` flags merge (gcloud map flags use UpdateAction).
|
|
957
|
+
for (const old of retired) {
|
|
958
|
+
if (!live.mounts.some((m) => m.mountPath === old.mountDir)) continue;
|
|
959
|
+
flags.push(`--remove-secrets=${old.mountDir}/${old.fileName}`);
|
|
960
|
+
changes.push(`secret file ${old.mountDir}/${old.fileName}: removed (${old.why})`);
|
|
961
|
+
}
|
|
962
|
+
for (const file of files) {
|
|
963
|
+
const secretId = gcpSecretId(env, file.secret);
|
|
964
|
+
const mount = live.mounts.find((m) => m.mountPath === file.mountDir);
|
|
965
|
+
if (mount) {
|
|
966
|
+
const volume = live.volumes.find((v) => v.name === mount.name);
|
|
967
|
+
const item = volume?.secret?.items?.find((i) => i.path === file.fileName);
|
|
968
|
+
const inShape =
|
|
969
|
+
bareSecretId(volume?.secret?.secretName) === secretId && item?.key === file.version;
|
|
970
|
+
if (inShape) continue;
|
|
971
|
+
return {
|
|
972
|
+
ok: false,
|
|
973
|
+
error: `the service mounts volume "${mount.name}" at ${file.mountDir}, where ${secretId}:${file.version} belongs as ${file.fileName}. Remove it (gcloud run services update <service> --remove-volume-mount=${file.mountDir}) only if nothing else needs it, then re-run upgrade.`,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
flags.push(`--update-secrets=${secretFilePath(file)}=${secretId}:${file.version}`);
|
|
977
|
+
changes.push(`secret file ${secretFilePath(file)}: (none) → ${secretId}:${file.version}`);
|
|
978
|
+
}
|
|
979
|
+
return { ok: true, flags, changes };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** The request timeout of a described service, in seconds (null: Cloud Run's default, 300). */
|
|
983
|
+
export function liveTimeoutSeconds(serviceJson: string): number | null {
|
|
984
|
+
const svc = JSON.parse(serviceJson) as {
|
|
985
|
+
spec?: { template?: { spec?: { timeoutSeconds?: number | string } } };
|
|
986
|
+
};
|
|
987
|
+
const t = svc.spec?.template?.spec?.timeoutSeconds;
|
|
988
|
+
return t === undefined ? null : Number(t);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** `--timeout=<desired>` when the live timeout differs; nothing when it matches. */
|
|
992
|
+
export function planRequestTimeout(live: number | null, desired: number): RollAdditionsPlan {
|
|
993
|
+
if (live === desired) return { ok: true, flags: [], changes: [] };
|
|
994
|
+
return {
|
|
995
|
+
ok: true,
|
|
996
|
+
flags: [`--timeout=${desired}`],
|
|
997
|
+
changes: [`request timeout: ${live === null ? "(default 300)" : live}s → ${desired}s`],
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
|
|
846
1001
|
/**
|
|
847
1002
|
* The single `gcloud run services update` that rolls a service to a new
|
|
848
1003
|
* image, its run SA and its env changes, so all land in one new revision.
|
package/src/state-attach.test.ts
CHANGED
|
@@ -186,12 +186,18 @@ describe("state locks", () => {
|
|
|
186
186
|
}
|
|
187
187
|
if (args[1] === "cat")
|
|
188
188
|
return objects.has(url) ? ok(objects.get(url)) : fail("matched no objects");
|
|
189
|
+
if (args[1] === "objects") {
|
|
190
|
+
const target = args[3] ?? "";
|
|
191
|
+
return objects.has(target)
|
|
192
|
+
? ok(JSON.stringify({ generation: "1" }))
|
|
193
|
+
: fail("matched no objects");
|
|
194
|
+
}
|
|
189
195
|
if (args[1] === "rm") return objects.delete(url) ? ok() : fail("matched no objects");
|
|
190
196
|
return fail("unexpected");
|
|
191
197
|
};
|
|
192
198
|
const release = acquireBucketStateLock("acme-site-caelo-ops", "state migrate", run);
|
|
193
199
|
expect(() => acquireBucketStateLock("acme-site-caelo-ops", "installer pulumi up", run)).toThrow(
|
|
194
|
-
/
|
|
200
|
+
/held by state migrate .*lease runs until/,
|
|
195
201
|
);
|
|
196
202
|
release();
|
|
197
203
|
acquireBucketStateLock("acme-site-caelo-ops", "installer pulumi up", run)();
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The state lock's lease (state-lock-lease.ts, #815): when a held lock may
|
|
5
|
+
* be broken, and how Cloud Run's execution status is read.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "bun:test";
|
|
9
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
10
|
+
import {
|
|
11
|
+
type CloudRunExecutionRef,
|
|
12
|
+
type ExecutionState,
|
|
13
|
+
judgeHeldLock,
|
|
14
|
+
LEASE_SKEW_MS,
|
|
15
|
+
type LocalHost,
|
|
16
|
+
lockBody,
|
|
17
|
+
machineId,
|
|
18
|
+
PROCESS_LEASE_MS,
|
|
19
|
+
parseExecutionState,
|
|
20
|
+
parseLockBody,
|
|
21
|
+
renewedLockBody,
|
|
22
|
+
} from "./state-lock-lease.js";
|
|
23
|
+
|
|
24
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
25
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
26
|
+
|
|
27
|
+
const EXEC: CloudRunExecutionRef = {
|
|
28
|
+
projectId: "acme",
|
|
29
|
+
region: "europe-west1",
|
|
30
|
+
name: "caelo-production-upgrader-x1",
|
|
31
|
+
};
|
|
32
|
+
const START = new Date("2026-10-16T10:00:00Z");
|
|
33
|
+
const HOUR = 60 * 60_000;
|
|
34
|
+
/** The lease of a lock taken at START by a one-hour job. */
|
|
35
|
+
const LEASE_END = START.getTime() + HOUR + LEASE_SKEW_MS;
|
|
36
|
+
|
|
37
|
+
const jobLock = lockBody("caelo upgrader job", START, { execution: EXEC, maxRuntimeMs: HOUR });
|
|
38
|
+
const at = (ms: number) => new Date(ms);
|
|
39
|
+
const state =
|
|
40
|
+
(s: ExecutionState) =>
|
|
41
|
+
(ref: CloudRunExecutionRef): ExecutionState => {
|
|
42
|
+
expect(ref).toEqual(EXEC);
|
|
43
|
+
return s;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe("lockBody", () => {
|
|
47
|
+
it("an execution's lock names the execution and a lease of task timeout + skew", () => {
|
|
48
|
+
const body = parseLockBody(jobLock);
|
|
49
|
+
expect(body?.execution).toEqual(EXEC);
|
|
50
|
+
expect(body?.leaseUntil).toBe(new Date(LEASE_END).toISOString());
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("a machine-local lock (no holder) carries neither", () => {
|
|
54
|
+
const body = parseLockBody(lockBody("cms-provision upgrade", START));
|
|
55
|
+
expect(body?.purpose).toBe("cms-provision upgrade");
|
|
56
|
+
expect(body?.execution).toBeUndefined();
|
|
57
|
+
expect(body?.leaseUntil).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("a CLI process's lock names host + pid, a unique id and a lease of leaseMs + skew", () => {
|
|
61
|
+
const a = parseLockBody(
|
|
62
|
+
lockBody("cms-provision upgrade", START, { leaseMs: PROCESS_LEASE_MS }),
|
|
63
|
+
);
|
|
64
|
+
const b = parseLockBody(
|
|
65
|
+
lockBody("cms-provision upgrade", START, { leaseMs: PROCESS_LEASE_MS }),
|
|
66
|
+
);
|
|
67
|
+
expect(a?.pid).toBe(process.pid);
|
|
68
|
+
expect(a?.host).toBeTruthy();
|
|
69
|
+
expect(a?.execution).toBeUndefined();
|
|
70
|
+
expect(a?.leaseUntil).toBe(
|
|
71
|
+
new Date(START.getTime() + PROCESS_LEASE_MS + LEASE_SKEW_MS).toISOString(),
|
|
72
|
+
);
|
|
73
|
+
expect(a?.id).toBeTruthy();
|
|
74
|
+
expect(a?.id).not.toBe(b?.id);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("a renewed body keeps its id and start and moves only the lease", () => {
|
|
78
|
+
const body = parseLockBody(
|
|
79
|
+
lockBody("cms-provision upgrade", START, { leaseMs: PROCESS_LEASE_MS }, "id-1"),
|
|
80
|
+
);
|
|
81
|
+
if (!body) throw new Error("unparsable");
|
|
82
|
+
const later = at(START.getTime() + 3 * HOUR);
|
|
83
|
+
const renewed = parseLockBody(renewedLockBody(body, later, PROCESS_LEASE_MS));
|
|
84
|
+
expect(renewed).toMatchObject({ id: "id-1", startedAt: START.toISOString() });
|
|
85
|
+
expect(renewed?.leaseUntil).toBe(
|
|
86
|
+
new Date(later.getTime() + PROCESS_LEASE_MS + LEASE_SKEW_MS).toISOString(),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("judgeHeldLock — a CLI process holder (#815 review)", () => {
|
|
92
|
+
const HERE: LocalHost = { host: "laptop", alive: (pid) => pid === 4242 };
|
|
93
|
+
const cli = (host: string, pid: number) =>
|
|
94
|
+
JSON.stringify({
|
|
95
|
+
purpose: "cms-provision upgrade",
|
|
96
|
+
host,
|
|
97
|
+
pid,
|
|
98
|
+
startedAt: START.toISOString(),
|
|
99
|
+
leaseUntil: new Date(START.getTime() + PROCESS_LEASE_MS + LEASE_SKEW_MS).toISOString(),
|
|
100
|
+
});
|
|
101
|
+
const LEASE = START.getTime() + PROCESS_LEASE_MS + LEASE_SKEW_MS;
|
|
102
|
+
const never = () => {
|
|
103
|
+
throw new Error("Cloud Run must not be asked about a CLI holder");
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
it("its own host's lock whose pid is dead is broken at once, inside the lease", () => {
|
|
107
|
+
const v = judgeHeldLock(cli("laptop", 9999), never, at(START.getTime() + 1), HERE);
|
|
108
|
+
expect(v.kind).toBe("break");
|
|
109
|
+
expect(v.why).toContain("process is gone");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("its own host's lock whose pid lives is held until the lease ends", () => {
|
|
113
|
+
expect(judgeHeldLock(cli("laptop", 4242), never, at(LEASE), HERE)).toMatchObject({
|
|
114
|
+
kind: "held",
|
|
115
|
+
manualRemoval: false,
|
|
116
|
+
});
|
|
117
|
+
expect(judgeHeldLock(cli("laptop", 4242), never, at(LEASE + 1), HERE).kind).toBe("break");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("another host's lock (a Cloud Shell) is held within the lease, broken after it", () => {
|
|
121
|
+
expect(judgeHeldLock(cli("cs-1", 9999), never, at(LEASE), HERE).kind).toBe("held");
|
|
122
|
+
const v = judgeHeldLock(cli("cs-1", 9999), never, at(LEASE + 1), HERE);
|
|
123
|
+
expect(v.kind).toBe("break");
|
|
124
|
+
expect(v.why).toContain("stopped renewing");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("#815 review: a dead pid is only judged on the machine that took the lock", () => {
|
|
128
|
+
const at1 = at(START.getTime() + 1);
|
|
129
|
+
const withMachine = (machine: string | null) =>
|
|
130
|
+
JSON.stringify({ ...JSON.parse(cli("localhost", 9999)), machine });
|
|
131
|
+
const here = (machine: string | null): LocalHost => ({
|
|
132
|
+
host: "localhost",
|
|
133
|
+
machine,
|
|
134
|
+
alive: () => false,
|
|
135
|
+
});
|
|
136
|
+
// Same host name, another machine (two Cloud Run containers, two
|
|
137
|
+
// laptops called alike): the pid is someone else's, the lease decides.
|
|
138
|
+
expect(judgeHeldLock(withMachine("m-a"), never, at1, here("m-b")).kind).toBe("held");
|
|
139
|
+
// A lock from Cloud Run names no machine: never judged by its pid.
|
|
140
|
+
expect(judgeHeldLock(withMachine(null), never, at1, here("m-b")).kind).toBe("held");
|
|
141
|
+
// A judge in Cloud Run has no machine id: it never trusts a pid, not
|
|
142
|
+
// even one of a lock written before machine ids.
|
|
143
|
+
expect(judgeHeldLock(cli("localhost", 9999), never, at1, here(null)).kind).toBe("held");
|
|
144
|
+
// The same machine: the dead pid ends it at once.
|
|
145
|
+
expect(judgeHeldLock(withMachine("m-a"), never, at1, here("m-a")).kind).toBe("break");
|
|
146
|
+
// Whatever the machine, the lease still ends it.
|
|
147
|
+
expect(judgeHeldLock(withMachine("m-a"), never, at(LEASE + 1), here("m-b")).kind).toBe("break");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("#815 review: machineId is null inside Cloud Run and stable outside", () => {
|
|
151
|
+
expect(machineId({ CLOUD_RUN_EXECUTION: "caelo-upgrader-abc" })).toBeNull();
|
|
152
|
+
expect(machineId({ K_SERVICE: "caelo-production-admin" })).toBeNull();
|
|
153
|
+
expect(machineId({})).toBe(machineId({}));
|
|
154
|
+
const body = parseLockBody(lockBody("cms-provision upgrade", START, { leaseMs: 1 }));
|
|
155
|
+
expect(body && "machine" in body).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("an unreadable lease is held for a person to remove", () => {
|
|
159
|
+
const body = JSON.stringify({
|
|
160
|
+
purpose: "cms-provision upgrade",
|
|
161
|
+
host: "cs-1",
|
|
162
|
+
pid: 1,
|
|
163
|
+
leaseUntil: "soon",
|
|
164
|
+
});
|
|
165
|
+
expect(judgeHeldLock(body, never, at(LEASE + HOUR), HERE)).toMatchObject({
|
|
166
|
+
kind: "held",
|
|
167
|
+
manualRemoval: true,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("judgeHeldLock", () => {
|
|
173
|
+
it("breaks the lock of an execution that has finished — succeeded, failed or cancelled", () => {
|
|
174
|
+
for (const how of ["succeeded", "failed", "cancelled"]) {
|
|
175
|
+
const v = judgeHeldLock(
|
|
176
|
+
jobLock,
|
|
177
|
+
state({ kind: "finished", how }),
|
|
178
|
+
at(START.getTime() + 60_000),
|
|
179
|
+
);
|
|
180
|
+
expect(v.kind).toBe("break");
|
|
181
|
+
expect(v.why).toContain(`has ${how}`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("never breaks the lock of a running execution, even long past its lease", () => {
|
|
186
|
+
for (const now of [START.getTime() + 1, LEASE_END + 1, LEASE_END + 24 * HOUR]) {
|
|
187
|
+
const v = judgeHeldLock(jobLock, state({ kind: "running" }), at(now));
|
|
188
|
+
expect(v).toMatchObject({ kind: "held", manualRemoval: false });
|
|
189
|
+
expect(v.why).toContain("still running");
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("an execution Cloud Run does not know: held within the lease, broken after it", () => {
|
|
194
|
+
const gone = state({ kind: "not-found" });
|
|
195
|
+
expect(judgeHeldLock(jobLock, gone, at(START.getTime())).kind).toBe("held");
|
|
196
|
+
// Exactly at the lease's end it still holds; one millisecond later it is dead.
|
|
197
|
+
expect(judgeHeldLock(jobLock, gone, at(LEASE_END)).kind).toBe("held");
|
|
198
|
+
expect(judgeHeldLock(jobLock, gone, at(LEASE_END + 1)).kind).toBe("break");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("a lock whose lease is unreadable is never broken on 'not found'", () => {
|
|
202
|
+
const body = JSON.stringify({
|
|
203
|
+
purpose: "caelo upgrader job",
|
|
204
|
+
execution: EXEC,
|
|
205
|
+
leaseUntil: "soon",
|
|
206
|
+
});
|
|
207
|
+
const v = judgeHeldLock(body, state({ kind: "not-found" }), at(LEASE_END + 10 * HOUR));
|
|
208
|
+
expect(v).toMatchObject({ kind: "held", manualRemoval: true });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("a holder Cloud Run cannot be asked about is undecided, never dead", () => {
|
|
212
|
+
const v = judgeHeldLock(
|
|
213
|
+
jobLock,
|
|
214
|
+
state({ kind: "unknown", error: "PERMISSION_DENIED" }),
|
|
215
|
+
at(LEASE_END + HOUR),
|
|
216
|
+
);
|
|
217
|
+
expect(v.kind).toBe("unknown");
|
|
218
|
+
expect(v.why).toContain("PERMISSION_DENIED");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("a pre-#815 lock (no lease) from another host is never broken automatically, and Cloud Run is not asked", () => {
|
|
222
|
+
const describe = () => {
|
|
223
|
+
throw new Error("must not be asked");
|
|
224
|
+
};
|
|
225
|
+
const cli = judgeHeldLock(
|
|
226
|
+
lockBody("cms-provision upgrade", START),
|
|
227
|
+
describe,
|
|
228
|
+
at(START.getTime() + 100 * HOUR),
|
|
229
|
+
);
|
|
230
|
+
expect(cli).toMatchObject({ kind: "held", manualRemoval: true });
|
|
231
|
+
const legacy = JSON.stringify({
|
|
232
|
+
purpose: "caelo upgrader job",
|
|
233
|
+
host: "localhost",
|
|
234
|
+
pid: 1,
|
|
235
|
+
startedAt: START.toISOString(),
|
|
236
|
+
});
|
|
237
|
+
expect(
|
|
238
|
+
judgeHeldLock(legacy, describe, at(START.getTime() + 100 * HOUR), {
|
|
239
|
+
host: "laptop",
|
|
240
|
+
alive: () => false,
|
|
241
|
+
}).kind,
|
|
242
|
+
).toBe("held");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("an unreadable lock body is held, with the body quoted", () => {
|
|
246
|
+
const v = judgeHeldLock("not json", state({ kind: "running" }), START);
|
|
247
|
+
expect(v).toMatchObject({ kind: "held", manualRemoval: true });
|
|
248
|
+
expect(v.why).toContain("not json");
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("parseExecutionState", () => {
|
|
253
|
+
const exec = (status: unknown) => ok(JSON.stringify({ status }));
|
|
254
|
+
|
|
255
|
+
it("reads the Completed condition: True succeeded, False failed or cancelled", () => {
|
|
256
|
+
expect(
|
|
257
|
+
parseExecutionState(exec({ conditions: [{ type: "Completed", status: "True" }] })),
|
|
258
|
+
).toEqual({
|
|
259
|
+
kind: "finished",
|
|
260
|
+
how: "succeeded",
|
|
261
|
+
});
|
|
262
|
+
expect(
|
|
263
|
+
parseExecutionState(
|
|
264
|
+
exec({ conditions: [{ type: "Completed", status: "False", reason: "Cancelled" }] }),
|
|
265
|
+
),
|
|
266
|
+
).toEqual({ kind: "finished", how: "cancelled" });
|
|
267
|
+
expect(
|
|
268
|
+
parseExecutionState(exec({ conditions: [{ type: "Completed", status: "False" }] })),
|
|
269
|
+
).toEqual({
|
|
270
|
+
kind: "finished",
|
|
271
|
+
how: "failed",
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("a completion time without a decided condition is finished too", () => {
|
|
276
|
+
expect(parseExecutionState(exec({ completionTime: "2026-10-16T11:00:00Z" })).kind).toBe(
|
|
277
|
+
"finished",
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("an undecided or not yet started execution is running", () => {
|
|
282
|
+
expect(
|
|
283
|
+
parseExecutionState(exec({ conditions: [{ type: "Completed", status: "Unknown" }] })).kind,
|
|
284
|
+
).toBe("running");
|
|
285
|
+
expect(parseExecutionState(exec({})).kind).toBe("running");
|
|
286
|
+
expect(parseExecutionState(ok("{}")).kind).toBe("running");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("not found vs. any other gcloud failure", () => {
|
|
290
|
+
expect(
|
|
291
|
+
parseExecutionState(fail("ERROR: (gcloud.run.jobs.executions.describe) NOT_FOUND")).kind,
|
|
292
|
+
).toBe("not-found");
|
|
293
|
+
expect(parseExecutionState(fail("PERMISSION_DENIED: run.executions.get"))).toEqual({
|
|
294
|
+
kind: "unknown",
|
|
295
|
+
error: "PERMISSION_DENIED: run.executions.get",
|
|
296
|
+
});
|
|
297
|
+
expect(parseExecutionState(ok("garbage")).kind).toBe("unknown");
|
|
298
|
+
expect(parseExecutionState(ok(JSON.stringify({ status: { conditions: "x" } }))).kind).toBe(
|
|
299
|
+
"unknown",
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
});
|