Skip to content
Open
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
14 changes: 14 additions & 0 deletions apps/docs/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,5 +92,19 @@ The command takes a PostgreSQL advisory lock. Repeating the exact binding is saf
it already exists; a different binding against a populated instance is refused. Use `--json` for
automation and protect `DATABASE_URL` as an administrative secret.

Every option also reads a `FACILITY_<OPTION>` environment variable — `--org-slug` reads
`FACILITY_ORG_SLUG`, `--github-installation-id` reads `FACILITY_GITHUB_INSTALLATION_ID` — so a
container task can supply the binding without a command line and without a shell to expand it. An
option given on the command line wins over its variable, and a malformed option fails rather than
falling back to the environment. Values are validated identically whichever way they arrive, and a
missing one is reported under both names.

The Compose bundle runs this as a one-shot service, which needs no local Node toolchain because the
CLI ships inside the API image:

```bash
docker compose --profile bootstrap run --rm bootstrap
```

Run `facility <command> --help` for local usage. Unknown options and missing option values fail
instead of being ignored.
5 changes: 4 additions & 1 deletion apps/docs/docs/self-host/production.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ secret value when separate rotation is useful.
Create an empty PostgreSQL database, run the deployment migration entrypoint, and require a zero
exit status before starting the API or worker. Then run `facility instance bootstrap` once to bind
the first organization owner and GitHub App installation. Repeating the exact binding is safe; a
different binding against a populated instance is refused.
different binding against a populated instance is refused. The command reads each value from its
`FACILITY_<OPTION>` variable when no option is given, so a one-shot task can carry the binding in
its environment; the single-host bundle exposes it as `docker compose --profile bootstrap run --rm
bootstrap`.

For application upgrades:

Expand Down
29 changes: 29 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,35 @@ services:
migrate: { condition: service_completed_successfully }
runner-image: { condition: service_completed_successfully }

# One-shot operator bootstrap: binds the first organization, owner identity and
# GitHub App installation, then reconciles the bundled roles. Not part of `up`;
# run it once after the GitHub App is installed.
# docker compose --profile bootstrap run --rm bootstrap
# Every value arrives as an environment variable rather than as an argument, so
# nothing here needs a shell: an organization name or a GitHub login is
# operator input, and a `sh -c` command would interpolate it. Missing values
# are named by the CLI itself, which is why none of these use `:?` — that would
# fail interpolation for the whole file, `up` included.
bootstrap:
profiles: ["bootstrap"]
build: { context: ., target: api }
command: ["facility", "instance", "bootstrap"]
environment:
DATABASE_URL: postgres://facility:${POSTGRES_PASSWORD:-facility}@postgres:5432/facility
FACILITY_ORG_NAME: ${FACILITY_ORG_NAME:-}
FACILITY_ORG_SLUG: ${FACILITY_ORG_SLUG:-}
FACILITY_OWNER_EMAIL: ${FACILITY_OWNER_EMAIL:-}
FACILITY_OWNER_NAME: ${FACILITY_OWNER_NAME:-}
FACILITY_GITHUB_USER_ID: ${FACILITY_GITHUB_USER_ID:-}
FACILITY_GITHUB_LOGIN: ${FACILITY_GITHUB_LOGIN:-}
FACILITY_GITHUB_ACCOUNT_ID: ${FACILITY_GITHUB_ACCOUNT_ID:-}
FACILITY_GITHUB_ACCOUNT_LOGIN: ${FACILITY_GITHUB_ACCOUNT_LOGIN:-}
FACILITY_GITHUB_INSTALLATION_ID: ${FACILITY_GITHUB_INSTALLATION_ID:-}
FACILITY_GITHUB_ACCOUNT_TYPE: ${FACILITY_GITHUB_ACCOUNT_TYPE:-organization}
depends_on:
migrate: { condition: service_completed_successfully }
restart: "no"

