Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/operations/how-to/set-up-production.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ them and asks first, because the migrations come from your checkout.
`pnpm preflight` runs the same checks and only reports. It never installs,
creates, writes or asks anything.

Both commands print every value that is not secret, such as the account ID,
the database ids and the Web Analytics tokens, so you can check that each is
the one you expect. The deploy token is the only value they hide: it is shown
as set or not set, never printed, and typed at a prompt that does not echo.

## Replacing a value

The bootstrap never replaces a value that is already set unless you ask for
Expand Down
7 changes: 6 additions & 1 deletion scripts/bootstrap/lib/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ export interface BootstrapIo {
/** Writes a file by its path relative to the repository root. */
writeFile: (relativePath: string, content: string) => void;
confirm: (message: string, initialValue: boolean) => Promise<boolean>;
/** Masked prompt. The answer is never echoed or logged. */
/**
* Masked prompt, for real credentials only: API keys, tokens, client and
* signing secrets, private keys. The answer is never echoed or logged.
* Anything else is asked with `text` and shown in every report, because
* hiding a value that is not secret only stops people checking it.
*/
secret: (message: string) => Promise<string>;
/** Plain prompt for a value that is not secret, checked by `check`. */
text: (message: string, check: (value: string) => string | undefined) => Promise<string>;
Expand Down
40 changes: 27 additions & 13 deletions scripts/bootstrap/phases/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,31 @@ function analyticsSteps(account: CloudflareAccount, variable: AnalyticsVariable)
].join('\n');
}

/** Names of the variables the environment already holds a value for, or
* null when they could not be read. */
function variablesSet(io: BootstrapIo): string[] | null {
/** The environment's variables that hold a value, by name, or null when
* they could not be read. */
function variablesSet(io: BootstrapIo): Map<string, string> | null {
const listed = io.run(`gh variable list --env ${ENVIRONMENT} --json name,value`);
if (!listed.ok) return null;
const rows = parseJsonOutput(listed.stdout);
if (!Array.isArray(rows)) return null;
return (rows as { name?: unknown; value?: unknown }[]).flatMap((row) =>
typeof row.name === 'string' && typeof row.value === 'string' && row.value.trim()
? [row.name]
: [],
return new Map(
(rows as { name?: unknown; value?: unknown }[]).flatMap((row) =>
typeof row.name === 'string' && typeof row.value === 'string' && row.value.trim()
? [[row.name, row.value] as const]
: [],
),
);
}

/**
* The token itself, beside the host it belongs to. It is public, so hiding
* it would only stop somebody checking that the right site's token is
* stored, which is the one mistake this report can catch.
*/
function tokenDetail(variable: AnalyticsVariable, token: string): string {
return `${token} (${variable.host})`;
}

async function askAndStore(
io: BootstrapIo,
account: CloudflareAccount,
Expand All @@ -91,6 +102,8 @@ async function askAndStore(
});
if (!result.ok) {
io.log('error', `Failed to set ${variable.name}: ${result.stderr || result.stdout}`);
} else {
io.log('success', `${variable.name} is set to ${tokenDetail(variable, token)}.`);
}
return result.ok;
}
Expand All @@ -114,12 +127,13 @@ export async function runAnalyticsPhase({ doctor, io }: PhaseContext): Promise<P
return { success: false };
}

