Move secrets + repo seeds to runtime bootstrap

This commit is contained in:
Josh Palmer 2026-01-10 17:03:52 +01:00
parent 258e8d81bb
commit a7106d3072
14 changed files with 261 additions and 73 deletions

View File

@ -44,7 +44,8 @@ jobs:
nixpkgs#nixos-generators \
nixpkgs#awscli2 \
nixpkgs#age \
nixpkgs#jq
nixpkgs#jq \
nixpkgs#zstd
- name: Write agenix image key
env:
@ -90,6 +91,16 @@ jobs:
run: |
scripts/prepare-repo-seeds.sh repo-seeds
- name: Upload bootstrap bundle
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
BOOTSTRAP_PREFIX: bootstrap/clawdinator-1
run: |
scripts/upload-bootstrap.sh
- name: Build image
run: scripts/build-image.sh

View File

@ -63,7 +63,8 @@ Deploy flow (automation-first):
- Use `nix/hosts/clawdinator-1-image.nix` for image builds.
- CI is preferred: `.github/workflows/image-build.yml` runs build → S3 upload → AMI import.
- Resume AMI pipeline work immediately if it stalls; do not use rsync as a workaround. Host edits are allowed but must be committed and baked into a new AMI to persist.
- CI must provide `CLAWDINATOR_AGE_KEY` (private key) so the image can bake `/etc/agenix/keys/clawdinator.agekey`.
- CI must provide `CLAWDINATOR_AGE_KEY` to build + upload the runtime bootstrap bundle to S3.
- Bootstrap bundle location: `s3://${S3_BUCKET}/bootstrap/<instance>/` (secrets + repo seeds).
- Bootstrap S3 bucket + scoped IAM user + VM Import role with `infra/opentofu/aws` (use homelab-admin creds).
- Bootstrap AWS instances from the AMI with `infra/opentofu/aws` (set `TF_VAR_ami_id`).
- Import the image into AWS as an AMI (snapshot import + register image).

View File

@ -99,13 +99,13 @@ Imagebased deploy (only path):
2) Upload the raw image to S3 (private object).
3) Import into AWS as an AMI (snapshot import + register image).
4) Launch hosts from the AMI (OpenTofu `infra/opentofu/aws`).
5) Ensure secrets are encrypted to the baked agenix key and sync them to `/var/lib/clawd/nix-secrets`.
6) Run `nixos-rebuild switch --flake /var/lib/clawd/repo#clawdinator-1`.
5) Upload the runtime bootstrap bundle to `s3://<bucket>/bootstrap/<instance>/` (secrets + repo seeds).
6) Hosts download secrets at boot (`clawdinator-bootstrap.service`) and then run `nixos-rebuild switch --flake /var/lib/clawd/repo#clawdinator-1`.
CI (recommended):
- GitHub Actions builds the image, uploads to S3, and imports an AMI.
- See `.github/workflows/image-build.yml` and `scripts/*.sh`.
- CI must provide `CLAWDINATOR_AGE_KEY` so the image can bake `/etc/agenix/keys/clawdinator.agekey`.
- CI must provide `CLAWDINATOR_AGE_KEY` to build + upload the runtime bootstrap bundle.
AWS bucket bootstrap:
- `infra/opentofu/aws` provisions a private S3 bucket + scoped IAM user + VM Import role.

View File

