@steve31415/baselib 3.3.0 → 3.3.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.
@@ -12,7 +12,7 @@ import { DEFAULT_ACCOUNT, loadConfig, loadHooks } from './config.js';
12
12
  import { must, mustJson } from './exec.js';
13
13
  import { openEvidence, PhaseTimer } from './evidence.js';
14
14
  import { acquireLock, clearHold, publishRetainedAssets, readHold, readReleaseRecord, releaseLock, verifyLock, writeReleaseRecord, } from './gcs.js';
15
- import { consistentBaseSha, servingStateOf, SHA40 } from './plan.js';
15
+ import { consistentBaseSha, readServingState, SHA40 } from './plan.js';
16
16
  const RELEASE_DEADLINE_MS = 25 * 60_000;
17
17
  const IMAGE_DIGEST = /@sha256:[0-9a-f]{64}$/;
18
18
  export async function runDeploy(options) {
@@ -87,12 +87,9 @@ export async function runDeploy(options) {
87
87
  }
88
88
  const serving = [];
89
89
  for (const service of config.services) {
90
- const described = await mustJson(runner, 'gcloud', [
91
- 'run', 'services', 'describe', service.name,
92
- ...gcloudBase, '--region', config.region, '--format=json',
93
- ]);
90
+ const { described, state } = await readServingState(runner, gcloudBase, config.region, service.name);
94
91
  await evidence.save(`${service.name}-before.json`, JSON.stringify(described, null, 2));
95
- serving.push(servingStateOf(service.name, described));
92
+ serving.push(state);
96
93
  }
97
94
  const base = consistentBaseSha(serving, sha);
98
95
  if (!base.ok)
@@ -1,4 +1,4 @@
1
- import type { ServingState } from './types.js';
1
+ import type { Runner, ServingState } from './types.js';
2
2
  export declare const SHA40: RegExp;
3
3
  /** Lock staleness is judged from the GCS object's server-side timeCreated,
4
4
  * never from anything the lock writer wrote (its clock may be wrong). */
@@ -41,8 +41,32 @@ export declare function pickRevisionForSha(revisions: unknown[], targetSha: stri
41
41
  revision: string;
42
42
  imageDigest: string;
43
43
  } | null;
44
- /** Extract serving state from a `gcloud run services describe --format=json`. */
45
- export declare function servingStateOf(service: string, described: unknown): ServingState;
44
+ /** The one revision carrying 100% of traffic in a `gcloud run services
45
+ * describe --format=json`, from *observed* traffic (`status.traffic`, never
46
+ * `spec.traffic`, which is only the desired state). */
47
+ export declare function servingRevisionName(service: string, described: unknown): string;
48
+ /**
49
+ * Extract serving state from a `gcloud run services describe --format=json`.
50
+ *
51
+ * The serving revision's BUILD_ID and image are read from the service
52
+ * template only when that revision IS the latest created one. When it is
53
+ * not — a previous release created a revision that never took traffic (a
54
+ * failed secret-access check, a crashed startup, an aborted deploy) — the
55
+ * template describes the *wrong* revision, so the caller must pass that
56
+ * revision's own `gcloud run revisions describe --format=json` as
57
+ * `servingRevision` (readServingState does). Lesson 2026-09-04, watchdog2:
58
+ * a re-run after a failed traffic switch read the new SHA out of the
59
+ * template, logged "already serving", wrote the release record, and left
60
+ * the old revision serving.
61
+ */
62
+ export declare function servingStateOf(service: string, described: unknown, servingRevision?: unknown): ServingState;
63
+ /** Describe a service and resolve its serving state, describing the serving
64
+ * revision too whenever the template no longer belongs to it. Returns the
65
+ * raw service description as well, for evidence. */
66
+ export declare function readServingState(runner: Runner, gcloudBase: string[], region: string, service: string): Promise<{
67
+ described: unknown;
68
+ state: ServingState;
69
+ }>;
46
70
  export declare function contentTypeFor(path: string): string;
47
71
  /** Parse release-record listing rows into {sha, at} entries. */
48
72
  export declare function releaseEntriesFrom(rows: {
@@ -1,6 +1,7 @@
1
1
  // Pure decision logic for pw-deploy/pw-rollback: everything here is
2
2
  // side-effect-free so the concurrency-sensitive choices (lock staleness,
3
3
  // rollback targeting, serving-state consistency) are unit-tested directly.
4
+ import { mustJson } from './exec.js';
4
5
  export const SHA40 = /^[0-9a-f]{40}$/;
5
6
  /** Lock staleness is judged from the GCS object's server-side timeCreated,
6
7
  * never from anything the lock writer wrote (its clock may be wrong). */
@@ -101,22 +102,71 @@ export function pickRevisionForSha(revisions, targetSha) {
101
102
  matches.sort((a, b) => b.created - a.created);
102
103
  return matches[0] ? { revision: matches[0].revision, imageDigest: matches[0].imageDigest } : null;
103
104
  }
104
- /** Extract serving state from a `gcloud run services describe --format=json`. */
105
- export function servingStateOf(service, described) {
105
+ /** The one revision carrying 100% of traffic in a `gcloud run services
106
+ * describe --format=json`, from *observed* traffic (`status.traffic`, never
107
+ * `spec.traffic`, which is only the desired state). */
108
+ export function servingRevisionName(service, described) {
106
109
  const d = described;
107
110
  const active = (d.status?.traffic ?? []).filter((t) => (t.percent ?? 0) === 100);
108
111
  if (active.length !== 1 || !active[0].revisionName) {
109
112
  throw new Error(`${service}: expected exactly one 100% traffic target`);
110
113
  }
111
- const env = d.spec?.template?.spec?.containers?.[0]?.env ?? [];
114
+ return active[0].revisionName;
115
+ }
116
+ /**
117
+ * Extract serving state from a `gcloud run services describe --format=json`.
118
+ *
119
+ * The serving revision's BUILD_ID and image are read from the service
120
+ * template only when that revision IS the latest created one. When it is
121
+ * not — a previous release created a revision that never took traffic (a
122
+ * failed secret-access check, a crashed startup, an aborted deploy) — the
123
+ * template describes the *wrong* revision, so the caller must pass that
124
+ * revision's own `gcloud run revisions describe --format=json` as
125
+ * `servingRevision` (readServingState does). Lesson 2026-09-04, watchdog2:
126
+ * a re-run after a failed traffic switch read the new SHA out of the
127
+ * template, logged "already serving", wrote the release record, and left
128
+ * the old revision serving.
129
+ */
130
+ export function servingStateOf(service, described, servingRevision) {
131
+ const d = described;
132
+ const revision = servingRevisionName(service, described);
133
+ const latest = d.status?.latestCreatedRevisionName;
134
+ let container;
135
+ if (latest === undefined || latest === revision) {
136
+ container = d.spec?.template?.spec?.containers?.[0];
137
+ }
138
+ else if (servingRevision !== undefined) {
139
+ container = servingRevision.spec?.containers?.[0];
140
+ }
141
+ else {
142
+ throw new Error(`${service}: traffic serves ${revision} but the latest revision is ${latest}; ` +
143
+ 'the serving revision must be described separately');
144
+ }
145
+ const env = container?.env ?? [];
112
146
  const buildSha = env.find((e) => e.name === 'BUILD_ID')?.value ?? null;
113
147
  return {
114
148
  service,
115
- revision: active[0].revisionName,
149
+ revision,
116
150
  buildSha: buildSha !== null && SHA40.test(buildSha) ? buildSha : null,
117
- imageDigest: d.spec?.template?.spec?.containers?.[0]?.image ?? null,
151
+ imageDigest: container?.image ?? null,
118
152
  };
119
153
  }
154
+ /** Describe a service and resolve its serving state, describing the serving
155
+ * revision too whenever the template no longer belongs to it. Returns the
156
+ * raw service description as well, for evidence. */
157
+ export async function readServingState(runner, gcloudBase, region, service) {
158
+ const described = await mustJson(runner, 'gcloud', [
159
+ 'run', 'services', 'describe', service, ...gcloudBase, '--region', region, '--format=json',
160
+ ]);
161
+ const revision = servingRevisionName(service, described);
162
+ const latest = described.status?.latestCreatedRevisionName;
163
+ const servingRevision = latest !== undefined && latest !== revision
164
+ ? await mustJson(runner, 'gcloud', [
165
+ 'run', 'revisions', 'describe', revision, ...gcloudBase, '--region', region, '--format=json',
166
+ ])
167
+ : undefined;
168
+ return { described, state: servingStateOf(service, described, servingRevision) };
169
+ }
120
170
  const CONTENT_TYPES = {
121
171
  '.js': 'text/javascript',
122
172
  '.mjs': 'text/javascript',
@@ -8,7 +8,7 @@ import { must, mustJson } from './exec.js';
8
8
  import { openEvidence, PhaseTimer } from './evidence.js';
9
9
  import { acquireLock, listReleaseRecords, placeHold, releaseLock } from './gcs.js';
10
10
  import { DEFAULT_ACCOUNT, loadConfig } from './config.js';
11
- import { pickRevisionForSha, releaseEntriesFrom, selectRollbackTarget, servingStateOf, SHA40, } from './plan.js';
11
+ import { pickRevisionForSha, releaseEntriesFrom, selectRollbackTarget, readServingState, SHA40, } from './plan.js';
12
12
  export async function runRollback(options) {
13
13
  const log = options.log ?? ((m) => console.error(`[pw-rollback] ${m}`));
14
14
  const runner = options.runner;
@@ -39,11 +39,8 @@ export async function runRollback(options) {
39
39
  timer.enter('target');
40
40
  const serving = [];
41
41
  for (const service of config.services) {
42
- const described = await mustJson(runner, 'gcloud', [
43
- 'run', 'services', 'describe', service.name,
44
- ...gcloudBase, '--region', config.region, '--format=json',
45
- ]);
46
- serving.push(servingStateOf(service.name, described));
42
+ const { state } = await readServingState(runner, gcloudBase, config.region, service.name);
43
+ serving.push(state);
47
44
  }
48
45
  const evidence = await openEvidence(config.app, requested ?? serving[0].buildSha ?? 'rollback');
49
46
  evidenceDir = evidence.dir;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.3.0",
3
+ "version": "3.3.1",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",