Skip to content

Commit a7106d3

Browse files
committed
Move secrets + repo seeds to runtime bootstrap
1 parent 258e8d8 commit a7106d3

File tree

14 files changed

+261
-73
lines changed

14 files changed

+261
-73
lines changed

.github/workflows/image-build.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ jobs:
4444
nixpkgs#nixos-generators \
4545
nixpkgs#awscli2 \
4646
nixpkgs#age \
47-
nixpkgs#jq
47+
nixpkgs#jq \
48+
nixpkgs#zstd
4849
4950
- name: Write agenix image key
5051
env:
@@ -90,6 +91,16 @@ jobs:
9091
run: |
9192
scripts/prepare-repo-seeds.sh repo-seeds
9293
94+
- name: Upload bootstrap bundle
95+
env:
96+
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
97+
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
98+
AWS_REGION: ${{ secrets.AWS_REGION }}
99+
S3_BUCKET: ${{ secrets.S3_BUCKET }}
100+
BOOTSTRAP_PREFIX: bootstrap/clawdinator-1
101+
run: |
102+
scripts/upload-bootstrap.sh
103+
93104
- name: Build image
94105
run: scripts/build-image.sh
95106

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ Deploy flow (automation-first):
6363
- Use `nix/hosts/clawdinator-1-image.nix` for image builds.
6464
- CI is preferred: `.github/workflows/image-build.yml` runs build → S3 upload → AMI import.
6565
- 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.
66-
- CI must provide `CLAWDINATOR_AGE_KEY` (private key) so the image can bake `/etc/agenix/keys/clawdinator.agekey`.
66+
- CI must provide `CLAWDINATOR_AGE_KEY` to build + upload the runtime bootstrap bundle to S3.
67+
- Bootstrap bundle location: `s3://${S3_BUCKET}/bootstrap/<instance>/` (secrets + repo seeds).
6768
- Bootstrap S3 bucket + scoped IAM user + VM Import role with `infra/opentofu/aws` (use homelab-admin creds).
6869
- Bootstrap AWS instances from the AMI with `infra/opentofu/aws` (set `TF_VAR_ami_id`).
6970
- Import the image into AWS as an AMI (snapshot import + register image).

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,13 @@ Image‑based deploy (only path):
9999
2) Upload the raw image to S3 (private object).
100100
3) Import into AWS as an AMI (snapshot import + register image).
101101
4) Launch hosts from the AMI (OpenTofu `infra/opentofu/aws`).
102-
5) Ensure secrets are encrypted to the baked agenix key and sync them to `/var/lib/clawd/nix-secrets`.
103-
6) Run `nixos-rebuild switch --flake /var/lib/clawd/repo#clawdinator-1`.
102+
5) Upload the runtime bootstrap bundle to `s3://<bucket>/bootstrap/<instance>/` (secrets + repo seeds).
103+
6) Hosts download secrets at boot (`clawdinator-bootstrap.service`) and then run `nixos-rebuild switch --flake /var/lib/clawd/repo#clawdinator-1`.
104104

105105
CI (recommended):
106106
- GitHub Actions builds the image, uploads to S3, and imports an AMI.
107107
- See `.github/workflows/image-build.yml` and `scripts/*.sh`.
108-
- CI must provide `CLAWDINATOR_AGE_KEY` so the image can bake `/etc/agenix/keys/clawdinator.agekey`.
108+
- CI must provide `CLAWDINATOR_AGE_KEY` to build + upload the runtime bootstrap bundle.
109109

110110
AWS bucket bootstrap:
111111
- `infra/opentofu/aws` provisions a private S3 bucket + scoped IAM user + VM Import role.

docs/SECRETS.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Infrastructure (OpenTofu):
88

