diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d654a6a50..0637da4bf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,16 +9,16 @@ concurrency: jobs: validate-contributors: runs-on: ubuntu-latest - strategy: - fail-fast: true - matrix: - go-version: ["1.23.x"] + env: + runnerUsername: ${{ github.actor }} steps: - name: Check out code uses: actions/checkout@v4 + - name: Test branch output + run: git branch --show-current - name: Set up Go uses: actions/setup-go@v5 with: go-version: "1.23.2" - name: Validate - run: go run ./scripts/validate-contributor-readmes/main.go + run: go build ./scripts/validate-contributors && ./validate-contributors diff --git a/.gitignore b/.gitignore index 1170717c1..7fda68e3c 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,9 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +## Script artifacts +/validate-contributors +/validate-modules +/validate-templates +/validate-repo-structure diff --git a/cmd/github/github.go b/cmd/github/github.go new file mode 100644 index 000000000..6562e6d28 --- /dev/null +++ b/cmd/github/github.go @@ -0,0 +1,13 @@ +// Package github contains utilities for making it easier to access GitHub +// resources via its official API +package github + +func ActionsRunnerUsername() (string, error) { + return "Parkreiner", nil +} + +func FetchCoderEmployeeUsernames() (map[string]struct{}, error) { + m := map[string]struct{}{} + m["Parkreiner"] = struct{}{} + return m, nil +} diff --git a/cmd/readme/readme.go b/cmd/readme/readme.go new file mode 100644 index 000000000..d1afe4d56 --- /dev/null +++ b/cmd/readme/readme.go @@ -0,0 +1,125 @@ +// Package readme contains general-use utilities for processing README files. +package readme + +import ( + "bufio" + "errors" + "fmt" + "strings" +) + +// RootRegistryPath is the directory where all READMEs that need to be validated +// should live. +const RootRegistryPath = "./registry" + +// Readme represents a single README file within the repo (usually within the +// /registry directory). +type Readme struct { + FilePath string + RawText string +} + +// SeparateFrontmatter attempts to separate a README file's frontmatter content +// from the main README body, returning both values in that order. It does not +// validate whether the structure of the frontmatter is valid (i.e., that it's +// structured as YAML). +func SeparateFrontmatter(readmeText string) (string, string, error) { + if readmeText == "" { + return "", "", errors.New("README is empty") + } + + const fence = "---" + fm := "" + body := "" + fenceCount := 0 + lineScanner := bufio.NewScanner( + strings.NewReader(strings.TrimSpace(readmeText)), + ) + for lineScanner.Scan() { + nextLine := lineScanner.Text() + if fenceCount < 2 && nextLine == fence { + fenceCount++ + continue + } + // Break early if the very first line wasn't a fence, because then we + // know for certain that the README has problems + if fenceCount == 0 { + break + } + + // It should be safe to trim each line of the frontmatter on a per-line + // basis, because there shouldn't be any extra meaning attached to the + // indentation. The same does NOT apply to the README; best we can do is + // gather all the lines, and then trim around it + if inReadmeBody := fenceCount >= 2; inReadmeBody { + body += nextLine + "\n" + } else { + fm += strings.TrimSpace(nextLine) + "\n" + } + } + if fenceCount < 2 { + return "", "", errors.New("README does not have two sets of frontmatter fences") + } + if fm == "" { + return "", "", errors.New("readme has frontmatter fences but no frontmatter content") + } + + return fm, strings.TrimSpace(body), nil +} + +// ValidationPhase represents a specific phase during README validation. It is +// expected that each phase is discrete, and errors during one will prevent a +// future phase from starting. +type ValidationPhase int + +const ( + // ValidationPhaseFilesystemRead indicates when a README file is being read + // from the file system + ValidationPhaseFilesystemRead ValidationPhase = iota + // ValidationPhaseReadmeParsing indicates when a README's frontmatter is being + // parsed as YAML. This phase does not include YAML validation. + ValidationPhaseReadmeParsing + // ValidationPhaseReadmeValidation indicates when a README's frontmatter is + // being validated as proper YAML with expected keys. + ValidationPhaseReadmeValidation + // ValidationPhaseAssetCrossReference indicates when a README's frontmatter + // is having all its relative URLs be validated for whether they point to + // valid resources. + ValidationPhaseAssetCrossReference +) + +func (p ValidationPhase) String() string { + switch p { + case ValidationPhaseFilesystemRead: + return "Filesystem reading" + case ValidationPhaseReadmeParsing: + return "README parsing" + case ValidationPhaseReadmeValidation: + return "README validation" + case ValidationPhaseAssetCrossReference: + return "Cross-referencing asset references" + default: + return "Unknown validation phase" + } +} + +var _ error = ValidationPhaseError{} + +// ValidationPhaseError represents an error that occurred during a specific +// phase of README validation. It should be used to collect ALL validation +// errors that happened during a specific phase, rather than the first one +// encountered. +type ValidationPhaseError struct { + Phase ValidationPhase + Errors []error +} + +func (vpe ValidationPhaseError) Error() string { + msg := fmt.Sprintf("Error during %q phase of README validation:", vpe.Phase.String()) + for _, e := range vpe.Errors { + msg += fmt.Sprintf("\n- %v", e) + } + msg += "\n" + + return msg +} diff --git a/registry/TheZoker/README.md b/registry/TheZoker/README.md new file mode 100644 index 000000000..14b28b0f2 --- /dev/null +++ b/registry/TheZoker/README.md @@ -0,0 +1,7 @@ +--- +display_name: The Zoker +bio: I'm a master computer science student at the TU munich and a webdesigner. +github: TheZoker +website: https://gareis.io/ +status: community +--- diff --git a/registry/TheZoker/modules/nodejs/README.md b/registry/TheZoker/modules/nodejs/README.md new file mode 100644 index 000000000..58a1a6029 --- /dev/null +++ b/registry/TheZoker/modules/nodejs/README.md @@ -0,0 +1,60 @@ +--- +display_name: Node.js +description: Install Node.js via nvm +icon: ../.icons/node.svg +verified: false +tags: [helper] +--- + +# nodejs + +Automatically installs [Node.js](https://github.com/nodejs/node) via [nvm](https://github.com/nvm-sh/nvm). It can also install multiple versions of node and set a default version. If no options are specified, the latest version is installed. + +```tf +module "nodejs" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/nodejs/coder" + version = "1.0.10" + agent_id = coder_agent.example.id +} +``` + +## Install multiple versions + +This installs multiple versions of Node.js: + +```tf +module "nodejs" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/nodejs/coder" + version = "1.0.10" + agent_id = coder_agent.example.id + node_versions = [ + "18", + "20", + "node" + ] + default_node_version = "20" +} +``` + +## Full example + +A example with all available options: + +```tf +module "nodejs" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/nodejs/coder" + version = "1.0.10" + agent_id = coder_agent.example.id + nvm_version = "v0.39.7" + nvm_install_prefix = "/opt/nvm" + node_versions = [ + "16", + "18", + "node" + ] + default_node_version = "16" +} +``` diff --git a/registry/WhizUs/README.md b/registry/WhizUs/README.md new file mode 100644 index 000000000..ad575f96d --- /dev/null +++ b/registry/WhizUs/README.md @@ -0,0 +1,8 @@ +--- +display_name: WhizUs +bio: WhizUs is your premier choice for DevOps, Kubernetes, and Cloud Native consulting. Based in Vienna we combine our expert solutions with a strong commitment to the community. Explore automation, scalability and drive success through collaboration. +github: WhizUs +linkedin: https://www.linkedin.com/company/whizus +website: https://gareis.io/ +status: community +--- diff --git a/registry/WhizUs/modules/exoscale-instance-type/README.md b/registry/WhizUs/modules/exoscale-instance-type/README.md new file mode 100644 index 000000000..af92ce115 --- /dev/null +++ b/registry/WhizUs/modules/exoscale-instance-type/README.md @@ -0,0 +1,116 @@ +--- +display_name: Exoscale Instance Type +description: A parameter with human readable exoscale instance names +icon: ../.icons/exoscale.svg +verified: false +tags: [helper, parameter, instances, exoscale] +--- + +# exoscale-instance-type + +A parameter with all Exoscale instance types. This allows developers to select +their desired virtual machine for the workspace. + +Customize the preselected parameter value: + +```tf +module "exoscale-instance-type" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/exoscale-instance-type/coder" + version = "1.0.12" + default = "standard.medium" +} + +resource "exoscale_compute_instance" "instance" { + type = module.exoscale-instance-type.value + # ... +} + +resource "coder_metadata" "workspace_info" { + item { + key = "instance type" + value = module.exoscale-instance-type.name + } +} +``` + +![Exoscale instance types](../.images/exoscale-instance-types.png) + +## Examples + +### Customize type + +Change the display name a type using the corresponding maps: + +```tf +module "exoscale-instance-type" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/exoscale-instance-type/coder" + version = "1.0.12" + default = "standard.medium" + + custom_names = { + "standard.medium" : "Mittlere Instanz" # German translation + } + + custom_descriptions = { + "standard.medium" : "4 GB Arbeitsspeicher, 2 Kerne, 10 - 400 GB Festplatte" # German translation + } +} + +resource "exoscale_compute_instance" "instance" { + type = module.exoscale-instance-type.value + # ... +} + +resource "coder_metadata" "workspace_info" { + item { + key = "instance type" + value = module.exoscale-instance-type.name + } +} +``` + +![Exoscale instance types Custom](../.images/exoscale-instance-custom.png) + +### Use category and exclude type + +Show only gpu1 types + +```tf +module "exoscale-instance-type" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/exoscale-instance-type/coder" + version = "1.0.12" + default = "gpu.large" + type_category = ["gpu"] + exclude = [ + "gpu2.small", + "gpu2.medium", + "gpu2.large", + "gpu2.huge", + "gpu3.small", + "gpu3.medium", + "gpu3.large", + "gpu3.huge" + ] +} + +resource "exoscale_compute_instance" "instance" { + type = module.exoscale-instance-type.value + # ... +} + +resource "coder_metadata" "workspace_info" { + item { + key = "instance type" + value = module.exoscale-instance-type.name + } +} +``` + +![Exoscale instance types category and exclude](../.images/exoscale-instance-exclude.png) + +## Related templates + +A related exoscale template will be provided soon. diff --git a/registry/WhizUs/modules/exoscale-instance-type/main.test.ts b/registry/WhizUs/modules/exoscale-instance-type/main.test.ts new file mode 100644 index 000000000..e4b998bc0 --- /dev/null +++ b/registry/WhizUs/modules/exoscale-instance-type/main.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("exoscale-instance-type", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe(""); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "gpu3.huge", + type_category: `["gpu", "cpu"]`, + }); + expect(state.outputs.value.value).toBe("gpu3.huge"); + }); + + it("fails because of wrong categroy definition", async () => { + expect(async () => { + await runTerraformApply(import.meta.dir, { + default: "gpu3.huge", + // type_category: ["standard"] is standard + }); + }).toThrow('default value "gpu3.huge" must be defined as one of options'); + }); + + it("set custom order for coder_parameter", async () => { + const order = 99; + const state = await runTerraformApply(import.meta.dir, { + coder_parameter_order: order.toString(), + }); + expect(state.resources).toHaveLength(1); + expect(state.resources[0].instances[0].attributes.order).toBe(order); + }); +}); diff --git a/registry/WhizUs/modules/exoscale-instance-type/main.tf b/registry/WhizUs/modules/exoscale-instance-type/main.tf new file mode 100644 index 000000000..65d372919 --- /dev/null +++ b/registry/WhizUs/modules/exoscale-instance-type/main.tf @@ -0,0 +1,286 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "display_name" { + default = "Exoscale instance type" + description = "The display name of the parameter." + type = string +} + +variable "description" { + default = "Select the exoscale instance type to use for the workspace. Check out the pricing page for more information: https://www.exoscale.com/pricing" + description = "The description of the parameter." + type = string +} + +variable "default" { + default = "" + description = "The default instance type to use if no type is specified. One of [\"standard.micro\", \"standard.tiny\", \"standard.small\", \"standard.medium\", \"standard.large\", \"standard.extra\", \"standard.huge\", \"standard.mega\", \"standard.titan\", \"standard.jumbo\", \"standard.colossus\", \"cpu.extra\", \"cpu.huge\", \"cpu.mega\", \"cpu.titan\", \"memory.extra\", \"memory.huge\", \"memory.mega\", \"memory.titan\", \"storage.extra\", \"storage.huge\", \"storage.mega\", \"storage.titan\", \"storage.jumbo\", \"gpu.small\", \"gpu.medium\", \"gpu.large\", \"gpu.huge\", \"gpu2.small\", \"gpu2.medium\", \"gpu2.large\", \"gpu2.huge\", \"gpu3.small\", \"gpu3.medium\", \"gpu3.large\", \"gpu3.huge\"]" + type = string +} + +variable "mutable" { + default = false + description = "Whether the parameter can be changed after creation." + type = bool +} + +variable "custom_names" { + default = {} + description = "A map of custom display names for instance type IDs." + type = map(string) +} +variable "custom_descriptions" { + default = {} + description = "A map of custom descriptions for instance type IDs." + type = map(string) +} + +variable "type_category" { + default = ["standard"] + description = "A list of instance type categories the user is allowed to choose. One of [\"standard\", \"cpu\", \"memory\", \"storage\", \"gpu\"]" + type = list(string) +} + +variable "exclude" { + default = [] + description = "A list of instance type IDs to exclude. One of [\"standard.micro\", \"standard.tiny\", \"standard.small\", \"standard.medium\", \"standard.large\", \"standard.extra\", \"standard.huge\", \"standard.mega\", \"standard.titan\", \"standard.jumbo\", \"standard.colossus\", \"cpu.extra\", \"cpu.huge\", \"cpu.mega\", \"cpu.titan\", \"memory.extra\", \"memory.huge\", \"memory.mega\", \"memory.titan\", \"storage.extra\", \"storage.huge\", \"storage.mega\", \"storage.titan\", \"storage.jumbo\", \"gpu.small\", \"gpu.medium\", \"gpu.large\", \"gpu.huge\", \"gpu2.small\", \"gpu2.medium\", \"gpu2.large\", \"gpu2.huge\", \"gpu3.small\", \"gpu3.medium\", \"gpu3.large\", \"gpu3.huge\"]" + type = list(string) +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +locals { + # https://www.exoscale.com/pricing/ + + standard_instances = [ + { + value = "standard.micro", + name = "Standard Micro", + description = "512 MB RAM, 1 Core, 10 - 200 GB Disk" + }, + { + value = "standard.tiny", + name = "Standard Tiny", + description = "1 GB RAM, 1 Core, 10 - 400 GB Disk" + }, + { + value = "standard.small", + name = "Standard Small", + description = "2 GB RAM, 2 Cores, 10 - 400 GB Disk" + }, + { + value = "standard.medium", + name = "Standard Medium", + description = "4 GB RAM, 2 Cores, 10 - 400 GB Disk" + }, + { + value = "standard.large", + name = "Standard Large", + description = "8 GB RAM, 4 Cores, 10 - 400 GB Disk" + }, + { + value = "standard.extra", + name = "Standard Extra", + description = "rge", + description = "16 GB RAM, 4 Cores, 10 - 800 GB Disk" + }, + { + value = "standard.huge", + name = "Standard Huge", + description = "32 GB RAM, 8 Cores, 10 - 800 GB Disk" + }, + { + value = "standard.mega", + name = "Standard Mega", + description = "64 GB RAM, 12 Cores, 10 - 800 GB Disk" + }, + { + value = "standard.titan", + name = "Standard Titan", + description = "128 GB RAM, 16 Cores, 10 - 1.6 TB Disk" + }, + { + value = "standard.jumbo", + name = "Standard Jumbo", + description = "256 GB RAM, 24 Cores, 10 - 1.6 TB Disk" + }, + { + value = "standard.colossus", + name = "Standard Colossus", + description = "320 GB RAM, 40 Cores, 10 - 1.6 TB Disk" + } + ] + cpu_instances = [ + { + value = "cpu.extra", + name = "CPU Extra-Large", + description = "16 GB RAM, 8 Cores, 10 - 800 GB Disk" + }, + { + value = "cpu.huge", + name = "CPU Huge", + description = "32 GB RAM, 16 Cores, 10 - 800 GB Disk" + }, + { + value = "cpu.mega", + name = "CPU Mega", + description = "64 GB RAM, 32 Cores, 10 - 800 GB Disk" + }, + { + value = "cpu.titan", + name = "CPU Titan", + description = "128 GB RAM, 40 Cores, 0.1 - 1.6 TB Disk" + } + ] + memory_instances = [ + { + value = "memory.extra", + name = "Memory Extra-Large", + description = "16 GB RAM, 2 Cores, 10 - 800 GB Disk" + }, + { + value = "memory.huge", + name = "Memory Huge", + description = "32 GB RAM, 4 Cores, 10 - 800 GB Disk" + }, + { + value = "memory.mega", + name = "Memory Mega", + description = "64 GB RAM, 8 Cores, 10 - 800 GB Disk" + }, + { + value = "memory.titan", + name = "Memory Titan", + description = "128 GB RAM, 12 Cores, 0.1 - 1.6 TB Disk" + } + ] + storage_instances = [ + { + value = "storage.extra", + name = "Storage Extra-Large", + description = "16 GB RAM, 4 Cores, 1 - 2 TB Disk" + }, + { + value = "storage.huge", + name = "Storage Huge", + description = "32 GB RAM, 8 Cores, 2 - 3 TB Disk" + }, + { + value = "storage.mega", + name = "Storage Mega", + description = "64 GB RAM, 12 Cores, 3 - 5 TB Disk" + }, + { + value = "storage.titan", + name = "Storage Titan", + description = "128 GB RAM, 16 Cores, 5 - 10 TB Disk" + }, + { + value = "storage.jumbo", + name = "Storage Jumbo", + description = "225 GB RAM, 24 Cores, 10 - 15 TB Disk" + } + ] + gpu_instances = [ + { + value = "gpu.small", + name = "GPU1 Small", + description = "56 GB RAM, 12 Cores, 1 GPU, 100 - 800 GB Disk" + }, + { + value = "gpu.medium", + name = "GPU1 Medium", + description = "90 GB RAM, 16 Cores, 2 GPU, 0.1 - 1.2 TB Disk" + }, + { + value = "gpu.large", + name = "GPU1 Large", + description = "120 GB RAM, 24 Cores, 3 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu.huge", + name = "GPU1 Huge", + description = "225 GB RAM, 48 Cores, 4 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu2.small", + name = "GPU2 Small", + description = "56 GB RAM, 12 Cores, 1 GPU, 100 - 800 GB Disk" + }, + { + value = "gpu2.medium", + name = "GPU2 Medium", + description = "90 GB RAM, 16 Cores, 2 GPU, 0.1 - 1.2 TB Disk" + }, + { + value = "gpu2.large", + name = "GPU2 Large", + description = "120 GB RAM, 24 Cores, 3 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu2.huge", + name = "GPU2 Huge", + description = "225 GB RAM, 48 Cores, 4 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu3.small", + name = "GPU3 Small", + description = "56 GB RAM, 12 Cores, 1 GPU, 100 - 800 GB Disk" + }, + { + value = "gpu3.medium", + name = "GPU3 Medium", + description = "120 GB RAM, 24 Cores, 2 GPU, 0.1 - 1.2 TB Disk" + }, + { + value = "gpu3.large", + name = "GPU3 Large", + description = "224 GB RAM, 48 Cores, 4 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu3.huge", + name = "GPU3 Huge", + description = "448 GB RAM, 96 Cores, 8 GPU, 0.1 - 1.6 TB Disk" + } + ] +} + +data "coder_parameter" "instance_type" { + name = "exoscale_instance_type" + display_name = var.display_name + description = var.description + default = var.default == "" ? null : var.default + order = var.coder_parameter_order + mutable = var.mutable + dynamic "option" { + for_each = [for k, v in concat( + contains(var.type_category, "standard") ? local.standard_instances : [], + contains(var.type_category, "cpu") ? local.cpu_instances : [], + contains(var.type_category, "memory") ? local.memory_instances : [], + contains(var.type_category, "storage") ? local.storage_instances : [], + contains(var.type_category, "gpu") ? local.gpu_instances : [] + ) : v if !(contains(var.exclude, v.value))] + content { + name = try(var.custom_names[option.value.value], option.value.name) + description = try(var.custom_descriptions[option.value.value], option.value.description) + value = option.value.value + } + } +} + +output "value" { + value = data.coder_parameter.instance_type.value +} diff --git a/registry/coder/modules/amazon-dcv-windows/README.md b/registry/coder/modules/amazon-dcv-windows/README.md new file mode 100644 index 000000000..6179bf47d --- /dev/null +++ b/registry/coder/modules/amazon-dcv-windows/README.md @@ -0,0 +1,47 @@ +--- +display_name: Amazon DCV Windows +description: Amazon DCV Server and Web Client for Windows +icon: ../.icons/dcv.svg +tags: [windows, amazon, dcv, web, desktop] +--- + +# Amazon DCV Windows + +Amazon DCV is high performance remote display protocol that provides a secure way to deliver remote desktop and application streaming from any cloud or data center to any device, over varying network conditions. + +![Amazon DCV on a Windows workspace](../.images/amazon-dcv-windows.png) + +Enable DCV Server and Web Client on Windows workspaces. + +```tf +module "dcv" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/amazon-dcv-windows/coder" + version = "1.0.24" + agent_id = resource.coder_agent.main.id +} + + +resource "coder_metadata" "dcv" { + count = data.coder_workspace.me.start_count + resource_id = aws_instance.dev.id # id of the instance resource + + item { + key = "DCV client instructions" + value = "Run `coder port-forward ${data.coder_workspace.me.name} -p ${module.dcv[count.index].port}` and connect to **localhost:${module.dcv[count.index].port}${module.dcv[count.index].web_url_path}**" + } + item { + key = "username" + value = module.dcv[count.index].username + } + item { + key = "password" + value = module.dcv[count.index].password + sensitive = true + } +} +``` + +## License + +Amazon DCV is free to use on AWS EC2 instances but requires a license for other cloud providers. Please see the instructions [here](https://docs.aws.amazon.com/dcv/latest/adminguide/setting-up-license.html#setting-up-license-ec2) for more information. diff --git a/registry/coder/modules/amazon-dcv-windows/install-dcv.ps1 b/registry/coder/modules/amazon-dcv-windows/install-dcv.ps1 new file mode 100644 index 000000000..2b1c9f4b2 --- /dev/null +++ b/registry/coder/modules/amazon-dcv-windows/install-dcv.ps1 @@ -0,0 +1,170 @@ +# Terraform variables +$adminPassword = "${admin_password}" +$port = "${port}" +$webURLPath = "${web_url_path}" + +function Set-LocalAdminUser { + Write-Output "[INFO] Starting Set-LocalAdminUser function" + $securePassword = ConvertTo-SecureString $adminPassword -AsPlainText -Force + Write-Output "[DEBUG] Secure password created" + Get-LocalUser -Name Administrator | Set-LocalUser -Password $securePassword + Write-Output "[INFO] Administrator password set" + Get-LocalUser -Name Administrator | Enable-LocalUser + Write-Output "[INFO] User Administrator enabled successfully" + Read-Host "[DEBUG] Press Enter to proceed to the next step" +} + +function Get-VirtualDisplayDriverRequired { + Write-Output "[INFO] Starting Get-VirtualDisplayDriverRequired function" + $token = Invoke-RestMethod -Headers @{'X-aws-ec2-metadata-token-ttl-seconds' = '21600'} -Method PUT -Uri http://169.254.169.254/latest/api/token + Write-Output "[DEBUG] Token acquired: $token" + $instanceType = Invoke-RestMethod -Headers @{'X-aws-ec2-metadata-token' = $token} -Method GET -Uri http://169.254.169.254/latest/meta-data/instance-type + Write-Output "[DEBUG] Instance type: $instanceType" + $OSVersion = ((Get-ItemProperty -Path "Microsoft.PowerShell.Core\Registry::\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Name ProductName).ProductName) -replace "[^0-9]", '' + Write-Output "[DEBUG] OS version: $OSVersion" + + # Force boolean result + $result = (($OSVersion -ne "2019") -and ($OSVersion -ne "2022") -and ($OSVersion -ne "2025")) -and (($instanceType[0] -ne 'g') -and ($instanceType[0] -ne 'p')) + Write-Output "[INFO] VirtualDisplayDriverRequired result: $result" + Read-Host "[DEBUG] Press Enter to proceed to the next step" + return [bool]$result +} + +function Download-DCV { + param ( + [bool]$VirtualDisplayDriverRequired + ) + Write-Output "[INFO] Starting Download-DCV function" + + $downloads = @( + @{ + Name = "DCV Display Driver" + Required = $VirtualDisplayDriverRequired + Path = "C:\Windows\Temp\DCVDisplayDriver.msi" + Uri = "https://d1uj6qtbmh3dt5.cloudfront.net/nice-dcv-virtual-display-x64-Release.msi" + }, + @{ + Name = "DCV Server" + Required = $true + Path = "C:\Windows\Temp\DCVServer.msi" + Uri = "https://d1uj6qtbmh3dt5.cloudfront.net/nice-dcv-server-x64-Release.msi" + } + ) + + foreach ($download in $downloads) { + if ($download.Required -and -not (Test-Path $download.Path)) { + try { + Write-Output "[INFO] Downloading $($download.Name)" + + # Display progress manually (no events) + $progressActivity = "Downloading $($download.Name)" + $progressStatus = "Starting download..." + Write-Progress -Activity $progressActivity -Status $progressStatus -PercentComplete 0 + + # Synchronously download the file + $webClient = New-Object System.Net.WebClient + $webClient.DownloadFile($download.Uri, $download.Path) + + # Update progress + Write-Progress -Activity $progressActivity -Status "Completed" -PercentComplete 100 + + Write-Output "[INFO] $($download.Name) downloaded successfully." + } catch { + Write-Output "[ERROR] Failed to download $($download.Name): $_" + throw + } + } else { + Write-Output "[INFO] $($download.Name) already exists. Skipping download." + } + } + + Write-Output "[INFO] All downloads completed" + Read-Host "[DEBUG] Press Enter to proceed to the next step" +} + +function Install-DCV { + param ( + [bool]$VirtualDisplayDriverRequired + ) + Write-Output "[INFO] Starting Install-DCV function" + + if (-not (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue)) { + if ($VirtualDisplayDriverRequired) { + Write-Output "[INFO] Installing DCV Display Driver" + Start-Process "C:\Windows\System32\msiexec.exe" -ArgumentList "/I C:\Windows\Temp\DCVDisplayDriver.msi /quiet /norestart" -Wait + } else { + Write-Output "[INFO] DCV Display Driver installation skipped (not required)." + } + Write-Output "[INFO] Installing DCV Server" + Start-Process "C:\Windows\System32\msiexec.exe" -ArgumentList "/I C:\Windows\Temp\DCVServer.msi ADDLOCAL=ALL /quiet /norestart /l*v C:\Windows\Temp\dcv_install_msi.log" -Wait + } else { + Write-Output "[INFO] DCV Server already installed, skipping installation." + } + + # Wait for the service to appear with a timeout + $timeout = 10 # seconds + $elapsed = 0 + while (-not (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue) -and ($elapsed -lt $timeout)) { + Start-Sleep -Seconds 1 + $elapsed++ + } + + if ($elapsed -ge $timeout) { + Write-Output "[WARNING] Timeout waiting for dcvserver service. A restart is required to complete installation." + Restart-SystemForDCV + } else { + Write-Output "[INFO] dcvserver service detected successfully." + } +} + +function Restart-SystemForDCV { + Write-Output "[INFO] The system will restart in 10 seconds to finalize DCV installation." + Start-Sleep -Seconds 10 + + # Initiate restart + Restart-Computer -Force + + # Exit the script after initiating restart + Write-Output "[INFO] Please wait for the system to restart..." + + Exit 1 +} + + +function Configure-DCV { + Write-Output "[INFO] Starting Configure-DCV function" + $dcvPath = "Microsoft.PowerShell.Core\Registry::\HKEY_USERS\S-1-5-18\Software\GSettings\com\nicesoftware\dcv" + + # Create the required paths + @("$dcvPath\connectivity", "$dcvPath\session-management", "$dcvPath\session-management\automatic-console-session", "$dcvPath\display") | ForEach-Object { + if (-not (Test-Path $_)) { + New-Item -Path $_ -Force | Out-Null + } + } + + # Set registry keys + New-ItemProperty -Path "$dcvPath\session-management" -Name create-session -PropertyType DWORD -Value 1 -Force + New-ItemProperty -Path "$dcvPath\session-management\automatic-console-session" -Name owner -Value Administrator -Force + New-ItemProperty -Path "$dcvPath\connectivity" -Name quic-port -PropertyType DWORD -Value $port -Force + New-ItemProperty -Path "$dcvPath\connectivity" -Name web-port -PropertyType DWORD -Value $port -Force + New-ItemProperty -Path "$dcvPath\connectivity" -Name web-url-path -PropertyType String -Value $webURLPath -Force + + # Attempt to restart service + if (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue) { + Restart-Service -Name "dcvserver" + } else { + Write-Output "[WARNING] dcvserver service not found. Ensure the system was restarted properly." + } + + Write-Output "[INFO] DCV configuration completed" + Read-Host "[DEBUG] Press Enter to proceed to the next step" +} + +# Main Script Execution +Write-Output "[INFO] Starting script" +$VirtualDisplayDriverRequired = [bool](Get-VirtualDisplayDriverRequired) +Set-LocalAdminUser +Download-DCV -VirtualDisplayDriverRequired $VirtualDisplayDriverRequired +Install-DCV -VirtualDisplayDriverRequired $VirtualDisplayDriverRequired +Configure-DCV +Write-Output "[INFO] Script completed" diff --git a/registry/coder/modules/amazon-dcv-windows/main.tf b/registry/coder/modules/amazon-dcv-windows/main.tf new file mode 100644 index 000000000..90058af3a --- /dev/null +++ b/registry/coder/modules/amazon-dcv-windows/main.tf @@ -0,0 +1,85 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "admin_password" { + type = string + default = "coderDCV!" + sensitive = true +} + +variable "port" { + type = number + description = "The port number for the DCV server." + default = 8443 +} + +variable "subdomain" { + type = bool + description = "Whether to use a subdomain for the DCV server." + default = true +} + +variable "slug" { + type = string + description = "The slug of the web-dcv coder_app resource." + default = "web-dcv" +} + +resource "coder_app" "web-dcv" { + agent_id = var.agent_id + slug = var.slug + display_name = "Web DCV" + url = "https://localhost:${var.port}${local.web_url_path}?username=${local.admin_username}&password=${var.admin_password}" + icon = "/icon/dcv.svg" + subdomain = var.subdomain +} + +resource "coder_script" "install-dcv" { + agent_id = var.agent_id + display_name = "Install DCV" + icon = "/icon/dcv.svg" + run_on_start = true + script = templatefile("${path.module}/install-dcv.ps1", { + admin_password : var.admin_password, + port : var.port, + web_url_path : local.web_url_path + }) +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +locals { + web_url_path = var.subdomain ? "/" : format("/@%s/%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug) + admin_username = "Administrator" +} + +output "web_url_path" { + value = local.web_url_path +} + +output "username" { + value = local.admin_username +} + +output "password" { + value = var.admin_password + sensitive = true +} + +output "port" { + value = var.port +} diff --git a/registry/coder/modules/apache-airflow/README.md b/registry/coder/modules/apache-airflow/README.md new file mode 100644 index 000000000..cc4a2f86e --- /dev/null +++ b/registry/coder/modules/apache-airflow/README.md @@ -0,0 +1,23 @@ +--- +display_name: Apache Airflow +description: A module that adds Apache Airflow in your Coder template +icon: ../.icons/airflow.svg +verified: true +contributors: [nataindata] +tags: [airflow, idea, web, helper] +--- + +# airflow + +A module that adds Apache Airflow in your Coder template. + +```tf +module "airflow" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/apache-airflow/coder" + version = "1.0.13" + agent_id = coder_agent.main.id +} +``` + +![Airflow](../.images/airflow.png) diff --git a/registry/coder/modules/apache-airflow/main.tf b/registry/coder/modules/apache-airflow/main.tf new file mode 100644 index 000000000..91b6682a5 --- /dev/null +++ b/registry/coder/modules/apache-airflow/main.tf @@ -0,0 +1,65 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "log_path" { + type = string + description = "The path to log airflow to." + default = "/tmp/airflow.log" +} + +variable "port" { + type = number + description = "The port to run airflow on." + default = 8080 +} + +variable "share" { + type = string + default = "owner" + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +resource "coder_script" "airflow" { + agent_id = var.agent_id + display_name = "airflow" + icon = "/icon/apache-guacamole.svg" + script = templatefile("${path.module}/run.sh", { + LOG_PATH : var.log_path, + PORT : var.port + }) + run_on_start = true +} + +resource "coder_app" "airflow" { + agent_id = var.agent_id + slug = "airflow" + display_name = "airflow" + url = "http://localhost:${var.port}" + icon = "/icon/apache-guacamole.svg" + subdomain = true + share = var.share + order = var.order +} diff --git a/registry/coder/modules/apache-airflow/run.sh b/registry/coder/modules/apache-airflow/run.sh new file mode 100644 index 000000000..109d47ec4 --- /dev/null +++ b/registry/coder/modules/apache-airflow/run.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env sh + +BOLD='\033[0;1m' + +PATH=$PATH:~/.local/bin +pip install --upgrade apache-airflow + +filename=~/airflow/airflow.db +if ! [ -f $filename ] || ! [ -s $filename ]; then + airflow db init +fi + +export AIRFLOW__CORE__LOAD_EXAMPLES=false + +airflow webserver >${LOG_PATH} 2>&1 & + +airflow scheduler >>/tmp/airflow_scheduler.log 2>&1 & + +airflow users create -u admin -p admin -r Admin -e admin@admin.com -f Coder -l User diff --git a/registry/coder/modules/jetbrains-gateway/README.md b/registry/coder/modules/jetbrains-gateway/README.md new file mode 100644 index 000000000..16359a5be --- /dev/null +++ b/registry/coder/modules/jetbrains-gateway/README.md @@ -0,0 +1,131 @@ +--- +display_name: JetBrains Gateway +description: Add a one-click button to launch JetBrains Gateway IDEs in the dashboard. +icon: ../.icons/gateway.svg +tags: [ide, jetbrains, helper, parameter] +--- + +# JetBrains Gateway + +This module adds a JetBrains Gateway Button to open any workspace with a single click. + +JetBrains recommends a minimum of 4 CPU cores and 8GB of RAM. +Consult the [JetBrains documentation](https://www.jetbrains.com/help/idea/prerequisites.html#min_requirements) to confirm other system requirements. + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"] + default = "GO" +} +``` + +![JetBrains Gateway IDes list](../.images/jetbrains-gateway.png) + +## Examples + +### Add GoLand and WebStorm as options with the default set to GoLand + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["GO", "WS"] + default = "GO" +} +``` + +### Use the latest version of each IDE + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["IU", "PY"] + default = "IU" + latest = true +} +``` + +### Use fixed versions set by `jetbrains_ide_versions` + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["IU", "PY"] + default = "IU" + latest = false + jetbrains_ide_versions = { + "IU" = { + build_number = "243.21565.193" + version = "2024.3" + } + "PY" = { + build_number = "243.21565.199" + version = "2024.3" + } + } +} +``` + +### Use the latest EAP version + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["GO", "WS"] + default = "GO" + latest = true + channel = "eap" +} +``` + +### Custom base link + +Due to the highest priority of the `ide_download_link` parameter in the `(jetbrains-gateway://...` within IDEA, the pre-configured download address will be overridden when using [IDEA's offline mode](https://www.jetbrains.com/help/idea/fully-offline-mode.html). Therefore, it is necessary to configure the `download_base_link` parameter for the `jetbrains_gateway` module to change the value of `ide_download_link`. + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["GO", "WS"] + releases_base_link = "https://releases.internal.site/" + download_base_link = "https://download.internal.site/" + default = "GO" +} +``` + +## Supported IDEs + +This module and JetBrains Gateway support the following JetBrains IDEs: + +- [GoLand (`GO`)](https://www.jetbrains.com/go/) +- [WebStorm (`WS`)](https://www.jetbrains.com/webstorm/) +- [IntelliJ IDEA Ultimate (`IU`)](https://www.jetbrains.com/idea/) +- [PyCharm Professional (`PY`)](https://www.jetbrains.com/pycharm/) +- [PhpStorm (`PS`)](https://www.jetbrains.com/phpstorm/) +- [CLion (`CL`)](https://www.jetbrains.com/clion/) +- [RubyMine (`RM`)](https://www.jetbrains.com/ruby/) +- [Rider (`RD`)](https://www.jetbrains.com/rider/) +- [RustRover (`RR`)](https://www.jetbrains.com/rust/) diff --git a/registry/coder/modules/jetbrains-gateway/main.test.ts b/registry/coder/modules/jetbrains-gateway/main.test.ts new file mode 100644 index 000000000..ea04a77db --- /dev/null +++ b/registry/coder/modules/jetbrains-gateway/main.test.ts @@ -0,0 +1,43 @@ +import { it, expect, describe } from "bun:test"; +import { + runTerraformInit, + testRequiredVariables, + runTerraformApply, +} from "../test"; + +describe("jetbrains-gateway", async () => { + await runTerraformInit(import.meta.dir); + + await testRequiredVariables(import.meta.dir, { + agent_id: "foo", + folder: "/home/foo", + }); + + it("should create a link with the default values", async () => { + const state = await runTerraformApply(import.meta.dir, { + // These are all required. + agent_id: "foo", + folder: "/home/coder", + }); + expect(state.outputs.url.value).toBe( + "jetbrains-gateway://connect#type=coder&workspace=default&owner=default&folder=/home/coder&url=https://mydeployment.coder.com&token=$SESSION_TOKEN&ide_product_code=IU&ide_build_number=243.21565.193&ide_download_link=https://download.jetbrains.com/idea/ideaIU-2024.3.tar.gz", + ); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "gateway", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBeNull(); + }); + + it("default to first ide", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/foo", + jetbrains_ides: '["IU", "GO", "PY"]', + }); + expect(state.outputs.identifier.value).toBe("IU"); + }); +}); diff --git a/registry/coder/modules/jetbrains-gateway/main.tf b/registry/coder/modules/jetbrains-gateway/main.tf new file mode 100644 index 000000000..d197399d9 --- /dev/null +++ b/registry/coder/modules/jetbrains-gateway/main.tf @@ -0,0 +1,341 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + http = { + source = "hashicorp/http" + version = ">= 3.0" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "slug" { + type = string + description = "The slug for the coder_app. Allows resuing the module with the same template." + default = "gateway" +} + +variable "agent_name" { + type = string + description = "Agent name. (unused). Will be removed in a future version" + + default = "" +} + +variable "folder" { + type = string + description = "The directory to open in the IDE. e.g. /home/coder/project" + validation { + condition = can(regex("^(?:/[^/]+)+$", var.folder)) + error_message = "The folder must be a full path and must not start with a ~." + } +} + +variable "default" { + default = "" + type = string + description = "Default IDE" +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +variable "latest" { + type = bool + description = "Whether to fetch the latest version of the IDE." + default = false +} + +variable "channel" { + type = string + description = "JetBrains IDE release channel. Valid values are release and eap." + default = "release" + validation { + condition = can(regex("^(release|eap)$", var.channel)) + error_message = "The channel must be either release or eap." + } +} + +variable "jetbrains_ide_versions" { + type = map(object({ + build_number = string + version = string + })) + description = "The set of versions for each jetbrains IDE" + default = { + "IU" = { + build_number = "243.21565.193" + version = "2024.3" + } + "PS" = { + build_number = "243.21565.202" + version = "2024.3" + } + "WS" = { + build_number = "243.21565.180" + version = "2024.3" + } + "PY" = { + build_number = "243.21565.199" + version = "2024.3" + } + "CL" = { + build_number = "243.21565.238" + version = "2024.1" + } + "GO" = { + build_number = "243.21565.208" + version = "2024.3" + } + "RM" = { + build_number = "243.21565.197" + version = "2024.3" + } + "RD" = { + build_number = "243.21565.191" + version = "2024.3" + } + "RR" = { + build_number = "243.22562.230" + version = "2024.3" + } + } + validation { + condition = ( + alltrue([ + for code in keys(var.jetbrains_ide_versions) : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"], code) + ]) + ) + error_message = "The jetbrains_ide_versions must contain a map of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"])}." + } +} + +variable "jetbrains_ides" { + type = list(string) + description = "The list of IDE product codes." + default = ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"] + validation { + condition = ( + alltrue([ + for code in var.jetbrains_ides : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"], code) + ]) + ) + error_message = "The jetbrains_ides must be a list of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"])}." + } + # check if the list is empty + validation { + condition = length(var.jetbrains_ides) > 0 + error_message = "The jetbrains_ides must not be empty." + } + # check if the list contains duplicates + validation { + condition = length(var.jetbrains_ides) == length(toset(var.jetbrains_ides)) + error_message = "The jetbrains_ides must not contain duplicates." + } +} + +variable "releases_base_link" { + type = string + description = "" + default = "https://data.services.jetbrains.com" + validation { + condition = can(regex("^https?://.+$", var.releases_base_link)) + error_message = "The releases_base_link must be a valid HTTP/S address." + } +} + +variable "download_base_link" { + type = string + description = "" + default = "https://download.jetbrains.com" + validation { + condition = can(regex("^https?://.+$", var.download_base_link)) + error_message = "The download_base_link must be a valid HTTP/S address." + } +} + +data "http" "jetbrains_ide_versions" { + for_each = var.latest ? toset(var.jetbrains_ides) : toset([]) + url = "${var.releases_base_link}/products/releases?code=${each.key}&latest=true&type=${var.channel}" +} + +locals { + jetbrains_ides = { + "GO" = { + icon = "/icon/goland.svg", + name = "GoLand", + identifier = "GO", + build_number = var.jetbrains_ide_versions["GO"].build_number, + download_link = "${var.download_base_link}/go/goland-${var.jetbrains_ide_versions["GO"].version}.tar.gz" + version = var.jetbrains_ide_versions["GO"].version + }, + "WS" = { + icon = "/icon/webstorm.svg", + name = "WebStorm", + identifier = "WS", + build_number = var.jetbrains_ide_versions["WS"].build_number, + download_link = "${var.download_base_link}/webstorm/WebStorm-${var.jetbrains_ide_versions["WS"].version}.tar.gz" + version = var.jetbrains_ide_versions["WS"].version + }, + "IU" = { + icon = "/icon/intellij.svg", + name = "IntelliJ IDEA Ultimate", + identifier = "IU", + build_number = var.jetbrains_ide_versions["IU"].build_number, + download_link = "${var.download_base_link}/idea/ideaIU-${var.jetbrains_ide_versions["IU"].version}.tar.gz" + version = var.jetbrains_ide_versions["IU"].version + }, + "PY" = { + icon = "/icon/pycharm.svg", + name = "PyCharm Professional", + identifier = "PY", + build_number = var.jetbrains_ide_versions["PY"].build_number, + download_link = "${var.download_base_link}/python/pycharm-professional-${var.jetbrains_ide_versions["PY"].version}.tar.gz" + version = var.jetbrains_ide_versions["PY"].version + }, + "CL" = { + icon = "/icon/clion.svg", + name = "CLion", + identifier = "CL", + build_number = var.jetbrains_ide_versions["CL"].build_number, + download_link = "${var.download_base_link}/cpp/CLion-${var.jetbrains_ide_versions["CL"].version}.tar.gz" + version = var.jetbrains_ide_versions["CL"].version + }, + "PS" = { + icon = "/icon/phpstorm.svg", + name = "PhpStorm", + identifier = "PS", + build_number = var.jetbrains_ide_versions["PS"].build_number, + download_link = "${var.download_base_link}/webide/PhpStorm-${var.jetbrains_ide_versions["PS"].version}.tar.gz" + version = var.jetbrains_ide_versions["PS"].version + }, + "RM" = { + icon = "/icon/rubymine.svg", + name = "RubyMine", + identifier = "RM", + build_number = var.jetbrains_ide_versions["RM"].build_number, + download_link = "${var.download_base_link}/ruby/RubyMine-${var.jetbrains_ide_versions["RM"].version}.tar.gz" + version = var.jetbrains_ide_versions["RM"].version + }, + "RD" = { + icon = "/icon/rider.svg", + name = "Rider", + identifier = "RD", + build_number = var.jetbrains_ide_versions["RD"].build_number, + download_link = "${var.download_base_link}/rider/JetBrains.Rider-${var.jetbrains_ide_versions["RD"].version}.tar.gz" + version = var.jetbrains_ide_versions["RD"].version + }, + "RR" = { + icon = "/icon/rustrover.svg", + name = "RustRover", + identifier = "RR", + build_number = var.jetbrains_ide_versions["RR"].build_number, + download_link = "${var.download_base_link}/rustrover/RustRover-${var.jetbrains_ide_versions["RR"].version}.tar.gz" + version = var.jetbrains_ide_versions["RR"].version + } + } + + icon = local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].icon + json_data = var.latest ? jsondecode(data.http.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].response_body) : {} + key = var.latest ? keys(local.json_data)[0] : "" + display_name = local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].name + identifier = data.coder_parameter.jetbrains_ide.value + download_link = var.latest ? local.json_data[local.key][0].downloads.linux.link : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].download_link + build_number = var.latest ? local.json_data[local.key][0].build : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].build_number + version = var.latest ? local.json_data[local.key][0].version : var.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].version +} + +data "coder_parameter" "jetbrains_ide" { + type = "string" + name = "jetbrains_ide" + display_name = "JetBrains IDE" + icon = "/icon/gateway.svg" + mutable = true + default = var.default == "" ? var.jetbrains_ides[0] : var.default + order = var.coder_parameter_order + + dynamic "option" { + for_each = var.jetbrains_ides + content { + icon = local.jetbrains_ides[option.value].icon + name = local.jetbrains_ides[option.value].name + value = option.value + } + } +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_app" "gateway" { + agent_id = var.agent_id + slug = var.slug + display_name = local.display_name + icon = local.icon + external = true + order = var.order + url = join("", [ + "jetbrains-gateway://connect#type=coder&workspace=", + data.coder_workspace.me.name, + "&owner=", + data.coder_workspace_owner.me.name, + "&folder=", + var.folder, + "&url=", + data.coder_workspace.me.access_url, + "&token=", + "$SESSION_TOKEN", + "&ide_product_code=", + data.coder_parameter.jetbrains_ide.value, + "&ide_build_number=", + local.build_number, + "&ide_download_link=", + local.download_link, + ]) +} + +output "identifier" { + value = local.identifier +} + +output "display_name" { + value = local.display_name +} + +output "icon" { + value = local.icon +} + +output "download_link" { + value = local.download_link +} + +output "build_number" { + value = local.build_number +} + +output "version" { + value = local.version +} + +output "url" { + value = coder_app.gateway.url +} diff --git a/registry/coder/modules/vscode-desktop/README.md b/registry/coder/modules/vscode-desktop/README.md new file mode 100644 index 000000000..501a7a5f7 --- /dev/null +++ b/registry/coder/modules/vscode-desktop/README.md @@ -0,0 +1,35 @@ +--- +display_name: VS Code Desktop +description: Add a one-click button to launch VS Code Desktop +icon: ../.icons/code.svg +tags: [ide, vscode, helper] +--- + +# VS Code Desktop + +Add a button to open any workspace with a single click. + +Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder). + +```tf +module "vscode" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-desktop/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} +``` + +## Examples + +### Open in a specific directory + +```tf +module "vscode" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-desktop/coder" + version = "1.0.15" + agent_id = coder_agent.example.id + folder = "/home/coder/project" +} +``` diff --git a/registry/coder/modules/vscode-desktop/main.test.ts b/registry/coder/modules/vscode-desktop/main.test.ts new file mode 100644 index 000000000..7aa144ec0 --- /dev/null +++ b/registry/coder/modules/vscode-desktop/main.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("vscode-desktop", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "vscode", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBeNull(); + }); + + it("adds folder", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder and open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + open_recent: "true", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder but not open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + openRecent: "false", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + open_recent: "true", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("expect order to be set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + order: "22", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "vscode", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBe(22); + }); +}); diff --git a/registry/coder/modules/vscode-desktop/main.tf b/registry/coder/modules/vscode-desktop/main.tf new file mode 100644 index 000000000..16d070b43 --- /dev/null +++ b/registry/coder/modules/vscode-desktop/main.tf @@ -0,0 +1,62 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "folder" { + type = string + description = "The folder to open in VS Code." + default = "" +} + +variable "open_recent" { + type = bool + description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open." + default = false +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_app" "vscode" { + agent_id = var.agent_id + external = true + icon = "/icon/code.svg" + slug = "vscode" + display_name = "VS Code Desktop" + order = var.order + url = join("", [ + "vscode://coder.coder-remote/open", + "?owner=", + data.coder_workspace_owner.me.name, + "&workspace=", + data.coder_workspace.me.name, + var.folder != "" ? join("", ["&folder=", var.folder]) : "", + var.open_recent ? "&openRecent" : "", + "&url=", + data.coder_workspace.me.access_url, + "&token=$SESSION_TOKEN", + ]) +} + +output "vscode_url" { + value = coder_app.vscode.url + description = "VS Code Desktop URL." +} diff --git a/registry/coder/modules/vscode-web/README.md b/registry/coder/modules/vscode-web/README.md new file mode 100644 index 000000000..d1055b69d --- /dev/null +++ b/registry/coder/modules/vscode-web/README.md @@ -0,0 +1,84 @@ +--- +display_name: VS Code Web +description: VS Code Web - Visual Studio Code in the browser +icon: ../.icons/code.svg +tags: [helper, ide, vscode, web] +--- + +# VS Code Web + +Automatically install [Visual Studio Code Server](https://code.visualstudio.com/docs/remote/vscode-server) in a workspace and create an app to access it via the dashboard. + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + accept_license = true +} +``` + +![VS Code Web with GitHub Copilot and live-share](../.images/vscode-web.gif) + +## Examples + +### Install VS Code Web to a custom folder + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + install_prefix = "/home/coder/.vscode-web" + folder = "/home/coder" + accept_license = true +} +``` + +### Install Extensions + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"] + accept_license = true +} +``` + +### Pre-configure Settings + +Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file: + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + extensions = ["dracula-theme.theme-dracula"] + settings = { + "workbench.colorTheme" = "Dracula" + } + accept_license = true +} +``` + +### Pin a specific VS Code Web version + +By default, this module installs the latest. To pin a specific version, retrieve the commit ID from the [VS Code Update API](https://update.code.visualstudio.com/api/commits/stable/server-linux-x64-web) and verify its corresponding release on the [VS Code GitHub Releases](https://github.com/microsoft/vscode/releases). + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447" + accept_license = true +} +``` diff --git a/registry/coder/modules/vscode-web/main.test.ts b/registry/coder/modules/vscode-web/main.test.ts new file mode 100644 index 000000000..d8e0e68e2 --- /dev/null +++ b/registry/coder/modules/vscode-web/main.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "bun:test"; +import { runTerraformApply, runTerraformInit } from "../test"; + +describe("vscode-web", async () => { + await runTerraformInit(import.meta.dir); + + it("accept_license should be set to true", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: "false", + }); + }; + expect(t).toThrow("Invalid value for variable"); + }); + + it("use_cached and offline can not be used together", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: "true", + use_cached: "true", + offline: "true", + }); + }; + expect(t).toThrow("Offline and Use Cached can not be used together"); + }); + + it("offline and extensions can not be used together", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: "true", + offline: "true", + extensions: '["1", "2"]', + }); + }; + expect(t).toThrow("Offline mode does not allow extensions to be installed"); + }); + + // More tests depend on shebang refactors +}); diff --git a/registry/coder/modules/vscode-web/main.tf b/registry/coder/modules/vscode-web/main.tf new file mode 100644 index 000000000..11e220cd2 --- /dev/null +++ b/registry/coder/modules/vscode-web/main.tf @@ -0,0 +1,198 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "port" { + type = number + description = "The port to run VS Code Web on." + default = 13338 +} + +variable "display_name" { + type = string + description = "The display name for the VS Code Web application." + default = "VS Code Web" +} + +variable "slug" { + type = string + description = "The slug for the VS Code Web application." + default = "vscode-web" +} + +variable "folder" { + type = string + description = "The folder to open in vscode-web." + default = "" +} + +variable "share" { + type = string + default = "owner" + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +variable "log_path" { + type = string + description = "The path to log." + default = "/tmp/vscode-web.log" +} + +variable "install_prefix" { + type = string + description = "The prefix to install vscode-web to." + default = "/tmp/vscode-web" +} + +variable "commit_id" { + type = string + description = "Specify the commit ID of the VS Code Web binary to pin to a specific version. If left empty, the latest stable version is used." + default = "" +} + +variable "extensions" { + type = list(string) + description = "A list of extensions to install." + default = [] +} + +variable "accept_license" { + type = bool + description = "Accept the VS Code Server license. https://code.visualstudio.com/license/server" + default = false + validation { + condition = var.accept_license == true + error_message = "You must accept the VS Code license agreement by setting accept_license=true." + } +} + +variable "telemetry_level" { + type = string + description = "Set the telemetry level for VS Code Web." + default = "error" + validation { + condition = var.telemetry_level == "off" || var.telemetry_level == "crash" || var.telemetry_level == "error" || var.telemetry_level == "all" + error_message = "Incorrect value. Please set either 'off', 'crash', 'error', or 'all'." + } +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "settings" { + type = any + description = "A map of settings to apply to VS Code web." + default = {} +} + +variable "offline" { + type = bool + description = "Just run VS Code Web in the background, don't fetch it from the internet." + default = false +} + +variable "use_cached" { + type = bool + description = "Uses cached copy of VS Code Web in the background, otherwise fetches it from internet." + default = false +} + +variable "extensions_dir" { + type = string + description = "Override the directory to store extensions in." + default = "" +} + +variable "auto_install_extensions" { + type = bool + description = "Automatically install recommended extensions when VS Code Web starts." + default = false +} + +variable "subdomain" { + type = bool + description = <<-EOT + Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. + If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible. + EOT + default = true +} + +data "coder_workspace_owner" "me" {} +data "coder_workspace" "me" {} + +resource "coder_script" "vscode-web" { + agent_id = var.agent_id + display_name = "VS Code Web" + icon = "/icon/code.svg" + script = templatefile("${path.module}/run.sh", { + PORT : var.port, + LOG_PATH : var.log_path, + INSTALL_PREFIX : var.install_prefix, + EXTENSIONS : join(",", var.extensions), + TELEMETRY_LEVEL : var.telemetry_level, + // This is necessary otherwise the quotes are stripped! + SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""), + OFFLINE : var.offline, + USE_CACHED : var.use_cached, + EXTENSIONS_DIR : var.extensions_dir, + FOLDER : var.folder, + AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions, + SERVER_BASE_PATH : local.server_base_path, + COMMIT_ID : var.commit_id, + }) + run_on_start = true + + lifecycle { + precondition { + condition = !var.offline || length(var.extensions) == 0 + error_message = "Offline mode does not allow extensions to be installed" + } + + precondition { + condition = !var.offline || !var.use_cached + error_message = "Offline and Use Cached can not be used together" + } + } +} + +resource "coder_app" "vscode-web" { + agent_id = var.agent_id + slug = var.slug + display_name = var.display_name + url = local.url + icon = "/icon/code.svg" + subdomain = var.subdomain + share = var.share + order = var.order + + healthcheck { + url = local.healthcheck_url + interval = 5 + threshold = 6 + } +} + +locals { + server_base_path = var.subdomain ? "" : format("/@%s/%s/apps/%s/", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug) + url = var.folder == "" ? "http://localhost:${var.port}${local.server_base_path}" : "http://localhost:${var.port}${local.server_base_path}?folder=${var.folder}" + healthcheck_url = var.subdomain ? "http://localhost:${var.port}/healthz" : "http://localhost:${var.port}${local.server_base_path}/healthz" +} diff --git a/registry/coder/modules/vscode-web/run.sh b/registry/coder/modules/vscode-web/run.sh new file mode 100644 index 000000000..588cec56d --- /dev/null +++ b/registry/coder/modules/vscode-web/run.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +BOLD='\033[0;1m' +EXTENSIONS=("${EXTENSIONS}") +VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server" + +# Set extension directory +EXTENSION_ARG="" +if [ -n "${EXTENSIONS_DIR}" ]; then + EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}" +fi + +# Set extension directory +SERVER_BASE_PATH_ARG="" +if [ -n "${SERVER_BASE_PATH}" ]; then + SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}" +fi + +run_vscode_web() { + echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..." + echo "Check logs at ${LOG_PATH}!" + "$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 & +} + +# Check if the settings file exists... +if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then + echo "⚙️ Creating settings file..." + mkdir -p ~/.vscode-server/data/Machine + echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json +fi + +# Check if vscode-server is already installed for offline or cached mode +if [ -f "$VSCODE_WEB" ]; then + if [ "${OFFLINE}" = true ] || [ "${USE_CACHED}" = true ]; then + echo "🥳 Found a copy of VS Code Web" + run_vscode_web + exit 0 + fi +fi +# Offline mode always expects a copy of vscode-server to be present +if [ "${OFFLINE}" = true ]; then + echo "Failed to find a copy of VS Code Web" + exit 1 +fi + +# Create install prefix +mkdir -p ${INSTALL_PREFIX} + +printf "$${BOLD}Installing Microsoft Visual Studio Code Server!\n" + +# Download and extract vscode-server +ARCH=$(uname -m) +case "$ARCH" in + x86_64) ARCH="x64" ;; + aarch64) ARCH="arm64" ;; + *) + echo "Unsupported architecture" + exit 1 + ;; +esac + +# Check if a specific VS Code Web commit ID was provided +if [ -n "${COMMIT_ID}" ]; then + HASH="${COMMIT_ID}" +else + HASH=$(curl -fsSL https://update.code.visualstudio.com/api/commits/stable/server-linux-$ARCH-web | cut -d '"' -f 2) +fi +printf "$${BOLD}VS Code Web commit id version $HASH.\n" + +output=$(curl -fsSL "https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-linux-$ARCH-web.tar.gz" | tar -xz -C "${INSTALL_PREFIX}" --strip-components 1) + +if [ $? -ne 0 ]; then + echo "Failed to install Microsoft Visual Studio Code Server: $output" + exit 1 +fi +printf "$${BOLD}VS Code Web has been installed.\n" + +# Install each extension... +IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}" +for extension in "$${EXTENSIONLIST[@]}"; do + if [ -z "$extension" ]; then + continue + fi + printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n" + output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force) + if [ $? -ne 0 ]; then + echo "Failed to install extension: $extension: $output" + fi +done + +if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then + if ! command -v jq > /dev/null; then + echo "jq is required to install extensions from a workspace file." + else + WORKSPACE_DIR="$HOME" + if [ -n "${FOLDER}" ]; then + WORKSPACE_DIR="${FOLDER}" + fi + + if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then + printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR" + # Use sed to remove single-line comments before parsing with jq + extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR"/.vscode/extensions.json | jq -r '.recommendations[]') + for extension in $extensions; do + $VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force + done + fi + fi +fi + +run_vscode_web diff --git a/scripts/validate-contributor-readmes/main.go b/scripts/validate-contributors/main.go similarity index 82% rename from scripts/validate-contributor-readmes/main.go rename to scripts/validate-contributors/main.go index 6cc3ed7dc..b00eef489 100644 --- a/scripts/validate-contributor-readmes/main.go +++ b/scripts/validate-contributors/main.go @@ -1,8 +1,11 @@ +// This package is for validating all contributors within the main Registry +// directory. It validates that it has nothing but sub-directories, and that +// each sub-directory has a README.md file. Each of those files must then +// describe a specific contributor. The contents of these files will be parsed +// by the Registry site build step to be displayed in the Registry site's UI. package main import ( - "bufio" - "errors" "fmt" "log" "net/url" @@ -11,21 +14,15 @@ import ( "slices" "strings" + "coder.com/coder-registry/cmd/readme" "gopkg.in/yaml.v3" ) -const rootRegistryPath = "./registry" - -type readme struct { - FilePath string - RawText string -} - type contributorProfileFrontmatter struct { DisplayName string `yaml:"display_name"` Bio string `yaml:"bio"` GithubUsername string `yaml:"github"` - AvatarUrl *string `yaml:"avatar"` // Script assumes that if value is nil, the Registry site build step will backfill the value with the user's GitHub avatar URL + AvatarURL *string `yaml:"avatar"` // Script assumes that if value is nil, the Registry site build step will backfill the value with the user's GitHub avatar URL LinkedinURL *string `yaml:"linkedin"` WebsiteURL *string `yaml:"website"` SupportEmail *string `yaml:"support_email"` @@ -38,57 +35,6 @@ type contributorFrontmatterWithFilePath struct { FilePath string } -var _ error = validationPhaseError{} - -type validationPhaseError struct { - Phase string - Errors []error -} - -func (vpe validationPhaseError) Error() string { - msg := fmt.Sprintf("Error during %q phase of README validation:", vpe.Phase) - for _, e := range vpe.Errors { - msg += fmt.Sprintf("\n- %v", e) - } - msg += "\n" - - return msg -} - -func extractFrontmatter(readmeText string) (string, error) { - if readmeText == "" { - return "", errors.New("README is empty") - } - - const fence = "---" - fm := "" - fenceCount := 0 - lineScanner := bufio.NewScanner( - strings.NewReader(strings.TrimSpace(readmeText)), - ) - for lineScanner.Scan() { - nextLine := lineScanner.Text() - if fenceCount == 0 && nextLine != fence { - return "", errors.New("README does not start with frontmatter fence") - } - - if nextLine != fence { - fm += nextLine + "\n" - continue - } - - fenceCount++ - if fenceCount >= 2 { - break - } - } - - if fenceCount == 1 { - return "", errors.New("README does not have two sets of frontmatter fences") - } - return fm, nil -} - func validateContributorYaml(yml contributorFrontmatterWithFilePath) []error { // This function needs to aggregate a bunch of different problems, rather // than stopping at the first one found, so using code blocks to section off @@ -322,11 +268,11 @@ func validateContributorYaml(yml contributorFrontmatterWithFilePath) []error { // Avatar URL - can't validate the image actually leads to a valid resource // in a pure function, but can at least catch obvious problems func() { - if yml.AvatarUrl == nil { + if yml.AvatarURL == nil { return } - if *yml.AvatarUrl == "" { + if *yml.AvatarURL == "" { problems = append( problems, fmt.Errorf( @@ -339,18 +285,18 @@ func validateContributorYaml(yml contributorFrontmatterWithFilePath) []error { // Have to use .Parse instead of .ParseRequestURI because this is the // one field that's allowed to be a relative URL - if _, err := url.Parse(*yml.AvatarUrl); err != nil { + if _, err := url.Parse(*yml.AvatarURL); err != nil { problems = append( problems, fmt.Errorf( "error %q (%q) is not a valid relative or absolute URL", - *yml.AvatarUrl, + *yml.AvatarURL, yml.FilePath, ), ) } - if strings.Contains(*yml.AvatarUrl, "?") { + if strings.Contains(*yml.AvatarURL, "?") { problems = append( problems, fmt.Errorf( @@ -363,7 +309,7 @@ func validateContributorYaml(yml contributorFrontmatterWithFilePath) []error { supportedFileFormats := []string{".png", ".jpeg", ".jpg", ".gif", ".svg"} matched := false for _, ff := range supportedFileFormats { - matched = strings.HasSuffix(*yml.AvatarUrl, ff) + matched = strings.HasSuffix(*yml.AvatarURL, ff) if matched { break } @@ -383,16 +329,16 @@ func validateContributorYaml(yml contributorFrontmatterWithFilePath) []error { return problems } -func parseContributorFiles(readmeEntries []readme) ( +func parseContributorFiles(entries []readme.Readme) ( map[string]contributorFrontmatterWithFilePath, error, ) { frontmatterByUsername := map[string]contributorFrontmatterWithFilePath{} - yamlParsingErrors := validationPhaseError{ - Phase: "YAML parsing", + yamlParsingErrors := readme.ValidationPhaseError{ + Phase: readme.ValidationPhaseReadmeParsing, } - for _, rm := range readmeEntries { - fm, err := extractFrontmatter(rm.RawText) + for _, rm := range entries { + fm, _, err := readme.SeparateFrontmatter(rm.RawText) if err != nil { yamlParsingErrors.Errors = append( yamlParsingErrors.Errors, @@ -434,8 +380,8 @@ func parseContributorFiles(readmeEntries []readme) ( } employeeGithubGroups := map[string][]string{} - yamlValidationErrors := validationPhaseError{ - Phase: "Raw YAML Validation", + yamlValidationErrors := readme.ValidationPhaseError{ + Phase: readme.ValidationPhaseReadmeValidation, } for _, yml := range frontmatterByUsername { errors := validateContributorYaml(yml) @@ -463,7 +409,7 @@ func parseContributorFiles(readmeEntries []readme) ( fmt.Errorf( "company %q does not exist in %q directory but is referenced by these profiles: [%s]", companyName, - rootRegistryPath, + readme.RootRegistryPath, strings.Join(group, ", "), ), ) @@ -475,16 +421,16 @@ func parseContributorFiles(readmeEntries []readme) ( return frontmatterByUsername, nil } -func aggregateContributorReadmeFiles() ([]readme, error) { - dirEntries, err := os.ReadDir(rootRegistryPath) +func aggregateContributorReadmeFiles() ([]readme.Readme, error) { + dirEntries, err := os.ReadDir(readme.RootRegistryPath) if err != nil { return nil, err } - allReadmeFiles := []readme{} + allReadmeFiles := []readme.Readme{} problems := []error{} for _, e := range dirEntries { - dirPath := path.Join(rootRegistryPath, e.Name()) + dirPath := path.Join(readme.RootRegistryPath, e.Name()) if !e.IsDir() { problems = append( problems, @@ -502,15 +448,15 @@ func aggregateContributorReadmeFiles() ([]readme, error) { problems = append(problems, err) continue } - allReadmeFiles = append(allReadmeFiles, readme{ + allReadmeFiles = append(allReadmeFiles, readme.Readme{ FilePath: readmePath, RawText: string(rmBytes), }) } if len(problems) != 0 { - return nil, validationPhaseError{ - Phase: "FileSystem reading", + return nil, readme.ValidationPhaseError{ + Phase: readme.ValidationPhaseFilesystemRead, Errors: problems, } } @@ -526,15 +472,15 @@ func validateRelativeUrls( problems := []error{} for _, con := range contributors { - if con.AvatarUrl == nil { + if con.AvatarURL == nil { continue } - if isRelativeUrl := strings.HasPrefix(*con.AvatarUrl, ".") || - strings.HasPrefix(*con.AvatarUrl, "/"); !isRelativeUrl { + if isRelativeURL := strings.HasPrefix(*con.AvatarURL, ".") || + strings.HasPrefix(*con.AvatarURL, "/"); !isRelativeURL { continue } - if strings.HasPrefix(*con.AvatarUrl, "..") { + if strings.HasPrefix(*con.AvatarURL, "..") { problems = append( problems, fmt.Errorf( @@ -546,14 +492,14 @@ func validateRelativeUrls( } absolutePath := strings.TrimSuffix(con.FilePath, "README.md") + - *con.AvatarUrl + *con.AvatarURL _, err := os.ReadFile(absolutePath) if err != nil { problems = append( problems, fmt.Errorf( "relative avatar path %q for %q does not point to image in file system", - *con.AvatarUrl, + *con.AvatarURL, con.FilePath, ), ) @@ -563,8 +509,8 @@ func validateRelativeUrls( if len(problems) == 0 { return nil } - return validationPhaseError{ - Phase: "Relative URL validation", + return readme.ValidationPhaseError{ + Phase: readme.ValidationPhaseAssetCrossReference, Errors: problems, } } @@ -594,6 +540,6 @@ func main() { log.Printf( "Processed all READMEs in the %q directory\n", - rootRegistryPath, + readme.RootRegistryPath, ) } diff --git a/scripts/validate-modules/main.go b/scripts/validate-modules/main.go new file mode 100644 index 000000000..dda4dc9d8 --- /dev/null +++ b/scripts/validate-modules/main.go @@ -0,0 +1,341 @@ +// This package handles validating the READMEs of each module within the main +// Registry directory, as well as any assets that they depend on. +package main + +import ( + "fmt" + "log" + "net/url" + "os" + "path" + "strings" + + "coder.com/coder-registry/cmd/readme" + "gopkg.in/yaml.v3" +) + +// Todo: Once we have all modules loaded in, see if it's worth switching +// any of the functions here over to goroutines + +type moduleFrontmatter struct { + Description string `yaml:"description"` + IconURL string `yaml:"icon"` + DisplayName *string `yaml:"display_name"` + Tags *[]string `yaml:"tags"` + Verified *bool `yaml:"verified"` +} + +type moduleReadme struct { + Frontmatter moduleFrontmatter + ModuleName string + FilePath string + Body string +} + +func aggregateModuleReadmeFiles() ([]readme.Readme, error) { + registryEntries, err := os.ReadDir(readme.RootRegistryPath) + if err != nil { + return nil, err + } + + allReadmeFiles := []readme.Readme{} + problems := []error{} + for _, e := range registryEntries { + if !e.IsDir() { + continue + } + + modulesPath := path.Join(readme.RootRegistryPath, e.Name(), "modules") + modEntries, err := os.ReadDir(modulesPath) + if err != nil { + if str := err.Error(); !strings.Contains(str, "no such file or directory") { + problems = append(problems, err) + } + continue + } + + for _, me := range modEntries { + if !me.IsDir() { + continue + } + + readmePath := path.Join(modulesPath, me.Name(), "README.md") + rmBytes, err := os.ReadFile(readmePath) + if err != nil { + problems = append(problems, err) + continue + } + allReadmeFiles = append(allReadmeFiles, readme.Readme{ + FilePath: readmePath, + RawText: string(rmBytes), + }) + } + } + + if len(problems) != 0 { + return nil, readme.ValidationPhaseError{ + Phase: readme.ValidationPhaseFilesystemRead, + Errors: problems, + } + } + + return allReadmeFiles, nil +} + +func validateModuleReadmeFiles(modules map[string]moduleReadme) error { + allErrors := []error{} + + // Todo: once we know how we want to have users structure the Terraform code + // snippet for importing a module, we'll need to verify that the README has + // that snippet + validateModuleBody := func(module moduleReadme) []error { + e := []error{} + trimmed := strings.TrimSpace(module.Body) + + // Not only is this required for a README to be in 100% valid structure, + // but we also need the READMEs to be structured this way because of how + // the Registry build site processes headers + if !strings.HasPrefix(trimmed, "# ") { + e = append( + e, + fmt.Errorf( + "%q: README body does not start with ATX-style h1 header (denoted by a single #)", + module.FilePath, + ), + ) + } + + return e + } + + // The verified field is the one field that can't be meaningfully verified + // in a pure way. There's no point in validating whether the field is the + // correct type, because the base YAML parsing would've already handled + // that. And to check whether the field was changed by a Coder employee, you + // need to make requests to the GitHub API + validateModuleFrontmatter := func(module moduleReadme) []error { + problems := []error{} + fm := module.Frontmatter + + // Display Name + func() { + if fm.DisplayName == nil { + return + } + + if *fm.DisplayName == "" { + problems = append( + problems, + fmt.Errorf( + "%q: if defined, display_name must not be empty string", + module.FilePath, + ), + ) + } + }() + + // Description + if fm.Description == "" { + problems = append( + problems, + fmt.Errorf( + "%q: frontmatter description cannot be empty", + module.FilePath, + ), + ) + } + + // Icon URL + func() { + if fm.IconURL == "" { + problems = append( + problems, + fmt.Errorf("%q: icon URL cannot be empty", module.FilePath), + ) + return + } + + if isAbsoluteURL := !strings.HasPrefix(fm.IconURL, ".") && + !strings.HasPrefix(fm.IconURL, "/"); isAbsoluteURL { + if _, err := url.ParseRequestURI(fm.IconURL); err != nil { + problems = append( + problems, + fmt.Errorf( + "%q: absolute icon URL is not correctly formatted", + module.FilePath, + ), + ) + } + + if strings.Contains(fm.IconURL, "?") { + problems = append( + problems, + fmt.Errorf( + "%q: icon URLs cannot contain query parameters", + module.FilePath, + ), + ) + } + + return + } + + // Would normally be skittish about having relative paths like this, + // but it should be safe because we have guarantees about the + // structure of the repo, and where this logic will run + isPermittedRelativeURL := strings.HasPrefix(fm.IconURL, "./") || + strings.HasPrefix(fm.IconURL, "/") || + strings.HasPrefix(fm.IconURL, "../../../.logos") + if !isPermittedRelativeURL { + problems = append( + problems, + fmt.Errorf( + "%q: relative icon URL %q for module must either be scoped to that module's directory, or the top-level /.logos directory", + module.FilePath, + fm.IconURL, + ), + ) + } + }() + + // Tags + func() { + if fm.Tags == nil { + return + } + + // All of these tags are used for the module/template filter + // controls in the Registry site. Need to make sure they can all be + // placed in the browser URL without issue + invalidTags := []string{} + for _, t := range *fm.Tags { + if t != url.QueryEscape(t) { + invalidTags = append(invalidTags, t) + } + } + if len(invalidTags) != 0 { + problems = append(problems, + fmt.Errorf( + "%q: cannot use the following tags as parts of URL filter state: [%s]", + module.FilePath, + strings.Join(invalidTags, ", "), + ), + ) + } + }() + + return problems + } + + for _, m := range modules { + allErrors = append(allErrors, validateModuleBody(m)...) + allErrors = append(allErrors, validateModuleFrontmatter(m)...) + } + if len(allErrors) == 0 { + return nil + } + return readme.ValidationPhaseError{ + Phase: readme.ValidationPhaseReadmeValidation, + Errors: allErrors, + } +} + +func parseModuleFiles(entries []readme.Readme) ( + map[string]moduleReadme, + error, +) { + readmesByName := map[string]moduleReadme{} + yamlParsingErrors := readme.ValidationPhaseError{ + Phase: readme.ValidationPhaseReadmeParsing, + } + for _, rm := range entries { + fm, body, err := readme.SeparateFrontmatter(rm.RawText) + if err != nil { + yamlParsingErrors.Errors = append( + yamlParsingErrors.Errors, + fmt.Errorf("failed to parse %q: %v", rm.FilePath, err), + ) + continue + } + + yml := moduleFrontmatter{} + if err := yaml.Unmarshal([]byte(fm), &yml); err != nil { + yamlParsingErrors.Errors = append( + yamlParsingErrors.Errors, + fmt.Errorf("failed to parse %q: %v", rm.FilePath, err), + ) + continue + } + + segments := strings.Split(rm.FilePath, "/") + if len(segments) < 2 { + yamlParsingErrors.Errors = append( + yamlParsingErrors.Errors, + fmt.Errorf("unable to parse main module name from README filepath: %q", rm.FilePath), + ) + continue + } + moduleName := segments[len(segments)-2] + + readmesByName[moduleName] = moduleReadme{ + ModuleName: moduleName, + FilePath: rm.FilePath, + Frontmatter: yml, + Body: body, + } + } + + if len(yamlParsingErrors.Errors) == 0 { + return readmesByName, nil + } + return nil, yamlParsingErrors +} + +// func validateVerifiedStatusChanges( +// modules map[string]moduleReadme, +// coderEmployeeUsernames map[string]struct{}, +// runnerUsername string, +// ) (bool, error) { +// return false, nil +// } + +func main() { + log.Println("Starting README validation for modules") + allReadmeFiles, err := aggregateModuleReadmeFiles() + if err != nil { + log.Panic(err) + } + if len(allReadmeFiles) == 0 { + log.Printf("No module files to process") + return + } + + log.Printf("Processing %d README files\n", len(allReadmeFiles)) + modules, err := parseModuleFiles(allReadmeFiles) + if err != nil { + log.Panic(err) + } + log.Printf("Parsed %d module README files", len(modules)) + + err = validateModuleReadmeFiles(modules) + if err != nil { + log.Panic(err) + } + log.Printf("Validated structure of %d module README files", len(modules)) + + // log.Println("Requesting data from GitHub API...") + // runner, err := github.RunnerUsername() + // if err != nil { + // log.Panic(err) + // } + // coderEmployees, err := github.FetchCoderEmployeeUsernames() + // if err != nil { + // log.Panic(err) + // } + // log.Println("All API data returned successfully.") + // changesAreValid, err := validateVerifiedStatusChanges( + // modules, + // coderEmployees, + // runner, + // ) +} diff --git a/scripts/validate-repo-structure/main.go b/scripts/validate-repo-structure/main.go new file mode 100644 index 000000000..c50361c7c --- /dev/null +++ b/scripts/validate-repo-structure/main.go @@ -0,0 +1,10 @@ +// This package handles validating the overall structure of the repo, so that +// all CI processes and all parts of the Registry website build step can work as +// expected. +package main + +import "log" + +func main() { + log.Panic("Not implemented yet") +} diff --git a/scripts/validate-templates/main.go b/scripts/validate-templates/main.go new file mode 100644 index 000000000..6357486db --- /dev/null +++ b/scripts/validate-templates/main.go @@ -0,0 +1,9 @@ +// This package handles validating the READMEs of each template within the main +// Registry directory, as well as any assets that they depend on. +package main + +import "log" + +func main() { + log.Panic("Not implemented yet") +}