Skip to content

feat(examples/templates/gcp-devcontainer): add envbuilder provider #14405

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Aug 23, 2024
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 216 additions & 78 deletions examples/templates/gcp-devcontainer/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,49 @@ terraform {
google = {
source = "hashicorp/google"
}
envbuilder = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious: will we support the original form without envbuilder? if so, should we add a new example like gcp-devcontainer?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The envbuilder provider only gets used if cache_repo is set.

source = "coder/envbuilder"
}
}
}

provider "coder" {
}

provider "google" {
zone = data.coder_parameter.zone.value
project = var.project_id
}

data "google_compute_default_service_account" "default" {}

data "coder_workspace" "me" {
}
data "coder_workspace_owner" "me" {}

variable "project_id" {
description = "Which Google Compute Project should your workspace live in?"
}

variable "cache_repo" {
default = ""
description = "(Optional) Use a container registry as a cache to speed up builds."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

People may be struggling whether cache_repo is a path, URI, endpoint, etc.

type = string
}

variable "insecure_cache_repo" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changeset is relatively big, so is the .tf file. Maybe wipe out this variable?

default = false
description = "Enable this option if your cache registry does not serve HTTPS."
type = bool
}

variable "cache_repo_docker_config_path" {
default = ""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any standard path we can suggest here or mention in the description?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll mention ~/.docker/config.json but it'll depend heavily on their setup.

description = "(Optional) Path to a docker config.json containing credentials to the provided cache repo, if required."
sensitive = true
type = string
}

