mirror of
https://github.com/coder/registry.git
synced 2026-06-06 14:38:17 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50637980bd | |||
| 5be6a38f23 | |||
| f80f36d674 | |||
| d1d2d5b433 |
@@ -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.
|
||||
|
||||
@@ -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 <workspace>` 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.
|
||||
@@ -0,0 +1,287 @@
|
||||
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/<ver>" 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"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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=<name>`.
|
||||
|
||||
## 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 <workspace>` 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://<host-ip>:8443 --token <paste-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.
|
||||
@@ -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 : ""
|
||||
}
|
||||
@@ -13,14 +13,14 @@ Install and configure the [Codex CLI](https://github.com/openai/codex) in your w
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "5.0.0"
|
||||
version = "5.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
openai_api_key = var.openai_api_key
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> If upgrading from v4.x.x of this module: v5 is a major refactor that drops support for [Coder Tasks](https://coder.com/docs/ai-coder/tasks) and [Boundary](https://coder.com/docs/ai-coder/agent-firewall). v5 also assumes npm is pre-installed; it no longer bootstraps Node.js. Keep using v4.x.x if you depend on them. See the [PR description](https://github.com/coder/registry/pull/879) for a full migration guide.
|
||||
> If upgrading from v4.x.x of this module: v5 is a major refactor that drops support for [Coder Tasks](https://coder.com/docs/ai-coder/tasks) and [Boundary](https://coder.com/docs/ai-coder/agent-firewall). Keep using v4.x.x if you depend on them. See the [PR description](https://github.com/coder/registry/pull/879) for a full migration guide.
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -33,7 +33,7 @@ locals {
|
||||
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "5.0.0"
|
||||
version = "5.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = local.codex_workdir
|
||||
openai_api_key = var.openai_api_key
|
||||
@@ -64,7 +64,7 @@ resource "coder_app" "codex" {
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "5.0.0"
|
||||
version = "5.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_ai_gateway = true
|
||||
@@ -88,7 +88,7 @@ When `enable_ai_gateway = true`, the module configures Codex to use the `aigatew
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "5.0.0"
|
||||
version = "5.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
openai_api_key = var.openai_api_key
|
||||
@@ -117,7 +117,7 @@ The module exposes the `scripts` output: an ordered list of `coder exp sync` nam
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "5.0.0"
|
||||
version = "5.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
openai_api_key = var.openai_api_key
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ describe("codex", async () => {
|
||||
});
|
||||
|
||||
test("install-codex-version", async () => {
|
||||
const version = "0.10.0";
|
||||
const version = "0.134.0";
|
||||
const { id, coderEnvVars, scripts } = await setup({
|
||||
skipCodexMock: true,
|
||||
moduleVariables: {
|
||||
|
||||
@@ -137,7 +137,7 @@ locals {
|
||||
EOF
|
||||
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
|
||||
ARG_INSTALL = tostring(var.install_codex)
|
||||
ARG_CODEX_VERSION = var.codex_version != "" ? base64encode(var.codex_version) : ""
|
||||
ARG_CODEX_VERSION = var.codex_version
|
||||
ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : ""
|
||||
ARG_BASE_CONFIG_TOML = var.base_config_toml != "" ? base64encode(var.base_config_toml) : ""
|
||||
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : ""
|
||||
|
||||
@@ -9,7 +9,7 @@ command_exists() {
|
||||
}
|
||||
|
||||
ARG_INSTALL='${ARG_INSTALL}'
|
||||
ARG_CODEX_VERSION=$(echo -n '${ARG_CODEX_VERSION}' | base64 -d)
|
||||
ARG_CODEX_VERSION='${ARG_CODEX_VERSION}'
|
||||
ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d)
|
||||
ARG_BASE_CONFIG_TOML=$(echo -n '${ARG_BASE_CONFIG_TOML}' | base64 -d)
|
||||
ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d)
|
||||
@@ -51,8 +51,6 @@ function ensure_codex_in_path() {
|
||||
local CODEX_BIN=""
|
||||
if command -v codex > /dev/null 2>&1; then
|
||||
CODEX_BIN=$(command -v codex)
|
||||
elif [ -x "$HOME/.npm-global/bin/codex" ]; then
|
||||
CODEX_BIN="$HOME/.npm-global/bin/codex"
|
||||
fi
|
||||
|
||||
if [ -z "$${CODEX_BIN}" ] || [ ! -x "$${CODEX_BIN}" ]; then
|
||||
@@ -78,35 +76,9 @@ function install_codex() {
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
. "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
|
||||
# Detect a package manager for global installs.
|
||||
if command_exists npm; then
|
||||
PKG_INSTALL="npm install -g"
|
||||
if ! command_exists nvm; then
|
||||
mkdir -p "$HOME/.npm-global"
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
fi
|
||||
elif command_exists pnpm; then
|
||||
PKG_INSTALL="pnpm add -g"
|
||||
elif command_exists bun; then
|
||||
PKG_INSTALL="bun add -g"
|
||||
else
|
||||
echo "Error: npm, pnpm, or bun is required to install Codex. Install one of them first or set install_codex = false."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf "%s Installing Codex CLI\n" "$${BOLD}"
|
||||
|
||||
if [ -n "$${ARG_CODEX_VERSION}" ]; then
|
||||
$PKG_INSTALL "@openai/codex@$${ARG_CODEX_VERSION}"
|
||||
else
|
||||
$PKG_INSTALL "@openai/codex"
|
||||
fi
|
||||
curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_RELEASE="$${ARG_CODEX_VERSION}" CODEX_NON_INTERACTIVE=1 sh
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
printf "%s Installed Codex CLI: %s\n" "$${BOLD}" "$(codex --version)"
|
||||
ensure_codex_in_path
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.4"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "code-server" {
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.4"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
install_version = "4.106.3"
|
||||
}
|
||||
@@ -43,7 +43,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.4"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = [
|
||||
"dracula-theme.theme-dracula"
|
||||
@@ -61,7 +61,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.4"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["dracula-theme.theme-dracula"]
|
||||
settings = {
|
||||
@@ -78,12 +78,26 @@ Install multiple extensions from [OpenVSX](https://open-vsx.org/) by adding them
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.4"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
|
||||
}
|
||||
```
|
||||
|
||||
### Open a Workspace File
|
||||
|
||||
Open a [`.code-workspace`](https://coder.com/docs/code-server/FAQ#how-does-code-server-decide-what-workspace-or-folder-to-open) file instead of a folder. `folder` and `workspace` are mutually exclusive.
|
||||
|
||||
```tf
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
workspace = "/home/coder/project/my.code-workspace"
|
||||
}
|
||||
```
|
||||
|
||||
### Pass Additional Arguments
|
||||
|
||||
You can pass additional command-line arguments to code-server using the `additional_args` variable. For example, to disable workspace trust:
|
||||
@@ -92,7 +106,7 @@ You can pass additional command-line arguments to code-server using the `additio
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.4"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
additional_args = "--disable-workspace-trust"
|
||||
}
|
||||
@@ -108,7 +122,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub:
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.4"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
use_cached = true
|
||||
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
|
||||
@@ -121,7 +135,7 @@ Just run code-server in the background, don't fetch it from GitHub:
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.4"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
offline = true
|
||||
}
|
||||
|
||||
@@ -48,3 +48,55 @@ run "url_with_folder_query" {
|
||||
error_message = "coder_app URL must include encoded folder query param"
|
||||
}
|
||||
}
|
||||
|
||||
run "url_with_workspace_query" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
workspace = "/home/coder/project/my.code-workspace"
|
||||
port = 13337
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_app.code-server.url == "http://localhost:13337/?workspace=%2Fhome%2Fcoder%2Fproject%2Fmy.code-workspace"
|
||||
error_message = "coder_app URL must include encoded workspace query param"
|
||||
}
|
||||
}
|
||||
|
||||
run "url_with_no_target" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
port = 13337
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_app.code-server.url == "http://localhost:13337/"
|
||||
error_message = "coder_app URL must omit query string when neither folder nor workspace is set"
|
||||
}
|
||||
}
|
||||
|
||||
run "folder_and_workspace_conflict" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder/project"
|
||||
workspace = "/home/coder/project/my.code-workspace"
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.workspace
|
||||
]
|
||||
}
|
||||
|
||||
run "workspace_extension_rejected" {
|
||||
command = plan
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
workspace = "/home/coder/project/settings.json"
|
||||
}
|
||||
expect_failures = [var.workspace]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
required_version = ">= 1.9"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
@@ -56,6 +56,19 @@ variable "folder" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "workspace" {
|
||||
type = string
|
||||
description = "The path to a `.code-workspace` file to open in code-server. Mutually exclusive with `folder`."
|
||||
default = ""
|
||||
validation {
|
||||
condition = var.workspace == "" || endswith(var.workspace, ".code-workspace")
|
||||
error_message = "workspace must be a path to a .code-workspace file"
|
||||
}
|
||||
validation {
|
||||
condition = var.folder == "" || var.workspace == ""
|
||||
error_message = "folder and workspace are mutually exclusive; set at most one"
|
||||
}
|
||||
}
|
||||
variable "install_prefix" {
|
||||
type = string
|
||||
description = "The prefix to install code-server to."
|
||||
@@ -173,6 +186,7 @@ resource "coder_script" "code-server" {
|
||||
USE_CACHED_EXTENSIONS : var.use_cached_extensions,
|
||||
EXTENSIONS_DIR : var.extensions_dir,
|
||||
FOLDER : var.folder,
|
||||
WORKSPACE : var.workspace,
|
||||
AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
|
||||
ADDITIONAL_ARGS : var.additional_args,
|
||||
})
|
||||
@@ -195,7 +209,7 @@ resource "coder_app" "code-server" {
|
||||
agent_id = var.agent_id
|
||||
slug = var.slug
|
||||
display_name = var.display_name
|
||||
url = "http://localhost:${var.port}/${var.folder != "" ? "?folder=${urlencode(var.folder)}" : ""}"
|
||||
url = "http://localhost:${var.port}/${var.folder != "" ? "?folder=${urlencode(var.folder)}" : var.workspace != "" ? "?workspace=${urlencode(var.workspace)}" : ""}"
|
||||
icon = "/icon/code.svg"
|
||||
subdomain = var.subdomain
|
||||
share = var.share
|
||||
|
||||
@@ -122,15 +122,29 @@ if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WORKSPACE_DIR="$HOME"
|
||||
if [ -n "${FOLDER}" ]; then
|
||||
WORKSPACE_DIR="${FOLDER}"
|
||||
RECOMMENDATIONS_FILE=""
|
||||
RECOMMENDATIONS_QUERY=".recommendations[]"
|
||||
if [ -n "${WORKSPACE}" ]; then
|
||||
if [ -f "${WORKSPACE}" ]; then
|
||||
RECOMMENDATIONS_FILE="${WORKSPACE}"
|
||||
RECOMMENDATIONS_QUERY=".extensions.recommendations[]?"
|
||||
else
|
||||
echo "⚠️ Workspace file ${WORKSPACE} not found, skipping extension recommendations."
|
||||
fi
|
||||
else
|
||||
WORKSPACE_DIR="$HOME"
|
||||
if [ -n "${FOLDER}" ]; then
|
||||
WORKSPACE_DIR="${FOLDER}"
|
||||
fi
|
||||
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
|
||||
RECOMMENDATIONS_FILE="$WORKSPACE_DIR/.vscode/extensions.json"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
|
||||
printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
|
||||
if [ -n "$RECOMMENDATIONS_FILE" ]; then
|
||||
printf "🧩 Installing extensions from %s...\n" "$RECOMMENDATIONS_FILE"
|
||||
# Use sed to remove single-line comments before parsing with jq
|
||||
extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR"/.vscode/extensions.json | jq -r '.recommendations[]')
|
||||
extensions=$(sed 's|//.*||g' "$RECOMMENDATIONS_FILE" | jq -r "$RECOMMENDATIONS_QUERY")
|
||||
for extension in $extensions; do
|
||||
if extension_installed "$extension"; then
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user