@ -8,7 +8,7 @@ Infrastructure (OpenTofu):
Image pipeline (CI):
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION` / `S3_BUCKET` (required).
- `CLAWDINATOR_AGE_KEY` (required; private age key baked into the AMI).
- `CLAWDINATOR_AGE_KEY` (required; used to build the bootstrap bundle uploaded to S3).
Local storage:
- Keep AWS keys encrypted in `../nix/nix-secrets` for local runs if needed.
@ -35,11 +35,18 @@ Agenix (local secrets repo):
- Store encrypted files in `../nix/nix-secrets` (relative to this repo).
- Sync encrypted secrets to the host at `/var/lib/clawd/nix-secrets`.
- Decrypt on host with agenix; point NixOS options at `/run/agenix/*`.
- Image builds bake the agenix identity to `/etc/agenix/keys/clawdinator.agekey`; do not commit this key.
- Image builds do **not** bake the agenix identity; the age key is injected at runtime via the bootstrap bundle.
- Required files (minimum): `clawdinator-github-app.pem.age`, `clawdinator-discord-token.age`, `clawdinator-anthropic-api-key.age`.
- Also required for OpenAI: `clawdinator-openai-api-key-peter-2.age`.
- CI image pipeline (stored locally, not on hosts): `clawdinator-image-uploader-access-key-id.age`, `clawdinator-image-uploader-secret-access-key.age`, `clawdinator-image-bucket-name.age`, `clawdinator-image-bucket-region.age`.
Bootstrap bundle (runtime injection):
- CI uploads `secrets.tar.zst` + `repo-seeds.tar.zst` to `s3://${S3_BUCKET}/bootstrap/<instance>/`.
- `secrets.tar.zst` contains:
- `clawdinator.agekey`
- `secrets/` directory with `*.age` files.
- The host downloads + installs these on boot (`clawdinator-bootstrap.service`).
Example NixOS wiring (agenix):
```
{ inputs, ... }:

View File

@ -1,6 +1,6 @@
# OpenTofu (AWS S3 Image Bucket)
Goal: use the CLAWDINATOR S3 bucket for images, plus create the VM Import role and attach import permissions to the CI IAM user.
Goal: use the CLAWDINATOR S3 bucket for images + bootstrap artifacts, create the VM Import role, and attach import permissions to the CI IAM user.
Also provisions EFS for shared memory.
Prereqs:
@ -34,3 +34,6 @@ CI wiring:
- `AWS_SECRET_ACCESS_KEY`
- `AWS_REGION`
- `S3_BUCKET`
Runtime bootstrap:
- Instances get an IAM role with read access to `s3://${S3_BUCKET}/bootstrap/*` for secrets + repo seeds.

View File

@ -167,6 +167,37 @@ resource "aws_iam_role_policy_attachment" "instance_ssm" {
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
data "aws_iam_policy_document" "instance_bootstrap" {
statement {
actions = [
"s3:GetObject",
"s3:GetObjectAttributes"
]
resources = [
"${aws_s3_bucket.image_bucket.arn}/bootstrap/*"
]
}
statement {
actions = [
"s3:GetBucketLocation",
"s3:ListBucket"
]
resources = [aws_s3_bucket.image_bucket.arn]
condition {
test = "StringLike"
variable = "s3:prefix"
values = ["bootstrap/*"]
}
}
}
resource "aws_iam_role_policy" "instance_bootstrap" {
name = "clawdinator-bootstrap"
role = aws_iam_role.instance.id
policy = data.aws_iam_policy_document.instance_bootstrap.json
}
resource "aws_iam_instance_profile" "instance" {
name = "clawdinator-instance"
role = aws_iam_role.instance.name

View File

@ -26,6 +26,8 @@ in
};
config = {
clawdinator.secretsPath = "/var/lib/clawd/nix-secrets";
age.identityPaths = [ "/etc/agenix/keys/clawdinator.agekey" ];
age.secrets."clawdinator-github-app.pem" = {
file = "${secretsPath}/clawdinator-github-app.pem.age";
@ -52,6 +54,16 @@ in
enable = true;
instanceName = "CLAWDINATOR-1";
memoryDir = "/memory";
repoSeedSnapshotDir = "/var/lib/clawd/repo-seeds";
bootstrap = {
enable = true;
s3Bucket = "clawdinator-images-eu1-20260107165216";
s3Prefix = "bootstrap/clawdinator-1";
region = "eu-central-1";
secretsDir = "/var/lib/clawd/nix-secrets";
repoSeedsDir = "/var/lib/clawd/repo-seeds";
ageKeyPath = "/etc/agenix/keys/clawdinator.agekey";
};
memoryEfs = {
enable = true;
fileSystemId = "fs-0e7920726c2965a88";

View File

@ -21,42 +21,12 @@
networking.useDHCP = true;
services.openssh.enable = true;
services.openssh.settings.PermitRootLogin = "prohibit-password";
assertions = [
{
assertion = (builtins.getEnv "CLAWDINATOR_AGE_KEY") != "";
message = "CLAWDINATOR_AGE_KEY must be set when building the image.";
}
{
assertion = (builtins.getEnv "CLAWDINATOR_SECRETS_DIR") != "";
message = "CLAWDINATOR_SECRETS_DIR must point at encrypted age secrets.";
}
{
assertion = (builtins.getEnv "CLAWDINATOR_REPO_SEEDS_DIR") != "";
message = "CLAWDINATOR_REPO_SEEDS_DIR must point at preseeded repos.";
}
];
environment.etc."agenix/keys/clawdinator.agekey" = {
text = builtins.getEnv "CLAWDINATOR_AGE_KEY";
mode = "0400";
};
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOLItFT3SVm5r7gELrfRRJxh6V2sf/BIx7HKXt6oVWpB"
];
clawdinator.secretsPath = toString (builtins.path {
path = builtins.toPath (builtins.getEnv "CLAWDINATOR_SECRETS_DIR");
name = "clawdinator-age-secrets";
});
services.clawdinator.repoSeedSnapshotDir =
let
seedsDir = builtins.getEnv "CLAWDINATOR_REPO_SEEDS_DIR";
in
if seedsDir == ""
then null
else builtins.path {
path = builtins.toPath seedsDir;
name = "clawdinator-repo-seeds";
};
fileSystems."/" = {
device = "/dev/disk/by-label/nixos";
fsType = "ext4";
};
}

View File

@ -21,5 +21,4 @@
networking.firewall.allowedTCPPorts = [ 22 18789 ];
clawdinator.secretsPath = "/var/lib/clawd/nix-secrets";
}

View File

@ -211,11 +211,62 @@ in
};
repoSeedSnapshotDir = mkOption {
type = types.nullOr types.path;
type = types.nullOr types.str;
default = null;
description = "Optional path to a preseeded repo snapshot (directory of repos). When set, no network cloning happens at boot.";
};
bootstrap = {
enable = mkEnableOption "Bootstrap secrets + repo seeds from S3";
s3Bucket = mkOption {
type = types.str;
description = "S3 bucket holding bootstrap artifacts.";
};
s3Prefix = mkOption {
type = types.str;
default = "bootstrap/${cfg.instanceName}";
description = "S3 prefix for bootstrap artifacts (relative to bucket).";
};
region = mkOption {
type = types.str;
default = "eu-central-1";
description = "AWS region for S3 bootstrap bucket.";
};
secretsArchive = mkOption {
type = types.str;
default = "secrets.tar.zst";
description = "Secrets archive name inside the bootstrap prefix.";
};
repoSeedsArchive = mkOption {
type = types.str;
default = "repo-seeds.tar.zst";
description = "Repo seeds archive name inside the bootstrap prefix.";
};
ageKeyPath = mkOption {
type = types.str;
default = "/etc/agenix/keys/clawdinator.agekey";
description = "Destination path for the agenix identity key.";
};
secretsDir = mkOption {
type = types.str;
default = "/var/lib/clawd/nix-secrets";
description = "Destination directory for encrypted age secrets.";
};
repoSeedsDir = mkOption {
type = types.str;
default = "/var/lib/clawd/repo-seeds";
description = "Destination directory for repo seed snapshots.";
};
};
workspaceTemplateDir = mkOption {
type = types.path;
default = ../../clawdinator/workspace;
@ -482,9 +533,11 @@ in
wantedBy = [ "multi-user.target" ];
after =
[ "network.target" ]
++ lib.optional cfg.bootstrap.enable "clawdinator-bootstrap.service"
++ lib.optional cfg.githubApp.enable "clawdinator-github-app-token.service"
++ lib.optional (cfg.repoSeedSnapshotDir != null) "clawdinator-repo-seed.service";
wants =
lib.optional cfg.bootstrap.enable "clawdinator-bootstrap.service"
lib.optional cfg.githubApp.enable "clawdinator-github-app-token.service"
++ lib.optional (cfg.repoSeedSnapshotDir != null) "clawdinator-repo-seed.service";
@ -527,7 +580,10 @@ in
description = "CLAWDINATOR repo seed (snapshot copy)";
wantedBy = [ "multi-user.target" ];
before = [ "clawdinator.service" ];
after = [ "local-fs.target" ];
after =
[ "local-fs.target" ]
++ lib.optional cfg.bootstrap.enable "clawdinator-bootstrap.service";
requires = lib.optional cfg.bootstrap.enable "clawdinator-bootstrap.service";
serviceConfig = {
Type = "oneshot";
User = "root";
@ -536,6 +592,28 @@ in
script = "${pkgs.bash}/bin/bash ${../../scripts/seed-repos-from-snapshot.sh} ${cfg.repoSeedSnapshotDir} ${repoSeedBaseDir} ${cfg.user} ${cfg.group}";
};
systemd.services.clawdinator-bootstrap = lib.mkIf cfg.bootstrap.enable {
description = "CLAWDINATOR bootstrap (S3 secrets + repo seeds)";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
environment = {
AWS_REGION = cfg.bootstrap.region;
AWS_DEFAULT_REGION = cfg.bootstrap.region;
};
path = [ pkgs.awscli2 pkgs.coreutils pkgs.gnutar pkgs.zstd ];
script = "${pkgs.bash}/bin/bash ${../../scripts/bootstrap-runtime.sh} ${cfg.bootstrap.s3Bucket} ${cfg.bootstrap.s3Prefix} ${cfg.bootstrap.secretsDir} ${cfg.bootstrap.repoSeedsDir} ${cfg.bootstrap.ageKeyPath} ${cfg.bootstrap.secretsArchive} ${cfg.bootstrap.repoSeedsArchive}";
};
systemd.services.agenix = lib.mkIf cfg.bootstrap.enable {
requires = [ "clawdinator-bootstrap.service" ];
after = [ "clawdinator-bootstrap.service" ];
};
systemd.services.clawdinator-efs-stunnel = lib.mkIf cfg.memoryEfs.enable {
description = "CLAWDINATOR EFS TLS tunnel";
wantedBy = [ "multi-user.target" ];

View File

@ -14,6 +14,8 @@
pkgs.util-linux
pkgs.nfs-utils
pkgs.stunnel
pkgs.awscli2
pkgs.zstd
];
docs = [
@ -31,5 +33,7 @@
{ name = "util-linux"; description = "Provides flock used by memory wrappers."; }
{ name = "nfs-utils"; description = "NFS client utilities for EFS."; }
{ name = "stunnel"; description = "TLS tunnel for EFS in transit."; }
{ name = "awscli2"; description = "AWS CLI for bootstrap S3 pulls."; }
{ name = "zstd"; description = "Compression tool for bootstrap archives."; }
];
}

View File

@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
bucket="${1:?S3 bucket required}"
prefix="${2:?S3 prefix required}"
secrets_dir="${3:?Secrets dir required}"
repo_seeds_dir="${4:?Repo seeds dir required}"
age_key_path="${5:?Age key path required}"
secrets_archive="${6:-secrets.tar.zst}"
repo_seeds_archive="${7:-repo-seeds.tar.zst}"
sentinel="${secrets_dir}/.bootstrap-ok"
if [ -f "${sentinel}" ]; then
echo "clawdinator-bootstrap: already initialized"
exit 0
fi
s3_base="s3://${bucket}/${prefix}"
workdir="$(mktemp -d)"
cleanup() {
rm -rf "${workdir}"
}
trap cleanup EXIT
mkdir -p "${secrets_dir}" "${repo_seeds_dir}" "$(dirname "${age_key_path}")"
aws s3 cp "${s3_base}/${secrets_archive}" "${workdir}/secrets.tar.zst" --only-show-errors
aws s3 cp "${s3_base}/${repo_seeds_archive}" "${workdir}/repo-seeds.tar.zst" --only-show-errors
tmp_secrets="${workdir}/secrets"
mkdir -p "${tmp_secrets}"
tar --zstd -xf "${workdir}/secrets.tar.zst" -C "${tmp_secrets}"
if [ ! -f "${tmp_secrets}/clawdinator.agekey" ]; then
echo "clawdinator-bootstrap: missing clawdinator.agekey in secrets archive" >&2
exit 1
fi
install -m 0400 "${tmp_secrets}/clawdinator.agekey" "${age_key_path}"
if [ ! -d "${tmp_secrets}/secrets" ]; then
echo "clawdinator-bootstrap: missing secrets/ directory in secrets archive" >&2
exit 1
fi
cp -a "${tmp_secrets}/secrets/." "${secrets_dir}/"
chmod -R u=rw,go= "${secrets_dir}" || true
tar --zstd -xf "${workdir}/repo-seeds.tar.zst" -C "${repo_seeds_dir}"
touch "${sentinel}"
echo "clawdinator-bootstrap: done"

View File

@ -9,34 +9,6 @@ if [ -e "${out_dir}" ]; then
rm -rf "${out_dir}"
fi
if [ -f nix/keys/clawdinator.agekey ]; then
export CLAWDINATOR_AGE_KEY
CLAWDINATOR_AGE_KEY="$(cat nix/keys/clawdinator.agekey)"
else
echo "Missing nix/keys/clawdinator.agekey" >&2
exit 1
fi
if [ -z "${CLAWDINATOR_SECRETS_DIR:-}" ]; then
if [ -d nix/age-secrets ]; then
export CLAWDINATOR_SECRETS_DIR
CLAWDINATOR_SECRETS_DIR="$(pwd)/nix/age-secrets"
else
echo "Missing nix/age-secrets; set CLAWDINATOR_SECRETS_DIR" >&2
exit 1
fi
fi
if [ -z "${CLAWDINATOR_REPO_SEEDS_DIR:-}" ]; then
if [ -d repo-seeds ]; then
export CLAWDINATOR_REPO_SEEDS_DIR
CLAWDINATOR_REPO_SEEDS_DIR="$(pwd)/repo-seeds"
else
echo "Missing repo-seeds; set CLAWDINATOR_REPO_SEEDS_DIR" >&2
exit 1
fi
fi
nix run --impure github:nix-community/nixos-generators -- --flake "${flake_ref}" -f "${format}" -o "${out_dir}"
out_real="${out_dir}"

View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
bucket="${S3_BUCKET:?S3_BUCKET required}"
region="${AWS_REGION:?AWS_REGION required}"
prefix="${BOOTSTRAP_PREFIX:-bootstrap/clawdinator-1}"
secrets_dir="${SECRETS_DIR:-nix/age-secrets}"
age_key_file="${AGE_KEY_FILE:-nix/keys/clawdinator.agekey}"
repo_seeds_dir="${REPO_SEEDS_DIR:-repo-seeds}"
if [ ! -f "${age_key_file}" ]; then
echo "Missing age key: ${age_key_file}" >&2
exit 1
fi
if [ ! -d "${secrets_dir}" ]; then
echo "Missing secrets dir: ${secrets_dir}" >&2
exit 1
fi
if [ ! -d "${repo_seeds_dir}" ]; then
echo "Missing repo seeds dir: ${repo_seeds_dir}" >&2
exit 1
fi
workdir="$(mktemp -d)"
cleanup() {
rm -rf "${workdir}"
}
trap cleanup EXIT
staging="${workdir}/staging"
mkdir -p "${staging}/secrets"
cp "${age_key_file}" "${staging}/clawdinator.agekey"
cp -a "${secrets_dir}/." "${staging}/secrets/"
tar --zstd -cf "${workdir}/secrets.tar.zst" -C "${staging}" .
tar --zstd -cf "${workdir}/repo-seeds.tar.zst" -C "${repo_seeds_dir}" .
aws s3 cp "${workdir}/secrets.tar.zst" "s3://${bucket}/${prefix}/secrets.tar.zst" \
--region "${region}" \
--only-show-errors
aws s3 cp "${workdir}/repo-seeds.tar.zst" "s3://${bucket}/${prefix}/repo-seeds.tar.zst" \
--region "${region}" \
--only-show-errors
echo "Uploaded bootstrap artifacts to s3://${bucket}/${prefix}/"