diff --git a/cmd/.icons/docker.svg b/cmd/.icons/docker.svg
new file mode 100644
index 00000000..78e549ef
--- /dev/null
+++ b/cmd/.icons/docker.svg
@@ -0,0 +1,3 @@
+
diff --git a/cmd/.icons/goose.svg b/cmd/.icons/goose.svg
new file mode 100644
index 00000000..cbbe8419
--- /dev/null
+++ b/cmd/.icons/goose.svg
@@ -0,0 +1,4 @@
+
diff --git a/cmd/readmevalidation/codermodules_test.go b/cmd/readmevalidation/codermodules_test.go
index 194a861e..e9e88bfc 100644
--- a/cmd/readmevalidation/codermodules_test.go
+++ b/cmd/readmevalidation/codermodules_test.go
@@ -1,22 +1,117 @@
package main
import (
- _ "embed"
+ "os"
+ "path/filepath"
"testing"
)
-//go:embed testSamples/sampleReadmeBody.md
-var testBody string
+type readmeTestCase struct {
+ filePath string
+ shouldPass bool
+}
-func TestValidateCoderResourceReadmeBody(t *testing.T) {
+func loadTestCases(t *testing.T, dir string, shouldPass bool) []readmeTestCase {
+ t.Helper()
+ files, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatalf("Failed to read directory %s: %v", dir, err)
+ }
+
+ var testCases []readmeTestCase
+ for _, file := range files {
+ testCases = append(testCases, readmeTestCase{
+ filePath: filepath.Join(dir, file.Name()),
+ shouldPass: shouldPass,
+ })
+ }
+ return testCases
+}
+
+func TestValidateModuleReadmes(t *testing.T) {
t.Parallel()
- t.Run("Parses a valid README body with zero issues", func(t *testing.T) {
- t.Parallel()
+ testCases := append(
+ loadTestCases(t, "testSamples/modules/pass", true),
+ loadTestCases(t, "testSamples/modules/fail", false)...,
+ )
- errs := validateCoderModuleReadmeBody(testBody)
- for _, e := range errs {
- t.Error(e)
- }
- })
+ for _, tc := range testCases {
+ t.Run(tc.filePath, func(t *testing.T) {
+ t.Parallel()
+
+ content, err := os.ReadFile(tc.filePath)
+ if err != nil {
+ t.Fatalf("Failed to read file: %v", err)
+ }
+
+ rm := readme{
+ filePath: tc.filePath,
+ rawText: string(content),
+ }
+
+ resource, errs := parseCoderResourceReadme("modules", rm)
+ if len(errs) != 0 {
+ if tc.shouldPass {
+ for _, e := range errs {
+ t.Errorf("Unexpected parsing error: %v", e)
+ }
+ }
+ return
+ }
+
+ validationErrs := validateCoderModuleReadme(resource)
+ if tc.shouldPass && len(validationErrs) != 0 {
+ for _, e := range validationErrs {
+ t.Errorf("Unexpected validation error: %v", e)
+ }
+ } else if !tc.shouldPass && len(validationErrs) == 0 {
+ t.Error("Expected validation errors but got none")
+ }
+ })
+ }
+}
+
+func TestValidateTemplateReadmes(t *testing.T) {
+ t.Parallel()
+
+ testCases := append(
+ loadTestCases(t, "testSamples/templates/pass", true),
+ loadTestCases(t, "testSamples/templates/fail", false)...,
+ )
+
+ for _, tc := range testCases {
+ t.Run(tc.filePath, func(t *testing.T) {
+ t.Parallel()
+
+ content, err := os.ReadFile(tc.filePath)
+ if err != nil {
+ t.Fatalf("Failed to read file: %v", err)
+ }
+
+ rm := readme{
+ filePath: tc.filePath,
+ rawText: string(content),
+ }
+
+ resource, errs := parseCoderResourceReadme("templates", rm)
+ if len(errs) != 0 {
+ if tc.shouldPass {
+ for _, e := range errs {
+ t.Errorf("Unexpected parsing error: %v", e)
+ }
+ }
+ return
+ }
+
+ validationErrs := validateCoderModuleReadme(resource)
+ if tc.shouldPass && len(validationErrs) != 0 {
+ for _, e := range validationErrs {
+ t.Errorf("Unexpected validation error: %v", e)
+ }
+ } else if !tc.shouldPass && len(validationErrs) == 0 {
+ t.Error("Expected validation errors but got none")
+ }
+ })
+ }
}
diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go
index 818d73c8..ee723265 100644
--- a/cmd/readmevalidation/coderresources.go
+++ b/cmd/readmevalidation/coderresources.go
@@ -82,33 +82,43 @@ func validateCoderResourceDescription(description string) error {
return nil
}
-func isPermittedRelativeURL(checkURL string) bool {
- // 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.
- return strings.HasPrefix(checkURL, "./") || strings.HasPrefix(checkURL, "/") || strings.HasPrefix(checkURL, "../../../../.icons")
+func isPermittedRelativeURL(checkURL string, readmeFilePath string) error {
+ // Icon URLs must reference the top-level .icons directory
+ expectedPrefix := "../../../../.icons/"
+ if !strings.HasPrefix(checkURL, expectedPrefix) {
+ return xerrors.Errorf("icon URL %q must reference the top-level .icons directory using %q", checkURL, expectedPrefix)
+ }
+
+ // Resolve the path relative to the README file and check if it exists
+ readmeDir := path.Dir(readmeFilePath)
+ resolvedPath := path.Join(readmeDir, checkURL)
+
+ if _, err := os.Stat(resolvedPath); err != nil {
+ if os.IsNotExist(err) {
+ return xerrors.Errorf("icon file does not exist at resolved path %q (referenced as %q)", resolvedPath, checkURL)
+ }
+ return xerrors.Errorf("error checking icon file at %q: %v", resolvedPath, err)
+ }
+
+ return nil
}
-func validateCoderResourceIconURL(iconURL string) []error {
+func validateCoderResourceIconURL(iconURL string, filePath string) []error {
if iconURL == "" {
return []error{xerrors.New("icon URL cannot be empty")}
}
var errs []error
- // If the URL does not have a relative path.
- if !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") {
- if _, err := url.ParseRequestURI(iconURL); err != nil {
- errs = append(errs, xerrors.New("absolute icon URL is not correctly formatted"))
- }
- if strings.Contains(iconURL, "?") {
- errs = append(errs, xerrors.New("icon URLs cannot contain query parameters"))
- }
+ // Reject absolute HTTP/HTTPS URLs - all icons must be local to the repository
+ if strings.HasPrefix(iconURL, "http://") || strings.HasPrefix(iconURL, "https://") {
+ errs = append(errs, xerrors.Errorf("icon URL must reference the top-level .icons directory, not an absolute URL %q", iconURL))
return errs
}
- // If the URL has a relative path.
- if !isPermittedRelativeURL(iconURL) {
- errs = append(errs, xerrors.Errorf("relative icon URL %q must either be scoped to that module's directory, or the top-level /.icons directory (this can usually be done by starting the path with \"../../../.icons\")", iconURL))
+ // Validate that the icon references ../../../../.icons/ and exists
+ if err := isPermittedRelativeURL(iconURL, filePath); err != nil {
+ errs = append(errs, err)
}
return errs
@@ -153,7 +163,7 @@ func validateCoderResourceFrontmatter(resourceType string, filePath string, fm c
errs = append(errs, addFilePathToError(filePath, err))
}
- for _, err := range validateCoderResourceIconURL(fm.IconURL) {
+ for _, err := range validateCoderResourceIconURL(fm.IconURL, filePath) {
errs = append(errs, addFilePathToError(filePath, err))
}
for _, err := range validateSupportedOperatingSystems(fm.OperatingSystems) {
diff --git a/cmd/readmevalidation/testSamples/modules/fail/absoluteIconPath.md b/cmd/readmevalidation/testSamples/modules/fail/absoluteIconPath.md
new file mode 100644
index 00000000..d68264b0
--- /dev/null
+++ b/cmd/readmevalidation/testSamples/modules/fail/absoluteIconPath.md
@@ -0,0 +1,22 @@
+---
+display_name: "Goose"
+description: "Run the Goose agent in your workspace to generate code and perform tasks"
+icon: "https://github.com/coder/registry/pull/599.svg"
+verified: false
+tags: ["ai", "agent"]
+---
+
+# Goose
+
+Run the [Goose](https://block.github.io/goose/) agent in your workspace to generate code and perform tasks.
+
+```tf
+module "goose" {
+ source = "registry.coder.com/coder/goose/coder"
+ version = "1.0.31"
+ agent_id = coder_agent.main.id
+ folder = "/home/coder"
+ install_goose = true
+ goose_version = "v1.0.16"
+}
+```
diff --git a/cmd/readmevalidation/testSamples/modules/fail/wrongPathFormat.md b/cmd/readmevalidation/testSamples/modules/fail/wrongPathFormat.md
new file mode 100644
index 00000000..e9c671c4
--- /dev/null
+++ b/cmd/readmevalidation/testSamples/modules/fail/wrongPathFormat.md
@@ -0,0 +1,19 @@
+---
+display_name: "Wrong Path"
+description: "Test module with wrong icon path format"
+icon: "../../../../.icons/invalid.svg"
+verified: false
+tags: ["test"]
+---
+
+# Wrong Path
+
+This should fail validation.
+
+```tf
+module "test" {
+ source = "registry.coder.com/coder/test/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.main.id
+}
+```
diff --git a/cmd/readmevalidation/testSamples/modules/pass/sampleModuleReadme.md b/cmd/readmevalidation/testSamples/modules/pass/sampleModuleReadme.md
new file mode 100644
index 00000000..b0db1f26
--- /dev/null
+++ b/cmd/readmevalidation/testSamples/modules/pass/sampleModuleReadme.md
@@ -0,0 +1,56 @@
+---
+display_name: "Docker Container"
+description: "Develop in a container on a Docker host"
+icon: "../../../../.icons/docker.svg"
+verified: true
+tags: ["docker", "container"]
+supported_os: ["linux", "macos"]
+---
+
+# Docker Container
+
+Develop in a Docker container on a remote Docker host.
+
+```tf
+terraform {
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = "~> 1.0"
+ }
+ docker = {
+ source = "kreuzwerker/docker"
+ version = "~> 3.0"
+ }
+ }
+}
+
+provider "docker" {}
+
+provider "coder" {}
+
+data "coder_workspace" "me" {}
+
+resource "coder_agent" "main" {
+ os = "linux"
+ arch = "amd64"
+}
+
+resource "docker_container" "workspace" {
+ image = "codercom/enterprise-base:ubuntu"
+ name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}"
+
+ env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
+}
+```
+
+## Getting Started
+
+This template creates a Docker container on your Docker host. You'll need:
+
+- A Docker host accessible from your Coder deployment
+- The Docker provider configured with appropriate credentials
+
+## Customization
+
+You can customize the container image, resources, and configuration to match your needs.
diff --git a/cmd/readmevalidation/testSamples/sampleReadmeBody.md b/cmd/readmevalidation/testSamples/sampleReadmeBody.md
deleted file mode 100644
index b96662af..00000000
--- a/cmd/readmevalidation/testSamples/sampleReadmeBody.md
+++ /dev/null
@@ -1,121 +0,0 @@
-# Goose
-
-Run the [Goose](https://block.github.io/goose/) agent in your workspace to generate code and perform tasks.
-
-```tf
-module "goose" {
- source = "registry.coder.com/coder/goose/coder"
- version = "1.0.31"
- agent_id = coder_agent.main.id
- folder = "/home/coder"
- install_goose = true
- goose_version = "v1.0.16"
-}
-```
-
-## Prerequisites
-
-- `screen` must be installed in your workspace to run Goose in the background
-- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template
-
-The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces.
-
-## Examples
-
-Your workspace must have `screen` installed to use this.
-
-### Run in the background and report tasks (Experimental)
-
-> This functionality is in early access as of Coder v2.21 and is still evolving.
-> For now, we recommend testing it in a demo or staging environment,
-> rather than deploying to production
->
-> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents)
->
-> Join our [Discord channel](https://discord.gg/coder) or
-> [contact us](https://coder.com/contact) to get help or share feedback.
-
-```tf
-module "coder-login" {
- count = data.coder_workspace.me.start_count
- source = "registry.coder.com/coder/coder-login/coder"
- version = "1.0.15"
- agent_id = coder_agent.main.id
-}
-
-variable "anthropic_api_key" {
- type = string
- description = "The Anthropic API key"
- sensitive = true
-}
-
-data "coder_parameter" "ai_prompt" {
- type = "string"
- name = "AI Prompt"
- default = ""
- description = "Write a prompt for Goose"
- mutable = true
-}
-
-# Set the prompt and system prompt for Goose via environment variables
-resource "coder_agent" "main" {
- # ...
- env = {
- GOOSE_SYSTEM_PROMPT = <<-EOT
- You are a helpful assistant that can help write code.
-
- Run all long running tasks (e.g. npm run dev) in the background and not in the foreground.
-
- Periodically check in on background tasks.
-
- Notify Coder of the status of the task before and after your steps.
- EOT
- GOOSE_TASK_PROMPT = data.coder_parameter.ai_prompt.value
-
- # An API key is required for experiment_auto_configure
- # See https://block.github.io/goose/docs/getting-started/providers
- ANTHROPIC_API_KEY = var.anthropic_api_key # or use a coder_parameter
- }
-}
-
-module "goose" {
- count = data.coder_workspace.me.start_count
- source = "registry.coder.com/coder/goose/coder"
- version = "1.0.31"
- agent_id = coder_agent.main.id
- folder = "/home/coder"
- install_goose = true
- goose_version = "v1.0.16"
-
- # Enable experimental features
- experiment_report_tasks = true
-
- # Run Goose in the background
- experiment_use_screen = true
-
- # Avoid configuring Goose manually
- experiment_auto_configure = true
-
- # Required for experiment_auto_configure
- experiment_goose_provider = "anthropic"
- experiment_goose_model = "claude-3-5-sonnet-latest"
-}
-```
-
-## Run standalone
-
-Run Goose as a standalone app in your workspace. This will install Goose and run it directly without using screen or any task reporting to the Coder UI.
-
-```tf
-module "goose" {
- source = "registry.coder.com/coder/goose/coder"
- version = "1.0.31"
- agent_id = coder_agent.main.id
- folder = "/home/coder"
- install_goose = true
- goose_version = "v1.0.16"
-
- # Icon is not available in Coder v2.20 and below, so we'll use a custom icon URL
- icon = "https://raw.githubusercontent.com/block/goose/refs/heads/main/ui/desktop/src/images/icon.svg"
-}
-```
diff --git a/cmd/readmevalidation/testSamples/templates/fail/absoluteIconPath.md b/cmd/readmevalidation/testSamples/templates/fail/absoluteIconPath.md
new file mode 100644
index 00000000..879a2a83
--- /dev/null
+++ b/cmd/readmevalidation/testSamples/templates/fail/absoluteIconPath.md
@@ -0,0 +1,27 @@
+---
+display_name: "Docker Container"
+description: "Develop in a container on a Docker host"
+icon: "https://github.com/coder/registry/pull/599.jpeg"
+verified: true
+tags: ["docker", "container"]
+supported_os: ["linux", "macos"]
+---
+
+# Docker Container
+
+Develop in a Docker container on a remote Docker host.
+
+```tf
+terraform {
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = "~> 1.0"
+ }
+ docker = {
+ source = "kreuzwerker/docker"
+ version = "~> 3.0"
+ }
+ }
+}
+```
diff --git a/cmd/readmevalidation/testSamples/templates/fail/wrongPathFormat.md b/cmd/readmevalidation/testSamples/templates/fail/wrongPathFormat.md
new file mode 100644
index 00000000..7078cfb8
--- /dev/null
+++ b/cmd/readmevalidation/testSamples/templates/fail/wrongPathFormat.md
@@ -0,0 +1,20 @@
+---
+display_name: "Docker Container"
+description: "Develop in a container on a Docker host"
+icon: "../../../../.icons/invalid.svg"
+verified: true
+tags: ["docker", "container"]
+supported_os: ["linux", "macos"]
+---
+
+# Wrong Path
+
+This should fail validation.
+
+```tf
+module "test" {
+ source = "registry.coder.com/coder/test/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.main.id
+}
+```
diff --git a/cmd/readmevalidation/testSamples/templates/pass/sampleTemplateReadme.md b/cmd/readmevalidation/testSamples/templates/pass/sampleTemplateReadme.md
new file mode 100644
index 00000000..b0db1f26
--- /dev/null
+++ b/cmd/readmevalidation/testSamples/templates/pass/sampleTemplateReadme.md
@@ -0,0 +1,56 @@
+---
+display_name: "Docker Container"
+description: "Develop in a container on a Docker host"
+icon: "../../../../.icons/docker.svg"
+verified: true
+tags: ["docker", "container"]
+supported_os: ["linux", "macos"]
+---
+
+# Docker Container
+
+Develop in a Docker container on a remote Docker host.
+
+```tf
+terraform {
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = "~> 1.0"
+ }
+ docker = {
+ source = "kreuzwerker/docker"
+ version = "~> 3.0"
+ }
+ }
+}
+
+provider "docker" {}
+
+provider "coder" {}
+
+data "coder_workspace" "me" {}
+
+resource "coder_agent" "main" {
+ os = "linux"
+ arch = "amd64"
+}
+
+resource "docker_container" "workspace" {
+ image = "codercom/enterprise-base:ubuntu"
+ name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}"
+
+ env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
+}
+```
+
+## Getting Started
+
+This template creates a Docker container on your Docker host. You'll need:
+
+- A Docker host accessible from your Coder deployment
+- The Docker provider configured with appropriate credentials
+
+## Customization
+
+You can customize the container image, resources, and configuration to match your needs.