diff --git a/.changeset/secret-state-update-time-evidence.md b/.changeset/secret-state-update-time-evidence.md new file mode 100644 index 0000000000..522d4250b3 --- /dev/null +++ b/.changeset/secret-state-update-time-evidence.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Fix deploy silently skipping a secret update after the remote value changed outside the current project directory (a deploy from another machine, or a console-side edit). Deploy now verifies each secret's last platform update time before skipping and re-updates the secret when it no longer matches. After upgrading, the first deploy re-pushes managed secrets once. diff --git a/packages/sdk/src/cli/commands/deploy/secret-manager.test.ts b/packages/sdk/src/cli/commands/deploy/secret-manager.test.ts index 498dd16422..ed98309058 100644 --- a/packages/sdk/src/cli/commands/deploy/secret-manager.test.ts +++ b/packages/sdk/src/cli/commands/deploy/secret-manager.test.ts @@ -65,7 +65,14 @@ function createMockApplyClient() { } as unknown as OperatorClient; } -function createMockPlanClient(existingSecrets: string[] = [], vaultName = "my-vault") { +type ExistingSecretFixture = + | string + | { name: string; updateTime?: { seconds: bigint; nanos: number } }; + +function createMockPlanClient( + existingSecrets: ExistingSecretFixture[] = [], + vaultName = "my-vault", +) { return { listSecretManagerVaults: vi.fn().mockResolvedValue({ vaults: [{ name: vaultName }], @@ -75,7 +82,9 @@ function createMockPlanClient(existingSecrets: string[] = [], vaultName = "my-va metadata: { labels: { "sdk-name": "test-app", "sdk-version": sdkVersion } }, }), listSecretManagerSecrets: vi.fn().mockResolvedValue({ - secrets: existingSecrets.map((name) => ({ name })), + secrets: existingSecrets.map((secret) => + typeof secret === "string" ? { name: secret } : secret, + ), nextPageToken: "", }), } as unknown as OperatorClient; @@ -160,7 +169,7 @@ describe("applySecretManager phase separation", () => { mockLoadSecretsState.mockReturnValue({ vaults: { "my-vault": { - "orphan-secret": hashValue("orphan-value"), + "orphan-secret": { hash: hashValue("orphan-value") }, }, }, }); @@ -253,28 +262,17 @@ describe("planSecretManager hash-based diff", () => { mockLoadSecretsState.mockReturnValue({ vaults: {} }); }); - test("skips update when hash matches stored state", async () => { + test("includes update when forceApplyAll is enabled even if evidence matches", async () => { const secretValue = "my-secret-value"; mockLoadSecretsState.mockReturnValue({ - vaults: { "my-vault": { "existing-secret": hashValue(secretValue) } }, + vaults: { + "my-vault": { "existing-secret": { hash: hashValue(secretValue), updateTime: "100.5" } }, + }, }); - const client = createMockPlanClient(["existing-secret"]); - const ctx = createPlanContext(client, [ - { vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }, + const client = createMockPlanClient([ + { name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } }, ]); - - const result = await planSecretManager(ctx); - expect(result.secretChangeSet.updates).toHaveLength(0); - }); - - test("includes update when forceApplyAll is enabled even if hash matches", async () => { - const secretValue = "my-secret-value"; - mockLoadSecretsState.mockReturnValue({ - vaults: { "my-vault": { "existing-secret": hashValue(secretValue) } }, - }); - - const client = createMockPlanClient(["existing-secret"]); const ctx = { ...createPlanContext(client, [ { vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }, @@ -288,10 +286,14 @@ describe("planSecretManager hash-based diff", () => { test("includes update when hash does not match", async () => { mockLoadSecretsState.mockReturnValue({ - vaults: { "my-vault": { "existing-secret": hashValue("old-value") } }, + vaults: { + "my-vault": { "existing-secret": { hash: hashValue("old-value"), updateTime: "100.5" } }, + }, }); - const client = createMockPlanClient(["existing-secret"]); + const client = createMockPlanClient([ + { name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } }, + ]); const ctx = createPlanContext(client, [ { vaultName: "my-vault", secrets: [{ name: "existing-secret", value: "new-value" }] }, ]); @@ -317,10 +319,18 @@ describe("planSecretManager hash-based diff", () => { const secretValue = "my-secret-value"; mockLoadSecretsState.mockImplementation((scope?: { workspaceId: string }) => scope?.workspaceId === "ws-a" - ? { vaults: { "my-vault": { "existing-secret": hashValue(secretValue) } } } + ? { + vaults: { + "my-vault": { + "existing-secret": { hash: hashValue(secretValue), updateTime: "100.5" }, + }, + }, + } : { vaults: {} }, ); - const client = createMockPlanClient(["existing-secret"]); + const client = createMockPlanClient([ + { name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } }, + ]); const ctxA = createPlanContext( client, [{ vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }], @@ -348,10 +358,18 @@ describe("planSecretManager hash-based diff", () => { const secretValue = "my-secret-value"; mockLoadSecretsState.mockImplementation((scope?: { applicationId: string }) => scope?.applicationId === "app-a" - ? { vaults: { "my-vault": { "existing-secret": hashValue(secretValue) } } } + ? { + vaults: { + "my-vault": { + "existing-secret": { hash: hashValue(secretValue), updateTime: "100.5" }, + }, + }, + } : { vaults: {} }, ); - const client = createMockPlanClient(["existing-secret"]); + const client = createMockPlanClient([ + { name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } }, + ]); const ctxA = createPlanContext( client, [{ vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }], @@ -376,6 +394,145 @@ describe("planSecretManager hash-based diff", () => { }); }); +describe("planSecretManager update-time evidence", () => { + const secretValue = "my-secret-value"; + const storedEntry = { hash: hashValue(secretValue), updateTime: "100.5" }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("skips update when hash and remote updateTime both match stored evidence", async () => { + mockLoadSecretsState.mockReturnValue({ + vaults: { "my-vault": { "existing-secret": storedEntry } }, + }); + const client = createMockPlanClient([ + { name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } }, + ]); + const ctx = createPlanContext(client, [ + { vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }, + ]); + + const result = await planSecretManager(ctx); + expect(result.secretChangeSet.updates).toHaveLength(0); + }); + + test("updates when the remote updateTime differs from stored evidence", async () => { + mockLoadSecretsState.mockReturnValue({ + vaults: { "my-vault": { "existing-secret": storedEntry } }, + }); + const client = createMockPlanClient([ + { name: "existing-secret", updateTime: { seconds: 200n, nanos: 0 } }, + ]); + const ctx = createPlanContext(client, [ + { vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }, + ]); + + const result = await planSecretManager(ctx); + expect(result.secretChangeSet.updates).toHaveLength(1); + }); + + test("updates when the stored entry has no updateTime evidence", async () => { + mockLoadSecretsState.mockReturnValue({ + vaults: { "my-vault": { "existing-secret": { hash: hashValue(secretValue) } } }, + }); + const client = createMockPlanClient([ + { name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } }, + ]); + const ctx = createPlanContext(client, [ + { vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }, + ]); + + const result = await planSecretManager(ctx); + expect(result.secretChangeSet.updates).toHaveLength(1); + }); + + test("updates when the remote secret carries no updateTime", async () => { + mockLoadSecretsState.mockReturnValue({ + vaults: { "my-vault": { "existing-secret": storedEntry } }, + }); + const client = createMockPlanClient(["existing-secret"]); + const ctx = createPlanContext(client, [ + { vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }, + ]); + + const result = await planSecretManager(ctx); + expect(result.secretChangeSet.updates).toHaveLength(1); + }); +}); + +describe("applySecretManager update-time evidence persistence", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadSecretsState.mockReturnValue({ vaults: {} }); + }); + + function evidencePlanResult() { + return { + stateScope, + vaultChangeSet: { creates: [], updates: [], deletes: [], replaces: [] }, + secretChangeSet: { + creates: [ + { + name: "my-vault/secret-a", + secretName: "secret-a", + workspaceId: "ws-1", + vaultName: "my-vault", + value: "value-a", + }, + ], + updates: [ + { + name: "my-vault/secret-b", + secretName: "secret-b", + workspaceId: "ws-1", + vaultName: "my-vault", + value: "value-b", + }, + ], + deletes: [], + replaces: [], + }, + } as unknown as Awaited>; + } + + test("stores updateTime evidence from create and update responses", async () => { + const client = { + ...createMockApplyClient(), + createSecretManagerSecret: vi.fn().mockResolvedValue({ + secret: { name: "secret-a", updateTime: { seconds: 10n, nanos: 1 } }, + }), + updateSecretManagerSecret: vi.fn().mockResolvedValue({ + secret: { name: "secret-b", updateTime: { seconds: 20n, nanos: 2 } }, + }), + } as unknown as OperatorClient; + const application = { secrets: [] } as unknown as Application; + + await applySecretManager(client, evidencePlanResult(), "create-update", application); + + const savedState = mockSaveSecretsState.mock.calls[0]![1]; + expect(savedState.vaults["my-vault"]["secret-a"]).toEqual({ + hash: hashValue("value-a"), + updateTime: "10.1", + }); + expect(savedState.vaults["my-vault"]["secret-b"]).toEqual({ + hash: hashValue("value-b"), + updateTime: "20.2", + }); + }); + + test("stores hash without evidence when a create response is empty", async () => { + const client = createMockApplyClient(); + const application = { secrets: [] } as unknown as Application; + + await applySecretManager(client, evidencePlanResult(), "create-update", application); + + const savedState = mockSaveSecretsState.mock.calls[0]![1]; + expect(savedState.vaults["my-vault"]["secret-a"]).toEqual({ hash: hashValue("value-a") }); + expect(savedState.vaults["my-vault"]["secret-b"]).toEqual({ hash: hashValue("value-b") }); + }); +}); + describe("planSecretManager vault metadata and deletion", () => { beforeEach(() => { vi.clearAllMocks(); @@ -668,8 +825,8 @@ describe("applySecretManager state persistence", () => { expect(mockSaveSecretsState).toHaveBeenCalledTimes(1); const savedState = mockSaveSecretsState.mock.calls[0]![1]; - expect(savedState.vaults["my-vault"]["secret-a"]).toBe(hashValue("value-a")); - expect(savedState.vaults["my-vault"]["secret-b"]).toBe(hashValue("value-b")); + expect(savedState.vaults["my-vault"]["secret-a"]).toEqual({ hash: hashValue("value-a") }); + expect(savedState.vaults["my-vault"]["secret-b"]).toEqual({ hash: hashValue("value-b") }); }); test("does not save state when application is not provided", async () => { @@ -706,8 +863,8 @@ describe("applySecretManager state persistence", () => { mockLoadSecretsState.mockReturnValue({ vaults: { "my-vault": { - "secret-a": hashValue("value-a"), - orphan: hashValue("orphan-value"), + "secret-a": { hash: hashValue("value-a") }, + orphan: { hash: hashValue("orphan-value") }, }, }, }); @@ -735,7 +892,7 @@ describe("applySecretManager state persistence", () => { expect(mockSaveSecretsState).toHaveBeenCalledTimes(1); const savedState = mockSaveSecretsState.mock.calls[0]![1]; - expect(savedState.vaults["my-vault"]["secret-a"]).toBe(hashValue("value-a")); + expect(savedState.vaults["my-vault"]["secret-a"]).toEqual({ hash: hashValue("value-a") }); expect(savedState.vaults["my-vault"]["orphan"]).toBeUndefined(); }); @@ -743,7 +900,7 @@ describe("applySecretManager state persistence", () => { mockLoadSecretsState.mockReturnValue({ vaults: { "my-vault": { - "only-secret": hashValue("value"), + "only-secret": { hash: hashValue("value") }, }, }, }); @@ -849,7 +1006,7 @@ describe("applySecretManager ignoreNullishValues state persistence", () => { mockLoadSecretsState.mockReturnValue({ vaults: { "my-vault": { - "nullish-secret": hashValue("previous-value"), + "nullish-secret": { hash: hashValue("previous-value") }, }, }, }); @@ -892,7 +1049,11 @@ describe("applySecretManager ignoreNullishValues state persistence", () => { expect(mockSaveSecretsState).toHaveBeenCalledTimes(1); const savedState = mockSaveSecretsState.mock.calls[0]![1]; - expect(savedState.vaults["my-vault"]["nullish-secret"]).toBe(hashValue("previous-value")); - expect(savedState.vaults["my-vault"]["valued-secret"]).toBe(hashValue("real-value")); + expect(savedState.vaults["my-vault"]["nullish-secret"]).toEqual({ + hash: hashValue("previous-value"), + }); + expect(savedState.vaults["my-vault"]["valued-secret"]).toEqual({ + hash: hashValue("real-value"), + }); }); }); diff --git a/packages/sdk/src/cli/commands/deploy/secret-manager.ts b/packages/sdk/src/cli/commands/deploy/secret-manager.ts index 9ffe9603e0..e49d2d9ba1 100644 --- a/packages/sdk/src/cli/commands/deploy/secret-manager.ts +++ b/packages/sdk/src/cli/commands/deploy/secret-manager.ts @@ -11,6 +11,7 @@ import { hashValue, loadSecretsState, saveSecretsState, + serializeUpdateTime, withSecretsStateLock, } from "./secrets-state"; import type { ApplyPhase, PlanContext } from "#/cli/commands/deploy/types"; @@ -187,7 +188,7 @@ export async function planSecretManager(context: PlanContext) { } // Fetch existing secrets in this vault - let existingSecrets: string[] = []; + const existingSecretTimes = new Map(); if (existing) { const secrets = await fetchAllTolerant(async (pageToken, maxPageSize) => { const { secrets, nextPageToken } = await client.listSecretManagerSecrets({ @@ -198,10 +199,12 @@ export async function planSecretManager(context: PlanContext) { }); return [secrets, nextPageToken]; }); - existingSecrets = secrets.map((s) => s.name); + for (const secret of secrets) { + existingSecretTimes.set(secret.name, serializeUpdateTime(secret.updateTime)); + } } - const existingSet = new Set(existingSecrets); + const existingSet = new Set(existingSecretTimes.keys()); // Diff secrets for (const secret of vault.secrets) { @@ -213,9 +216,16 @@ export async function planSecretManager(context: PlanContext) { } if (existingSet.has(secret.name)) { - const currentHash = hashValue(secret.value); - const storedHash = state.vaults[vaultName]?.[secret.name]; - if (forceApplyAll || currentHash !== storedHash) { + const stored = state.vaults[vaultName]?.[secret.name]; + const remoteUpdateTime = existingSecretTimes.get(secret.name); + // Skip only when the stored hash matches and the remote updateTime + // proves no other writer changed the secret since that hash was saved. + const unchanged = + stored !== undefined && + stored.hash === hashValue(secret.value) && + stored.updateTime !== undefined && + stored.updateTime === remoteUpdateTime; + if (forceApplyAll || !unchanged) { secretChangeSet.updates.push({ name: `${vaultName}/${secret.name}`, secretName: secret.name, @@ -345,18 +355,24 @@ export async function applySecretManager( const secretHashUpdates = [...secretChangeSet.creates, ...secretChangeSet.updates]; if (secretHashUpdates.length > 0) { await withSecretsStateLock(stateScope, async () => { + // Evidence must come from this deploy's own mutation responses; pairing + // the hash with a re-listed timestamp could adopt another writer's. + const appliedUpdateTimes = new Map(); + // Create new secrets await Promise.all( - secretChangeSet.creates.map((create) => - client.createSecretManagerSecret(secretCreateRequest(create)), - ), + secretChangeSet.creates.map(async (create) => { + const response = await client.createSecretManagerSecret(secretCreateRequest(create)); + appliedUpdateTimes.set(create.name, serializeUpdateTime(response.secret?.updateTime)); + }), ); // Update existing secrets await Promise.all( - secretChangeSet.updates.map((update) => - client.updateSecretManagerSecret(secretUpdateRequest(update)), - ), + secretChangeSet.updates.map(async (update) => { + const response = await client.updateSecretManagerSecret(secretUpdateRequest(update)); + appliedUpdateTimes.set(update.name, serializeUpdateTime(response.secret?.updateTime)); + }), ); if (application) { @@ -365,9 +381,13 @@ export async function applySecretManager( if (!Object.hasOwn(state.vaults, secret.vaultName)) { state.vaults[secret.vaultName] = {}; } + const updateTime = appliedUpdateTimes.get(secret.name); assertDefined(state.vaults[secret.vaultName], "vault state entry missing")[ secret.secretName - ] = hashValue(secret.value); + ] = { + hash: hashValue(secret.value), + ...(updateTime === undefined ? {} : { updateTime }), + }; } saveSecretsState(stateScope, state); } diff --git a/packages/sdk/src/cli/commands/deploy/secrets-state-concurrency.test.ts b/packages/sdk/src/cli/commands/deploy/secrets-state-concurrency.test.ts index ddb8156d97..2b8ce1ad43 100644 --- a/packages/sdk/src/cli/commands/deploy/secrets-state-concurrency.test.ts +++ b/packages/sdk/src/cli/commands/deploy/secrets-state-concurrency.test.ts @@ -127,10 +127,10 @@ describe("concurrent deploys to the same target", () => { await Promise.all([applyA, applyB]); const state = loadSecretsState(stateScope); - expect(state.vaults["my-vault"]?.["shared-secret"]).toBe( + expect(state.vaults["my-vault"]?.["shared-secret"]?.hash).toBe( hashValue(remote.get("shared-secret")!), ); - expect(state.vaults["my-vault"]?.["trailing-secret"]).toBe(hashValue("trailing-value")); + expect(state.vaults["my-vault"]?.["trailing-secret"]?.hash).toBe(hashValue("trailing-value")); }); test("auth connection hash state matches the last remote write", async () => { diff --git a/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts b/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts index b1bce56260..4067cde4c1 100644 --- a/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts +++ b/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts @@ -50,8 +50,8 @@ describe("secrets-state", () => { const state = { vaults: { "my-vault": { - "secret-a": "abc123", - "secret-b": "def456", + "secret-a": { hash: "abc123", updateTime: "100.5" }, + "secret-b": { hash: "def456" }, }, }, }; @@ -62,7 +62,7 @@ describe("secrets-state", () => { test("state from another workspace is a cache miss", () => { saveSecretsState(scopeA, { - vaults: { "shared-vault": { "shared-secret": "matching-hash" } }, + vaults: { "shared-vault": { "shared-secret": { hash: "matching-hash" } } }, connections: { "shared-connection": "matching-hash" }, }); @@ -76,7 +76,7 @@ describe("secrets-state", () => { test("state from another application is a cache miss", () => { saveSecretsState(scopeA, { - vaults: { "shared-vault": { "shared-secret": "matching-hash" } }, + vaults: { "shared-vault": { "shared-secret": { hash: "matching-hash" } } }, connections: { "shared-connection": "matching-hash" }, }); @@ -90,7 +90,7 @@ describe("secrets-state", () => { test("a renamed application keeps state when its stable id matches", () => { saveSecretsState(scopeA, { - vaults: { "shared-vault": { "shared-secret": "matching-hash" } }, + vaults: { "shared-vault": { "shared-secret": { hash: "matching-hash" } } }, }); const loaded = loadSecretsState({ @@ -98,7 +98,7 @@ describe("secrets-state", () => { applicationName: "renamed-application", }); - expect(loaded.vaults["shared-vault"]?.["shared-secret"]).toBe("matching-hash"); + expect(loaded.vaults["shared-vault"]?.["shared-secret"]?.hash).toBe("matching-hash"); }); test("state without a stable application id is always a cache miss", () => { @@ -108,7 +108,7 @@ describe("secrets-state", () => { }; saveSecretsState(scopeWithoutId, { - vaults: { "shared-vault": { "shared-secret": "matching-hash" } }, + vaults: { "shared-vault": { "shared-secret": { hash: "matching-hash" } } }, }); expect(loadSecretsState(scopeWithoutId)).toEqual({ vaults: {} }); @@ -116,11 +116,11 @@ describe("secrets-state", () => { }); test("saving one scope preserves another scope", () => { - saveSecretsState(scopeA, { vaults: { "vault-a": { secret: "hash-a" } } }); - saveSecretsState(scopeB, { vaults: { "vault-b": { secret: "hash-b" } } }); + saveSecretsState(scopeA, { vaults: { "vault-a": { secret: { hash: "hash-a" } } } }); + saveSecretsState(scopeB, { vaults: { "vault-b": { secret: { hash: "hash-b" } } } }); - expect(loadSecretsState(scopeA).vaults["vault-a"]?.secret).toBe("hash-a"); - expect(loadSecretsState(scopeB).vaults["vault-b"]?.secret).toBe("hash-b"); + expect(loadSecretsState(scopeA).vaults["vault-a"]?.secret?.hash).toBe("hash-a"); + expect(loadSecretsState(scopeB).vaults["vault-b"]?.secret?.hash).toBe("hash-b"); }); test("stores different scopes in different files", () => { @@ -128,12 +128,12 @@ describe("secrets-state", () => { }); test("a malformed scope file does not invalidate another scope", () => { - saveSecretsState(scopeB, { vaults: { "vault-b": { secret: "hash-b" } } }); + saveSecretsState(scopeB, { vaults: { "vault-b": { secret: { hash: "hash-b" } } } }); const statePath = getSecretsStatePath(scopeA); mkdirSync(path.dirname(statePath), { recursive: true }); writeFileSync(statePath, "{broken json,,", "utf-8"); - expect(loadSecretsState(scopeB).vaults["vault-b"]?.secret).toBe("hash-b"); + expect(loadSecretsState(scopeB).vaults["vault-b"]?.secret?.hash).toBe("hash-b"); }); test("legacy unscoped state is a cache miss", () => { @@ -151,13 +151,33 @@ describe("secrets-state", () => { expect(loadSecretsState(scopeA)).toEqual({ vaults: {} }); }); + test("version 1 hash-only state is a cache miss", () => { + const statePath = getSecretsStatePath(scopeA); + mkdirSync(path.dirname(statePath), { recursive: true }); + writeFileSync( + statePath, + JSON.stringify({ + version: 1, + workspaceId: scopeA.workspaceId, + applicationKey: `id:${scopeA.applicationId}`, + state: { + vaults: { "shared-vault": { "shared-secret": "matching-hash" } }, + connections: { "shared-connection": "matching-hash" }, + }, + }), + "utf-8", + ); + + expect(loadSecretsState(scopeA)).toEqual({ vaults: {} }); + }); + test("state with an unknown version is a cache miss", () => { const statePath = getSecretsStatePath(scopeA); mkdirSync(path.dirname(statePath), { recursive: true }); writeFileSync( statePath, JSON.stringify({ - version: 2, + version: 3, workspaces: { "workspace-a": { applications: { diff --git a/packages/sdk/src/cli/commands/deploy/secrets-state.ts b/packages/sdk/src/cli/commands/deploy/secrets-state.ts index ccee040e59..9d5a78229a 100644 --- a/packages/sdk/src/cli/commands/deploy/secrets-state.ts +++ b/packages/sdk/src/cli/commands/deploy/secrets-state.ts @@ -12,14 +12,20 @@ import pLimit, { type LimitFunction } from "p-limit"; import * as path from "pathe"; import { z } from "zod"; import { getDistDir } from "#/cli/shared/dist-dir"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; + +const SecretsStateEntrySchema = z.object({ + hash: z.string(), + updateTime: z.string().optional(), +}); const SecretsStateSchema = z.object({ - vaults: z.record(z.string(), z.record(z.string(), z.string())), + vaults: z.record(z.string(), z.record(z.string(), SecretsStateEntrySchema)), connections: z.record(z.string(), z.string()).optional(), }); const PersistedSecretsStateSchema = z.object({ - version: z.literal(1), + version: z.literal(2), workspaceId: z.string(), applicationKey: z.string(), state: SecretsStateSchema, @@ -97,7 +103,7 @@ export function saveSecretsState(scope: SecretsStateScope, state: SecretsState): tempPath, JSON.stringify( { - version: 1, + version: 2, workspaceId: scope.workspaceId, applicationKey: applicationStateKey(scope), state, @@ -119,6 +125,15 @@ export function hashValue(value: string): string { return createHash("sha256").update(value).digest("hex"); } +/** + * Serialize a platform timestamp into the string stored as update evidence. + * @param updateTime - Timestamp from a Secret Manager list or mutation response + * @returns Serialized timestamp, or undefined when the platform sent none + */ +export function serializeUpdateTime(updateTime: Timestamp | undefined): string | undefined { + return updateTime === undefined ? undefined : `${updateTime.seconds}.${updateTime.nanos}`; +} + const LOCK_POLL_INTERVAL_MS = 100; const LOCK_ACQUIRE_TIMEOUT_MS = 5 * 60 * 1000; // A holder refreshes the lock directory mtime while working, so a lock whose diff --git a/packages/sdk/src/cli/shared/client.ts b/packages/sdk/src/cli/shared/client.ts index d0e827a83b..951f92247c 100644 --- a/packages/sdk/src/cli/shared/client.ts +++ b/packages/sdk/src/cli/shared/client.ts @@ -294,8 +294,10 @@ function connectCodeName(error: unknown): string { * * Membership is deliberately an allowlist, not `startsWith("Create")`: swallowing * synthesizes an empty response (see `synthesizeEmptyUnaryResponse`), which is only - * safe when every caller ignores the response body. These are the deploy/apply - * resource creations that fire under heavy parallelism and discard their response. + * safe when every caller tolerates an empty response body. These are the deploy/apply + * resource creations that fire under heavy parallelism and discard their response — + * except `CreateSecretManagerSecret`, whose caller reads `secret.updateTime` but + * degrades safely when it is absent (the next deploy re-updates the secret). * * Intentionally excluded because their callers read the response body — swallowing * would hand back an empty message and corrupt downstream state: