@caelo-cms/provisioning 0.11.0 → 0.11.1

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/src/cdn-copy.ts CHANGED
@@ -11,13 +11,12 @@
11
11
  * respects the long TTL.
12
12
  * - Azure → Front Door purge + warm endpoint.
13
13
  *
14
- * The redeploy-orchestrator's `cdnCopyTick()` (currently a no-op for
15
- * cloud installs) drains the per-asset CDN-pin queue using the adapter
16
- * registered for the active provider.
14
+ * A worker that drains the per-asset CDN-pin queue (none ships yet) uses
15
+ * the adapter registered for the active provider.
17
16
  *
18
17
  * Each provider's stack publishes a `CdnCopyAdapter` instance via its
19
18
  * Pulumi outputs (the actual implementation is dynamically imported by
20
- * the orchestrator at runtime so `@caelo-cms/provisioning` doesn't pull in
19
+ * that worker at runtime so `@caelo-cms/provisioning` doesn't pull in
21
20
  * `@aws-sdk/...`/`@google-cloud/...`/`@azure/...` as static deps). The
22
21
  * interface defined here is the only thing both sides agree on.
23
22
  */
@@ -0,0 +1,64 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Cloud Run CPU allocation. A missing `cpuIdle` in the v2 API means
5
+ * always-on CPU once `resources` is set, so both stacks ran the admin and
6
+ * the gateway billed around the clock. The stacks now set it from
7
+ * SERVICE_CPU_IDLE, and an upgrade moves existing installs there.
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import { readFileSync } from "node:fs";
12
+ import { resolve } from "node:path";
13
+ import { SERVICE_CPU_IDLE } from "./stack-contract.js";
14
+ import { liveCpuIdle, planCpuIdle } from "./stack-converge.js";
15
+
16
+ const described = (annotations?: Record<string, string>) =>
17
+ JSON.stringify({ spec: { template: { metadata: annotations ? { annotations } : {} } } });
18
+
19
+ describe("liveCpuIdle", () => {
20
+ it("reads the revision's cpu-throttling annotation", () => {
21
+ expect(liveCpuIdle(described({ "run.googleapis.com/cpu-throttling": "false" }))).toBe(false);
22
+ expect(liveCpuIdle(described({ "run.googleapis.com/cpu-throttling": "true" }))).toBe(true);
23
+ });
24
+
25
+ it("treats a missing annotation as Cloud Run's default, request-based CPU", () => {
26
+ expect(liveCpuIdle(described())).toBe(true);
27
+ expect(liveCpuIdle(described({ "autoscaling.knative.dev/maxScale": "5" }))).toBe(true);
28
+ });
29
+ });
30
+
31
+ describe("planCpuIdle", () => {
32
+ it("moves an always-on service to request-based CPU", () => {
33
+ expect(planCpuIdle(false, true)).toEqual({
34
+ ok: true,
35
+ flags: ["--cpu-throttling"],
36
+ changes: ["CPU: always allocated → only during requests"],
37
+ });
38
+ });
39
+
40
+ it("changes nothing when the service already matches", () => {
41
+ expect(planCpuIdle(true, true)).toEqual({ ok: true, flags: [], changes: [] });
42
+ expect(planCpuIdle(false, false)).toEqual({ ok: true, flags: [], changes: [] });
43
+ });
44
+
45
+ it("never switches a service to always-on CPU", () => {
46
+ expect(planCpuIdle(true, false)).toEqual({ ok: true, flags: [], changes: [] });
47
+ });
48
+ });
49
+
50
+ describe("the stacks set cpuIdle", () => {
51
+ it("the gateway runs request-billed", () => {
52
+ expect(SERVICE_CPU_IDLE.gateway).toBe(true);
53
+ });
54
+
55
+ for (const provider of ["gcp", "gcp-firebase"]) {
56
+ it(`${provider} takes cpuIdle from the contract`, () => {
57
+ const stack = readFileSync(
58
+ resolve(import.meta.dir, `../stacks/${provider}/index.ts`),
59
+ "utf8",
60
+ );
61
+ expect(stack).toContain("cpuIdle: SERVICE_CPU_IDLE[args.serviceName]");
62
+ });
63
+ }
64
+ });
@@ -620,6 +620,25 @@ export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvV
620
620
  */
