feat(bootstrap): Bun-workspace skeleton + typed config + vendored modules — T02
- Bun workspaces (packages/* + bootstrap); Pulumi nodejs runtime under
packagemanager: bun (no npm fallback needed).
- bootstrap/config.ts: typed FoundationConfig per CONTRACT_001; loadConfig()
fails closed, aggregating all missing+malformed keys in one error. Reads flat
dotted keys; image digests excluded (they live in VERSIONS, D5).
- bootstrap/Pulumi.foundation.yaml: non-secret placeholders only (RFC-5737 vm.host,
.invalid offsite); no encryptionsalt/secrets committed (D2). pulumi preview = 0
resources under the passphrase provider via gitignored file:// state backend.
- Stage-1 vendoring: packages/pulumi-{docker,vault} as @olsitec/* (source-only,
logic unchanged). vault's 5 type-only imports from modules/olsitec re-homed
verbatim into pulumi-vault/olsitec-types.ts to keep the egg self-contained.
Realizes PLAN-002 §10 T02; ADR-005 / 000_TOPOLOGY.md §5 Stage-1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
edc708b826
commit
57c4eadea7
26 changed files with 2758 additions and 0 deletions
72
bootstrap/Pulumi.foundation.yaml
Normal file
72
bootstrap/Pulumi.foundation.yaml
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Pulumi.foundation.yaml — stack config for the single `foundation` stack.
|
||||
#
|
||||
# THIS FILE CONTAINS NON-SECRET PLACEHOLDERS ONLY (CONTRACT_001 §1.2).
|
||||
# All keys here are reproducible/derivable and safe to commit in plaintext.
|
||||
#
|
||||
# NO secrets are committed yet (CONTRACT_001 §1.3). The secret keys
|
||||
# vaultCredentials:rootToken, vaultCredentials:unsealKeys,
|
||||
# foundation:cloudflareApiToken, foundation:backup.offsiteAccessKey/SecretKey
|
||||
# are added by LATER tasks as `secure: v1:…` passphrase-encrypted values
|
||||
# (T05 Vault init capture, etc.). There is intentionally NO `encryptionsalt`
|
||||
# line yet because no secret has been encrypted into this stack — committing an
|
||||
# encryptionsalt (or any secret material) is forbidden by baseline D2.
|
||||
#
|
||||
# OPERATOR NOTE: every `pulumi preview/up` under the passphrase provider rewrites
|
||||
# this file — it appends an `encryptionsalt:` line and quotes scalars ("2222",
|
||||
# "true"). Before committing, STRIP any `encryptionsalt:` line that Pulumi added
|
||||
# (no secret depends on it yet; D2 forbids committing it). The loader (config.ts)
|
||||
# reads scalars via getNumber/getBoolean, so quoted or unquoted both parse.
|
||||
#
|
||||
# Image digests are NOT here — they live in foundation/VERSIONS (determinism, D5).
|
||||
config:
|
||||
# --- identity / networking (CONTRACT_001) ---
|
||||
foundation:baseDomain: olsitec.de
|
||||
foundation:hosts.forge: forge.olsitec.de
|
||||
foundation:hosts.vault: vault.olsitec.de
|
||||
foundation:hosts.s3: s3.olsitec.de
|
||||
foundation:forgeSshPort: 2222
|
||||
|
||||
# --- deployment target (Docker-over-SSH provider; key path comes from ENV) ---
|
||||
# Placeholder VM coordinates — replaced with the real foundation VM in Phase 0 / T03+.
|
||||
foundation:vm.host: 192.0.2.10
|
||||
foundation:vm.user: deploy
|
||||
|
||||
# --- container plane (CONTRACT_003 §3.1) ---
|
||||
foundation:network.name: foundation-net
|
||||
foundation:network.subnet: 172.30.0.0/24
|
||||
foundation:dataRoot: /srv/foundation
|
||||
|
||||
# --- TLS strategy (day-zero starts internal-ca, switch to LE later — CONTRACT_001) ---
|
||||
foundation:tls.mode: internal-ca
|
||||
foundation:tls.acmeEmail: platform@olsitec.de
|
||||
|
||||
# --- fixed names (derived, non-secret; creds are generated → Vault) ---
|
||||
foundation:postgres.db: foundation
|
||||
foundation:postgres.forgejoDb: forgejo
|
||||
foundation:rustfs.buckets:
|
||||
- forgejo-packages
|
||||
- forgejo-artifacts
|
||||
- forgejo-lfs
|
||||
- foundation-backups
|
||||
foundation:forgejo.adminUser: platform-admin
|
||||
foundation:forgejo.orgName: olsitec
|
||||
# PLAN-001 §4a runner labels (docker + dind backends).
|
||||
foundation:runner.labels:
|
||||
- docker:docker://node:20-bookworm
|
||||
- dind:docker://-
|
||||
|
||||
# --- credential feature flags (ADR-002; selects what @pulumi/random generates) ---
|
||||
# NOTE: index.ts is a T02 no-op scaffold — these flags create NOTHING yet.
|
||||
# They are committed so the typed surface is complete and later tasks read them.
|
||||
foundation:features.postgres: true
|
||||
foundation:features.rustfs: true
|
||||
foundation:features.forgejo: true
|
||||
foundation:features.runner: true
|
||||
foundation:features.backup: true
|
||||
foundation:features.registry: true
|
||||
|
||||
# --- backup (offsite creds are SECRET → seeded later, not here) ---
|
||||
foundation:backup.bucket: foundation-backups
|
||||
foundation:backup.offsiteEndpoint: https://offsite.example.invalid:9000
|
||||
foundation:backup.retentionDaily: 7
|
||||
foundation:backup.retentionWeekly: 4
|
||||
18
bootstrap/Pulumi.yaml
Normal file
18
bootstrap/Pulumi.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
name: foundation
|
||||
description: >-
|
||||
The olsitec-foundation bootstrap "egg" — one Pulumi project that deploys
|
||||
Forgejo + PostgreSQL + Vault + RustFS + Caddy as Docker containers on a single
|
||||
VM via @pulumi/docker over SSH (PLAN-002 §0, Layer 0). No K8s / ArgoCD / Helm.
|
||||
runtime:
|
||||
name: nodejs
|
||||
options:
|
||||
# Bun is the Olsitec-preferred package manager (footgun 16.3). Pulumi's
|
||||
# nodejs runtime supports it via `packagemanager: bun`. Bun resolves the
|
||||
# workspace `@olsitec/pulumi-*` packages from ../packages/* locally — no
|
||||
# registry needed at day-zero (the registry is part of what this builds).
|
||||
packagemanager: bun
|
||||
config:
|
||||
pulumi:tags:
|
||||
value:
|
||||
pulumi:template: typescript
|
||||
252
bootstrap/config.ts
Normal file
252
bootstrap/config.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// config.ts
|
||||
//
|
||||
// Producer of the typed foundation config (CONTRACT_001 §1.1). Every component
|
||||
// in bootstrap/components/* consumes THIS — components MUST NOT read raw
|
||||
// pulumi.Config ad hoc (CONTRACT_001 "Ownership").
|
||||
//
|
||||
// Three (and only three) configuration channels exist (CONTRACT_001 §Interface):
|
||||
// 1. ENV PULUMI_CONFIG_PASSPHRASE (master passphrase — the only external secret)
|
||||
// SSH_PRIVATE_KEY_PATH (key reaching the VM; default ~/.ssh/id_rsa)
|
||||
// 2. VERSIONS foundation/VERSIONS (image DIGESTS + tool versions — NOT in Pulumi config)
|
||||
// 3. Pulumi config Pulumi.foundation.yaml (typed non-secret + passphrase-encrypted secrets)
|
||||
//
|
||||
// IMPORTANT: image digests live in VERSIONS (determinism, baseline D5 / PLAN-002 §7.1),
|
||||
// NOT in this config. `loadConfig()` deliberately does not read or validate any image
|
||||
// reference — that is the preflight/VERSIONS concern, resolved at container-build time
|
||||
// in later component tasks (T03+).
|
||||
//
|
||||
// CONFIG KEY SHAPE: the non-secret keys are stored FLAT with dotted names
|
||||
// (CONTRACT_001 §1.2: `foundation:hosts.forge`, `foundation:features.forgejo`, …).
|
||||
// Pulumi does NOT auto-assemble `hosts.forge` + `hosts.vault` into a `hosts` object,
|
||||
// so each leaf is read by its full dotted key via the typed accessor for its kind
|
||||
// (`get` for strings, `getNumber`, `getBoolean`, `getObject` for arrays). This also
|
||||
// makes the loader robust to Pulumi storing scalars as quoted strings.
|
||||
import * as pulumi from "@pulumi/pulumi";
|
||||
|
||||
/**
|
||||
* The single typed configuration surface for the `foundation` stack (CONTRACT_001 §1.1).
|
||||
* Realises PLAN-002 §3, §4.2. See CONTRACT_003 for the fixed container names/ports/network.
|
||||
*/
|
||||
export interface FoundationConfig {
|
||||
// --- identity / networking ---
|
||||
baseDomain: string; // "olsitec.de"
|
||||
hosts: {
|
||||
// public FQDNs terminated by Caddy
|
||||
forge: string; // "forge.olsitec.de" (Forgejo web/API/registry)
|
||||
vault: string; // "vault.olsitec.de" (Vault UI/API — internal-restricted)
|
||||
s3: string; // "s3.olsitec.de" (RustFS API, optional public)
|
||||
};
|
||||
forgeSshPort: number; // 2222 (git-over-ssh, published directly, not via Caddy)
|
||||
|
||||
// --- deployment target (Docker-over-SSH provider) ---
|
||||
vm: {
|
||||
host: string; // IP or DNS of the foundation VM
|
||||
user: string; // ssh user (e.g. "root" or "deploy")
|
||||
// private key path comes from ENV SSH_PRIVATE_KEY_PATH, never config
|
||||
};
|
||||
|
||||
// --- container plane (see CONTRACT_003 for names/ports) ---
|
||||
network: { name: string; subnet: string }; // "foundation-net", "172.30.0.0/24"
|
||||
dataRoot: string; // host path for bind mounts / named-volume root (e.g. "/srv/foundation")
|
||||
|
||||
// --- TLS strategy ---
|
||||
tls: {
|
||||
mode: "letsencrypt-dns01" | "internal-ca"; // day-zero may start internal-ca, switch later
|
||||
acmeEmail: string;
|
||||
// cloudflareApiToken is a SECRET (CONTRACT_001 §1.3) — not in this typed surface
|
||||
};
|
||||
|
||||
// --- service sizing / fixed names (derived, non-secret) ---
|
||||
postgres: { db: string; forgejoDb: string }; // names only; creds are generated → Vault
|
||||
rustfs: { buckets: string[] }; // ["forgejo-packages","forgejo-artifacts","forgejo-lfs","foundation-backups"]
|
||||
forgejo: { adminUser: string; orgName: string }; // "platform-admin", "olsitec"
|
||||
runner: { labels: string[] }; // ["docker:docker://…","dind:docker://-"] (PLAN-001 §4a)
|
||||
|
||||
// --- credential feature flags (ADR-002 style; selects what @pulumi/random generates) ---
|
||||
features: {
|
||||
postgres: boolean;
|
||||
rustfs: boolean;
|
||||
forgejo: boolean;
|
||||
runner: boolean;
|
||||
backup: boolean;
|
||||
registry: boolean;
|
||||
};
|
||||
|
||||
// --- backup ---
|
||||
backup: {
|
||||
bucket: string; // "foundation-backups" (in RustFS)
|
||||
offsiteEndpoint: string; // self-hosted second location (CONTRACT_004); creds are SECRET
|
||||
retentionDaily: number;
|
||||
retentionWeekly: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* FoundationConfig because it is an operator/host concern, not stack state. Mirrors the
|
||||
* proven olsitec-core/config.ts pattern.
|
||||
*/
|
||||
export function sshPrivateKeyPath(): string {
|
||||
const home = process.env.HOME ?? "";
|
||||
return process.env.SSH_PRIVATE_KEY_PATH || `${home}/.ssh/id_rsa`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads + validates the Pulumi config and returns the typed FoundationConfig.
|
||||
*
|
||||
* Fails CLOSED (CONTRACT_001 "Ownership", "Validation"): every required key is read
|
||||
* defensively; any that is missing OR malformed is collected, and a single error
|
||||
* listing ALL of them is thrown — so `pulumi preview` on an under-populated stack
|
||||
* reports the whole gap at once (acceptance T02), not one key at a time.
|
||||
*
|
||||
* Secrets (CONTRACT_001 §1.3: vaultCredentials:*, foundation:cloudflareApiToken,
|
||||
* foundation:backup.offsite*Key) are NOT required here — they are seeded by later
|
||||
* tasks. This loader validates only the non-secret typed surface (§1.2).
|
||||
*/
|
||||
export function loadConfig(): FoundationConfig {
|
||||
const c = new pulumi.Config("foundation");
|
||||
|
||||
const missing: string[] = [];
|
||||
const malformed: string[] = [];
|
||||
|
||||
// --- typed leaf readers: record a missing/malformed key, return undefined on failure ---
|
||||
const reqStr = (key: string): string | undefined => {
|
||||
const v = c.get(key);
|
||||
if (v === undefined || v === "") {
|
||||
missing.push(`foundation:${key}`);
|
||||
return undefined;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
const reqNum = (key: string): number | undefined => {
|
||||
if (c.get(key) === undefined) {
|
||||
missing.push(`foundation:${key}`);
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return c.requireNumber(key);
|
||||
} catch {
|
||||
malformed.push(`foundation:${key} must be a number`);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const reqBool = (key: string): boolean | undefined => {
|
||||
if (c.get(key) === undefined) {
|
||||
missing.push(`foundation:${key}`);
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return c.requireBoolean(key);
|
||||
} catch {
|
||||
malformed.push(`foundation:${key} must be a boolean`);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const reqStrArr = (key: string): string[] | undefined => {
|
||||
if (c.get(key) === undefined) {
|
||||
missing.push(`foundation:${key}`);
|
||||
return undefined;
|
||||
}
|
||||
const v = c.getObject<unknown>(key);
|
||||
if (
|
||||
!Array.isArray(v) ||
|
||||
v.length === 0 ||
|
||||
!v.every((x) => typeof x === "string")
|
||||
) {
|
||||
malformed.push(`foundation:${key} must be a non-empty string[]`);
|
||||
return undefined;
|
||||
}
|
||||
return v as string[];
|
||||
};
|
||||
|
||||
// --- read every required non-secret key (CONTRACT_001 §1.1 / §1.2) ---
|
||||
const baseDomain = reqStr("baseDomain");
|
||||
const hostsForge = reqStr("hosts.forge");
|
||||
const hostsVault = reqStr("hosts.vault");
|
||||
const hostsS3 = reqStr("hosts.s3");
|
||||
const forgeSshPort = reqNum("forgeSshPort");
|
||||
const vmHost = reqStr("vm.host");
|
||||
const vmUser = reqStr("vm.user");
|
||||
const networkName = reqStr("network.name");
|
||||
const networkSubnet = reqStr("network.subnet");
|
||||
const dataRoot = reqStr("dataRoot");
|
||||
const tlsModeRaw = reqStr("tls.mode");
|
||||
const tlsAcmeEmail = reqStr("tls.acmeEmail");
|
||||
const postgresDb = reqStr("postgres.db");
|
||||
const postgresForgejoDb = reqStr("postgres.forgejoDb");
|
||||
const rustfsBuckets = reqStrArr("rustfs.buckets");
|
||||
const forgejoAdminUser = reqStr("forgejo.adminUser");
|
||||
const forgejoOrgName = reqStr("forgejo.orgName");
|
||||
const runnerLabels = reqStrArr("runner.labels");
|
||||
const fPostgres = reqBool("features.postgres");
|
||||
const fRustfs = reqBool("features.rustfs");
|
||||
const fForgejo = reqBool("features.forgejo");
|
||||
const fRunner = reqBool("features.runner");
|
||||
const fBackup = reqBool("features.backup");
|
||||
const fRegistry = reqBool("features.registry");
|
||||
const backupBucket = reqStr("backup.bucket");
|
||||
const backupOffsiteEndpoint = reqStr("backup.offsiteEndpoint");
|
||||
const backupRetentionDaily = reqNum("backup.retentionDaily");
|
||||
const backupRetentionWeekly = reqNum("backup.retentionWeekly");
|
||||
|
||||
// tls.mode is an enum — validate only if it was present.
|
||||
if (
|
||||
tlsModeRaw !== undefined &&
|
||||
tlsModeRaw !== "letsencrypt-dns01" &&
|
||||
tlsModeRaw !== "internal-ca"
|
||||
) {
|
||||
malformed.push(
|
||||
`foundation:tls.mode must be "letsencrypt-dns01" | "internal-ca" (got ${JSON.stringify(tlsModeRaw)})`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- fail closed: report ALL missing, then ALL malformed, in one error ---
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
"Foundation config validation FAILED (CONTRACT_001). " +
|
||||
`${missing.length} required key(s) missing from the 'foundation' stack:\n` +
|
||||
missing.map((k) => ` - ${k}`).join("\n") +
|
||||
"\nSet them in Pulumi.foundation.yaml (non-secret) and re-run. " +
|
||||
"Image digests are NOT config — they live in foundation/VERSIONS.",
|
||||
);
|
||||
}
|
||||
if (malformed.length > 0) {
|
||||
throw new Error(
|
||||
"Foundation config validation FAILED (CONTRACT_001) — malformed value(s):\n" +
|
||||
malformed.map((m) => ` - ${m}`).join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
// All keys present + well-typed past this point (the `!` are now sound).
|
||||
return {
|
||||
baseDomain: baseDomain!,
|
||||
hosts: { forge: hostsForge!, vault: hostsVault!, s3: hostsS3! },
|
||||
forgeSshPort: forgeSshPort!,
|
||||
vm: { host: vmHost!, user: vmUser! },
|
||||
network: { name: networkName!, subnet: networkSubnet! },
|
||||
dataRoot: dataRoot!,
|
||||
tls: {
|
||||
mode: tlsModeRaw as FoundationConfig["tls"]["mode"],
|
||||
acmeEmail: tlsAcmeEmail!,
|
||||
},
|
||||
postgres: { db: postgresDb!, forgejoDb: postgresForgejoDb! },
|
||||
rustfs: { buckets: rustfsBuckets! },
|
||||
forgejo: { adminUser: forgejoAdminUser!, orgName: forgejoOrgName! },
|
||||
runner: { labels: runnerLabels! },
|
||||
features: {
|
||||
postgres: fPostgres!,
|
||||
rustfs: fRustfs!,
|
||||
forgejo: fForgejo!,
|
||||
runner: fRunner!,
|
||||
backup: fBackup!,
|
||||
registry: fRegistry!,
|
||||
},
|
||||
backup: {
|
||||
bucket: backupBucket!,
|
||||
offsiteEndpoint: backupOffsiteEndpoint!,
|
||||
retentionDaily: backupRetentionDaily!,
|
||||
retentionWeekly: backupRetentionWeekly!,
|
||||
},
|
||||
};
|
||||
}
|
||||
37
bootstrap/index.ts
Normal file
37
bootstrap/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// index.ts — the foundation egg entrypoint (PLAN-002 §0, Layer 0).
|
||||
//
|
||||
// T02 SCAFFOLD STATE: this entrypoint is intentionally a NO-OP beyond config
|
||||
// validation. It calls loadConfig() so that `pulumi preview` exercises the
|
||||
// CONTRACT_001 fail-closed validation (acceptance T02), but it creates NO real
|
||||
// resources yet. The data plane / Vault / RustFS / Postgres / Forgejo / Caddy /
|
||||
// runner components are LATER tasks (PLAN-002 §10: T03–T15) and are deliberately
|
||||
// NOT authored here.
|
||||
import * as pulumi from "@pulumi/pulumi";
|
||||
|
||||
import { loadConfig, sshPrivateKeyPath } from "./config";
|
||||
|
||||
// Fail closed here: if required config is missing/malformed, loadConfig throws
|
||||
// and `pulumi preview` reports the full gap (CONTRACT_001 §Validation).
|
||||
const config = loadConfig();
|
||||
|
||||
// The vendored @olsitec/pulumi-docker provider (CONTRACT_003) will, in T03+, use
|
||||
// this key path + config.vm.{host,user} to reach the foundation VM over SSH.
|
||||
// Resolved here only to prove the ENV channel is wired; not yet consumed.
|
||||
const sshKeyPath = sshPrivateKeyPath();
|
||||
|
||||
pulumi.log.info(
|
||||
`foundation config loaded (no-op scaffold): ` +
|
||||
`baseDomain=${config.baseDomain}, vm=${config.vm.user}@${config.vm.host}, ` +
|
||||
`network=${config.network.name} (${config.network.subnet}), tls=${config.tls.mode}`,
|
||||
);
|
||||
|
||||
// Stack outputs — safe, non-secret echoes so `pulumi preview`/`up` has something
|
||||
// to show while no resources exist. Replaced by real component outputs in T03+.
|
||||
export const phase = "T02-scaffold";
|
||||
export const baseDomain = config.baseDomain;
|
||||
export const networkName = config.network.name;
|
||||
export const vmTarget = pulumi.interpolate`${config.vm.user}@${config.vm.host}`;
|
||||
export const sshKeyConfigured = sshKeyPath.length > 0;
|
||||
export const enabledFeatures = Object.entries(config.features)
|
||||
.filter(([, on]) => on)
|
||||
.map(([name]) => name);
|
||||
16
bootstrap/package.json
Normal file
16
bootstrap/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "@olsitec/foundation-bootstrap",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"main": "index.ts",
|
||||
"description": "The foundation egg — single Pulumi project (PLAN-002 §0, Layer 0).",
|
||||
"dependencies": {
|
||||
"@olsitec/pulumi-docker": "workspace:*",
|
||||
"@olsitec/pulumi-vault": "workspace:*",
|
||||
"@pulumi/pulumi": "^3.138.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
19
bootstrap/tsconfig.json
Normal file
19
bootstrap/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"outDir": "bin",
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"pretty": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitReturns": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"files": [
|
||||
"config.ts",
|
||||
"index.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue