@caelo-cms/provisioning 0.10.61 → 0.10.64
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 +7 -0
- package/caddy/Caddyfile.staging +7 -0
- package/dist/admin-internal-answer.d.ts +12 -0
- package/dist/admin-internal-answer.d.ts.map +1 -0
- package/dist/admin-internal-answer.js +32 -0
- package/dist/admin-internal-answer.js.map +1 -0
- package/dist/admin-internal-google.d.ts +4 -0
- package/dist/admin-internal-google.d.ts.map +1 -0
- package/dist/admin-internal-google.js +161 -0
- package/dist/admin-internal-google.js.map +1 -0
- package/dist/admin-internal-self-hosted.d.ts +30 -0
- package/dist/admin-internal-self-hosted.d.ts.map +1 -0
- package/dist/admin-internal-self-hosted.js +92 -0
- package/dist/admin-internal-self-hosted.js.map +1 -0
- package/dist/admin-internal.d.ts +33 -12
- package/dist/admin-internal.d.ts.map +1 -1
- package/dist/admin-internal.js +27 -159
- package/dist/admin-internal.js.map +1 -1
- package/dist/agent/command.d.ts +7 -0
- package/dist/agent/command.d.ts.map +1 -1
- package/dist/agent/command.js +1 -0
- package/dist/agent/command.js.map +1 -1
- package/dist/agent/lock.d.ts +13 -1
- package/dist/agent/lock.d.ts.map +1 -1
- package/dist/agent/lock.js +62 -5
- package/dist/agent/lock.js.map +1 -1
- package/dist/agent/run.d.ts +5 -1
- package/dist/agent/run.d.ts.map +1 -1
- package/dist/agent/run.js +8 -1
- package/dist/agent/run.js.map +1 -1
- package/dist/agent-db.js +192 -90
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +11 -1
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +95 -38
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +7 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +5 -0
- package/dist/compose.js.map +1 -1
- package/dist/converge-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +10 -7
- package/dist/converge-job-gcp.js.map +1 -1
- package/dist/converge-job-preflight.d.ts +21 -0
- package/dist/converge-job-preflight.d.ts.map +1 -0
- package/dist/converge-job-preflight.js +111 -0
- package/dist/converge-job-preflight.js.map +1 -0
- package/dist/converge-job.d.ts +8 -2
- package/dist/converge-job.d.ts.map +1 -1
- package/dist/converge-job.js +14 -0
- package/dist/converge-job.js.map +1 -1
- package/dist/gateway-credentials.d.ts +5 -3
- package/dist/gateway-credentials.d.ts.map +1 -1
- package/dist/gateway-credentials.js +5 -8
- package/dist/gateway-credentials.js.map +1 -1
- package/dist/gcloud.d.ts +29 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +66 -0
- package/dist/gcloud.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 +10 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +3 -1
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js.map +1 -1
- package/dist/install-state.d.ts +10 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +12 -0
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts +12 -0
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +113 -28
- package/dist/lifecycle.js.map +1 -1
- package/dist/mcp-iap.d.ts +2 -0
- package/dist/mcp-iap.d.ts.map +1 -1
- package/dist/mcp-iap.js +50 -17
- package/dist/mcp-iap.js.map +1 -1
- package/dist/owner-reset.d.ts +16 -19
- package/dist/owner-reset.d.ts.map +1 -1
- package/dist/owner-reset.js +92 -19
- package/dist/owner-reset.js.map +1 -1
- package/dist/preview-plan.d.ts +71 -0
- package/dist/preview-plan.d.ts.map +1 -0
- package/dist/preview-plan.js +168 -0
- package/dist/preview-plan.js.map +1 -0
- package/dist/public-role-isolation.d.ts +2 -0
- package/dist/public-role-isolation.d.ts.map +1 -1
- package/dist/public-role-isolation.js +9 -3
- package/dist/public-role-isolation.js.map +1 -1
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +4 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/self-hosted-internal-secret.d.ts +27 -0
- package/dist/self-hosted-internal-secret.d.ts.map +1 -0
- package/dist/self-hosted-internal-secret.js +69 -0
- package/dist/self-hosted-internal-secret.js.map +1 -0
- package/dist/self-hosted-release.d.ts +3 -1
- package/dist/self-hosted-release.d.ts.map +1 -1
- package/dist/self-hosted-release.js +6 -2
- package/dist/self-hosted-release.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +13 -1
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +26 -2
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-converge.d.ts +5 -2
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +73 -8
- package/dist/stack-converge.js.map +1 -1
- package/dist/staging-admin.d.ts +4 -1
- package/dist/staging-admin.d.ts.map +1 -1
- package/dist/staging-admin.js.map +1 -1
- package/dist/state-backend.d.ts +3 -2
- package/dist/state-backend.d.ts.map +1 -1
- package/dist/state-backend.js +3 -2
- package/dist/state-backend.js.map +1 -1
- package/dist/state-command.d.ts +17 -0
- package/dist/state-command.d.ts.map +1 -1
- package/dist/state-command.js +34 -5
- package/dist/state-command.js.map +1 -1
- package/dist/state-migrate.d.ts +14 -3
- package/dist/state-migrate.d.ts.map +1 -1
- package/dist/state-migrate.js +31 -7
- package/dist/state-migrate.js.map +1 -1
- package/dist/state-pulumi.d.ts +0 -2
- package/dist/state-pulumi.d.ts.map +1 -1
- package/dist/state-pulumi.js +19 -20
- package/dist/state-pulumi.js.map +1 -1
- package/dist/static-security-headers.d.ts +86 -0
- package/dist/static-security-headers.d.ts.map +1 -0
- package/dist/static-security-headers.js +126 -0
- package/dist/static-security-headers.js.map +1 -0
- package/dist/static-svg-gcp.d.ts +28 -0
- package/dist/static-svg-gcp.d.ts.map +1 -0
- package/dist/static-svg-gcp.js +106 -0
- package/dist/static-svg-gcp.js.map +1 -0
- package/dist/upgrade/app-roll.d.ts +3 -0
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +12 -5
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/backup.d.ts +2 -0
- package/dist/upgrade/backup.d.ts.map +1 -1
- package/dist/upgrade/backup.js +5 -4
- package/dist/upgrade/backup.js.map +1 -1
- package/dist/upgrade/converge.d.ts +7 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +19 -7
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +38 -3
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +31 -9
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/health.d.ts +31 -5
- package/dist/upgrade/health.d.ts.map +1 -1
- package/dist/upgrade/health.js +114 -25
- package/dist/upgrade/health.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +17 -0
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +23 -12
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/reporter.d.ts +2 -0
- package/dist/upgrade/reporter.d.ts.map +1 -1
- package/dist/upgrade/reporter.js +1 -0
- package/dist/upgrade/reporter.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +11 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +5 -2
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +12 -2
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +22 -13
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts +16 -3
- package/dist/upgrader-iam-preflight.d.ts.map +1 -1
- package/dist/upgrader-iam-preflight.js +9 -4
- package/dist/upgrader-iam-preflight.js.map +1 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +10 -8
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-reporter.d.ts.map +1 -1
- package/dist/upgrader-reporter.js +1 -0
- package/dist/upgrader-reporter.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +70 -23
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +118 -39
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +67 -11
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/admin-internal-answer.ts +43 -0
- package/src/admin-internal-google.ts +187 -0
- package/src/admin-internal-self-hosted.test.ts +70 -0
- package/src/admin-internal-self-hosted.ts +128 -0
- package/src/admin-internal.ts +59 -174
- package/src/admin-memory.test.ts +1 -0
- package/src/agent/command.ts +3 -0
- package/src/agent/lock-config.test.ts +57 -2
- package/src/agent/lock.ts +68 -5
- package/src/agent/run.test.ts +18 -1
- package/src/agent/run.ts +17 -6
- package/src/caddy-redirects.test.ts +1 -0
- package/src/caddy.ts +12 -0
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +102 -44
- package/src/compose.ts +12 -0
- package/src/converge-job-gcp.ts +16 -11
- package/src/converge-job-preflight.test.ts +108 -0
- package/src/converge-job-preflight.ts +124 -0
- package/src/converge-job.test.ts +50 -0
- package/src/converge-job.ts +38 -2
- package/src/gateway-credentials.test.ts +4 -3
- package/src/gateway-credentials.ts +6 -10
- package/src/gateway-env-isolation.test.ts +1 -0
- package/src/gcloud.test.ts +93 -1
- package/src/gcloud.ts +82 -0
- package/src/index.test.ts +38 -1
- package/src/infra-contract.test.ts +4 -3
- package/src/infra-contract.ts +10 -1
- package/src/install-job.ts +5 -2
- package/src/install-state.ts +13 -0
- package/src/lifecycle.ts +134 -28
- package/src/mcp-iap.test.ts +66 -5
- package/src/mcp-iap.ts +55 -17
- package/src/owner-reset-providers.test.ts +370 -0
- package/src/owner-reset.test.ts +2 -2
- package/src/owner-reset.ts +108 -19
- package/src/preview-plan.test.ts +233 -0
- package/src/preview-plan.ts +226 -0
- package/src/public-role-isolation.test.ts +13 -2
- package/src/public-role-isolation.ts +14 -4
- package/src/release-contract.ts +4 -0
- package/src/self-hosted-compose.test.ts +25 -0
- package/src/self-hosted-internal-secret.test.ts +61 -0
- package/src/self-hosted-internal-secret.ts +80 -0
- package/src/self-hosted-release.ts +10 -3
- package/src/self-hosted-upgrade.test.ts +35 -1
- package/src/self-hosted-upgrade.ts +40 -3
- package/src/stack-contract.test.ts +1 -0
- package/src/stack-converge.test.ts +89 -5
- package/src/stack-converge.ts +81 -8
- package/src/staging-admin.test.ts +1 -1
- package/src/staging-admin.ts +2 -1
- package/src/state-backend.ts +3 -2
- package/src/state-command.test.ts +31 -0
- package/src/state-command.ts +35 -8
- package/src/state-migrate.test.ts +58 -7
- package/src/state-migrate.ts +48 -10
- package/src/state-pulumi.ts +20 -21
- package/src/static-security-headers.test.ts +124 -0
- package/src/static-security-headers.ts +154 -0
- package/src/static-svg-gcp.test.ts +133 -0
- package/src/static-svg-gcp.ts +140 -0
- package/src/upgrade/app-roll.ts +18 -7
- package/src/upgrade/backup.ts +14 -5
- package/src/upgrade/converge.ts +25 -7
- package/src/upgrade/gcp-lookup.test.ts +80 -1
- package/src/upgrade/gcp-lookup.ts +61 -11
- package/src/upgrade/health.test.ts +132 -8
- package/src/upgrade/health.ts +169 -31
- package/src/upgrade/release-images.test.ts +17 -0
- package/src/upgrade/release-images.ts +26 -14
- package/src/upgrade/reporter.ts +3 -0
- package/src/upgrade/upgrade.test.ts +137 -8
- package/src/upgrade/upgrade.ts +14 -2
- package/src/upgrader-gcp.ts +28 -13
- package/src/upgrader-iam-preflight.test.ts +6 -3
- package/src/upgrader-iam-preflight.ts +26 -5
- package/src/upgrader-job.ts +14 -6
- package/src/upgrader-reporter.ts +1 -0
- package/src/wizards/gcp-pulumi.test.ts +1 -1
- package/src/wizards/gcp-pulumi.ts +147 -38
- package/src/wizards/gcp.ts +89 -9
- package/src/wizards/stack-work-dir.test.ts +175 -0
- package/stacks/aws/index.ts +40 -0
- package/stacks/azure/index.ts +22 -1
- package/stacks/gcp/README.md +4 -0
- package/stacks/gcp/index.ts +6 -2
- package/stacks/gcp/Pulumi.production.yaml +0 -16
package/src/owner-reset.ts
CHANGED
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* #769 — `cms-provision owner-reset` — get an Owner back into a
|
|
5
|
-
* when the install cannot email a reset link (no email
|
|
4
|
+
* #769 / #770 — `cms-provision owner-reset` — get an Owner back into a
|
|
5
|
+
* locked-out admin when the install cannot email a reset link (no email
|
|
6
|
+
* transport set up).
|
|
6
7
|
*
|
|
7
|
-
* The root of trust is the operator's
|
|
8
|
-
* install's internal secret and pass the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* The root of trust is the operator's control of the install: on Google
|
|
9
|
+
* Cloud, whoever may read the install's internal secret and pass the
|
|
10
|
+
* admin's IAP already controls its database and deploys; on self-hosted,
|
|
11
|
+
* whoever may run `docker compose` for it already controls the host. Issuing
|
|
12
|
+
* them a reset link grants nothing new. The admin issues the one-hour,
|
|
13
|
+
* single-use token (`auth.issue_owner_reset`, audited) and hands it back over
|
|
14
|
+
* the admin-internal.ts channel; the CLI prints the link once. It is never
|
|
15
|
+
* logged, so it does not land in the project's Cloud Logging or the host's
|
|
16
|
+
* container logs the way a "log the link" fallback would.
|
|
14
17
|
*
|
|
15
|
-
* The request
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* The request names who asked, for the admin's audit row: the operator's
|
|
19
|
+
* gcloud account on Google Cloud, `<user>@<host>` of the CLI process on
|
|
20
|
+
* self-hosted. Either is self-asserted (the admin sees a shared identity and
|
|
21
|
+
* the shared internal secret) and labelled so; the authoritative record is
|
|
22
|
+
* Google's Data Access audit log for the IAM Credentials and Secret Manager
|
|
23
|
+
* calls, or the host's own login records.
|
|
20
24
|
*/
|
|
21
25
|
|
|
26
|
+
import { hostname, userInfo } from "node:os";
|
|
22
27
|
import {
|
|
23
28
|
type AdminInternalDeps,
|
|
24
29
|
type AdminInternalResult,
|
|
@@ -26,11 +31,57 @@ import {
|
|
|
26
31
|
callAdminInternal,
|
|
27
32
|
} from "./admin-internal.js";
|
|
28
33
|
import { activeAccount, gcloud as defaultGcloud } from "./gcloud.js";
|
|
34
|
+
import type { InstallMetadata } from "./install-state.js";
|
|
29
35
|
|
|
30
36
|
/** Token scope the admin endpoint checks (apps/admin …/api/internal/owner-reset). */
|
|
31
37
|
export const OWNER_RESET_SCOPE = "auth.owner_reset";
|
|
32
38
|
const ENDPOINT = "/api/internal/owner-reset";
|
|
33
39
|
|
|
40
|
+
/** Where another Owner or the email setup gets a locked-out Owner back in meanwhile. */
|
|
41
|
+
const OTHER_WAYS_IN =
|
|
42
|
+
"Until then, another Owner can set a new password under Settings -> Users, or set up email under Settings -> Email so /forgot can send the link.";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The admin of a cloud install recorded in install.json, or why this
|
|
46
|
+
* command cannot reach it. aws and azure have no channel to the admin's
|
|
47
|
+
* internal endpoints yet (#952); a self-hosted install is found by its
|
|
48
|
+
* `.caelo/` directory, not by install.json.
|
|
49
|
+
*/
|
|
50
|
+
export function ownerResetTarget(
|
|
51
|
+
meta: Pick<InstallMetadata, "installId" | "provider" | "projectId" | "region" | "domain">,
|
|
52
|
+
): AdminInternalResult<AdminInternalTarget> {
|
|
53
|
+
const unrecorded = (what: string) => ({
|
|
54
|
+
ok: false as const,
|
|
55
|
+
error: `install ${meta.installId} has no ${what} recorded in install.json; re-run the wizard to record it.`,
|
|
56
|
+
});
|
|
57
|
+
switch (meta.provider) {
|
|
58
|
+
case "gcp-firebase":
|
|
59
|
+
if (!meta.projectId || !meta.region) return unrecorded("project or region");
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
value: { provider: "gcp-firebase", projectId: meta.projectId, region: meta.region },
|
|
63
|
+
};
|
|
64
|
+
case "gcp":
|
|
65
|
+
if (!meta.projectId || !meta.domain) return unrecorded("project or domain");
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
value: { provider: "gcp", projectId: meta.projectId, domain: meta.domain },
|
|
69
|
+
};
|
|
70
|
+
case "self-hosted":
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
error:
|
|
74
|
+
"this install is self-hosted: run `cms-provision owner-reset` without --install in the directory that holds its .caelo/ (or with CAELO_SELF_HOSTED_DIR set to that .caelo/).",
|
|
75
|
+
};
|
|
76
|
+
case "aws":
|
|
77
|
+
case "azure":
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: `owner-reset cannot reach the admin of ${meta.provider} installs yet (issue #952). ${OTHER_WAYS_IN}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
34
85
|
/** What the admin issued: whose account, the link to open, and until when it works. */
|
|
35
86
|
export interface OwnerReset {
|
|
36
87
|
readonly email: string;
|
|
@@ -45,10 +96,10 @@ export interface OwnerReset {
|
|
|
45
96
|
export async function requestOwnerReset(
|
|
46
97
|
target: AdminInternalTarget,
|
|
47
98
|
request: { readonly email?: string },
|
|
48
|
-
deps:
|
|
99
|
+
deps: OwnerResetDeps = {},
|
|
49
100
|
): Promise<AdminInternalResult<OwnerReset>> {
|
|
50
|
-
const
|
|
51
|
-
if (!
|
|
101
|
+
const requester = await readRequester(target, deps);
|
|
102
|
+
if (!requester.ok) return requester;
|
|
52
103
|
const r = await callAdminInternal<{ email: string; token: string; expiresAt: string }>(
|
|
53
104
|
target,
|
|
54
105
|
{
|
|
@@ -56,9 +107,9 @@ export async function requestOwnerReset(
|
|
|
56
107
|
scope: OWNER_RESET_SCOPE,
|
|
57
108
|
body: {
|
|
58
109
|
...(request.email !== undefined ? { email: request.email } : {}),
|
|
59
|
-
|
|
110
|
+
...requester.value,
|
|
60
111
|
},
|
|
61
|
-
introducedBy: "#769",
|
|
112
|
+
introducedBy: target.provider === "self-hosted" ? "#770" : "#769",
|
|
62
113
|
},
|
|
63
114
|
deps,
|
|
64
115
|
);
|
|
@@ -72,6 +123,44 @@ export async function requestOwnerReset(
|
|
|
72
123
|
return { ok: true, value: { email, resetUrl, expiresAt } };
|
|
73
124
|
}
|
|
74
125
|
|
|
126
|
+
/** {@link requestOwnerReset}'s seams; `localUser` names the host user on self-hosted. */
|
|
127
|
+
export interface OwnerResetDeps extends AdminInternalDeps {
|
|
128
|
+
readonly localUser?: () => { readonly user: string; readonly host: string };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Who asks, as the admin's body field for it: `requestedBy` (a gcloud
|
|
133
|
+
* account) on Google Cloud — the field #769's admins accept — and
|
|
134
|
+
* `requestedByHostUser` on self-hosted, where only admins from #770 on hold
|
|
135
|
+
* the internal secret at all.
|
|
136
|
+
*/
|
|
137
|
+
async function readRequester(
|
|
138
|
+
target: AdminInternalTarget,
|
|
139
|
+
deps: OwnerResetDeps,
|
|
140
|
+
): Promise<AdminInternalResult<{ requestedBy: string } | { requestedByHostUser: string }>> {
|
|
141
|
+
if (target.provider !== "self-hosted") {
|
|
142
|
+
const account = await readGcloudAccount(deps.run ?? defaultGcloud);
|
|
143
|
+
return account.ok ? { ok: true, value: { requestedBy: account.value } } : account;
|
|
144
|
+
}
|
|
145
|
+
const { user, host } = (deps.localUser ?? defaultLocalUser)();
|
|
146
|
+
// The admin accepts exactly this shape (…/api/internal/owner-reset).
|
|
147
|
+
if (!HOST_USER.test(user) || !HOST_NAME.test(host)) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
error: `name this host's user for the admin's audit row: ${JSON.stringify(`${user}@${host}`)} is not <user>@<host> in letters, digits, '.', '_' and '-'. Run the command as a user (and on a host) named that way.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, value: { requestedByHostUser: `${user}@${host}` } };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The pieces of `requestedByHostUser`, as the admin validates them. */
|
|
157
|
+
const HOST_USER = /^[A-Za-z0-9._-]{1,64}$/;
|
|
158
|
+
const HOST_NAME = /^[A-Za-z0-9.-]{1,253}$/;
|
|
159
|
+
|
|
160
|
+
function defaultLocalUser(): { user: string; host: string } {
|
|
161
|
+
return { user: userInfo().username, host: hostname() };
|
|
162
|
+
}
|
|
163
|
+
|
|
75
164
|
/**
|
|
76
165
|
* The gcloud account this command runs as, for the admin's audit row. Without
|
|
77
166
|
* one there is nobody to name, and the IAP step after this would fail anyway.
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* preview-plan.ts against an engine event stream in the shape
|
|
5
|
+
* `stack.preview({ onEvent })` delivers (#737): one planned step per
|
|
6
|
+
* resource, replace sub-steps folded in, bookkeeping dropped, and every
|
|
7
|
+
* data-bearing replace or delete named.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
11
|
+
import { closeSync, fstatSync, mkdtempSync, openSync, readFileSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { EngineEvent, OpType } from "@pulumi/pulumi/automation";
|
|
15
|
+
import {
|
|
16
|
+
collectPreviewSteps,
|
|
17
|
+
formatPlan,
|
|
18
|
+
hasReplaceOrDelete,
|
|
19
|
+
losesData,
|
|
20
|
+
type PlannedStep,
|
|
21
|
+
planGate,
|
|
22
|
+
writePlanFile,
|
|
23
|
+
} from "./preview-plan.js";
|
|
24
|
+
|
|
25
|
+
const URN = "urn:pulumi:production::caelo-gcp-firebase::";
|
|
26
|
+
let sequence = 0;
|
|
27
|
+
|
|
28
|
+
/** A `resourcePreEvent` as the engine sends it. */
|
|
29
|
+
function pre(
|
|
30
|
+
op: OpType,
|
|
31
|
+
type: string,
|
|
32
|
+
name: string,
|
|
33
|
+
extra: { diffs?: string[]; keys?: string[] } = {},
|
|
34
|
+
): EngineEvent {
|
|
35
|
+
return {
|
|
36
|
+
sequence: sequence++,
|
|
37
|
+
timestamp: 1_760_000_000,
|
|
38
|
+
resourcePreEvent: {
|
|
39
|
+
metadata: {
|
|
40
|
+
op,
|
|
41
|
+
urn: `${URN}${type}::${name}`,
|
|
42
|
+
type,
|
|
43
|
+
provider: `${URN}pulumi:providers:gcp::default_8_10_0::04da6b54`,
|
|
44
|
+
...extra,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A preview of a drifted gcp-firebase install, as recorded from the engine. */
|
|
51
|
+
const RECORDED: EngineEvent[] = [
|
|
52
|
+
{ sequence: sequence++, timestamp: 1_760_000_000, preludeEvent: { config: {} } },
|
|
53
|
+
pre("same", "pulumi:pulumi:Stack", "caelo-gcp-firebase-production"),
|
|
54
|
+
pre("create", "pulumi:providers:gcp", "default_8_10_0"),
|
|
55
|
+
pre("refresh", "gcp:run/v2Service:V2Service", "caelo-production-admin"),
|
|
56
|
+
pre("same", "gcp:run/v2Service:V2Service", "caelo-production-admin"),
|
|
57
|
+
pre("update", "gcp:cloudrunv2/service:Service", "caelo-production-gateway", {
|
|
58
|
+
diffs: ["template", "ingress"],
|
|
59
|
+
}),
|
|
60
|
+
pre("create", "gcp:projects/iAMMember:IAMMember", "admin-run-invoker"),
|
|
61
|
+
pre("replace", "gcp:sql/databaseInstance:DatabaseInstance", "caelo-production-pg", {
|
|
62
|
+
diffs: ["region", "settings"],
|
|
63
|
+
keys: ["region"],
|
|
64
|
+
}),
|
|
65
|
+
pre("create-replacement", "gcp:sql/databaseInstance:DatabaseInstance", "caelo-production-pg", {
|
|
66
|
+
keys: ["region"],
|
|
67
|
+
}),
|
|
68
|
+
pre("delete-replaced", "gcp:sql/databaseInstance:DatabaseInstance", "caelo-production-pg"),
|
|
69
|
+
pre("delete", "gcp:storage/bucket:Bucket", "caelo-production-media"),
|
|
70
|
+
pre("replace", "gcp:secretmanager/secret:Secret", "caelo-production-postgres-password", {
|
|
71
|
+
keys: ["replication"],
|
|
72
|
+
}),
|
|
73
|
+
pre("delete", "gcp:firebase/project:Project", "firebase"),
|
|
74
|
+
pre("delete", "gcp:compute/firewall:Firewall", "allow-health-checks"),
|
|
75
|
+
pre("read", "gcp:organizations/getProject:getProject", "project"),
|
|
76
|
+
{
|
|
77
|
+
sequence: sequence++,
|
|
78
|
+
timestamp: 1_760_000_001,
|
|
79
|
+
summaryEvent: {
|
|
80
|
+
maybeCorrupt: false,
|
|
81
|
+
durationSeconds: 12,
|
|
82
|
+
resourceChanges: { same: 2, update: 1, create: 1, replace: 2, delete: 3 },
|
|
83
|
+
policyPacks: {},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
function recordedPlan(): PlannedStep[] {
|
|
89
|
+
const c = collectPreviewSteps();
|
|
90
|
+
for (const e of RECORDED) c.onEvent(e);
|
|
91
|
+
return c.steps();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("collectPreviewSteps", () => {
|
|
95
|
+
it("one step per changed resource; same, refresh, read, providers and the stack drop out", () => {
|
|
96
|
+
const steps = recordedPlan();
|
|
97
|
+
expect(steps.map((s) => `${s.op} ${s.name}`)).toEqual([
|
|
98
|
+
"update caelo-production-gateway",
|
|
99
|
+
"create admin-run-invoker",
|
|
100
|
+
"replace caelo-production-pg",
|
|
101
|
+
"delete caelo-production-media",
|
|
102
|
+
"replace caelo-production-postgres-password",
|
|
103
|
+
"delete firebase",
|
|
104
|
+
"delete allow-health-checks",
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("a replace keeps why it is replaced; its sub-steps do not overwrite it", () => {
|
|
109
|
+
const sql = recordedPlan().find((s) => s.name === "caelo-production-pg");
|
|
110
|
+
expect(sql).toEqual({
|
|
111
|
+
op: "replace",
|
|
112
|
+
type: "gcp:sql/databaseInstance:DatabaseInstance",
|
|
113
|
+
name: "caelo-production-pg",
|
|
114
|
+
urn: `${URN}gcp:sql/databaseInstance:DatabaseInstance::caelo-production-pg`,
|
|
115
|
+
diffs: ["region", "settings"],
|
|
116
|
+
replaceReasons: ["region"],
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("losesData", () => {
|
|
122
|
+
it("flags the Cloud SQL instance, a bucket, a secret and the Firebase project", () => {
|
|
123
|
+
const lost = recordedPlan()
|
|
124
|
+
.filter(losesData)
|
|
125
|
+
.map((s) => s.type);
|
|
126
|
+
expect(lost).toEqual([
|
|
127
|
+
"gcp:sql/databaseInstance:DatabaseInstance",
|
|
128
|
+
"gcp:storage/bucket:Bucket",
|
|
129
|
+
"gcp:secretmanager/secret:Secret",
|
|
130
|
+
"gcp:firebase/project:Project",
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("an in-place update or a create of a data-bearing resource loses nothing", () => {
|
|
135
|
+
const step = (op: PlannedStep["op"]): PlannedStep => ({
|
|
136
|
+
op,
|
|
137
|
+
type: "gcp:sql/databaseInstance:DatabaseInstance",
|
|
138
|
+
name: "pg",
|
|
139
|
+
urn: "u",
|
|
140
|
+
diffs: [],
|
|
141
|
+
replaceReasons: [],
|
|
142
|
+
});
|
|
143
|
+
expect(losesData(step("update"))).toBe(false);
|
|
144
|
+
expect(losesData(step("create"))).toBe(false);
|
|
145
|
+
expect(losesData(step("delete"))).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("a firewall delete is a delete, but not data loss", () => {
|
|
149
|
+
const fw = recordedPlan().find((s) => s.name === "allow-health-checks");
|
|
150
|
+
expect(fw && losesData(fw)).toBe(false);
|
|
151
|
+
expect(hasReplaceOrDelete(recordedPlan())).toBe(true);
|
|
152
|
+
expect(hasReplaceOrDelete(recordedPlan().filter((s) => s.op === "update"))).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("formatPlan", () => {
|
|
157
|
+
it("groups destructive steps first and names what each data loss destroys", () => {
|
|
158
|
+
expect(formatPlan(recordedPlan())).toEqual([
|
|
159
|
+
"DELETES (3):",
|
|
160
|
+
" gcp:storage/bucket:Bucket caelo-production-media <-- LOSES DATA: Cloud Storage bucket (media, published site or state)",
|
|
161
|
+
" gcp:firebase/project:Project firebase <-- LOSES DATA: Firebase project",
|
|
162
|
+
" gcp:compute/firewall:Firewall allow-health-checks",
|
|
163
|
+
"REPLACES (delete + create) (2):",
|
|
164
|
+
" gcp:sql/databaseInstance:DatabaseInstance caelo-production-pg (replaced because region changes) <-- LOSES DATA: Cloud SQL instance (all site data)",
|
|
165
|
+
" gcp:secretmanager/secret:Secret caelo-production-postgres-password (replaced because replication changes) <-- LOSES DATA: Secret Manager secret",
|
|
166
|
+
"Updates in place (1):",
|
|
167
|
+
" gcp:cloudrunv2/service:Service caelo-production-gateway (template, ingress)",
|
|
168
|
+
"Creates (1):",
|
|
169
|
+
" gcp:projects/iAMMember:IAMMember admin-run-invoker",
|
|
170
|
+
"",
|
|
171
|
+
"4 of these would destroy data: Cloud SQL instance (all site data) caelo-production-pg; Cloud Storage bucket (media, published site or state) caelo-production-media; Secret Manager secret caelo-production-postgres-password; Firebase project firebase.",
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("an empty plan says so", () => {
|
|
176
|
+
expect(formatPlan([])).toEqual(["The preview plans no changes."]);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("writePlanFile", () => {
|
|
181
|
+
let dir: string;
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
dir = mkdtempSync(join(tmpdir(), "caelo-plan-"));
|
|
184
|
+
});
|
|
185
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
186
|
+
|
|
187
|
+
it("writes every step with its URN and changed properties, mode 600", () => {
|
|
188
|
+
const path = writePlanFile(dir, "installer", recordedPlan(), new Date("2026-10-21T10:00:00Z"));
|
|
189
|
+
expect(path).toBe(join(dir, "plan-installer-2026-10-21T10-00-00-000Z.txt"));
|
|
190
|
+
// One descriptor for the mode and the content (no check-then-read race).
|
|
191
|
+
const fd = openSync(path, "r");
|
|
192
|
+
let text: string;
|
|
193
|
+
try {
|
|
194
|
+
expect(fstatSync(fd).mode & 0o777).toBe(0o600);
|
|
195
|
+
text = readFileSync(fd, "utf8");
|
|
196
|
+
} finally {
|
|
197
|
+
closeSync(fd);
|
|
198
|
+
}
|
|
199
|
+
expect(text).toContain("7 change(s)");
|
|
200
|
+
expect(text).toContain(
|
|
201
|
+
`- replace ${URN}gcp:sql/databaseInstance:DatabaseInstance::caelo-production-pg\n replaced because: region\n changes: region, settings`,
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("planGate", () => {
|
|
207
|
+
const only = (pred: (s: PlannedStep) => boolean) => recordedPlan().filter(pred);
|
|
208
|
+
const typed = (type: string) => only((s) => s.type === type);
|
|
209
|
+
|
|
210
|
+
it.each([
|
|
211
|
+
["the Cloud SQL instance", "gcp:sql/databaseInstance:DatabaseInstance"],
|
|
212
|
+
["a bucket", "gcp:storage/bucket:Bucket"],
|
|
213
|
+
["a secret", "gcp:secretmanager/secret:Secret"],
|
|
214
|
+
["the Firebase project", "gcp:firebase/project:Project"],
|
|
215
|
+
])("replacing or deleting %s needs an explicit yes no flag can give", (_name, type) => {
|
|
216
|
+
const steps = typed(type);
|
|
217
|
+
expect(steps).toHaveLength(1);
|
|
218
|
+
expect(planGate(steps, { interactive: true })).toEqual({ kind: "confirm", losesData: true });
|
|
219
|
+
expect(planGate(steps, { interactive: false })).toEqual({ kind: "refuse" });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("a replace or delete without data is asked about, and goes ahead unattended", () => {
|
|
223
|
+
const steps = only((s) => s.name === "allow-health-checks");
|
|
224
|
+
expect(planGate(steps, { interactive: true })).toEqual({ kind: "confirm", losesData: false });
|
|
225
|
+
expect(planGate(steps, { interactive: false })).toEqual({ kind: "proceed" });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("creates and updates only: no question", () => {
|
|
229
|
+
const steps = only((s) => s.op === "create" || s.op === "update");
|
|
230
|
+
expect(planGate(steps, { interactive: true })).toEqual({ kind: "proceed" });
|
|
231
|
+
expect(planGate([], { interactive: false })).toEqual({ kind: "proceed" });
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a Pulumi preview would change, per resource (#737). `state migrate`
|
|
5
|
+
* used to refuse with "a preview shows 36 change(s)" and the installer ran
|
|
6
|
+
* `pulumi up` blind, so an operator could not tell an IAM tweak from a
|
|
7
|
+
* Cloud SQL replace without assembling a `pulumi preview --diff` by hand.
|
|
8
|
+
*
|
|
9
|
+
* {@link collectPreviewSteps} turns the Automation API's engine events into
|
|
10
|
+
* {@link PlannedStep}s; {@link formatPlan} groups them (creates / updates /
|
|
11
|
+
* REPLACES / DELETES) and names the resources whose replace or delete loses
|
|
12
|
+
* data; {@link writePlanFile} keeps the full diff next to the install.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { writeFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import type { EngineEvent, OpType } from "@pulumi/pulumi/automation";
|
|
18
|
+
|
|
19
|
+
/** The operations a plan lists; the rest (same, read, refresh) change nothing. */
|
|
20
|
+
export type PlannedOp = "create" | "update" | "replace" | "delete" | "import";
|
|
21
|
+
|
|
22
|
+
/** One resource a preview would change. */
|
|
23
|
+
export interface PlannedStep {
|
|
24
|
+
readonly op: PlannedOp;
|
|
25
|
+
/** The Pulumi type token, e.g. `gcp:sql/databaseInstance:DatabaseInstance`. */
|
|
26
|
+
readonly type: string;
|
|
27
|
+
/** The resource's name in the program (the URN's last segment). */
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly urn: string;
|
|
30
|
+
/** Properties that change. */
|
|
31
|
+
readonly diffs: readonly string[];
|
|
32
|
+
/** Properties that force the replace (replace steps only). */
|
|
33
|
+
readonly replaceReasons: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The step ops that make one planned change. A replace also emits
|
|
38
|
+
* `create-replacement` / `delete-replaced` sub-steps for the same URN; they
|
|
39
|
+
* are the same change and are not counted again.
|
|
40
|
+
*/
|
|
41
|
+
const PLANNED: Readonly<Partial<Record<OpType, PlannedOp>>> = {
|
|
42
|
+
create: "create",
|
|
43
|
+
update: "update",
|
|
44
|
+
replace: "replace",
|
|
45
|
+
delete: "delete",
|
|
46
|
+
import: "import",
|
|
47
|
+
"import-replacement": "replace",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resources whose replace or delete loses data, by Pulumi type token, with
|
|
52
|
+
* the name an operator knows them by. Replacing any of them is never a
|
|
53
|
+
* "state catching up" detail.
|
|
54
|
+
*/
|
|
55
|
+
const DATA_BEARING: Readonly<Record<string, string>> = {
|
|
56
|
+
"gcp:sql/databaseInstance:DatabaseInstance": "Cloud SQL instance (all site data)",
|
|
57
|
+
"gcp:sql/database:Database": "Cloud SQL database",
|
|
58
|
+
"gcp:storage/bucket:Bucket": "Cloud Storage bucket (media, published site or state)",
|
|
59
|
+
"gcp:secretmanager/secret:Secret": "Secret Manager secret",
|
|
60
|
+
"gcp:secretmanager/secretVersion:SecretVersion": "Secret Manager secret value",
|
|
61
|
+
"gcp:firebase/project:Project": "Firebase project",
|
|
62
|
+
"gcp:firebase/hostingSite:HostingSite": "Firebase Hosting site",
|
|
63
|
+
"gcp:kms/keyRing:KeyRing": "Cloud KMS key ring",
|
|
64
|
+
"gcp:kms/cryptoKey:CryptoKey": "Cloud KMS key (decrypts the install's state)",
|
|
65
|
+
"gcp:bigquery/dataset:Dataset": "BigQuery dataset (edge logs)",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** What a data-bearing resource holds, or null for any other type. */
|
|
69
|
+
export function dataBearingKind(type: string): string | null {
|
|
70
|
+
return DATA_BEARING[type] ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether `step` would destroy data: a replace or delete of a data-bearing resource. */
|
|
74
|
+
export function losesData(step: PlannedStep): boolean {
|
|
75
|
+
return (step.op === "replace" || step.op === "delete") && dataBearingKind(step.type) !== null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether the plan replaces or deletes anything. */
|
|
79
|
+
export function hasReplaceOrDelete(steps: readonly PlannedStep[]): boolean {
|
|
80
|
+
return steps.some((s) => s.op === "replace" || s.op === "delete");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The URN's last `::` segment: the name the program gave the resource. */
|
|
84
|
+
function urnName(urn: string): string {
|
|
85
|
+
return urn.split("::").at(-1) ?? urn;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Collects a preview's planned steps from its engine events: pass
|
|
90
|
+
* {@link PreviewCollector.onEvent} as the preview's `onEvent`, read
|
|
91
|
+
* {@link PreviewCollector.steps} afterwards. One step per URN.
|
|
92
|
+
*/
|
|
93
|
+
export interface PreviewCollector {
|
|
94
|
+
readonly onEvent: (event: EngineEvent) => void;
|
|
95
|
+
steps(): PlannedStep[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A fresh {@link PreviewCollector}. */
|
|
99
|
+
export function collectPreviewSteps(): PreviewCollector {
|
|
100
|
+
const byUrn = new Map<string, PlannedStep>();
|
|
101
|
+
return {
|
|
102
|
+
onEvent: (event) => {
|
|
103
|
+
const m = event.resourcePreEvent?.metadata;
|
|
104
|
+
if (!m) return;
|
|
105
|
+
const op = PLANNED[m.op];
|
|
106
|
+
// The stack resource itself and providers are bookkeeping, not cloud changes.
|
|
107
|
+
if (!op || m.type === "pulumi:pulumi:Stack" || m.type.startsWith("pulumi:providers:")) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const prior = byUrn.get(m.urn);
|
|
111
|
+
if (prior?.op === "replace") return;
|
|
112
|
+
byUrn.set(m.urn, {
|
|
113
|
+
op,
|
|
114
|
+
type: m.type,
|
|
115
|
+
name: urnName(m.urn),
|
|
116
|
+
urn: m.urn,
|
|
117
|
+
diffs: [...(m.diffs ?? [])],
|
|
118
|
+
replaceReasons: op === "replace" ? [...(m.keys ?? [])] : [],
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
steps: () => [...byUrn.values()],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const GROUPS: readonly { readonly op: PlannedOp; readonly title: string }[] = [
|
|
126
|
+
{ op: "delete", title: "DELETES" },
|
|
127
|
+
{ op: "replace", title: "REPLACES (delete + create)" },
|
|
128
|
+
{ op: "update", title: "Updates in place" },
|
|
129
|
+
{ op: "create", title: "Creates" },
|
|
130
|
+
{ op: "import", title: "Imports" },
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
/** One line for `step`: type, name, what changes and why it is replaced. */
|
|
134
|
+
function stepLine(step: PlannedStep): string {
|
|
135
|
+
const kind = dataBearingKind(step.type);
|
|
136
|
+
const data = losesData(step) && kind ? ` <-- LOSES DATA: ${kind}` : "";
|
|
137
|
+
const why =
|
|
138
|
+
step.op === "replace" && step.replaceReasons.length > 0
|
|
139
|
+
? ` (replaced because ${step.replaceReasons.join(", ")} changes)`
|
|
140
|
+
: step.diffs.length > 0 && step.op !== "create"
|
|
141
|
+
? ` (${step.diffs.join(", ")})`
|
|
142
|
+
: "";
|
|
143
|
+
return ` ${step.type} ${step.name}${why}${data}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The plan as grouped lines, destructive groups first. Empty plan: one line
|
|
148
|
+
* saying so.
|
|
149
|
+
*/
|
|
150
|
+
export function formatPlan(steps: readonly PlannedStep[]): string[] {
|
|
151
|
+
if (steps.length === 0) return ["The preview plans no changes."];
|
|
152
|
+
const lines: string[] = [];
|
|
153
|
+
for (const g of GROUPS) {
|
|
154
|
+
const inGroup = steps.filter((s) => s.op === g.op);
|
|
155
|
+
if (inGroup.length === 0) continue;
|
|
156
|
+
lines.push(`${g.title} (${inGroup.length}):`, ...inGroup.map(stepLine));
|
|
157
|
+
}
|
|
158
|
+
const lost = steps.filter(losesData);
|
|
159
|
+
if (lost.length > 0) {
|
|
160
|
+
lines.push(
|
|
161
|
+
"",
|
|
162
|
+
`${lost.length} of these would destroy data: ${lost.map((s) => `${dataBearingKind(s.type)} ${s.name}`).join("; ")}.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Write the full plan (every step with every changed property) to a file in
|
|
170
|
+
* `installRoot` and return its path. Names and property paths only, never
|
|
171
|
+
* values: the file holds no secret.
|
|
172
|
+
*
|
|
173
|
+
* @param label what the preview was for, part of the file name (`state-migrate`, `installer`)
|
|
174
|
+
*/
|
|
175
|
+
export function writePlanFile(
|
|
176
|
+
installRoot: string,
|
|
177
|
+
label: string,
|
|
178
|
+
steps: readonly PlannedStep[],
|
|
179
|
+
now: Date = new Date(),
|
|
180
|
+
): string {
|
|
181
|
+
const path = join(installRoot, `plan-${label}-${now.toISOString().replace(/[:.]/g, "-")}.txt`);
|
|
182
|
+
const body = [
|
|
183
|
+
`Pulumi preview (${label}), ${now.toISOString()}: ${steps.length} change(s)`,
|
|
184
|
+
"",
|
|
185
|
+
...formatPlan(steps),
|
|
186
|
+
"",
|
|
187
|
+
"Every step:",
|
|
188
|
+
...steps.map((s) =>
|
|
189
|
+
[
|
|
190
|
+
`- ${s.op} ${s.urn}`,
|
|
191
|
+
...(s.replaceReasons.length > 0
|
|
192
|
+
? [` replaced because: ${s.replaceReasons.join(", ")}`]
|
|
193
|
+
: []),
|
|
194
|
+
...(s.diffs.length > 0 ? [` changes: ${s.diffs.join(", ")}`] : []),
|
|
195
|
+
].join("\n"),
|
|
196
|
+
),
|
|
197
|
+
"",
|
|
198
|
+
].join("\n");
|
|
199
|
+
writeFileSync(path, body, { mode: 0o600 });
|
|
200
|
+
return path;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** What the installer does with a plan before `pulumi up` (#737). */
|
|
204
|
+
export type PlanGate =
|
|
205
|
+
/** Nothing is replaced or deleted: apply. */
|
|
206
|
+
| { readonly kind: "proceed" }
|
|
207
|
+
/** Ask the operator y/N; `losesData` makes the question explicit about it. */
|
|
208
|
+
| { readonly kind: "confirm"; readonly losesData: boolean }
|
|
209
|
+
/** Nobody can be asked and data would be lost: stop, nothing applied. */
|
|
210
|
+
| { readonly kind: "refuse" };
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The gate for `steps`. A replace or delete is asked about whenever someone
|
|
214
|
+
* can answer. Without a terminal (`--non-interactive`, the install job)
|
|
215
|
+
* replaces and deletes of resources without data go ahead, already listed;
|
|
216
|
+
* a data-bearing one is refused: no flag stands in for that yes.
|
|
217
|
+
*/
|
|
218
|
+
export function planGate(
|
|
219
|
+
steps: readonly PlannedStep[],
|
|
220
|
+
opts: { readonly interactive: boolean },
|
|
221
|
+
): PlanGate {
|
|
222
|
+
if (!hasReplaceOrDelete(steps)) return { kind: "proceed" };
|
|
223
|
+
const lost = steps.some(losesData);
|
|
224
|
+
if (opts.interactive) return { kind: "confirm", losesData: lost };
|
|
225
|
+
return lost ? { kind: "refuse" } : { kind: "proceed" };
|
|
226
|
+
}
|
|
@@ -46,7 +46,7 @@ describe("revokePublicRoleMembershipsArgs (#633)", () => {
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
describe("revokePublicRoleMemberships", () => {
|
|
49
|
-
const install = { projectId: "acme", env: "production" };
|
|
49
|
+
const install = { projectId: "acme", env: "production", credentials: "operator" } as const;
|
|
50
50
|
|
|
51
51
|
it("finds the instance, then revokes", async () => {
|
|
52
52
|
const { run, calls } = fake((l) => (l.startsWith("sql instances list") ? ok("pg-1\n") : ok()));
|
|
@@ -73,7 +73,18 @@ describe("revokePublicRoleMemberships", () => {
|
|
|
73
73
|
const { run, calls } = fake(() => ok(""));
|
|
74
74
|
const r = await revokePublicRoleMemberships(install, { run, sleep: noSleep });
|
|
75
75
|
expect(r.status).toBe("failed");
|
|
76
|
-
expect(r.error).toBe("the Cloud SQL instance
|
|
76
|
+
expect(r.error).toBe("Could not find the Cloud SQL instance");
|
|
77
|
+
expect(calls).toHaveLength(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("a failed listing is reported as such, not as a missing instance (#630)", async () => {
|
|
81
|
+
const { run, calls } = fake(() =>
|
|
82
|
+
fail("ERROR: Reauthentication failed. cannot prompt during non-interactive execution."),
|
|
83
|
+
);
|
|
84
|
+
const r = await revokePublicRoleMemberships(install, { run, sleep: noSleep });
|
|
85
|
+
expect(r.status).toBe("failed");
|
|
86
|
+
expect(r.error).toContain("gcloud auth login");
|
|
87
|
+
expect(r.error).not.toContain("Could not find");
|
|
77
88
|
expect(calls).toHaveLength(1);
|
|
78
89
|
});
|
|
79
90
|
|