feat(bootstrap): rustfs S3 data-plane + buckets/service account (T04)

foundation-rustfs (rustfs/rustfs digest-pinned) on foundation-net, internal only
(9000/9001 unpublished); named volume foundation-rustfs-data with retainOnDelete.
The four buckets (forgejo-packages/-artifacts/-lfs, foundation-backups) and a
scoped service account with generated keys (CONTRACT_002 rustfs slice) are
provisioned post-boot by an idempotent, readiness-gated remote.Command using a
throwaway mc container (ADR-007). RustFS speaks enough MinIO admin API for
`svcacct add`; `mc ready` is unreliable so readiness gates on `mc ls`; the mc
image's busybox lacks grep so existence checks use a shell `case`. Pins the
IMAGE_MC tool image in VERSIONS.

Live on cx33 Helsinki: 4 buckets present, service key registered, put/get
roundtrip OK, no published ports. Acceptance T04 met.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Niemann 2026-06-30 21:19:53 +02:00
parent 6edba60612
commit 1792fd9f89
4 changed files with 147 additions and 4 deletions

View file

@ -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" };
}