mirror of
https://github.com/coder/registry.git
synced 2026-06-03 04:58:15 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8c9ad122c | |||
| 056937a758 | |||
| af8b4f02fd | |||
| 2de6a57a3f | |||
| 494e4deb72 | |||
| 60fec19d7d | |||
| 44354b202d | |||
| 80acbd7e3a | |||
| 80f429faf1 | |||
| e516446d03 | |||
| f0045397d4 | |||
| 6af8508bc0 | |||
| 343a2f2bb8 | |||
| 9cc3d5a776 | |||
| 9c00c576a3 | |||
| 5419676276 | |||
| 7e94395bd1 | |||
| 3b6b1ba4b9 | |||
| 5b6d878fd7 | |||
| 72d7ee418b |
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Validate formatting
|
||||
run: bun fmt:ci
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@v1.36.2
|
||||
uses: crate-ci/typos@v1.37.2
|
||||
with:
|
||||
config: .github/typos.toml
|
||||
validate-readme-files:
|
||||
@@ -63,8 +63,8 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.23.2"
|
||||
- name: Validate contributors
|
||||
go-version: "1.24"
|
||||
- name: Validate README
|
||||
run: go build ./cmd/readmevalidation && ./readmevalidation
|
||||
- name: Remove build file artifact
|
||||
run: rm ./readmevalidation
|
||||
|
||||
@@ -3,7 +3,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg height="32" viewBox="0 0 375 375" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect fill="#0071ff" height="375.001088" rx="58.59392" stroke-width=".91553" width="375.001088" x=".0009759" y="-.0066962"/>
|
||||
<path d="m150.428 322.264c-29.063-6.202-53.897-22.439-73.115-47.804-19.507-25.746-27.838-55.355-25.723-91.414 6.655-62.013 47.667-106.753 99.687-120.411 4.509-.989 8.353-3.462 12.55-1.322 3.22 1.64 6.028 4.467 7.206 7.251 1.25 2.955 1.877 21.54.99 29.331-1.076 9.46-3.877 12.418-14.566 15.388-29.723 10.195-48.105 34.07-53.697 61.017-4.8 29.668 2.951 59.729 21.528 78.727 8.966 8.993 17.92 14.24 30.869 18.086 8.646 2.57 13.393 5.758 15.036 10.102 1.085 2.867 1.63 22.984.779 28.772-1.33 9.046-1.702 9.796-5.792 11.667-5.029 2.3-7.404 2.392-15.752.61zm50.708.29c-3.092-1.402-5.673-4.83-6.73-8.94-.134-9.408-2.366-25.754 1.02-33.373 1.88-4.128 4.65-5.999 12.433-8.396 21.267-6.551 37.593-19.88 46.806-38.213 11.11-22.108 11.877-55.183 1.808-77.975-9.154-20.723-25.7-35.217-48.555-42.534-8.872-2.84-12.004-5.065-12.968-9.21-1.002-4.31-1.435-19.87-.785-28.218.682-8.766 1.249-9.99 6.162-13.318 3.701-2.505 5.482-2.446 17.223.575 36.718 10.077 65.97 33.597 83.026 66.68 18.495 37.034 19.191 86.11 1.742 122.655-17.233 36.09-50.591 62.511-88.622 70.194-8.172 1.65-9.07 1.656-12.56.073z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -3,11 +3,84 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var (
|
||||
// Matches Terraform source lines with registry.coder.com URLs
|
||||
// Pattern: source = "registry.coder.com/namespace/module/coder"
|
||||
terraformSourceRe = regexp.MustCompile(`^\s*source\s*=\s*"` + registryDomain + `/([a-zA-Z0-9-]+)/([a-zA-Z0-9-]+)/coder"`)
|
||||
)
|
||||
|
||||
func validateModuleSourceURL(rm coderResourceReadme) []error {
|
||||
var errs []error
|
||||
|
||||
// Skip validation if we couldn't parse namespace/resourceName from path
|
||||
if rm.namespace == "" || rm.resourceName == "" {
|
||||
return []error{xerrors.Errorf("invalid module path format: %s", rm.filePath)}
|
||||
}
|
||||
|
||||
expectedSource := registryDomain + "/" + rm.namespace + "/" + rm.resourceName + "/coder"
|
||||
|
||||
trimmed := strings.TrimSpace(rm.body)
|
||||
foundCorrectSource := false
|
||||
var incorrectSources []string
|
||||
isInsideTerraform := false
|
||||
|
||||
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
|
||||
for lineScanner.Scan() {
|
||||
nextLine := lineScanner.Text()
|
||||
|
||||
if strings.HasPrefix(nextLine, "```") {
|
||||
if strings.HasPrefix(nextLine, "```tf") {
|
||||
isInsideTerraform = true
|
||||
continue
|
||||
}
|
||||
if isInsideTerraform {
|
||||
// End of current terraform block, continue to look for more
|
||||
isInsideTerraform = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !isInsideTerraform {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for source line in terraform blocks
|
||||
if matches := terraformSourceRe.FindStringSubmatch(nextLine); matches != nil {
|
||||
actualNamespace := matches[1]
|
||||
actualModule := matches[2]
|
||||
actualSource := registryDomain + "/" + actualNamespace + "/" + actualModule + "/coder"
|
||||
|
||||
if actualSource == expectedSource {
|
||||
foundCorrectSource = true
|
||||
} else {
|
||||
// Collect incorrect sources but don't return immediately
|
||||
incorrectSources = append(incorrectSources, actualSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found the correct source, ignore any incorrect ones
|
||||
if foundCorrectSource {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we found incorrect sources but no correct one, report the first incorrect source
|
||||
if len(incorrectSources) > 0 {
|
||||
errs = append(errs, xerrors.Errorf("incorrect source URL format: found %q, expected %q", incorrectSources[0], expectedSource))
|
||||
return errs
|
||||
}
|
||||
|
||||
// If we found no sources at all
|
||||
errs = append(errs, xerrors.Errorf("did not find correct source URL %q in any Terraform code block", expectedSource))
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateCoderModuleReadmeBody(body string) []error {
|
||||
var errs []error
|
||||
|
||||
@@ -94,6 +167,9 @@ func validateCoderModuleReadme(rm coderResourceReadme) []error {
|
||||
for _, err := range validateCoderModuleReadmeBody(rm.body) {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
for _, err := range validateModuleSourceURL(rm) {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
for _, err := range validateResourceGfmAlerts(rm.body) {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
@@ -143,4 +219,4 @@ func validateAllCoderModules() error {
|
||||
}
|
||||
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,70 @@ package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
//go:embed testSamples/sampleReadmeBody.md
|
||||
var testBody string
|
||||
|
||||
// Test bodies extracted for better readability
|
||||
var (
|
||||
validModuleBody = `# Test Module
|
||||
|
||||
` + "```tf\n" + `module "test-module" {
|
||||
source = "registry.coder.com/test-namespace/test-module/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
` + "```\n"
|
||||
|
||||
wrongNamespaceBody = `# Test Module
|
||||
|
||||
` + "```tf\n" + `module "test-module" {
|
||||
source = "registry.coder.com/wrong-namespace/test-module/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
` + "```\n"
|
||||
|
||||
missingSourceBody = `# Test Module
|
||||
|
||||
` + "```tf\n" + `module "test-module" {
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
` + "```\n"
|
||||
|
||||
multipleBlocksValidBody = `# Test Module
|
||||
|
||||
` + "```tf\n" + `module "other-module" {
|
||||
source = "registry.coder.com/other/module/coder"
|
||||
version = "1.0.0"
|
||||
}
|
||||
` + "```\n" + `
|
||||
` + "```tf\n" + `module "test-module" {
|
||||
source = "registry.coder.com/test-namespace/test-module/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
` + "```\n"
|
||||
|
||||
multipleBlocksInvalidBody = `# Test Module
|
||||
|
||||
` + "```tf\n" + `module "test-module" {
|
||||
source = "registry.coder.com/wrong-namespace/test-module/coder"
|
||||
version = "1.0.0"
|
||||
}
|
||||
` + "```\n" + `
|
||||
` + "```tf\n" + `module "other-module" {
|
||||
source = "registry.coder.com/another-wrong/test-module/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
` + "```\n"
|
||||
)
|
||||
|
||||
func TestValidateCoderResourceReadmeBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -20,3 +78,115 @@ func TestValidateCoderResourceReadmeBody(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateModuleSourceURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Valid source URL format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rm := coderResourceReadme{
|
||||
resourceType: "modules",
|
||||
filePath: "registry/test-namespace/modules/test-module/README.md",
|
||||
namespace: "test-namespace",
|
||||
resourceName: "test-module",
|
||||
body: validModuleBody,
|
||||
}
|
||||
errs := validateModuleSourceURL(rm)
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("Expected no errors, got: %v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid source URL format - wrong namespace", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rm := coderResourceReadme{
|
||||
resourceType: "modules",
|
||||
filePath: "registry/test-namespace/modules/test-module/README.md",
|
||||
namespace: "test-namespace",
|
||||
resourceName: "test-module",
|
||||
body: wrongNamespaceBody,
|
||||
}
|
||||
errs := validateModuleSourceURL(rm)
|
||||
if len(errs) != 1 {
|
||||
t.Errorf("Expected 1 error, got %d: %v", len(errs), errs)
|
||||
}
|
||||
if !strings.Contains(errs[0].Error(), "incorrect source URL format") {
|
||||
t.Errorf("Expected source URL format error, got: %s", errs[0].Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Missing source URL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rm := coderResourceReadme{
|
||||
resourceType: "modules",
|
||||
filePath: "registry/test-namespace/modules/test-module/README.md",
|
||||
namespace: "test-namespace",
|
||||
resourceName: "test-module",
|
||||
body: missingSourceBody,
|
||||
}
|
||||
errs := validateModuleSourceURL(rm)
|
||||
if len(errs) != 1 {
|
||||
t.Errorf("Expected 1 error, got %d: %v", len(errs), errs)
|
||||
}
|
||||
if !strings.Contains(errs[0].Error(), "did not find correct source URL") {
|
||||
t.Errorf("Expected missing source URL error, got: %s", errs[0].Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid file path format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rm := coderResourceReadme{
|
||||
resourceType: "modules",
|
||||
filePath: "invalid/path/format",
|
||||
namespace: "", // Empty because path parsing failed
|
||||
resourceName: "", // Empty because path parsing failed
|
||||
body: "# Test Module",
|
||||
}
|
||||
errs := validateModuleSourceURL(rm)
|
||||
if len(errs) != 1 {
|
||||
t.Errorf("Expected 1 error, got %d: %v", len(errs), errs)
|
||||
}
|
||||
if !strings.Contains(errs[0].Error(), "invalid module path format") {
|
||||
t.Errorf("Expected path format error, got: %s", errs[0].Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Multiple blocks with valid source in second block", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rm := coderResourceReadme{
|
||||
resourceType: "modules",
|
||||
filePath: "registry/test-namespace/modules/test-module/README.md",
|
||||
namespace: "test-namespace",
|
||||
resourceName: "test-module",
|
||||
body: multipleBlocksValidBody,
|
||||
}
|
||||
errs := validateModuleSourceURL(rm)
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("Expected no errors, got: %v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Multiple blocks with incorrect source in second block", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rm := coderResourceReadme{
|
||||
resourceType: "modules",
|
||||
filePath: "registry/test-namespace/modules/test-module/README.md",
|
||||
namespace: "test-namespace",
|
||||
resourceName: "test-module",
|
||||
body: multipleBlocksInvalidBody,
|
||||
}
|
||||
errs := validateModuleSourceURL(rm)
|
||||
if len(errs) != 1 {
|
||||
t.Errorf("Expected 1 error, got %d: %v", len(errs), errs)
|
||||
}
|
||||
if !strings.Contains(errs[0].Error(), "incorrect source URL format") {
|
||||
t.Errorf("Expected source URL format error, got: %s", errs[0].Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,7 @@ var (
|
||||
supportedResourceTypes = []string{"modules", "templates"}
|
||||
operatingSystems = []string{"windows", "macos", "linux"}
|
||||
gfmAlertTypes = []string{"NOTE", "IMPORTANT", "CAUTION", "WARNING", "TIP"}
|
||||
registryDomain = "registry.coder.com"
|
||||
|
||||
// TODO: This is a holdover from the validation logic used by the Coder Modules repo. It gives us some assurance, but
|
||||
// realistically, we probably want to parse any Terraform code snippets, and make some deeper guarantees about how it's
|
||||
@@ -25,7 +26,7 @@ var (
|
||||
terraformVersionRe = regexp.MustCompile(`^\s*\bversion\s+=`)
|
||||
|
||||
// Matches the format "> [!INFO]". Deliberately using a broad pattern to catch formatting issues that can mess up
|
||||
// the renderer for the Registry website
|
||||
// the renderer for the Registry website.
|
||||
gfmAlertRegex = regexp.MustCompile(`^>(\s*)\[!(\w+)\](\s*)(.*)`)
|
||||
)
|
||||
|
||||
@@ -39,7 +40,7 @@ type coderResourceFrontmatter struct {
|
||||
}
|
||||
|
||||
// A slice version of the struct tags from coderResourceFrontmatter. Might be worth using reflection to generate this
|
||||
// list at runtime in the future, but this should be okay for now
|
||||
// list at runtime in the future, but this should be okay for now.
|
||||
var supportedCoderResourceStructKeys = []string{
|
||||
"description", "icon", "display_name", "verified", "tags", "supported_os",
|
||||
// TODO: This is an old, officially deprecated key from the archived coder/modules repo. We can remove this once we
|
||||
@@ -53,6 +54,8 @@ var supportedCoderResourceStructKeys = []string{
|
||||
type coderResourceReadme struct {
|
||||
resourceType string
|
||||
filePath string
|
||||
namespace string
|
||||
resourceName string
|
||||
body string
|
||||
frontmatter coderResourceFrontmatter
|
||||
}
|
||||
@@ -183,9 +186,20 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead
|
||||
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
// Extract namespace and resource name from file path
|
||||
// Expected path format: registry/<namespace>/<resourceType>/<resource-name>/README.md
|
||||
var namespace, resourceName string
|
||||
parts := strings.Split(path.Clean(rm.filePath), "/")
|
||||
if len(parts) >= 5 && parts[0] == "registry" && parts[2] == resourceType && parts[4] == "README.md" {
|
||||
namespace = parts[1]
|
||||
resourceName = parts[3]
|
||||
}
|
||||
|
||||
return coderResourceReadme{
|
||||
resourceType: resourceType,
|
||||
filePath: rm.filePath,
|
||||
namespace: namespace,
|
||||
resourceName: resourceName,
|
||||
body: body,
|
||||
frontmatter: yml,
|
||||
}, nil
|
||||
@@ -315,15 +329,15 @@ func validateResourceGfmAlerts(readmeBody string) []error {
|
||||
}
|
||||
|
||||
// Nested GFM alerts is such a weird mistake that it's probably not really safe to keep trying to process the
|
||||
// rest of the content, so this will prevent any other validations from happening for the given line
|
||||
// rest of the content, so this will prevent any other validations from happening for the given line.
|
||||
if isInsideGfmQuotes {
|
||||
errs = append(errs, errors.New("registry does not support nested GFM alerts"))
|
||||
errs = append(errs, xerrors.New("registry does not support nested GFM alerts"))
|
||||
continue
|
||||
}
|
||||
|
||||
leadingWhitespace := currentMatch[1]
|
||||
if len(leadingWhitespace) != 1 {
|
||||
errs = append(errs, errors.New("GFM alerts must have one space between the '>' and the start of the GFM brackets"))
|
||||
errs = append(errs, xerrors.New("GFM alerts must have one space between the '>' and the start of the GFM brackets"))
|
||||
}
|
||||
isInsideGfmQuotes = true
|
||||
|
||||
@@ -347,7 +361,7 @@ func validateResourceGfmAlerts(readmeBody string) []error {
|
||||
}
|
||||
}
|
||||
|
||||
if gfmAlertRegex.Match([]byte(sourceLine)) {
|
||||
if gfmAlertRegex.MatchString(sourceLine) {
|
||||
errs = append(errs, xerrors.Errorf("README has an incomplete GFM alert at the end of the file"))
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ type contributorProfileFrontmatter struct {
|
||||
}
|
||||
|
||||
// A slice version of the struct tags from contributorProfileFrontmatter. Might be worth using reflection to generate
|
||||
// this list at runtime in the future, but this should be okay for now
|
||||
// this list at runtime in the future, but this should be okay for now.
|
||||
var supportedContributorProfileStructKeys = []string{"display_name", "bio", "status", "avatar", "linkedin", "github", "website", "support_email"}
|
||||
|
||||
type contributorProfileReadme struct {
|
||||
|
||||
@@ -13,12 +13,11 @@ import (
|
||||
|
||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
|
||||
|
||||
// validNameRe validates that names contain only alphanumeric characters and hyphens
|
||||
// validNameRe validates that names contain only alphanumeric characters and hyphens.
|
||||
var validNameRe = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$`)
|
||||
|
||||
|
||||
// validateCoderResourceSubdirectory validates that the structure of a module or template within a namespace follows all
|
||||
// expected file conventions
|
||||
// expected file conventions.
|
||||
func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
resourceDir, err := os.Stat(dirPath)
|
||||
if err != nil {
|
||||
@@ -47,7 +46,7 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate module/template name
|
||||
// Validate module/template name.
|
||||
if !validNameRe.MatchString(f.Name()) {
|
||||
errs = append(errs, xerrors.Errorf("%q: name contains invalid characters (only alphanumeric characters and hyphens are allowed)", path.Join(dirPath, f.Name())))
|
||||
continue
|
||||
@@ -90,7 +89,7 @@ func validateRegistryDirectory() []error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate namespace name
|
||||
// Validate namespace name.
|
||||
if !validNameRe.MatchString(nDir.Name()) {
|
||||
allErrs = append(allErrs, xerrors.Errorf("%q: namespace name contains invalid characters (only alphanumeric characters and hyphens are allowed)", namespacePath))
|
||||
continue
|
||||
@@ -136,7 +135,7 @@ func validateRegistryDirectory() []error {
|
||||
|
||||
// validateRepoStructure validates that the structure of the repo is "correct enough" to do all necessary validation
|
||||
// checks. It is NOT an exhaustive validation of the entire repo structure – it only checks the parts of the repo that
|
||||
// are relevant for the main validation steps
|
||||
// are relevant for the main validation steps.
|
||||
func validateRepoStructure() error {
|
||||
var errs []error
|
||||
if vrdErrs := validateRegistryDirectory(); len(vrdErrs) != 0 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module coder.com/coder-registry
|
||||
|
||||
go 1.23.2
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
cdr.dev/slog v1.6.1
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@
|
||||
"fmt:ci": "bun x prettier --check . && terraform fmt -check -recursive -diff",
|
||||
"terraform-validate": "./scripts/terraform_validate.sh",
|
||||
"test": "./scripts/terraform_test_all.sh",
|
||||
"update-version": "./update-version.sh"
|
||||
"update-version": "./update-version.sh",
|
||||
"validate-readme": "go build ./cmd/readmevalidation && ./readmevalidation"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.21",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,14 @@
|
||||
---
|
||||
display_name: "Benraouane Soufiane"
|
||||
bio: "Full stack developer creating awesome things."
|
||||
avatar: "./.images/avatar.png"
|
||||
github: "benraouanesoufiane"
|
||||
linkedin: "https://www.linkedin.com/in/benraouane-soufiane" # Optional
|
||||
website: "https://benraouanesoufiane.com" # Optional
|
||||
support_email: "hello@benraouanesoufiane.com" # Optional
|
||||
status: "community"
|
||||
---
|
||||
|
||||
# Benraouane Soufiane
|
||||
|
||||
Full stack developer creating awesome things.
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
display_name: RustDesk
|
||||
description: Run RustDesk in your workspace with virtual display
|
||||
icon: ../../../../.icons/rustdesk.svg
|
||||
verified: false
|
||||
tags: [rustdesk, rdp, vm]
|
||||
---
|
||||
|
||||
# RustDesk
|
||||
|
||||
Launches RustDesk within your workspace with a virtual display to provide remote desktop access. The module outputs the RustDesk ID and password needed to connect from external RustDesk clients.
|
||||
|
||||
```tf
|
||||
module "rustdesk" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/BenraouaneSoufiane/rustdesk/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Automatically sets up virtual display (Xvfb)
|
||||
- Downloads and configures RustDesk
|
||||
- Outputs RustDesk ID and password for easy connection
|
||||
- Provides external app link to RustDesk web client for browser-based access
|
||||
- Starts virtual display (Xvfb) with customizable resolution
|
||||
- Customizable screen resolution and RustDesk version
|
||||
|
||||
## Requirements
|
||||
|
||||
- Coder v2.5 or higher
|
||||
- Linux workspace with `apt`, `dnf`, or `yum` package manager
|
||||
|
||||
## Examples
|
||||
|
||||
### Custom configuration with specific version
|
||||
|
||||
```tf
|
||||
module "rustdesk" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/BenraouaneSoufiane/rustdesk/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
rustdesk_password = "mycustompass"
|
||||
xvfb_resolution = "1920x1080x24"
|
||||
rustdesk_version = "1.4.1"
|
||||
}
|
||||
```
|
||||
|
||||
### Docker container configuration
|
||||
|
||||
It requires coder' server to be run as root, when using with Docker, add the following to your `docker_container` resource:
|
||||
|
||||
```tf
|
||||
resource "docker_container" "workspace" {
|
||||
|
||||
# ... other configuration ...
|
||||
|
||||
user = "root"
|
||||
privileged = true
|
||||
network_mode = "host"
|
||||
|
||||
ports {
|
||||
internal = 21115
|
||||
external = 21115
|
||||
}
|
||||
ports {
|
||||
internal = 21116
|
||||
external = 21116
|
||||
}
|
||||
ports {
|
||||
internal = 21118
|
||||
external = 21118
|
||||
}
|
||||
ports {
|
||||
internal = 21119
|
||||
external = 21119
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "log_path" {
|
||||
type = string
|
||||
description = "The path to log rustdesk to."
|
||||
default = "/tmp/rustdesk.log"
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
description = "Attach RustDesk setup to this agent"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "order" {
|
||||
description = "Run order among scripts/apps"
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
# Optional knobs passed as env (you can expose these as variables too)
|
||||
variable "rustdesk_password" {
|
||||
description = "If empty, the script will generate one"
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "xvfb_resolution" {
|
||||
description = "Xvfb screen size/depth"
|
||||
type = string
|
||||
default = "1024x768x16"
|
||||
}
|
||||
|
||||
variable "rustdesk_version" {
|
||||
description = "RustDesk version to install (use 'latest' for most recent release)"
|
||||
type = string
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
resource "coder_script" "rustdesk" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "RustDesk"
|
||||
run_on_start = true
|
||||
|
||||
# Prepend env as bash exports, then append the script file literally.
|
||||
script = <<-EOT
|
||||
# --- module-provided env knobs ---
|
||||
export RUSTDESK_PASSWORD="${var.rustdesk_password}"
|
||||
export XVFB_RESOLUTION="${var.xvfb_resolution}"
|
||||
export RUSTDESK_VERSION="${var.rustdesk_version}"
|
||||
# ---------------------------------
|
||||
|
||||
${file("${path.module}/run.sh")}
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_app" "rustdesk" {
|
||||
agent_id = var.agent_id
|
||||
slug = "rustdesk"
|
||||
display_name = "Rustdesk"
|
||||
url = "https://rustdesk.com/web"
|
||||
icon = "/icon/rustdesk.svg"
|
||||
order = var.order
|
||||
external = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
RESET='\033[0m'
|
||||
|
||||
printf "${BOLD}🖥️ Installing RustDesk Remote Desktop\n${RESET}"
|
||||
|
||||
# ---- configurable knobs (env overrides) ----
|
||||
RUSTDESK_VERSION="${RUSTDESK_VERSION:-latest}"
|
||||
LOG_PATH="${LOG_PATH:-/tmp/rustdesk.log}"
|
||||
|
||||
# ---- fetch latest version if needed ----
|
||||
if [ "$RUSTDESK_VERSION" = "latest" ]; then
|
||||
printf "🔍 Fetching latest RustDesk version...\n"
|
||||
RUSTDESK_VERSION=$(curl -s https://api.github.com/repos/rustdesk/rustdesk/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' || echo "1.4.1")
|
||||
printf "📌 Fetched RustDesk version: ${RUSTDESK_VERSION}\n"
|
||||
else
|
||||
printf "📌 Using specified RustDesk version: ${RUSTDESK_VERSION}\n"
|
||||
fi
|
||||
XVFB_RESOLUTION="${XVFB_RESOLUTION:-1024x768x16}"
|
||||
RUSTDESK_PASSWORD="${RUSTDESK_PASSWORD:-}"
|
||||
|
||||
# ---- detect package manager & arch ----
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64 | amd64) PKG_ARCH="x86_64" ;;
|
||||
aarch64 | arm64) PKG_ARCH="aarch64" ;;
|
||||
*)
|
||||
echo "❌ Unsupported arch: $ARCH"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if command -v apt-get > /dev/null 2>&1; then
|
||||
PKG_SYS="deb"
|
||||
PKG_NAME="rustdesk-${RUSTDESK_VERSION}-${PKG_ARCH}.deb"
|
||||
INSTALL_DEPS='apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y wget libva2 libva-drm2 libva-x11-2 libgstreamer-plugins-base1.0-0 gstreamer1.0-pipewire xfce4 xfce4-goodies xvfb x11-xserver-utils dbus-x11 libegl1 libgl1 libglx0 libglu1-mesa mesa-utils libxrandr2 libxss1 libgtk-3-0t64 libgbm1 libdrm2 libxcomposite1 libxdamage1 libxfixes3'
|
||||
INSTALL_CMD="apt-get install -y ./${PKG_NAME}"
|
||||
CLEAN_CMD="rm -f \"${PKG_NAME}\""
|
||||
elif command -v dnf > /dev/null 2>&1; then
|
||||
PKG_SYS="rpm"
|
||||
PKG_NAME="rustdesk-${RUSTDESK_VERSION}-${PKG_ARCH}.rpm"
|
||||
INSTALL_DEPS='dnf install -y wget libva libva-intel-driver gstreamer1-plugins-base pipewire xfce4-session xfce4-panel xorg-x11-server-Xvfb xorg-x11-xauth dbus-x11 mesa-libEGL mesa-libGL mesa-libGLU mesa-dri-drivers libXrandr libXScrnSaver gtk3 mesa-libgbm libdrm libXcomposite libXdamage libXfixes'
|
||||
INSTALL_CMD="dnf install -y ./${PKG_NAME}"
|
||||
CLEAN_CMD="rm -f \"${PKG_NAME}\""
|
||||
elif command -v yum > /dev/null 2>&1; then
|
||||
PKG_SYS="rpm"
|
||||
PKG_NAME="rustdesk-${RUSTDESK_VERSION}-${PKG_ARCH}.rpm"
|
||||
INSTALL_DEPS='yum install -y wget libva libva-intel-driver gstreamer1-plugins-base pipewire xfce4-session xfce4-panel xorg-x11-server-Xvfb xorg-x11-xauth dbus-x11 mesa-libEGL mesa-libGL mesa-libGLU mesa-dri-drivers libXrandr libXScrnSaver gtk3 mesa-libgbm libdrm libXcomposite libXdamage libXfixes'
|
||||
INSTALL_CMD="yum install -y ./${PKG_NAME}"
|
||||
CLEAN_CMD="rm -f \"${PKG_NAME}\""
|
||||
else
|
||||
echo "❌ Unsupported distro: need apt, dnf, or yum."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- install rustdesk if missing ----
|
||||
if ! command -v rustdesk > /dev/null 2>&1; then
|
||||
printf "📦 Installing dependencies...\n"
|
||||
sudo bash -c "$INSTALL_DEPS" 2>&1 | tee -a "${LOG_PATH}"
|
||||
|
||||
printf "⬇️ Downloading RustDesk ${RUSTDESK_VERSION} (${PKG_SYS}, ${PKG_ARCH})...\n"
|
||||
URL="https://github.com/rustdesk/rustdesk/releases/download/${RUSTDESK_VERSION}/${PKG_NAME}"
|
||||
wget -q "$URL" 2>&1 | tee -a "${LOG_PATH}"
|
||||
|
||||
printf "🔧 Installing RustDesk...\n"
|
||||
sudo bash -c "$INSTALL_CMD" 2>&1 | tee -a "${LOG_PATH}"
|
||||
|
||||
printf "🧹 Cleaning up...\n"
|
||||
bash -c "$CLEAN_CMD" 2>&1 | tee -a "${LOG_PATH}"
|
||||
else
|
||||
printf "✅ RustDesk already installed\n"
|
||||
fi
|
||||
|
||||
# ---- start virtual display ----
|
||||
echo "Starting Xvfb with resolution ${XVFB_RESOLUTION}…"
|
||||
Xvfb :99 -screen 0 "${XVFB_RESOLUTION}" >> "${LOG_PATH}" 2>&1 &
|
||||
export DISPLAY=:99
|
||||
|
||||
# Wait for X to be ready
|
||||
for i in {1..10}; do
|
||||
if xdpyinfo -display :99 > /dev/null 2>&1; then
|
||||
echo "X display is ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ---- create (or accept) password and start rustdesk ----
|
||||
if [[ -z "${RUSTDESK_PASSWORD}" ]]; then
|
||||
RUSTDESK_PASSWORD="$(tr -dc 'a-zA-Z0-9@' < /dev/urandom | head -c 10)@97"
|
||||
fi
|
||||
|
||||
echo "Starting XFCE desktop environment..."
|
||||
xfce4-session >> "${LOG_PATH}" 2>&1 &
|
||||
|
||||
echo "Waiting for xfce4-session to initialize..."
|
||||
sleep 5
|
||||
|
||||
printf "🔐 Setting RustDesk password and starting service...\n"
|
||||
rustdesk >> "${LOG_PATH}" 2>&1 &
|
||||
sleep 2
|
||||
|
||||
rustdesk --password "${RUSTDESK_PASSWORD}" >> "${LOG_PATH}" 2>&1 &
|
||||
sleep 3
|
||||
|
||||
RID="$(rustdesk --get-id 2> /dev/null || echo 'ID_PENDING')"
|
||||
|
||||
printf "🥳 RustDesk setup complete!\n\n"
|
||||
printf "${BOLD}📋 Connection Details:${RESET}\n"
|
||||
printf " RustDesk ID: ${RID}\n"
|
||||
printf " RustDesk Password: ${RUSTDESK_PASSWORD}\n"
|
||||
printf " Display: ${DISPLAY} (${XVFB_RESOLUTION})\n"
|
||||
printf "\n📝 Logs available at: ${LOG_PATH}\n\n"
|
||||
|
||||
echo "Setup script completed successfully. All services running in background."
|
||||
exit 0
|
||||
@@ -22,31 +22,16 @@ provider "docker" {}
|
||||
module "claude-code" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "2.0.0"
|
||||
version = "3.0.0"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/projects"
|
||||
install_claude_code = true
|
||||
claude_code_version = "latest"
|
||||
workdir = "/home/coder/projects"
|
||||
order = 999
|
||||
|
||||
experiment_post_install_script = data.coder_parameter.setup_script.value
|
||||
|
||||
# This enables Coder Tasks
|
||||
experiment_report_tasks = true
|
||||
}
|
||||
|
||||
# You can also use a model provider, like AWS Bedrock or Vertex by replacing
|
||||
# this with the special env vars from the Claude Code docs.
|
||||
# see: https://docs.anthropic.com/en/docs/claude-code/third-party-integrations
|
||||
variable "anthropic_api_key" {
|
||||
type = string
|
||||
description = "Generate one at: https://console.anthropic.com/settings/keys"
|
||||
sensitive = true
|
||||
}
|
||||
resource "coder_env" "anthropic_api_key" {
|
||||
agent_id = coder_agent.main.id
|
||||
name = "CODER_MCP_CLAUDE_API_KEY"
|
||||
value = var.anthropic_api_key
|
||||
claude_api_key = ""
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
system_prompt = data.coder_parameter.system_prompt.value
|
||||
model = "sonnet"
|
||||
permission_mode = "plan"
|
||||
post_install_script = data.coder_parameter.setup_script.value
|
||||
}
|
||||
|
||||
# We are using presets to set the prompts, image, and set up instructions
|
||||
@@ -172,23 +157,6 @@ data "coder_parameter" "preview_port" {
|
||||
mutable = false
|
||||
}
|
||||
|
||||
# Other variables for Claude Code
|
||||
resource "coder_env" "claude_task_prompt" {
|
||||
agent_id = coder_agent.main.id
|
||||
name = "CODER_MCP_CLAUDE_TASK_PROMPT"
|
||||
value = data.coder_parameter.ai_prompt.value
|
||||
}
|
||||
resource "coder_env" "app_status_slug" {
|
||||
agent_id = coder_agent.main.id
|
||||
name = "CODER_MCP_APP_STATUS_SLUG"
|
||||
value = "ccw"
|
||||
}
|
||||
resource "coder_env" "claude_system_prompt" {
|
||||
agent_id = coder_agent.main.id
|
||||
name = "CODER_MCP_CLAUDE_SYSTEM_PROMPT"
|
||||
value = data.coder_parameter.system_prompt.value
|
||||
}
|
||||
|
||||
data "coder_provisioner" "me" {}
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "3.0.0"
|
||||
version = "3.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_api_key = "xxxx-xxxxx-xxxx"
|
||||
@@ -49,7 +49,7 @@ data "coder_parameter" "ai_prompt" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "3.0.0"
|
||||
version = "3.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -85,7 +85,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "3.0.0"
|
||||
version = "3.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder"
|
||||
install_claude_code = true
|
||||
@@ -108,7 +108,7 @@ variable "claude_code_oauth_token" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "3.0.0"
|
||||
version = "3.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_code_oauth_token = var.claude_code_oauth_token
|
||||
|
||||
@@ -244,6 +244,7 @@ module "agentapi" {
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = var.web_app_display_name
|
||||
folder = local.workdir
|
||||
cli_app = var.cli_app
|
||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||
|
||||
@@ -42,7 +42,7 @@ run "test_claude_code_with_api_key" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.claude_api_key.value == "test-api-key-123"
|
||||
condition = coder_env.claude_api_key[0].value == "test-api-key-123"
|
||||
error_message = "Claude API key value should match the input"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ tags: [ide, jetbrains, parameter, gateway]
|
||||
|
||||
This module adds a JetBrains Gateway Button to open any workspace with a single click.
|
||||
|
||||
> We recommend using the [Coder Toolbox module](https://registry.coder.com/modules/coder/jetbrains), which offers significant stability and connectivity benefits over Gateway. Reference our [documentation](https://coder.com/docs/user-guides/workspace-access/jetbrains/toolbox) for more information.
|
||||
|
||||
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.
|
||||
|
||||
@@ -17,7 +19,7 @@ Consult the [JetBrains documentation](https://www.jetbrains.com/help/idea/prereq
|
||||
module "jetbrains_gateway" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains-gateway/coder"
|
||||
version = "1.2.2"
|
||||
version = "1.2.4"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/example"
|
||||
jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"]
|
||||
@@ -35,7 +37,7 @@ module "jetbrains_gateway" {
|
||||
module "jetbrains_gateway" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains-gateway/coder"
|
||||
version = "1.2.2"
|
||||
version = "1.2.4"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/example"
|
||||
jetbrains_ides = ["GO", "WS"]
|
||||
@@ -49,7 +51,7 @@ module "jetbrains_gateway" {
|
||||
module "jetbrains_gateway" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains-gateway/coder"
|
||||
version = "1.2.2"
|
||||
version = "1.2.4"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/example"
|
||||
jetbrains_ides = ["IU", "PY"]
|
||||
@@ -64,7 +66,7 @@ module "jetbrains_gateway" {
|
||||
module "jetbrains_gateway" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains-gateway/coder"
|
||||
version = "1.2.2"
|
||||
version = "1.2.4"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/example"
|
||||
jetbrains_ides = ["IU", "PY"]
|
||||
@@ -89,7 +91,7 @@ module "jetbrains_gateway" {
|
||||
module "jetbrains_gateway" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains-gateway/coder"
|
||||
version = "1.2.2"
|
||||
version = "1.2.4"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/example"
|
||||
jetbrains_ides = ["GO", "WS"]
|
||||
@@ -107,7 +109,7 @@ Due to the highest priority of the `ide_download_link` parameter in the `(jetbra
|
||||
module "jetbrains_gateway" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains-gateway/coder"
|
||||
version = "1.2.2"
|
||||
version = "1.2.4"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/example"
|
||||
jetbrains_ides = ["GO", "WS"]
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("jetbrains-gateway", async () => {
|
||||
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&agent_id=foo",
|
||||
"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&agent=",
|
||||
);
|
||||
|
||||
const coder_app = state.resources.find(
|
||||
@@ -40,4 +40,28 @@ describe("jetbrains-gateway", async () => {
|
||||
});
|
||||
expect(state.outputs.identifier.value).toBe("IU");
|
||||
});
|
||||
|
||||
it("optionally includes agent when an agent name is provided", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
agent_name: "main",
|
||||
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&agent=main",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes the agent parameter even when the provided value is blank", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
agent_name: " ",
|
||||
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&agent= ",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,15 +30,14 @@ variable "agent_id" {
|
||||
|
||||
variable "slug" {
|
||||
type = string
|
||||
description = "The slug for the coder_app. Allows resuing the module with the same template."
|
||||
description = "The slug for the coder_app. Allows reusing 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 = ""
|
||||
description = "Agent name."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
@@ -348,8 +347,8 @@ resource "coder_app" "gateway" {
|
||||
local.build_number,
|
||||
"&ide_download_link=",
|
||||
local.download_link,
|
||||
"&agent_id=",
|
||||
var.agent_id,
|
||||
"&agent=",
|
||||
var.agent_name,
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
# tooltip = "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button." # Optional
|
||||
}
|
||||
```
|
||||
|
||||
@@ -39,7 +40,7 @@ When `default` contains IDE codes, those IDEs are created directly without user
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA
|
||||
@@ -52,7 +53,7 @@ module "jetbrains" {
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
# Show parameter with limited options
|
||||
@@ -66,7 +67,7 @@ module "jetbrains" {
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["IU", "PY"]
|
||||
@@ -81,7 +82,7 @@ module "jetbrains" {
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/workspace/project"
|
||||
|
||||
@@ -107,7 +108,7 @@ module "jetbrains" {
|
||||
module "jetbrains_pycharm" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/workspace/project"
|
||||
|
||||
@@ -119,6 +120,22 @@ module "jetbrains_pycharm" {
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Tooltip
|
||||
|
||||
Add helpful tooltip text that appears when users hover over the IDE app buttons:
|
||||
|
||||
```tf
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["IU", "PY"]
|
||||
tooltip = "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button."
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
### Parameter vs Direct Apps
|
||||
@@ -132,6 +149,13 @@ module "jetbrains_pycharm" {
|
||||
- If the API is unreachable (air-gapped environments), the module automatically falls back to build numbers from `ide_config`
|
||||
- `major_version` and `channel` control which API endpoint is queried (when API access is available)
|
||||
|
||||
### Tooltip
|
||||
|
||||
- **`tooltip`**: Optional markdown text displayed when hovering over IDE app buttons
|
||||
- If not specified, no tooltip is shown
|
||||
- Supports markdown formatting for rich text (bold, italic, links, etc.)
|
||||
- All IDE apps created by this module will show the same tooltip text
|
||||
|
||||
## Supported IDEs
|
||||
|
||||
All JetBrains IDEs with remote development capabilities:
|
||||
|
||||
@@ -129,3 +129,34 @@ run "app_order_when_default_not_empty" {
|
||||
error_message = "Expected coder_app order to be set to 10"
|
||||
}
|
||||
}
|
||||
|
||||
run "tooltip_when_provided" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO"]
|
||||
tooltip = "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.tooltip == "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button."])
|
||||
error_message = "Expected coder_app tooltip to be set when provided"
|
||||
}
|
||||
}
|
||||
|
||||
run "tooltip_null_when_not_provided" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO"]
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.tooltip == null])
|
||||
error_message = "Expected coder_app tooltip to be null when not provided"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +276,36 @@ describe("jetbrains", async () => {
|
||||
);
|
||||
expect(parameter?.instances[0].attributes.order).toBe(5);
|
||||
});
|
||||
|
||||
it("should set tooltip when specified", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
folder: "/home/coder",
|
||||
default: '["GO"]',
|
||||
tooltip:
|
||||
"You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button.",
|
||||
});
|
||||
|
||||
const coder_app = state.resources.find(
|
||||
(res) => res.type === "coder_app" && res.name === "jetbrains",
|
||||
);
|
||||
expect(coder_app?.instances[0].attributes.tooltip).toBe(
|
||||
"You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should have null tooltip when not specified", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
folder: "/home/coder",
|
||||
default: '["GO"]',
|
||||
});
|
||||
|
||||
const coder_app = state.resources.find(
|
||||
(res) => res.type === "coder_app" && res.name === "jetbrains",
|
||||
);
|
||||
expect(coder_app?.instances[0].attributes.tooltip).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// URL Generation Tests
|
||||
|
||||
@@ -59,6 +59,12 @@ variable "coder_parameter_order" {
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "tooltip" {
|
||||
type = string
|
||||
description = "Markdown text that is displayed when hovering over workspace apps."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "major_version" {
|
||||
type = string
|
||||
description = "The major version of the IDE. i.e. 2025.1"
|
||||
@@ -232,6 +238,7 @@ resource "coder_app" "jetbrains" {
|
||||
external = true
|
||||
order = var.coder_app_order
|
||||
group = var.group
|
||||
tooltip = var.tooltip
|
||||
url = join("", [
|
||||
"jetbrains://gateway/coder?&workspace=", # requires 2.6.3+ version of Toolbox
|
||||
data.coder_workspace.me.name,
|
||||
|
||||
@@ -24,12 +24,10 @@ describe("jfrog-oauth", async () => {
|
||||
const fakeFrogUrl = "http://localhost:8081";
|
||||
const user = "default";
|
||||
|
||||
it("can run apply with required variables", async () => {
|
||||
testRequiredVariables<TestVariables>(import.meta.dir, {
|
||||
agent_id: "some-agent-id",
|
||||
jfrog_url: fakeFrogUrl,
|
||||
package_managers: "{}",
|
||||
});
|
||||
testRequiredVariables<TestVariables>(import.meta.dir, {
|
||||
agent_id: "some-agent-id",
|
||||
jfrog_url: fakeFrogUrl,
|
||||
package_managers: "{}",
|
||||
});
|
||||
|
||||
it("generates an npmrc with scoped repos", async () => {
|
||||
|
||||
@@ -55,13 +55,11 @@ describe("jfrog-token", async () => {
|
||||
const user = "default";
|
||||
const token = "xxx";
|
||||
|
||||
it("can run apply with required variables", async () => {
|
||||
testRequiredVariables<TestVariables>(import.meta.dir, {
|
||||
agent_id: "some-agent-id",
|
||||
jfrog_url: fakeFrogUrl,
|
||||
artifactory_access_token: "XXXX",
|
||||
package_managers: "{}",
|
||||
});
|
||||
testRequiredVariables<TestVariables>(import.meta.dir, {
|
||||
agent_id: "some-agent-id",
|
||||
jfrog_url: fakeFrogUrl,
|
||||
artifactory_access_token: "XXXX",
|
||||
package_managers: "{}",
|
||||
});
|
||||
|
||||
it("generates an npmrc with scoped repos", async () => {
|
||||
|
||||
Reference in New Issue
Block a user