Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Turn a fresh machine into an instance on first boot

04e063ca5d73b8f57f45d7c8bb07b0fdaa91b5b8 · Keenan Nemetz · 3d ago · parent ea21f923

Turn a fresh machine into an instance on first boot

A new instance is one package, one configuration, one directory, and one
account. Assembling that by hand is where a first deployment goes wrong:
the capability the hardened unit needs for ports 80 and 443, the order that
makes the package's own init harmless, and the fact that the first instance
admin has to be granted on the host.

deploy/cloud-init/vole.yaml is that assembly. It stages two configurations,
one that lets Vole hold its own certificate and one that leaves TLS to a
proxy, then installs the package, writes the configuration, creates the
instance admin, and starts the service. Running it again changes nothing.

render.sh renders it for providers that take user data without Terraform,
using the placeholder list as the contract with the template.

The three Terraform configurations create the machine, its firewall, its
address, and optionally the DNS record, and hand it that same file. They are
root modules: init and apply, three variables, no other file to edit.

First boot is not configuration management. apply does not upgrade an
instance or touch its data, because users.db migrates in place and cannot be
rolled back.

deploy/terraform/aws/main.tf +190

@@ -0,0 +1,190 @@
1 # A Vole instance on EC2, configured by cloud-init.
2 #
3 # Run it from a clone of the Vole repository, because the machine's user data is
4 # deploy/cloud-init/vole.yaml and is read by its path:
5 #
6 # cd deploy/terraform/aws
7 # terraform init
8 # terraform apply \
9 # -var region=eu-central-1 \
10 # -var domain=forge.example.com \
11 # -var "admin_ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
12 # -var vole_package_url=https://example.com/r/vole/releases/v0.2.0/artifacts/vole_0.2.0-1_amd64.deb
13 #
14 # Credentials come from the usual AWS environment or profile. See
15 # docs/deploy/aws.md.
16
17 terraform {
18 required_version = ">= 1.9"
19 required_providers {
20 aws = {
21 source = "hashicorp/aws"
22 version = ">= 5.60"
23 }
24 }
25 }
26
27 provider "aws" {
28 region = var.region
29 }
30
31 # Ubuntu's own images, published by Canonical. The filter names the release and
32 # architecture rather than an AMI id, so the plan picks the current image in
33 # whatever region is chosen.
34 data "aws_ami" "ubuntu" {
35 most_recent = true
36 owners = ["099720109477"]
37
38 filter {
39 name = "name"
40 values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
41 }
42
43 filter {
44 name = "virtualization-type"
45 values = ["hvm"]
46 }
47 }
48
49 locals {
50 tags = {
51 Name = var.name
52 app = "vole"
53 }
54
55 user_data = templatefile(
56 var.cloud_init_file != "" ? var.cloud_init_file : "${path.module}/../../cloud-init/vole.yaml",
57 {
58 hostname = var.domain
59 admin_account = var.admin_account
60 admin_ssh_key = trimspace(var.admin_ssh_public_key)
61 admin_email = var.admin_email
62 instance_name = var.instance_name
63 tls_mode = var.tls_mode
64 vole_package_url = var.vole_package_url
65 }
66 )
67 }
68
69 resource "aws_key_pair" "admin" {
70 key_name = "${var.name}-admin"
71 public_key = trimspace(var.admin_ssh_public_key)
72 tags = local.tags
73 }
74
75 resource "aws_security_group" "vole" {
76 name = var.name
77 description = "Vole software forge"
78 tags = local.tags
79
80 ingress {
81 description = "Server login"
82 from_port = 22
83 to_port = 22
84 protocol = "tcp"
85 cidr_blocks = var.ssh_admin_cidrs
86 }
87
88 # Git over SSH. Restricting this to your team's addresses is the cheapest way
89 # to take the write surface off the internet.
90 ingress {
91 description = "Git over SSH"
92 from_port = 2222
93 to_port = 2222
94 protocol = "tcp"
95 cidr_blocks = var.ssh_admin_cidrs
96 }
97
98 ingress {
99 description = "HTTP, used for the certificate challenge"
100 from_port = 80
101 to_port = 80
102 protocol = "tcp"
103 cidr_blocks = ["0.0.0.0/0"]
104 }
105
106 ingress {
107 description = "HTTPS"
108 from_port = 443
109 to_port = 443
110 protocol = "tcp"
111 cidr_blocks = ["0.0.0.0/0"]
112 }
113
114 ingress {
115 description = "ICMP"
116 from_port = -1
117 to_port = -1
118 protocol = "icmp"
119 cidr_blocks = ["0.0.0.0/0"]
120 }
121
122 egress {
123 from_port = 0
124 to_port = 0
125 protocol = "-1"
126 cidr_blocks = ["0.0.0.0/0"]
127 }
128 }
129
130 resource "aws_instance" "vole" {
131 ami = data.aws_ami.ubuntu.id
132 instance_type = var.instance_type
133 key_name = aws_key_pair.admin.key_name
134 vpc_security_group_ids = [aws_security_group.vole.id]
135 associate_public_ip_address = true
136 user_data = local.user_data
137 tags = local.tags
138
139 # The instance is the only copy of its accounts, CI history, and the host key
140 # people's clients pin. Deleting it has to be done on purpose, from outside
141 # Terraform, or by lifting the protection.
142 disable_api_termination = var.protect_instance
143
144 root_block_device {
145 volume_size = var.disk_gb
146 volume_type = "gp3"
147 encrypted = true
148 tags = local.tags
149 }
150
151 metadata_options {
152 http_endpoint = "enabled"
153 http_tokens = "required"
154 }
155
156 lifecycle {
157 # user_data only matters at first boot; changing it later would replace the
158 # machine, and the machine is the instance's data.
159 ignore_changes = [user_data]
160 }
161 }
162
163 # A fixed address. An EC2 public address changes when the instance is stopped and
164 # started, and clients pin both the host name and Vole's SSH host key.
165 resource "aws_eip" "vole" {
166 domain = "vpc"
167 tags = local.tags
168 }
169
170 resource "aws_eip_association" "vole" {
171 instance_id = aws_instance.vole.id
172 allocation_id = aws_eip.vole.id
173 }
174
175 data "aws_route53_zone" "this" {
176 count = var.create_dns ? 1 : 0
177
178 name = var.dns_zone
179 private_zone = false
180 }
181
182 resource "aws_route53_record" "forge" {
183 count = var.create_dns ? 1 : 0
184
185 zone_id = data.aws_route53_zone.this[0].zone_id
186 name = var.domain
187 type = "A"
188 ttl = 300
189 records = [aws_eip.vole.public_ip]
190 }