@caelo-cms/provisioning 0.11.10 → 0.11.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter.d.ts +6 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adopt-existing.d.ts +6 -2
- package/dist/adopt-existing.d.ts.map +1 -1
- package/dist/adopt-existing.js +57 -0
- package/dist/adopt-existing.js.map +1 -1
- package/dist/agent/command.d.ts +5 -2
- package/dist/agent/command.d.ts.map +1 -1
- package/dist/agent/command.js +1 -1
- package/dist/agent/command.js.map +1 -1
- package/dist/agent-db.js +25 -8
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +59 -7
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-tls.d.ts +30 -0
- package/dist/cloudsql-tls.d.ts.map +1 -1
- package/dist/cloudsql-tls.js +7 -1
- package/dist/cloudsql-tls.js.map +1 -1
- package/dist/compose.d.ts +14 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +54 -1
- package/dist/compose.js.map +1 -1
- package/dist/converge-db-trust.d.ts +44 -0
- package/dist/converge-db-trust.d.ts.map +1 -0
- package/dist/converge-db-trust.js +158 -0
- package/dist/converge-db-trust.js.map +1 -0
- package/dist/converge-job-gcp.d.ts +5 -2
- package/dist/converge-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +9 -2
- package/dist/converge-job-gcp.js.map +1 -1
- package/dist/converge-job-preflight.d.ts.map +1 -1
- package/dist/converge-job-preflight.js +8 -0
- package/dist/converge-job-preflight.js.map +1 -1
- package/dist/converge-job.d.ts +17 -0
- package/dist/converge-job.d.ts.map +1 -1
- package/dist/converge-job.js +12 -0
- package/dist/converge-job.js.map +1 -1
- package/dist/gcp-names.d.ts +13 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +21 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +22 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +4 -2
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js +4 -1
- package/dist/install-job.js.map +1 -1
- package/dist/install-state.d.ts +7 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +3 -0
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +8 -3
- package/dist/migration-runner.js.map +1 -1
- package/dist/migration-targets.d.ts +11 -0
- package/dist/migration-targets.d.ts.map +1 -0
- package/dist/migration-targets.js +12 -0
- package/dist/migration-targets.js.map +1 -0
- package/dist/operator-access.d.ts +10 -0
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +5 -0
- package/dist/operator-access.js.map +1 -1
- package/dist/pool-budget-stack.d.ts +13 -1
- package/dist/pool-budget-stack.d.ts.map +1 -1
- package/dist/pool-budget-stack.js +11 -1
- package/dist/pool-budget-stack.js.map +1 -1
- package/dist/public-role-isolation.d.ts +7 -3
- package/dist/public-role-isolation.d.ts.map +1 -1
- package/dist/public-role-isolation.js +25 -13
- package/dist/public-role-isolation.js.map +1 -1
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +3 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/runtime-secrets.d.ts +2 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +19 -15
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/scheduler-setup.d.ts +75 -0
- package/dist/scheduler-setup.d.ts.map +1 -0
- package/dist/scheduler-setup.js +331 -0
- package/dist/scheduler-setup.js.map +1 -0
- package/dist/self-hosted-roles.d.ts +16 -9
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +49 -15
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-scheduler-interval.d.ts +21 -0
- package/dist/self-hosted-scheduler-interval.d.ts.map +1 -0
- package/dist/self-hosted-scheduler-interval.js +68 -0
- package/dist/self-hosted-scheduler-interval.js.map +1 -0
- package/dist/self-hosted-upgrade.d.ts +7 -4
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +9 -5
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +67 -6
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +87 -6
- package/dist/stack-contract.js.map +1 -1
- package/dist/staging-database.d.ts +22 -0
- package/dist/staging-database.d.ts.map +1 -0
- package/dist/staging-database.js +117 -0
- package/dist/staging-database.js.map +1 -0
- package/dist/upgrade/converge.d.ts +5 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +110 -10
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +6 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +3 -0
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +5 -4
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +5 -4
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/wizard.d.ts +7 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +11 -0
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts +31 -0
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +81 -11
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +87 -11
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/adapter.ts +6 -0
- package/src/admin-memory.test.ts +1 -1
- package/src/adopt-existing.test.ts +46 -0
- package/src/adopt-existing.ts +58 -2
- package/src/agent/command.ts +6 -3
- package/src/caddy-redirects.test.ts +1 -1
- package/src/cli-routing.test.ts +1 -1
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +63 -8
- package/src/cloudsql-tls.test.ts +30 -0
- package/src/cloudsql-tls.ts +10 -3
- package/src/compose.ts +68 -0
- package/src/converge-db-trust.test.ts +237 -0
- package/src/converge-db-trust.ts +212 -0
- package/src/converge-job-gcp.ts +9 -1
- package/src/converge-job-preflight.test.ts +5 -0
- package/src/converge-job-preflight.ts +8 -0
- package/src/converge-job.test.ts +70 -0
- package/src/converge-job.ts +32 -0
- package/src/database-tls-builders.test.ts +3 -2
- package/src/deployed-release.test.ts +3 -0
- package/src/gateway-env-isolation.test.ts +40 -7
- package/src/gcp-names.ts +25 -0
- package/src/index.test.ts +3 -3
- package/src/index.ts +6 -0
- package/src/infra-contract.test.ts +5 -3
- package/src/infra-contract.ts +22 -1
- package/src/install-job.ts +4 -1
- package/src/install-state.ts +7 -0
- package/src/lifecycle.ts +3 -0
- package/src/migration-runner.ts +14 -9
- package/src/migration-targets.ts +12 -0
- package/src/operator-access.test.ts +15 -0
- package/src/operator-access.ts +14 -0
- package/src/pool-budget-stack.ts +15 -0
- package/src/pool-budget-stacks.test.ts +38 -13
- package/src/public-role-isolation.test.ts +18 -8
- package/src/public-role-isolation.ts +31 -17
- package/src/release-contract.ts +3 -0
- package/src/runtime-secrets.test.ts +18 -8
- package/src/runtime-secrets.ts +37 -18
- package/src/scheduler-setup.test.ts +201 -0
- package/src/scheduler-setup.ts +375 -0
- package/src/self-hosted-backup.test.ts +1 -0
- package/src/self-hosted-compose.test.ts +72 -1
- package/src/self-hosted-roles.integration.test.ts +2 -0
- package/src/self-hosted-roles.test.ts +49 -19
- package/src/self-hosted-roles.ts +60 -20
- package/src/self-hosted-scheduler-interval.test.ts +66 -0
- package/src/self-hosted-scheduler-interval.ts +79 -0
- package/src/self-hosted-start.test.ts +1 -0
- package/src/self-hosted-upgrade.test.ts +2 -1
- package/src/self-hosted-upgrade.ts +14 -8
- package/src/stack-contract.test.ts +31 -3
- package/src/stack-contract.ts +113 -6
- package/src/stack-converge-secrets.test.ts +2 -0
- package/src/stack-converge.test.ts +6 -4
- package/src/staging-database.test.ts +114 -0
- package/src/staging-database.ts +148 -0
- package/src/upgrade/converge.ts +130 -9
- package/src/upgrade/upgrade.test.ts +85 -2
- package/src/upgrade/upgrade.ts +9 -0
- package/src/upgrader-gcp.ts +5 -4
- package/src/wizard.ts +19 -0
- package/src/wizards/gcp-cost.test.ts +54 -1
- package/src/wizards/gcp-cost.ts +101 -11
- package/src/wizards/gcp.ts +109 -8
- package/stacks/aws/Pulumi.yaml +1 -1
- package/stacks/aws/index.ts +41 -1
- package/stacks/azure/index.ts +51 -3
- package/stacks/gcp/Pulumi.yaml +1 -1
- package/stacks/gcp/README.md +1 -0
- package/stacks/gcp/index.ts +50 -1
- package/stacks/gcp-firebase/Pulumi.yaml +1 -1
- package/stacks/gcp-firebase/index.ts +50 -1
- package/stacks/self-hosted/Pulumi.yaml +3 -0
- package/stacks/self-hosted/README.md +4 -0
- package/stacks/self-hosted/index.ts +29 -4
package/src/runtime-secrets.ts
CHANGED
|
@@ -572,6 +572,12 @@ async function setSqlUserPassword(
|
|
|
572
572
|
http: HttpFetch,
|
|
573
573
|
sleep: Sleep,
|
|
574
574
|
user: { projectId: string; instance: string; role: string; password: string },
|
|
575
|
+
/**
|
|
576
|
+
* `update` sets an existing user's password (`users.update`); `create`
|
|
577
|
+
* adds the user with it (`users.insert`, #1329: the staging gateway's
|
|
578
|
+
* login on an install from before it).
|
|
579
|
+
*/
|
|
580
|
+
mode: "update" | "create",
|
|
575
581
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
576
582
|
const token = await run(["auth", "print-access-token"]);
|
|
577
583
|
const bearer = token.ok ? token.stdout.trim() : "";
|
|
@@ -587,11 +593,17 @@ async function setSqlUserPassword(
|
|
|
587
593
|
ok: false as const,
|
|
588
594
|
error: `${what}: HTTP ${res.status} ${(await res.text()).slice(0, 500)}`,
|
|
589
595
|
});
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
596
|
+
const users = `${project}/instances/${encodeURIComponent(user.instance)}/users`;
|
|
597
|
+
const body = JSON.stringify({ name: user.role, password: user.password });
|
|
598
|
+
const put =
|
|
599
|
+
mode === "update"
|
|
600
|
+
? await http(`${users}?name=${encodeURIComponent(user.role)}`, {
|
|
601
|
+
method: "PUT",
|
|
602
|
+
headers,
|
|
603
|
+
body,
|
|
604
|
+
})
|
|
605
|
+
: await http(users, { method: "POST", headers, body });
|
|
606
|
+
if (!put.ok) return failed(mode === "update" ? "update the user" : "create the user", put);
|
|
595
607
|
type Operation = {
|
|
596
608
|
name?: string;
|
|
597
609
|
status?: string;
|
|
@@ -693,12 +705,13 @@ export async function rotateRuntimeSecret(
|
|
|
693
705
|
}
|
|
694
706
|
const previous = current.stdout;
|
|
695
707
|
const setPassword = (role: string, password: string) =>
|
|
696
|
-
setSqlUserPassword(
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
password,
|
|
701
|
-
|
|
708
|
+
setSqlUserPassword(
|
|
709
|
+
run,
|
|
710
|
+
http,
|
|
711
|
+
sleep,
|
|
712
|
+
{ projectId: target.projectId, instance, role, password },
|
|
713
|
+
"update",
|
|
714
|
+
);
|
|
702
715
|
const changed: string[] = [];
|
|
703
716
|
const restore = async (): Promise<string> => {
|
|
704
717
|
const failed: string[] = [];
|
|
@@ -807,7 +820,12 @@ export async function readSecretValue(
|
|
|
807
820
|
export async function ensureDatabaseRolePassword(
|
|
808
821
|
target: DatabaseRoleTarget,
|
|
809
822
|
role: DatabaseRole,
|
|
810
|
-
deps: RuntimeDeps & {
|
|
823
|
+
deps: RuntimeDeps & {
|
|
824
|
+
readonly http?: HttpFetch;
|
|
825
|
+
readonly password?: string;
|
|
826
|
+
/** Create the Cloud SQL user with the password instead (it does not exist yet, #1329). */
|
|
827
|
+
readonly create?: boolean;
|
|
828
|
+
} = {},
|
|
811
829
|
): Promise<EnsureOutcome> {
|
|
812
830
|
const run = deps.run ?? defaultGcloud;
|
|
813
831
|
const sleep = deps.sleep ?? realSleep;
|
|
@@ -819,11 +837,12 @@ export async function ensureDatabaseRolePassword(
|
|
|
819
837
|
if (!value.ok) return { id, status: "failed", error: value.error };
|
|
820
838
|
password = value.value;
|
|
821
839
|
}
|
|
822
|
-
const set = await setSqlUserPassword(
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
password,
|
|
827
|
-
|
|
840
|
+
const set = await setSqlUserPassword(
|
|
841
|
+
run,
|
|
842
|
+
http,
|
|
843
|
+
sleep,
|
|
844
|
+
{ projectId: target.projectId, instance: target.sqlInstance, role, password },
|
|
845
|
+
deps.create === true ? "create" : "update",
|
|
846
|
+
);
|
|
828
847
|
return set.ok ? { id, status: "applied" } : { id, status: "failed", error: set.error };
|
|
829
848
|
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The scheduler tick's setup (scheduler-setup.ts) against a fake gcloud. The
|
|
5
|
+
* load-bearing assertions are the trust boundary (#1140): the scheduler's
|
|
6
|
+
* account may sign as itself, pass the admin's IAP, start workflows and
|
|
7
|
+
* write logs — no secret, database, storage, run or IAM-admin right — and
|
|
8
|
+
* the one Cloud Scheduler job starts the workflow at the knob's cadence.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
13
|
+
import {
|
|
14
|
+
ensureSchedulerTick,
|
|
15
|
+
SCHEDULER_FORBIDDEN_ROLES,
|
|
16
|
+
SCHEDULER_TICK_PATH,
|
|
17
|
+
type SchedulerTickTarget,
|
|
18
|
+
schedulerWorkflowSource,
|
|
19
|
+
tickOrigin,
|
|
20
|
+
} from "./scheduler-setup.js";
|
|
21
|
+
|
|
22
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
23
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
24
|
+
|
|
25
|
+
const SA = "caelo-production-scheduler@acme.iam.gserviceaccount.com";
|
|
26
|
+
const MEMBER = `serviceAccount:${SA}`;
|
|
27
|
+
|
|
28
|
+
const TARGET: SchedulerTickTarget = {
|
|
29
|
+
projectId: "acme",
|
|
30
|
+
region: "europe-west1",
|
|
31
|
+
env: "production",
|
|
32
|
+
adminUrl: "https://admin.acme.com",
|
|
33
|
+
resource: { kind: "backend-services", service: "caelo-production-admin-backend-1a2b" },
|
|
34
|
+
intervalMinutes: 5,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function fakeGcloud(overrides: [RegExp, GcloudResult][] = []) {
|
|
38
|
+
const calls: string[][] = [];
|
|
39
|
+
const sources: string[] = [];
|
|
40
|
+
const rules: [RegExp, GcloudResult][] = [
|
|
41
|
+
...overrides,
|
|
42
|
+
[/^iam service-accounts describe/, fail("NOT_FOUND")],
|
|
43
|
+
[/^scheduler jobs describe/, fail("NOT_FOUND")],
|
|
44
|
+
];
|
|
45
|
+
const run = async (args: string[]) => {
|
|
46
|
+
calls.push(args);
|
|
47
|
+
const src = args.find((a) => a.startsWith("--source="));
|
|
48
|
+
if (src) sources.push(await Bun.file(src.slice("--source=".length)).text());
|
|
49
|
+
const line = args.join(" ");
|
|
50
|
+
return rules.find(([re]) => re.test(line))?.[1] ?? ok("");
|
|
51
|
+
};
|
|
52
|
+
return { run, calls, sources };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const flag = (args: string[], name: string) =>
|
|
56
|
+
args.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
57
|
+
|
|
58
|
+
function grants(calls: string[][]) {
|
|
59
|
+
return calls
|
|
60
|
+
.filter((c) => c.includes("add-iam-policy-binding"))
|
|
61
|
+
.map((c) => ({
|
|
62
|
+
where: c.slice(0, c.indexOf("add-iam-policy-binding") + 2).join(" "),
|
|
63
|
+
member: flag(c, "member"),
|
|
64
|
+
role: flag(c, "role") as string,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sleep = async () => {};
|
|
69
|
+
|
|
70
|
+
describe("ensureSchedulerTick", () => {
|
|
71
|
+
it("grants the scheduler only self-signing, IAP access, workflow start and logs", async () => {
|
|
72
|
+
const { run, calls } = fakeGcloud();
|
|
73
|
+
const r = await ensureSchedulerTick(TARGET, { run, sleep });
|
|
74
|
+
expect(r.ok).toBe(true);
|
|
75
|
+
const mine = grants(calls).filter((g) => g.member === MEMBER);
|
|
76
|
+
expect(mine.map((g) => [g.where, g.role])).toEqual([
|
|
77
|
+
[`iam service-accounts add-iam-policy-binding ${SA}`, "roles/iam.serviceAccountTokenCreator"],
|
|
78
|
+
["projects add-iam-policy-binding acme", "roles/workflows.invoker"],
|
|
79
|
+
["projects add-iam-policy-binding acme", "roles/logging.logWriter"],
|
|
80
|
+
[
|
|
81
|
+
"iap web add-iam-policy-binding --resource-type=backend-services",
|
|
82
|
+
"roles/iap.httpsResourceAccessor",
|
|
83
|
+
],
|
|
84
|
+
]);
|
|
85
|
+
for (const g of mine) {
|
|
86
|
+
for (const forbidden of SCHEDULER_FORBIDDEN_ROLES) {
|
|
87
|
+
expect(g.role.startsWith(forbidden)).toBe(false);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Nobody else is granted anything.
|
|
91
|
+
expect(grants(calls).every((g) => g.member === MEMBER)).toBe(true);
|
|
92
|
+
// The IAP grant names the admin's resource.
|
|
93
|
+
const iap = calls.find((c) => c[0] === "iap") as string[];
|
|
94
|
+
expect(iap).toContain("--resource-type=backend-services");
|
|
95
|
+
expect(iap).toContain("--service=caelo-production-admin-backend-1a2b");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("signs as itself only: token creator on its own account, never on another", async () => {
|
|
99
|
+
const { run, calls } = fakeGcloud();
|
|
100
|
+
await ensureSchedulerTick(TARGET, { run, sleep });
|
|
101
|
+
const saGrants = calls.filter(
|
|
102
|
+
(c) => c[0] === "iam" && c[1] === "service-accounts" && c[2] === "add-iam-policy-binding",
|
|
103
|
+
);
|
|
104
|
+
expect(saGrants.map((c) => c[3])).toEqual([SA]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("deploys the workflow as the scheduler account and schedules it every 5 minutes", async () => {
|
|
108
|
+
const { run, calls, sources } = fakeGcloud();
|
|
109
|
+
await ensureSchedulerTick(TARGET, { run, sleep });
|
|
110
|
+
const deploy = calls.find((c) => c[0] === "workflows" && c[1] === "deploy") as string[];
|
|
111
|
+
expect(deploy[2]).toBe("caelo-production-scheduler-tick");
|
|
112
|
+
expect(flag(deploy, "service-account")).toBe(SA);
|
|
113
|
+
expect(flag(deploy, "location")).toBe("europe-west1");
|
|
114
|
+
expect(sources[0]).toContain(`url: "https://admin.acme.com${SCHEDULER_TICK_PATH}"`);
|
|
115
|
+
const create = calls.find(
|
|
116
|
+
(c) => c[0] === "scheduler" && c[1] === "jobs" && c[2] === "create",
|
|
117
|
+
) as string[];
|
|
118
|
+
expect(create[4]).toBe("caelo-production-scheduler-tick");
|
|
119
|
+
expect(flag(create, "schedule")).toBe("*/5 * * * *");
|
|
120
|
+
expect(flag(create, "time-zone")).toBe("Etc/UTC");
|
|
121
|
+
expect(flag(create, "uri")).toBe(
|
|
122
|
+
"https://workflowexecutions.googleapis.com/v1/projects/acme/locations/europe-west1/workflows/caelo-production-scheduler-tick/executions",
|
|
123
|
+
);
|
|
124
|
+
expect(flag(create, "oauth-service-account-email")).toBe(SA);
|
|
125
|
+
// OAuth (a googleapis.com target), never an OIDC token IAP would refuse.
|
|
126
|
+
expect(create.some((a) => a.startsWith("--oidc"))).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("updates the existing job on a re-run, at the knob's new cadence", async () => {
|
|
130
|
+
const { run, calls } = fakeGcloud([[/^scheduler jobs describe/, ok("projects/acme/…")]]);
|
|
131
|
+
const r = await ensureSchedulerTick({ ...TARGET, intervalMinutes: 1 }, { run, sleep });
|
|
132
|
+
expect(r.ok).toBe(true);
|
|
133
|
+
const update = calls.find(
|
|
134
|
+
(c) => c[0] === "scheduler" && c[1] === "jobs" && c[2] === "update",
|
|
135
|
+
) as string[];
|
|
136
|
+
expect(flag(update, "schedule")).toBe("* * * * *");
|
|
137
|
+
expect(calls.some((c) => c[0] === "scheduler" && c[2] === "create")).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("refuses an interval outside 1-15 minutes before touching the project", async () => {
|
|
141
|
+
const { run, calls } = fakeGcloud();
|
|
142
|
+
const r = await ensureSchedulerTick({ ...TARGET, intervalMinutes: 30 }, { run, sleep });
|
|
143
|
+
expect(r.ok).toBe(false);
|
|
144
|
+
expect(r.ok ? "" : r.error).toContain("between 1 and 15");
|
|
145
|
+
expect(calls).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("refuses an admin URL that is not a plain https origin", async () => {
|
|
149
|
+
for (const bad of ["http://admin.acme.com", "https://admin.acme.com/x", "https://a@b.com"]) {
|
|
150
|
+
const { run, calls } = fakeGcloud();
|
|
151
|
+
const r = await ensureSchedulerTick({ ...TARGET, adminUrl: bad }, { run, sleep });
|
|
152
|
+
expect(r.ok).toBe(false);
|
|
153
|
+
expect(calls).toEqual([]);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("stops at the first failing step and says which", async () => {
|
|
158
|
+
const { run, calls } = fakeGcloud([[/^workflows deploy/, fail("PERMISSION_DENIED: nope")]]);
|
|
159
|
+
const r = await ensureSchedulerTick(TARGET, { run, sleep });
|
|
160
|
+
expect(r.ok).toBe(false);
|
|
161
|
+
expect(r.ok ? "" : r.error).toContain("deploy workflow caelo-production-scheduler-tick");
|
|
162
|
+
expect(calls.some((c) => c[0] === "scheduler")).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("schedulerWorkflowSource", () => {
|
|
167
|
+
const src = schedulerWorkflowSource({ serviceAccount: SA, origin: "https://admin.acme.com" });
|
|
168
|
+
|
|
169
|
+
it("signs a JWT for the admin origin as the scheduler itself", () => {
|
|
170
|
+
expect(src).toContain(`iss: "${SA}"`);
|
|
171
|
+
expect(src).toContain(`sub: "${SA}"`);
|
|
172
|
+
expect(src).toContain('aud: "https://admin.acme.com/*"');
|
|
173
|
+
expect(src).toContain(
|
|
174
|
+
`url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${SA}:signJwt"`,
|
|
175
|
+
);
|
|
176
|
+
expect(src).toContain("type: OAuth2");
|
|
177
|
+
expect(src).toContain(`exp: \${now + 600}`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("posts the tick with the signed JWT and a bounded timeout", () => {
|
|
181
|
+
expect(src).toContain('url: "https://admin.acme.com/api/internal/scheduler/tick"');
|
|
182
|
+
expect(src).toContain(`Authorization: \${"Bearer " + signed.body.signedJwt}`);
|
|
183
|
+
expect(src).toMatch(/timeout: (\d+)/);
|
|
184
|
+
const timeout = Number(/timeout: (\d+)/.exec(src)?.[1]);
|
|
185
|
+
expect(timeout).toBeGreaterThan(300);
|
|
186
|
+
expect(timeout).toBeLessThanOrEqual(1800);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("tickOrigin", () => {
|
|
191
|
+
it("accepts the admin's origin, with or without a trailing slash", () => {
|
|
192
|
+
expect(tickOrigin("https://admin.acme.com/")).toEqual({
|
|
193
|
+
ok: true,
|
|
194
|
+
origin: "https://admin.acme.com",
|
|
195
|
+
});
|
|
196
|
+
expect(tickOrigin("https://caelo-production-admin-x-ew.a.run.app")).toEqual({
|
|
197
|
+
ok: true,
|
|
198
|
+
origin: "https://caelo-production-admin-x-ew.a.run.app",
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #1140 — set up (and converge) the scheduler tick on a Google Cloud install
|
|
5
|
+
* (`gcp`, `gcp-firebase`): the ONE Cloud Scheduler job that calls the
|
|
6
|
+
* admin's `/api/internal/scheduler/tick` every `schedulerIntervalMinutes`
|
|
7
|
+
* (1–15, default 5). Each task's own cadence lives in the admin's
|
|
8
|
+
* due-table; the tick only says "run what is due now". Run by the wizard
|
|
9
|
+
* after migrations and by `cms-provision upgrade`'s converge (and the
|
|
10
|
+
* converge job), as the operator's or the provisioner's identity.
|
|
11
|
+
*
|
|
12
|
+
* Why not Cloud Scheduler → admin directly. The admin sits behind IAP, and
|
|
13
|
+
* IAP accepts a service account's OIDC ID token only when its `aud` is an
|
|
14
|
+
* OAuth client allowlisted for programmatic access; an install on the
|
|
15
|
+
* Google-managed IAP client has none (cloud.google.com/iap/docs/
|
|
16
|
+
* authentication-howto). Cloud Scheduler's other credential, an OAuth
|
|
17
|
+
* access token, it sends only to `*.googleapis.com` (cloud.google.com/
|
|
18
|
+
* scheduler/docs/http-target-auth). What passes IAP for a service account
|
|
19
|
+
* on every install is a self-signed service-account JWT with `aud` =
|
|
20
|
+
* `<admin origin>/*` — the path caelo-mcp and admin-internal-google.ts
|
|
21
|
+
* take. Cloud Scheduler cannot sign one, so the job starts a Cloud
|
|
22
|
+
* Workflows execution (a googleapis.com target, OAuth), and the workflow
|
|
23
|
+
* signs the JWT as the scheduler account through the IAM Credentials API
|
|
24
|
+
* and POSTs the tick. No container per tick and no image to keep in step
|
|
25
|
+
* with the app-roll; cents a month (wizards/gcp-cost.ts).
|
|
26
|
+
*
|
|
27
|
+
* Why the CLI owns it and not the stacks: the same as audit-job-setup.ts —
|
|
28
|
+
* `upgrade` never runs Pulumi, and existing installs get the tick from here.
|
|
29
|
+
*
|
|
30
|
+
* Trust boundary. The scheduler's identity, `caelo-<env>-scheduler`, holds:
|
|
31
|
+
*
|
|
32
|
+
* - `roles/iam.serviceAccountTokenCreator` on ITSELF only (signJwt);
|
|
33
|
+
* - `roles/iap.httpsResourceAccessor` on the admin's IAP resource (the
|
|
34
|
+
* operator-access sync job keeps it there, operator-access.ts);
|
|
35
|
+
* - `roles/workflows.invoker` on the project (Cloud Scheduler starts the
|
|
36
|
+
* workflow as it; Workflows has no per-workflow IAM policy to bind to);
|
|
37
|
+
* - `roles/logging.logWriter` (the workflow's error call logs).
|
|
38
|
+
*
|
|
39
|
+
* Nothing else: no Secret Manager, no Cloud SQL, no Cloud Run rights. What
|
|
40
|
+
* it can do with the admin is the admin's to decide: the tick endpoint
|
|
41
|
+
* accepts exactly this account's IAP identity (`CAELO_SCHEDULER_SERVICE_
|
|
42
|
+
* ACCOUNT` on the admin) and only ever runs the due-table, never a build
|
|
43
|
+
* or a publish (scripts/no-automatic-production-deploy.test.ts).
|
|
44
|
+
* {@link SCHEDULER_FORBIDDEN_ROLES} names that line; the tests assert no
|
|
45
|
+
* grant crosses it.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
49
|
+
import { tmpdir } from "node:os";
|
|
50
|
+
import { join } from "node:path";
|
|
51
|
+
import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
|
|
52
|
+
import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
|
|
53
|
+
import {
|
|
54
|
+
schedulerJobName,
|
|
55
|
+
schedulerServiceAccountEmail,
|
|
56
|
+
schedulerServiceAccountId,
|
|
57
|
+
schedulerWorkflowName,
|
|
58
|
+
} from "./gcp-names.js";
|
|
59
|
+
import { type IapResource, iapResourceArgs } from "./mcp-iap.js";
|
|
60
|
+
import { schedulerCron } from "./stack-contract.js";
|
|
61
|
+
|
|
62
|
+
/** The admin path the workflow calls (apps/admin `api/internal/scheduler/tick`). */
|
|
63
|
+
export const SCHEDULER_TICK_PATH = "/api/internal/scheduler/tick";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How long the workflow waits for the admin's answer, in seconds: Workflows'
|
|
67
|
+
* maximum (cloud.google.com/workflows/docs/reference/stdlib/http/post). The
|
|
68
|
+
* admin holds the request until the tick's work is done: it claims tasks for
|
|
69
|
+
* at most its budget (4 minutes, admin-core scheduler/tick.ts) and then waits
|
|
70
|
+
* at most one task's lease (10 minutes) for the last one, so 14 minutes in
|
|
71
|
+
* the worst case, inside the admin's own 3600 s request timeout.
|
|
72
|
+
*/
|
|
73
|
+
export const SCHEDULER_TICK_TIMEOUT_SECONDS = 1800;
|
|
74
|
+
|
|
75
|
+
/** Lifetime of the IAP credential the workflow signs per tick. */
|
|
76
|
+
const JWT_LIFETIME_SECONDS = 600;
|
|
77
|
+
|
|
78
|
+
/** APIs the tick needs on the project. */
|
|
79
|
+
export const SCHEDULER_APIS = [
|
|
80
|
+
"cloudscheduler.googleapis.com",
|
|
81
|
+
"workflows.googleapis.com",
|
|
82
|
+
"iamcredentials.googleapis.com",
|
|
83
|
+
] as const;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Roles the scheduler's identity must never hold anywhere. Prefix matches
|
|
87
|
+
* (`roles/secretmanager.` = every Secret Manager role).
|
|
88
|
+
*/
|
|
89
|
+
export const SCHEDULER_FORBIDDEN_ROLES = [
|
|
90
|
+
"roles/owner",
|
|
91
|
+
"roles/editor",
|
|
92
|
+
"roles/viewer",
|
|
93
|
+
"roles/run.",
|
|
94
|
+
"roles/iam.serviceAccountUser",
|
|
95
|
+
"roles/iam.serviceAccountAdmin",
|
|
96
|
+
"roles/iam.securityAdmin",
|
|
97
|
+
"roles/iap.admin",
|
|
98
|
+
"roles/iap.settingsAdmin",
|
|
99
|
+
"roles/secretmanager.",
|
|
100
|
+
"roles/cloudsql.",
|
|
101
|
+
"roles/compute.",
|
|
102
|
+
"roles/storage.",
|
|
103
|
+
"roles/resourcemanager.",
|
|
104
|
+
"roles/workflows.admin",
|
|
105
|
+
"roles/workflows.editor",
|
|
106
|
+
"roles/cloudscheduler.",
|
|
107
|
+
] as const;
|
|
108
|
+
|
|
109
|
+
export interface SchedulerTickTarget {
|
|
110
|
+
readonly projectId: string;
|
|
111
|
+
readonly region: string;
|
|
112
|
+
/** Pulumi stack name (`production`). */
|
|
113
|
+
readonly env: string;
|
|
114
|
+
/**
|
|
115
|
+
* Where the admin answers behind IAP, an origin: `https://admin.<domain>`
|
|
116
|
+
* on gcp, the admin service's URL on gcp-firebase (remote-mcp.ts
|
|
117
|
+
* RemoteMcpIdentity.adminUrl, the same value as the admin's
|
|
118
|
+
* CAELO_ADMIN_URL).
|
|
119
|
+
*/
|
|
120
|
+
readonly adminUrl: string;
|
|
121
|
+
/** The admin's IAP resource (the service on gcp-firebase, the LB backend on gcp). */
|
|
122
|
+
readonly resource: IapResource;
|
|
123
|
+
/** The `schedulerIntervalMinutes` knob (1–15). */
|
|
124
|
+
readonly intervalMinutes: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type SchedulerTickResult =
|
|
128
|
+
| { readonly ok: true; readonly done: string[]; readonly serviceAccount: string }
|
|
129
|
+
| { readonly ok: false; readonly done: string[]; readonly error: string };
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The tick's admin origin, or an error naming why it is not one: an https
|
|
133
|
+
* origin without path, query or credentials (it is written into the
|
|
134
|
+
* workflow's source, so nothing but an origin may get there).
|
|
135
|
+
*/
|
|
136
|
+
export function tickOrigin(
|
|
137
|
+
adminUrl: string,
|
|
138
|
+
): { ok: true; origin: string } | { ok: false; error: string } {
|
|
139
|
+
let u: URL;
|
|
140
|
+
try {
|
|
141
|
+
u = new URL(adminUrl);
|
|
142
|
+
} catch {
|
|
143
|
+
return { ok: false, error: `the admin URL ${JSON.stringify(adminUrl)} is not a URL` };
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
u.protocol !== "https:" ||
|
|
147
|
+
u.username ||
|
|
148
|
+
u.password ||
|
|
149
|
+
(u.pathname !== "/" && u.pathname !== "") ||
|
|
150
|
+
u.search ||
|
|
151
|
+
u.hash ||
|
|
152
|
+
!/^[a-z0-9.-]+$/.test(u.hostname)
|
|
153
|
+
) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: `the admin URL ${JSON.stringify(adminUrl)} is not a plain https origin`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, origin: u.origin };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The workflow one tick runs (Cloud Workflows YAML): sign a service-account
|
|
164
|
+
* JWT for the admin's IAP as the scheduler account itself (the IAM
|
|
165
|
+
* Credentials API, authenticated with the workflow's own OAuth token), then
|
|
166
|
+
* POST the tick with it. A refused or failed tick (HTTP ≥ 400) fails the
|
|
167
|
+
* execution, so it shows in the workflow's execution history and logs.
|
|
168
|
+
*/
|
|
169
|
+
export function schedulerWorkflowSource(opts: {
|
|
170
|
+
readonly serviceAccount: string;
|
|
171
|
+
readonly origin: string;
|
|
172
|
+
}): string {
|
|
173
|
+
const { serviceAccount: sa, origin } = opts;
|
|
174
|
+
return `# Caelo scheduler tick (#1140) — generated by cms-provision, do not edit.
|
|
175
|
+
main:
|
|
176
|
+
steps:
|
|
177
|
+
- claims:
|
|
178
|
+
assign:
|
|
179
|
+
- now: \${int(sys.now())}
|
|
180
|
+
- jwtClaims:
|
|
181
|
+
iss: "${sa}"
|
|
182
|
+
sub: "${sa}"
|
|
183
|
+
aud: "${origin}/*"
|
|
184
|
+
iat: \${now}
|
|
185
|
+
exp: \${now + ${JWT_LIFETIME_SECONDS}}
|
|
186
|
+
- sign:
|
|
187
|
+
call: http.post
|
|
188
|
+
args:
|
|
189
|
+
url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${sa}:signJwt"
|
|
190
|
+
auth:
|
|
191
|
+
type: OAuth2
|
|
192
|
+
body:
|
|
193
|
+
payload: \${json.encode_to_string(jwtClaims)}
|
|
194
|
+
result: signed
|
|
195
|
+
- tick:
|
|
196
|
+
call: http.post
|
|
197
|
+
args:
|
|
198
|
+
url: "${origin}${SCHEDULER_TICK_PATH}"
|
|
199
|
+
headers:
|
|
200
|
+
Authorization: \${"Bearer " + signed.body.signedJwt}
|
|
201
|
+
timeout: ${SCHEDULER_TICK_TIMEOUT_SECONDS}
|
|
202
|
+
result: answer
|
|
203
|
+
- done:
|
|
204
|
+
return: \${answer.code}
|
|
205
|
+
`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Ensure the scheduler tick and everything it needs. Never throws for a gcloud failure. */
|
|
209
|
+
export async function ensureSchedulerTick(
|
|
210
|
+
target: SchedulerTickTarget,
|
|
211
|
+
deps: { run?: GcloudRunner; sleep?: Sleep } = {},
|
|
212
|
+
): Promise<SchedulerTickResult> {
|
|
213
|
+
const run = deps.run ?? defaultGcloud;
|
|
214
|
+
const sleep = deps.sleep ?? realSleep;
|
|
215
|
+
const retry = (args: string[]) => runWithRetry(run, sleep, args);
|
|
216
|
+
const { projectId, region, env } = target;
|
|
217
|
+
const project = `--project=${projectId}`;
|
|
218
|
+
const sa = schedulerServiceAccountEmail(projectId, env);
|
|
219
|
+
const member = `serviceAccount:${sa}`;
|
|
220
|
+
const workflow = schedulerWorkflowName(env);
|
|
221
|
+
const job = schedulerJobName(env);
|
|
222
|
+
const done: string[] = [];
|
|
223
|
+
const fail = (step: string, r: GcloudResult): SchedulerTickResult => ({
|
|
224
|
+
ok: false,
|
|
225
|
+
done,
|
|
226
|
+
error: `${step}: ${r.stderr.trim()}`,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const origin = tickOrigin(target.adminUrl);
|
|
230
|
+
if (!origin.ok) return { ok: false, done, error: origin.error };
|
|
231
|
+
let cron: string;
|
|
232
|
+
try {
|
|
233
|
+
cron = schedulerCron(target.intervalMinutes);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
return { ok: false, done, error: (e as Error).message };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 1. APIs.
|
|
239
|
+
const api = await run(["services", "enable", ...SCHEDULER_APIS, project, "--quiet"]);
|
|
240
|
+
if (!api.ok) return fail(`enable ${SCHEDULER_APIS.join(", ")}`, api);
|
|
241
|
+
done.push(`APIs enabled (${SCHEDULER_APIS.join(", ")})`);
|
|
242
|
+
|
|
243
|
+
// 2. The scheduler's own service account.
|
|
244
|
+
const describe = await run([
|
|
245
|
+
"iam",
|
|
246
|
+
"service-accounts",
|
|
247
|
+
"describe",
|
|
248
|
+
sa,
|
|
249
|
+
project,
|
|
250
|
+
"--format=value(email)",
|
|
251
|
+
]);
|
|
252
|
+
if (!describe.ok) {
|
|
253
|
+
const create = await run([
|
|
254
|
+
"iam",
|
|
255
|
+
"service-accounts",
|
|
256
|
+
"create",
|
|
257
|
+
schedulerServiceAccountId(env),
|
|
258
|
+
"--display-name=Caelo scheduler tick",
|
|
259
|
+
"--description=Calls the admin's scheduler tick through IAP every few minutes; signs its own IAP credential, nothing else.",
|
|
260
|
+
project,
|
|
261
|
+
]);
|
|
262
|
+
if (!create.ok && !/already exists/i.test(create.stderr)) return fail(`create ${sa}`, create);
|
|
263
|
+
}
|
|
264
|
+
done.push(`service account ${sa}`);
|
|
265
|
+
|
|
266
|
+
// 3. Its grants (see the file comment).
|
|
267
|
+
const self = await retry([
|
|
268
|
+
"iam",
|
|
269
|
+
"service-accounts",
|
|
270
|
+
"add-iam-policy-binding",
|
|
271
|
+
sa,
|
|
272
|
+
`--member=${member}`,
|
|
273
|
+
"--role=roles/iam.serviceAccountTokenCreator",
|
|
274
|
+
"--condition=None",
|
|
275
|
+
project,
|
|
276
|
+
"--quiet",
|
|
277
|
+
"--format=none",
|
|
278
|
+
]);
|
|
279
|
+
if (!self.ok) return fail("let the scheduler sign its IAP credential as itself", self);
|
|
280
|
+
done.push(`scheduler: roles/iam.serviceAccountTokenCreator on ${sa} (itself, signJwt)`);
|
|
281
|
+
|
|
282
|
+
for (const g of [
|
|
283
|
+
{ role: "roles/workflows.invoker", why: "Cloud Scheduler starts the tick workflow as it" },
|
|
284
|
+
{ role: "roles/logging.logWriter", why: "the workflow logs failed calls" },
|
|
285
|
+
]) {
|
|
286
|
+
const r = await retry([
|
|
287
|
+
"projects",
|
|
288
|
+
"add-iam-policy-binding",
|
|
289
|
+
projectId,
|
|
290
|
+
`--member=${member}`,
|
|
291
|
+
`--role=${g.role}`,
|
|
292
|
+
"--condition=None",
|
|
293
|
+
"--quiet",
|
|
294
|
+
"--format=none",
|
|
295
|
+
]);
|
|
296
|
+
if (!r.ok) return fail(`grant the scheduler ${g.role} (${g.why})`, r);
|
|
297
|
+
done.push(`scheduler: ${g.role} on the project`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const iap = await retry([
|
|
301
|
+
"iap",
|
|
302
|
+
"web",
|
|
303
|
+
"add-iam-policy-binding",
|
|
304
|
+
...iapResourceArgs(target.resource),
|
|
305
|
+
`--member=${member}`,
|
|
306
|
+
"--role=roles/iap.httpsResourceAccessor",
|
|
307
|
+
"--condition=None",
|
|
308
|
+
project,
|
|
309
|
+
"--quiet",
|
|
310
|
+
]);
|
|
311
|
+
if (!iap.ok) return fail("let the scheduler pass the admin's IAP", iap);
|
|
312
|
+
done.push(`scheduler: roles/iap.httpsResourceAccessor on the admin (${target.resource.service})`);
|
|
313
|
+
|
|
314
|
+
// 4. The workflow. A private directory (mkdtemp, mode 700): no other local
|
|
315
|
+
// user can swap the file between write and read.
|
|
316
|
+
const dir = mkdtempSync(join(tmpdir(), "caelo-scheduler-"));
|
|
317
|
+
try {
|
|
318
|
+
const file = join(dir, "tick.yaml");
|
|
319
|
+
writeFileSync(file, schedulerWorkflowSource({ serviceAccount: sa, origin: origin.origin }), {
|
|
320
|
+
mode: 0o600,
|
|
321
|
+
});
|
|
322
|
+
const deploy = await retry([
|
|
323
|
+
"workflows",
|
|
324
|
+
"deploy",
|
|
325
|
+
workflow,
|
|
326
|
+
`--source=${file}`,
|
|
327
|
+
`--location=${region}`,
|
|
328
|
+
`--service-account=${sa}`,
|
|
329
|
+
"--call-log-level=log-errors-only",
|
|
330
|
+
`--description=Caelo scheduler tick (#1140): calls ${origin.origin}${SCHEDULER_TICK_PATH}`,
|
|
331
|
+
project,
|
|
332
|
+
"--quiet",
|
|
333
|
+
]);
|
|
334
|
+
if (!deploy.ok) return fail(`deploy workflow ${workflow}`, deploy);
|
|
335
|
+
} finally {
|
|
336
|
+
rmSync(dir, { recursive: true, force: true });
|
|
337
|
+
}
|
|
338
|
+
done.push(`workflow ${workflow} (calls ${origin.origin}${SCHEDULER_TICK_PATH})`);
|
|
339
|
+
|
|
340
|
+
// 5. The one Cloud Scheduler job.
|
|
341
|
+
const scheduleArgs = [
|
|
342
|
+
job,
|
|
343
|
+
`--location=${region}`,
|
|
344
|
+
project,
|
|
345
|
+
`--schedule=${cron}`,
|
|
346
|
+
"--time-zone=Etc/UTC",
|
|
347
|
+
`--uri=https://workflowexecutions.googleapis.com/v1/projects/${projectId}/locations/${region}/workflows/${workflow}/executions`,
|
|
348
|
+
"--http-method=POST",
|
|
349
|
+
`--oauth-service-account-email=${sa}`,
|
|
350
|
+
// A tick that has not answered when the next one is due is the admin's
|
|
351
|
+
// lease to sort out (its due-table), not a reason to retry this one.
|
|
352
|
+
"--max-retry-attempts=0",
|
|
353
|
+
"--quiet",
|
|
354
|
+
];
|
|
355
|
+
const existing = await run([
|
|
356
|
+
"scheduler",
|
|
357
|
+
"jobs",
|
|
358
|
+
"describe",
|
|
359
|
+
job,
|
|
360
|
+
`--location=${region}`,
|
|
361
|
+
project,
|
|
362
|
+
"--format=value(name)",
|
|
363
|
+
]);
|
|
364
|
+
const sched = await retry([
|
|
365
|
+
"scheduler",
|
|
366
|
+
"jobs",
|
|
367
|
+
existing.ok ? "update" : "create",
|
|
368
|
+
"http",
|
|
369
|
+
...scheduleArgs,
|
|
370
|
+
]);
|
|
371
|
+
if (!sched.ok) return fail(`schedule ${job}`, sched);
|
|
372
|
+
done.push(`Cloud Scheduler job ${job} (${cron}, UTC)`);
|
|
373
|
+
|
|
374
|
+
return { ok: true, done, serviceAccount: sa };
|
|
375
|
+
}
|
|
@@ -203,6 +203,7 @@ describe("restoreSelfHosted", () => {
|
|
|
203
203
|
"<converge roles>",
|
|
204
204
|
`${MIGRATE} admin`,
|
|
205
205
|
`${MIGRATE} public`,
|
|
206
|
+
`${MIGRATE} public-staging`,
|
|
206
207
|
"run --rm --no-deps -T --entrypoint sh caelo-admin -c set -e; find /srv/caelo /data/media -mindepth 1 -delete; tar -C / -xf -",
|
|
207
208
|
"up -d --remove-orphans",
|
|
208
209
|
"exec -T caelo-admin bun -e <probe>",
|