@caelo-cms/provisioning 0.10.47 → 0.10.49
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/caddy/Caddyfile.production +1 -1
- package/caddy/Caddyfile.staging +1 -1
- package/dist/agent/apply.d.ts +45 -0
- package/dist/agent/apply.d.ts.map +1 -0
- package/dist/agent/apply.js +83 -0
- package/dist/agent/apply.js.map +1 -0
- package/dist/agent/cli-provenance.d.ts +42 -0
- package/dist/agent/cli-provenance.d.ts.map +1 -0
- package/dist/agent/cli-provenance.js +138 -0
- package/dist/agent/cli-provenance.js.map +1 -0
- package/dist/agent/command.d.ts +79 -0
- package/dist/agent/command.d.ts.map +1 -0
- package/dist/agent/command.js +454 -0
- package/dist/agent/command.js.map +1 -0
- package/dist/agent/config.d.ts +30 -0
- package/dist/agent/config.d.ts.map +1 -0
- package/dist/agent/config.js +60 -0
- package/dist/agent/config.js.map +1 -0
- package/dist/agent/lock.d.ts +13 -0
- package/dist/agent/lock.d.ts.map +1 -0
- package/dist/agent/lock.js +100 -0
- package/dist/agent/lock.js.map +1 -0
- package/dist/agent/queue.d.ts +35 -0
- package/dist/agent/queue.d.ts.map +1 -0
- package/dist/agent/queue.js +50 -0
- package/dist/agent/queue.js.map +1 -0
- package/dist/agent/run.d.ts +97 -0
- package/dist/agent/run.d.ts.map +1 -0
- package/dist/agent/run.js +198 -0
- package/dist/agent/run.js.map +1 -0
- package/dist/agent/systemd.d.ts +55 -0
- package/dist/agent/systemd.d.ts.map +1 -0
- package/dist/agent/systemd.js +171 -0
- package/dist/agent/systemd.js.map +1 -0
- package/dist/agent/units.d.ts +44 -0
- package/dist/agent/units.d.ts.map +1 -0
- package/dist/agent/units.js +127 -0
- package/dist/agent/units.js.map +1 -0
- package/dist/agent-db.js +18402 -0
- package/dist/cli.js +119 -16
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-iam-user.d.ts +39 -0
- package/dist/cloudsql-iam-user.d.ts.map +1 -0
- package/dist/cloudsql-iam-user.js +119 -0
- package/dist/cloudsql-iam-user.js.map +1 -0
- package/dist/compose.d.ts +16 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +26 -1
- package/dist/compose.js.map +1 -1
- package/dist/dns/index.d.ts +2 -0
- package/dist/dns/index.d.ts.map +1 -1
- package/dist/dns/index.js +3 -2
- package/dist/dns/index.js.map +1 -1
- package/dist/dns/manual.d.ts +5 -0
- package/dist/dns/manual.d.ts.map +1 -1
- package/dist/dns/manual.js +28 -12
- package/dist/dns/manual.js.map +1 -1
- package/dist/gcloud.d.ts +2 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +1 -1
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +15 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +23 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.js +7 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job-runner.d.ts +13 -0
- package/dist/install-job-runner.d.ts.map +1 -0
- package/dist/install-job-runner.js +155 -0
- package/dist/install-job-runner.js.map +1 -0
- package/dist/install-job.d.ts +210 -0
- package/dist/install-job.d.ts.map +1 -0
- package/dist/install-job.js +449 -0
- package/dist/install-job.js.map +1 -0
- package/dist/install-state.d.ts +10 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +10 -2
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +19 -4
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts +5 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +1 -1
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +14 -8
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +16 -102
- package/dist/operator-access.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -41
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +18 -121
- package/dist/release-manifest.js.map +1 -1
- package/dist/run-mode.d.ts +27 -0
- package/dist/run-mode.d.ts.map +1 -0
- package/dist/run-mode.js +22 -0
- package/dist/run-mode.js.map +1 -0
- package/dist/runtime-secrets.d.ts +19 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +44 -0
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +22 -1
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +81 -12
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +17 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +16 -1
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +6 -0
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +10 -1
- package/dist/stack-contract.js.map +1 -1
- package/dist/upgrade/converge.d.ts +6 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +59 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +6 -0
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +25 -0
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +26 -2
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +35 -2
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +5 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +28 -1
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +85 -0
- package/dist/upgrader-assess.d.ts.map +1 -0
- package/dist/upgrader-assess.js +122 -0
- package/dist/upgrader-assess.js.map +1 -0
- package/dist/upgrader-gcp.d.ts +17 -0
- package/dist/upgrader-gcp.d.ts.map +1 -0
- package/dist/upgrader-gcp.js +87 -0
- package/dist/upgrader-gcp.js.map +1 -0
- package/dist/upgrader-job.d.ts +133 -0
- package/dist/upgrader-job.d.ts.map +1 -0
- package/dist/upgrader-job.js +259 -0
- package/dist/upgrader-job.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +35 -0
- package/dist/upgrader-reporter.d.ts.map +1 -0
- package/dist/upgrader-reporter.js +79 -0
- package/dist/upgrader-reporter.js.map +1 -0
- package/dist/upgrader-setup.d.ts +59 -0
- package/dist/upgrader-setup.d.ts.map +1 -0
- package/dist/upgrader-setup.js +308 -0
- package/dist/upgrader-setup.js.map +1 -0
- package/dist/wizard.d.ts +6 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +32 -4
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-firebase.d.ts +3 -0
- package/dist/wizards/gcp-firebase.d.ts.map +1 -1
- package/dist/wizards/gcp-firebase.js +1 -0
- package/dist/wizards/gcp-firebase.js.map +1 -1
- package/dist/wizards/gcp-install-job.d.ts +74 -0
- package/dist/wizards/gcp-install-job.d.ts.map +1 -0
- package/dist/wizards/gcp-install-job.js +398 -0
- package/dist/wizards/gcp-install-job.js.map +1 -0
- package/dist/wizards/gcp-prompts.d.ts +3 -0
- package/dist/wizards/gcp-prompts.d.ts.map +1 -0
- package/dist/wizards/gcp-prompts.js +25 -0
- package/dist/wizards/gcp-prompts.js.map +1 -0
- package/dist/wizards/gcp-pulumi.d.ts +4 -2
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +1 -1
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +40 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +223 -59
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +6 -4
- package/src/adapter.ts +97 -0
- package/src/adc-impersonation.ts +176 -0
- package/src/admin-domain.test.ts +254 -0
- package/src/admin-domain.ts +245 -0
- package/src/admin-memory.test.ts +151 -0
- package/src/agent/apply.test.ts +185 -0
- package/src/agent/apply.ts +124 -0
- package/src/agent/cli-provenance.test.ts +159 -0
- package/src/agent/cli-provenance.ts +191 -0
- package/src/agent/command.test.ts +77 -0
- package/src/agent/command.ts +564 -0
- package/src/agent/config.ts +86 -0
- package/src/agent/lock-config.test.ts +97 -0
- package/src/agent/lock.ts +110 -0
- package/src/agent/queue.test.ts +10 -0
- package/src/agent/queue.ts +91 -0
- package/src/agent/run.test.ts +437 -0
- package/src/agent/run.ts +295 -0
- package/src/agent/systemd.ts +209 -0
- package/src/agent/units.test.ts +223 -0
- package/src/agent/units.ts +163 -0
- package/src/azure-front-door.test.ts +119 -0
- package/src/azure-front-door.ts +56 -0
- package/src/bootstrap-token.ts +20 -0
- package/src/caddy.ts +112 -0
- package/src/cdn-copy.ts +91 -0
- package/src/cert-wait.test.ts +194 -0
- package/src/cert-wait.ts +163 -0
- package/src/checkpoint-secrets.test.ts +380 -0
- package/src/checkpoint-secrets.ts +283 -0
- package/src/cli-routing.test.ts +45 -0
- package/src/cli-routing.ts +43 -0
- package/src/cli.ts +1491 -0
- package/src/cloudsql-iam-user.ts +147 -0
- package/src/compose.ts +334 -0
- package/src/default-firewall.test.ts +411 -0
- package/src/default-firewall.ts +342 -0
- package/src/deployed-release.test.ts +231 -0
- package/src/deployed-release.ts +220 -0
- package/src/dns/cloudflare.ts +182 -0
- package/src/dns/groups.test.ts +76 -0
- package/src/dns/groups.ts +86 -0
- package/src/dns/index.ts +56 -0
- package/src/dns/manual.ts +130 -0
- package/src/dns/types.ts +27 -0
- package/src/gateway-credentials.test.ts +180 -0
- package/src/gateway-credentials.ts +144 -0
- package/src/gcloud-components.test.ts +76 -0
- package/src/gcloud-components.ts +85 -0
- package/src/gcloud-retry.ts +41 -0
- package/src/gcloud-runner.test.ts +61 -0
- package/src/gcloud.test.ts +99 -0
- package/src/gcloud.ts +322 -0
- package/src/gcp-names.ts +135 -0
- package/src/index.test.ts +332 -0
- package/src/index.ts +114 -0
- package/src/infra-contract.ts +32 -0
- package/src/install-job-runner.ts +166 -0
- package/src/install-job.test.ts +566 -0
- package/src/install-job.ts +542 -0
- package/src/install-state.test.ts +239 -0
- package/src/install-state.ts +583 -0
- package/src/internal-token.ts +35 -0
- package/src/lifecycle.ts +900 -0
- package/src/mcp-iap-stacks.test.ts +58 -0
- package/src/mcp-iap.test.ts +149 -0
- package/src/mcp-iap.ts +131 -0
- package/src/media-volume.test.ts +296 -0
- package/src/migration-runner.ts +365 -0
- package/src/operator-access-stacks.test.ts +55 -0
- package/src/operator-access.test.ts +332 -0
- package/src/operator-access.ts +554 -0
- package/src/ops-backend.test.ts +320 -0
- package/src/ops-backend.ts +414 -0
- package/src/provisioner-identity.test.ts +482 -0
- package/src/provisioner-identity.ts +407 -0
- package/src/redirects-emit.ts +175 -0
- package/src/region-data.ts +428 -0
- package/src/regions.test.ts +277 -0
- package/src/regions.ts +370 -0
- package/src/release-contract.ts +57 -0
- package/src/release-manifest.test.ts +300 -0
- package/src/release-manifest.ts +310 -0
- package/src/run-mode.ts +40 -0
- package/src/runtime-secrets.test.ts +627 -0
- package/src/runtime-secrets.ts +754 -0
- package/src/self-command.test.ts +14 -0
- package/src/self-command.ts +21 -0
- package/src/self-hosted-compose.test.ts +177 -0
- package/src/self-hosted-release.ts +240 -0
- package/src/self-hosted-roles.integration.test.ts +161 -0
- package/src/self-hosted-roles.test.ts +124 -0
- package/src/self-hosted-roles.ts +203 -0
- package/src/self-hosted-upgrade.test.ts +394 -0
- package/src/self-hosted-upgrade.ts +548 -0
- package/src/stack-contract-parity.test.ts +199 -0
- package/src/stack-contract.test.ts +391 -0
- package/src/stack-contract.ts +888 -0
- package/src/stack-converge-secrets.test.ts +421 -0
- package/src/stack-converge.test.ts +833 -0
- package/src/stack-converge.ts +909 -0
- package/src/staging-admin.test.ts +246 -0
- package/src/staging-admin.ts +324 -0
- package/src/state-attach.test.ts +211 -0
- package/src/state-attach.ts +132 -0
- package/src/state-backend.test.ts +145 -0
- package/src/state-backend.ts +262 -0
- package/src/state-command.ts +293 -0
- package/src/state-migrate.test.ts +479 -0
- package/src/state-migrate.ts +434 -0
- package/src/state-pulumi.ts +289 -0
- package/src/state-store.test.ts +212 -0
- package/src/state-store.ts +243 -0
- package/src/upgrade/app-roll.ts +486 -0
- package/src/upgrade/backup.ts +109 -0
- package/src/upgrade/converge.ts +437 -0
- package/src/upgrade/gcp-lookup.ts +167 -0
- package/src/upgrade/health.test.ts +279 -0
- package/src/upgrade/health.ts +305 -0
- package/src/upgrade/release-images.test.ts +119 -0
- package/src/upgrade/release-images.ts +393 -0
- package/src/upgrade/reporter.ts +46 -0
- package/src/upgrade/upgrade.test.ts +626 -0
- package/src/upgrade/upgrade.ts +249 -0
- package/src/upgrader-assess.test.ts +306 -0
- package/src/upgrader-assess.ts +250 -0
- package/src/upgrader-gcp.ts +123 -0
- package/src/upgrader-job.test.ts +411 -0
- package/src/upgrader-job.ts +502 -0
- package/src/upgrader-reporter.test.ts +46 -0
- package/src/upgrader-reporter.ts +105 -0
- package/src/upgrader-setup.test.ts +169 -0
- package/src/upgrader-setup.ts +355 -0
- package/src/wizard.ts +425 -0
- package/src/wizards/gcp-cost.ts +188 -0
- package/src/wizards/gcp-firebase.ts +72 -0
- package/src/wizards/gcp-install-job.ts +506 -0
- package/src/wizards/gcp-prompts.ts +26 -0
- package/src/wizards/gcp-pulumi.test.ts +246 -0
- package/src/wizards/gcp-pulumi.ts +516 -0
- package/src/wizards/gcp.ts +1935 -0
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Converge an existing GCP install to what its Pulumi stack would produce,
|
|
5
|
+
* through gcloud (`cms-provision upgrade` does not run Pulumi). The desired
|
|
6
|
+
* state comes from stack-contract.ts, which the stacks use too.
|
|
7
|
+
*
|
|
8
|
+
* - {@link planEnvUpdate}: the env-var flags for the `services update` that
|
|
9
|
+
* rolls the image, so env changes ride the same new revision — including
|
|
10
|
+
* moving a plain var to a Secret Manager reference.
|
|
11
|
+
* - {@link ensureStackInvariants}: IAM bindings + CDN settings. Each binding
|
|
12
|
+
* is checked against the resource's policy first and only added when
|
|
13
|
+
* missing, so an operator without IAM-admin rights can still upgrade an
|
|
14
|
+
* install that is already in shape.
|
|
15
|
+
* - {@link ensureStackInvariants} also deletes the firewall rules GCP
|
|
16
|
+
* pre-populates on the `default` network (SSH, RDP, ICMP, internal)
|
|
17
|
+
* (default-firewall.ts), the one thing it removes.
|
|
18
|
+
* - {@link planMediaVolume}: the admin's media bucket volume (gen2 + Cloud
|
|
19
|
+
* Storage volume + mount), added in the same `services update` when
|
|
20
|
+
* missing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { removeDefaultFirewallRules } from "./default-firewall.js";
|
|
24
|
+
import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
|
|
25
|
+
import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
|
|
26
|
+
import { gcpBucketName, gcpSecretId } from "./gcp-names.js";
|
|
27
|
+
import { selfCommand } from "./self-command.js";
|
|
28
|
+
import {
|
|
29
|
+
ADMIN_MEMORY_DEFAULT,
|
|
30
|
+
type AdminEnvInputs,
|
|
31
|
+
adminEnvContract,
|
|
32
|
+
type CloudRunEnvVar,
|
|
33
|
+
type CloudRunSlug,
|
|
34
|
+
databaseUrls,
|
|
35
|
+
type GcpProvider,
|
|
36
|
+
gatewayEnvContract,
|
|
37
|
+
type IamInvariant,
|
|
38
|
+
type IamTarget,
|
|
39
|
+
iamMember,
|
|
40
|
+
type MediaVolumeContract,
|
|
41
|
+
memoryQuantityMiB,
|
|
42
|
+
RETIRED_IAM_BINDINGS,
|
|
43
|
+
RETIRED_SERVICE_ENV,
|
|
44
|
+
type RetiredIamBinding,
|
|
45
|
+
type RuntimeEnvInputs,
|
|
46
|
+
type ServiceEnvInputs,
|
|
47
|
+
STATIC_CDN_POLICY,
|
|
48
|
+
stackIamInvariants,
|
|
49
|
+
staticBackendBucketPrefix,
|
|
50
|
+
} from "./stack-contract.js";
|
|
51
|
+
|
|
52
|
+
// ===========================================================================
|
|
53
|
+
// Env vars
|
|
54
|
+
// ===========================================================================
|
|
55
|
+
|
|
56
|
+
/** An env var as a deployed Cloud Run service carries it. */
|
|
57
|
+
export type LiveEnvValue =
|
|
58
|
+
| { readonly kind: "value"; readonly value: string }
|
|
59
|
+
| { readonly kind: "secret"; readonly secret: string; readonly version: string };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Env vars of the first container in `gcloud run services describe
|
|
63
|
+
* --format=json` output (Knative `serving.knative.dev/v1` Service).
|
|
64
|
+
*/
|
|
65
|
+
export function liveContainerEnv(serviceJson: string): Map<string, LiveEnvValue> {
|
|
66
|
+
const svc = JSON.parse(serviceJson) as {
|
|
67
|
+
spec?: {
|
|
68
|
+
template?: {
|
|
69
|
+
spec?: {
|
|
70
|
+
containers?: {
|
|
71
|
+
env?: {
|
|
72
|
+
name: string;
|
|
73
|
+
value?: string;
|
|
74
|
+
valueFrom?: { secretKeyRef?: { name: string; key: string } };
|
|
75
|
+
}[];
|
|
76
|
+
}[];
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
const env = svc.spec?.template?.spec?.containers?.[0]?.env ?? [];
|
|
82
|
+
const out = new Map<string, LiveEnvValue>();
|
|
83
|
+
for (const e of env) {
|
|
84
|
+
const ref = e.valueFrom?.secretKeyRef;
|
|
85
|
+
out.set(
|
|
86
|
+
e.name,
|
|
87
|
+
ref
|
|
88
|
+
? { kind: "secret", secret: secretIdOf(ref.name), version: ref.key }
|
|
89
|
+
: { kind: "value", value: e.value ?? "" },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `projects/<p>/secrets/<id>` → `<id>`; a bare id stays as it is. */
|
|
96
|
+
function secretIdOf(name: string): string {
|
|
97
|
+
return name.slice(name.lastIndexOf("/") + 1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The service account the service's revisions run as (`undefined`: the default compute SA). */
|
|
101
|
+
export function liveServiceAccount(serviceJson: string): string | undefined {
|
|
102
|
+
const svc = JSON.parse(serviceJson) as {
|
|
103
|
+
spec?: { template?: { spec?: { serviceAccountName?: string } } };
|
|
104
|
+
};
|
|
105
|
+
return svc.spec?.template?.spec?.serviceAccountName || undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface EnvChange {
|
|
109
|
+
readonly name: string;
|
|
110
|
+
/** `undefined` when the service does not have the var yet. */
|
|
111
|
+
readonly from: string | undefined;
|
|
112
|
+
/** `undefined` when the var is removed. */
|
|
113
|
+
readonly to: string | undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type EnvUpdatePlan =
|
|
117
|
+
| { readonly ok: true; readonly changes: EnvChange[]; readonly flags: string[] }
|
|
118
|
+
| { readonly ok: false; readonly error: string };
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A live value as upgrade prints it. Passwords in URLs are masked: before
|
|
122
|
+
* the move to Secret Manager the database URLs carried them inline.
|
|
123
|
+
*/
|
|
124
|
+
function describeLive(v: LiveEnvValue): string {
|
|
125
|
+
return v.kind === "value" ? maskUrlPassword(v.value) : `secret:${v.secret}:${v.version}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether a service still carries a URL with an inline password in a plain
|
|
130
|
+
* var — the pre-Secret Manager shape. Such a password is in every revision
|
|
131
|
+
* Cloud Run keeps, so it should be rotated once the services moved off it.
|
|
132
|
+
*/
|
|
133
|
+
export function liveEnvHasInlinePassword(liveEnv: ReadonlyMap<string, LiveEnvValue>): boolean {
|
|
134
|
+
return [...liveEnv.values()].some(
|
|
135
|
+
(v) => v.kind === "value" && maskUrlPassword(v.value) !== v.value,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function maskUrlPassword(value: string): string {
|
|
140
|
+
if (!value.includes("://")) return value;
|
|
141
|
+
try {
|
|
142
|
+
const url = new URL(value);
|
|
143
|
+
if (!url.password) return value;
|
|
144
|
+
url.password = "***";
|
|
145
|
+
return url.toString();
|
|
146
|
+
} catch {
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* gcloud's list flags split on `,`; a value containing one needs the
|
|
153
|
+
* `^<delim>^` escape syntax (`gcloud topic escaping`).
|
|
154
|
+
*/
|
|
155
|
+
function listFlag(flag: string, pairs: string[]): string {
|
|
156
|
+
const joined = pairs.join(",");
|
|
157
|
+
if (!pairs.some((p) => p.includes(","))) return `${flag}=${joined}`;
|
|
158
|
+
const delim = ["@", "|", "#", "~", ";"].find((d) => !pairs.some((p) => p.includes(d)));
|
|
159
|
+
if (!delim) throw new Error(`${flag}: no free delimiter for ${pairs.join(" ")}`);
|
|
160
|
+
return `${flag}=^${delim}^${pairs.join(delim)}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The `gcloud run services update` flags that bring `live` to `desired`.
|
|
165
|
+
* Only vars that differ are touched; vars the contract does not own
|
|
166
|
+
* (operator additions) are left alone, and the `retired` ones are removed.
|
|
167
|
+
*
|
|
168
|
+
* A plain var that becomes a Secret Manager reference moves in the same
|
|
169
|
+
* update: `--remove-env-vars=X --update-secrets=X=…`. gcloud applies all
|
|
170
|
+
* literal env changes (removals included) before the secret changes of one
|
|
171
|
+
* `services update` (googlecloudsdk/command_lib/run/flags.py
|
|
172
|
+
* `_GetConfigurationChanges`), so the new revision has X as a secret and no
|
|
173
|
+
* revision is ever without X. The reverse — a secret becoming a plain value
|
|
174
|
+
* — cannot be expressed in one update (the literal is set while X is still
|
|
175
|
+
* a secret, which gcloud rejects), so it is refused rather than attempted.
|
|
176
|
+
*/
|
|
177
|
+
export function planEnvUpdate(
|
|
178
|
+
live: ReadonlyMap<string, LiveEnvValue>,
|
|
179
|
+
desired: readonly CloudRunEnvVar[],
|
|
180
|
+
retired: readonly string[] = [],
|
|
181
|
+
): EnvUpdatePlan {
|
|
182
|
+
const changes: EnvChange[] = [];
|
|
183
|
+
const plain: string[] = [];
|
|
184
|
+
const secrets: string[] = [];
|
|
185
|
+
const removePlain: string[] = [];
|
|
186
|
+
const removeSecrets: string[] = [];
|
|
187
|
+
for (const entry of desired) {
|
|
188
|
+
const current = live.get(entry.name);
|
|
189
|
+
if ("value" in entry) {
|
|
190
|
+
if (current?.kind === "secret") {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
error: `${entry.name} is a Secret Manager reference on the service but a plain value in the contract`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (current?.value === entry.value) continue;
|
|
197
|
+
plain.push(`${entry.name}=${entry.value}`);
|
|
198
|
+
changes.push({
|
|
199
|
+
name: entry.name,
|
|
200
|
+
from: current ? describeLive(current) : undefined,
|
|
201
|
+
to: entry.value,
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
const { secret, version } = entry.valueSource.secretKeyRef;
|
|
205
|
+
if (current?.kind === "secret" && current.secret === secret && current.version === version) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// Plain → secret: drop the literal in the same update (see above).
|
|
209
|
+
// The old literal may be a secret value, so it is never printed.
|
|
210
|
+
if (current?.kind === "value") removePlain.push(entry.name);
|
|
211
|
+
secrets.push(`${entry.name}=${secret}:${version}`);
|
|
212
|
+
changes.push({
|
|
213
|
+
name: entry.name,
|
|
214
|
+
from:
|
|
215
|
+
current === undefined
|
|
216
|
+
? undefined
|
|
217
|
+
: current.kind === "value"
|
|
218
|
+
? "(plain value)"
|
|
219
|
+
: describeLive(current),
|
|
220
|
+
to: `secret:${secret}:${version}`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const name of retired) {
|
|
225
|
+
if (desired.some((e) => e.name === name)) {
|
|
226
|
+
return { ok: false, error: `${name} is both in the contract and retired` };
|
|
227
|
+
}
|
|
228
|
+
const current = live.get(name);
|
|
229
|
+
if (!current) continue;
|
|
230
|
+
(current.kind === "value" ? removePlain : removeSecrets).push(name);
|
|
231
|
+
// A retired var may hold a secret value (a plain KEK), so a literal is
|
|
232
|
+
// never printed.
|
|
233
|
+
changes.push({
|
|
234
|
+
name,
|
|
235
|
+
from: current.kind === "value" ? "(plain value)" : describeLive(current),
|
|
236
|
+
to: undefined,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const flags = [
|
|
240
|
+
...(removePlain.length > 0 ? [listFlag("--remove-env-vars", removePlain)] : []),
|
|
241
|
+
...(plain.length > 0 ? [listFlag("--update-env-vars", plain)] : []),
|
|
242
|
+
...(removeSecrets.length > 0 ? [listFlag("--remove-secrets", removeSecrets)] : []),
|
|
243
|
+
...(secrets.length > 0 ? [listFlag("--update-secrets", secrets)] : []),
|
|
244
|
+
];
|
|
245
|
+
return { ok: true, changes, flags };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A deployed Cloud Run service as upgrade found it. */
|
|
249
|
+
export interface DeployedService {
|
|
250
|
+
/** Pulumi-suffixed service name. */
|
|
251
|
+
readonly serviceName: string;
|
|
252
|
+
readonly liveEnv: ReadonlyMap<string, LiveEnvValue>;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The Cloud SQL host the admin connects to, from its live `ADMIN_DATABASE_URL`
|
|
257
|
+
* (with or without an inline password — before and after the move to Secret
|
|
258
|
+
* Manager).
|
|
259
|
+
*/
|
|
260
|
+
export function liveDatabaseHost(
|
|
261
|
+
liveEnv: ReadonlyMap<string, LiveEnvValue>,
|
|
262
|
+
): { ok: true; host: string } | { ok: false; error: string } {
|
|
263
|
+
const url = liveEnv.get("ADMIN_DATABASE_URL");
|
|
264
|
+
if (url?.kind !== "value" || !url.value) {
|
|
265
|
+
return { ok: false, error: "it has no plain ADMIN_DATABASE_URL" };
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const host = new URL(url.value).hostname;
|
|
269
|
+
if (host) return { ok: true, host };
|
|
270
|
+
} catch {
|
|
271
|
+
// falls through to the error below
|
|
272
|
+
}
|
|
273
|
+
return { ok: false, error: "its ADMIN_DATABASE_URL is not a valid URL" };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The env changes that bring both deployed services to their env contract
|
|
278
|
+
* (stack-contract.ts — the contract the stacks deploy). Values Pulumi
|
|
279
|
+
* generated at install time (database host, Firebase site) are read from the
|
|
280
|
+
* live services. Fails instead of guessing when one can't be determined.
|
|
281
|
+
*/
|
|
282
|
+
export function planContractEnv(
|
|
283
|
+
install: ServiceEnvInputs,
|
|
284
|
+
services: Readonly<Record<CloudRunSlug, DeployedService>>,
|
|
285
|
+
opts: {
|
|
286
|
+
/** Contract vars to leave exactly as the services have them. */
|
|
287
|
+
readonly leaveUntouched?: readonly string[];
|
|
288
|
+
} = {},
|
|
289
|
+
):
|
|
290
|
+
| {
|
|
291
|
+
readonly ok: true;
|
|
292
|
+
readonly services: Record<CloudRunSlug, { flags: string[]; changes: EnvChange[] }>;
|
|
293
|
+
}
|
|
294
|
+
| { readonly ok: false; readonly error: string } {
|
|
295
|
+
const host = liveDatabaseHost(services.admin.liveEnv);
|
|
296
|
+
if (!host.ok) {
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
error: `the Cloud SQL host is unknown: the admin service (${services.admin.serviceName}) ${host.error}`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const runtime: RuntimeEnvInputs = { ...install, databaseUrls: databaseUrls(host.host) };
|
|
303
|
+
let inputs: AdminEnvInputs;
|
|
304
|
+
if (install.provider === "gcp-firebase") {
|
|
305
|
+
// The site id carries a random suffix Pulumi generated at install time.
|
|
306
|
+
const site = services.admin.liveEnv.get("CAELO_FIREBASE_SITE");
|
|
307
|
+
if (site?.kind !== "value" || !site.value) {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
error: `the admin service has no CAELO_FIREBASE_SITE, so the Firebase Hosting site is unknown. Set it (gcloud run services update ${services.admin.serviceName} --update-env-vars=CAELO_FIREBASE_SITE=<site-id>) and re-run.`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
inputs = {
|
|
314
|
+
...runtime,
|
|
315
|
+
provider: "gcp-firebase",
|
|
316
|
+
firebaseSiteId: site.value,
|
|
317
|
+
gatewayService: services.gateway.serviceName,
|
|
318
|
+
};
|
|
319
|
+
} else {
|
|
320
|
+
inputs = { ...runtime, provider: "gcp" };
|
|
321
|
+
}
|
|
322
|
+
const skip = new Set(opts.leaveUntouched ?? []);
|
|
323
|
+
const owned = (contract: readonly CloudRunEnvVar[]) => contract.filter((e) => !skip.has(e.name));
|
|
324
|
+
const admin = planEnvUpdate(
|
|
325
|
+
services.admin.liveEnv,
|
|
326
|
+
owned(adminEnvContract(inputs)),
|
|
327
|
+
RETIRED_SERVICE_ENV.admin,
|
|
328
|
+
);
|
|
329
|
+
if (!admin.ok) return { ok: false, error: `admin: ${admin.error}` };
|
|
330
|
+
const gateway = planEnvUpdate(
|
|
331
|
+
services.gateway.liveEnv,
|
|
332
|
+
owned(gatewayEnvContract(runtime)),
|
|
333
|
+
RETIRED_SERVICE_ENV.gateway,
|
|
334
|
+
);
|
|
335
|
+
if (!gateway.ok) return { ok: false, error: `gateway: ${gateway.error}` };
|
|
336
|
+
return {
|
|
337
|
+
ok: true,
|
|
338
|
+
services: {
|
|
339
|
+
admin: { flags: admin.flags, changes: admin.changes },
|
|
340
|
+
gateway: { flags: gateway.flags, changes: gateway.changes },
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ===========================================================================
|
|
346
|
+
// IAM + CDN invariants
|
|
347
|
+
// ===========================================================================
|
|
348
|
+
|
|
349
|
+
/** The deployed install `ensureStackInvariants` works on. */
|
|
350
|
+
export interface InstallTarget {
|
|
351
|
+
readonly provider: GcpProvider;
|
|
352
|
+
readonly projectId: string;
|
|
353
|
+
readonly region: string;
|
|
354
|
+
/** Pulumi stack name (`production`). */
|
|
355
|
+
readonly env: string;
|
|
356
|
+
/** Deployed (Pulumi-suffixed) Cloud Run service names. */
|
|
357
|
+
readonly services: Readonly<Record<CloudRunSlug, string>>;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export interface InvariantOutcome {
|
|
361
|
+
/** Human-readable identity, e.g. `roles/run.viewer → serviceAccount:… on gateway`. */
|
|
362
|
+
readonly id: string;
|
|
363
|
+
readonly status: "present" | "applied" | "failed";
|
|
364
|
+
/** What a failure means for the upgrade (from the invariant). */
|
|
365
|
+
readonly onFailure: "abort" | "warn";
|
|
366
|
+
readonly why: string;
|
|
367
|
+
readonly error?: string;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export interface InvariantsReport {
|
|
371
|
+
readonly outcomes: InvariantOutcome[];
|
|
372
|
+
/** True when a failed invariant is one the install cannot work without. */
|
|
373
|
+
readonly mustAbort: boolean;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function targetLabel(target: IamTarget): string {
|
|
377
|
+
switch (target.kind) {
|
|
378
|
+
case "project":
|
|
379
|
+
return "project";
|
|
380
|
+
case "secret":
|
|
381
|
+
return `secret ${target.name}`;
|
|
382
|
+
case "bucket":
|
|
383
|
+
return `bucket ${target.bucket}`;
|
|
384
|
+
case "run-service":
|
|
385
|
+
return `${target.service} service`;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** gcloud argv (minus the trailing verb args) addressing an IAM target. */
|
|
390
|
+
function iamCommand(
|
|
391
|
+
target: IamTarget,
|
|
392
|
+
verb: "get-iam-policy" | "add-iam-policy-binding" | "remove-iam-policy-binding",
|
|
393
|
+
install: InstallTarget,
|
|
394
|
+
): string[] {
|
|
395
|
+
const project = `--project=${install.projectId}`;
|
|
396
|
+
switch (target.kind) {
|
|
397
|
+
case "project":
|
|
398
|
+
return ["projects", verb, install.projectId];
|
|
399
|
+
case "secret":
|
|
400
|
+
return ["secrets", verb, gcpSecretId(install.env, target.name), project];
|
|
401
|
+
case "bucket":
|
|
402
|
+
return [
|
|
403
|
+
"storage",
|
|
404
|
+
"buckets",
|
|
405
|
+
verb,
|
|
406
|
+
`gs://${gcpBucketName(install.projectId, install.env, target.bucket)}`,
|
|
407
|
+
project,
|
|
408
|
+
];
|
|
409
|
+
case "run-service":
|
|
410
|
+
return [
|
|
411
|
+
"run",
|
|
412
|
+
"services",
|
|
413
|
+
verb,
|
|
414
|
+
install.services[target.service],
|
|
415
|
+
`--region=${install.region}`,
|
|
416
|
+
project,
|
|
417
|
+
];
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Whether an IAM policy (JSON) grants `role` to `member` unconditionally. */
|
|
422
|
+
export function policyGrants(policyJson: string, role: string, member: string): boolean {
|
|
423
|
+
const policy = JSON.parse(policyJson) as {
|
|
424
|
+
bindings?: { role: string; members?: string[]; condition?: unknown }[];
|
|
425
|
+
};
|
|
426
|
+
return (policy.bindings ?? []).some(
|
|
427
|
+
(b) => b.role === role && !b.condition && (b.members ?? []).includes(member),
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** gcloud's error when the resource a binding targets does not exist. */
|
|
432
|
+
const RESOURCE_MISSING = /NOT_FOUND|not found|does not exist/i;
|
|
433
|
+
|
|
434
|
+
async function ensureIam(
|
|
435
|
+
invariants: readonly IamInvariant[],
|
|
436
|
+
install: InstallTarget,
|
|
437
|
+
projectNumber: string,
|
|
438
|
+
run: GcloudRunner,
|
|
439
|
+
sleep: Sleep,
|
|
440
|
+
): Promise<InvariantOutcome[]> {
|
|
441
|
+
const outcomes: InvariantOutcome[] = [];
|
|
442
|
+
// One policy read per resource; several invariants share a resource.
|
|
443
|
+
const policies = new Map<
|
|
444
|
+
string,
|
|
445
|
+
Promise<{ ok: true; json: string } | { ok: false; error: string }>
|
|
446
|
+
>();
|
|
447
|
+
const readPolicy = (target: IamTarget) => {
|
|
448
|
+
const args = [...iamCommand(target, "get-iam-policy", install), "--format=json"];
|
|
449
|
+
const key = args.join(" ");
|
|
450
|
+
let p = policies.get(key);
|
|
451
|
+
if (!p) {
|
|
452
|
+
p = run(args).then((r) =>
|
|
453
|
+
r.ok
|
|
454
|
+
? { ok: true as const, json: r.stdout }
|
|
455
|
+
: { ok: false as const, error: r.stderr.trim() },
|
|
456
|
+
);
|
|
457
|
+
policies.set(key, p);
|
|
458
|
+
}
|
|
459
|
+
return p;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
for (const inv of invariants) {
|
|
463
|
+
const member = iamMember(inv.member, { ...install, projectNumber });
|
|
464
|
+
const id = `${inv.role} → ${member} on ${targetLabel(inv.target)}`;
|
|
465
|
+
const base = { id, onFailure: inv.onFailure, why: inv.why };
|
|
466
|
+
const policy = await readPolicy(inv.target);
|
|
467
|
+
if (policy.ok && policyGrants(policy.json, inv.role, member)) {
|
|
468
|
+
outcomes.push({ ...base, status: "present" });
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
// Policy unreadable or binding missing: add it. `--condition=None` only
|
|
472
|
+
// where gcloud prompts for it (project policies with conditional bindings).
|
|
473
|
+
const add = await runWithRetry(run, sleep, [
|
|
474
|
+
...iamCommand(inv.target, "add-iam-policy-binding", install),
|
|
475
|
+
`--member=${member}`,
|
|
476
|
+
`--role=${inv.role}`,
|
|
477
|
+
...(inv.target.kind === "project" ? ["--condition=None"] : []),
|
|
478
|
+
"--quiet",
|
|
479
|
+
"--format=none",
|
|
480
|
+
]);
|
|
481
|
+
if (add.ok) {
|
|
482
|
+
outcomes.push({ ...base, status: "applied" });
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const error = policy.ok ? add.stderr.trim() : `${policy.error}; ${add.stderr.trim()}`;
|
|
486
|
+
outcomes.push({
|
|
487
|
+
...base,
|
|
488
|
+
status: "failed",
|
|
489
|
+
// upgrade only adds bindings; it never creates the resource itself.
|
|
490
|
+
error: RESOURCE_MISSING.test(error)
|
|
491
|
+
? `${error}\n The ${targetLabel(inv.target)} or the account it grants to does not exist on this install (the install predates it). Re-run the installer (${selfCommand()}) to create it — it keeps the release this install runs — then re-run upgrade.`
|
|
492
|
+
: error,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
return outcomes;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Bring the `gcp` stack's static backend bucket to {@link STATIC_CDN_POLICY}. */
|
|
499
|
+
async function ensureCdnPolicy(
|
|
500
|
+
install: InstallTarget,
|
|
501
|
+
run: GcloudRunner,
|
|
502
|
+
): Promise<InvariantOutcome> {
|
|
503
|
+
const base = {
|
|
504
|
+
id: `Cloud CDN policy of ${staticBackendBucketPrefix(install.env)}*`,
|
|
505
|
+
onFailure: "warn" as const,
|
|
506
|
+
why: "immutable assets stay cached for a year instead of 1h (#555)",
|
|
507
|
+
};
|
|
508
|
+
const project = `--project=${install.projectId}`;
|
|
509
|
+
const list = await run([
|
|
510
|
+
"compute",
|
|
511
|
+
"backend-buckets",
|
|
512
|
+
"list",
|
|
513
|
+
project,
|
|
514
|
+
`--filter=name~^${staticBackendBucketPrefix(install.env)}`,
|
|
515
|
+
"--format=value(name)",
|
|
516
|
+
]);
|
|
517
|
+
const name = list.ok ? list.stdout.trim().split("\n")[0]?.trim() : "";
|
|
518
|
+
if (!name) {
|
|
519
|
+
return {
|
|
520
|
+
...base,
|
|
521
|
+
status: "failed",
|
|
522
|
+
error: list.ok ? "static backend bucket not found" : list.stderr.trim(),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
const describe = await run([
|
|
526
|
+
"compute",
|
|
527
|
+
"backend-buckets",
|
|
528
|
+
"describe",
|
|
529
|
+
name,
|
|
530
|
+
project,
|
|
531
|
+
"--format=json",
|
|
532
|
+
]);
|
|
533
|
+
if (describe.ok) {
|
|
534
|
+
const cdn = (JSON.parse(describe.stdout) as { cdnPolicy?: Record<string, unknown> }).cdnPolicy;
|
|
535
|
+
const inShape =
|
|
536
|
+
cdn !== undefined &&
|
|
537
|
+
(Object.keys(STATIC_CDN_POLICY) as (keyof typeof STATIC_CDN_POLICY)[]).every(
|
|
538
|
+
(k) => String(cdn[k]) === String(STATIC_CDN_POLICY[k]),
|
|
539
|
+
);
|
|
540
|
+
if (inShape) return { ...base, status: "present" };
|
|
541
|
+
}
|
|
542
|
+
const update = await run([
|
|
543
|
+
"compute",
|
|
544
|
+
"backend-buckets",
|
|
545
|
+
"update",
|
|
546
|
+
name,
|
|
547
|
+
project,
|
|
548
|
+
`--cache-mode=${STATIC_CDN_POLICY.cacheMode}`,
|
|
549
|
+
`--default-ttl=${STATIC_CDN_POLICY.defaultTtl}`,
|
|
550
|
+
`--max-ttl=${STATIC_CDN_POLICY.maxTtl}`,
|
|
551
|
+
`--client-ttl=${STATIC_CDN_POLICY.clientTtl}`,
|
|
552
|
+
"--quiet",
|
|
553
|
+
]);
|
|
554
|
+
return update.ok
|
|
555
|
+
? { ...base, status: "applied" }
|
|
556
|
+
: { ...base, status: "failed", error: update.stderr.trim() };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Idempotently ensure every IAM binding and CDN setting the install's stack
|
|
561
|
+
* declares (stack-contract.ts), and that the firewall rules GCP pre-populates
|
|
562
|
+
* on the default network are gone (ABSENT_DEFAULT_FIREWALL_RULES). Bindings
|
|
563
|
+
* are only added, never removed; those firewall rules are the only deletion. Never
|
|
564
|
+
* throws for a gcloud failure; the report says what failed and whether the
|
|
565
|
+
* upgrade must stop.
|
|
566
|
+
*/
|
|
567
|
+
export async function ensureStackInvariants(
|
|
568
|
+
install: InstallTarget,
|
|
569
|
+
deps: { run?: GcloudRunner; sleep?: Sleep } = {},
|
|
570
|
+
): Promise<InvariantsReport> {
|
|
571
|
+
const run = deps.run ?? defaultGcloud;
|
|
572
|
+
const sleep = deps.sleep ?? realSleep;
|
|
573
|
+
const invariants = stackIamInvariants(install.provider);
|
|
574
|
+
|
|
575
|
+
const outcomes: InvariantOutcome[] = [];
|
|
576
|
+
let projectNumber = "";
|
|
577
|
+
if (invariants.some((i) => i.member === "iap-service-agent")) {
|
|
578
|
+
const r = await run([
|
|
579
|
+
"projects",
|
|
580
|
+
"describe",
|
|
581
|
+
install.projectId,
|
|
582
|
+
"--format=value(projectNumber)",
|
|
583
|
+
]);
|
|
584
|
+
projectNumber = r.ok ? r.stdout.trim() : "";
|
|
585
|
+
if (!projectNumber) {
|
|
586
|
+
// Without the project number the IAP agent's binding can't be checked;
|
|
587
|
+
// report each of those invariants as failed instead of guessing.
|
|
588
|
+
for (const inv of invariants.filter((i) => i.member === "iap-service-agent")) {
|
|
589
|
+
outcomes.push({
|
|
590
|
+
id: `${inv.role} → IAP service agent on ${targetLabel(inv.target)}`,
|
|
591
|
+
status: "failed",
|
|
592
|
+
onFailure: inv.onFailure,
|
|
593
|
+
why: inv.why,
|
|
594
|
+
error: `read project number: ${r.stderr.trim() || "empty"}`,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const checkable = projectNumber
|
|
600
|
+
? invariants
|
|
601
|
+
: invariants.filter((i) => i.member !== "iap-service-agent");
|
|
602
|
+
outcomes.push(...(await ensureIam(checkable, install, projectNumber, run, sleep)));
|
|
603
|
+
if (install.provider === "gcp") outcomes.push(await ensureCdnPolicy(install, run));
|
|
604
|
+
outcomes.push(...(await removeDefaultFirewallRules(install.projectId, { run })));
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
outcomes,
|
|
608
|
+
mustAbort: outcomes.some((o) => o.status === "failed" && o.onFailure === "abort"),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** What happened to one retired binding. */
|
|
613
|
+
export interface RetiredBindingOutcome {
|
|
614
|
+
readonly id: string;
|
|
615
|
+
/** `absent`: nothing to remove. */
|
|
616
|
+
readonly status: "absent" | "removed" | "failed";
|
|
617
|
+
readonly why: string;
|
|
618
|
+
readonly error?: string;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Remove the bindings older stacks declared and the contract retired
|
|
623
|
+
* ({@link RETIRED_IAM_BINDINGS}) — run by upgrade AFTER the services rolled
|
|
624
|
+
* off them. Checks the policy first, so an operator without IAM-admin
|
|
625
|
+
* rights can still upgrade an install that is already clean. Never throws
|
|
626
|
+
* for a gcloud failure; the caller decides how loudly to report it.
|
|
627
|
+
*/
|
|
628
|
+
export async function removeRetiredIamBindings(
|
|
629
|
+
install: InstallTarget,
|
|
630
|
+
deps: { run?: GcloudRunner; sleep?: Sleep; retired?: readonly RetiredIamBinding[] } = {},
|
|
631
|
+
): Promise<RetiredBindingOutcome[]> {
|
|
632
|
+
const run = deps.run ?? defaultGcloud;
|
|
633
|
+
const sleep = deps.sleep ?? realSleep;
|
|
634
|
+
const outcomes: RetiredBindingOutcome[] = [];
|
|
635
|
+
for (const b of deps.retired ?? RETIRED_IAM_BINDINGS) {
|
|
636
|
+
// Retired bindings never name the IAP agent, so no project number.
|
|
637
|
+
const member = iamMember(b.member, { ...install, projectNumber: "" });
|
|
638
|
+
const id = `${b.role} → ${member} on ${targetLabel(b.target)}`;
|
|
639
|
+
const policy = await run([...iamCommand(b.target, "get-iam-policy", install), "--format=json"]);
|
|
640
|
+
if (policy.ok && !policyGrants(policy.stdout, b.role, member)) {
|
|
641
|
+
outcomes.push({ id, status: "absent", why: b.why });
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (!policy.ok && RESOURCE_MISSING.test(policy.stderr)) {
|
|
645
|
+
outcomes.push({ id, status: "absent", why: b.why });
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
const remove = await runWithRetry(run, sleep, [
|
|
649
|
+
...iamCommand(b.target, "remove-iam-policy-binding", install),
|
|
650
|
+
`--member=${member}`,
|
|
651
|
+
`--role=${b.role}`,
|
|
652
|
+
...(b.target.kind === "project" ? ["--condition=None"] : []),
|
|
653
|
+
"--quiet",
|
|
654
|
+
"--format=none",
|
|
655
|
+
]);
|
|
656
|
+
outcomes.push(
|
|
657
|
+
remove.ok
|
|
658
|
+
? { id, status: "removed", why: b.why }
|
|
659
|
+
: { id, status: "failed", why: b.why, error: remove.stderr.trim() },
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
return outcomes;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** The memory limit of the service's container (`512Mi`, `2Gi`), or null
|
|
666
|
+
* when the service declares none (Cloud Run then runs it with 512Mi). */
|
|
667
|
+
export function liveContainerMemory(serviceJson: string): string | null {
|
|
668
|
+
const svc = JSON.parse(serviceJson) as {
|
|
669
|
+
spec?: {
|
|
670
|
+
template?: {
|
|
671
|
+
spec?: { containers?: { resources?: { limits?: { memory?: string } } }[] };
|
|
672
|
+
};
|
|
673
|
+
};
|
|
674
|
+
};
|
|
675
|
+
return svc.spec?.template?.spec?.containers?.[0]?.resources?.limits?.memory ?? null;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Cloud Run's memory when a service declares no limit. */
|
|
679
|
+
const CLOUD_RUN_DEFAULT_MEMORY = "512Mi";
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* #553 — raise the admin's memory to the stack's default (it runs the
|
|
683
|
+
* Lighthouse quality audit). Upgrade only ever RAISES: an operator who set
|
|
684
|
+
* more keeps it, and a value this contract cannot parse is left alone and
|
|
685
|
+
* reported, never overwritten blindly.
|
|
686
|
+
*/
|
|
687
|
+
export function planAdminMemory(
|
|
688
|
+
live: string | null,
|
|
689
|
+
desired: string = ADMIN_MEMORY_DEFAULT,
|
|
690
|
+
):
|
|
691
|
+
| { readonly ok: true; readonly flags: readonly string[]; readonly from: string | null }
|
|
692
|
+
| { readonly ok: false; readonly error: string } {
|
|
693
|
+
const desiredMiB = memoryQuantityMiB(desired);
|
|
694
|
+
if (desiredMiB === null) return { ok: false, error: `invalid desired memory "${desired}"` };
|
|
695
|
+
const liveMiB = memoryQuantityMiB(live ?? CLOUD_RUN_DEFAULT_MEMORY);
|
|
696
|
+
if (liveMiB === null) {
|
|
697
|
+
return {
|
|
698
|
+
ok: false,
|
|
699
|
+
error: `the admin's memory "${live}" is not a quantity upgrade understands`,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
if (liveMiB >= desiredMiB) return { ok: true, flags: [], from: live };
|
|
703
|
+
return { ok: true, flags: [`--memory=${desired}`], from: live };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ===========================================================================
|
|
707
|
+
// Media volume
|
|
708
|
+
// ===========================================================================
|
|
709
|
+
|
|
710
|
+
/** The CSI driver Cloud Run reports for a Cloud Storage (gcsfuse) volume. */
|
|
711
|
+
const GCSFUSE_DRIVER = "gcsfuse.run.googleapis.com";
|
|
712
|
+
const EXECUTION_ENVIRONMENT_ANNOTATION = "run.googleapis.com/execution-environment";
|
|
713
|
+
|
|
714
|
+
interface KnativeVolume {
|
|
715
|
+
readonly name: string;
|
|
716
|
+
readonly csi?: {
|
|
717
|
+
readonly driver?: string;
|
|
718
|
+
readonly readOnly?: boolean;
|
|
719
|
+
readonly volumeAttributes?: { readonly bucketName?: string; readonly mountOptions?: string };
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
interface KnativeVolumeMount {
|
|
724
|
+
readonly name: string;
|
|
725
|
+
readonly mountPath: string;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** The volume-related parts of a `gcloud run services describe --format=json` service. */
|
|
729
|
+
export interface LiveVolumes {
|
|
730
|
+
/** `gen1`, `gen2`, or null when the service leaves it to Cloud Run. */
|
|
731
|
+
readonly executionEnvironment: string | null;
|
|
732
|
+
readonly volumes: readonly KnativeVolume[];
|
|
733
|
+
/** Mounts of the first container. */
|
|
734
|
+
readonly mounts: readonly KnativeVolumeMount[];
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Read the execution environment, volumes and mounts of a described service. */
|
|
738
|
+
export function liveVolumes(serviceJson: string): LiveVolumes {
|
|
739
|
+
const svc = JSON.parse(serviceJson) as {
|
|
740
|
+
spec?: {
|
|
741
|
+
template?: {
|
|
742
|
+
metadata?: { annotations?: Record<string, string> };
|
|
743
|
+
spec?: {
|
|
744
|
+
volumes?: KnativeVolume[];
|
|
745
|
+
containers?: { volumeMounts?: KnativeVolumeMount[] }[];
|
|
746
|
+
};
|
|
747
|
+
};
|
|
748
|
+
};
|
|
749
|
+
};
|
|
750
|
+
const template = svc.spec?.template;
|
|
751
|
+
return {
|
|
752
|
+
executionEnvironment:
|
|
753
|
+
template?.metadata?.annotations?.[EXECUTION_ENVIRONMENT_ANNOTATION] ?? null,
|
|
754
|
+
volumes: template?.spec?.volumes ?? [],
|
|
755
|
+
mounts: template?.spec?.containers?.[0]?.volumeMounts ?? [],
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export type MediaVolumePlan =
|
|
760
|
+
| {
|
|
761
|
+
readonly ok: true;
|
|
762
|
+
/** Flags for the roll's `services update`; empty when converged. */
|
|
763
|
+
readonly flags: string[];
|
|
764
|
+
/** One line per change, for the upgrade log. */
|
|
765
|
+
readonly changes: string[];
|
|
766
|
+
}
|
|
767
|
+
| { readonly ok: false; readonly error: string };
|
|
768
|
+
|
|
769
|
+
function describeVolume(v: KnativeVolume): string {
|
|
770
|
+
if (v.csi?.driver === GCSFUSE_DRIVER) {
|
|
771
|
+
return `Cloud Storage volume "${v.name}" of bucket ${v.csi.volumeAttributes?.bucketName ?? "(none)"}${v.csi.readOnly ? " (read-only)" : ""}`;
|
|
772
|
+
}
|
|
773
|
+
return `volume "${v.name}" (not a Cloud Storage volume)`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* The flags that give the admin service its media volume
|
|
778
|
+
* ({@link adminMediaVolume} in stack-contract.ts), adding only what is
|
|
779
|
+
* missing: the gen2 execution environment, the volume, the mount. A service
|
|
780
|
+
* that already has exactly that volume and mount — from the stack, an
|
|
781
|
+
* earlier upgrade, or an operator's manual
|
|
782
|
+
* `gcloud run services update … --execution-environment gen2
|
|
783
|
+
* --add-volume name=media,type=cloud-storage,bucket=<bucket>
|
|
784
|
+
* --add-volume-mount volume=media,mount-path=<path>` — is converged and
|
|
785
|
+
* gets no volume flags. Mount options on that volume are left as they are.
|
|
786
|
+
*
|
|
787
|
+
* Something else at that name or path (another bucket, a read-only or
|
|
788
|
+
* non-Cloud-Storage volume, the media volume mounted elsewhere) is refused
|
|
789
|
+
* with what was found instead of being replaced: replacing it could point
|
|
790
|
+
* the admin at different media than it has been serving.
|
|
791
|
+
*/
|
|
792
|
+
export function planMediaVolume(live: LiveVolumes, desired: MediaVolumeContract): MediaVolumePlan {
|
|
793
|
+
const flags: string[] = [];
|
|
794
|
+
const changes: string[] = [];
|
|
795
|
+
const fix = `Remove it (gcloud run services update <admin-service> --remove-volume-mount=${desired.mountPath} --remove-volume=${desired.volumeName}) only if it is not the media you mean to keep, then re-run upgrade.`;
|
|
796
|
+
|
|
797
|
+
const volume = live.volumes.find((v) => v.name === desired.volumeName);
|
|
798
|
+
if (volume) {
|
|
799
|
+
const inShape =
|
|
800
|
+
volume.csi?.driver === GCSFUSE_DRIVER &&
|
|
801
|
+
volume.csi.volumeAttributes?.bucketName === desired.bucket &&
|
|
802
|
+
volume.csi.readOnly !== true;
|
|
803
|
+
if (!inShape) {
|
|
804
|
+
return {
|
|
805
|
+
ok: false,
|
|
806
|
+
error: `the admin has ${describeVolume(volume)}, but media must be the read-write Cloud Storage volume of bucket ${desired.bucket}. ${fix}`,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const atPath = live.mounts.find((m) => m.mountPath === desired.mountPath);
|
|
811
|
+
if (atPath && atPath.name !== desired.volumeName) {
|
|
812
|
+
return {
|
|
813
|
+
ok: false,
|
|
814
|
+
error: `the admin mounts volume "${atPath.name}" at ${desired.mountPath}, where the media bucket belongs. ${fix}`,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
const elsewhere = live.mounts.find(
|
|
818
|
+
(m) => m.name === desired.volumeName && m.mountPath !== desired.mountPath,
|
|
819
|
+
);
|
|
820
|
+
if (elsewhere && !atPath) {
|
|
821
|
+
return {
|
|
822
|
+
ok: false,
|
|
823
|
+
error: `the admin mounts the media volume at ${elsewhere.mountPath}, but the admin reads media from ${desired.mountPath}. ${fix}`,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (live.executionEnvironment !== "gen2") {
|
|
828
|
+
flags.push("--execution-environment=gen2");
|
|
829
|
+
changes.push(
|
|
830
|
+
`execution environment: ${live.executionEnvironment ?? "(default)"} → gen2 (Cloud Storage volumes need it)`,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
if (!volume) {
|
|
834
|
+
flags.push(
|
|
835
|
+
`--add-volume=name=${desired.volumeName},type=cloud-storage,bucket=${desired.bucket}`,
|
|
836
|
+
);
|
|
837
|
+
changes.push(`volume ${desired.volumeName}: (none) → Cloud Storage bucket ${desired.bucket}`);
|
|
838
|
+
}
|
|
839
|
+
if (!atPath) {
|
|
840
|
+
flags.push(`--add-volume-mount=volume=${desired.volumeName},mount-path=${desired.mountPath}`);
|
|
841
|
+
changes.push(`volume mount: (none) → ${desired.volumeName} at ${desired.mountPath}`);
|
|
842
|
+
}
|
|
843
|
+
return { ok: true, flags, changes };
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* The single `gcloud run services update` that rolls a service to a new
|
|
848
|
+
* image, its run SA and its env changes, so all land in one new revision.
|
|
849
|
+
*/
|
|
850
|
+
export function serviceRollArgs(roll: {
|
|
851
|
+
readonly serviceName: string;
|
|
852
|
+
readonly region: string;
|
|
853
|
+
readonly projectId: string;
|
|
854
|
+
readonly imageRef: string;
|
|
855
|
+
/** The SA the new revision runs as (stack-contract.ts per-service SAs). */
|
|
856
|
+
readonly serviceAccount: string;
|
|
857
|
+
readonly envFlags: readonly string[];
|
|
858
|
+
/** Resource changes (`--memory=…`) that ride the same revision. */
|
|
859
|
+
readonly resourceFlags?: readonly string[];
|
|
860
|
+
/** The admin's media volume ({@link planMediaVolume}), in the same revision. */
|
|
861
|
+
readonly volumeFlags?: readonly string[];
|
|
862
|
+
}): string[] {
|
|
863
|
+
return [
|
|
864
|
+
"run",
|
|
865
|
+
"services",
|
|
866
|
+
"update",
|
|
867
|
+
roll.serviceName,
|
|
868
|
+
"--region",
|
|
869
|
+
roll.region,
|
|
870
|
+
"--project",
|
|
871
|
+
roll.projectId,
|
|
872
|
+
"--image",
|
|
873
|
+
roll.imageRef,
|
|
874
|
+
`--service-account=${roll.serviceAccount}`,
|
|
875
|
+
...roll.envFlags,
|
|
876
|
+
...(roll.resourceFlags ?? []),
|
|
877
|
+
...(roll.volumeFlags ?? []),
|
|
878
|
+
"--quiet",
|
|
879
|
+
];
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Cloud Run checks at deploy time that the revision's SA can read every
|
|
884
|
+
* secret it references. A `secretAccessor` binding upgrade added seconds
|
|
885
|
+
* earlier (a new secret, the new gateway SA) can take a while to reach that
|
|
886
|
+
* check, so the deploy fails with a permission error that resolves itself.
|
|
887
|
+
*/
|
|
888
|
+
const SECRET_ACCESS_PROPAGATING = /Permission denied on secret|secretmanager\.versions\.access/i;
|
|
889
|
+
const ROLL_RETRY_DELAYS_MS: readonly number[] = [10_000, 20_000, 30_000, 60_000];
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Run a roll (`serviceRollArgs`), retrying while it fails only because a
|
|
893
|
+
* fresh secret binding has not propagated yet. Any other failure returns at
|
|
894
|
+
* once.
|
|
895
|
+
*/
|
|
896
|
+
export async function rollService(
|
|
897
|
+
args: string[],
|
|
898
|
+
deps: { run?: GcloudRunner; sleep?: Sleep } = {},
|
|
899
|
+
): Promise<GcloudResult> {
|
|
900
|
+
const run = deps.run ?? defaultGcloud;
|
|
901
|
+
const sleep = deps.sleep ?? realSleep;
|
|
902
|
+
let r = await run(args);
|
|
903
|
+
for (const delay of ROLL_RETRY_DELAYS_MS) {
|
|
904
|
+
if (r.ok || !SECRET_ACCESS_PROPAGATING.test(r.stderr)) break;
|
|
905
|
+
await sleep(delay);
|
|
906
|
+
r = await run(args);
|
|
907
|
+
}
|
|
908
|
+
return r;
|
|
909
|
+
}
|