diff --git a/VERSIONS b/VERSIONS index d864569..33bf460 100644 --- a/VERSIONS +++ b/VERSIONS @@ -60,10 +60,15 @@ IMAGE_CADDY=caddy:2.10@sha256:PIN_DIGEST IMAGE_FORGEJO=codeberg.org/forgejo/forgejo:11@sha256:PIN_DIGEST IMAGE_POSTGRES=postgres:17@sha256:5c855ad7b85e68e48a62f34662853f38b57c1c1d80f3a927ab58034fd6d31c5e IMAGE_VAULT=hashicorp/vault:1.18@sha256:PIN_DIGEST -IMAGE_RUSTFS=rustfs/rustfs:latest@sha256:PIN_DIGEST +IMAGE_RUSTFS=rustfs/rustfs:latest@sha256:fa19210ac4697c79d7ccca1ec9b0eb91aebacc6691991ffb14014bb3c67e6cc3 IMAGE_ACT_RUNNER=code.forgejo.org/forgejo/runner:6@sha256:PIN_DIGEST IMAGE_REGISTRY=registry:2@sha256:PIN_DIGEST +# Tool image: MinIO client `mc` — used transiently (never a long-running service) +# for S3 control-plane ops against RustFS: bucket creation + service accounts +# (T04) and backup put/get (T12). RustFS speaks enough of the MinIO admin API. +IMAGE_MC=minio/mc:latest@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727 + # NOTE on specific images: # IMAGE_RUSTFS uses `latest` because RustFS does not (yet) publish stable # semver tags reliably (PLAN-002 R3 — RustFS is young). MUST be pinned by diff --git a/bootstrap/components/credentials.ts b/bootstrap/components/credentials.ts index 52e5131..028fd73 100644 --- a/bootstrap/components/credentials.ts +++ b/bootstrap/components/credentials.ts @@ -24,9 +24,18 @@ export interface PostgresCredentials { forgejoDbPassword: pulumi.Output; } +/** `foundation/rustfs/service-credentials` (CONTRACT_002 §2.3). */ +export interface RustfsCredentials { + adminUser: string; // "rustfsadmin" — root access key name (deterministic) + adminPassword: pulumi.Output; // root secret key (RUSTFS_SECRET_KEY) + serviceKeyId: pulumi.Output; // scoped S3 key Forgejo/backup use + serviceKeySecret: pulumi.Output; +} + /** Everything generateCredentials() produces; grows as Wave-2 tasks land. */ export interface FoundationCredentials { postgres: PostgresCredentials; + rustfs: RustfsCredentials; } /** @@ -46,5 +55,11 @@ export function generateCredentials(ctx: DeployCtx): FoundationCredentials { forgejoDbUser: "forgejo", forgejoDbPassword: secret("forgejo-db-password"), }, + 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 + }, }; } diff --git a/bootstrap/components/rustfs.ts b/bootstrap/components/rustfs.ts new file mode 100644 index 0000000..91ee6bb --- /dev/null +++ b/bootstrap/components/rustfs.ts @@ -0,0 +1,121 @@ +// components/rustfs.ts (T04) +// +// foundation-rustfs — the Layer-0 S3 (CONTRACT_003 §3.2; ADR-004 RustFS primary). +// Holds Forgejo blobs (LFS, packages, Actions artifacts) + backup bundles. +// Internal-only (9000 S3 / 9001 console NOT published; Caddy may expose S3 at +// s3.olsitec.net later). Named volume foundation-rustfs-data → /data +// (CONTRACT_003 §3.4; bucket-level backup, CONTRACT_004). +// +// RustFS root creds = RUSTFS_ACCESS_KEY/RUSTFS_SECRET_KEY (generateCredentials). +// Buckets + a scoped service key (for Forgejo/backup) are provisioned post-boot +// by an idempotent, readiness-gated remote.Command using a throwaway `mc` +// container on foundation-net (ADR-007) — RustFS speaks the MinIO S3 + admin API +// (verified: mb/cp/svcacct work; `mc ready` does not, so readiness gates on `mc ls`). +import * as pulumi from "@pulumi/pulumi"; +import * as docker from "@pulumi/docker"; +import * as command from "@pulumi/command"; +import { DeployCtx } from "../lib/context"; +import { vmConnection } from "../lib/remote"; +import { RustfsCredentials } from "./credentials"; + +export interface RustfsOutputs { + container: docker.Container; + /** Buckets + service account exist once this resolves — gates Forgejo (T08). */ + ready: command.remote.Command; + /** Internal S3 endpoint for app.ini / consumers (CONTRACT_003 §3.3). */ + endpoint: string; +} + +// Idempotent, readiness-gated bucket + service-account setup (ADR-007). The four +// values (root access key + secret, service key id + secret) arrive on stdin; the +// non-secret MinIO-client image ref ($MC_IMAGE) is prepended by the caller. mc +// reads MC_HOST_rfs for auth; creds reach the throwaway mc container via -e only. +const BUCKET_SETUP = `set -eu +IFS= read -r ROOT_AK +IFS= read -r ROOT_SK +IFS= read -r SVC_AK +IFS= read -r SVC_SK +MCHOST="http://$ROOT_AK:$ROOT_SK@foundation-rustfs:9000" + +ready= +for _ in $(seq 1 40); do + if docker run --rm --network foundation-net --entrypoint sh -e MC_HOST_rfs="$MCHOST" "$MC_IMAGE" -c 'mc ls rfs/ >/dev/null 2>&1'; then + ready=1; break + fi + sleep 2 +done +[ "$ready" = 1 ] || { echo "foundation-rustfs not ready after 80s" >&2; exit 1; } + +docker run --rm --network foundation-net --entrypoint sh \ + -e MC_HOST_rfs="$MCHOST" -e ROOT_AK="$ROOT_AK" -e SVC_AK="$SVC_AK" -e SVC_SK="$SVC_SK" \ + "$MC_IMAGE" -c ' + set -e + for b in forgejo-packages forgejo-artifacts forgejo-lfs foundation-backups; do + mc mb --ignore-existing "rfs/$b" + done + EXISTING=$(mc admin user svcacct ls rfs "$ROOT_AK" 2>/dev/null || true) + case "$EXISTING" in + *"$SVC_AK"*) echo "svcacct exists" ;; + *) mc admin user svcacct add --access-key "$SVC_AK" --secret-key "$SVC_SK" rfs "$ROOT_AK" >/dev/null; echo "svcacct created" ;; + esac + echo "buckets:"; mc ls rfs/ + ' +echo "foundation-rustfs: buckets + service account ready"`; + +export function deployRustfs( + ctx: DeployCtx, + creds: RustfsCredentials, +): RustfsOutputs { + const { provider, network } = ctx; + + const image = new docker.RemoteImage( + "foundation-rustfs-image", + { name: ctx.image("RUSTFS"), keepLocally: true }, + { provider }, + ); + + const volume = new docker.Volume( + "foundation-rustfs-data", + { name: "foundation-rustfs-data" }, + { provider, retainOnDelete: true }, // blobs — never silently drop + ); + + const container = new docker.Container( + "foundation-rustfs", + { + name: "foundation-rustfs", + image: image.imageId, + hostname: "foundation-rustfs", + restart: "unless-stopped", + envs: [ + pulumi.interpolate`RUSTFS_ACCESS_KEY=${creds.adminUser}`, + pulumi.interpolate`RUSTFS_SECRET_KEY=${creds.adminPassword}`, + "RUSTFS_VOLUMES=/data", + ], + volumes: [{ volumeName: volume.name, containerPath: "/data" }], + networksAdvanced: [{ name: network.name, aliases: ["foundation-rustfs"] }], + logDriver: "json-file", + logOpts: { "max-size": "10m", "max-file": "3" }, + }, + { provider, dependsOn: [network], deleteBeforeReplace: true }, + ); + + const create = pulumi.interpolate`MC_IMAGE='${ctx.image("MC")}' +${BUCKET_SETUP}`; + + const ready = new command.remote.Command( + "foundation-rustfs-buckets", + { + connection: vmConnection(ctx), + create, + // stdin order MUST match the `read`s in BUCKET_SETUP; trailing newline so + // the final read sees EOL not EOF under `set -e`. + stdin: pulumi.interpolate`${creds.adminUser}\n${creds.adminPassword}\n${creds.serviceKeyId}\n${creds.serviceKeySecret}\n`, + addPreviousOutputInEnv: false, + triggers: [container.id, creds.serviceKeyId, creds.serviceKeySecret], + }, + { dependsOn: [container] }, + ); + + return { container, ready, endpoint: "http://foundation-rustfs:9000" }; +} diff --git a/bootstrap/index.ts b/bootstrap/index.ts index 8460f22..de52708 100644 --- a/bootstrap/index.ts +++ b/bootstrap/index.ts @@ -11,6 +11,7 @@ import { deployNetwork } from "./components/network"; import { deployDns } from "./components/dns"; import { generateCredentials } from "./components/credentials"; import { deployPostgres } from "./components/postgres"; +import { deployRustfs } from "./components/rustfs"; const cfg = loadConfig(); @@ -28,10 +29,10 @@ const credentials = generateCredentials(ctx); // ============================================================================= // PHASE 3 — DATA PLANE (depends on: network) -// T03 postgres ✓ · T04 rustfs · T05 vault (sealed) +// T03 postgres ✓ · T04 rustfs ✓ · T05 vault (sealed) // ----------------------------------------------------------------------------- const postgres = deployPostgres(ctx, credentials.postgres); -// const rustfs = deployRustfs(ctx, credentials.rustfs); +const rustfs = deployRustfs(ctx, credentials.rustfs); // const vault = deployVault(ctx); // // --- GATE A: Vault init + unseal (T05) → writes unseal keys to encrypted config; @@ -49,10 +50,11 @@ const postgres = deployPostgres(ctx, credentials.postgres); // ============================================================================= // Stack outputs (extended as phases land). -export const phase = "T03-postgres"; // network + DNS + data-plane: postgres +export const phase = "T04-rustfs"; // network + DNS + data-plane: postgres, rustfs export const networkName = network.name; export const vmTarget = `${cfg.vm.user}@${cfg.vm.host}`; export const postgresEndpoint = postgres.endpoint; +export const rustfsEndpoint = rustfs.endpoint; export const enabledFeatures = Object.entries(cfg.features) .filter(([, on]) => on) .map(([name]) => name);