Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public route.
The provider owns how those VMs and networks exist:

- macOS + Lima uses two local Lima VMs and a Lima user-v2 private network.
- AWS EC2 uses two EC2 instances, a dedicated VPC, and Terraform-managed
- AWS EC2 uses two EC2 instances, a dedicated VPC, and OpenTofu-managed
networking.

The two VMs have the same roles in either provider:
Expand Down Expand Up @@ -116,7 +116,7 @@ On macOS with Homebrew:
brew tap oven-sh/bun
brew install bun
brew install lima # for the macOS + Lima provider
brew install terraform # for the AWS EC2 provider
brew install opentofu # for the AWS EC2 provider

chmod +x ./rootcell
bun install --frozen-lockfile
Expand Down Expand Up @@ -152,8 +152,9 @@ Initialize the instance `.env` before the first run:
./rootcell -i aws-dev
```

See [AWS EC2 provider](src/rootcell/providers/aws-ec2/README.md) for Terraform
layout, AWS resource ownership, AMI selection, and IAM isolation details.
See [AWS EC2 provider](src/rootcell/providers/aws-ec2/README.md) for the
OpenTofu/Terraform layout, AWS resource ownership, AMI selection, and IAM
isolation details.

First run creates the provider resources and provisions both VMs. Provisioning
uses Nix inside the VMs, but you do not need Nix installed on the host unless
Expand All @@ -170,9 +171,13 @@ environment variable:

```bash
ROOTCELL_LIMACTL=/path/to/limactl # Lima provider
ROOTCELL_TERRAFORM=/path/to/terraform # AWS EC2 provider
ROOTCELL_TERRAFORM=/path/to/tofu # AWS EC2 provider
```

The AWS EC2 provider uses OpenTofu's `tofu` command by default. Set
`ROOTCELL_TERRAFORM=/path/to/terraform` if you want to use a Terraform binary
you installed yourself.

Per-instance state defaults to `instances/<name>` under the current repo. Set
`ROOTCELL_STATE_DIR=/path/to/rootcell-instances` to use a different persistent
state root.
Expand Down Expand Up @@ -365,7 +370,7 @@ Provider state is intentionally provider-specific:

- macOS + Lima writes generated Lima YAML and VM state under `v/a/` and `v/f/`
and keeps Lima's own VM state under normal `LIMA_HOME`.
- AWS EC2 writes a generated Terraform module and Terraform state under
- AWS EC2 writes a generated Terraform-compatible module and state under
`v/aws-ec2/`.

Use `./rootcell list` to show known VMs and their state. `./rootcell stop`
Expand Down
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
paths = [
p.bun
p.lima
p.opentofu
];
};
});
Expand Down
2 changes: 1 addition & 1 deletion src/rootcell/host-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function hostToolMissingMessage(spec: HostToolSpec): string {
...(envVars.length === 0 ? [] : [`Set ${envVars.join(" or ")}=/path/to/${spec.name} to use a non-PATH binary.`]),
"Install host tools with Homebrew:",
" brew tap oven-sh/bun",
" brew install bun lima",
" brew install bun lima opentofu",
"Or run with Nix-provided host tools:",
" nix shell .#hostTools --command ./rootcell",
].join("\n");
Expand Down
8 changes: 7 additions & 1 deletion src/rootcell/integration/providers/aws-ec2/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "../../common/fixtures.ts";
import { instancePaths } from "../../../instance.ts";
import { commandExists, runInherited } from "../../../process.ts";
import { resolveHostTool } from "../../../host-tools.ts";
import type { RootcellConfig } from "../../../types.ts";
import { AwsEc2VmProvider } from "../../../providers/aws-ec2.ts";
import {
Expand Down Expand Up @@ -47,7 +48,12 @@ export function preflightAwsEc2Integration(): Promise<void> {
requireEnv("ROOTCELL_VM_PROVIDER", "aws-ec2");
requireEnv("ROOTCELL_AWS_PROFILE");
requireEnv("ROOTCELL_AWS_REGION");
for (const tool of ["terraform", "ssh", "scp", "ssh-keygen", "curl"]) {
resolveHostTool({
name: "tofu",
envVar: "ROOTCELL_TERRAFORM",
purpose: "for AWS EC2 integration tests",
});
for (const tool of ["ssh", "scp", "ssh-keygen", "curl"]) {
if (!commandExists(tool)) {
throw new Error(`aws-ec2 integration tests require '${tool}' on PATH`);
}
Expand Down
4 changes: 2 additions & 2 deletions src/rootcell/providers/aws-ec2-terraform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export class AwsEc2TerraformProject {
return;
}

this.log(`applying AWS EC2 Terraform for instance '${this.config.instanceName}'...`);
this.log(`applying AWS EC2 infrastructure for instance '${this.config.instanceName}'...`);
this.writeTerraformFiles(metadata, "running", controlCidr);
this.runner.init(this.terraformDir(), this.terraformEnv());
this.runner.apply(this.terraformDir(), this.terraformEnv());
Expand Down Expand Up @@ -354,7 +354,7 @@ export class AwsEc2TerraformProject {
private ensureTerraform(): string {
if (this.terraformBin.length === 0) {
this.terraformBin = resolveHostTool({
name: "terraform",
name: "tofu",
envVar: "ROOTCELL_TERRAFORM",
purpose: "to manage rootcell AWS EC2 resources",
});
Expand Down
25 changes: 14 additions & 11 deletions src/rootcell/providers/aws-ec2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

The `aws-ec2` provider runs rootcell's agent and firewall VMs as EC2
instances. Rootcell creates a dedicated VPC per rootcell instance and manages
AWS infrastructure with a generated Terraform module.
AWS infrastructure with a generated Terraform-compatible module. It runs
OpenTofu's `tofu` command by default; set
`ROOTCELL_TERRAFORM=/path/to/terraform` to use a Terraform binary you installed
yourself.

## Required Instance Environment

Expand All @@ -26,13 +29,13 @@ ROOTCELL_AWS_CONTROL_CIDR=auto
not fall back to `AWS_PROFILE` or `AWS_REGION` for provider selection.

`ROOTCELL_AWS_CONTROL_CIDR=auto` resolves your current public IPv4 address to a
single `/32` when Terraform is applied. If that address changes, normal
single `/32` when OpenTofu is applied. If that address changes, normal
`rootcell` entry fails with instructions to run `rootcell provision` so the
firewall SSH ingress rule is updated intentionally.

## Terraform Layout
## OpenTofu / Terraform Layout

Rootcell writes one Terraform module per instance:
Rootcell writes one Terraform-compatible module per instance:

```text
<instance-dir>/v/aws-ec2/
Expand All @@ -47,20 +50,20 @@ Rootcell writes one Terraform module per instance:
```

Terraform state is the ownership record for AWS resources. Normal VM entry does
not run `terraform init` or `terraform apply`; it reads cached Terraform
outputs, checks EC2 status, syncs allowlists, injects explicitly configured
not run `tofu init` or `tofu apply`; it reads cached infrastructure outputs,
checks EC2 status, syncs allowlists, injects explicitly configured
session secrets, and opens SSH through the firewall.

Terraform runs for first create, explicit `rootcell provision`, Terraform-backed
start/stop transitions, and `rootcell remove`.
OpenTofu runs for first create, explicit `rootcell provision`,
state-backed start/stop transitions, and `rootcell remove`.

## Upstream NixOS AMI

AWS EC2 instances boot from the official upstream NixOS ARM64 AMI. Rootcell
does not use rootcell-owned release image manifests, VM Import/Export, imported
snapshots, generated AMIs, or S3 image staging.

Terraform resolves the AMI at apply time:
OpenTofu resolves the AMI at apply time:

```hcl
data "aws_ami" "nixos_arm64" {
Expand Down Expand Up @@ -112,8 +115,8 @@ values.
## IAM And Credential Isolation

Rootcell does not attach an IAM instance profile to the agent or firewall. The
generated Terraform module must not create IAM roles, IAM instance profiles, or
instance-profile associations for those instances.
generated Terraform-compatible module must not create IAM roles, IAM instance
profiles, or instance-profile associations for those instances.

Rootcell never copies host `~/.aws` files into either VM and never injects AWS
credentials unless the user explicitly maps them in `secrets.env`.
Expand Down
20 changes: 19 additions & 1 deletion src/rootcell/rootcell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,22 @@ describe("host tool resolution", () => {
})).toThrow("nix shell .#hostTools --command ./rootcell");
});

