mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat(coderd/templatebuilder): add exampleID->OS map and base template .tf.tmpl files (#26115)
Add the bundled `exampleID -> OS` Go map for Docker, Kubernetes, and AWS
EC2 Linux base templates. Create `.tf.tmpl` Go template files for each
within `coderd/templatebuilder/bases/`, along with `BaseRenderContext`
and `RenderBaseTemplate` rendering helpers.
The `.tf.tmpl` files are independent copies of the example templates
with module blocks (code-server, jetbrains) removed, since the template
builder composes modules separately into `modules.tf`. When
`ImageOptions` is provided, the container image field references the
Terraform parameter; otherwise it uses the hardcoded value via Go
template whitespace control (`{{-`).
Golden file snapshot tests verify rendered output stability with an
`-update` flag for regeneration.
Depends on #25909
> [!NOTE]
> This PR was authored by Coder Agents on behalf of @jeremyruppel.
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
package templatebuilder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// BaseOS enumerates operating systems for base template filtering.
|
||||
type BaseOS string
|
||||
|
||||
const (
|
||||
BaseOSLinux BaseOS = "linux"
|
||||
)
|
||||
|
||||
// validBaseOS maps base.json os strings to their typed equivalents.
|
||||
var validBaseOS = map[string]BaseOS{
|
||||
"linux": BaseOSLinux,
|
||||
}
|
||||
|
||||
//go:embed bases
|
||||
var basesFS embed.FS
|
||||
|
||||
const basesDir = "bases"
|
||||
|
||||
// templateSuffix identifies Go template files that are pre-parsed at load time.
|
||||
// Terraform templatefile() inputs (.tftpl) are not Go templates and are left
|
||||
// as raw files in the embedded FS.
|
||||
const templateSuffix = ".tf.tmpl"
|
||||
|
||||
// BaseManifest is the on-disk schema for a base.json file.
|
||||
type BaseManifest struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
OS string `json:"os"`
|
||||
DefaultContext BaseDefaultContext `json:"default_context"`
|
||||
}
|
||||
|
||||
// BaseDefaultContext holds default render values stored in base.json.
|
||||
type BaseDefaultContext struct {
|
||||
ContainerImage string `json:"container_image,omitempty"`
|
||||
}
|
||||
|
||||
// parsedBase holds the result of loading and pre-parsing a single base
|
||||
// template directory.
|
||||
type parsedBase struct {
|
||||
Manifest BaseManifest
|
||||
Templates map[string]*template.Template
|
||||
FS fs.FS
|
||||
}
|
||||
|
||||
var loadBases = sync.OnceValues(func() (map[string]*parsedBase, error) {
|
||||
return parseBasesFromFS(basesFS)
|
||||
})
|
||||
|
||||
// parseBasesFromFS reads and validates all base.json manifests and pre-parses
|
||||
// Go template files from the given filesystem. Most callers should use the
|
||||
// exported accessors, which read from the cached embedded catalog.
|
||||
func parseBasesFromFS(fsys fs.FS) (map[string]*parsedBase, error) {
|
||||
sub, err := fs.Sub(fsys, basesDir)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("open embedded base catalog: %w", err)
|
||||
}
|
||||
|
||||
dirs, err := fs.ReadDir(sub, ".")
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("list base catalog entries: %w", err)
|
||||
}
|
||||
|
||||
bases := make(map[string]*parsedBase)
|
||||
for _, dir := range dirs {
|
||||
if !dir.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
manifestPath := path.Join(dir.Name(), "base.json")
|
||||
data, err := fs.ReadFile(sub, manifestPath)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read %s: %w", manifestPath, err)
|
||||
}
|
||||
|
||||
var manifest BaseManifest
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&manifest); err != nil {
|
||||
return nil, xerrors.Errorf("decode %s: %w", manifestPath, err)
|
||||
}
|
||||
|
||||
if manifest.ID == "" {
|
||||
return nil, xerrors.Errorf("base in %s has empty id", dir.Name())
|
||||
}
|
||||
if _, ok := validBaseOS[manifest.OS]; !ok && manifest.OS != "" {
|
||||
return nil, xerrors.Errorf("base %q has unknown os %q", manifest.ID, manifest.OS)
|
||||
}
|
||||
if bases[manifest.ID] != nil {
|
||||
return nil, xerrors.Errorf("duplicate base id %q", manifest.ID)
|
||||
}
|
||||
|
||||
baseFS, err := fs.Sub(sub, dir.Name())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("sub fs for %s: %w", dir.Name(), err)
|
||||
}
|
||||
|
||||
templates, err := parseTemplatesFromFS(baseFS)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse templates for base %q: %w", manifest.ID, err)
|
||||
}
|
||||
|
||||
bases[manifest.ID] = &parsedBase{
|
||||
Manifest: manifest,
|
||||
Templates: templates,
|
||||
FS: baseFS,
|
||||
}
|
||||
}
|
||||
|
||||
return bases, nil
|
||||
}
|
||||
|
||||
// parseTemplatesFromFS walks the filesystem and pre-parses all .tf.tmpl files
|
||||
// into Go templates. Returned keys are paths relative to the FS root.
|
||||
func parseTemplatesFromFS(fsys fs.FS) (map[string]*template.Template, error) {
|
||||
templates := make(map[string]*template.Template)
|
||||
|
||||
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(p, templateSuffix) {
|
||||
return nil
|
||||
}
|
||||
|
||||
raw, err := fs.ReadFile(fsys, p)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("read %s: %w", p, err)
|
||||
}
|
||||
|
||||
tmpl, err := template.New(p).Parse(string(raw))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse %s: %w", p, err)
|
||||
}
|
||||
|
||||
templates[p] = tmpl
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
// BaseTemplateOS resolves the OS for a given example ID.
|
||||
// Returns empty string if the example is not a known base template.
|
||||
func BaseTemplateOS(exampleID string) BaseOS {
|
||||
bases, err := loadBases()
|
||||
if err != nil || bases[exampleID] == nil {
|
||||
return ""
|
||||
}
|
||||
return validBaseOS[bases[exampleID].Manifest.OS]
|
||||
}
|
||||
|
||||
// DefaultBaseRenderContext returns the render context that produces the
|
||||
// canonical default output for a base template.
|
||||
func DefaultBaseRenderContext(exampleID string) BaseRenderContext {
|
||||
bases, err := loadBases()
|
||||
if err != nil || bases[exampleID] == nil {
|
||||
return BaseRenderContext{}
|
||||
}
|
||||
dc := bases[exampleID].Manifest.DefaultContext
|
||||
return BaseRenderContext{
|
||||
ContainerImage: dc.ContainerImage,
|
||||
}
|
||||
}
|
||||
|
||||
// BaseTemplateIDs returns the set of known base template example IDs.
|
||||
func BaseTemplateIDs() []string {
|
||||
bases, err := loadBases()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(bases))
|
||||
for id := range bases {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// BaseTemplateFS returns a filesystem rooted at the given base template
|
||||
// directory within the embedded bases catalog. Returns an error if
|
||||
// exampleID is not a known base template.
|
||||
func BaseTemplateFS(exampleID string) (fs.FS, error) {
|
||||
bases, err := loadBases()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("load base catalog: %w", err)
|
||||
}
|
||||
base, ok := bases[exampleID]
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("unknown base template %q", exampleID)
|
||||
}
|
||||
return base.FS, nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "aws-linux",
|
||||
"display_name": "AWS EC2 (Linux)",
|
||||
"os": "linux",
|
||||
"default_context": {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#cloud-config
|
||||
cloud_final_modules:
|
||||
- [scripts-user, always]
|
||||
hostname: ${hostname}
|
||||
users:
|
||||
- name: ${linux_user}
|
||||
sudo: ALL=(ALL) NOPASSWD:ALL
|
||||
shell: /bin/bash
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
sudo -u '${linux_user}' sh -c '${init_script}'
|
||||
@@ -0,0 +1,264 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
cloudinit = {
|
||||
source = "hashicorp/cloudinit"
|
||||
}
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Last updated 2023-03-14
|
||||
# aws ec2 describe-regions | jq -r '[.Regions[].RegionName] | sort'
|
||||
data "coder_parameter" "region" {
|
||||
name = "region"
|
||||
display_name = "Region"
|
||||
description = "The region to deploy the workspace in."
|
||||
default = "us-east-1"
|
||||
mutable = false
|
||||
option {
|
||||
name = "Asia Pacific (Tokyo)"
|
||||
value = "ap-northeast-1"
|
||||
icon = "/emojis/1f1ef-1f1f5.png"
|
||||
}
|
||||
option {
|
||||
name = "Asia Pacific (Seoul)"
|
||||
value = "ap-northeast-2"
|
||||
icon = "/emojis/1f1f0-1f1f7.png"
|
||||
}
|
||||
option {
|
||||
name = "Asia Pacific (Osaka)"
|
||||
value = "ap-northeast-3"
|
||||
icon = "/emojis/1f1ef-1f1f5.png"
|
||||
}
|
||||
option {
|
||||
name = "Asia Pacific (Mumbai)"
|
||||
value = "ap-south-1"
|
||||
icon = "/emojis/1f1ee-1f1f3.png"
|
||||
}
|
||||
option {
|
||||
name = "Asia Pacific (Singapore)"
|
||||
value = "ap-southeast-1"
|
||||
icon = "/emojis/1f1f8-1f1ec.png"
|
||||
}
|
||||
option {
|
||||
name = "Asia Pacific (Sydney)"
|
||||
value = "ap-southeast-2"
|
||||
icon = "/emojis/1f1e6-1f1fa.png"
|
||||
}
|
||||
option {
|
||||
name = "Canada (Central)"
|
||||
value = "ca-central-1"
|
||||
icon = "/emojis/1f1e8-1f1e6.png"
|
||||
}
|
||||
option {
|
||||
name = "EU (Frankfurt)"
|
||||
value = "eu-central-1"
|
||||
icon = "/emojis/1f1ea-1f1fa.png"
|
||||
}
|
||||
option {
|
||||
name = "EU (Stockholm)"
|
||||
value = "eu-north-1"
|
||||
icon = "/emojis/1f1ea-1f1fa.png"
|
||||
}
|
||||
option {
|
||||
name = "EU (Ireland)"
|
||||
value = "eu-west-1"
|
||||
icon = "/emojis/1f1ea-1f1fa.png"
|
||||
}
|
||||
option {
|
||||
name = "EU (London)"
|
||||
value = "eu-west-2"
|
||||
icon = "/emojis/1f1ea-1f1fa.png"
|
||||
}
|
||||
option {
|
||||
name = "EU (Paris)"
|
||||
value = "eu-west-3"
|
||||
icon = "/emojis/1f1ea-1f1fa.png"
|
||||
}
|
||||
option {
|
||||
name = "South America (São Paulo)"
|
||||
value = "sa-east-1"
|
||||
icon = "/emojis/1f1e7-1f1f7.png"
|
||||
}
|
||||
option {
|
||||
name = "US East (N. Virginia)"
|
||||
value = "us-east-1"
|
||||
icon = "/emojis/1f1fa-1f1f8.png"
|
||||
}
|
||||
option {
|
||||
name = "US East (Ohio)"
|
||||
value = "us-east-2"
|
||||
icon = "/emojis/1f1fa-1f1f8.png"
|
||||
}
|
||||
option {
|
||||
name = "US West (N. California)"
|
||||
value = "us-west-1"
|
||||
icon = "/emojis/1f1fa-1f1f8.png"
|
||||
}
|
||||
option {
|
||||
name = "US West (Oregon)"
|
||||
value = "us-west-2"
|
||||
icon = "/emojis/1f1fa-1f1f8.png"
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "instance_type" {
|
||||
name = "instance_type"
|
||||
display_name = "Instance type"
|
||||
description = "What instance type should your workspace use?"
|
||||
default = "t3.micro"
|
||||
mutable = false
|
||||
option {
|
||||
name = "2 vCPU, 1 GiB RAM"
|
||||
value = "t3.micro"
|
||||
}
|
||||
option {
|
||||
name = "2 vCPU, 2 GiB RAM"
|
||||
value = "t3.small"
|
||||
}
|
||||
option {
|
||||
name = "2 vCPU, 4 GiB RAM"
|
||||
value = "t3.medium"
|
||||
}
|
||||
option {
|
||||
name = "2 vCPU, 8 GiB RAM"
|
||||
value = "t3.large"
|
||||
}
|
||||
option {
|
||||
name = "4 vCPU, 16 GiB RAM"
|
||||
value = "t3.xlarge"
|
||||
}
|
||||
option {
|
||||
name = "8 vCPU, 32 GiB RAM"
|
||||
value = "t3.2xlarge"
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = data.coder_parameter.region.value
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
data "aws_ami" "ubuntu" {
|
||||
most_recent = true
|
||||
filter {
|
||||
name = "name"
|
||||
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
|
||||
}
|
||||
filter {
|
||||
name = "virtualization-type"
|
||||
values = ["hvm"]
|
||||
}
|
||||
owners = ["099720109477"] # Canonical
|
||||
}
|
||||
|
||||
resource "coder_agent" "dev" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
arch = "amd64"
|
||||
auth = "aws-instance-identity"
|
||||
os = "linux"
|
||||
startup_script = <<-EOT
|
||||
set -e
|
||||
|
||||
# Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here
|
||||
EOT
|
||||
|
||||
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 = 600 # every 10 minutes
|
||||
timeout = 30 # df can take a while on large filesystems
|
||||
script = "coder stat disk --path $HOME"
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
hostname = lower(data.coder_workspace.me.name)
|
||||
linux_user = "coder"
|
||||
}
|
||||
|
||||
data "cloudinit_config" "user_data" {
|
||||
gzip = false
|
||||
base64_encode = false
|
||||
|
||||
boundary = "//"
|
||||
|
||||
part {
|
||||
filename = "cloud-config.yaml"
|
||||
content_type = "text/cloud-config"
|
||||
|
||||
content = templatefile("${path.module}/cloud-init/cloud-config.yaml.tftpl", {
|
||||
hostname = local.hostname
|
||||
linux_user = local.linux_user
|
||||
})
|
||||
}
|
||||
|
||||
part {
|
||||
filename = "userdata.sh"
|
||||
content_type = "text/x-shellscript"
|
||||
|
||||
content = templatefile("${path.module}/cloud-init/userdata.sh.tftpl", {
|
||||
linux_user = local.linux_user
|
||||
|
||||
init_script = try(coder_agent.dev[0].init_script, "")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_instance" "dev" {
|
||||
ami = data.aws_ami.ubuntu.id
|
||||
availability_zone = "${data.coder_parameter.region.value}a"
|
||||
instance_type = data.coder_parameter.instance_type.value
|
||||
|
||||
user_data = data.cloudinit_config.user_data.rendered
|
||||
tags = {
|
||||
Name = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}"
|
||||
# Required if you are using our example policy, see template README
|
||||
Coder_Provisioned = "true"
|
||||
}
|
||||
lifecycle {
|
||||
ignore_changes = [ami]
|
||||
}
|
||||
}
|
||||
|
||||
resource "coder_metadata" "workspace_info" {
|
||||
resource_id = aws_instance.dev.id
|
||||
item {
|
||||
key = "region"
|
||||
value = data.coder_parameter.region.value
|
||||
}
|
||||
item {
|
||||
key = "instance type"
|
||||
value = aws_instance.dev.instance_type
|
||||
}
|
||||
item {
|
||||
key = "disk"
|
||||
value = "${aws_instance.dev.root_block_device[0].volume_size} GiB"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_ec2_instance_state" "dev" {
|
||||
instance_id = aws_instance.dev.id
|
||||
state = data.coder_workspace.me.transition == "start" ? "running" : "stopped"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "docker",
|
||||
"display_name": "Docker",
|
||||
"os": "linux",
|
||||
"default_context": {
|
||||
"container_image": "codercom/enterprise-base:ubuntu"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
docker = {
|
||||
source = "kreuzwerker/docker"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
username = data.coder_workspace_owner.me.name
|
||||
}
|
||||
|
||||
variable "docker_socket" {
|
||||
default = ""
|
||||
description = "(Optional) Docker socket URI"
|
||||
type = string
|
||||
}
|
||||
|
||||
provider "docker" {
|
||||
# Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default
|
||||
host = var.docker_socket != "" ? var.docker_socket : null
|
||||
}
|
||||
|
||||
data "coder_provisioner" "me" {}
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
{{ if .ImageOptions }}
|
||||
data "coder_parameter" "container_image" {
|
||||
name = "container_image"
|
||||
display_name = "Container Image"
|
||||
default = "{{ .ContainerImage }}"
|
||||
mutable = true
|
||||
{{ range .ImageOptions }}
|
||||
option {
|
||||
name = "{{ .Name }}"
|
||||
value = "{{ .Value }}"
|
||||
}
|
||||
{{ end }}
|
||||
}
|
||||
{{ end }}
|
||||
resource "coder_agent" "main" {
|
||||
arch = data.coder_provisioner.me.arch
|
||||
os = "linux"
|
||||
startup_script = <<-EOT
|
||||
set -e
|
||||
|
||||
# Prepare user home with default files on first start.
|
||||
if [ ! -f ~/.init_done ]; then
|
||||
cp -rT /etc/skel ~
|
||||
touch ~/.init_done
|
||||
fi
|
||||
|
||||
# Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here
|
||||
EOT
|
||||
|
||||
# These environment variables allow you to make Git commits right away after creating a
|
||||
# workspace. Note that they take precedence over configuration defined in ~/.gitconfig!
|
||||
# You can remove this block if you'd prefer to configure Git manually or using
|
||||
# dotfiles. (see docs/dotfiles.md)
|
||||
env = {
|
||||
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
|
||||
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
|
||||
}
|
||||
|
||||
# The following metadata blocks are optional. They are used to display
|
||||
# information about your workspace in the dashboard. You can remove them
|
||||
# if you don't want to display any information.
|
||||
# For basic resources, you can use the `coder stat` command.
|
||||
# If you need more control, you can write your own script.
|
||||
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 = "Home Disk"
|
||||
key = "3_home_disk"
|
||||
script = "coder stat disk --path $${HOME}"
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "CPU Usage (Host)"
|
||||
key = "4_cpu_usage_host"
|
||||
script = "coder stat cpu --host"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Memory Usage (Host)"
|
||||
key = "5_mem_usage_host"
|
||||
script = "coder stat mem --host"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Load Average (Host)"
|
||||
key = "6_load_host"
|
||||
# get load avg scaled by number of cores
|
||||
script = <<EOT
|
||||
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
|
||||
EOT
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Swap Usage (Host)"
|
||||
key = "7_swap_host"
|
||||
script = <<EOT
|
||||
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
|
||||
EOT
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
}
|
||||
|
||||
resource "docker_volume" "home_volume" {
|
||||
name = "coder-${data.coder_workspace.me.id}-home"
|
||||
# Protect the volume from being deleted due to changes in attributes.
|
||||
lifecycle {
|
||||
ignore_changes = all
|
||||
}
|
||||
# Add labels in Docker to keep track of orphan resources.
|
||||
labels {
|
||||
label = "coder.owner"
|
||||
value = data.coder_workspace_owner.me.name
|
||||
}
|
||||
labels {
|
||||
label = "coder.owner_id"
|
||||
value = data.coder_workspace_owner.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_id"
|
||||
value = data.coder_workspace.me.id
|
||||
}
|
||||
# This field becomes outdated if the workspace is renamed but can
|
||||
# be useful for debugging or cleaning out dangling volumes.
|
||||
labels {
|
||||
label = "coder.workspace_name_at_creation"
|
||||
value = data.coder_workspace.me.name
|
||||
}
|
||||
}
|
||||
|
||||
resource "docker_container" "workspace" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
{{- if .ImageOptions }}
|
||||
image = data.coder_parameter.container_image.value
|
||||
{{- else }}
|
||||
image = "{{ .ContainerImage }}"
|
||||
{{- end }}
|
||||
# Uses lower() to avoid Docker restriction on container names.
|
||||
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
|
||||
# Hostname makes the shell more user friendly: coder@my-workspace:~$
|
||||
hostname = data.coder_workspace.me.name
|
||||
# Use the docker gateway if the access URL is 127.0.0.1
|
||||
entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
|
||||
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
|
||||
host {
|
||||
host = "host.docker.internal"
|
||||
ip = "host-gateway"
|
||||
}
|
||||
volumes {
|
||||
container_path = "/home/coder"
|
||||
volume_name = docker_volume.home_volume.name
|
||||
read_only = false
|
||||
}
|
||||
|
||||
# Add labels in Docker to keep track of orphan resources.
|
||||
labels {
|
||||
label = "coder.owner"
|
||||
value = data.coder_workspace_owner.me.name
|
||||
}
|
||||
labels {
|
||||
label = "coder.owner_id"
|
||||
value = data.coder_workspace_owner.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_id"
|
||||
value = data.coder_workspace.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_name"
|
||||
value = data.coder_workspace.me.name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "kubernetes",
|
||||
"display_name": "Kubernetes",
|
||||
"os": "linux",
|
||||
"default_context": {
|
||||
"container_image": "codercom/enterprise-base:ubuntu"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
kubernetes = {
|
||||
source = "hashicorp/kubernetes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "coder" {
|
||||
}
|
||||
|
||||
variable "use_kubeconfig" {
|
||||
type = bool
|
||||
description = <<-EOF
|
||||
Use host kubeconfig? (true/false)
|
||||
|
||||
Set this to false if the Coder host is itself running as a Pod on the same
|
||||
Kubernetes cluster as you are deploying workspaces to.
|
||||
|
||||
Set this to true if the Coder host is running outside the Kubernetes cluster
|
||||
for workspaces. A valid "~/.kube/config" must be present on the Coder host.
|
||||
EOF
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "namespace" {
|
||||
type = string
|
||||
description = "The Kubernetes namespace to create workspaces in (must exist prior to creating workspaces). If the Coder host is itself running as a Pod on the same Kubernetes cluster as you are deploying workspaces to, set this to the same namespace."
|
||||
}
|
||||
|
||||
data "coder_parameter" "cpu" {
|
||||
name = "cpu"
|
||||
display_name = "CPU"
|
||||
description = "The number of CPU cores"
|
||||
default = "2"
|
||||
icon = "/icon/memory.svg"
|
||||
mutable = true
|
||||
option {
|
||||
name = "2 Cores"
|
||||
value = "2"
|
||||
}
|
||||
option {
|
||||
name = "4 Cores"
|
||||
value = "4"
|
||||
}
|
||||
option {
|
||||
name = "6 Cores"
|
||||
value = "6"
|
||||
}
|
||||
option {
|
||||
name = "8 Cores"
|
||||
value = "8"
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "memory" {
|
||||
name = "memory"
|
||||
display_name = "Memory"
|
||||
description = "The amount of memory in GB"
|
||||
default = "2"
|
||||
icon = "/icon/memory.svg"
|
||||
mutable = true
|
||||
option {
|
||||
name = "2 GB"
|
||||
value = "2"
|
||||
}
|
||||
option {
|
||||
name = "4 GB"
|
||||
value = "4"
|
||||
}
|
||||
option {
|
||||
name = "6 GB"
|
||||
value = "6"
|
||||
}
|
||||
option {
|
||||
name = "8 GB"
|
||||
value = "8"
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "home_disk_size" {
|
||||
name = "home_disk_size"
|
||||
display_name = "Home disk size"
|
||||
description = "The size of the home disk in GB"
|
||||
default = "10"
|
||||
type = "number"
|
||||
icon = "/emojis/1f4be.png"
|
||||
mutable = false
|
||||
validation {
|
||||
min = 1
|
||||
max = 99999
|
||||
}
|
||||
}
|
||||
|
||||
provider "kubernetes" {
|
||||
# Authenticate via ~/.kube/config or a Coder-specific ServiceAccount, depending on admin preferences
|
||||
config_path = var.use_kubeconfig == true ? "~/.kube/config" : null
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
{{ if .ImageOptions }}
|
||||
data "coder_parameter" "container_image" {
|
||||
name = "container_image"
|
||||
display_name = "Container Image"
|
||||
default = "{{ .ContainerImage }}"
|
||||
mutable = true
|
||||
{{ range .ImageOptions }}
|
||||
option {
|
||||
name = "{{ .Name }}"
|
||||
value = "{{ .Value }}"
|
||||
}
|
||||
{{ end }}
|
||||
}
|
||||
{{ end }}
|
||||
resource "coder_agent" "main" {
|
||||
os = "linux"
|
||||
arch = "amd64"
|
||||
startup_script = <<-EOT
|
||||
set -e
|
||||
|
||||
# Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here
|
||||
EOT
|
||||
|
||||
# The following metadata blocks are optional. They are used to display
|
||||
# information about your workspace in the dashboard. You can remove them
|
||||
# if you don't want to display any information.
|
||||
# For basic resources, you can use the `coder stat` command.
|
||||
# If you need more control, you can write your own script.
|
||||
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 = "Home Disk"
|
||||
key = "3_home_disk"
|
||||
script = "coder stat disk --path $${HOME}"
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "CPU Usage (Host)"
|
||||
key = "4_cpu_usage_host"
|
||||
script = "coder stat cpu --host"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Memory Usage (Host)"
|
||||
key = "5_mem_usage_host"
|
||||
script = "coder stat mem --host"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Load Average (Host)"
|
||||
key = "6_load_host"
|
||||
# get load avg scaled by number of cores
|
||||
script = <<EOT
|
||||
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
|
||||
EOT
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_persistent_volume_claim_v1" "home" {
|
||||
metadata {
|
||||
name = "coder-${data.coder_workspace.me.id}-home"
|
||||
namespace = var.namespace
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "coder-pvc"
|
||||
"app.kubernetes.io/instance" = "coder-pvc-${data.coder_workspace.me.id}"
|
||||
"app.kubernetes.io/part-of" = "coder"
|
||||
//Coder-specific labels.
|
||||
"com.coder.resource" = "true"
|
||||
"com.coder.workspace.id" = data.coder_workspace.me.id
|
||||
"com.coder.workspace.name" = data.coder_workspace.me.name
|
||||
"com.coder.user.id" = data.coder_workspace_owner.me.id
|
||||
"com.coder.user.username" = data.coder_workspace_owner.me.name
|
||||
}
|
||||
annotations = {
|
||||
"com.coder.user.email" = data.coder_workspace_owner.me.email
|
||||
}
|
||||
}
|
||||
wait_until_bound = false
|
||||
spec {
|
||||
access_modes = ["ReadWriteOnce"]
|
||||
resources {
|
||||
requests = {
|
||||
storage = "${data.coder_parameter.home_disk_size.value}Gi"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_deployment_v1" "main" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
depends_on = [
|
||||
kubernetes_persistent_volume_claim_v1.home
|
||||
]
|
||||
wait_for_rollout = false
|
||||
metadata {
|
||||
name = "coder-${data.coder_workspace.me.id}"
|
||||
namespace = var.namespace
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
|
||||
"app.kubernetes.io/part-of" = "coder"
|
||||
"com.coder.resource" = "true"
|
||||
"com.coder.workspace.id" = data.coder_workspace.me.id
|
||||
"com.coder.workspace.name" = data.coder_workspace.me.name
|
||||
"com.coder.user.id" = data.coder_workspace_owner.me.id
|
||||
"com.coder.user.username" = data.coder_workspace_owner.me.name
|
||||
}
|
||||
annotations = {
|
||||
"com.coder.user.email" = data.coder_workspace_owner.me.email
|
||||
}
|
||||
}
|
||||
|
||||
spec {
|
||||
replicas = 1
|
||||
selector {
|
||||
match_labels = {
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
|
||||
"app.kubernetes.io/part-of" = "coder"
|
||||
"com.coder.resource" = "true"
|
||||
"com.coder.workspace.id" = data.coder_workspace.me.id
|
||||
"com.coder.workspace.name" = data.coder_workspace.me.name
|
||||
"com.coder.user.id" = data.coder_workspace_owner.me.id
|
||||
"com.coder.user.username" = data.coder_workspace_owner.me.name
|
||||
}
|
||||
}
|
||||
strategy {
|
||||
type = "Recreate"
|
||||
}
|
||||
|
||||
template {
|
||||
metadata {
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
|
||||
"app.kubernetes.io/part-of" = "coder"
|
||||
"com.coder.resource" = "true"
|
||||
"com.coder.workspace.id" = data.coder_workspace.me.id
|
||||
"com.coder.workspace.name" = data.coder_workspace.me.name
|
||||
"com.coder.user.id" = data.coder_workspace_owner.me.id
|
||||
"com.coder.user.username" = data.coder_workspace_owner.me.name
|
||||
}
|
||||
}
|
||||
spec {
|
||||
security_context {
|
||||
run_as_user = 1000
|
||||
fs_group = 1000
|
||||
run_as_non_root = true
|
||||
}
|
||||
|
||||
container {
|
||||
name = "dev"
|
||||
{{- if .ImageOptions }}
|
||||
image = data.coder_parameter.container_image.value
|
||||
{{- else }}
|
||||
image = "{{ .ContainerImage }}"
|
||||
{{- end }}
|
||||
image_pull_policy = "Always"
|
||||
command = ["sh", "-c", coder_agent.main.init_script]
|
||||
security_context {
|
||||
run_as_user = "1000"
|
||||
}
|
||||
env {
|
||||
name = "CODER_AGENT_TOKEN"
|
||||
value = coder_agent.main.token
|
||||
}
|
||||
resources {
|
||||
requests = {
|
||||
"cpu" = "250m"
|
||||
"memory" = "512Mi"
|
||||
}
|
||||
limits = {
|
||||
"cpu" = "${data.coder_parameter.cpu.value}"
|
||||
"memory" = "${data.coder_parameter.memory.value}Gi"
|
||||
}
|
||||
}
|
||||
volume_mount {
|
||||
mount_path = "/home/coder"
|
||||
name = "home"
|
||||
read_only = false
|
||||
}
|
||||
}
|
||||
|
||||
volume {
|
||||
name = "home"
|
||||
persistent_volume_claim {
|
||||
claim_name = kubernetes_persistent_volume_claim_v1.home.metadata.0.name
|
||||
read_only = false
|
||||
}
|
||||
}
|
||||
|
||||
affinity {
|
||||
// This affinity attempts to spread out all workspace pods evenly across
|
||||
// nodes.
|
||||
pod_anti_affinity {
|
||||
preferred_during_scheduling_ignored_during_execution {
|
||||
weight = 1
|
||||
pod_affinity_term {
|
||||
topology_key = "kubernetes.io/hostname"
|
||||
label_selector {
|
||||
match_expressions {
|
||||
key = "app.kubernetes.io/name"
|
||||
operator = "In"
|
||||
values = ["coder-workspace"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package templatebuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseBasesFromFS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ValidManifest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"bases/docker/base.json": &fstest.MapFile{
|
||||
Data: []byte(`{
|
||||
"id": "docker",
|
||||
"display_name": "Docker",
|
||||
"os": "linux",
|
||||
"default_context": {
|
||||
"container_image": "codercom/enterprise-base:ubuntu"
|
||||
}
|
||||
}`),
|
||||
},
|
||||
"bases/docker/main.tf.tmpl": &fstest.MapFile{
|
||||
Data: []byte(`image = "{{ .ContainerImage }}"`),
|
||||
},
|
||||
}
|
||||
|
||||
bases, err := parseBasesFromFS(fsys)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, bases, 1)
|
||||
|
||||
b := bases["docker"]
|
||||
require.NotNil(t, b)
|
||||
require.Equal(t, "docker", b.Manifest.ID)
|
||||
require.Equal(t, "Docker", b.Manifest.DisplayName)
|
||||
require.Equal(t, "linux", b.Manifest.OS)
|
||||
require.Equal(t, "codercom/enterprise-base:ubuntu", b.Manifest.DefaultContext.ContainerImage)
|
||||
require.Contains(t, b.Templates, "main.tf.tmpl")
|
||||
})
|
||||
|
||||
t.Run("MultipleBases", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"bases/alpha/base.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id": "alpha", "os": "linux"}`),
|
||||
},
|
||||
"bases/alpha/main.tf.tmpl": &fstest.MapFile{
|
||||
Data: []byte(`resource "alpha" {}`),
|
||||
},
|
||||
"bases/beta/base.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id": "beta", "os": "linux"}`),
|
||||
},
|
||||
"bases/beta/main.tf.tmpl": &fstest.MapFile{
|
||||
Data: []byte(`resource "beta" {}`),
|
||||
},
|
||||
}
|
||||
|
||||
bases, err := parseBasesFromFS(fsys)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, bases, 2)
|
||||
require.NotNil(t, bases["alpha"])
|
||||
require.NotNil(t, bases["beta"])
|
||||
})
|
||||
|
||||
t.Run("EmptyCatalog", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"bases/.keep": &fstest.MapFile{Data: []byte{}},
|
||||
}
|
||||
|
||||
bases, err := parseBasesFromFS(fsys)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, bases)
|
||||
})
|
||||
|
||||
t.Run("PreParsesTemplates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"bases/mybase/base.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id": "mybase", "os": "linux"}`),
|
||||
},
|
||||
"bases/mybase/main.tf.tmpl": &fstest.MapFile{
|
||||
Data: []byte(`image = "{{ .ContainerImage }}"`),
|
||||
},
|
||||
// .tftpl files are Terraform templatefile() inputs, not Go templates.
|
||||
"bases/mybase/cloud-init/config.yaml.tftpl": &fstest.MapFile{
|
||||
Data: []byte(`${some_terraform_var}`),
|
||||
},
|
||||
}
|
||||
|
||||
bases, err := parseBasesFromFS(fsys)
|
||||
require.NoError(t, err)
|
||||
|
||||
b := bases["mybase"]
|
||||
require.NotNil(t, b)
|
||||
require.Contains(t, b.Templates, "main.tf.tmpl")
|
||||
// .tftpl files should not be pre-parsed as Go templates.
|
||||
require.NotContains(t, b.Templates, "cloud-init/config.yaml.tftpl")
|
||||
})
|
||||
|
||||
t.Run("RejectsDirWithoutManifest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"bases/nobase/readme.txt": &fstest.MapFile{Data: []byte("hi")},
|
||||
}
|
||||
|
||||
_, err := parseBasesFromFS(fsys)
|
||||
require.ErrorContains(t, err, "read nobase/base.json")
|
||||
})
|
||||
|
||||
t.Run("RejectsEmptyID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"bases/bad/base.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id": "", "os": "linux"}`),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := parseBasesFromFS(fsys)
|
||||
require.ErrorContains(t, err, "empty id")
|
||||
})
|
||||
|
||||
t.Run("RejectsDuplicateID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"bases/a/base.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id": "dupe", "os": "linux"}`),
|
||||
},
|
||||
"bases/b/base.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id": "dupe", "os": "linux"}`),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := parseBasesFromFS(fsys)
|
||||
require.ErrorContains(t, err, "duplicate base id")
|
||||
})
|
||||
|
||||
t.Run("RejectsUnknownOS", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"bases/bad/base.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id": "bad", "os": "beos"}`),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := parseBasesFromFS(fsys)
|
||||
require.ErrorContains(t, err, `unknown os "beos"`)
|
||||
})
|
||||
|
||||
t.Run("RejectsUnknownField", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"bases/bad/base.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id": "bad", "os": "linux", "dispaly_name": "typo"}`),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := parseBasesFromFS(fsys)
|
||||
require.ErrorContains(t, err, "decode")
|
||||
})
|
||||
|
||||
t.Run("RejectsInvalidJSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"bases/bad/base.json": &fstest.MapFile{
|
||||
Data: []byte(`{not json`),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := parseBasesFromFS(fsys)
|
||||
require.ErrorContains(t, err, "decode")
|
||||
})
|
||||
|
||||
t.Run("RejectsInvalidTemplate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"bases/bad/base.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id": "bad", "os": "linux"}`),
|
||||
},
|
||||
"bases/bad/main.tf.tmpl": &fstest.MapFile{
|
||||
Data: []byte(`{{ .Broken`),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := parseBasesFromFS(fsys)
|
||||
require.ErrorContains(t, err, "parse templates")
|
||||
})
|
||||
|
||||
t.Run("AllowsEmptyOS", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"bases/nospec/base.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id": "nospec"}`),
|
||||
},
|
||||
}
|
||||
|
||||
bases, err := parseBasesFromFS(fsys)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", bases["nospec"].Manifest.OS)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package templatebuilder_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/templatebuilder"
|
||||
)
|
||||
|
||||
func TestBaseTemplateOS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Docker", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, templatebuilder.BaseOSLinux, templatebuilder.BaseTemplateOS("docker"))
|
||||
})
|
||||
|
||||
t.Run("Kubernetes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, templatebuilder.BaseOSLinux, templatebuilder.BaseTemplateOS("kubernetes"))
|
||||
})
|
||||
|
||||
t.Run("AWSLinux", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, templatebuilder.BaseOSLinux, templatebuilder.BaseTemplateOS("aws-linux"))
|
||||
})
|
||||
|
||||
t.Run("UnknownReturnsEmpty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, templatebuilder.BaseOS(""), templatebuilder.BaseTemplateOS("unknown-template"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseTemplateIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ids := templatebuilder.BaseTemplateIDs()
|
||||
require.Len(t, ids, 3)
|
||||
require.Contains(t, ids, "docker")
|
||||
require.Contains(t, ids, "kubernetes")
|
||||
require.Contains(t, ids, "aws-linux")
|
||||
}
|
||||
|
||||
func TestDefaultBaseRenderContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Docker", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc := templatebuilder.DefaultBaseRenderContext("docker")
|
||||
require.Equal(t, "codercom/enterprise-base:ubuntu", rc.ContainerImage)
|
||||
require.Nil(t, rc.ImageOptions)
|
||||
})
|
||||
|
||||
t.Run("Kubernetes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc := templatebuilder.DefaultBaseRenderContext("kubernetes")
|
||||
require.Equal(t, "codercom/enterprise-base:ubuntu", rc.ContainerImage)
|
||||
require.Nil(t, rc.ImageOptions)
|
||||
})
|
||||
|
||||
t.Run("AWSLinux", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc := templatebuilder.DefaultBaseRenderContext("aws-linux")
|
||||
require.Empty(t, rc.ContainerImage)
|
||||
require.Nil(t, rc.ImageOptions)
|
||||
})
|
||||
|
||||
t.Run("Unknown", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc := templatebuilder.DefaultBaseRenderContext("unknown")
|
||||
require.Empty(t, rc.ContainerImage)
|
||||
})
|
||||
|
||||
t.Run("AllBaseTemplatesHaveDefaults", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Verify that every known base template produces a render
|
||||
// context via DefaultBaseRenderContext (not just the zero value
|
||||
// from an unknown ID). This catches forgotten entries.
|
||||
for _, id := range templatebuilder.BaseTemplateIDs() {
|
||||
rc := templatebuilder.DefaultBaseRenderContext(id)
|
||||
_ = rc // existence is the assertion; the value varies per template
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseTemplateFS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("KnownTemplate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
fsys, err := templatebuilder.BaseTemplateFS("docker")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fsys)
|
||||
})
|
||||
|
||||
t.Run("UnknownTemplate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := templatebuilder.BaseTemplateFS("nonexistent")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown base template")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package templatebuilder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// ImageOption represents a container image choice for base template parameters.
|
||||
type ImageOption struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
// BaseRenderContext is the data passed to base template .tf.tmpl files.
|
||||
type BaseRenderContext struct {
|
||||
ContainerImage string
|
||||
ImageOptions []ImageOption
|
||||
Variables map[string]string
|
||||
}
|
||||
|
||||
// RenderBaseTemplate executes a pre-parsed .tf.tmpl template for the given
|
||||
// base, applying the provided render context. Templates are parsed once at
|
||||
// startup; parse errors surface on first access rather than at render time.
|
||||
func RenderBaseTemplate(exampleID, templatePath string, renderCtx BaseRenderContext) ([]byte, error) {
|
||||
bases, err := loadBases()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("load base catalog: %w", err)
|
||||
}
|
||||
|
||||
base, ok := bases[exampleID]
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("unknown base template %q", exampleID)
|
||||
}
|
||||
|
||||
tmpl, ok := base.Templates[templatePath]
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("template %s not found in base %q", templatePath, exampleID)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, renderCtx); err != nil {
|
||||
return nil, xerrors.Errorf("execute template %s: %w", templatePath, err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package templatebuilder_test
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/templatebuilder"
|
||||
)
|
||||
|
||||
var updateGolden = flag.Bool("update", false, "update golden files")
|
||||
|
||||
func TestRenderBaseTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("UnknownBase", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := templatebuilder.RenderBaseTemplate("nonexistent", "main.tf.tmpl", templatebuilder.BaseRenderContext{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown base template")
|
||||
})
|
||||
|
||||
t.Run("InvalidPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := templatebuilder.RenderBaseTemplate("docker", "nonexistent.tf.tmpl", templatebuilder.BaseRenderContext{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
|
||||
imageOpts := []templatebuilder.ImageOption{
|
||||
{Name: "Ubuntu", Value: "codercom/enterprise-base:ubuntu"},
|
||||
{Name: "Custom", Value: "custom/image:latest"},
|
||||
}
|
||||
|
||||
t.Run("DockerWithImageOptions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
renderCtx := templatebuilder.BaseRenderContext{
|
||||
ContainerImage: "custom/image:latest",
|
||||
ImageOptions: imageOpts,
|
||||
}
|
||||
out, err := templatebuilder.RenderBaseTemplate("docker", "main.tf.tmpl", renderCtx)
|
||||
require.NoError(t, err)
|
||||
rendered := string(out)
|
||||
require.Contains(t, rendered, `data.coder_parameter.container_image.value`)
|
||||
require.Contains(t, rendered, `name = "Ubuntu"`)
|
||||
require.Contains(t, rendered, `name = "Custom"`)
|
||||
require.Contains(t, rendered, `coder_parameter`)
|
||||
})
|
||||
|
||||
t.Run("KubernetesWithImageOptions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
renderCtx := templatebuilder.BaseRenderContext{
|
||||
ContainerImage: "custom/image:latest",
|
||||
ImageOptions: imageOpts,
|
||||
}
|
||||
out, err := templatebuilder.RenderBaseTemplate("kubernetes", "main.tf.tmpl", renderCtx)
|
||||
require.NoError(t, err)
|
||||
rendered := string(out)
|
||||
require.Contains(t, rendered, `data.coder_parameter.container_image.value`)
|
||||
require.Contains(t, rendered, `name = "Ubuntu"`)
|
||||
require.Contains(t, rendered, `coder_parameter`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseTemplateSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This test table must cover every known base template.
|
||||
// BaseTemplateIDs() is the source of truth; this list must match.
|
||||
tests := []struct {
|
||||
exampleID string
|
||||
}{
|
||||
{exampleID: "docker"},
|
||||
{exampleID: "kubernetes"},
|
||||
{exampleID: "aws-linux"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.exampleID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
renderCtx := templatebuilder.DefaultBaseRenderContext(tc.exampleID)
|
||||
rendered, err := templatebuilder.RenderBaseTemplate(tc.exampleID, "main.tf.tmpl", renderCtx)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, rendered)
|
||||
|
||||
goldenPath := filepath.Join("testdata", tc.exampleID+".tf.golden")
|
||||
|
||||
if *updateGolden {
|
||||
err := os.MkdirAll("testdata", 0o755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(goldenPath, rendered, 0o600)
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
expected, err := os.ReadFile(goldenPath)
|
||||
require.NoError(t, err, "golden file %s not found; run with -update to create", goldenPath)
|
||||
require.Equal(t, string(expected), string(rendered),
|
||||
"rendered output for %s does not match golden file; run with -update to regenerate", tc.exampleID)
|
||||
})
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
cloudinit = {
|
||||
source = "hashicorp/cloudinit"
|
||||
}
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Last updated 2023-03-14
|
||||
# aws ec2 describe-regions | jq -r '[.Regions[].RegionName] | sort'
|
||||
data "coder_parameter" "region" {
|
||||
name = "region"
|
||||
display_name = "Region"
|
||||
description = "The region to deploy the workspace in."
|
||||
default = "us-east-1"
|
||||
mutable = false
|
||||
option {
|
||||
name = "Asia Pacific (Tokyo)"
|
||||
value = "ap-northeast-1"
|
||||
icon = "/emojis/1f1ef-1f1f5.png"
|
||||
}
|
||||
option {
|
||||
name = "Asia Pacific (Seoul)"
|
||||
value = "ap-northeast-2"
|
||||
icon = "/emojis/1f1f0-1f1f7.png"
|
||||
}
|
||||
option {
|
||||
name = "Asia Pacific (Osaka)"
|
||||
value = "ap-northeast-3"
|
||||
icon = "/emojis/1f1ef-1f1f5.png"
|
||||
}
|
||||
option {
|
||||
name = "Asia Pacific (Mumbai)"
|
||||
value = "ap-south-1"
|
||||
icon = "/emojis/1f1ee-1f1f3.png"
|
||||
}
|
||||
option {
|
||||
name = "Asia Pacific (Singapore)"
|
||||
value = "ap-southeast-1"
|
||||
icon = "/emojis/1f1f8-1f1ec.png"
|
||||
}
|
||||
option {
|
||||
name = "Asia Pacific (Sydney)"
|
||||
value = "ap-southeast-2"
|
||||
icon = "/emojis/1f1e6-1f1fa.png"
|
||||
}
|
||||
option {
|
||||
name = "Canada (Central)"
|
||||
value = "ca-central-1"
|
||||
icon = "/emojis/1f1e8-1f1e6.png"
|
||||
}
|
||||
option {
|
||||
name = "EU (Frankfurt)"
|
||||
value = "eu-central-1"
|
||||
icon = "/emojis/1f1ea-1f1fa.png"
|
||||
}
|
||||
option {
|
||||
name = "EU (Stockholm)"
|
||||
value = "eu-north-1"
|
||||
icon = "/emojis/1f1ea-1f1fa.png"
|
||||
}
|
||||
option {
|
||||
name = "EU (Ireland)"
|
||||
value = "eu-west-1"
|
||||
icon = "/emojis/1f1ea-1f1fa.png"
|
||||
}
|
||||
option {
|
||||
name = "EU (London)"
|
||||
value = "eu-west-2"
|
||||
icon = "/emojis/1f1ea-1f1fa.png"
|
||||
}
|
||||
option {
|
||||
name = "EU (Paris)"
|
||||
value = "eu-west-3"
|
||||
icon = "/emojis/1f1ea-1f1fa.png"
|
||||
}
|
||||
option {
|
||||
name = "South America (São Paulo)"
|
||||
value = "sa-east-1"
|
||||
icon = "/emojis/1f1e7-1f1f7.png"
|
||||
}
|
||||
option {
|
||||
name = "US East (N. Virginia)"
|
||||
value = "us-east-1"
|
||||
icon = "/emojis/1f1fa-1f1f8.png"
|
||||
}
|
||||
option {
|
||||
name = "US East (Ohio)"
|
||||
value = "us-east-2"
|
||||
icon = "/emojis/1f1fa-1f1f8.png"
|
||||
}
|
||||
option {
|
||||
name = "US West (N. California)"
|
||||
value = "us-west-1"
|
||||
icon = "/emojis/1f1fa-1f1f8.png"
|
||||
}
|
||||
option {
|
||||
name = "US West (Oregon)"
|
||||
value = "us-west-2"
|
||||
icon = "/emojis/1f1fa-1f1f8.png"
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "instance_type" {
|
||||
name = "instance_type"
|
||||
display_name = "Instance type"
|
||||
description = "What instance type should your workspace use?"
|
||||
default = "t3.micro"
|
||||
mutable = false
|
||||
option {
|
||||
name = "2 vCPU, 1 GiB RAM"
|
||||
value = "t3.micro"
|
||||
}
|
||||
option {
|
||||
name = "2 vCPU, 2 GiB RAM"
|
||||
value = "t3.small"
|
||||
}
|
||||
option {
|
||||
name = "2 vCPU, 4 GiB RAM"
|
||||
value = "t3.medium"
|
||||
}
|
||||
option {
|
||||
name = "2 vCPU, 8 GiB RAM"
|
||||
value = "t3.large"
|
||||
}
|
||||
option {
|
||||
name = "4 vCPU, 16 GiB RAM"
|
||||
value = "t3.xlarge"
|
||||
}
|
||||
option {
|
||||
name = "8 vCPU, 32 GiB RAM"
|
||||
value = "t3.2xlarge"
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = data.coder_parameter.region.value
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
data "aws_ami" "ubuntu" {
|
||||
most_recent = true
|
||||
filter {
|
||||
name = "name"
|
||||
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
|
||||
}
|
||||
filter {
|
||||
name = "virtualization-type"
|
||||
values = ["hvm"]
|
||||
}
|
||||
owners = ["099720109477"] # Canonical
|
||||
}
|
||||
|
||||
resource "coder_agent" "dev" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
arch = "amd64"
|
||||
auth = "aws-instance-identity"
|
||||
os = "linux"
|
||||
startup_script = <<-EOT
|
||||
set -e
|
||||
|
||||
# Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here
|
||||
EOT
|
||||
|
||||
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 = 600 # every 10 minutes
|
||||
timeout = 30 # df can take a while on large filesystems
|
||||
script = "coder stat disk --path $HOME"
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
hostname = lower(data.coder_workspace.me.name)
|
||||
linux_user = "coder"
|
||||
}
|
||||
|
||||
data "cloudinit_config" "user_data" {
|
||||
gzip = false
|
||||
base64_encode = false
|
||||
|
||||
boundary = "//"
|
||||
|
||||
part {
|
||||
filename = "cloud-config.yaml"
|
||||
content_type = "text/cloud-config"
|
||||
|
||||
content = templatefile("${path.module}/cloud-init/cloud-config.yaml.tftpl", {
|
||||
hostname = local.hostname
|
||||
linux_user = local.linux_user
|
||||
})
|
||||
}
|
||||
|
||||
part {
|
||||
filename = "userdata.sh"
|
||||
content_type = "text/x-shellscript"
|
||||
|
||||
content = templatefile("${path.module}/cloud-init/userdata.sh.tftpl", {
|
||||
linux_user = local.linux_user
|
||||
|
||||
init_script = try(coder_agent.dev[0].init_script, "")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_instance" "dev" {
|
||||
ami = data.aws_ami.ubuntu.id
|
||||
availability_zone = "${data.coder_parameter.region.value}a"
|
||||
instance_type = data.coder_parameter.instance_type.value
|
||||
|
||||
user_data = data.cloudinit_config.user_data.rendered
|
||||
tags = {
|
||||
Name = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}"
|
||||
# Required if you are using our example policy, see template README
|
||||
Coder_Provisioned = "true"
|
||||
}
|
||||
lifecycle {
|
||||
ignore_changes = [ami]
|
||||
}
|
||||
}
|
||||
|
||||
resource "coder_metadata" "workspace_info" {
|
||||
resource_id = aws_instance.dev.id
|
||||
item {
|
||||
key = "region"
|
||||
value = data.coder_parameter.region.value
|
||||
}
|
||||
item {
|
||||
key = "instance type"
|
||||
value = aws_instance.dev.instance_type
|
||||
}
|
||||
item {
|
||||
key = "disk"
|
||||
value = "${aws_instance.dev.root_block_device[0].volume_size} GiB"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_ec2_instance_state" "dev" {
|
||||
instance_id = aws_instance.dev.id
|
||||
state = data.coder_workspace.me.transition == "start" ? "running" : "stopped"
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
docker = {
|
||||
source = "kreuzwerker/docker"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
username = data.coder_workspace_owner.me.name
|
||||
}
|
||||
|
||||
variable "docker_socket" {
|
||||
default = ""
|
||||
description = "(Optional) Docker socket URI"
|
||||
type = string
|
||||
}
|
||||
|
||||
provider "docker" {
|
||||
# Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default
|
||||
host = var.docker_socket != "" ? var.docker_socket : null
|
||||
}
|
||||
|
||||
data "coder_provisioner" "me" {}
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
resource "coder_agent" "main" {
|
||||
arch = data.coder_provisioner.me.arch
|
||||
os = "linux"
|
||||
startup_script = <<-EOT
|
||||
set -e
|
||||
|
||||
# Prepare user home with default files on first start.
|
||||
if [ ! -f ~/.init_done ]; then
|
||||
cp -rT /etc/skel ~
|
||||
touch ~/.init_done
|
||||
fi
|
||||
|
||||
# Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here
|
||||
EOT
|
||||
|
||||
# These environment variables allow you to make Git commits right away after creating a
|
||||
# workspace. Note that they take precedence over configuration defined in ~/.gitconfig!
|
||||
# You can remove this block if you'd prefer to configure Git manually or using
|
||||
# dotfiles. (see docs/dotfiles.md)
|
||||
env = {
|
||||
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
|
||||
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
|
||||
}
|
||||
|
||||
# The following metadata blocks are optional. They are used to display
|
||||
# information about your workspace in the dashboard. You can remove them
|
||||
# if you don't want to display any information.
|
||||
# For basic resources, you can use the `coder stat` command.
|
||||
# If you need more control, you can write your own script.
|
||||
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 = "Home Disk"
|
||||
key = "3_home_disk"
|
||||
script = "coder stat disk --path $${HOME}"
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "CPU Usage (Host)"
|
||||
key = "4_cpu_usage_host"
|
||||
script = "coder stat cpu --host"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Memory Usage (Host)"
|
||||
key = "5_mem_usage_host"
|
||||
script = "coder stat mem --host"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Load Average (Host)"
|
||||
key = "6_load_host"
|
||||
# get load avg scaled by number of cores
|
||||
script = <<EOT
|
||||
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
|
||||
EOT
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Swap Usage (Host)"
|
||||
key = "7_swap_host"
|
||||
script = <<EOT
|
||||
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
|
||||
EOT
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
}
|
||||
|
||||
resource "docker_volume" "home_volume" {
|
||||
name = "coder-${data.coder_workspace.me.id}-home"
|
||||
# Protect the volume from being deleted due to changes in attributes.
|
||||
lifecycle {
|
||||
ignore_changes = all
|
||||
}
|
||||
# Add labels in Docker to keep track of orphan resources.
|
||||
labels {
|
||||
label = "coder.owner"
|
||||
value = data.coder_workspace_owner.me.name
|
||||
}
|
||||
labels {
|
||||
label = "coder.owner_id"
|
||||
value = data.coder_workspace_owner.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_id"
|
||||
value = data.coder_workspace.me.id
|
||||
}
|
||||
# This field becomes outdated if the workspace is renamed but can
|
||||
# be useful for debugging or cleaning out dangling volumes.
|
||||
labels {
|
||||
label = "coder.workspace_name_at_creation"
|
||||
value = data.coder_workspace.me.name
|
||||
}
|
||||
}
|
||||
|
||||
resource "docker_container" "workspace" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
image = "codercom/enterprise-base:ubuntu"
|
||||
# Uses lower() to avoid Docker restriction on container names.
|
||||
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
|
||||
# Hostname makes the shell more user friendly: coder@my-workspace:~$
|
||||
hostname = data.coder_workspace.me.name
|
||||
# Use the docker gateway if the access URL is 127.0.0.1
|
||||
entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
|
||||
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
|
||||
host {
|
||||
host = "host.docker.internal"
|
||||
ip = "host-gateway"
|
||||
}
|
||||
volumes {
|
||||
container_path = "/home/coder"
|
||||
volume_name = docker_volume.home_volume.name
|
||||
read_only = false
|
||||
}
|
||||
|
||||
# Add labels in Docker to keep track of orphan resources.
|
||||
labels {
|
||||
label = "coder.owner"
|
||||
value = data.coder_workspace_owner.me.name
|
||||
}
|
||||
labels {
|
||||
label = "coder.owner_id"
|
||||
value = data.coder_workspace_owner.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_id"
|
||||
value = data.coder_workspace.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_name"
|
||||
value = data.coder_workspace.me.name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
kubernetes = {
|
||||
source = "hashicorp/kubernetes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "coder" {
|
||||
}
|
||||
|
||||
variable "use_kubeconfig" {
|
||||
type = bool
|
||||
description = <<-EOF
|
||||
Use host kubeconfig? (true/false)
|
||||
|
||||
Set this to false if the Coder host is itself running as a Pod on the same
|
||||
Kubernetes cluster as you are deploying workspaces to.
|
||||
|
||||
Set this to true if the Coder host is running outside the Kubernetes cluster
|
||||
for workspaces. A valid "~/.kube/config" must be present on the Coder host.
|
||||
EOF
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "namespace" {
|
||||
type = string
|
||||
description = "The Kubernetes namespace to create workspaces in (must exist prior to creating workspaces). If the Coder host is itself running as a Pod on the same Kubernetes cluster as you are deploying workspaces to, set this to the same namespace."
|
||||
}
|
||||
|
||||
data "coder_parameter" "cpu" {
|
||||
name = "cpu"
|
||||
display_name = "CPU"
|
||||
description = "The number of CPU cores"
|
||||
default = "2"
|
||||
icon = "/icon/memory.svg"
|
||||
mutable = true
|
||||
option {
|
||||
name = "2 Cores"
|
||||
value = "2"
|
||||
}
|
||||
option {
|
||||
name = "4 Cores"
|
||||
value = "4"
|
||||
}
|
||||
option {
|
||||
name = "6 Cores"
|
||||
value = "6"
|
||||
}
|
||||
option {
|
||||
name = "8 Cores"
|
||||
value = "8"
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "memory" {
|
||||
name = "memory"
|
||||
display_name = "Memory"
|
||||
description = "The amount of memory in GB"
|
||||
default = "2"
|
||||
icon = "/icon/memory.svg"
|
||||
mutable = true
|
||||
option {
|
||||
name = "2 GB"
|
||||
value = "2"
|
||||
}
|
||||
option {
|
||||
name = "4 GB"
|
||||
value = "4"
|
||||
}
|
||||
option {
|
||||
name = "6 GB"
|
||||
value = "6"
|
||||
}
|
||||
option {
|
||||
name = "8 GB"
|
||||
value = "8"
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "home_disk_size" {
|
||||
name = "home_disk_size"
|
||||
display_name = "Home disk size"
|
||||
description = "The size of the home disk in GB"
|
||||
default = "10"
|
||||
type = "number"
|
||||
icon = "/emojis/1f4be.png"
|
||||
mutable = false
|
||||
validation {
|
||||
min = 1
|
||||
max = 99999
|
||||
}
|
||||
}
|
||||
|
||||
provider "kubernetes" {
|
||||
# Authenticate via ~/.kube/config or a Coder-specific ServiceAccount, depending on admin preferences
|
||||
config_path = var.use_kubeconfig == true ? "~/.kube/config" : null
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
resource "coder_agent" "main" {
|
||||
os = "linux"
|
||||
arch = "amd64"
|
||||
startup_script = <<-EOT
|
||||
set -e
|
||||
|
||||
# Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here
|
||||
EOT
|
||||
|
||||
# The following metadata blocks are optional. They are used to display
|
||||
# information about your workspace in the dashboard. You can remove them
|
||||
# if you don't want to display any information.
|
||||
# For basic resources, you can use the `coder stat` command.
|
||||
# If you need more control, you can write your own script.
|
||||
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 = "Home Disk"
|
||||
key = "3_home_disk"
|
||||
script = "coder stat disk --path $${HOME}"
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "CPU Usage (Host)"
|
||||
key = "4_cpu_usage_host"
|
||||
script = "coder stat cpu --host"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Memory Usage (Host)"
|
||||
key = "5_mem_usage_host"
|
||||
script = "coder stat mem --host"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Load Average (Host)"
|
||||
key = "6_load_host"
|
||||
# get load avg scaled by number of cores
|
||||
script = <<EOT
|
||||
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
|
||||
EOT
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_persistent_volume_claim_v1" "home" {
|
||||
metadata {
|
||||
name = "coder-${data.coder_workspace.me.id}-home"
|
||||
namespace = var.namespace
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "coder-pvc"
|
||||
"app.kubernetes.io/instance" = "coder-pvc-${data.coder_workspace.me.id}"
|
||||
"app.kubernetes.io/part-of" = "coder"
|
||||
//Coder-specific labels.
|
||||
"com.coder.resource" = "true"
|
||||
"com.coder.workspace.id" = data.coder_workspace.me.id
|
||||
"com.coder.workspace.name" = data.coder_workspace.me.name
|
||||
"com.coder.user.id" = data.coder_workspace_owner.me.id
|
||||
"com.coder.user.username" = data.coder_workspace_owner.me.name
|
||||
}
|
||||
annotations = {
|
||||
"com.coder.user.email" = data.coder_workspace_owner.me.email
|
||||
}
|
||||
}
|
||||
wait_until_bound = false
|
||||
spec {
|
||||
access_modes = ["ReadWriteOnce"]
|
||||
resources {
|
||||
requests = {
|
||||
storage = "${data.coder_parameter.home_disk_size.value}Gi"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_deployment_v1" "main" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
depends_on = [
|
||||
kubernetes_persistent_volume_claim_v1.home
|
||||
]
|
||||
wait_for_rollout = false
|
||||
metadata {
|
||||
name = "coder-${data.coder_workspace.me.id}"
|
||||
namespace = var.namespace
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
|
||||
"app.kubernetes.io/part-of" = "coder"
|
||||
"com.coder.resource" = "true"
|
||||
"com.coder.workspace.id" = data.coder_workspace.me.id
|
||||
"com.coder.workspace.name" = data.coder_workspace.me.name
|
||||
"com.coder.user.id" = data.coder_workspace_owner.me.id
|
||||
"com.coder.user.username" = data.coder_workspace_owner.me.name
|
||||
}
|
||||
annotations = {
|
||||
"com.coder.user.email" = data.coder_workspace_owner.me.email
|
||||
}
|
||||
}
|
||||
|
||||
spec {
|
||||
replicas = 1
|
||||
selector {
|
||||
match_labels = {
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
|
||||
"app.kubernetes.io/part-of" = "coder"
|
||||
"com.coder.resource" = "true"
|
||||
"com.coder.workspace.id" = data.coder_workspace.me.id
|
||||
"com.coder.workspace.name" = data.coder_workspace.me.name
|
||||
"com.coder.user.id" = data.coder_workspace_owner.me.id
|
||||
"com.coder.user.username" = data.coder_workspace_owner.me.name
|
||||
}
|
||||
}
|
||||
strategy {
|
||||
type = "Recreate"
|
||||
}
|
||||
|
||||
template {
|
||||
metadata {
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
|
||||
"app.kubernetes.io/part-of" = "coder"
|
||||
"com.coder.resource" = "true"
|
||||
"com.coder.workspace.id" = data.coder_workspace.me.id
|
||||
"com.coder.workspace.name" = data.coder_workspace.me.name
|
||||
"com.coder.user.id" = data.coder_workspace_owner.me.id
|
||||
"com.coder.user.username" = data.coder_workspace_owner.me.name
|
||||
}
|
||||
}
|
||||
spec {
|
||||
security_context {
|
||||
run_as_user = 1000
|
||||
fs_group = 1000
|
||||
run_as_non_root = true
|
||||
}
|
||||
|
||||
container {
|
||||
name = "dev"
|
||||
image = "codercom/enterprise-base:ubuntu"
|
||||
image_pull_policy = "Always"
|
||||
command = ["sh", "-c", coder_agent.main.init_script]
|
||||
security_context {
|
||||
run_as_user = "1000"
|
||||
}
|
||||
env {
|
||||
name = "CODER_AGENT_TOKEN"
|
||||
value = coder_agent.main.token
|
||||
}
|
||||
resources {
|
||||
requests = {
|
||||
"cpu" = "250m"
|
||||
"memory" = "512Mi"
|
||||
}
|
||||
limits = {
|
||||
"cpu" = "${data.coder_parameter.cpu.value}"
|
||||
"memory" = "${data.coder_parameter.memory.value}Gi"
|
||||
}
|
||||
}
|
||||
volume_mount {
|
||||
mount_path = "/home/coder"
|
||||
name = "home"
|
||||
read_only = false
|
||||
}
|
||||
}
|
||||
|
||||
volume {
|
||||
name = "home"
|
||||
persistent_volume_claim {
|
||||
claim_name = kubernetes_persistent_volume_claim_v1.home.metadata.0.name
|
||||
read_only = false
|
||||
}
|
||||
}
|
||||
|
||||
affinity {
|
||||
// This affinity attempts to spread out all workspace pods evenly across
|
||||
// nodes.
|
||||
pod_anti_affinity {
|
||||
preferred_during_scheduling_ignored_during_execution {
|
||||
weight = 1
|
||||
pod_affinity_term {
|
||||
topology_key = "kubernetes.io/hostname"
|
||||
label_selector {
|
||||
match_expressions {
|
||||
key = "app.kubernetes.io/name"
|
||||
operator = "In"
|
||||
values = ["coder-workspace"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user