diff --git a/docs/runbooks/first-user-walk.md b/docs/runbooks/first-user-walk.md index d9511a0454..4d9e4b8e45 100644 --- a/docs/runbooks/first-user-walk.md +++ b/docs/runbooks/first-user-walk.md @@ -30,6 +30,24 @@ COMMONLY_WALK_PASSWORD="$(openssl rand -base64 24)" \ cleanup, `2` the walk passed but a step is not yet covered (today, the Bring-your-own connect step). +### Staging: the shared sign-in account + +Staging signup needs a verified email, which a walk address cannot receive, so +staging runs sign in to one shared walk account that SRE pre-verifies. Set +`COMMONLY_WALK_EMAIL` and `COMMONLY_WALK_PASSWORD` from the environment and pass +the staging API as the target: + +```bash +COMMONLY_WALK_EMAIL= COMMONLY_WALK_PASSWORD= \ + node scripts/first-user-walk.mjs --base https://api-staging.commonly.me +``` + +- The run signs in and never signs up. It removes only the pods, seats and + agents it created, and it never deletes the shared account. +- `COMMONLY_WALK_EMAIL` aimed at any host other than staging is refused before + the walk starts, so the shared account cannot be used on prod. +- The manifest records `"mode": "signin"` for these runs. + ## Cleanup is part of the run The walk deletes its own account in a `finally` block, sending the password and diff --git a/scripts/first-user-walk-cleanup.mjs b/scripts/first-user-walk-cleanup.mjs index 4637656212..1b5e25ef67 100644 --- a/scripts/first-user-walk-cleanup.mjs +++ b/scripts/first-user-walk-cleanup.mjs @@ -18,10 +18,36 @@ export const ownerOf = (pod) => String(pod.createdBy?._id ?? pod.createdBy ?? '' // Only the bots this run created go in the manifest: the hosted agent (its // username carries this run's agent name) and the per-user Scout (`scout-u…`). // The shared default bot is a member of every new pod and is never recorded, -// because SRE's purge would delete an identity every pod uses. -export const isRunBot = (member, agentName) => { +// because SRE's purge would delete an identity every pod uses. `scout: false` +// is for a kept account: its Scout is that account's own bot, not this run's. +export const isRunBot = (member, agentName, { scout = true } = {}) => { const name = String(member?.username || ''); - return member?.isBot === true && (name === agentName || name.startsWith(`${agentName}-`) || name.startsWith('scout-u')); + const isScout = scout && name.startsWith('scout-u'); + return member?.isBot === true && (name === agentName || name.startsWith(`${agentName}-`) || isScout); +}; + +// After an ambiguous pod create (an error, or a reply without an id), the pod +// may exist. Find this run's pods by their unique name and record them, so +// cleanup removes them. Returns true only when a matching pod is found. It +// returns false when the list cannot be read or holds no match, and the caller +// must report the name as an unresolved survivor. An empty match would let +// cleanup mark the run clean while the pod is still live. +export const recoverRunPods = async ({ api, token, userId, podName, podIds }) => { + try { + const list = await api('GET', '/api/pods', { token }); + // A 200 whose body is not a pod list is unreadable, not an empty list. + if (list.status !== 200 || !(Array.isArray(list.data) || Array.isArray(list.data?.pods))) return false; + let found = false; + for (const p of ids(list.data)) { + if (p.name === podName && ownerOf(p) === String(userId)) { + found = true; + if (!podIds.includes(String(p._id))) podIds.push(String(p._id)); + } + } + return found; + } catch { + return false; + } }; // `api` is the walk's request helper: (method, path, { token, body }) => { status, data }. @@ -29,7 +55,10 @@ export const isRunBot = (member, agentName) => { // removed and verified even when the owner match misses them, so a filter // miss cannot turn into a silent survivor. `onBotIds(podId, botIds)` is called // before each pod is deleted, so a throw later in the loop cannot lose the ids. -export const cleanupRun = async ({ api, token, password, email, userId, agentName, podIds = [], onBotIds = () => {} }) => { +// `deleteAccount: false` is the shared staging sign-in account: the run removes +// only the pods it recorded, never pods that account owns from elsewhere, and +// never the account itself. +export const cleanupRun = async ({ api, token, password, email, userId, agentName, podIds = [], onBotIds = () => {}, deleteAccount = true }) => { const survivors = []; const botIds = new Set(); @@ -44,10 +73,16 @@ export const cleanupRun = async ({ api, token, password, email, userId, agentNam const stop = await api('POST', '/api/hosted/deprovision', { token, body: { agentName } }); if (stop.status >= 300 && stop.status !== 404) survivors.push(`hosted:${agentName} (deprovision ${stop.status})`); - const owned = await listOwnPods(); - if (owned === null) survivors.push('pods (list failed)'); + let owned = []; + if (deleteAccount) { + owned = await listOwnPods(); + if (owned === null) { + survivors.push('pods (list failed)'); + owned = []; + } + } const known = new Set(podIds.map(String)); - for (const pod of owned || []) known.add(String(pod._id)); + for (const pod of owned) known.add(String(pod._id)); // Pods only: the walk never joins a pod it did not create, so every pod // whose createdBy is this account is the run's own footprint. Bot members @@ -61,7 +96,7 @@ export const cleanupRun = async ({ api, token, password, email, userId, agentNam survivors.push(`pod:${id} (lookup ${detail.status})`); continue; } - const bots = (detail.data?.members || []).filter((m) => isRunBot(m, agentName)).map((m) => String(m._id)); + const bots = (detail.data?.members || []).filter((m) => isRunBot(m, agentName, { scout: deleteAccount })).map((m) => String(m._id)); bots.forEach((bot) => botIds.add(bot)); onBotIds(id, bots); @@ -75,23 +110,27 @@ export const cleanupRun = async ({ api, token, password, email, userId, agentNam // listed under this account, or still readable by id, is a survivor // whatever the delete said. A list that fails here cannot prove the pods // are gone, so it is a survivor too. - const after = await listOwnPods(); - if (after === null) survivors.push('pods (list failed on verify)'); - for (const pod of after || []) survivors.push(`pod:${String(pod._id)} (still listed)`); + if (deleteAccount) { + const after = await listOwnPods(); + if (after === null) survivors.push('pods (list failed on verify)'); + for (const pod of after || []) survivors.push(`pod:${String(pod._id)} (still listed)`); + } for (const id of known) { const check = await api('GET', `/api/pods/${id}`, { token }); if (check.status === 200) survivors.push(`pod:${id} (still readable)`); else if (check.status !== 404) survivors.push(`pod:${id} (verify ${check.status})`); } - const del = await api('DELETE', '/api/users/me', { token, body: { password, confirmation: 'DELETE' } }); - if (del.status >= 300) { - survivors.push(`account (delete ${del.status})`); - } else { - // The delete reply is not proof: the account is gone only if its own - // password no longer logs in. - const login = await api('POST', '/api/auth/login', { body: { email, password } }); - if (login.status === 200) survivors.push('account (still logs in)'); + if (deleteAccount) { + const del = await api('DELETE', '/api/users/me', { token, body: { password, confirmation: 'DELETE' } }); + if (del.status >= 300) { + survivors.push(`account (delete ${del.status})`); + } else { + // The delete reply is not proof: the account is gone only if its own + // password no longer logs in. + const login = await api('POST', '/api/auth/login', { body: { email, password } }); + if (login.status === 200) survivors.push('account (still logs in)'); + } } return { ok: survivors.length === 0, survivors, botIds: [...botIds] }; diff --git a/scripts/first-user-walk-cleanup.test.mjs b/scripts/first-user-walk-cleanup.test.mjs index 1ccee62d41..d75f562dd5 100644 --- a/scripts/first-user-walk-cleanup.test.mjs +++ b/scripts/first-user-walk-cleanup.test.mjs @@ -1,7 +1,8 @@ // Run: node --test scripts/first-user-walk-cleanup.test.mjs import test from 'node:test'; import assert from 'node:assert/strict'; -import { cleanupRun } from './first-user-walk-cleanup.mjs'; +import { cleanupRun, recoverRunPods } from './first-user-walk-cleanup.mjs'; +import { chooseWalkMode } from './first-user-walk-mode.mjs'; const USER = 'user-walk'; const OTHER = 'user-other'; @@ -194,3 +195,98 @@ test('the shared default bot is never recorded, even though it is a pod member', assert.deepEqual(result.botIds, ['scout', 'agent']); assert.ok(!result.botIds.includes('shared-bot')); }); + +test('shared sign-in account: the run never deletes the account', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }] }); + const result = await run(backend, { podIds: ['p1'], deleteAccount: false }); + assert.equal(result.ok, true); + assert.equal(backend.state.accountGone, false); + assert.ok(!backend.state.calls.includes('DELETE /api/users/me'), `calls: ${backend.state.calls}`); + assert.ok(!backend.state.calls.includes('POST /api/auth/login'), 'no login check when the account is kept'); +}); + +test('shared sign-in account: only the recorded pods go, not other pods the account owns', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }, { _id: 'old', createdBy: USER }] }); + const result = await run(backend, { podIds: ['p1'], deleteAccount: false }); + assert.equal(result.ok, true); + assert.deepEqual(backend.state.pods.map((p) => p._id), ['old']); + assert.ok(!backend.state.calls.includes('DELETE /api/pods/old')); +}); + +test('shared sign-in account: a recorded pod that survives is still a survivor', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }], stuck: new Set(['p1']) }); + const result = await run(backend, { podIds: ['p1'], deleteAccount: false }); + assert.equal(result.ok, false); + assert.ok(result.survivors.includes('pod:p1 (still readable)'), `survivors: ${result.survivors}`); +}); + +test('shared sign-in account: its own Scout bot is never recorded for purge', async () => { + const members = [ + { _id: 'account-scout', isBot: true, username: 'scout-uowner1' }, + { _id: 'agent', isBot: true, username: 'walk-x-agent' }, + ]; + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER, members }] }); + const result = await run(backend, { podIds: ['p1'], deleteAccount: false }); + assert.deepEqual(result.botIds, ['agent']); +}); + +test('a signup run still records the Scout it created', async () => { + const members = [{ _id: 'run-scout', isBot: true, username: 'scout-unew1' }]; + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER, members }] }); + const result = await run(backend); + assert.deepEqual(result.botIds, ['run-scout']); +}); + +test('handoff: the staging sign-in mode reaches cleanup as deleteAccount false, and the account is never deleted', async () => { + const { deleteAccount } = chooseWalkMode({ base: 'https://api-staging.commonly.me', env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }); + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }] }); + const result = await run(backend, { podIds: ['p1'], deleteAccount }); + assert.equal(result.ok, true); + assert.ok(!backend.state.calls.includes('DELETE /api/users/me'), 'the shared account must not be deleted'); +}); + +test('handoff: a signup run reaches cleanup as deleteAccount true, and the account is deleted', async () => { + const { deleteAccount } = chooseWalkMode({ base: 'https://api-staging.commonly.me', env: {} }); + const backend = fakeBackend({ pods: [] }); + await run(backend, { deleteAccount }); + assert.ok(backend.state.calls.includes('DELETE /api/users/me')); +}); + +test('recoverRunPods records this run\'s pod by its unique name, never another owner\'s', async () => { + const backend = fakeBackend({ pods: [ + { _id: 'mine', name: 'walk-x workspace', createdBy: USER }, + { _id: 'theirs', name: 'walk-x workspace', createdBy: OTHER }, + { _id: 'other-name', name: 'Team', createdBy: USER }, + ] }); + const podIds = []; + const ok = await recoverRunPods({ api: backend.api, token: 'tok', userId: USER, podName: 'walk-x workspace', podIds }); + assert.equal(ok, true); + assert.deepEqual(podIds, ['mine']); +}); + +test('recoverRunPods reports failure when the list cannot be read, so the name is unresolved', async () => { + const backend = fakeBackend({ pods: [], listFails: true }); + const podIds = []; + const ok = await recoverRunPods({ api: backend.api, token: 'tok', userId: USER, podName: 'walk-x workspace', podIds }); + assert.equal(ok, false); + assert.deepEqual(podIds, []); +}); + +test('recoverRunPods reports unresolved when a valid list has no match for this run', async () => { + const backend = fakeBackend({ pods: [ + { _id: 'theirs', name: 'walk-x workspace', createdBy: OTHER }, + { _id: 'other-name', name: 'Team', createdBy: USER }, + ] }); + const podIds = []; + const ok = await recoverRunPods({ api: backend.api, token: 'tok', userId: USER, podName: 'walk-x workspace', podIds }); + assert.equal(ok, false); + assert.deepEqual(podIds, []); +}); + +test('recoverRunPods treats a 200 that is not a pod list as unresolved', async () => { + const backend = fakeBackend({ pods: [] }); + const garbage = async (method, path) => (method === 'GET' && path === '/api/pods' ? { status: 200, data: { error: 'nope' } } : backend.api(method, path)); + const podIds = []; + const ok = await recoverRunPods({ api: garbage, token: 'tok', userId: USER, podName: 'walk-x workspace', podIds }); + assert.equal(ok, false); +}); diff --git a/scripts/first-user-walk-mode.mjs b/scripts/first-user-walk-mode.mjs new file mode 100644 index 0000000000..17baa8301a --- /dev/null +++ b/scripts/first-user-walk-mode.mjs @@ -0,0 +1,19 @@ +// How the walk gets its account (TASK-706). Prod signs up a fresh account every +// run. Staging needs a verified email, which a walk address cannot receive, so +// SRE pre-verifies one shared walk account there and the walk signs in to it +// instead. Its credentials come from env only, and that account is never deleted. +// Exact host and https only: a substring match would send the shared password to +// any host whose name contains "staging". +const STAGING_HOSTS = new Set(['api-staging.commonly.me']); + +// `deleteAccount` is the one value main() hands to cleanup, so the mode decides +// the account's fate here and nowhere else. +export const chooseWalkMode = ({ base, env }) => { + const email = env.COMMONLY_WALK_EMAIL || ''; + if (!email) return { mode: 'signup', deleteAccount: true }; + const url = new URL(base); + if (url.protocol !== 'https:' || !STAGING_HOSTS.has(url.hostname)) { + throw new Error('COMMONLY_WALK_EMAIL is set but the target is not the staging API over https; the shared sign-in account exists on staging only'); + } + return { mode: 'signin', email, deleteAccount: false }; +}; diff --git a/scripts/first-user-walk-mode.test.mjs b/scripts/first-user-walk-mode.test.mjs new file mode 100644 index 0000000000..363fa30413 --- /dev/null +++ b/scripts/first-user-walk-mode.test.mjs @@ -0,0 +1,30 @@ +// Run: node --test scripts/first-user-walk-mode.test.mjs +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chooseWalkMode } from './first-user-walk-mode.mjs'; + +const STAGING = 'https://api-staging.commonly.me'; +const PROD = 'https://api.commonly.me'; + +test('staging with COMMONLY_WALK_EMAIL takes the sign-in path', () => { + assert.deepEqual(chooseWalkMode({ base: STAGING, env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), { mode: 'signin', email: 'walk@commonly.me', deleteAccount: false }); +}); + +test('no COMMONLY_WALK_EMAIL signs up a fresh account, on any target, and that account is deleted', () => { + assert.deepEqual(chooseWalkMode({ base: PROD, env: {} }), { mode: 'signup', deleteAccount: true }); + assert.deepEqual(chooseWalkMode({ base: STAGING, env: {} }), { mode: 'signup', deleteAccount: true }); +}); + +test('the shared sign-in account is refused on prod', () => { + assert.throws(() => chooseWalkMode({ base: PROD, env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), /staging only/); +}); + +test('a host that merely contains "staging" never receives the shared account', () => { + for (const base of ['https://staging.attacker.example', 'https://api-staging.commonly.me.attacker.example', 'https://evil-staging.example']) { + assert.throws(() => chooseWalkMode({ base, env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), /staging API over https/, base); + } +}); + +test('the shared sign-in account is refused over plain http, even on the staging host', () => { + assert.throws(() => chooseWalkMode({ base: 'http://api-staging.commonly.me', env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), /over https/); +}); diff --git a/scripts/first-user-walk.mjs b/scripts/first-user-walk.mjs index 805ed3e9fa..2f0c9fee6d 100644 --- a/scripts/first-user-walk.mjs +++ b/scripts/first-user-walk.mjs @@ -14,6 +14,12 @@ * Usage: * COMMONLY_WALK_PASSWORD=... node scripts/first-user-walk.mjs [--base https://api.commonly.me] * + * Staging: signup needs a verified email, so SRE's shared walk account is used + * instead. Set COMMONLY_WALK_EMAIL and COMMONLY_WALK_PASSWORD from env (never a + * flag default or a file in the repo) and pass the staging API as --base. The + * run signs in, removes only its own pods, seats and agents, and never deletes + * that account. A COMMONLY_WALK_EMAIL aimed at any other host is refused. + * * Not yet covered: the Bring your own connect step (copy the command, run it * under a throwaway HOME, get a live reply). It is reported as NOT COVERED and * the run exits 2, so a green run never claims more than it checked. @@ -22,7 +28,8 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import path from 'node:path'; -import { cleanupRun, ownerOf } from './first-user-walk-cleanup.mjs'; +import { cleanupRun, ownerOf, recoverRunPods } from './first-user-walk-cleanup.mjs'; +import { chooseWalkMode } from './first-user-walk-mode.mjs'; const WALK_PREFIX = 'walk-'; // Every request is bounded, cleanup included, so one stalled socket cannot @@ -39,12 +46,14 @@ const BASE = (flag('base', process.env.COMMONLY_API_URL || 'https://api.commonly const now = Date.now(); const stamp = now.toString(36); const username = `${WALK_PREFIX}${stamp}`; -const email = `${username}@example.invalid`; +let email = `${username}@example.invalid`; const agentName = `${username}-agent`; const results = []; let step = 'init'; // True once signup may have created the account, so cleanup knows to look for it. let accountMayExist = false; +// 'signup' makes a fresh account; 'signin' uses the shared staging account and never deletes it. +let walkMode = { mode: 'signup' }; const api = async (method, path, { token, body } = {}) => { const res = await fetch(`${BASE}${path}`, { @@ -55,6 +64,8 @@ const api = async (method, path, { token, body } = {}) => { ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: body ? JSON.stringify(body) : undefined, + // A redirect would replay the JSON body, shared password included, to another origin. + redirect: 'error', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); let data = null; @@ -87,7 +98,10 @@ const expect = (cond, msg) => { if (!cond) throw new Error(msg); }; const STATE_DIR = process.env.COMMONLY_WALK_STATE_DIR || path.join(homedir(), '.commonly', 'state', 'walk-706'); // Signup returns no user id, so the manifest carries the exact username instead: // SRE finds the account by that name, not by a prefix. Never the email. -const manifest = { userId: null, username: null, podIds: [], agentName: null, botIds: [], survivors: [], status: 'running' }; +const manifest = { mode: null, userId: null, username: null, podIds: [], unresolvedPodNames: [], agentName: null, botIds: [], survivors: [], status: 'running' }; +// A pod whose create outcome could not be checked. Its name is unique to the run, +// so SRE can find it by exact name, the same way as the account. +const unresolvedSurvivors = () => manifest.unresolvedPodNames.map((name) => `pod named "${name}" (create outcome unknown)`); const writeManifest = () => { mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 }); writeFileSync(path.join(STATE_DIR, `${stamp}.json`), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); @@ -113,6 +127,7 @@ const cleanup = async (token, password) => { manifest.survivors = [ ...manifest.podIds.map((id) => `pod:${id} (no sign-in for cleanup)`), ...(accountMayExist ? [`account ${manifest.username} (no sign-in for cleanup)`] : []), + ...unresolvedSurvivors(), ]; manifest.status = manifest.survivors.length ? 'needs-admin-purge' : 'clean'; writeManifest(); @@ -128,6 +143,7 @@ const cleanup = async (token, password) => { userId: manifest.userId, agentName, podIds: manifest.podIds, + deleteAccount: walkMode.deleteAccount, // Persist each pod's bot ids before that pod is deleted, so a throw // later in the loop still leaves the ids SRE needs. onBotIds: (podId, bots) => { @@ -135,11 +151,11 @@ const cleanup = async (token, password) => { writeManifest(); }, }); - manifest.survivors = result.survivors; + manifest.survivors = [...result.survivors, ...unresolvedSurvivors()]; manifest.botIds = [...new Set([...manifest.botIds, ...result.botIds])]; - manifest.status = result.ok ? 'clean' : 'needs-admin-purge'; + manifest.status = result.ok && manifest.survivors.length === 0 ? 'clean' : 'needs-admin-purge'; } catch (error) { - manifest.survivors = [`cleanup error: ${error.message}`]; + manifest.survivors = [`cleanup error: ${error.message}`, ...unresolvedSurvivors()]; manifest.status = 'needs-admin-purge'; } writeManifest(); @@ -151,27 +167,33 @@ const cleanup = async (token, password) => { const main = async () => { const password = process.env.COMMONLY_WALK_PASSWORD; if (!password) throw new Error('COMMONLY_WALK_PASSWORD is not set'); + walkMode = chooseWalkMode({ base: BASE, env: process.env }); + if (walkMode.mode === 'signin') email = walkMode.email; + manifest.mode = walkMode.mode; let token = null; let exitCode = 0; try { - await runStep('signup', async () => { - manifest.username = username; - writeManifest(); - let r; - try { - r = await api('POST', '/api/auth/register', { body: { username, email, password } }); - } catch (error) { - accountMayExist = true; // the request may have landed before the failure - throw error; - } - if (r.status === 201) accountMayExist = true; - expect(r.status === 201, `register ${r.status}: ${JSON.stringify(r.data)}`); - }); + if (walkMode.mode === 'signup') { + await runStep('signup', async () => { + manifest.username = username; + writeManifest(); + let r; + try { + r = await api('POST', '/api/auth/register', { body: { username, email, password } }); + } catch (error) { + accountMayExist = true; // the request may have landed before the failure + throw error; + } + if (r.status === 201) accountMayExist = true; + expect(r.status === 201, `register ${r.status}: ${JSON.stringify(r.data)}`); + }); + } token = await runStep('login', async () => { const r = await api('POST', '/api/auth/login', { body: { email, password } }); - expect(r.status === 200 && r.data?.token, `login ${r.status}: ${JSON.stringify(r.data)}`); + // Status and error code only: a body could echo the shared email into the log. + expect(r.status === 200 && r.data?.token, `login ${r.status}${r.data?.code ? ` ${r.data.code}` : ''}`); expect(r.data.user?.role !== 'admin', 'walk account is admin; the walk is meaningless'); manifest.userId = String(r.data.user?._id || r.data.user?.id || ''); expect(manifest.userId, 'login returned no user id'); @@ -181,7 +203,9 @@ const main = async () => { // Signup creates a workspace pod. Check it exists before the walk makes its // own pod, so a failed signup workspace cannot be hidden by the second pod. - await runStep('signup-workspace', async () => { + // Signup-only: on the shared account a workspace pod already exists, and + // recording it here would make cleanup delete a pod this run did not create. + if (walkMode.mode === 'signup') await runStep('signup-workspace', async () => { const list = await api('GET', '/api/pods', { token }); const pods = Array.isArray(list.data) ? list.data : (list.data?.pods || []); const owned = list.status === 200 ? pods.filter((p) => ownerOf(p) === manifest.userId) : []; @@ -193,8 +217,27 @@ const main = async () => { }); const pod = await runStep('first-pod', async () => { - const r = await api('POST', '/api/pods', { token, body: { name: `${username} workspace`, type: 'team' } }); + const podName = `${username} workspace`; + // An error, or a reply without an id, may still mean the pod was made. + // Look for it by its unique name before the step can fail, so cleanup + // removes it, or the name is reported as unresolved. + const recover = async () => { + if (!await recoverRunPods({ api, token, userId: manifest.userId, podName, podIds: manifest.podIds })) { + manifest.unresolvedPodNames.push(podName); + } + writeManifest(); + }; + let r; + try { + r = await api('POST', '/api/pods', { token, body: { name: podName, type: 'team' } }); + } catch (error) { + await recover(); + throw error; + } const created = r.data?.pod || r.data; + // Any reply without a created pod may still have committed one, including a + // 4xx or 5xx, so the outcome is checked by name rather than trusted. + if (!(r.status < 300 && created?._id)) await recover(); expect(r.status < 300 && created?._id, `pod create ${r.status}: ${JSON.stringify(r.data)}`); manifest.podIds.push(String(created._id)); writeManifest();