web:
build:
context: .
Expand Down
57 changes: 40 additions & 17 deletions packages/cli/src/instance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,39 @@ import postgres from "postgres";
export async function bootstrapInstance(flags, options = {}) {
if (flags.help) {
console.log("facility instance bootstrap --org-name <name> --org-slug <slug> --owner-email <email> --owner-name <name> --github-user-id <id> --github-login <login> --github-account-id <id> --github-account-login <login> --github-installation-id <id> [--github-account-type <organization|user>] [--json]");
console.log("Each option also reads its FACILITY_<OPTION> environment variable, so a container task needs no command line. An option given on the command line wins.");
return 0;
}
const databaseUrl = options.databaseUrl ?? process.env.DATABASE_URL;
const environment = options.environment ?? process.env;
const databaseUrl = options.databaseUrl ?? environment.DATABASE_URL;
if (!databaseUrl) return failure(flags, "DATABASE_URL is required");
const input = {
orgName: stringFlag(flags, "org-name"),
orgSlug: stringFlag(flags, "org-slug"),
ownerEmail: stringFlag(flags, "owner-email")?.toLowerCase(),
ownerName: stringFlag(flags, "owner-name"),
githubUserId: positiveInteger(flags, "github-user-id"),
githubLogin: stringFlag(flags, "github-login"),
githubAccountId: positiveInteger(flags, "github-account-id"),
githubInstallationId: positiveInteger(flags, "github-installation-id"),
githubAccountLogin: stringFlag(flags, "github-account-login"),
githubAccountType: (stringFlag(flags, "github-account-type") ?? "organization").toLowerCase(),
// Resolve every option to its raw string before parsing, so a malformed
// command line fails instead of being rescued by an ambient variable.
const option = (name) => stringFlag(flags, name) ?? trimmed(environment[environmentName(name)]);
const fields = {
orgName: ["org-name", option("org-name")],
orgSlug: ["org-slug", option("org-slug")],
ownerEmail: ["owner-email", option("owner-email")?.toLowerCase()],
ownerName: ["owner-name", option("owner-name")],
githubUserId: ["github-user-id", positiveInteger(option("github-user-id"))],
githubLogin: ["github-login", option("github-login")],
githubAccountId: ["github-account-id", positiveInteger(option("github-account-id"))],
githubInstallationId: [
"github-installation-id",
positiveInteger(option("github-installation-id")),
],
githubAccountLogin: ["github-account-login", option("github-account-login")],
githubAccountType: [
"github-account-type",
(option("github-account-type") ?? "organization").toLowerCase(),
],
};
const missing = Object.entries(input).filter(([, value]) => value === undefined).map(([key]) => key);
const input = Object.fromEntries(Object.entries(fields).map(([key, [, value]]) => [key, value]));
// Name both spellings: this command runs as often from a container task, where
// only the variable exists, as from a shell.
const missing = Object.values(fields)
.filter(([, value]) => value === undefined)
.map(([name]) => `--${name} (${environmentName(name)})`);
if (missing.length) return failure(flags, `Missing required bootstrap values: ${missing.join(", ")}`);
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.orgSlug)) return failure(flags, "--org-slug must be a lowercase URL slug");
if (!/^\S+@\S+\.\S+$/.test(input.ownerEmail)) return failure(flags, "--owner-email must be valid");
Expand Down Expand Up @@ -94,13 +110,20 @@ export async function bootstrapInstance(flags, options = {}) {
}

function stringFlag(flags, name) {
const value = flags[name];
return trimmed(flags[name]);
}

function trimmed(value) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