test("AWS EC2 uses OpenTofu by default but accepts Terraform override", () => {
const tofuSpec = {
name: "tofu",
envVar: "ROOTCELL_TERRAFORM",
purpose: "to manage rootcell AWS EC2 resources",
};
expect(resolveHostTool(tofuSpec, {
env: {},
commandExists: (command) => command === "tofu",
})).toBe("tofu");
expect(resolveHostTool(tofuSpec, {
env: { ROOTCELL_TERRAFORM: "/usr/local/bin/terraform" },
commandExists: () => false,
})).toBe("/usr/local/bin/terraform");
});

test("runtime host tools do not fall back to host-side nix builds", () => {
for (const file of [
"src/rootcell/images.ts",
Expand Down Expand Up @@ -1038,8 +1054,10 @@ describe("VM and network providers", () => {
}
});

test("AWS EC2 README documents Terraform layout, upstream AMI, tags, and credential isolation", () => {
test("AWS EC2 README documents OpenTofu layout, upstream AMI, tags, and credential isolation", () => {
const readme = readFileSync("src/rootcell/providers/aws-ec2/README.md", "utf8");
expect(readme).toContain("OpenTofu");
expect(readme).toContain("ROOTCELL_TERRAFORM=/path/to/terraform");
expect(readme).toContain("<instance-dir>/v/aws-ec2/");
expect(readme).toContain("official upstream NixOS ARM64 AMI");
expect(readme).toContain("427812963091");
Expand Down