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.