99
Image pipeline (CI):
1010
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION` / `S3_BUCKET` (required).
11-
- `CLAWDINATOR_AGE_KEY` (required; private age key baked into the AMI).
11+
- `CLAWDINATOR_AGE_KEY` (required; used to build the bootstrap bundle uploaded to S3).
1212

1313
Local storage:
1414
- Keep AWS keys encrypted in `../nix/nix-secrets` for local runs if needed.
@@ -35,11 +35,18 @@ Agenix (local secrets repo):
3535
- Store encrypted files in `../nix/nix-secrets` (relative to this repo).
3636
- Sync encrypted secrets to the host at `/var/lib/clawd/nix-secrets`.
3737
- Decrypt on host with agenix; point NixOS options at `/run/agenix/*`.
38-
- Image builds bake the agenix identity to `/etc/agenix/keys/clawdinator.agekey`; do not commit this key.
38+
- Image builds do **not** bake the agenix identity; the age key is injected at runtime via the bootstrap bundle.
3939
- Required files (minimum): `clawdinator-github-app.pem.age`, `clawdinator-discord-token.age`, `clawdinator-anthropic-api-key.age`.
4040
- Also required for OpenAI: `clawdinator-openai-api-key-peter-2.age`.
4141
- 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`.
4242

43+
Bootstrap bundle (runtime injection):
44+
- CI uploads `secrets.tar.zst` + `repo-seeds.tar.zst` to `s3://${S3_BUCKET}/bootstrap/<instance>/`.
45+
- `secrets.tar.zst` contains:
46+
- `clawdinator.agekey`
47+
- `secrets/` directory with `*.age` files.
48+
- The host downloads + installs these on boot (`clawdinator-bootstrap.service`).
49+
4350
Example NixOS wiring (agenix):
4451
```
4552
{ inputs, ... }:

infra/opentofu/aws/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# OpenTofu (AWS S3 Image Bucket)
22

3-
Goal: use the CLAWDINATOR S3 bucket for images, plus create the VM Import role and attach import permissions to the CI IAM user.
3+
Goal: use the CLAWDINATOR S3 bucket for images + bootstrap artifacts, create the VM Import role, and attach import permissions to the CI IAM user.
44
Also provisions EFS for shared memory.
55

66
Prereqs:
@@ -34,3 +34,6 @@ CI wiring:
3434
- `AWS_SECRET_ACCESS_KEY`
3535
- `AWS_REGION`
3636
- `S3_BUCKET`
37+
38+
Runtime bootstrap:
39+
- Instances get an IAM role with read access to `s3://${S3_BUCKET}/bootstrap/*` for secrets + repo seeds.

infra/opentofu/aws/main.tf

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,37 @@ resource "aws_iam_role_policy_attachment" "instance_ssm" {
167167
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
168168
}
169169

170+
data "aws_iam_policy_document" "instance_bootstrap" {
171+
statement {
172+
actions = [
173+
"s3:GetObject",
174+
"s3:GetObjectAttributes"
175+
]
176+
resources = [
177+
"${aws_s3_bucket.image_bucket.arn}/bootstrap/*"
178+
]
179+
}
180+
181+
statement {
182+
actions = [
183+
"s3:GetBucketLocation",
184+
"s3:ListBucket"
185+
]
186+
resources = [aws_s3_bucket.image_bucket.arn]
187+
condition {
188+
test = "StringLike"
189+
variable = "s3:prefix"
190+
values = ["bootstrap/*"]
191+
}
192+
}
193+
}
194+
195+
resource "aws_iam_role_policy" "instance_bootstrap" {
196+
name = "clawdinator-bootstrap"
197+
role = aws_iam_role.instance.id
198+
policy = data.aws_iam_policy_document.instance_bootstrap.json
199+
}
200+
170201
resource "aws_iam_instance_profile" "instance" {
171202
name = "clawdinator-instance"
172203
role = aws_iam_role.instance.name

nix/hosts/clawdinator-1-common.nix

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ in
2626
};
2727

2828
config = {
29+
clawdinator.secretsPath = "/var/lib/clawd/nix-secrets";
30+
2931
age.identityPaths = [ "/etc/agenix/keys/clawdinator.agekey" ];
3032
age.secrets."clawdinator-github-app.pem" = {
3133
file = "${secretsPath}/clawdinator-github-app.pem.age";
@@ -52,6 +54,16 @@ in
5254
enable = true;
5355
instanceName = "CLAWDINATOR-1";
5456
memoryDir = "/memory";
57+
repoSeedSnapshotDir = "/var/lib/clawd/repo-seeds";
58+
bootstrap = {
59+
enable = true;
60+
s3Bucket = "clawdinator-images-eu1-20260107165216";
61+
s3Prefix = "bootstrap/clawdinator-1";
62+
region = "eu-central-1";
63+
secretsDir = "/var/lib/clawd/nix-secrets";
64+
repoSeedsDir = "/var/lib/clawd/repo-seeds";
65+
ageKeyPath = "/etc/agenix/keys/clawdinator.agekey";
66+
};
5567
memoryEfs = {
5668
enable = true;
5769
fileSystemId = "fs-0e7920726c2965a88";

nix/hosts/clawdinator-1-image.nix

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -21,42 +21,12 @@
2121
networking.useDHCP = true;
2222
services.openssh.enable = true;
2323
services.openssh.settings.PermitRootLogin = "prohibit-password";
24-
assertions = [
25-
{
26-
assertion = (builtins.getEnv "CLAWDINATOR_AGE_KEY") != "";
27-
message = "CLAWDINATOR_AGE_KEY must be set when building the image.";
28-
}
29-
{
30-
assertion = (builtins.getEnv "CLAWDINATOR_SECRETS_DIR") != "";
31-
message = "CLAWDINATOR_SECRETS_DIR must point at encrypted age secrets.";
32-
}
33-
{
34-
assertion = (builtins.getEnv "CLAWDINATOR_REPO_SEEDS_DIR") != "";
35-
message = "CLAWDINATOR_REPO_SEEDS_DIR must point at preseeded repos.";
36-
}
37-
];
38-
39-
environment.etc."agenix/keys/clawdinator.agekey" = {
40-
text = builtins.getEnv "CLAWDINATOR_AGE_KEY";
41-
mode = "0400";
42-
};
4324
users.users.root.openssh.authorizedKeys.keys = [
4425
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOLItFT3SVm5r7gELrfRRJxh6V2sf/BIx7HKXt6oVWpB"
4526
];
4627

47-
clawdinator.secretsPath = toString (builtins.path {
48-
path = builtins.toPath (builtins.getEnv "CLAWDINATOR_SECRETS_DIR");
49-
name = "clawdinator-age-secrets";
50-
});
51-
52-
services.clawdinator.repoSeedSnapshotDir =
53-
let
54-
seedsDir = builtins.getEnv "CLAWDINATOR_REPO_SEEDS_DIR";
55-
in
56-
if seedsDir == ""
57-
then null
58-
else builtins.path {
59-
path = builtins.toPath seedsDir;
60-
name = "clawdinator-repo-seeds";
61-
};
28+
fileSystems."/" = {
29+
device = "/dev/disk/by-label/nixos";
30+
fsType = "ext4";
31+
};
6232
}

nix/hosts/clawdinator-1.nix

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,4 @@
2121

2222
networking.firewall.allowedTCPPorts = [ 22 18789 ];
2323

24-
clawdinator.secretsPath = "/var/lib/clawd/nix-secrets";
2524
}

nix/modules/clawdinator.nix

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,62 @@ in
211211
};
212212

213213
repoSeedSnapshotDir = mkOption {
214-
type = types.nullOr types.path;
214+
type = types.nullOr types.str;
215215
default = null;
216216
description = "Optional path to a preseeded repo snapshot (directory of repos). When set, no network cloning happens at boot.";
217217
};
218218

219+
bootstrap = {
220+
enable = mkEnableOption "Bootstrap secrets + repo seeds from S3";
221+
222+
s3Bucket = mkOption {
223+
type = types.str;
224+
description = "S3 bucket holding bootstrap artifacts.";
225+
};
226+
227+
s3Prefix = mkOption {
228+
type = types.str;
229+
default = "bootstrap/${cfg.instanceName}";
230+
description = "S3 prefix for bootstrap artifacts (relative to bucket).";
231+
};
232+
233+
region = mkOption {
234+
type = types.str;
235+
default = "eu-central-1";
236+
description = "AWS region for S3 bootstrap bucket.";
237+
};
238+
239+
secretsArchive = mkOption {
240+
type = types.str;
241+
default = "secrets.tar.zst";
242+
description = "Secrets archive name inside the bootstrap prefix.";
243+
};
244+
245+
repoSeedsArchive = mkOption {
246+
type = types.str;
247+
default = "repo-seeds.tar.zst";
248+
description = "Repo seeds archive name inside the bootstrap prefix.";
249+
};
250+
251+
ageKeyPath = mkOption {
252+
type = types.str;
253+
default = "/etc/agenix/keys/clawdinator.agekey";
254+
description = "Destination path for the agenix identity key.";
255+
};
256+
257+
secretsDir = mkOption {
258+
type = types.str;
259+
default = "/var/lib/clawd/nix-secrets";
260+
description = "Destination directory for encrypted age secrets.";
261+
};
262+
263+
repoSeedsDir = mkOption {
264+
type = types.str;
265+
default = "/var/lib/clawd/repo-seeds";
266+
description = "Destination directory for repo seed snapshots.";
267+
};
268+
};
269+
219270
workspaceTemplateDir = mkOption {
220271
type = types.path;
221272
default = ../../clawdinator/workspace;
@@ -482,9 +533,11 @@ in
482533
wantedBy = [ "multi-user.target" ];
483534
after =
484535
[ "network.target" ]
536+
++ lib.optional cfg.bootstrap.enable "clawdinator-bootstrap.service"
485537
++ lib.optional cfg.githubApp.enable "clawdinator-github-app-token.service"
486538
++ lib.optional (cfg.repoSeedSnapshotDir != null) "clawdinator-repo-seed.service";
487539
wants =
540+
lib.optional cfg.bootstrap.enable "clawdinator-bootstrap.service"
488541
lib.optional cfg.githubApp.enable "clawdinator-github-app-token.service"
489542
++ lib.optional (cfg.repoSeedSnapshotDir != null) "clawdinator-repo-seed.service";
490543

@@ -527,7 +580,10 @@ in
527580
description = "CLAWDINATOR repo seed (snapshot copy)";
528581
wantedBy = [ "multi-user.target" ];
529582
before = [ "clawdinator.service" ];
530-
after = [ "local-fs.target" ];
583+
after =
584+
[ "local-fs.target" ]
585+
++ lib.optional cfg.bootstrap.enable "clawdinator-bootstrap.service";
586+
requires = lib.optional cfg.bootstrap.enable "clawdinator-bootstrap.service";
531587
serviceConfig = {
532588
Type = "oneshot";
533589
User = "root";
@@ -536,6 +592,28 @@ in
536592
script = "${pkgs.bash}/bin/bash ${../../scripts/seed-repos-from-snapshot.sh} ${cfg.repoSeedSnapshotDir} ${repoSeedBaseDir} ${cfg.user} ${cfg.group}";
537593
};
538594

595+
systemd.services.clawdinator-bootstrap = lib.mkIf cfg.bootstrap.enable {
596+
description = "CLAWDINATOR bootstrap (S3 secrets + repo seeds)";
597+
wantedBy = [ "multi-user.target" ];
598+
after = [ "network-online.target" ];
599+
wants = [ "network-online.target" ];
600+
serviceConfig = {
601+
Type = "oneshot";
602+
RemainAfterExit = true;
603+
};
604+
environment = {
605+
AWS_REGION = cfg.bootstrap.region;
606+
AWS_DEFAULT_REGION = cfg.bootstrap.region;
607+
};
608+
path = [ pkgs.awscli2 pkgs.coreutils pkgs.gnutar pkgs.zstd ];
609+
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}";
610+
};
611+
612+
systemd.services.agenix = lib.mkIf cfg.bootstrap.enable {
613+
requires = [ "clawdinator-bootstrap.service" ];
614+
after = [ "clawdinator-bootstrap.service" ];
615+
};
616+
539617
systemd.services.clawdinator-efs-stunnel = lib.mkIf cfg.memoryEfs.enable {
540618
description = "CLAWDINATOR EFS TLS tunnel";
541619
wantedBy = [ "multi-user.target" ];

0 commit comments

Comments
 (0)