const missing = ANALYTICS_VARIABLES.filter((variable) => !set.includes(variable.name));
const rows: ToolRow[] = ANALYTICS_VARIABLES.map((variable) =>
missing.includes(variable)
? { label: variable.name, status: 'failed', detail: 'not set' }
: { label: variable.name, status: 'ready', detail: variable.host },
);
const missing = ANALYTICS_VARIABLES.filter((variable) => !set.has(variable.name));
const rows: ToolRow[] = ANALYTICS_VARIABLES.map((variable) => {
const token = set.get(variable.name);
return token === undefined
? { label: variable.name, status: 'failed', detail: `not set (${variable.host})` }
: { label: variable.name, status: 'ready', detail: tokenDetail(variable, token) };
});
io.table('Analytics variables', rows);
if (missing.length === 0) return { success: true };
if (doctor) return { success: false };
Expand Down
2 changes: 1 addition & 1 deletion scripts/bootstrap/phases/ci-secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ export async function runCiSecretsPhase({

io.log(
'success',
`${TOKEN_SECRET} (secret) and ${ACCOUNT_VARIABLE} (variable) are set on ${states.map((state) => state.environment).join(' and ')}. CI deploys should work on the next push to main.`,
`${TOKEN_SECRET} (secret) and ${ACCOUNT_VARIABLE} (variable, ${accountId}) are set on ${states.map((state) => state.environment).join(' and ')}. CI deploys should work on the next push to main.`,
);
return { success: true };
}
27 changes: 27 additions & 0 deletions scripts/tests/bootstrap-idempotency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '../bootstrap/lib/wrangler-config.js';
import {
ACCOUNT,
FAKE_ANALYTICS_TOKEN,
FAKE_TOKEN,
FakeServices,
isMutation,
Expand Down Expand Up @@ -352,6 +353,32 @@ describe('credentials that are already set', () => {
});
});

describe('what the bootstrap shows', () => {
it('masks the token and nothing else', async () => {
const first = await run(services);

expect(first.maskedPrompts).toHaveLength(1);
expect(first.prompts.length).toBeGreaterThan(first.maskedPrompts.length);
});

it('never prints the token, and shows every value that is not secret', async () => {
const first = await run(services);
const second = await run(services);
const report = await run(services, { doctor: true });

for (const record of [first, second, report]) {
expect(record.output.join('\n')).not.toContain(FAKE_TOKEN);
expect(record.calls.some((call) => call.command.includes(FAKE_TOKEN))).toBe(false);
}
for (const record of [second, report]) {
const printed = record.output.join('\n');
expect(printed).toContain(ACCOUNT.id);
expect(printed).toContain(FAKE_ANALYTICS_TOKEN);
for (const database of services.databases) expect(printed).toContain(database.uuid);
}
});
});

describe('preflight', () => {
it('reports a brand-new setup without changing or asking anything', async () => {
const report = await run(services, { doctor: true });
Expand Down
27 changes: 23 additions & 4 deletions scripts/tests/support/fake-bootstrap-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,12 @@ interface RecordedCall {
export interface RunRecord {
calls: RecordedCall[];
prompts: string[];
/** The prompts that masked their answer. */
maskedPrompts: string[];
writes: string[];
tables: ToolRow[][];
/** Everything printed: notes, table rows, and log lines. */
output: string[];
}

/** One GitHub API request as the fake routes it. */
Expand Down Expand Up @@ -130,7 +134,14 @@ export class FakeServices {

/** A fresh seam for one run, recording what that run does. */
io(): BootstrapIo & { record: RunRecord } {
const record: RunRecord = { calls: [], prompts: [], writes: [], tables: [] };
const record: RunRecord = {
calls: [],
prompts: [],
maskedPrompts: [],
writes: [],
tables: [],
output: [],
};
const ask = <T>(message: string, answer: T): Promise<T> => {
record.prompts.push(message);
return Promise.resolve(answer);
Expand All @@ -150,18 +161,26 @@ export class FakeServices {
writeFileSync(path.join(this.root, relativePath), content, 'utf8');
},
confirm: (message) => ask(message, true),
secret: (message) => ask(message, FAKE_TOKEN),
secret: (message) => {
record.maskedPrompts.push(message);
return ask(message, FAKE_TOKEN);
},
text: (message, check) => {
const refused = check(FAKE_ANALYTICS_TOKEN);
if (refused) throw new Error(`the fake analytics token was refused: ${refused}`);
return ask(message, FAKE_ANALYTICS_TOKEN);
},
openUrl: () => true,
note: () => undefined,
note: (body) => {
record.output.push(body);
},
table: (_title, rows) => {
record.tables.push([...rows]);
record.output.push(...rows.map((row) => `${row.label} ${row.detail ?? ''}`));
},
log: (_level, message) => {
record.output.push(message);
},
log: () => undefined,
};
}

Expand Down
Loading