function positiveInteger(flags, name) {
const value = Number(stringFlag(flags, name));
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
function environmentName(option) {
return `FACILITY_${option.replaceAll("-", "_").toUpperCase()}`;
}

function positiveInteger(value) {
const parsed = Number(value);
return value !== undefined && Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
}

function id(prefix) {
Expand Down
96 changes: 96 additions & 0 deletions packages/cli/test/instance.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,106 @@ const valid = {
json: true,
};

const environment = {
FACILITY_ORG_NAME: "Facility Test",
FACILITY_ORG_SLUG: "facility-test",
FACILITY_OWNER_EMAIL: "Owner@Example.com",
FACILITY_OWNER_NAME: "Owner",
FACILITY_GITHUB_USER_ID: "123",
FACILITY_GITHUB_LOGIN: "owner",
FACILITY_GITHUB_ACCOUNT_ID: "456",
FACILITY_GITHUB_INSTALLATION_ID: "789",
FACILITY_GITHUB_ACCOUNT_LOGIN: "facility-test",
};

// Fails at the first database call, so a run that reaches it has passed every
// validation without needing Postgres or the network.
function refusingPostgres() {
const sql = () => {
throw new Error("unreachable");
};
sql.begin = async () => {
throw new Error("reached-the-database");
};
sql.end = async () => {};
return () => sql;
}

async function captureJson(run) {
const written = [];
const original = console.log;
console.log = (line) => written.push(line);
try {
return { code: await run(), output: written.map((line) => JSON.parse(line)) };
} finally {
console.log = original;
}
}

test("bootstrap validates all identity and installation bindings before connecting", async () => {
assert.equal(await bootstrapInstance({ ...valid, "github-user-id": "not-a-number" }, { databaseUrl: "postgres://unused" }), 1);
});

test("bootstrap takes every value from the environment when no options are given", async () => {
const { code, output } = await captureJson(() =>
bootstrapInstance(
{ json: true },
{ databaseUrl: "postgres://unused", environment, postgres: refusingPostgres() },
),
);
assert.equal(code, 1);
assert.equal(output[0].error.message, "reached-the-database");
});

test("bootstrap prefers an explicit option over its environment variable", async () => {
const { output } = await captureJson(() =>
bootstrapInstance(
{ json: true, "org-slug": "" },
{
databaseUrl: "postgres://unused",
environment: { ...environment, FACILITY_ORG_SLUG: "Not A Slug" },
postgres: refusingPostgres(),
},
),
);
// A blank option is not a value, so the variable still supplies one — and it
// is validated rather than trusted for having come from the environment.
assert.equal(output[0].error.message, "--org-slug must be a lowercase URL slug");

const explicit = await captureJson(() =>
bootstrapInstance(
{ json: true, "org-slug": "from-option" },
{
databaseUrl: "postgres://unused",
environment: { ...environment, FACILITY_ORG_SLUG: "from-environment" },
postgres: refusingPostgres(),
},
),
);
assert.equal(explicit.output[0].error.message, "reached-the-database");
});

test("bootstrap refuses a malformed option instead of falling back to the environment", async () => {
const { code, output } = await captureJson(() =>
bootstrapInstance(
{ json: true, "github-user-id": "not-a-number" },
{ databaseUrl: "postgres://unused", environment, postgres: refusingPostgres() },
),
);
assert.equal(code, 1);
assert.match(output[0].error.message, /^Missing required bootstrap values: /);
});

test("bootstrap names the environment variable for every value it is missing", async () => {
const { output } = await captureJson(() =>
bootstrapInstance({ json: true }, { databaseUrl: "postgres://unused", environment: {} }),
);
assert.equal(
output[0].error.message,
"Missing required bootstrap values: --org-name (FACILITY_ORG_NAME), --org-slug (FACILITY_ORG_SLUG), --owner-email (FACILITY_OWNER_EMAIL), --owner-name (FACILITY_OWNER_NAME), --github-user-id (FACILITY_GITHUB_USER_ID), --github-login (FACILITY_GITHUB_LOGIN), --github-account-id (FACILITY_GITHUB_ACCOUNT_ID), --github-installation-id (FACILITY_GITHUB_INSTALLATION_ID), --github-account-login (FACILITY_GITHUB_ACCOUNT_LOGIN)",
);
});

test("bootstrap is transactional, idempotent for identical input, and rejects conflicts", async (t) => {
const databaseUrl = process.env.DATABASE_URL ?? "postgres://facility:facility@localhost:5461/facility_test";
const admin = postgres(databaseUrl, { max: 1, connect_timeout: 2 });
Expand Down