diff --git a/coderd/templatebuilder/bases.go b/coderd/templatebuilder/bases.go new file mode 100644 index 0000000000..90d91f32a4 --- /dev/null +++ b/coderd/templatebuilder/bases.go @@ -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 +} diff --git a/coderd/templatebuilder/bases/aws-linux/base.json b/coderd/templatebuilder/bases/aws-linux/base.json new file mode 100644 index 0000000000..e8ae5c4473 --- /dev/null +++ b/coderd/templatebuilder/bases/aws-linux/base.json @@ -0,0 +1,6 @@ +{ + "id": "aws-linux", + "display_name": "AWS EC2 (Linux)", + "os": "linux", + "default_context": {} +} diff --git a/coderd/templatebuilder/bases/aws-linux/cloud-init/cloud-config.yaml.tftpl b/coderd/templatebuilder/bases/aws-linux/cloud-init/cloud-config.yaml.tftpl new file mode 100644 index 0000000000..14da769454 --- /dev/null +++ b/coderd/templatebuilder/bases/aws-linux/cloud-init/cloud-config.yaml.tftpl @@ -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 diff --git a/coderd/templatebuilder/bases/aws-linux/cloud-init/userdata.sh.tftpl b/coderd/templatebuilder/bases/aws-linux/cloud-init/userdata.sh.tftpl new file mode 100644 index 0000000000..2070bc4df3 --- /dev/null +++ b/coderd/templatebuilder/bases/aws-linux/cloud-init/userdata.sh.tftpl @@ -0,0 +1,2 @@ +#!/bin/bash +sudo -u '${linux_user}' sh -c '${init_script}' diff --git a/coderd/templatebuilder/bases/aws-linux/main.tf.tmpl b/coderd/templatebuilder/bases/aws-linux/main.tf.tmpl new file mode 100644 index 0000000000..15eb600644 --- /dev/null +++ b/coderd/templatebuilder/bases/aws-linux/main.tf.tmpl @@ -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" +} diff --git a/coderd/templatebuilder/bases/docker/base.json b/coderd/templatebuilder/bases/docker/base.json new file mode 100644 index 0000000000..09b8224c01 --- /dev/null +++ b/coderd/templatebuilder/bases/docker/base.json @@ -0,0 +1,8 @@ +{ + "id": "docker", + "display_name": "Docker", + "os": "linux", + "default_context": { + "container_image": "codercom/enterprise-base:ubuntu" + } +} diff --git a/coderd/templatebuilder/bases/docker/main.tf.tmpl b/coderd/templatebuilder/bases/docker/main.tf.tmpl new file mode 100644 index 0000000000..b044974892 --- /dev/null +++ b/coderd/templatebuilder/bases/docker/main.tf.tmpl @@ -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 = <