feat(bootstrap): real olsitec.net config + DNS records (steps 1+2)
CONTRACT_001 amendments: hosts.git, vm.sshPort (default 22; VM uses 222), cloudflare.zoneId. config.ts + lib/context.ts (provider host uses sshPort). - components/dns.ts: forge/vault/s3/git.olsitec.net A -> VM (DNS-only, own CF provider from encrypted token). Deployed + verified authoritative = 204.168.234.72. - Pulumi.foundation.yaml: real config (olsitec.net, vm 204.168.234.72:222, letsencrypt-dns01) + encrypted secrets (cloudflare token, offsite creds). Master passphrase: pass olsitec-foundation/PULUMI_CONFIG_PASSPHRASE. - run.sh: reproducible deploy (passphrase + ssh key from pass/home). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
db47037bdc
commit
185be52763
10 changed files with 141 additions and 60 deletions
|
|
@ -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==
|
||||
|
|
|
|||
41
bootstrap/components/dns.ts
Normal file
41
bootstrap/components/dns.ts
Normal file
|
|
@ -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 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ export interface DeployCtx extends BaseCtx {
|
|||
export type ComponentFactory<T> = (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}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
9
bootstrap/run.sh
Executable file
9
bootstrap/run.sh
Executable file
|
|
@ -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 "$@" )
|
||||
Loading…
Add table
Add a link
Reference in a new issue