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.
Drop the Terraform configurations; cloud-init is the deployment path
51624dc663738e70cc37b8ac5e232841fd49aaf8 · Keenan Nemetz · 3d ago · parent e21f6c5e
Drop the Terraform configurations; cloud-init is the deployment path A Vole instance is one package, one configuration file, and one directory. The first-boot file that installs the package and writes the configuration is that same work described once, and it needs no provider, no state file, and no cloud token with write access to an account. What Terraform did outside the machine — firewall, address, DNS record — is three fields in a console the operator already has open. So the three configurations go, and with them what existed only to check them: the pinned Terraform download in the verify image, the `fmt -check` step, the .terraform entries in the ignore file, and the directory skip in the link checker. The deployment check now needs python3 and PyYAML and nothing else — no network, no credentials. deploy/README.md keeps the reasoning, so the next person tempted to add a provider back sees what it would buy and what it would cost. The templates' header comments no longer name a renderer that is gone: render.sh fills them in, and check-deploy.py renders them the same way.
deploy/terraform/aws/main.tf −271
| @@ -1,271 +0,0 @@ | |||
| 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 | # A runner is a second machine with its own job: it leases work from the | ||
| 69 | # instance, runs each job in a container, and reports the result. It does not | ||
| 70 | # need a name of its own, an address that stays put, or backups. | ||
| 71 | runner_user_data = templatefile( | ||
| 72 | var.runner_cloud_init_file != "" ? var.runner_cloud_init_file : "${path.module}/../../cloud-init/runner.yaml", | ||
| 73 | { | ||
| 74 | runner_hostname = "${var.name}-runner" | ||
| 75 | forge_host = var.domain | ||
| 76 | runner_runtime = "podman" | ||
| 77 | runner_network = var.runner_network | ||
| 78 | runner_dir = "/var/lib/vole-runner" | ||
| 79 | runner_labels = join(" ", [for label in var.runner_labels : "--label ${label}"]) | ||
| 80 | vole_package_url = var.vole_package_url | ||
| 81 | } | ||
| 82 | ) | ||
| 83 | } | ||
| 84 | |||
| 85 | resource "aws_key_pair" "admin" { | ||
| 86 | key_name = "${var.name}-admin" | ||
| 87 | public_key = trimspace(var.admin_ssh_public_key) | ||
| 88 | tags = local.tags | ||
| 89 | } | ||
| 90 | |||
| 91 | resource "aws_security_group" "vole" { | ||
| 92 | name = var.name | ||
| 93 | description = "Vole software forge" | ||
| 94 | tags = local.tags | ||
| 95 | |||
| 96 | ingress { | ||
| 97 | description = "Server login" | ||
| 98 | from_port = 22 | ||
| 99 | to_port = 22 | ||
| 100 | protocol = "tcp" | ||
| 101 | cidr_blocks = var.ssh_admin_cidrs | ||
| 102 | } | ||
| 103 | |||
| 104 | # Git over SSH. Restricting this to your team's addresses is the cheapest way | ||
| 105 | # to take the write surface off the internet. | ||
| 106 | ingress { | ||
| 107 | description = "Git over SSH" | ||
| 108 | from_port = 2222 | ||
| 109 | to_port = 2222 | ||
| 110 | protocol = "tcp" | ||
| 111 | cidr_blocks = var.ssh_admin_cidrs | ||
| 112 | } | ||
| 113 | |||
| 114 | ingress { | ||
| 115 | description = "HTTP, used for the certificate challenge" | ||
| 116 | from_port = 80 | ||
| 117 | to_port = 80 | ||
| 118 | protocol = "tcp" | ||
| 119 | cidr_blocks = ["0.0.0.0/0"] | ||
| 120 | } | ||
| 121 | |||
| 122 | ingress { | ||
| 123 | description = "HTTPS" | ||
| 124 | from_port = 443 | ||
| 125 | to_port = 443 | ||
| 126 | protocol = "tcp" | ||
| 127 | cidr_blocks = ["0.0.0.0/0"] | ||
| 128 | } | ||
| 129 | |||
| 130 | ingress { | ||
| 131 | description = "ICMP" | ||
| 132 | from_port = -1 | ||
| 133 | to_port = -1 | ||
| 134 | protocol = "icmp" | ||
| 135 | cidr_blocks = ["0.0.0.0/0"] | ||
| 136 | } | ||
| 137 | |||
| 138 | egress { | ||
| 139 | from_port = 0 | ||
| 140 | to_port = 0 | ||
| 141 | protocol = "-1" | ||
| 142 | cidr_blocks = ["0.0.0.0/0"] | ||
| 143 | } | ||
| 144 | } | ||
| 145 | |||
| 146 | resource "aws_instance" "vole" { | ||
| 147 | ami = data.aws_ami.ubuntu.id | ||
| 148 | instance_type = var.instance_type | ||
| 149 | key_name = aws_key_pair.admin.key_name | ||
| 150 | vpc_security_group_ids = [aws_security_group.vole.id] | ||
| 151 | associate_public_ip_address = true | ||
| 152 | user_data = local.user_data | ||
| 153 | tags = local.tags | ||
| 154 | |||
| 155 | # The instance is the only copy of its accounts, CI history, and the host key | ||
| 156 | # people's clients pin. Deleting it has to be done on purpose, from outside | ||
| 157 | # Terraform, or by lifting the protection. | ||
| 158 | disable_api_termination = var.protect_instance | ||
| 159 | |||
| 160 | root_block_device { | ||
| 161 | volume_size = var.disk_gb | ||
| 162 | volume_type = "gp3" | ||
| 163 | encrypted = true | ||
| 164 | tags = local.tags | ||
| 165 | } | ||
| 166 | |||
| 167 | metadata_options { | ||
| 168 | http_endpoint = "enabled" | ||
| 169 | http_tokens = "required" | ||
| 170 | } | ||
| 171 | |||
| 172 | lifecycle { | ||
| 173 | # user_data only matters at first boot; changing it later would replace the | ||
| 174 | # machine, and the machine is the instance's data. | ||
| 175 | ignore_changes = [user_data] | ||
| 176 | } | ||
| 177 | } | ||
| 178 | |||
| 179 | # A fixed address. An EC2 public address changes when the instance is stopped and | ||
| 180 | # started, and clients pin both the host name and Vole's SSH host key. | ||
| 181 | resource "aws_eip" "vole" { | ||
| 182 | domain = "vpc" | ||
| 183 | tags = local.tags | ||
| 184 | } | ||
| 185 | |||
| 186 | resource "aws_eip_association" "vole" { | ||
| 187 | instance_id = aws_instance.vole.id | ||
| 188 | allocation_id = aws_eip.vole.id | ||
| 189 | } | ||
| 190 | |||
| 191 | data "aws_route53_zone" "this" { | ||
| 192 | count = var.create_dns ? 1 : 0 | ||
| 193 | |||
| 194 | name = var.dns_zone | ||
| 195 | private_zone = false | ||
| 196 | } | ||
| 197 | |||
| 198 | resource "aws_route53_record" "forge" { | ||
| 199 | count = var.create_dns ? 1 : 0 | ||
| 200 | |||
| 201 | zone_id = data.aws_route53_zone.this[0].zone_id | ||
| 202 | name = var.domain | ||
| 203 | type = "A" | ||
| 204 | ttl = 300 | ||
| 205 | records = [aws_eip.vole.public_ip] | ||
| 206 | } | ||
| 207 | |||
| 208 | resource "aws_security_group" "runner" { | ||
| 209 | count = var.runner ? 1 : 0 | ||
| 210 | |||
| 211 | name = "${var.name}-runner" | ||
| 212 | description = "Vole CI runner" | ||
| 213 | tags = local.tags | ||
| 214 | |||
| 215 | # Nothing arrives except the operator's own shell. The runner dials out to the | ||
| 216 | # instance and to the registries its images come from. | ||
| 217 | ingress { | ||
| 218 | description = "Server login" | ||
| 219 | from_port = 22 | ||
| 220 | to_port = 22 | ||
| 221 | protocol = "tcp" | ||
| 222 | cidr_blocks = var.ssh_admin_cidrs | ||
| 223 | } | ||
| 224 | |||
| 225 | ingress { | ||
| 226 | description = "ICMP" | ||
| 227 | from_port = -1 | ||
| 228 | to_port = -1 | ||
| 229 | protocol = "icmp" | ||
| 230 | cidr_blocks = ["0.0.0.0/0"] | ||
| 231 | } | ||
| 232 | |||
| 233 | egress { | ||
| 234 | from_port = 0 | ||
| 235 | to_port = 0 | ||
| 236 | protocol = "-1" | ||
| 237 | cidr_blocks = ["0.0.0.0/0"] | ||
| 238 | } | ||
| 239 | } | ||
| 240 | |||
| 241 | resource "aws_instance" "runner" { | ||
| 242 | count = var.runner ? 1 : 0 | ||
| 243 | |||
| 244 | ami = data.aws_ami.ubuntu.id | ||
| 245 | instance_type = var.runner_type | ||
| 246 | key_name = aws_key_pair.admin.key_name | ||
| 247 | vpc_security_group_ids = [aws_security_group.runner[0].id] | ||
| 248 | associate_public_ip_address = true | ||
| 249 | user_data = local.runner_user_data | ||
| 250 | tags = local.tags | ||
| 251 | |||
| 252 | # A runner holds no data worth protecting from a rebuild: its checkouts and | ||
| 253 | # caches are copies of what the repositories have. | ||
| 254 | disable_api_termination = false | ||
| 255 | |||
| 256 | root_block_device { | ||
| 257 | volume_size = var.runner_disk_gb | ||
| 258 | volume_type = "gp3" | ||
| 259 | encrypted = true | ||
| 260 | tags = local.tags | ||
| 261 | } | ||
| 262 | |||
| 263 | metadata_options { | ||
| 264 | http_endpoint = "enabled" | ||
| 265 | http_tokens = "required" | ||
| 266 | } | ||
| 267 | |||
| 268 | lifecycle { | ||
| 269 | ignore_changes = [user_data] | ||
| 270 | } | ||
| 271 | } | ||