621
621
  export const GATEWAY_TIMEOUT_SECONDS = 3600;
622
622
 
623
+ /**
624
+ * Whether each Cloud Run service gets CPU only while it handles a request
625
+ * (Cloud Run's request-based billing, `cpuIdle: true`) or for its whole
626
+ * lifetime (`false`, instance-based billing). Set explicitly: the v2 API
627
+ * reads a missing `cpuIdle` as `false` as soon as a service declares
628
+ * `resources`, which is how both services ran with always-on CPU without
629
+ * anyone choosing it — billed for every idle minute an instance stayed up.
630
+ *
631
+ * The gateway only does work inside requests. The admin still runs its
632
+ * background work (Tier 5: audit worker, importer, plugin cron, GC) on
633
+ * in-process timers that need CPU between requests.
634
+ */
635
+ export const SERVICE_CPU_IDLE: Readonly<Record<CloudRunSlug, boolean>> = {
636
+ // FIXME(issue #1140): true once the admin's background work no longer
637
+ // depends on CPU between requests (jobs, scheduler, plugin host).
638
+ admin: false,
639
+ gateway: true,
640
+ };
641
+
623
642
  /** Admin env var naming the Secret Manager secret the Owner writes the Google sign-in client to. */
624
643
  export const OPERATOR_OAUTH_CLIENT_STORE_ENV = "CAELO_OPERATOR_OAUTH_CLIENT_STORE";
625
644
 
@@ -25,6 +25,8 @@
25
25
  * - {@link planSecretFiles} / {@link planRequestTimeout} (#806): the
26
26
  * gateway's secret file (the remote-MCP OAuth client) and its request
27
27
  * timeout, in the same `services update`.
28
+ * - {@link planCpuIdle}: request-based CPU where the contract wants it
29
+ * (`SERVICE_CPU_IDLE`), in the same `services update`.
28
30
  */
29
31
 
30
32
  import { removeDefaultFirewallRules } from "./default-firewall.js";
@@ -1152,6 +1154,35 @@ export function planRequestTimeout(live: number | null, desired: number): RollAd
1152
1154
  };
1153
1155
  }
1154
1156
 
1157
+ /**
1158
+ * Whether a described service gets CPU only during requests. Cloud Run keeps
1159
+ * the setting in the `run.googleapis.com/cpu-throttling` revision
1160
+ * annotation; a missing annotation is Cloud Run's default, throttled.
1161
+ */
1162
+ export function liveCpuIdle(serviceJson: string): boolean {
1163
+ const svc = JSON.parse(serviceJson) as {
1164
+ spec?: { template?: { metadata?: { annotations?: Record<string, string> } } };
1165
+ };
1166
+ return (
1167
+ svc.spec?.template?.metadata?.annotations?.["run.googleapis.com/cpu-throttling"] !== "false"
1168
+ );
1169
+ }
1170
+
1171
+ /**
1172
+ * `--cpu-throttling` when the contract wants request-based CPU and the
1173
+ * service still runs always-on. Never the other way: always-on CPU is
1174
+ * billed around the clock, so a service the operator already moved to
1175
+ * request-based CPU keeps it.
1176
+ */
1177
+ export function planCpuIdle(live: boolean, desired: boolean): RollAdditionsPlan {
1178
+ if (!desired || live) return { ok: true, flags: [], changes: [] };
1179
+ return {
1180
+ ok: true,
1181
+ flags: ["--cpu-throttling"],
1182
+ changes: ["CPU: always allocated → only during requests"],
1183
+ };
1184
+ }
1185
+
1155
1186
  /**
1156
1187
  * The single `gcloud run services update` that rolls a service to a new
1157
1188
  * image, its run SA and its env changes, so all land in one new revision.
@@ -32,6 +32,7 @@ import {
32
32
  type CloudRunEnvVar,
33
33
  GATEWAY_TIMEOUT_SECONDS,
34
34
  RETIRED_SECRET_FILES,
35
+ SERVICE_CPU_IDLE,
35
36
  SERVICE_SECRET_FILES,
36
37
  } from "../stack-contract.js";
37
38
  import {
@@ -42,10 +43,12 @@ import {
42
43
  type LiveVolumes,
43
44
  liveContainerEnv,
44
45
  liveContainerMemory,
46
+ liveCpuIdle,
45
47
  liveTimeoutSeconds,
46
48
  liveVolumes,
47
49
  planAdminMemory,
48
50
  planContractEnv,
51
+ planCpuIdle,
49
52
  planMediaVolume,
50
53
  planRequestTimeout,
51
54
  planSecretFiles,
@@ -105,6 +108,8 @@ export interface ServicePlan {
105
108
  readonly liveVolumes: LiveVolumes;
106
109
  /** Request timeout now, in seconds (null = Cloud Run's default). */
