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>
This commit is contained in:
parent
92e8f978a5
commit
f2ef9bc922
3 changed files with 95 additions and 1 deletions
|
|
@ -15,6 +15,7 @@
|
||||||
import * as pulumi from "@pulumi/pulumi";
|
import * as pulumi from "@pulumi/pulumi";
|
||||||
import * as command from "@pulumi/command";
|
import * as command from "@pulumi/command";
|
||||||
import { RandomPassword } from "@pulumi/random";
|
import { RandomPassword } from "@pulumi/random";
|
||||||
|
import { BackupSecrets } from "../config";
|
||||||
import { DeployCtx } from "../lib/context";
|
import { DeployCtx } from "../lib/context";
|
||||||
import { vmConnection } from "../lib/remote";
|
import { vmConnection } from "../lib/remote";
|
||||||
import { VaultOutputs } from "./vault";
|
import { VaultOutputs } from "./vault";
|
||||||
|
|
@ -167,3 +168,65 @@ ${creds.forgejo.adminPassword}
|
||||||
{ dependsOn: [vault.init] },
|
{ dependsOn: [vault.init] },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,33 @@ export interface FoundationConfig {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backup secret slice (CONTRACT_001 §1.3 + CONTRACT_002 `foundation/backup/
|
||||||
|
* backup-credentials`). Kept OUT of the non-secret FoundationConfig surface: these
|
||||||
|
* are `secure:`-encrypted (offsite creds, age identity) or a public key that only
|
||||||
|
* the Vault mirror + backup scripts consume. `loadBackupSecrets()` is the single
|
||||||
|
* reader (composition point), so components still never touch raw pulumi.Config.
|
||||||
|
*/
|
||||||
|
export interface BackupSecrets {
|
||||||
|
offsiteEndpoint: string; // non-secret (also in FoundationConfig.backup)
|
||||||
|
offsiteAccessKey: pulumi.Output<string>;
|
||||||
|
offsiteSecretKey: pulumi.Output<string>;
|
||||||
|
ageRecipient: string; // age1… public key (non-secret)
|
||||||
|
ageIdentity: pulumi.Output<string>; // AGE-SECRET-KEY-… (secret; survives Vault loss via config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the backup secret slice for the Vault mirror (CONTRACT_002 §2.3). */
|
||||||
|
export function loadBackupSecrets(): BackupSecrets {
|
||||||
|
const c = new pulumi.Config("foundation");
|
||||||
|
return {
|
||||||
|
offsiteEndpoint: c.require("backup.offsiteEndpoint"),
|
||||||
|
offsiteAccessKey: c.requireSecret("backup.offsiteAccessKey"),
|
||||||
|
offsiteSecretKey: c.requireSecret("backup.offsiteSecretKey"),
|
||||||
|
ageRecipient: c.require("backup.ageRecipient"),
|
||||||
|
ageIdentity: c.requireSecret("backup.ageIdentity"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The SSH private key path is supplied by ENV (CONTRACT_001 §1: `SSH_PRIVATE_KEY_PATH`,
|
* The SSH private key path is supplied by ENV (CONTRACT_001 §1: `SSH_PRIVATE_KEY_PATH`,
|
||||||
* default ~/.ssh/id_rsa) — NEVER by Pulumi config. Exposed separately from
|
* default ~/.ssh/id_rsa) — NEVER by Pulumi config. Exposed separately from
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,14 @@
|
||||||
// fill the marked slots — this file is the single composition point; components stay
|
// fill the marked slots — this file is the single composition point; components stay
|
||||||
// pure factories in components/*.
|
// pure factories in components/*.
|
||||||
import * as pulumi from "@pulumi/pulumi";
|
import * as pulumi from "@pulumi/pulumi";
|
||||||
import { loadConfig } from "./config";
|
import { loadConfig, loadBackupSecrets } from "./config";
|
||||||
import { buildBaseContext, DeployCtx } from "./lib/context";
|
import { buildBaseContext, DeployCtx } from "./lib/context";
|
||||||
import { deployNetwork } from "./components/network";
|
import { deployNetwork } from "./components/network";
|
||||||
import { deployDns } from "./components/dns";
|
import { deployDns } from "./components/dns";
|
||||||
import {
|
import {
|
||||||
generateCredentials,
|
generateCredentials,
|
||||||
writeCredentialsToVault,
|
writeCredentialsToVault,
|
||||||
|
writeBackupCredentialsToVault,
|
||||||
} from "./components/credentials";
|
} from "./components/credentials";
|
||||||
import { deployPostgres } from "./components/postgres";
|
import { deployPostgres } from "./components/postgres";
|
||||||
import { deployRustfs } from "./components/rustfs";
|
import { deployRustfs } from "./components/rustfs";
|
||||||
|
|
@ -47,6 +48,8 @@ const vault = deployVault(ctx);
|
||||||
// --- GATE A: Vault init + unseal (T05). T06 writes the generated data-plane creds
|
// --- GATE A: Vault init + unseal (T05). T06 writes the generated data-plane creds
|
||||||
// into Vault (CONTRACT_002), dependsOn vault.init so it runs only once unsealed.
|
// into Vault (CONTRACT_002), dependsOn vault.init so it runs only once unsealed.
|
||||||
const vaultCreds = writeCredentialsToVault(ctx, credentials, vault);
|
const vaultCreds = writeCredentialsToVault(ctx, credentials, vault);
|
||||||
|
// Mirror the config-seeded backup creds + age key into Vault (CONTRACT_002 §2.3).
|
||||||
|
const backupCreds = writeBackupCredentialsToVault(ctx, vault, loadBackupSecrets());
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// PHASE 6 — FORGE (depends on: credentials, GATE A)
|
// PHASE 6 — FORGE (depends on: credentials, GATE A)
|
||||||
// T07 caddy ✓ · T08 forgejo · T10 runner
|
// T07 caddy ✓ · T08 forgejo · T10 runner
|
||||||
|
|
@ -77,6 +80,7 @@ const runner = cfg.features.runner ? deployRunner(ctx, forgejo) : undefined;
|
||||||
// Stack outputs (extended as phases land).
|
// Stack outputs (extended as phases land).
|
||||||
// vaultCreds (T06) is a gate for Forgejo (T08) — it has no output to export yet.
|
// vaultCreds (T06) is a gate for Forgejo (T08) — it has no output to export yet.
|
||||||
void vaultCreds;
|
void vaultCreds;
|
||||||
|
void backupCreds; // CONTRACT_002 backup/backup-credentials mirror; no secret output
|
||||||
|
|
||||||
export const phase = "T10-runner"; // forge + CI runner live
|
export const phase = "T10-runner"; // forge + CI runner live
|
||||||
export const caddyImageId = proxy.imageId;
|
export const caddyImageId = proxy.imageId;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue