diff --git a/registry/bpmct/README.md b/registry/bpmct/README.md index a19a70c07..a7af289bf 100644 --- a/registry/bpmct/README.md +++ b/registry/bpmct/README.md @@ -8,4 +8,4 @@ status: community # Ben Potter -Tinkerer and Product Manager at Coder. Building modules to make dev environments better. +Tinkerer and Product Manager at Coder. diff --git a/registry/bpmct/templates/incus-nixos/README.md b/registry/bpmct/templates/incus-nixos/README.md new file mode 100644 index 000000000..648b20a08 --- /dev/null +++ b/registry/bpmct/templates/incus-nixos/README.md @@ -0,0 +1,75 @@ +--- +display_name: Incus NixOS VM +description: Run a NixOS virtual machine on a local Incus host +icon: ../../../../.icons/lxc.svg +verified: false +tags: [local, incus, vm, nixos] +--- + +# Incus NixOS VM + +Provision a NixOS KVM virtual machine on an [Incus](https://linuxcontainers.org/incus/) host. The image is pulled from [images.linuxcontainers.org](https://images.linuxcontainers.org) and cached on the host. + +NixOS does not support cloud-init. This template uses `nixos-rebuild switch` via `incus exec` to configure the workspace user and start the Coder agent. The rebuild only runs on first boot; subsequent starts rotate the agent token and restart the service directly. + +## Prerequisites + +### 1. Install Incus on the VM host + +Follow the [Incus installation guide](https://linuxcontainers.org/incus/docs/main/installing/) for your distro. On Debian/Ubuntu: + +```sh +sudo apt-get install incus +sudo incus admin init +``` + +### 2. Run the Coder provisioner on the same machine + +This template uses the local Incus socket, so the Coder provisioner must run on the same machine as Incus. See [provisioner daemons](https://coder.com/docs/admin/provisioners). + +### 3. Ensure the host has KVM + +```sh +ls /dev/kvm +``` + +If the device is missing, enable virtualisation in your BIOS/UEFI or, in a nested setup, pass through the `kvm` module. + +### 4. Create a storage pool (if needed) + +```sh +incus storage create default btrfs +incus storage list +``` + +### 5. Push the template + +```sh +# amd64 host: +coder templates push incus-nixos --directory . --variable arch=amd64 + +# arm64 host: +coder templates push incus-nixos --directory . --variable arch=arm64 +``` + +The `storage_pool` variable defaults to `default`. Override if needed: + +```sh +coder templates push incus-nixos --directory . \ + --variable arch=arm64 \ + --variable storage_pool=fast-nvme +``` + +The `nixos_channel` variable controls which NixOS channel is used for `nixos-rebuild`. It must match the image version (default: `nixos-25.11`). + +## Usage + +1. Create a workspace from this template and choose CPU, memory, and disk. +2. Connect via `coder ssh ` or use VS Code in the browser via the [VS Code extension](https://coder.com/docs/user-guides/workspace-access/vscode). +3. Install packages declaratively by editing `/etc/nixos/coder.nix` and running `sudo nixos-rebuild switch`. + +## Notes + +- `code-server` is not installed by this template. The Coder agent is started as a plain systemd service. Install editors via nix packages in `coder.nix`. +- The first workspace start takes several minutes while `nixos-rebuild switch` runs. Subsequent starts are fast. +- Advanced Incus remotes (targeting a separate host over the network) are not supported by this template. See the `incus-vm` template for guidance on adding remote support. diff --git a/registry/bpmct/templates/incus-nixos/main.tf b/registry/bpmct/templates/incus-nixos/main.tf new file mode 100644 index 000000000..ed09b68d7 --- /dev/null +++ b/registry/bpmct/templates/incus-nixos/main.tf @@ -0,0 +1,311 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.4.0" + } + incus = { + source = "lxc/incus" + version = "~> 1.0" + } + null = { + source = "hashicorp/null" + version = "~> 3.0" + } + } +} + +provider "incus" {} + +variable "arch" { + description = "CPU architecture of the VM host. Set this when pushing the template to match your Incus host. Valid values: amd64, arm64." + type = string + default = "amd64" + validation { + condition = contains(["amd64", "arm64"], var.arch) + error_message = "arch must be amd64 or arm64." + } +} + +variable "storage_pool" { + description = "Incus storage pool for the root disk. Run `incus storage list` on the host to see available pools." + type = string + default = "default" +} + +variable "nixos_channel" { + description = "NixOS channel to use for nixos-rebuild. Must match the image version (e.g. nixos-25.11)." + type = string + default = "nixos-25.11" +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "cpu" { + name = "cpu" + display_name = "CPU" + description = "Number of vCPUs." + type = "number" + default = 2 + icon = "https://raw.githubusercontent.com/matifali/logos/main/cpu-3.svg" + mutable = true + order = 1 + validation { + min = 1 + max = 16 + } +} + +data "coder_parameter" "memory" { + name = "memory" + display_name = "Memory (GB)" + type = "number" + default = 4 + icon = "/icon/memory.svg" + mutable = true + order = 2 + validation { + min = 1 + max = 64 + } +} + +data "coder_parameter" "disk" { + name = "disk" + display_name = "Disk (GB)" + type = "number" + default = 30 + icon = "/icon/database.svg" + mutable = true + order = 3 + validation { + min = 10 + max = 500 + } +} + +locals { + workspace_user = lower(data.coder_workspace_owner.me.name) + agent_token = data.coder_workspace.me.start_count == 1 ? coder_agent.main[0].token : "" + agent_init_script = data.coder_workspace.me.start_count == 1 ? coder_agent.main[0].init_script : "" + + # NixOS images on images.linuxcontainers.org use "nixos/" with no arch suffix. + # The channel version (e.g. "25.11") is extracted from var.nixos_channel. + nixos_version = replace(var.nixos_channel, "nixos-", "") + image_alias = "nixos/${local.nixos_version}" + + # PATH required for incus exec commands on NixOS VMs. The Nix store is not + # on the default system PATH until after the first nixos-rebuild switch. + nix_path = "/nix/var/nix/profiles/system/sw/bin:/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin:/run/wrappers/bin" +} + +resource "coder_agent" "main" { + count = data.coder_workspace.me.start_count + arch = var.arch + os = "linux" + + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Disk" + key = "2_disk" + script = "coder stat disk --path /" + interval = 60 + timeout = 1 + } +} + +resource "incus_image" "nixos" { + source_image = { + remote = "images" + name = local.image_alias + type = "virtual-machine" + architecture = var.arch == "amd64" ? "x86_64" : "aarch64" + } +} + +resource "incus_instance" "dev" { + running = data.coder_workspace.me.start_count == 1 + name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}" + image = incus_image.nixos.fingerprint + type = "virtual-machine" + + config = { + "limits.cpu" = tostring(data.coder_parameter.cpu.value) + "limits.memory" = "${data.coder_parameter.memory.value}GiB" + "security.secureboot" = false + "boot.autostart" = data.coder_workspace.me.start_count == 1 + "user.coder-agent-token" = local.agent_token + } + + device { + name = "root" + type = "disk" + properties = { + path = "/" + pool = var.storage_pool + size = "${data.coder_parameter.disk.value}GiB" + } + } + + lifecycle { + ignore_changes = [ + config["user.coder-agent-token"], + image, + ] + } +} + +# NixOS does not support cloud-init. Provisioning steps: +# 1. Wait for the incus-agent to be ready (virtio serial channel). +# 2. Push the Coder agent binary (/opt/coder/init) and token env file. +# 3. On first boot: write coder.nix and an updated configuration.nix +# that imports the Incus VM module, then run nixos-rebuild switch. +# Leave a marker so subsequent starts skip the rebuild. +# 4. On subsequent starts: overwrite init.env + restart coder-agent. + +resource "null_resource" "provision" { + count = data.coder_workspace.me.start_count + + triggers = { + agent_token = local.agent_token + instance = incus_instance.dev.name + } + + depends_on = [incus_instance.dev] + + provisioner "local-exec" { + command = <<-EOT + set -e + INSTANCE="${incus_instance.dev.name}" + WUSER="${local.workspace_user}" + NIX_PATH="${local.nix_path}" + CHANNEL="${var.nixos_channel}" + + echo "Waiting for incus-agent..." + for i in $(seq 1 60); do + incus exec "$INSTANCE" -- true 2>/dev/null && break + echo " attempt $i/60..." + sleep 5 + done + + echo "Pushing Coder agent binary..." + TMPDIR=$(mktemp -d) + echo "${base64encode(local.agent_init_script)}" | base64 -d > "$TMPDIR/init" + chmod 755 "$TMPDIR/init" + incus exec "$INSTANCE" -- env PATH="$NIX_PATH" mkdir -p /opt/coder + incus file push "$TMPDIR/init" "$INSTANCE/opt/coder/init" + incus exec "$INSTANCE" -- env PATH="$NIX_PATH" chmod 755 /opt/coder/init + rm -rf "$TMPDIR" + + printf 'CODER_AGENT_TOKEN=${local.agent_token}\nCODER_AGENT_URL=${data.coder_workspace.me.access_url}\n' \ + | incus file push - "$INSTANCE/opt/coder/init.env" --mode 0600 + + # Fast path: already provisioned -- just rotate token and restart. + if incus exec "$INSTANCE" -- test -f /etc/nixos/.coder-provisioned 2>/dev/null; then + echo "Already provisioned; restarting coder-agent..." + incus exec "$INSTANCE" -- env PATH="$NIX_PATH" systemctl restart coder-agent.service + echo "Done." + exit 0 + fi + + # First boot: write NixOS config and rebuild. + echo "Writing /etc/nixos/coder.nix..." + cat <<'NIXEOF' | incus exec "$INSTANCE" -- env PATH="$NIX_PATH" bash -c 'cat > /etc/nixos/coder.nix' +{ config, pkgs, lib, ... }: +{ + users.users."${local.workspace_user}" = { + isNormalUser = true; + uid = 1000; + home = "/home/${local.workspace_user}"; + shell = pkgs.bash; + extraGroups = [ "wheel" ]; + }; + security.sudo.wheelNeedsPassword = false; + nix.settings.trusted-users = [ "root" "${local.workspace_user}" ]; + + systemd.services.coder-agent = { + description = "Coder Agent"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + User = "${local.workspace_user}"; + EnvironmentFile = "/opt/coder/init.env"; + ExecStart = "/opt/coder/init"; + Environment = "PATH=/run/current-system/sw/bin:/run/wrappers/bin:/usr/local/bin:/usr/bin:/bin"; + Restart = "always"; + RestartSec = 10; + TimeoutStopSec = 90; + KillMode = "process"; + OOMScoreAdjust = -900; + SyslogIdentifier = "coder-agent"; + }; + }; +} +NIXEOF + + echo "Writing /etc/nixos/configuration.nix..." + cat <<'NIXEOF' | incus exec "$INSTANCE" -- env PATH="$NIX_PATH" bash -c 'cat > /etc/nixos/configuration.nix' +{ modulesPath, ... }: +{ + imports = [ + "$${modulesPath}/virtualisation/incus-virtual-machine.nix" + ./incus.nix + ./coder.nix + ]; + + systemd.network = { + enable = true; + networks."50-enp5s0" = { + matchConfig.Name = "enp5s0"; + networkConfig = { + DHCP = "ipv4"; + IPv6AcceptRA = true; + }; + linkConfig.RequiredForOnline = "routable"; + }; + }; + + system.stateVersion = "${local.nixos_version}"; +} +NIXEOF + + echo "Restoring nixos channel if needed..." + incus exec "$INSTANCE" -- env PATH="$NIX_PATH" HOME=/root bash -c " + if [ ! -e /nix/var/nix/profiles/per-user/root/channels/nixos ]; then + nix-channel --add https://channels.nixos.org/$CHANNEL nixos + nix-channel --update nixos + fi + " + + echo "Running nixos-rebuild switch..." + incus exec "$INSTANCE" -- env PATH="$NIX_PATH" HOME=/root bash -c " + NIXOS_CH=\$(ls -d /nix/var/nix/profiles/per-user/root/channels/nixos 2>/dev/null || echo '') + nixos-rebuild switch -I nixpkgs=\"\$NIXOS_CH\" -I nixos-config=/etc/nixos/configuration.nix \ + || { EC=\$?; [ \$EC -eq 4 ] || exit \$EC; } + " + + incus exec "$INSTANCE" -- env PATH="$NIX_PATH" touch /etc/nixos/.coder-provisioned + incus exec "$INSTANCE" -- env PATH="$NIX_PATH" bash -c \ + "mkdir -p /home/$WUSER && chown 1000:1000 /home/$WUSER" + + echo "NixOS provisioning complete." + EOT + } +} diff --git a/registry/bpmct/templates/incus-vm/README.md b/registry/bpmct/templates/incus-vm/README.md new file mode 100644 index 000000000..db41328ce --- /dev/null +++ b/registry/bpmct/templates/incus-vm/README.md @@ -0,0 +1,96 @@ +--- +display_name: Incus VM +description: Run a full virtual machine on a local Incus host +icon: ../../../../.icons/lxc.svg +verified: false +tags: [local, incus, vm, virtual-machine] +--- + +# Incus VM + +Provision a full KVM virtual machine on an [Incus](https://linuxcontainers.org/incus/) host. Unlike the system container template, this creates an isolated VM with its own kernel. Images are pulled from [images.linuxcontainers.org](https://images.linuxcontainers.org) and cached on the host. + +## Prerequisites + +### 1. Install Incus on the VM host + +Follow the [Incus installation guide](https://linuxcontainers.org/incus/docs/main/installing/) for your distro. On Debian/Ubuntu: + +```sh +sudo apt-get install incus +sudo incus admin init +``` + +Verify it's working: + +```sh +incus list +``` + +### 2. Run the Coder provisioner on the same machine + +This template uses Incus via the local Unix socket, so the Coder provisioner (or `coderd` itself) must run on the same machine as Incus. The simplest setup is a [provisioner daemon](https://coder.com/docs/admin/provisioners) running directly on the Incus host. + +### 3. Set the architecture when pushing the template + +The `arch` variable must match your Incus host's CPU architecture. Pass it when pushing: + +```sh +# For amd64 (x86-64) hosts: +coder templates push incus-vm --directory . --variable arch=amd64 + +# For arm64 (aarch64) hosts: +coder templates push incus-vm --directory . --variable arch=arm64 +``` + +### 4. Ensure the VM host has KVM + +VMs require hardware virtualisation. Check on the host: + +```sh +ls /dev/kvm +``` + +If the device is missing, enable virtualisation in your BIOS/UEFI or, in a nested setup, pass through the `kvm` module. + +### 5. Create a storage pool (if needed) + +The template uses an Incus storage pool to back the VM root disk. If you don't already have one: + +```sh +incus storage create default btrfs +``` + +List existing pools: + +```sh +incus storage list +``` + +The pool name defaults to `default` and can be overridden when pushing the template with `--variable storage_pool=`. + +## Usage + +1. Push this template to your Coder deployment: + + ```sh + coder templates push incus-vm --directory . --variable arch=amd64 + ``` + +2. Create a workspace and select an image and resource sizes. + +3. Connect via `coder ssh ` or open VS Code in the browser. + +## Advanced: using a remote Incus host + +By default this template connects to the local Incus socket. If you want the provisioner to target a separate Incus host over the network, add a `remote` parameter and use `incus remote add` to register the host on the provisioner machine: + +```sh +# On the Incus host — generate a trust token: +incus config trust add coder-provisioner + +# On the provisioner — add the remote: +incus remote add my-server https://:8443 --token +``` + +Then update `main.tf` to accept a `remote` parameter and pass it to the `incus_image` and `incus_instance` resources. See the [Incus remote docs](https://linuxcontainers.org/incus/docs/main/remotes/) for details. diff --git a/registry/bpmct/templates/incus-vm/main.tf b/registry/bpmct/templates/incus-vm/main.tf new file mode 100644 index 000000000..cbaf74c62 --- /dev/null +++ b/registry/bpmct/templates/incus-vm/main.tf @@ -0,0 +1,304 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.4.0" + } + incus = { + source = "lxc/incus" + version = "~> 1.0" + } + null = { + source = "hashicorp/null" + version = "~> 3.0" + } + } +} + +provider "incus" {} + +variable "arch" { + description = "CPU architecture of the VM host. Set this when pushing the template to match your Incus host. Valid values: amd64, arm64." + type = string + default = "amd64" + validation { + condition = contains(["amd64", "arm64"], var.arch) + error_message = "arch must be amd64 or arm64." + } +} + +variable "storage_pool" { + description = "Incus storage pool for the root disk. Run `incus storage list` on the host to see available pools." + type = string + default = "default" +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "image" { + name = "image" + display_name = "Image" + description = "Base image name from images.linuxcontainers.org (e.g. `ubuntu/noble/cloud`). The template architecture is appended automatically." + type = "string" + default = "ubuntu/noble/cloud" + icon = "/icon/image.svg" + mutable = true + order = 1 + + option { + name = "Ubuntu 24.04 LTS (Noble)" + value = "ubuntu/noble/cloud" + icon = "/icon/ubuntu.svg" + } + + option { + name = "Ubuntu 22.04 LTS (Jammy)" + value = "ubuntu/jammy/cloud" + icon = "/icon/ubuntu.svg" + } + + option { + name = "Debian 12 (Bookworm)" + value = "debian/12/cloud" + icon = "/icon/debian.svg" + } +} + +data "coder_parameter" "cpu" { + name = "cpu" + display_name = "CPU" + description = "Number of vCPUs." + type = "number" + default = 2 + icon = "https://raw.githubusercontent.com/matifali/logos/main/cpu-3.svg" + mutable = true + order = 2 + validation { + min = 1 + max = 16 + } +} + +data "coder_parameter" "memory" { + name = "memory" + display_name = "Memory (GB)" + type = "number" + default = 4 + icon = "/icon/memory.svg" + mutable = true + order = 3 + validation { + min = 1 + max = 64 + } +} + +data "coder_parameter" "disk" { + name = "disk" + display_name = "Disk (GB)" + type = "number" + default = 30 + icon = "/icon/database.svg" + mutable = true + order = 4 + validation { + min = 10 + max = 500 + } +} + +resource "coder_agent" "main" { + count = data.coder_workspace.me.start_count + arch = var.arch + os = "linux" + + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Disk" + key = "2_disk" + script = "coder stat disk --path /" + interval = 60 + timeout = 1 + } +} + +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/code-server/coder" + version = "~> 1.0" + agent_id = coder_agent.main[0].id +} + +resource "incus_image" "image" { + source_image = { + remote = "images" + name = "${data.coder_parameter.image.value}/${var.arch}" + type = "virtual-machine" + architecture = var.arch == "amd64" ? "x86_64" : "aarch64" + } +} + +resource "incus_instance" "dev" { + running = data.coder_workspace.me.start_count == 1 + name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}" + image = incus_image.image.fingerprint + type = "virtual-machine" + + config = { + "limits.cpu" = tostring(data.coder_parameter.cpu.value) + "limits.memory" = "${data.coder_parameter.memory.value}GiB" + "security.secureboot" = false + "boot.autostart" = data.coder_workspace.me.start_count == 1 + "user.coder-agent-token" = local.agent_token + + "cloud-init.user-data" = <<-EOF + #cloud-config + hostname: ${lower(data.coder_workspace.me.name)} + users: + - name: ${local.workspace_user} + uid: 1000 + groups: sudo + shell: /bin/bash + sudo: ALL=(ALL) NOPASSWD:ALL + write_files: + - path: /opt/coder/init + permissions: "0755" + encoding: b64 + content: ${base64encode(local.agent_init_script)} + - path: /opt/coder/init.env + permissions: "0600" + content: | + CODER_AGENT_TOKEN=${local.agent_token} + CODER_AGENT_URL=${data.coder_workspace.me.access_url} + - path: /etc/systemd/system/coder-agent.service + permissions: "0644" + content: | + [Unit] + Description=Coder Agent + After=network-online.target + Wants=network-online.target + [Service] + User=${local.workspace_user} + EnvironmentFile=/opt/coder/init.env + ExecStart=/opt/coder/init + Restart=always + RestartSec=10 + TimeoutStopSec=90 + KillMode=process + OOMScoreAdjust=-900 + SyslogIdentifier=coder-agent + [Install] + WantedBy=multi-user.target + runcmd: + - systemctl enable --now coder-agent.service + EOF + } + + device { + name = "root" + type = "disk" + properties = { + path = "/" + pool = var.storage_pool + size = "${data.coder_parameter.disk.value}GiB" + } + } + + lifecycle { + ignore_changes = [ + config["cloud-init.user-data"], + config["user.coder-agent-token"], + image, + ] + } +} + +resource "null_resource" "token_refresh" { + count = data.coder_workspace.me.start_count + + triggers = { + agent_token = local.agent_token + instance = incus_instance.dev.name + } + + depends_on = [incus_instance.dev] + + provisioner "local-exec" { + command = <<-EOT + INSTANCE="${incus_instance.dev.name}" + echo "Waiting for VM agent..." + for i in $(seq 1 40); do + incus exec "$INSTANCE" -- true 2>/dev/null && break + echo "Attempt $i: not ready, waiting..." + sleep 5 + done + echo "Waiting for cloud-init..." + incus exec "$INSTANCE" -- bash -c ' + for i in $(seq 1 60); do + [ -f /var/lib/cloud/instance/boot-finished ] && break + sleep 5 + done + ' + echo "Refreshing agent token..." + printf 'CODER_AGENT_TOKEN=${local.agent_token}\nCODER_AGENT_URL=${data.coder_workspace.me.access_url}\n' \ + | incus exec "$INSTANCE" -- bash -c 'cat > /opt/coder/init.env && chmod 600 /opt/coder/init.env' + incus exec "$INSTANCE" -- systemctl restart coder-agent.service + EOT + } +} + +resource "coder_metadata" "info" { + count = data.coder_workspace.me.start_count + resource_id = incus_instance.dev.name + + item { + key = "instance" + value = incus_instance.dev.name + } + item { + key = "image" + value = "images:${data.coder_parameter.image.value}/${var.arch}" + } + item { + key = "storage_pool" + value = var.storage_pool + } + item { + key = "arch" + value = var.arch + } + item { + key = "cpu" + value = tostring(data.coder_parameter.cpu.value) + } + item { + key = "memory" + value = "${data.coder_parameter.memory.value} GiB" + } + item { + key = "disk" + value = "${data.coder_parameter.disk.value} GiB" + } +} + +locals { + workspace_user = lower(data.coder_workspace_owner.me.name) + agent_token = data.coder_workspace.me.start_count == 1 ? coder_agent.main[0].token : "" + agent_init_script = data.coder_workspace.me.start_count == 1 ? coder_agent.main[0].init_script : "" +}