107
110
  readonly liveTimeout: number | null;
111
+ /** Whether the service gets CPU only during requests now. */
112
+ readonly liveCpuIdle: boolean;
108
113
  }
109
114
 
110
115
  /** A service plan plus the changes its roll applies. */
@@ -113,7 +118,8 @@ export interface RollPlan extends ServicePlan {
113
118
  readonly envChanges: readonly EnvChange[];
114
119
  /**
115
120
  * #553 — e.g. `--memory=2Gi` when the admin runs below the stack default;
116
- * #806 — `--timeout=3600` when the gateway runs below its contract timeout.
121
+ * #806 — `--timeout=3600` when the gateway runs below its contract timeout;
122
+ * `--cpu-throttling` when the contract wants request-based CPU.
117
123
  */
118
124
  readonly resourceFlags: readonly string[];
119
125
  readonly resourceChanges: readonly string[];
@@ -203,6 +209,7 @@ export async function planAppRoll(
203
209
  liveMemory: liveContainerMemory(json),
204
210
  liveVolumes: liveVolumes(json),
205
211
  liveTimeout: liveTimeoutSeconds(json),
212
+ liveCpuIdle: liveCpuIdle(json),
206
213
  });
207
214
  }
208
215
  const env = planEnv(install, services, {});
@@ -264,14 +271,17 @@ export async function planAppRoll(
264
271
  s.slug === "gateway" ? planRequestTimeout(s.liveTimeout, GATEWAY_TIMEOUT_SECONDS) : null;
265
272
  const memoryFlags = memory?.ok ? memory.flags : [];
266
273
  const timeoutFlags = timeout?.ok ? timeout.flags : [];
274
+ const cpu = planCpuIdle(s.liveCpuIdle, SERVICE_CPU_IDLE[s.slug]);
275
+ const cpuFlags = cpu.ok ? cpu.flags : [];
267
276
  return {
268
277
  ...s,
269
278
  envFlags: env.services[s.slug].flags,
270
279
  envChanges: env.services[s.slug].changes,
271
- resourceFlags: [...memoryFlags, ...timeoutFlags],
280
+ resourceFlags: [...memoryFlags, ...timeoutFlags, ...cpuFlags],
272
281
  resourceChanges: [
273
282
  ...memoryFlags.map((f) => `${s.liveMemory ?? "(default)"} → ${f}`),
274
283
  ...(timeout?.ok ? timeout.changes : []),
284
+ ...(cpu.ok ? cpu.changes : []),
275
285
  ],
276
286
  volumeFlags: [...(s.slug === "admin" ? media.flags : []), ...secretFiles[s.slug].flags],
277
287
  volumeChanges: [...(s.slug === "admin" ? media.changes : []), ...secretFiles[s.slug].changes],
@@ -50,7 +50,8 @@ const secretRef = (name: string, secret: string) => ({
50
50
 
51
51
  /**
52
52
  * A described service, as `gcloud run services describe --format=json`
53
- * returns it. `ingress`: the service-level setting (#732).
53
+ * returns it. `ingress`: the service-level setting (#732). Always-on CPU,
54
+ * as every install the stacks created before `cpuIdle` was set runs.
54
55
  */
55
56
  const serviceJson = (image: string, env: unknown[], ingress = "all") =>
56
57
  JSON.stringify({
@@ -59,6 +60,7 @@ const serviceJson = (image: string, env: unknown[], ingress = "all") =>
59
60
  template: {
60
61
  metadata: {
61
62
  annotations: {
63
+ "run.googleapis.com/cpu-throttling": "false",
62
64
  "run.googleapis.com/network-interfaces": JSON.stringify([
63
65
  { network: "caelo-production-vpc", subnetwork: "caelo-production-subnet" },
64
66
  ]),
@@ -698,6 +700,20 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
698
700
  expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(2);
699
701
  });
700
702
 
703
+ it("moves the gateway to request-based CPU in its image roll; the admin keeps its CPU", async () => {
704
+ const events: string[] = [];
705
+ const { deps, calls } = upgradeDeps(events);
706
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
707
+ const rolls = calls.filter((c) =>
708
+ /^run services update \S+ --region europe-west1 .*--image/.test(c),
709
+ );
710
+ const admin = rolls.find((c) => c.startsWith(`run services update ${ADMIN} `)) ?? "";
711
+ const gateway = rolls.find((c) => c.startsWith(`run services update ${GATEWAY} `)) ?? "";
712
+ expect(gateway).toContain("--cpu-throttling");
713
+ expect(admin).not.toContain("cpu-throttling");
714
+ expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(2);
715
+ });
716
+
701
717
  it("#806 — on gcp, routes the discovery paths and adds the Cloud Armor rule before the roll", async () => {
702
718
  const events: string[] = [];
703
719
  const urlMap = {
@@ -91,13 +91,16 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
91
91
  },
92
92
  {
93
93
  name: `Cloud Run admin (${adminMemory})`,
94
+ // FIXME(issue #1140): ~$1 once the admin runs request-billed CPU
95
+ // (SERVICE_CPU_IDLE). Until then each editing session is billed for
96
+ // as long as the instance stays up, ~15 minutes past its last request.
94
97
  monthlyUsd:
95
98
  inputs.adminMinInstances === 0
96
- ? 1
99
+ ? 7
97
100
  : Math.round(1 + inputs.adminMinInstances * perMinInstanceUsd),
98
101
  notes:
99
102
  inputs.adminMinInstances === 0
100
- ? "scale-to-zero; ~$1/mo light editorial use"
103
+ ? "scale-to-zero, CPU always allocated while running; ~$7/mo light editorial use"
101
104
  : `${inputs.adminMinInstances} min-instance${inputs.adminMinInstances > 1 ? "s" : ""} (no cold start)`,
102
105
  },
103
106
  {
@@ -48,6 +48,7 @@ import {
48
48
  type RuntimeSecret,
49
49
  remoteMcpIdentityStackEnv,
50
50
  runtimeSecretBindings,
51
+ SERVICE_CPU_IDLE,
51
52
  STATIC_CDN_POLICY,
52
53
  secretFileVolumesTemplate,
53
54
  } from "../../dist/stack-contract.js";
@@ -738,7 +739,11 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
738
739
  ...(args.mediaVolume?.volumeMounts ?? []),
739
740
  ...secretFileVolumesTemplate(args.serviceName, env).volumeMounts,
740
741
  ],
741
- resources: { limits: { cpu: "1", memory: args.memory } },
742
+ // Explicit: a missing cpuIdle means always-on CPU (stack-contract.ts).
743
+ resources: {
744
+ limits: { cpu: "1", memory: args.memory },
745
+ cpuIdle: SERVICE_CPU_IDLE[args.serviceName],
746
+ },
742
747
  },
743
748
  ],
744
749
  vpcAccess: {
@@ -53,6 +53,7 @@ import {
53
53
  type RuntimeSecret,
54
54
  remoteMcpIdentityStackEnv,
55
55
  runtimeSecretBindings,
56
+ SERVICE_CPU_IDLE,
56
57
  secretFileVolumesTemplate,
57
58
  } from "../../dist/stack-contract.js";
58
59
 
@@ -598,7 +599,11 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
598
599
  ...(args.mediaVolume?.volumeMounts ?? []),
599
600
  ...secretFileVolumesTemplate(args.serviceName, env).volumeMounts,
600
601
  ],
601
- resources: { limits: { cpu: "1", memory: args.memory } },
602
+ // Explicit: a missing cpuIdle means always-on CPU (stack-contract.ts).
603
+ resources: {
604
+ limits: { cpu: "1", memory: args.memory },
605
+ cpuIdle: SERVICE_CPU_IDLE[args.serviceName],
606
+ },
602
607
  },
603
608
  ],
604
609
  vpcAccess: {