diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 000000000..73c1ede91 --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1,5 @@ +*.auto.tfvars +*.tfstate +*.tfstate.backup +.terraform/ +.terraform.lock.hcl diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 000000000..27b6afa06 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,59 @@ +# NethSecurity deploy + +Basic OpenTofu config: create DO droplets (each with a public network and a private VPC network), point DNS at them. + +## SSH keys + +`sshkeys` (SSH key names already registered in your DO account) get attached to every droplet. This is required by the DO API whenever the image has no cloud-init/agent support — as with a custom OpenWrt/NethSecurity image — DO otherwise refuses to create the droplet, since it has no other way to hand you access to it. The image itself never reads or installs the key (no cloud-init to do so); log in with its own baked-in credentials instead. + +## Usage + +1. Create `tofu.auto.tfvars` (see `tofu.auto.tfvars.example`): + + do_token = "secret" + project = "Aldo" + domain = "al.nethserver.net" + sshkeys = ["yourkey"] + nodes = { + "ns1" = { + hostname = "ns1" + region = "ams3" + size = "s-1vcpu-2gb" + custom_image = { + name = "nethsecurity-8.7.2" + url = "https://updates.nethsecurity.nethserver.org/stable/8.7.2/targets/x86/64/nethsecurity-8.7.2-x86-64-generic-squashfs-combined-efi.img.gz" + } + } + } + + `nodes` is a map of node key => droplet spec. Each entry creates one droplet (named `.`), an A record for it, and (per region used) a VPC providing its private network. `size` is optional, default `s-1vcpu-2gb`; do not go below 2GB RAM. + +2. Init and apply: + + tofu init + tofu apply + +Outputs the IP and FQDN of every droplet in `nodes`, keyed by node key. + +## Image + +Each node sets exactly one of: + +- `image`: an existing marketplace/snapshot image slug or ID. +- `custom_image`: import a custom image and use it — scoped to that node's own `region` only, even if another node shares the same region. + +### Custom image + +Imported from a URL (the DO image-import API only accepts a URL, no local upload): + + custom_image = { + name = "nethsecurity-8.7.2" + url = "https://updates.nethsecurity.nethserver.org/stable/8.7.2/targets/x86/64/nethsecurity-8.7.2-x86-64-generic-squashfs-combined-efi.img.gz" + } + +Default images (x86/64, generic, squashfs-combined-efi): + +| Version | URL | +|---|---| +| 8.7.2 | https://updates.nethsecurity.nethserver.org/stable/8.7.2/targets/x86/64/nethsecurity-8.7.2-x86-64-generic-squashfs-combined-efi.img.gz | +| 8.7.1 | https://updates.nethsecurity.nethserver.org/stable/8.7.1/targets/x86/64/nethsecurity-8.7.1-x86-64-generic-squashfs-combined-efi.img.gz | diff --git a/deploy/main.tf b/deploy/main.tf new file mode 100644 index 000000000..5de2bbe6a --- /dev/null +++ b/deploy/main.tf @@ -0,0 +1,82 @@ +data "digitalocean_project" "default" { + name = var.project +} + +data "digitalocean_domain" "default" { + name = var.domain +} + +data "digitalocean_ssh_key" "keys" { + for_each = toset(var.sshkeys) + name = each.value +} + +locals { + custom_image_nodes = { for k, n in var.nodes : k => n if n.custom_image != null } +} + +# Imported into the node's own region only -- not shared across nodes, +# even if two nodes happen to use the same region. +resource "digitalocean_custom_image" "this" { + for_each = local.custom_image_nodes + name = "${each.value.custom_image.name}-${each.key}" + url = each.value.custom_image.url + regions = [each.value.region] + distribution = "Unknown OS" +} + +locals { + image = { + for k, n in var.nodes : k => n.custom_image != null ? digitalocean_custom_image.this[k].id : n.image + } +} + +resource "digitalocean_vpc" "private_network" { + for_each = toset([for n in var.nodes : n.region]) + name = "${terraform.workspace}-${each.key}-net" + region = each.key +} + +# Prevent errors during vpc destroy: wait a few seconds after all +# droplets are destroyed before destroying the private networks. +resource "time_sleep" "vpc_destroy_delay" { + depends_on = [digitalocean_vpc.private_network] + destroy_duration = "10s" +} + +resource "digitalocean_droplet" "vps" { + depends_on = [time_sleep.vpc_destroy_delay] + for_each = var.nodes + name = "${each.value.hostname}.${var.domain}" + region = each.value.region + size = each.value.size + image = local.image[each.key] + vpc_uuid = digitalocean_vpc.private_network[each.value.region].id + ssh_keys = [ + for k in var.sshkeys : data.digitalocean_ssh_key.keys[k].id + ] +} + +resource "digitalocean_project_resources" "vps" { + project = data.digitalocean_project.default.id + resources = [for k in keys(var.nodes) : digitalocean_droplet.vps[k].urn] +} + +resource "digitalocean_record" "vps" { + for_each = var.nodes + domain = data.digitalocean_domain.default.name + type = "A" + name = each.value.hostname + value = digitalocean_droplet.vps[each.key].ipv4_address + ttl = 300 +} + +output "droplet_ips" { + description = "Public IPv4 address of each droplet, keyed by node key" + value = { for k, d in digitalocean_droplet.vps : k => d.ipv4_address } +} + +output "fqdns" { + description = "FQDN of each droplet, keyed by node key" + value = { for k, n in var.nodes : k => "${n.hostname}.${var.domain}" } +} diff --git a/deploy/provider.tf b/deploy/provider.tf new file mode 100644 index 000000000..328606f07 --- /dev/null +++ b/deploy/provider.tf @@ -0,0 +1,18 @@ +terraform { + required_providers { + digitalocean = { + source = "digitalocean/digitalocean" + version = "~> 2" + } + } +} + +variable "do_token" { + description = "DigitalOcean API token" + type = string + sensitive = true +} + +provider "digitalocean" { + token = var.do_token +} diff --git a/deploy/tofu.auto.tfvars.example b/deploy/tofu.auto.tfvars.example new file mode 100644 index 000000000..9bdf9c4ed --- /dev/null +++ b/deploy/tofu.auto.tfvars.example @@ -0,0 +1,16 @@ +do_token = "dop_v1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +project = "Aldo" +domain = "al.nethserver.net" +sshkeys = ["yourkey"] + +nodes = { + "ns1" = { + hostname = "ns1" + region = "ams3" + size = "s-1vcpu-2gb" + custom_image = { + name = "nethsecurity-8.7.2" + url = "https://updates.nethsecurity.nethserver.org/stable/8.7.2/targets/x86/64/nethsecurity-8.7.2-x86-64-generic-squashfs-combined-efi.img.gz" + } + } +} diff --git a/deploy/variables.tf b/deploy/variables.tf new file mode 100644 index 000000000..d1e95d06f --- /dev/null +++ b/deploy/variables.tf @@ -0,0 +1,43 @@ +variable "project" { + description = "DigitalOcean project name to attach the droplets to" + type = string +} + +variable "domain" { + description = "DNS domain to associate with the droplets" + type = string +} + +variable "sshkeys" { + description = <<-EOT + DigitalOcean SSH key names (already registered in your account) to + attach to every droplet. Required by the DO API when the image has no + cloud-init/agent support (e.g. a custom OpenWrt/NethSecurity image) -- + DO refuses to create the droplet otherwise, since it has no other way + to hand you access. OpenWrt itself never reads this key (no cloud-init + to inject it); log in with the image's own baked-in credentials. + EOT + type = list(string) + default = [] +} + +variable "nodes" { + description = <<-EOT + Map of node key => droplet spec. size must be 2GB RAM or more. + Set exactly one of `image` / `custom_image`: + - `image`: an existing marketplace/snapshot image slug or ID. + - `custom_image`: import a custom image from a URL into this node's + region only, and use it. + EOT + type = map(object({ + hostname = string + region = string + size = optional(string, "s-1vcpu-2gb") + image = optional(string) + custom_image = optional(object({ + name = string + url = string + })) + })) + default = {} +}