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 | } | ||