data "coder_parameter" "zone" {
name = "zone"
display_name = "Zone"
Expand All @@ -24,6 +57,7 @@ data "coder_parameter" "zone" {
icon = "/emojis/1f30e.png"
default = "us-central1-a"
mutable = false
order = 1
option {
name = "North America (Northeast)"
value = "northamerica-northeast1-a"
Expand Down Expand Up @@ -51,25 +85,48 @@ data "coder_parameter" "zone" {
}
}

provider "google" {
zone = data.coder_parameter.zone.value
project = var.project_id
}

data "google_compute_default_service_account" "default" {
data "coder_parameter" "instance_type" {
name = "instance_type"
display_name = "Instance Type"
description = "Select an instance type for your workspace."
type = "string"
mutable = false
order = 2
default = "e2-micro"
option {
name = "e2-micro (2C, 1G)"
value = "e2-micro"
}
option {
name = "e2-small (2C, 2G)"
value = "e2-small"
}
option {
name = "e2-medium (2C, 2G)"
value = "e2-medium"
}
}

data "coder_workspace" "me" {
data "coder_parameter" "fallback_image" {
default = "codercom/enterprise-base:ubuntu"
description = "This image runs if the devcontainer fails to build."
display_name = "Fallback Image"
mutable = true
name = "fallback_image"
order = 3
}
data "coder_workspace_owner" "me" {}

resource "google_compute_disk" "root" {
name = "coder-${data.coder_workspace.me.id}-root"
type = "pd-ssd"
image = "debian-cloud/debian-12"
lifecycle {
ignore_changes = [name, image]
}
data "coder_parameter" "devcontainer_builder" {
description = <<-EOF
Image that will build the devcontainer.
We highly recommend using a specific release as the `:latest` tag will change.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe rephrase it to indicate it is dangerous? For example: Do not use envbuilder:latest tag due to the risk of build instability.

Find the latest version of Envbuilder here: https://github.com/coder/envbuilder/pkgs/container/envbuilder
EOF
display_name = "Devcontainer Builder"
mutable = true
name = "devcontainer_builder"
default = "ghcr.io/coder/envbuilder:latest"
order = 4
}

data "coder_parameter" "repo_url" {
Expand All @@ -80,46 +137,122 @@ data "coder_parameter" "repo_url" {
mutable = true
}

resource "coder_agent" "dev" {
count = data.coder_workspace.me.start_count
arch = "amd64"
auth = "token"
os = "linux"
dir = "/workspaces/${trimsuffix(basename(data.coder_parameter.repo_url.value), ".git")}"
connection_timeout = 0
data "local_sensitive_file" "cache_repo_dockerconfigjson" {
count = var.cache_repo_docker_config_path == "" ? 0 : 1
filename = var.cache_repo_docker_config_path
}

metadata {
key = "cpu"
display_name = "CPU Usage"
interval = 5
timeout = 5
script = "coder stat cpu"
}
metadata {
key = "memory"
display_name = "Memory Usage"
interval = 5
timeout = 5
script = "coder stat mem"

locals {
# Ensure Coder username is a valid Linux username
linux_user = lower(substr(data.coder_workspace_owner.me.name, 0, 32))
# Name the container after the workspace and owner.
container_name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
# The devcontainer builder image is the image that will build the devcontainer.
devcontainer_builder_image = data.coder_parameter.devcontainer_builder.value
# We may need to authenticate with a registry. If so, the user will provide a path to a docker config.json.
docker_config_json_base64 = try(data.local_sensitive_file.cache_repo_dockerconfigjson[0].content_base64, "")
# The envbuilder provider requires a key-value map of environment variables. Build this here.
envbuilder_env = {
# ENVBUILDER_GIT_URL and ENVBUILDER_CACHE_REPO will be overridden by the provider
# if the cache repo is enabled.
"ENVBUILDER_GIT_URL" : data.coder_parameter.repo_url.value,
# The agent token is required for the agent to connect to the Coder platform.
"CODER_AGENT_TOKEN" : try(coder_agent.dev.0.token, ""),
# The agent URL is required for the agent to connect to the Coder platform.
"CODER_AGENT_URL" : data.coder_workspace.me.access_url,
# The agent init script is required for the agent to start up. We base64 encode it here
# to avoid quoting issues.
"ENVBUILDER_INIT_SCRIPT" : "echo ${base64encode(try(coder_agent.dev[0].init_script, ""))} | base64 -d | sh",
"ENVBUILDER_DOCKER_CONFIG_BASE64" : try(data.local_sensitive_file.cache_repo_dockerconfigjson[0].content_base64, ""),
# The fallback image is the image that will run if the devcontainer fails to build.
"ENVBUILDER_FALLBACK_IMAGE" : data.coder_parameter.fallback_image.value,
# The following are used to push the image to the cache repo, if defined.
"ENVBUILDER_CACHE_REPO" : var.cache_repo,
"ENVBUILDER_PUSH_IMAGE" : var.cache_repo == "" ? "" : "true",
"ENVBUILDER_INSECURE" : "${var.insecure_cache_repo}",
}
metadata {
key = "disk"
display_name = "Disk Usage"
interval = 5
timeout = 5
script = "coder stat disk"
# If we have a cached image, use the cached image's environment variables. Otherwise, just use
# the environment variables we've defined above.
docker_env_input = try(envbuilder_cached_image.cached.0.env_map, local.envbuilder_env)
# Convert the above to the list of arguments for the Docker run command. This is going to end
# up in our startup script metadata. These are all terminated by backslashes.
docker_env_arg_list = [for k, v in local.docker_env_input : " -e \"${k}=${v}\" \\"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should user modify these locals? If not, maybe indicate that in a comment.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah probably not :D

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conversion might have trouble with some inputs, writing to an env file and referencing that is an alternative as it doesn't require quoting. Newline inputs could still spell trouble though.

Copy link
Member Author

@johnstcn johnstcn Aug 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like this?

printf %s "<base64-encoded string>" | base64 -d > env.list


# The GCP VM needs a startup script to set up the environment and start the container. Defining this here.
# NOTE: make sure to test changes by uncommenting the local_file resource at the bottom of this file
# and running `terraform apply` to see the generated script. You should also run shellcheck on the script
# to ensure it is valid.
startup_script = <<-META
#!/usr/bin/env sh
set -eux

# If user does not exist, create it and set up passwordless sudo
if ! id -u "${local.linux_user}" >/dev/null 2>&1; then
useradd -m -s /bin/bash "${local.linux_user}"
echo "${local.linux_user} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/coder-user
fi

# Check for Docker, install if not present
if ! command -v docker >/dev/null 2>&1; then
echo "Docker not found, installing..."
curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh >/dev/null 2>&1
sudo usermod -aG docker ${local.linux_user}
newgrp docker
else
echo "Docker is already installed."
fi

# Write the Docker config JSON to disk if it is provided.
if [ -n "${local.docker_config_json_base64}" ]; then
mkdir -p "/home/${local.linux_user}/.docker"
printf "%s" "${local.docker_config_json_base64}" | base64 -d | tee "/home/${local.linux_user}/.docker/config.json"
chown -R ${local.linux_user}:${local.linux_user} "/home/${local.linux_user}/.docker"
fi

# Start envbuilder.
docker run \
--rm \
--net=host \
-h ${lower(data.coder_workspace.me.name)} \
-v /home/${local.linux_user}/envbuilder:/workspaces \
-v /var/run/docker.sock:/var/run/docker.sock \
${join("\n", local.docker_env_arg_list)}
${data.coder_parameter.devcontainer_builder.value}
META
}

# Create a persistent to store the workspace data.
resource "google_compute_disk" "root" {
name = "coder-${data.coder_workspace.me.id}-root"
type = "pd-ssd"
image = "debian-cloud/debian-12"
lifecycle {
ignore_changes = all
}
}

module "code-server" {
count = data.coder_workspace.me.start_count
source = "https://registry.coder.com/modules/code-server"
agent_id = coder_agent.dev[0].id
# Check for the presence of a prebuilt image in the cache repo
# that we can use instead.
resource "envbuilder_cached_image" "cached" {
count = var.cache_repo == "" ? 0 : data.coder_workspace.me.start_count
builder_image = local.devcontainer_builder_image
git_url = data.coder_parameter.repo_url.value
cache_repo = var.cache_repo
extra_env = local.envbuilder_env
insecure = var.insecure_cache_repo
}

# This is useful for debugging the startup script. Left here for reference.
# resource local_file "startup_script" {
# content = local.startup_script
# filename = "${path.module}/startup_script.sh"
# }

# Create a VM where the workspace will run.
resource "google_compute_instance" "vm" {
name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root"
machine_type = "e2-medium"
machine_type = data.coder_parameter.instance_type.value
# data.coder_workspace_owner.me.name == "default" is a workaround to suppress error in the terraform plan phase while creating a new workspace.
desired_status = (data.coder_workspace_owner.me.name == "default" || data.coder_workspace.me.start_count == 1) ? "RUNNING" : "TERMINATED"

Expand All @@ -144,45 +277,50 @@ resource "google_compute_instance" "vm" {
# The startup script runs as root with no $HOME environment set up, so instead of directly
# running the agent init script, create a user (with a homedir, default shell and sudo
# permissions) and execute the init script as that user.
startup-script = <<-META
#!/usr/bin/env sh
set -eux
startup-script = local.startup_script
}
}

# If user does not exist, create it and set up passwordless sudo
if ! id -u "${local.linux_user}" >/dev/null 2>&1; then
useradd -m -s /bin/bash "${local.linux_user}"
echo "${local.linux_user} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/coder-user
fi
# Create a Coder agent to manage the workspace.
resource "coder_agent" "dev" {
count = data.coder_workspace.me.start_count
arch = "amd64"
auth = "token"
os = "linux"
dir = "/workspaces/${trimsuffix(basename(data.coder_parameter.repo_url.value), ".git")}"
connection_timeout = 0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does setting this to zero actually serve a purpose? Doesn't it just use the default of 30s in this case?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsure; this was from the original template IIRC.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, so from reading https://registry.terraform.io/providers/coder/coder/latest/docs/resources/agent#connection_timeout I could imagine that this is set to avoid the agent showing up as 'timed out' due to the GCP instance potentially taking a long time to start.


# Check for Docker, install if not present
if ! command -v docker &> /dev/null
then
echo "Docker not found, installing..."
curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh 2>&1 >/dev/null
sudo usermod -aG docker ${local.linux_user}
newgrp docker
else
echo "Docker is already installed."
fi
# Start envbuilder
docker run --rm \
-h ${lower(data.coder_workspace.me.name)} \
-v /home/${local.linux_user}/envbuilder:/workspaces \
-e CODER_AGENT_TOKEN="${try(coder_agent.dev[0].token, "")}" \
-e CODER_AGENT_URL="${data.coder_workspace.me.access_url}" \
-e GIT_URL="${data.coder_parameter.repo_url.value}" \
-e INIT_SCRIPT="echo ${base64encode(try(coder_agent.dev[0].init_script, ""))} | base64 -d | sh" \
-e FALLBACK_IMAGE="codercom/enterprise-base:ubuntu" \
ghcr.io/coder/envbuilder
META
metadata {
key = "cpu"
display_name = "CPU Usage"
interval = 5
timeout = 5
script = "coder stat cpu"
}
metadata {
key = "memory"
display_name = "Memory Usage"
interval = 5
timeout = 5
script = "coder stat mem"
}
metadata {
key = "disk"
display_name = "Disk Usage"
interval = 5
timeout = 5
script = "coder stat disk"
}
}

locals {
# Ensure Coder username is a valid Linux username
linux_user = lower(substr(data.coder_workspace_owner.me.name, 0, 32))
# Install code-server via Terraform module.
module "code-server" {
count = data.coder_workspace.me.start_count
source = "https://registry.coder.com/modules/code-server"
agent_id = coder_agent.dev[0].id
}

# Create metadata for the workspace and home disk.
resource "coder_metadata" "workspace_info" {
count = data.coder_workspace.me.start_count
resource_id = google_compute_instance.vm.id
Expand Down