diff --git a/bootstrap/Pulumi.foundation.yaml b/bootstrap/Pulumi.foundation.yaml index 016547c..a36ec8a 100644 --- a/bootstrap/Pulumi.foundation.yaml +++ b/bootstrap/Pulumi.foundation.yaml @@ -1,46 +1,34 @@ -# Pulumi.foundation.yaml — stack config for the single `foundation` stack. +# Pulumi.foundation.yaml — stack config for the `foundation` stack (initial Hetzner home). # -# THIS FILE CONTAINS NON-SECRET PLACEHOLDERS ONLY (CONTRACT_001 §1.2). -# All keys here are reproducible/derivable and safe to commit in plaintext. +# NON-SECRET values only here (CONTRACT_001 §1.2) — safe to commit in plaintext. +# Secrets (CONTRACT_001 §1.3) are added by `pulumi config set --secret` as +# `secure: v1:…` (passphrase-encrypted): foundation:cloudflareApiToken, +# foundation:backup.offsiteAccessKey/SecretKey, and later vaultCredentials:*. # -# 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). +# Master passphrase: pass olsitec-foundation/PULUMI_CONFIG_PASSPHRASE (the ONE +# external secret). Image digests live in foundation/VERSIONS, not here (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) --- + # --- identity / networking (real: olsitec.net) --- + foundation:baseDomain: olsitec.net + foundation:hosts.forge: forge.olsitec.net + foundation:hosts.vault: vault.olsitec.net + foundation:hosts.s3: s3.olsitec.net + foundation:hosts.git: git.olsitec.net + foundation:forgeSshPort: "2222" + # --- deployment target: the Helsinki cx33 VM (Docker-over-SSH, port 222) --- + foundation:vm.host: 204.168.234.72 + foundation:vm.user: root + foundation:vm.sshPort: "222" + # --- container plane (CONTRACT_003) --- 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) --- + # --- TLS: real Let's Encrypt via Cloudflare DNS-01 --- + foundation:tls.mode: letsencrypt-dns01 + foundation:tls.acmeEmail: a.niemann@olsitec.de + # --- Cloudflare (DNS records + DNS-01); token is a SECRET set separately --- + foundation:cloudflare.zoneId: 27e587d5574d5fd6e2cf75b9e914a02c + # --- fixed names (derived, non-secret; creds generated → Vault) --- foundation:postgres.db: foundation foundation:postgres.forgejoDb: forgejo foundation:rustfs.buckets: @@ -50,23 +38,25 @@ config: - 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) --- + # --- credential feature flags (ADR-002) --- + 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 = home Synology MinIO (CONTRACT_004); creds are SECRET --- foundation:backup.bucket: foundation-backups - foundation:backup.offsiteEndpoint: https://offsite.example.invalid:9000 - foundation:backup.retentionDaily: 7 - foundation:backup.retentionWeekly: 4 + foundation:backup.offsiteEndpoint: https://minio.wob.olsitec.de:19000 + foundation:backup.retentionDaily: "7" + foundation:backup.retentionWeekly: "4" + foundation:cloudflareApiToken: + secure: v1:xDFqTVZxRm2nvIrQ:ddjNyqKi4C27Fppp9YA0B+gNZPtjWig/NBC6y9dR3cQ8xfNfwEsEHxvRgn8aUTH9UrmjXtLEoYk= + foundation:backup.offsiteAccessKey: + secure: v1:svEvJ5K9u+FMnpV/:RztjS8VMSxrdgpBtbNpBPA6gfPLVgnABp77diBC6nWGHZRnG + foundation:backup.offsiteSecretKey: + secure: v1:lkkGBjgmJqVziusc:gpmw5lkfFAjXzeFikhtQnvWObYpKD3Bq5XSmrBA/vlLaoqqxFGAAO4Cq7V8nOLZ926x3fXukPQI= +encryptionsalt: v1:5YhUt8BVfH0=:v1:DPCHl+7zwn4RaMPj:A19tZzBlZ1NmDtTWrHreEKk5e8idyw== diff --git a/bootstrap/components/dns.ts b/bootstrap/components/dns.ts new file mode 100644 index 0000000..befeaa5 --- /dev/null +++ b/bootstrap/components/dns.ts @@ -0,0 +1,41 @@ +// components/dns.ts +// +// Public A records for the foundation's hostnames → the VM (CONTRACT_001 hosts). +// DNS-only (proxied:false): Git-over-SSH and Vault/RustFS need raw TCP, and ACME +// DNS-01 doesn't want the Cloudflare proxy in front. Records are scoped to the +// foundation's own names — they do not touch the zone's other (Pulumi-managed) +// records. The Cloudflare token is read from the passphrase-encrypted config +// secret (CONTRACT_001 §1.3), so no env var is needed at deploy time. +import * as pulumi from "@pulumi/pulumi"; +import * as cloudflare from "@pulumi/cloudflare"; +import { DeployCtx } from "../lib/context"; + +export function deployDns(ctx: DeployCtx): cloudflare.Record[] { + const { cfg } = ctx; + const apiToken = new pulumi.Config("foundation").requireSecret("cloudflareApiToken"); + const provider = new cloudflare.Provider("foundation-cloudflare", { apiToken }); + + const ip = cfg.vm.host; // for the test/initial deploy, vm.host is the VM's public IPv4 + const hosts: { key: string; fqdn: string }[] = [ + { key: "forge", fqdn: cfg.hosts.forge }, + { key: "vault", fqdn: cfg.hosts.vault }, + { key: "s3", fqdn: cfg.hosts.s3 }, + { key: "git", fqdn: cfg.hosts.git }, + ]; + + return hosts.map( + (h) => + new cloudflare.Record( + `dns-${h.key}`, + { + zoneId: cfg.cloudflare.zoneId, + name: h.fqdn, + type: "A", + content: ip, + proxied: false, + ttl: 300, + }, + { provider }, + ), + ); +} diff --git a/bootstrap/config.ts b/bootstrap/config.ts index 96c9613..762c19d 100644 --- a/bootstrap/config.ts +++ b/bootstrap/config.ts @@ -32,9 +32,10 @@ export interface FoundationConfig { 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) + forge: string; // "forge.olsitec.net" (Forgejo web/API/registry) + vault: string; // "vault.olsitec.net" (Vault UI/API — internal-restricted) + s3: string; // "s3.olsitec.net" (RustFS API, optional public) + git: string; // "git.olsitec.net" (Git-over-SSH host; CONTRACT_001 amendment 2026-06-30) }; forgeSshPort: number; // 2222 (git-over-ssh, published directly, not via Caddy) @@ -42,9 +43,15 @@ export interface FoundationConfig { vm: { host: string; // IP or DNS of the foundation VM user: string; // ssh user (e.g. "root" or "deploy") + sshPort: number; // SSH port for the Docker-over-SSH provider (22 default; test VM uses 222) // private key path comes from ENV SSH_PRIVATE_KEY_PATH, never config }; + // --- Cloudflare (DNS records + ACME DNS-01); token is a SECRET (§1.3) --- + cloudflare: { + zoneId: string; // olsitec.net zone id (non-secret) + }; + // --- 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") @@ -165,9 +172,12 @@ export function loadConfig(): FoundationConfig { const hostsForge = reqStr("hosts.forge"); const hostsVault = reqStr("hosts.vault"); const hostsS3 = reqStr("hosts.s3"); + const hostsGit = reqStr("hosts.git"); const forgeSshPort = reqNum("forgeSshPort"); const vmHost = reqStr("vm.host"); const vmUser = reqStr("vm.user"); + const vmSshPort = c.getNumber("vm.sshPort") ?? 22; // optional; defaults to standard SSH + const cloudflareZoneId = reqStr("cloudflare.zoneId"); const networkName = reqStr("network.name"); const networkSubnet = reqStr("network.subnet"); const dataRoot = reqStr("dataRoot"); @@ -221,9 +231,10 @@ export function loadConfig(): FoundationConfig { // All keys present + well-typed past this point (the `!` are now sound). return { baseDomain: baseDomain!, - hosts: { forge: hostsForge!, vault: hostsVault!, s3: hostsS3! }, + hosts: { forge: hostsForge!, vault: hostsVault!, s3: hostsS3!, git: hostsGit! }, forgeSshPort: forgeSshPort!, - vm: { host: vmHost!, user: vmUser! }, + vm: { host: vmHost!, user: vmUser!, sshPort: vmSshPort }, + cloudflare: { zoneId: cloudflareZoneId! }, network: { name: networkName!, subnet: networkSubnet! }, dataRoot: dataRoot!, tls: { diff --git a/bootstrap/index.ts b/bootstrap/index.ts index 64d37b7..2b4e961 100644 --- a/bootstrap/index.ts +++ b/bootstrap/index.ts @@ -8,6 +8,7 @@ import { loadConfig } from "./config"; import { buildBaseContext, DeployCtx } from "./lib/context"; import { deployNetwork } from "./components/network"; +import { deployDns } from "./components/dns"; const cfg = loadConfig(); @@ -16,6 +17,10 @@ const base = buildBaseContext(cfg); const network = deployNetwork(base); const ctx: DeployCtx = { ...base, network }; +// --- public DNS records → the VM (independent of the container plane) --- +const dnsRecords = deployDns(ctx); +export const dnsHosts = dnsRecords.map((r) => r.name); + // ============================================================================= // PHASE 3 — DATA PLANE (depends on: network) // T03 postgres · T04 rustfs · T05 vault (sealed) diff --git a/bootstrap/lib/context.ts b/bootstrap/lib/context.ts index a603151..7924801 100644 --- a/bootstrap/lib/context.ts +++ b/bootstrap/lib/context.ts @@ -34,7 +34,10 @@ export interface DeployCtx extends BaseCtx { export type ComponentFactory = (ctx: DeployCtx) => T; function providerHost(cfg: FoundationConfig): string { - return process.env.FOUNDATION_DOCKER_HOST || `ssh://${cfg.vm.user}@${cfg.vm.host}`; + return ( + process.env.FOUNDATION_DOCKER_HOST || + `ssh://${cfg.vm.user}@${cfg.vm.host}:${cfg.vm.sshPort}` + ); } /** diff --git a/bootstrap/package.json b/bootstrap/package.json index da5f8f9..020bf16 100644 --- a/bootstrap/package.json +++ b/bootstrap/package.json @@ -8,7 +8,8 @@ "@olsitec/pulumi-docker": "workspace:*", "@olsitec/pulumi-vault": "workspace:*", "@pulumi/docker": "^4.5.8", - "@pulumi/pulumi": "^3.138.0" + "@pulumi/pulumi": "^3.138.0", + "@pulumi/cloudflare": "^5.45.0" }, "devDependencies": { "@types/node": "^18", diff --git a/bootstrap/run.sh b/bootstrap/run.sh new file mode 100755 index 0000000..d90c556 --- /dev/null +++ b/bootstrap/run.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Reproducible foundation deploy. Master passphrase = the single external secret. +set -euo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +export PULUMI_CONFIG_PASSPHRASE="$(pass olsitec-foundation/PULUMI_CONFIG_PASSPHRASE)" +# Test/initial deploy uses the dedicated VM key on port 222 (config carries host+port). +export SSH_PRIVATE_KEY_PATH="${SSH_PRIVATE_KEY_PATH:-${HOME}/.ssh/foundation-test_ed25519}" +pulumi login "file://${DIR}/state" >/dev/null +( cd "$DIR" && (pulumi stack select foundation 2>/dev/null || pulumi stack init foundation) && pulumi "$@" ) diff --git a/bun.lock b/bun.lock index 966246b..71233cf 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "dependencies": { "@olsitec/pulumi-docker": "workspace:*", "@olsitec/pulumi-vault": "workspace:*", + "@pulumi/cloudflare": "^5.45.0", "@pulumi/docker": "^4.5.8", "@pulumi/pulumi": "^3.138.0", }, @@ -238,6 +239,8 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + "@pulumi/cloudflare": ["@pulumi/cloudflare@5.49.1", "", { "dependencies": { "@pulumi/pulumi": "^3.142.0" } }, "sha512-sc4j3XgKId9g9hIB5ZS4QXCLStZzYwzIAgbeAfW4+O78Nd3/tkNsuEWmUnPTpsw5Ezpc5zIwZxBCwhPX5qg+sA=="], + "@pulumi/docker": ["@pulumi/docker@4.11.2", "", { "dependencies": { "@pulumi/pulumi": "^3.142.0", "semver": "^5.4.0" } }, "sha512-mm8Uscb/3S7OieYyg1E/vvFx3OS4bAkZvtFvi1yTqYda9NbnYOMbJi7a5fU5xB0N0Kd/uliS8olJ/e6nnvVVPg=="], "@pulumi/eslint-plugin": ["@pulumi/eslint-plugin@0.2.0", "", { "dependencies": { "@typescript-eslint/type-utils": "^5.33.1", "@typescript-eslint/typescript-estree": "^5.33.1", "@typescript-eslint/utils": "^5.33.1", "tsutils": "^3.21.0", "typescript": "^4.7.4" } }, "sha512-tb2Wo1pO8kmNIt+ECkVd7ykRHgadFJfddjLG8Of002X+qbRkNZNttdt55o7EdCDHGB6Dn1RFo/MJYNuHjYn/Dg=="], diff --git a/documentation/command-log/2026-06-30_foundation-bootstrap.log b/documentation/command-log/2026-06-30_foundation-bootstrap.log index 3664b7c..2dd28e5 100644 --- a/documentation/command-log/2026-06-30_foundation-bootstrap.log +++ b/documentation/command-log/2026-06-30_foundation-bootstrap.log @@ -32,3 +32,6 @@ NOTE: docker-over-SSH provider path needs SSH_PRIVATE_KEY_PATH=~/.ssh/foundation --- 2026-06-30T18:32:52Z --- HOST: mac->minio.wob.olsitec.de:19000 CMD: pulumi up (olsitec-foundation bucket + scoped SA) EXIT: RUNNING NOTE: offsite backup target setup --- 2026-06-30T18:32:54Z UPDATE --- EXIT: 0 — bucket+scoped SA created on home MinIO --- 2026-06-30T18:34:55Z UPDATE --- EXIT: 0 — olsitec-foundation bucket + scoped SA verified (put/list/delete OK, cross-bucket DENIED). +--- 2026-06-30T18:45:46Z --- HOST: mac->VM(204.168.234.72:222)+cloudflare CMD: ./run.sh up (foundation-net + 4 DNS A records) EXIT: RUNNING NOTE: steps 1+2 — real config deploy: network on Helsinki VM + forge/vault/s3/git.olsitec.net DNS +--- 2026-06-30T18:45:52Z UPDATE --- EXIT: 0 (see above) +--- 2026-06-30T18:47:30Z UPDATE --- EXIT: 0 — DNS authoritative=204.168.234.72 for forge/vault/s3/git; foundation-net live on VM. diff --git a/documentation/contracts/CONTRACT_001_config_schema.md b/documentation/contracts/CONTRACT_001_config_schema.md index 3c50c17..0250e63 100644 --- a/documentation/contracts/CONTRACT_001_config_schema.md +++ b/documentation/contracts/CONTRACT_001_config_schema.md @@ -101,3 +101,18 @@ Namespace **`vaultCredentials:`** and **`foundation:`** as appropriate: Adding a service = add its `features.` flag + its fixed names here, then its Vault keys in CONTRACT_002 and its container in CONTRACT_003. Breaking key renames require a minor version note in this contract and a coordinated update across consumers. + +--- + +## Amendment 2026-06-30 (steps 1+2) + +Added to the typed surface (FoundationConfig): +- **`hosts.git`** — `git.olsitec.net`, dedicated Git-over-SSH host (forge+vault+s3+git set). +- **`vm.sshPort`** — optional number, **default 22**; the test/initial Helsinki VM uses **222** + (the vendored hetzner cloud-init moves sshd to 222). `lib/context.ts` builds the Docker-over-SSH + provider host as `ssh://@:`. +- **`cloudflare.zoneId`** — non-secret zone id for DNS records + ACME DNS-01. The matching API token + is the secret `foundation:cloudflareApiToken` (§1.3). + +The `foundation` stack is now the **initial Hetzner home** (olsitec.net, vm `204.168.234.72:222`). +Master passphrase: `pass olsitec-foundation/PULUMI_CONFIG_PASSPHRASE` (the single external secret).