2026-06-30 21:10:34 +02:00
|
|
|
// components/credentials.ts
|
|
|
|
|
//
|
|
|
|
|
// CONTRACT_002 single owner of credential GENERATION + Vault distribution.
|
|
|
|
|
//
|
|
|
|
|
// Generation (`generateCredentials`) is pure @pulumi/random — no Vault, no
|
|
|
|
|
// network — so it can be created in Phase 3 and consumed by the data-plane
|
|
|
|
|
// components (postgres.ts needs its password at container boot). Distribution
|
|
|
|
|
// (`writeCredentialsToVault`, T06) is the half that depends on Vault being
|
|
|
|
|
// unsealed (Gate A); it writes every value to the KV paths in CONTRACT_002 §2.3.
|
|
|
|
|
// Splitting the two halves resolves the ordering tension between "Postgres up in
|
|
|
|
|
// Phase 3" and "secrets in Vault in Phase 5" without giving up single ownership.
|
|
|
|
|
//
|
|
|
|
|
// camelCase keys, no exceptions (CONTRACT_002 §2.2): the Vault write JSON-encodes
|
|
|
|
|
// these objects, so the key names ARE the camelCase Vault keys downstream reads.
|
|
|
|
|
import * as pulumi from "@pulumi/pulumi";
|
2026-06-30 21:37:26 +02:00
|
|
|
import * as command from "@pulumi/command";
|
2026-06-30 21:10:34 +02:00
|
|
|
import { RandomPassword } from "@pulumi/random";
|
feat(credentials): mirror backup creds + age key into Vault (CONTRACT_002)
foundation/backup/backup-credentials was never populated in Vault. Add a
writer (same ADR-007 docker-exec-over-SSH pattern, GATE A / dependsOn
vault.init) that mirrors the config-seeded offsite S3 creds and the age key
into Vault, completing CONTRACT_002 §2.3 for in-Vault consumers (Layer-1
ESO, the weekly backup-verify job).
- config.ts: loadBackupSecrets() — single reader of the backup secret slice
(offsite creds + age recipient/identity), keeping components off raw Config.
- credentials.ts: writeBackupCredentialsToVault() — idempotent vault kv put;
secret values on stdin (D2), non-secrets as shell vars.
- index.ts: wire it beside the data-plane creds writer.
Keys written: offsiteEndpoint, offsiteAccessKey, offsiteSecretKey,
backupAgeRecipient, backupAgeIdentity. Validated live: +1 resource, then
42 unchanged (idempotent); vault kv get shows all five keys populated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:23:38 +02:00
|
|
|
import { BackupSecrets } from "../config";
|
2026-06-30 21:10:34 +02:00
|
|
|
import { DeployCtx } from "../lib/context";
|
2026-06-30 21:37:26 +02:00
|
|
|
import { vmConnection } from "../lib/remote";
|
|
|
|
|
import { VaultOutputs } from "./vault";
|
2026-06-30 21:10:34 +02:00
|
|
|
|
|
|
|
|
/** `foundation/postgres/service-credentials` (CONTRACT_002 §2.3). */
|
|
|
|
|
export interface PostgresCredentials {
|
|
|
|
|
superUser: string; // "postgres" — image default superuser (deterministic)
|
|
|
|
|
superPassword: pulumi.Output<string>;
|
|
|
|
|
forgejoDbUser: string; // "forgejo" (deterministic)
|
|
|
|
|
forgejoDbPassword: pulumi.Output<string>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 21:19:53 +02:00
|
|
|
/** `foundation/rustfs/service-credentials` (CONTRACT_002 §2.3). */
|
|
|
|
|
export interface RustfsCredentials {
|
|
|
|
|
adminUser: string; // "rustfsadmin" — root access key name (deterministic)
|
|
|
|
|
adminPassword: pulumi.Output<string>; // root secret key (RUSTFS_SECRET_KEY)
|
|
|
|
|
serviceKeyId: pulumi.Output<string>; // scoped S3 key Forgejo/backup use
|
|
|
|
|
serviceKeySecret: pulumi.Output<string>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 22:31:13 +02:00
|
|
|
/**
|
|
|
|
|
* `foundation/forgejo/service-credentials` — the admin slice (CONTRACT_002 §2.3).
|
|
|
|
|
* The crypto secrets (forgejoSecretKey/InternalToken/Jwt*) are auto-generated by
|
|
|
|
|
* Forgejo into its app.ini (format-constrained — JWTs, not free random), so they
|
|
|
|
|
* are not generated here; capturing them into Vault is a later refinement.
|
|
|
|
|
*/
|
|
|
|
|
export interface ForgejoCredentials {
|
|
|
|
|
adminUser: string; // cfg.forgejo.adminUser (deterministic)
|
|
|
|
|
adminPassword: pulumi.Output<string>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 21:10:34 +02:00
|
|
|
/** Everything generateCredentials() produces; grows as Wave-2 tasks land. */
|
|
|
|
|
export interface FoundationCredentials {
|
|
|
|
|
postgres: PostgresCredentials;
|
2026-06-30 21:19:53 +02:00
|
|
|
rustfs: RustfsCredentials;
|
2026-06-30 22:31:13 +02:00
|
|
|
forgejo: ForgejoCredentials;
|
2026-06-30 21:10:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* High-entropy alphanumeric secret. special:false keeps values safe to drop into
|
|
|
|
|
* connection strings / app.ini / psql literals without escaping (len 28 ≈ 166 bits).
|
|
|
|
|
*/
|
|
|
|
|
function secret(name: string, length = 28): pulumi.Output<string> {
|
|
|
|
|
return new RandomPassword(name, { length, special: false }).result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Generate all egg credentials (pure; no dependencies). CONTRACT_002 writer. */
|
|
|
|
|
export function generateCredentials(ctx: DeployCtx): FoundationCredentials {
|
|
|
|
|
return {
|
|
|
|
|
postgres: {
|
|
|
|
|
superUser: "postgres",
|
|
|
|
|
superPassword: secret("postgres-super-password"),
|
|
|
|
|
forgejoDbUser: "forgejo",
|
|
|
|
|
forgejoDbPassword: secret("forgejo-db-password"),
|
|
|
|
|
},
|
2026-06-30 21:19:53 +02:00
|
|
|
rustfs: {
|
|
|
|
|
adminUser: "rustfsadmin",
|
|
|
|
|
adminPassword: secret("rustfs-admin-password"),
|
|
|
|
|
serviceKeyId: secret("rustfs-service-key-id", 20), // S3 access-key id
|
|
|
|
|
serviceKeySecret: secret("rustfs-service-key-secret", 40), // S3 secret
|
|
|
|
|
},
|
2026-06-30 22:31:13 +02:00
|
|
|
forgejo: {
|
|
|
|
|
adminUser: ctx.cfg.forgejo.adminUser, // "platform-admin"
|
|
|
|
|
adminPassword: secret("forgejo-admin-password"),
|
|
|
|
|
},
|
2026-06-30 21:10:34 +02:00
|
|
|
};
|
|
|
|
|
}
|
2026-06-30 21:37:26 +02:00
|
|
|
|
|
|
|
|
// Writes the generated data-plane credentials into Vault KV v2 at the CONTRACT_002
|
|
|
|
|
// paths. Like every control-plane op the egg can't reach 8200 directly, so this
|
|
|
|
|
// runs `vault kv put` inside foundation-vault over SSH (ADR-007). Secret VALUES go
|
|
|
|
|
// in as a JSON object on the container's stdin (never in argv); the root token
|
|
|
|
|
// authenticates via -e (transient, VM-trusted). Idempotent: kv-v2 enable is
|
|
|
|
|
// guarded and `vault kv put` overwrites. Forgejo crypto secrets, the runner token,
|
|
|
|
|
// registry tokens, and backup creds are written by their own tasks (T08/T10/T12) —
|
|
|
|
|
// this is the Phase-5 data-plane slice. dependsOn vault.init = GATE A.
|
|
|
|
|
const WRITE_CREDS = `set -eu
|
|
|
|
|
IFS= read -r ROOT_TOKEN
|
|
|
|
|
IFS= read -r PG_SUPER_PW
|
|
|
|
|
IFS= read -r PG_FORGEJO_PW
|
|
|
|
|
IFS= read -r RUSTFS_ADMIN_PW
|
|
|
|
|
IFS= read -r RUSTFS_SVC_ID
|
|
|
|
|
IFS= read -r RUSTFS_SVC_SECRET
|
2026-06-30 22:31:13 +02:00
|
|
|
IFS= read -r FORGEJO_ADMIN_PW
|
2026-06-30 21:37:26 +02:00
|
|
|
C=foundation-vault
|
|
|
|
|
VE="-e VAULT_ADDR=http://127.0.0.1:8200 -e VAULT_TOKEN=$ROOT_TOKEN"
|
|
|
|
|
|
|
|
|
|
if ! docker exec $VE "$C" vault secrets list -format=json 2>/dev/null | jq -e 'has("foundation/")' >/dev/null; then
|
|
|
|
|
docker exec $VE "$C" vault secrets enable -path=foundation kv-v2 >/dev/null
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
put() { docker exec -i $VE "$C" vault kv put "foundation/$1" - >/dev/null; }
|
|
|
|
|
|
|
|
|
|
jq -n --arg u "$PG_SUPER_USER" --arg p "$PG_SUPER_PW" --arg fu "$PG_FORGEJO_USER" --arg fp "$PG_FORGEJO_PW" \
|
|
|
|
|
'{postgresSuperUser:$u,postgresSuperPassword:$p,forgejoDbUser:$fu,forgejoDbPassword:$fp}' \
|
|
|
|
|
| put postgres/service-credentials
|
|
|
|
|
|
|
|
|
|
jq -n --arg u "$RUSTFS_ADMIN_USER" --arg p "$RUSTFS_ADMIN_PW" --arg ki "$RUSTFS_SVC_ID" --arg ks "$RUSTFS_SVC_SECRET" \
|
|
|
|
|
'{rustfsAdminUser:$u,rustfsAdminPassword:$p,rustfsServiceKeyId:$ki,rustfsServiceKeySecret:$ks}' \
|
|
|
|
|
| put rustfs/service-credentials
|
|
|
|
|
|
2026-06-30 22:31:13 +02:00
|
|
|
jq -n --arg u "$FORGEJO_ADMIN_USER" --arg p "$FORGEJO_ADMIN_PW" \
|
|
|
|
|
'{forgejoAdminUser:$u,forgejoAdminPassword:$p}' \
|
|
|
|
|
| put forgejo/service-credentials
|
|
|
|
|
|
|
|
|
|
echo "vault: wrote postgres + rustfs + forgejo service-credentials"`;
|
2026-06-30 21:37:26 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* T06 — distribute the generated data-plane credentials into Vault (CONTRACT_002).
|
|
|
|
|
* Depends on Vault being unsealed (GATE A) via vault.init.
|
|
|
|
|
*/
|
|
|
|
|
export function writeCredentialsToVault(
|
|
|
|
|
ctx: DeployCtx,
|
|
|
|
|
creds: FoundationCredentials,
|
|
|
|
|
vault: VaultOutputs,
|
|
|
|
|
): command.remote.Command {
|
|
|
|
|
// Non-secret usernames are prepended as shell vars; the 6 secret values (root
|
|
|
|
|
// token first, then the order the script `read`s them) arrive on stdin.
|
|
|
|
|
const create = pulumi.interpolate`PG_SUPER_USER='${creds.postgres.superUser}'
|
|
|
|
|
PG_FORGEJO_USER='${creds.postgres.forgejoDbUser}'
|
|
|
|
|
RUSTFS_ADMIN_USER='${creds.rustfs.adminUser}'
|
2026-06-30 22:31:13 +02:00
|
|
|
FORGEJO_ADMIN_USER='${creds.forgejo.adminUser}'
|
2026-06-30 21:37:26 +02:00
|
|
|
${WRITE_CREDS}`;
|
|
|
|
|
|
|
|
|
|
return new command.remote.Command(
|
|
|
|
|
"foundation-vault-credentials",
|
|
|
|
|
{
|
|
|
|
|
connection: vmConnection(ctx),
|
|
|
|
|
create,
|
|
|
|
|
update: create,
|
|
|
|
|
stdin: pulumi.interpolate`${vault.rootToken}
|
|
|
|
|
${creds.postgres.superPassword}
|
|
|
|
|
${creds.postgres.forgejoDbPassword}
|
|
|
|
|
${creds.rustfs.adminPassword}
|
|
|
|
|
${creds.rustfs.serviceKeyId}
|
|
|
|
|
${creds.rustfs.serviceKeySecret}
|
2026-06-30 22:31:13 +02:00
|
|
|
${creds.forgejo.adminPassword}
|
2026-06-30 21:37:26 +02:00
|
|
|
`,
|
|
|
|
|
addPreviousOutputInEnv: false,
|
|
|
|
|
triggers: [
|
|
|
|
|
vault.init.id,
|
|
|
|
|
creds.postgres.superPassword,
|
|
|
|
|
creds.postgres.forgejoDbPassword,
|
|
|
|
|
creds.rustfs.adminPassword,
|
|
|
|
|
creds.rustfs.serviceKeyId,
|
|
|
|
|
creds.rustfs.serviceKeySecret,
|
2026-06-30 22:31:13 +02:00
|
|
|
creds.forgejo.adminPassword,
|
2026-06-30 21:37:26 +02:00
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{ dependsOn: [vault.init] },
|
|
|
|
|
);
|
|
|
|
|
}
|
feat(credentials): mirror backup creds + age key into Vault (CONTRACT_002)
foundation/backup/backup-credentials was never populated in Vault. Add a
writer (same ADR-007 docker-exec-over-SSH pattern, GATE A / dependsOn
vault.init) that mirrors the config-seeded offsite S3 creds and the age key
into Vault, completing CONTRACT_002 §2.3 for in-Vault consumers (Layer-1
ESO, the weekly backup-verify job).
- config.ts: loadBackupSecrets() — single reader of the backup secret slice
(offsite creds + age recipient/identity), keeping components off raw Config.
- credentials.ts: writeBackupCredentialsToVault() — idempotent vault kv put;
secret values on stdin (D2), non-secrets as shell vars.
- index.ts: wire it beside the data-plane creds writer.
Keys written: offsiteEndpoint, offsiteAccessKey, offsiteSecretKey,
backupAgeRecipient, backupAgeIdentity. Validated live: +1 resource, then
42 unchanged (idempotent); vault kv get shows all five keys populated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:23:38 +02:00
|
|
|
|
|
|
|
|
// Mirrors the config-seeded backup credentials (offsite S3 creds + the age key)
|
|
|
|
|
// into Vault at foundation/backup/backup-credentials (CONTRACT_002 §2.3). Unlike
|
|
|
|
|
// the generated data-plane creds these are seeded once into passphrase-encrypted
|
|
|
|
|
// config (the age IDENTITY MUST also live there so {repo + passphrase} can decrypt
|
|
|
|
|
// a bundle after total Vault loss — CONTRACT_004 §4.3); this writer makes them
|
|
|
|
|
// available to in-Vault consumers (Layer-1 ESO, the backup-verify job). Secret
|
|
|
|
|
// values on stdin (ADR-007 D2); non-secrets (endpoint, recipient) as shell vars.
|
|
|
|
|
const WRITE_BACKUP_CREDS = `set -eu
|
|
|
|
|
IFS= read -r ROOT_TOKEN
|
|
|
|
|
IFS= read -r OFF_AK
|
|
|
|
|
IFS= read -r OFF_SK
|
|
|
|
|
IFS= read -r AGE_IDENTITY
|
|
|
|
|
C=foundation-vault
|
|
|
|
|
VE="-e VAULT_ADDR=http://127.0.0.1:8200 -e VAULT_TOKEN=$ROOT_TOKEN"
|
|
|
|
|
|
|
|
|
|
if ! docker exec $VE "$C" vault secrets list -format=json 2>/dev/null | jq -e 'has("foundation/")' >/dev/null; then
|
|
|
|
|
docker exec $VE "$C" vault secrets enable -path=foundation kv-v2 >/dev/null
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
jq -n --arg ep "$OFF_EP" --arg ak "$OFF_AK" --arg sk "$OFF_SK" --arg ar "$AGE_RECIPIENT" --arg ai "$AGE_IDENTITY" \
|
|
|
|
|
'{offsiteEndpoint:$ep,offsiteAccessKey:$ak,offsiteSecretKey:$sk,backupAgeRecipient:$ar,backupAgeIdentity:$ai}' \
|
|
|
|
|
| docker exec -i $VE "$C" vault kv put foundation/backup/backup-credentials - >/dev/null
|
|
|
|
|
|
|
|
|
|
echo "vault: wrote backup/backup-credentials (offsite + age key)"`;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Mirror the backup credentials (incl. the age key) into Vault (CONTRACT_002 §2.3).
|
|
|
|
|
* Depends on Vault being unsealed (GATE A) via vault.init — same pattern as the
|
|
|
|
|
* data-plane creds writer above.
|
|
|
|
|
*/
|
|
|
|
|
export function writeBackupCredentialsToVault(
|
|
|
|
|
ctx: DeployCtx,
|
|
|
|
|
vault: VaultOutputs,
|
|
|
|
|
backup: BackupSecrets,
|
|
|
|
|
): command.remote.Command {
|
|
|
|
|
const create = pulumi.interpolate`OFF_EP='${backup.offsiteEndpoint}'
|
|
|
|
|
AGE_RECIPIENT='${backup.ageRecipient}'
|
|
|
|
|
${WRITE_BACKUP_CREDS}`;
|
|
|
|
|
|
|
|
|
|
return new command.remote.Command(
|
|
|
|
|
"foundation-backup-credentials",
|
|
|
|
|
{
|
|
|
|
|
connection: vmConnection(ctx),
|
|
|
|
|
create,
|
|
|
|
|
update: create,
|
|
|
|
|
stdin: pulumi.interpolate`${vault.rootToken}
|
|
|
|
|
${backup.offsiteAccessKey}
|
|
|
|
|
${backup.offsiteSecretKey}
|
|
|
|
|
${backup.ageIdentity}
|
|
|
|
|
`,
|
|
|
|
|
addPreviousOutputInEnv: false,
|
|
|
|
|
triggers: [
|
|
|
|
|
vault.init.id,
|
|
|
|
|
backup.offsiteAccessKey,
|
|
|
|
|
backup.offsiteSecretKey,
|
|
|
|
|
backup.ageIdentity,
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{ dependsOn: [vault.init] },
|
|
|
|
|
);
|
|
|
|
|
}
|