Compare commits

..

23 Commits

Author SHA1 Message Date
Michael Smith 2e5f3bfea1 refactor: rename files 2025-05-02 15:20:57 +00:00
Michael Smith cc51af1b6e Merge branch 'main' into mes/module-validation 2025-05-02 15:19:43 +00:00
Michael Smith 9e18a4e3a8 chore: add prettier/typo check to CI (#14)
## Changes made
- Added back CI steps for validating the codebase for typos and formatting
- Updated README validation CI step to be dependent on typo-checking step
- Updated configuration files as needed to support the new CI step
- Updated all files that were previously getting skipped over from improperly-set-up CI logic
2025-04-29 10:09:22 -04:00
Michael Smith 0ce1e7ab01 chore: add CONTRIBUTING.md file (#13)
## Changes made
- Added `CONTRIBUTING.md` file (mostly copied over from modules repo, with some parts reworded, and some sections added)

## Notes
- This definitely isn't the final version of the file (it should definitely change we have more Bash stuff added), but it felt like it's in a good enough spot for an initial release
2025-04-29 10:07:11 -04:00
Michael Smith 9404ad9a53 chore: add Success! The configuration is valid. (#16)
More work towards closing https://github.com/coder/internal/issues/532

## Changes made
- Added Bash script to run `terraform validate` on all relevant repos
- Updated `package.json` and CI to use the script
2025-04-29 09:51:04 -04:00
Michael Smith d4d307451f Merge pull request #18 from coder/mes/tighten-schema
chore: update README files and validation to reflect new requirements
2025-04-28 15:41:27 -04:00
Michael Smith 7e8d4b7f27 fix: make Github field optional 2025-04-28 19:37:06 +00:00
Michael Smith c731a5c64e fix: remove employer field entirely 2025-04-28 19:24:34 +00:00
Michael Smith 45640689e4 fix: update README files 2025-04-28 19:22:06 +00:00
Michael Smith c1e196c8b0 Merge pull request #17 from coder/mes/main-readme
chore: add main README.md file to project
2025-04-23 09:45:26 -04:00
Michael Smith 36acd61e40 fix: apply project rename to h1 title 2025-04-22 21:48:44 +00:00
Michael Smith 7d447d1672 Merge pull request #12 from coder/mes/site-check-script
fix: make site health check easier to use
2025-04-22 11:39:48 -04:00
Michael Smith 9780217535 fix: add back cron script 2025-04-22 15:07:02 +00:00
Michael Smith 494a25e688 fix: make site health check eaiser to use 2025-04-22 14:35:20 +00:00
Michael Smith 31c69e28a8 refactor: make code easier to read 2025-04-18 21:43:52 +00:00
Michael Smith fdc0f98c0f docs: update typo 2025-04-18 21:29:42 +00:00
Michael Smith eea1e059f0 docs: add missing words to comment 2025-04-18 21:29:21 +00:00
Michael Smith 1dcc645b14 fix: improve check for user namespace subdirectories 2025-04-18 21:26:26 +00:00
Michael Smith 812dd8faaf fix: update repo structure checks 2025-04-18 21:14:37 +00:00
Michael Smith 52c1f45da6 refactor: remove directory validation from contributors file 2025-04-18 20:51:18 +00:00
Michael Smith 1a5c102c2f refactor: more domain splitting 2025-04-18 20:49:04 +00:00
Michael Smith 36d33895b7 refactor: start splitting up files 2025-04-18 20:05:05 +00:00
Michael Smith 404b1e0a46 refactor: update file structure to reflect new changes 2025-04-18 19:16:35 +00:00
29 changed files with 699 additions and 1240 deletions
+21 -17
View File
@@ -1,19 +1,23 @@
# This should be the value of the GitHub Actions actor who triggered a run. The
# CI script will inject this value from the GitHub Actions context to verify
# whether changing certain README fields is allowed. In local development, you
# can set this to your GitHub username.
CI_ACTOR=
# ---
# These are used for the site health script, run from GitHub Actions. They can
# be set manually if you need to run the script locally.
# This is the configurable base URL for accessing the GitHub REST API. This
# value will be injected by the CI script's Actions context, but if the value is
# not defined (either in CI or when running locally), "https://api.github.com/"
# will be used as a fallback.
GITHUB_API_URL=
# Coder admins for Instatus can get this value from https://dashboard.instatus.com/developer
export INSTATUS_API_KEY=
# This is the API token for the user that will be used to authenticate calls to
# the GitHub API. In CI, the value will be loaded with a token belonging to a
# Coder Registry admin to verify whether modifying certain README fields is
# allowed. In local development, you can set a token with the read:org
# permission. If the loaded token does not belong to a Coder employee, certain
# README verification steps will be skipped.
GITHUB_API_TOKEN=
# Can be obtained from calling the Instatus API with a valid token. This value
# might not actually need to be private, but better safe than sorry
# https://instatus.com/help/api/status-pages
export INSTATUS_PAGE_ID=
# Can be obtained from calling the Instatus API with a valid token. This value
# might not actually need to be private, but better safe than sorry
# https://instatus.com/help/api/components
export INSTATUS_COMPONENT_ID=
# Can be grabbed from https://vercel.com/codercom/registry/stores/integration/upstash/store_1YDPuBF4Jd0aNpuV/guides
# Please make sure that the token you use is KV_REST_API_TOKEN; the script needs
# to be able to queries and mutations
export VERCEL_API_KEY=
# ---
+128 -128
View File
@@ -4,23 +4,23 @@ set -u
VERBOSE="${VERBOSE:-0}"
if [[ "${VERBOSE}" -ne "0" ]]; then
set -x
set -x
fi
# List of required environment variables
required_vars=(
"INSTATUS_API_KEY"
"INSTATUS_PAGE_ID"
"INSTATUS_COMPONENT_ID"
"VERCEL_API_KEY"
"INSTATUS_API_KEY"
"INSTATUS_PAGE_ID"
"INSTATUS_COMPONENT_ID"
"VERCEL_API_KEY"
)
# Check if each required variable is set
for var in "${required_vars[@]}"; do
if [[ -z "${!var:-}" ]]; then
echo "Error: Environment variable '$var' is not set."
exit 1
fi
if [[ -z "${!var:-}" ]]; then
echo "Error: Environment variable '$var' is not set."
exit 1
fi
done
REGISTRY_BASE_URL="${REGISTRY_BASE_URL:-https://registry.coder.com}"
@@ -31,38 +31,38 @@ declare -a failures=()
# Collect all module directories containing a main.tf file
for path in $(find . -maxdepth 2 -not -path '*/.*' -type f -name main.tf | cut -d '/' -f 2 | sort -u); do
modules+=("${path}")
modules+=("${path}")
done
echo "Checking modules: ${modules[*]}"
# Function to update the component status on Instatus
update_component_status() {
local component_status=$1
# see https://instatus.com/help/api/components
(curl -X PUT "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/components/$INSTATUS_COMPONENT_ID" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"status\": \"$component_status\"}")
local component_status=$1
# see https://instatus.com/help/api/components
(curl -X PUT "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/components/$INSTATUS_COMPONENT_ID" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"status\": \"$component_status\"}")
}
# Function to create an incident
create_incident() {
local incident_name="Degraded Service"
local message="The following modules are experiencing issues:\n"
for i in "${!failures[@]}"; do
message+="$((i + 1)). ${failures[$i]}\n"
done
local incident_name="Degraded Service"
local message="The following modules are experiencing issues:\n"
for i in "${!failures[@]}"; do
message+="$((i + 1)). ${failures[$i]}\n"
done
component_status="PARTIALOUTAGE"
if ((${#failures[@]} == ${#modules[@]})); then
component_status="MAJOROUTAGE"
fi
# see https://instatus.com/help/api/incidents
incident_id=$(curl -s -X POST "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/incidents" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" \
-d "{
component_status="PARTIALOUTAGE"
if ((${#failures[@]} == ${#modules[@]})); then
component_status="MAJOROUTAGE"
fi
# see https://instatus.com/help/api/incidents
incident_id=$(curl -s -X POST "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/incidents" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"$incident_name\",
\"message\": \"$message\",
\"components\": [\"$INSTATUS_COMPONENT_ID\"],
@@ -76,129 +76,129 @@ create_incident() {
]
}" | jq -r '.id')
echo "Created incident with ID: $incident_id"
echo "Created incident with ID: $incident_id"
}
# Function to check for existing unresolved incidents
check_existing_incident() {
# Fetch the latest incidents with status not equal to "RESOLVED"
local unresolved_incidents=$(curl -s -X GET "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/incidents" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" | jq -r '.incidents[] | select(.status != "RESOLVED") | .id')
# Fetch the latest incidents with status not equal to "RESOLVED"
local unresolved_incidents=$(curl -s -X GET "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/incidents" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" | jq -r '.incidents[] | select(.status != "RESOLVED") | .id')
if [[ -n "$unresolved_incidents" ]]; then
echo "Unresolved incidents found: $unresolved_incidents"
return 0 # Indicate that there are unresolved incidents
else
echo "No unresolved incidents found."
return 1 # Indicate that no unresolved incidents exist
fi
if [[ -n "$unresolved_incidents" ]]; then
echo "Unresolved incidents found: $unresolved_incidents"
return 0 # Indicate that there are unresolved incidents
else
echo "No unresolved incidents found."
return 1 # Indicate that no unresolved incidents exist
fi
}
force_redeploy_registry() {
# These are not secret values; safe to just expose directly in script
local VERCEL_TEAM_SLUG="codercom"
local VERCEL_TEAM_ID="team_tGkWfhEGGelkkqUUm9nXq17r"
local VERCEL_APP="registry"
# These are not secret values; safe to just expose directly in script
local VERCEL_TEAM_SLUG="codercom"
local VERCEL_TEAM_ID="team_tGkWfhEGGelkkqUUm9nXq17r"
local VERCEL_APP="registry"
local latest_res
latest_res=$(
curl "https://api.vercel.com/v6/deployments?app=$VERCEL_APP&limit=1&slug=$VERCEL_TEAM_SLUG&teamId=$VERCEL_TEAM_ID&target=production&state=BUILDING,INITIALIZING,QUEUED,READY" \
--fail \
--silent \
--header "Authorization: Bearer $VERCEL_API_KEY" \
--header "Content-Type: application/json"
)
local latest_res
latest_res=$(
curl "https://api.vercel.com/v6/deployments?app=$VERCEL_APP&limit=1&slug=$VERCEL_TEAM_SLUG&teamId=$VERCEL_TEAM_ID&target=production&state=BUILDING,INITIALIZING,QUEUED,READY" \
--fail \
--silent \
--header "Authorization: Bearer $VERCEL_API_KEY" \
--header "Content-Type: application/json"
)
# If we have zero deployments, something is VERY wrong. Make the whole
# script exit with a non-zero status code
local latest_id
latest_id=$(echo "${latest_res}" | jq -r '.deployments[0].uid')
if [[ "${latest_id}" = "null" ]]; then
echo "Unable to pull any previous deployments for redeployment"
echo "Please redeploy the latest deployment manually in Vercel."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
# If we have zero deployments, something is VERY wrong. Make the whole
# script exit with a non-zero status code
local latest_id
latest_id=$(echo "${latest_res}" | jq -r '.deployments[0].uid')
if [[ "${latest_id}" = "null" ]]; then
echo "Unable to pull any previous deployments for redeployment"
echo "Please redeploy the latest deployment manually in Vercel."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
local latest_date_ts_seconds
latest_date_ts_seconds=$(echo "${latest_res}" | jq -r '.deployments[0].createdAt/1000|floor')
local current_date_ts_seconds
current_date_ts_seconds="$(date +%s)"
local max_redeploy_interval_seconds=7200 # 2 hours
if ((current_date_ts_seconds - latest_date_ts_seconds < max_redeploy_interval_seconds)); then
echo "The registry was deployed less than 2 hours ago."
echo "Not automatically re-deploying the regitstry."
echo "A human reading this message should decide if a redeployment is necessary."
echo "Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
local latest_date_ts_seconds
latest_date_ts_seconds=$(echo "${latest_res}" | jq -r '.deployments[0].createdAt/1000|floor')
local current_date_ts_seconds
current_date_ts_seconds="$(date +%s)"
local max_redeploy_interval_seconds=7200 # 2 hours
if ((current_date_ts_seconds - latest_date_ts_seconds < max_redeploy_interval_seconds)); then
echo "The registry was deployed less than 2 hours ago."
echo "Not automatically re-deploying the regitstry."
echo "A human reading this message should decide if a redeployment is necessary."
echo "Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
local latest_deployment_state
latest_deployment_state="$(echo "${latest_res}" | jq -r '.deployments[0].state')"
if [[ "${latest_deployment_state}" != "READY" ]]; then
echo "Last deployment was not in READY state. Skipping redeployment."
echo "A human reading this message should decide if a redeployment is necessary."
echo "Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
local latest_deployment_state
latest_deployment_state="$(echo "${latest_res}" | jq -r '.deployments[0].state')"
if [[ "${latest_deployment_state}" != "READY" ]]; then
echo "Last deployment was not in READY state. Skipping redeployment."
echo "A human reading this message should decide if a redeployment is necessary."
echo "Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
echo "============================================================="
echo "!!! Redeploying registry with deployment ID: ${latest_id} !!!"
echo "============================================================="
echo "============================================================="
echo "!!! Redeploying registry with deployment ID: ${latest_id} !!!"
echo "============================================================="
if ! curl -X POST "https://api.vercel.com/v13/deployments?forceNew=1&skipAutoDetectionConfirmation=1&slug=$VERCEL_TEAM_SLUG&teamId=$VERCEL_TEAM_ID" \
--fail \
--header "Authorization: Bearer $VERCEL_API_KEY" \
--header "Content-Type: application/json" \
--data-raw "{ \"deploymentId\": \"${latest_id}\", \"name\": \"${VERCEL_APP}\", \"target\": \"production\" }"; then
echo "DEPLOYMENT FAILED! Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
if ! curl -X POST "https://api.vercel.com/v13/deployments?forceNew=1&skipAutoDetectionConfirmation=1&slug=$VERCEL_TEAM_SLUG&teamId=$VERCEL_TEAM_ID" \
--fail \
--header "Authorization: Bearer $VERCEL_API_KEY" \
--header "Content-Type: application/json" \
--data-raw "{ \"deploymentId\": \"${latest_id}\", \"name\": \"${VERCEL_APP}\", \"target\": \"production\" }"; then
echo "DEPLOYMENT FAILED! Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
}
# Check each module's accessibility
for module in "${modules[@]}"; do
# Trim leading/trailing whitespace from module name
module=$(echo "${module}" | xargs)
url="${REGISTRY_BASE_URL}/modules/${module}"
printf "=== Checking module %s at %s\n" "${module}" "${url}"
status_code=$(curl --output /dev/null --head --silent --fail --location "${url}" --retry 3 --write-out "%{http_code}")
if ((status_code != 200)); then
printf "==> FAIL(%s)\n" "${status_code}"
status=1
failures+=("${module}")
else
printf "==> OK(%s)\n" "${status_code}"
fi
# Trim leading/trailing whitespace from module name
module=$(echo "${module}" | xargs)
url="${REGISTRY_BASE_URL}/modules/${module}"
printf "=== Checking module %s at %s\n" "${module}" "${url}"
status_code=$(curl --output /dev/null --head --silent --fail --location "${url}" --retry 3 --write-out "%{http_code}")
if ((status_code != 200)); then
printf "==> FAIL(%s)\n" "${status_code}"
status=1
failures+=("${module}")
else
printf "==> OK(%s)\n" "${status_code}"
fi
done
# Determine overall status and update Instatus component
if ((status == 0)); then
echo "All modules are operational."
# set to
update_component_status "OPERATIONAL"
echo "All modules are operational."
# set to
update_component_status "OPERATIONAL"
else
echo "The following modules have issues: ${failures[*]}"
# check if all modules are down
if ((${#failures[@]} == ${#modules[@]})); then
update_component_status "MAJOROUTAGE"
else
update_component_status "PARTIALOUTAGE"
fi
echo "The following modules have issues: ${failures[*]}"
# check if all modules are down
if ((${#failures[@]} == ${#modules[@]})); then
update_component_status "MAJOROUTAGE"
else
update_component_status "PARTIALOUTAGE"
fi
# Check if there is an existing incident before creating a new one
if ! check_existing_incident; then
create_incident
fi
# Check if there is an existing incident before creating a new one
if ! check_existing_incident; then
create_incident
fi
# If a module is down, force a reployment to try getting things back online
# ASAP
# EDIT: registry.coder.com is no longer hosted on vercel
#force_redeploy_registry
# If a module is down, force a reployment to try getting things back online
# ASAP
# EDIT: registry.coder.com is no longer hosted on vercel
#force_redeploy_registry
fi
exit "${status}"
+4
View File
@@ -0,0 +1,4 @@
[default.extend-words]
muc = "muc" # For Munich location code
Hashi = "Hashi"
HashiCorp = "HashiCorp"
@@ -0,0 +1,23 @@
# Check modules health on registry.coder.com
name: check-registry-site-health
on:
schedule:
- cron: "0,15,30,45 * * * *" # Runs every 15 minutes
workflow_dispatch: # Allows manual triggering of the workflow if needed
jobs:
run-script:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run check.sh
run: |
./.github/scripts/check_registry_site_health.sh
env:
INSTATUS_API_KEY: ${{ secrets.INSTATUS_API_KEY }}
INSTATUS_PAGE_ID: ${{ secrets.INSTATUS_PAGE_ID }}
INSTATUS_COMPONENT_ID: ${{ secrets.INSTATUS_COMPONENT_ID }}
VERCEL_API_KEY: ${{ secrets.VERCEL_API_KEY }}
+42 -20
View File
@@ -7,26 +7,8 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
validate-readme-files:
runs-on: ubuntu-latest
env:
ACTOR: ${{ github.actor }}
BASE_REF: ${{ github.base_ref }}
HEAD_REF: ${{ github.head_ref }}
GITHUB_API_URL: ${{ github.api_url }}
GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23.2"
- name: Validate contributors
run: go build ./cmd/readmevalidation && ./readmevalidation
- name: Remove build file artifact
run: rm ./readmevalidation
test-terraform:
name: Validate Terraform output
runs-on: ubuntu-latest
steps:
- name: Check out code
@@ -44,5 +26,45 @@ jobs:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Run tests
- name: Run TypeScript tests
run: bun test
- name: Run Terraform Validate
run: bun terraform-validate
validate-style:
name: Check for typos and unformatted code
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# Need Terraform for its formatter
- name: Install Terraform
uses: coder/coder/.github/actions/setup-tf@main
- name: Install dependencies
run: bun install
- name: Validate formatting
run: bun fmt:ci
- name: Check for typos
uses: crate-ci/typos@v1.31.1
with:
config: .github/typos.toml
validate-readme-files:
name: Validate README files
runs-on: ubuntu-latest
# We want to do some basic README checks first before we try analyzing the
# contents
needs: validate-style
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23.2"
- name: Validate contributors
run: go build ./cmd/readmevalidation && ./readmevalidation
- name: Remove build file artifact
run: rm ./readmevalidation
-1
View File
@@ -137,7 +137,6 @@ dist
# Things needed for CI
/readmevalidation
/readmevalidation-git
# Terraform files generated during testing
.terraform*
+200
View File
@@ -0,0 +1,200 @@
# Contributing
## Getting started
This repo uses two main runtimes to verify the correctness of a module/template before it is published:
- [Bun](https://bun.sh/) Used to run tests for each module/template to validate overall functionality and correctness of Terraform output
- [Go](https://go.dev/) Used to validate all README files in the directory
### Installing Bun
To install Bun, you can run this command on Linux/MacOS:
```shell
curl -fsSL https://bun.sh/install | bash
```
Or this command on Windows:
```shell
powershell -c "irm bun.sh/install.ps1 | iex"
```
Follow the instructions to ensure that Bun is available globally. Once Bun is installed, install all necessary dependencies from the root of the repo:
Via NPM:
```shell
npm i
```
Via PNPM:
```shell
pnpm i
```
This repo does not support Yarn.
### Installing Go (optional)
This step can be skipped if you are not working on any of the README validation logic. The validation will still run as part of CI.
[Navigate to the official Go Installation page](https://go.dev/doc/install), and install the correct version for your operating system.
Once Go has been installed, verify the installation via:
```shell
go version
```
### Adding a new module/template (coming soon)
Once Bun (and possibly Go) have been installed, clone this repository. From there, you can run this script to make it easier to start contributing a new module or template:
```shell
./new.sh NAME_OF_NEW_MODULE
```
You can also create the correct module/template files manually.
## Testing a Module
> [!IMPORTANT]
> It is the responsibility of the module author to implement tests for every new module they wish to contribute. It falls to the author to test the module locally before submitting a PR.
All general-purpose test helpers for validating Terraform can be found in the top-level `/testing` directory. The helpers run `terraform apply` on modules that use variables, testing the script output against containers.
> [!NOTE]
> The testing suite must be able to run docker containers with the `--network=host` flag. This typically requires running the tests on Linux as this flag does not apply to Docker Desktop for MacOS and Windows. MacOS users can work around this by using something like [colima](https://github.com/abiosoft/colima) or [Orbstack](https://orbstack.dev/) instead of Docker Desktop.
You can reference the existing `*.test.ts` files to get an idea for how to set up tests.
You can run all tests by running this command:
```shell
bun test
```
Note that tests can take some time to run, so you probably don't want to be running this as part of your development loop.
To run specific tests, you can use the `-t` flag, which accepts a filepath regex:
```shell
bun test -t '<regex_pattern>'
```
To ensure that the module runs predictably in local development, you can update the Terraform source as follows:
```tf
module "example" {
# You may need to remove the 'version' field, it is incompatible with some sources.
source = "git::https://github.com/<USERNAME>/<REPO>.git//<MODULE-NAME>?ref=<BRANCH-NAME>"
}
```
## Adding/modifying README files
This repo uses Go to do a quick validation of each README. If you are working with the README files at all, it is strongly recommended that you install Go, so that the files can be validated locally.
### Validating all README files
To validate all README files throughout the entire repo, you can run the following:
```shell
go build ./cmd/readmevalidation && ./readmevalidation
```
The resulting binary is already part of the `.gitignore` file, but you can quickly remove it with:
```shell
rm ./readmevalidation
```
### README validation criteria
The following criteria exists for one of two reasons: (1) content accessibility, or (2) having content be designed in a way that's easy for the Registry site build step to use:
#### General README requirements
- There must be a frontmatter section.
- There must be exactly one h1 header, and it must be at the very top
- The README body (if it exists) must start with an h1 header. No other content (including GitHub-Flavored Markdown alerts) is allowed to be placed above it.
- When increasing the level of a header, the header's level must be incremented by one each time.
- Additional image/video assets can be placed in one of two places:
- In the same user namespace directory where that user's main content lives
- In the top-level `.icons` directory
- Any `.hcl` code snippets must be labeled as `.tf` snippets instead
```txt
\`\`\`tf
Content
\`\`\`
```
#### Contributor profiles
- The README body is allowed to be empty, but if it isn't, it must follow all the rules above.
- The frontmatter supports the following fields:
- `display_name` (required string) The name to use when displaying your user profile in the Coder Registry site
- `bio` (optional string) A short description of who you are
- `github` (required string) Your GitHub handle
- `avatar_url` (optional string)  A relative/absolute URL pointing to your avatar
- `linkedin` (optional string)  A URL pointing to your LinkedIn page
- `support_email` (optional string) An email for users to reach you at if they need help with a published module/template
- `employer_github` (optional string) The name of another user namespace whom you'd like to have associated with your account. The namespace must also exist in the repo, or else the README validation will fail.
- `status` (optional string union)  If defined, must be one of "community", "partner", or "official". "Community" is treated as the default value if not specified, and should be used for the majority of external contributions. "Official" should be used for Coder and Coder satellite companies. "Partner" is for companies who have a formal business agreement with Coder.
#### Modules and templates
- The frontmatter supports the following fields:
- `description` (required string) A short description of what the module/template does.
- `icon` (required string)  A URL pointing to the icon to use for the module/template when viewing it on the Registry website.
- `display_name` (optional string) A name to display instead of the name intuited from the module's/template's directory name
- `verified` (optional boolean)  A boolean indicated that the Coder team has officially tested and vouched for the functionality/reliability of a given module or template. This field should only be changed by Coder employees.
- `tags` (optional string array) A list of tags to associate with the module/template. Users will be able to search for these tags from the Registry website.
## Releases
The release process is automated with these steps:
### 1. Create and merge a new PR
- Create a PR with your module changes
- Get your PR reviewed, approved, and merged into the `main` branch
### 2. Prepare Release (Maintainer Task)
After merging to `main`, a maintainer will:
- View all modules and their current versions:
```shell
./release.sh --list
```
- Determine the next version number based on changes:
- **Patch version** (1.2.3 → 1.2.4): Bug fixes
- **Minor version** (1.2.3 → 1.3.0): New features, adding inputs, deprecating inputs
- **Major version** (1.2.3 → 2.0.0): Breaking changes (removing inputs, changing input types)
- Create and push an annotated tag:
```shell
# Fetch latest changes
git fetch origin
# Create and push tag
./release.sh module-name 1.2.3 --push
```
The tag format will be: `release/module-name/v1.2.3`
### 3. Publishing to Coder Registry
Our automated processes will handle publishing new data to [registry.coder.com](https://registry.coder.com).
> [!NOTE]
> Some data in registry.coder.com is fetched on demand from the [coder/modules](https://github.com/coder/modules) repo's `main` branch. This data should update almost immediately after a release, while other changes will take some time to propagate.
+1 -1
View File
@@ -1,3 +1,3 @@
# hub
# Coder Registry
Publish Coder modules and templates for other developers to use.
-169
View File
@@ -1,169 +0,0 @@
// Package github provides utilities to make it easier to deal with various
// GitHub APIs
package github
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)
const defaultGithubAPIBaseRoute = "https://api.github.com/"
// Client is a reusable REST client for making requests to the GitHub API.
// It should be instantiated via NewGithubClient
type Client struct {
baseURL string
token string
httpClient http.Client
}
// ClientInit is used to instantiate a new client. If the value of BaseURL is
// not defined, a default value of "https://api.github.com/" is used instead
type ClientInit struct {
BaseURL string
APIToken string
}
// NewClient instantiates a GitHub client. If the baseURL is
func NewClient(init ClientInit) (*Client, error) {
// Considered letting the user continue on with no token and more aggressive
// rate-limiting, but from experimentation, the non-authenticated experience
// hit the rate limits really quickly, and had a lot of restrictions
apiToken := init.APIToken
if apiToken == "" {
return nil, errors.New("API token is missing")
}
baseURL := init.BaseURL
if baseURL == "" {
baseURL = defaultGithubAPIBaseRoute
}
return &Client{
baseURL: baseURL,
token: apiToken,
httpClient: http.Client{Timeout: 10 * time.Second},
}, nil
}
// User represents a truncated version of the API response from Github's /user
// endpoint.
type User struct {
Login string `json:"login"`
}
// GetUserFromToken returns the user associated with the loaded API token
func (gc *Client) GetUserFromToken() (User, error) {
req, err := http.NewRequest("GET", gc.baseURL+"user", nil)
if err != nil {
return User{}, err
}
if gc.token != "" {
req.Header.Add("Authorization", "Bearer "+gc.token)
}
res, err := gc.httpClient.Do(req)
if err != nil {
return User{}, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusUnauthorized {
return User{}, errors.New("request is not authorized")
}
if res.StatusCode == http.StatusForbidden {
return User{}, errors.New("request is forbidden")
}
b, err := io.ReadAll(res.Body)
if err != nil {
return User{}, err
}
user := User{}
if err := json.Unmarshal(b, &user); err != nil {
return User{}, err
}
return user, nil
}
// OrgStatus indicates whether a GitHub user is a member of a given organization
type OrgStatus int
var _ fmt.Stringer = OrgStatus(0)
const (
// OrgStatusIndeterminate indicates when a user's organization status
// could not be determined. It is the zero value of the OrgStatus type, and
// any users with this value should be treated as completely untrusted
OrgStatusIndeterminate = iota
// OrgStatusNonMember indicates when a user is definitely NOT part of an
// organization
OrgStatusNonMember
// OrgStatusMember indicates when a user is a member of a Github
// organization
OrgStatusMember
)
func (s OrgStatus) String() string {
switch s {
case OrgStatusMember:
return "Member"
case OrgStatusNonMember:
return "Non-member"
default:
return "Indeterminate"
}
}
// GetUserOrgStatus takes a GitHub username, and checks the GitHub API to see
// whether that member is part of the provided organization
func (gc *Client) GetUserOrgStatus(orgName string, username string) (OrgStatus, error) {
// This API endpoint is really annoying, because it's able to produce false
// negatives. Any user can be:
// 1. A public member of an organization
// 2. A private member of an organization
// 3. Not a member of an organization
//
// So if the function returns status 200, you can always trust that. But if
// it returns any 400 code, that could indicate a few things:
// 1. The user associated with the token is a member of the organization,
// and the user being checked is not.
// 2. The user associated with the token is NOT a member of the
// organization, and the member being checked is a private member. The
// token user will have no way to view the private member's status.
// 3. Neither the user being checked nor the user associated with the token
// are members of the organization.
//
// The best option to avoid false positives is to make sure that the token
// being used belongs to a member of the organization being checked.
url := fmt.Sprintf("%sorgs/%s/members/%s", gc.baseURL, orgName, username)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return OrgStatusIndeterminate, err
}
if gc.token != "" {
req.Header.Add("Authorization", "Bearer "+gc.token)
}
res, err := gc.httpClient.Do(req)
if err != nil {
return OrgStatusIndeterminate, err
}
defer res.Body.Close()
switch res.StatusCode {
case http.StatusNoContent:
return OrgStatusMember, nil
case http.StatusNotFound:
return OrgStatusNonMember, nil
default:
return OrgStatusIndeterminate, nil
}
}
-313
View File
@@ -1,313 +0,0 @@
package main
import (
"errors"
"fmt"
"net/url"
"os"
"path"
"slices"
"strings"
"coder.com/coder-registry/cmd/github"
"gopkg.in/yaml.v3"
)
// dummyGitDirectory is the directory that a full version of the Registry will
// be cloned into during CI. The CI needs to use Git history to validate
// certain README files, and using the root branch itself (even though it's
// fully equivalent) has a risk of breaking other CI steps when switching
// branches. Better to make a full isolated copy and manipulate that.
const dummyGitDirectory = "./readmevalidation-git"
var supportedResourceTypes = []string{"modules", "templates"}
type coderResourceFrontmatter struct {
Description string `yaml:"description"`
IconURL string `yaml:"icon"`
DisplayName *string `yaml:"display_name"`
Verified *bool `yaml:"verified"`
Tags []string `yaml:"tags"`
}
// coderResource represents a generic concept for a Terraform resource used to
// help create Coder workspaces. As of 2025-04-15, this encapsulates both
// Coder Modules and Coder Templates. If the newReadmeBody and newFrontmatter
// fields are nil, that represents that the file has been deleted
type coderResource struct {
resourceType string
filePath string
newReadmeBody *string
oldFrontmatter *coderResourceFrontmatter
newFrontmatter *coderResourceFrontmatter
oldIsVerified bool
newIsVerified bool
}
func validateCoderResourceDisplayName(displayName *string) error {
if displayName == nil {
return nil
}
if *displayName == "" {
return errors.New("if defined, display_name must not be empty string")
}
return nil
}
func validateCoderResourceDescription(description string) error {
if description == "" {
return errors.New("frontmatter description cannot be empty")
}
return nil
}
func validateCoderResourceIconURL(iconURL string) []error {
problems := []error{}
if iconURL == "" {
problems = append(problems, errors.New("icon URL cannot be empty"))
return problems
}
isAbsoluteURL := !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/")
if isAbsoluteURL {
if _, err := url.ParseRequestURI(iconURL); err != nil {
problems = append(problems, errors.New("absolute icon URL is not correctly formatted"))
}
if strings.Contains(iconURL, "?") {
problems = append(problems, errors.New("icon URLs cannot contain query parameters"))
}
return problems
}
// 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(iconURL, "./") ||
strings.HasPrefix(iconURL, "/") ||
strings.HasPrefix(iconURL, "../../../.icons")
if !isPermittedRelativeURL {
problems = append(problems, fmt.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))
}
return problems
}
func validateCoderResourceTags(tags []string) error {
if len(tags) == 0 {
return nil
}
// 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 tags {
if t != url.QueryEscape(t) {
invalidTags = append(invalidTags, t)
}
}
if len(invalidTags) != 0 {
return fmt.Errorf("found invalid tags (tags that cannot be used for filter state in the Registry website): [%s]", strings.Join(invalidTags, ", "))
}
return nil
}
func validateCoderResourceVerifiedStatus(oldVerified bool, newVerified bool, actorOrgStatus github.OrgStatus) error {
// If the actor making the changes is an employee of Coder, any changes are
// assumed to be valid
if actorOrgStatus == github.OrgStatusMember {
return nil
}
// Right now, because we collapse the omitted/nil case and false together,
// the only field transition that's allowed is if the verified statuses are
// exactly the same (which includes the field going from omitted to
// explicitly false, or vice-versa).
isPermittedChangeForNonEmployee := oldVerified == newVerified
if isPermittedChangeForNonEmployee {
return nil
}
return fmt.Errorf("actor with status %q is not allowed to flip verified status from %t to %t", actorOrgStatus.String(), oldVerified, newVerified)
}
// Todo: once we decide on how we want the README frontmatter to be formatted
// for the Embedded Registry work, update this function to validate that the
// correct Terraform code snippets are included in the README and are actually
// valid Terraform. Might also want to validate that each header follows proper
// hierarchy (i.e., not jumping from h1 to h3 because you think it looks nicer)
func validateCoderResourceReadmeBody(body string) error {
trimmed := strings.TrimSpace(body)
if !strings.HasPrefix(trimmed, "# ") {
return errors.New("README body must start with ATX-style h1 header (i.e., \"# \")")
}
return nil
}
func validateCoderResourceChanges(resource coderResource, actorOrgStatus github.OrgStatus) []error {
var problems []error
if resource.newReadmeBody != nil {
if err := validateCoderResourceReadmeBody(*resource.newReadmeBody); err != nil {
problems = append(problems, addFilePathToError(resource.filePath, err))
}
}
if resource.newFrontmatter != nil {
if err := validateCoderResourceDisplayName(resource.newFrontmatter.DisplayName); err != nil {
problems = append(problems, addFilePathToError(resource.filePath, err))
}
if err := validateCoderResourceDescription(resource.newFrontmatter.Description); err != nil {
problems = append(problems, addFilePathToError(resource.filePath, err))
}
if err := validateCoderResourceTags(resource.newFrontmatter.Tags); err != nil {
problems = append(problems, addFilePathToError(resource.filePath, err))
}
if err := validateCoderResourceVerifiedStatus(resource.oldIsVerified, resource.newIsVerified, actorOrgStatus); err != nil {
problems = append(problems, addFilePathToError(resource.filePath, err))
}
for _, err := range validateCoderResourceIconURL(resource.newFrontmatter.IconURL) {
problems = append(problems, addFilePathToError(resource.filePath, err))
}
}
return problems
}
func parseCoderResourceFiles(resourceType string, oldReadmeFiles []readme, newReadmeFiles []readme, actorOrgStatus github.OrgStatus) (map[string]coderResource, error) {
if !slices.Contains(supportedResourceTypes, resourceType) {
return nil, fmt.Errorf("resource type %q is not in supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", "))
}
var errs []error
resourcesByFilePath := map[string]coderResource{}
zipped := zipReadmes(oldReadmeFiles, newReadmeFiles)
for filePath, z := range zipped {
resource := coderResource{
resourceType: resourceType,
filePath: filePath,
}
if z.new != nil {
fm, body, err := separateFrontmatter(z.new.rawText)
if err != nil {
errs = append(errs, fmt.Errorf("resource type %s - %q: %v", resourceType, filePath, err))
} else {
resource.newReadmeBody = &body
var newFm coderResourceFrontmatter
if err := yaml.Unmarshal([]byte(fm), &newFm); err != nil {
errs = append(errs, fmt.Errorf("resource type %s - %q: %v", resourceType, filePath, err))
} else {
resource.newFrontmatter = &newFm
if newFm.Verified != nil && *newFm.Verified {
resource.newIsVerified = true
}
}
}
}
if z.old != nil {
fm, _, err := separateFrontmatter(z.old.rawText)
if err != nil {
errs = append(errs, fmt.Errorf("resource type %s - %q: %v", resourceType, filePath, err))
} else {
var oldFm coderResourceFrontmatter
if err := yaml.Unmarshal([]byte(fm), &oldFm); err != nil {
errs = append(errs, fmt.Errorf("resource type %s - %q: %v", resourceType, filePath, err))
} else {
resource.oldFrontmatter = &oldFm
if oldFm.Verified != nil && *oldFm.Verified {
resource.oldIsVerified = true
}
}
}
}
if z.old != nil || z.new != nil {
resourcesByFilePath[filePath] = resource
}
}
for _, r := range resourcesByFilePath {
errs = append(errs, validateCoderResourceChanges(r, actorOrgStatus)...)
}
if len(errs) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadmeParsing,
errors: errs,
}
}
return resourcesByFilePath, nil
}
// Todo: because Coder Resource READMEs will have their full contents
// (frontmatter and body) rendered on the Registry site, we need to make sure
// that all image references in the body are valid, too
func validateCoderResourceRelativeUrls(map[string]coderResource) []error {
return nil
}
func aggregateCoderResourceReadmeFiles(resourceDirectoryName string) ([]readme, error) {
if !slices.Contains(supportedResourceTypes, resourceDirectoryName) {
return nil, fmt.Errorf("%q is not a supported resource type. Must be one of [%s]", resourceDirectoryName, strings.Join(supportedResourceTypes, ", "))
}
registryFiles, err := os.ReadDir(rootRegistryPath)
if err != nil {
return nil, err
}
var allReadmeFiles []readme
var problems []error
for _, f := range registryFiles {
if !f.IsDir() {
continue
}
resourceDirPath := path.Join(rootRegistryPath, f.Name(), resourceDirectoryName)
resourceFiles, err := os.ReadDir(resourceDirPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
problems = append(problems, err)
}
continue
}
for _, resFile := range resourceFiles {
// Not sure if we want to allow non-directories to live inside of
// main directories like /modules or /templates, but we can tighten
// things up later
if !resFile.IsDir() {
continue
}
readmePath := path.Join(resourceDirPath, resFile.Name(), "README.md")
rawRm, err := os.ReadFile(readmePath)
if err != nil {
problems = append(problems, err)
continue
}
allReadmeFiles = append(allReadmeFiles, readme{
filePath: readmePath,
rawText: string(rawRm),
})
}
}
if len(problems) != 0 {
return nil, validationPhaseError{
phase: validationPhaseFileLoad,
errors: problems,
}
}
return allReadmeFiles, nil
}
+61 -116
View File
@@ -16,63 +16,23 @@ import (
var validContributorStatuses = []string{"official", "partner", "community"}
type contributorProfileFrontmatter struct {
DisplayName string `yaml:"display_name"`
Bio string `yaml:"bio"`
GithubUsername string `yaml:"github"`
DisplayName string `yaml:"display_name"`
Bio string `yaml:"bio"`
// 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"`
LinkedinURL *string `yaml:"linkedin"`
WebsiteURL *string `yaml:"website"`
SupportEmail *string `yaml:"support_email"`
EmployerGithubUsername *string `yaml:"employer_github"`
ContributorStatus *string `yaml:"status"`
AvatarURL *string `yaml:"avatar"`
LinkedinURL *string `yaml:"linkedin"`
WebsiteURL *string `yaml:"website"`
SupportEmail *string `yaml:"support_email"`
ContributorStatus *string `yaml:"status"`
}
type contributorProfile struct {
frontmatter contributorProfileFrontmatter
namespace string
filePath string
}
func validateContributorGithubUsername(githubUsername string) error {
if githubUsername == "" {
return errors.New("missing GitHub username")
}
lower := strings.ToLower(githubUsername)
if uriSafe := url.PathEscape(lower); uriSafe != lower {
return fmt.Errorf("gitHub username %q is not a valid URL path segment", githubUsername)
}
return nil
}
func validateContributorEmployerGithubUsername(
employerGithubUsername *string,
githubUsername string,
) []error {
if employerGithubUsername == nil {
return nil
}
problems := []error{}
if *employerGithubUsername == "" {
problems = append(problems, errors.New("company_github field is defined but has empty value"))
return problems
}
lower := strings.ToLower(*employerGithubUsername)
if uriSafe := url.PathEscape(lower); uriSafe != lower {
problems = append(problems, fmt.Errorf("gitHub company username %q is not a valid URL path segment", *employerGithubUsername))
}
if *employerGithubUsername == githubUsername {
problems = append(problems, fmt.Errorf("cannot list own GitHub name (%q) as employer", githubUsername))
}
return problems
}
func validateContributorDisplayName(displayName string) error {
if displayName == "" {
return fmt.Errorf("missing display_name")
@@ -98,7 +58,7 @@ func validateContributorSupportEmail(email *string) []error {
return nil
}
problems := []error{}
errs := []error{}
// Can't 100% validate that this is correct without actually sending
// an email, and especially with some contributors being individual
@@ -106,31 +66,31 @@ func validateContributorSupportEmail(email *string) []error {
// pipeline. Best we can do is verify the general structure
username, server, ok := strings.Cut(*email, "@")
if !ok {
problems = append(problems, fmt.Errorf("email address %q is missing @ symbol", *email))
return problems
errs = append(errs, fmt.Errorf("email address %q is missing @ symbol", *email))
return errs
}
if username == "" {
problems = append(problems, fmt.Errorf("email address %q is missing username", *email))
errs = append(errs, fmt.Errorf("email address %q is missing username", *email))
}
domain, tld, ok := strings.Cut(server, ".")
if !ok {
problems = append(problems, fmt.Errorf("email address %q is missing period for server segment", *email))
return problems
errs = append(errs, fmt.Errorf("email address %q is missing period for server segment", *email))
return errs
}
if domain == "" {
problems = append(problems, fmt.Errorf("email address %q is missing domain", *email))
errs = append(errs, fmt.Errorf("email address %q is missing domain", *email))
}
if tld == "" {
problems = append(problems, fmt.Errorf("email address %q is missing top-level domain", *email))
errs = append(errs, fmt.Errorf("email address %q is missing top-level domain", *email))
}
if strings.Contains(*email, "?") {
problems = append(problems, errors.New("email is not allowed to contain query parameters"))
errs = append(errs, errors.New("email is not allowed to contain query parameters"))
}
return problems
return errs
}
func validateContributorWebsite(websiteURL *string) error {
@@ -164,19 +124,19 @@ func validateContributorAvatarURL(avatarURL *string) []error {
return nil
}
problems := []error{}
errs := []error{}
if *avatarURL == "" {
problems = append(problems, errors.New("avatar URL must be omitted or non-empty string"))
return problems
errs = append(errs, errors.New("avatar URL must be omitted or non-empty string"))
return errs
}
// 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(*avatarURL); err != nil {
problems = append(problems, fmt.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL))
errs = append(errs, fmt.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL))
}
if strings.Contains(*avatarURL, "?") {
problems = append(problems, errors.New("avatar URL is not allowed to contain search parameters"))
errs = append(errs, errors.New("avatar URL is not allowed to contain search parameters"))
}
matched := false
@@ -189,42 +149,36 @@ func validateContributorAvatarURL(avatarURL *string) []error {
if !matched {
segments := strings.Split(*avatarURL, ".")
fileExtension := segments[len(segments)-1]
problems = append(problems, fmt.Errorf("avatar URL '.%s' does not end in a supported file format: [%s]", fileExtension, strings.Join(supportedAvatarFileFormats, ", ")))
errs = append(errs, fmt.Errorf("avatar URL '.%s' does not end in a supported file format: [%s]", fileExtension, strings.Join(supportedAvatarFileFormats, ", ")))
}
return problems
return errs
}
func validateContributorProfile(yml contributorProfile) []error {
allProblems := []error{}
func validateContributorYaml(yml contributorProfile) []error {
allErrs := []error{}
if err := validateContributorGithubUsername(yml.frontmatter.GithubUsername); err != nil {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
}
if err := validateContributorDisplayName(yml.frontmatter.DisplayName); err != nil {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
allErrs = append(allErrs, addFilePathToError(yml.filePath, err))
}
if err := validateContributorLinkedinURL(yml.frontmatter.LinkedinURL); err != nil {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
allErrs = append(allErrs, addFilePathToError(yml.filePath, err))
}
if err := validateContributorWebsite(yml.frontmatter.WebsiteURL); err != nil {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
allErrs = append(allErrs, addFilePathToError(yml.filePath, err))
}
if err := validateContributorStatus(yml.frontmatter.ContributorStatus); err != nil {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
allErrs = append(allErrs, addFilePathToError(yml.filePath, err))
}
for _, err := range validateContributorEmployerGithubUsername(yml.frontmatter.EmployerGithubUsername, yml.frontmatter.GithubUsername) {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
}
for _, err := range validateContributorSupportEmail(yml.frontmatter.SupportEmail) {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
allErrs = append(allErrs, addFilePathToError(yml.filePath, err))
}
for _, err := range validateContributorAvatarURL(yml.frontmatter.AvatarURL) {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
allErrs = append(allErrs, addFilePathToError(yml.filePath, err))
}
return allProblems
return allErrs
}
func parseContributorProfile(rm readme) (contributorProfile, error) {
@@ -241,11 +195,12 @@ func parseContributorProfile(rm readme) (contributorProfile, error) {
return contributorProfile{
filePath: rm.filePath,
frontmatter: yml,
namespace: strings.TrimSuffix(strings.TrimPrefix(rm.filePath, "registry/"), "/README.md"),
}, nil
}
func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfile, error) {
profilesByUsername := map[string]contributorProfile{}
profilesByNamespace := map[string]contributorProfile{}
yamlParsingErrors := []error{}
for _, rm := range readmeEntries {
p, err := parseContributorProfile(rm)
@@ -254,11 +209,11 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
continue
}
if prev, alreadyExists := profilesByUsername[p.frontmatter.GithubUsername]; alreadyExists {
yamlParsingErrors = append(yamlParsingErrors, fmt.Errorf("%q: GitHub name %s conflicts with field defined in %q", p.filePath, p.frontmatter.GithubUsername, prev.filePath))
if prev, alreadyExists := profilesByNamespace[p.namespace]; alreadyExists {
yamlParsingErrors = append(yamlParsingErrors, fmt.Errorf("%q: namespace %q conflicts with namespace from %q", p.filePath, p.namespace, prev.filePath))
continue
}
profilesByUsername[p.frontmatter.GithubUsername] = p
profilesByNamespace[p.namespace] = p
}
if len(yamlParsingErrors) != 0 {
return nil, validationPhaseError{
@@ -267,36 +222,22 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
}
}
employeeGithubGroups := map[string][]string{}
yamlValidationErrors := []error{}
for _, p := range profilesByUsername {
errors := validateContributorProfile(p)
for _, p := range profilesByNamespace {
errors := validateContributorYaml(p)
if len(errors) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errors...)
continue
}
if p.frontmatter.EmployerGithubUsername != nil {
employeeGithubGroups[*p.frontmatter.EmployerGithubUsername] = append(
employeeGithubGroups[*p.frontmatter.EmployerGithubUsername],
p.frontmatter.GithubUsername,
)
}
}
for companyName, group := range employeeGithubGroups {
if _, found := profilesByUsername[companyName]; found {
continue
}
yamlValidationErrors = append(yamlValidationErrors, fmt.Errorf("company %q does not exist in %q directory but is referenced by these profiles: [%s]", companyName, rootRegistryPath, strings.Join(group, ", ")))
}
if len(yamlValidationErrors) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadmeValidation,
phase: validationPhaseReadmeParsing,
errors: yamlValidationErrors,
}
}
return profilesByUsername, nil
return profilesByNamespace, nil
}
func aggregateContributorReadmeFiles() ([]readme, error) {
@@ -306,7 +247,7 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
}
allReadmeFiles := []readme{}
problems := []error{}
errs := []error{}
for _, e := range dirEntries {
dirPath := path.Join(rootRegistryPath, e.Name())
if !e.IsDir() {
@@ -316,7 +257,7 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
readmePath := path.Join(dirPath, "README.md")
rmBytes, err := os.ReadFile(readmePath)
if err != nil {
problems = append(problems, err)
errs = append(errs, err)
continue
}
allReadmeFiles = append(allReadmeFiles, readme{
@@ -325,10 +266,10 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
})
}
if len(problems) != 0 {
if len(errs) != 0 {
return nil, validationPhaseError{
phase: validationPhaseFileLoad,
errors: problems,
errors: errs,
}
}
@@ -338,7 +279,7 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
func validateContributorRelativeUrls(contributors map[string]contributorProfile) error {
// This function only validates relative avatar URLs for now, but it can be
// beefed up to validate more in the future
problems := []error{}
errs := []error{}
for _, con := range contributors {
// If the avatar URL is missing, we'll just assume that the Registry
@@ -346,13 +287,15 @@ func validateContributorRelativeUrls(contributors map[string]contributorProfile)
if con.frontmatter.AvatarURL == nil {
continue
}
if isRelativeURL := strings.HasPrefix(*con.frontmatter.AvatarURL, ".") ||
strings.HasPrefix(*con.frontmatter.AvatarURL, "/"); !isRelativeURL {
isRelativeURL := strings.HasPrefix(*con.frontmatter.AvatarURL, ".") ||
strings.HasPrefix(*con.frontmatter.AvatarURL, "/")
if !isRelativeURL {
continue
}
if strings.HasPrefix(*con.frontmatter.AvatarURL, "..") {
problems = append(problems, fmt.Errorf("%q: relative avatar URLs cannot be placed outside a user's namespaced directory", con.filePath))
errs = append(errs, fmt.Errorf("%q: relative avatar URLs cannot be placed outside a user's namespaced directory", con.filePath))
continue
}
@@ -360,36 +303,38 @@ func validateContributorRelativeUrls(contributors map[string]contributorProfile)
*con.frontmatter.AvatarURL
_, err := os.ReadFile(absolutePath)
if err != nil {
problems = append(problems, fmt.Errorf("%q: relative avatar path %q does not point to image in file system", con.filePath, *con.frontmatter.AvatarURL))
errs = append(errs, fmt.Errorf("%q: relative avatar path %q does not point to image in file system", con.filePath, *con.frontmatter.AvatarURL))
}
}
if len(problems) == 0 {
if len(errs) == 0 {
return nil
}
return validationPhaseError{
phase: validationPhaseAssetCrossReference,
errors: problems,
errors: errs,
}
}
func validateAllContributors() error {
func validateAllContributorFiles() error {
allReadmeFiles, err := aggregateContributorReadmeFiles()
if err != nil {
return err
}
log.Printf("Processing %d README files\n", len(allReadmeFiles))
contributors, err := parseContributorFiles(allReadmeFiles)
log.Printf("Processed %d README files as valid contributor profiles", len(contributors))
if err != nil {
return err
}
log.Printf("Processed %d README files as valid contributor profiles", len(contributors))
err = validateContributorRelativeUrls(contributors)
if err != nil {
return err
}
log.Println("All relative URLs for READMEs are valid")
log.Printf("Processed all READMEs in the %q directory\n", rootRegistryPath)
log.Printf("Processed all READMEs in the %q directory\n", rootRegistryPath)
return nil
}
+2 -2
View File
@@ -2,8 +2,6 @@ package main
import "fmt"
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
@@ -13,6 +11,8 @@ type validationPhaseError struct {
errors []error
}
var _ error = validationPhaseError{}
func (vpe validationPhaseError) Error() string {
msg := fmt.Sprintf("Error during %q phase of README validation:", vpe.phase.String())
for _, e := range vpe.errors {
-33
View File
@@ -1,33 +0,0 @@
package main
import (
"fmt"
"os"
)
const actionsActorKey = "CI_ACTOR"
const (
githubAPIBaseURLKey = "GITHUB_API_URL"
githubAPITokenKey = "GITHUB_API_TOKEN"
)
// actionsActor returns the username of the GitHub user who triggered the
// current CI run as part of GitHub Actions. It is expected that this value be
// set using a local .env file in local development, and set via GitHub Actions
// context during CI.
func actionsActor() (string, error) {
username := os.Getenv(actionsActorKey)
if username == "" {
return "", fmt.Errorf("value for %q is not in env. If running from CI, please add value via ci.yaml file", actionsActorKey)
}
return username, nil
}
func githubAPIToken() (string, error) {
token := os.Getenv(githubAPITokenKey)
if token == "" {
return "", fmt.Errorf("value for %q is not in env. If running from CI, please add value via ci.yaml file", githubAPITokenKey)
}
return token, nil
}
+20 -161
View File
@@ -1,179 +1,38 @@
// This package is for validating all the README files present in the Registry
// directory. The expectation is that each contributor, module, and template
// will have an associated README containing useful metadata. This metadata must
// be validated for correct structure during CI, because the files themselves
// are parsed and rendered as UI as part of the Registry site build step (the
// Registry site itself lives in a separate repo).
// 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 (
"fmt"
"log"
"os"
"sync"
"coder.com/coder-registry/cmd/github"
"github.com/joho/godotenv"
)
func main() {
log.Println("Beginning README file validation")
// Do basic setup
err := godotenv.Load()
if err != nil {
log.Panic(err)
}
actorUsername, err := actionsActor()
if err != nil {
log.Panic(err)
}
ghAPIToken, err := githubAPIToken()
if err != nil {
log.Panic(err)
}
// Retrieve data necessary from the GitHub API to help determine whether
// certain field changes are allowed
log.Printf("Using GitHub API to determine what fields can be set by user %q\n", actorUsername)
client, err := github.NewClient(github.ClientInit{
BaseURL: os.Getenv(githubAPIBaseURLKey),
APIToken: ghAPIToken,
})
if err != nil {
log.Panic(err)
}
tokenUser, err := client.GetUserFromToken()
if err != nil {
log.Panic(err)
}
tokenUserStatus, err := client.GetUserOrgStatus("coder", tokenUser.Login)
if err != nil {
log.Panic(err)
}
var actorOrgStatus github.OrgStatus
if tokenUserStatus == github.OrgStatusMember {
actorOrgStatus, err = client.GetUserOrgStatus("coder", actorUsername)
if err != nil {
log.Panic(err)
}
} else {
log.Println("Provided API token does not belong to a Coder employee. Some README validation steps will be skipped compared to when they run in CI.")
}
fmt.Printf("Script GitHub actor %q has Coder organization status %q\n", actorUsername, actorOrgStatus.String())
// Start main validation
log.Println("Starting README validation")
// Validate file structure of main README directory. Have to do this
// synchronously and before everything else, or else there's no way to for
// the other main validation functions can't make any safe assumptions
// about where they should look in the repo
log.Println("Validating directory structure of the README directory")
err = validateRepoStructure()
// If there are fundamental problems with how the repo is structured, we
// can't make any guarantees that any further validations will be relevant
// or accurate
repoErr := validateRepoStructure()
if repoErr != nil {
log.Println(repoErr)
os.Exit(1)
}
errs := []error{}
err := validateAllContributorFiles()
if err != nil {
log.Panic(err)
errs = append(errs, err)
}
// Set up concurrency for validating each category of README file
var readmeValidationErrors []error
errChan := make(chan error, 1)
doneChan := make(chan struct{})
wg := sync.WaitGroup{}
go func() {
for err := range errChan {
readmeValidationErrors = append(readmeValidationErrors, err)
}
close(doneChan)
}()
// Validate contributor README files
wg.Add(1)
go func() {
defer wg.Done()
if err := validateAllContributors(); err != nil {
errChan <- fmt.Errorf("contributor validation: %v", err)
}
}()
// Validate modules
wg.Add(1)
go func() {
defer wg.Done()
moveToOuterScopeLater := func() error {
baseRefReadmeFiles, err := aggregateCoderResourceReadmeFiles("modules")
if err != nil {
return err
}
parsed, err := parseCoderResourceFiles("modules", baseRefReadmeFiles, baseRefReadmeFiles, actorOrgStatus)
if err != nil {
return err
}
fmt.Printf("------ got %d back\n", len(parsed))
// repo, err := git.PlainClone(dummyGitDirectory, true, &git.CloneOptions{
// URL: "https://github.com/coder/registry",
// Auth: &http.BasicAuth{},
// })
// if err != nil {
// return err
// }
// head, err := repo.Head()
// if err != nil {
// return err
// }
// activeBranchName := head.Name().Short()
// tree, err := repo.Worktree()
// if err != nil {
// return err
// }
// err = tree.Checkout(&git.CheckoutOptions{
// Branch: plumbing.NewBranchReferenceName(activeBranchName),
// Create: false,
// Force: false,
// Keep: true,
// })
// if err != nil {
// return err
// }
// files, _ := tree.Filesystem.ReadDir(".")
// for _, f := range files {
// if f.IsDir() {
// fmt.Println(f.Name())
// }
// }
return nil
}
if err := moveToOuterScopeLater(); err != nil {
errChan <- fmt.Errorf("module validation: %v", err)
}
}()
// Validate templates
wg.Add(1)
go func() {
defer wg.Done()
}()
// Clean up and then log errors
wg.Wait()
close(errChan)
<-doneChan
if len(readmeValidationErrors) == 0 {
log.Println("All validation was successful")
return
if len(errs) == 0 {
os.Exit(0)
}
fmt.Println("---")
log.Println("Encountered the following problems")
for _, err := range readmeValidationErrors {
for _, err := range errs {
fmt.Println(err)
}
os.Exit(1)
@@ -3,6 +3,7 @@ package main
import (
"bufio"
"errors"
"fmt"
"strings"
)
@@ -10,8 +11,8 @@ const rootRegistryPath = "./registry"
var supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"}
// Readme represents a single README file within the repo (usually within the
// "/registry" directory).
// readme represents a single README file within the repo (usually within the
// top-level "/registry" directory).
type readme struct {
filePath string
rawText string
@@ -96,6 +97,8 @@ const (
func (p validationPhase) String() string {
switch p {
case validationPhaseFileStructureValidation:
return "File structure validation"
case validationPhaseFileLoad:
return "Filesystem reading"
case validationPhaseReadmeParsing:
@@ -103,53 +106,8 @@ func (p validationPhase) String() string {
case validationPhaseReadmeValidation:
return "README validation"
case validationPhaseAssetCrossReference:
return "Cross-referencing asset references"
return "Cross-referencing relative asset URLs"
default:
return "Unknown validation phase"
return fmt.Sprintf("Unknown validation phase: %d", p)
}
}
type zippedReadmes struct {
old *readme
new *readme
}
// zipReadmes takes two slices of README files, and combines them into a map,
// where each key is a file path, and each value is a struct containing the old
// value for the path, and the new value for the path. If the old value exists
// but the new one doesn't, that indicates that a file has been deleted. If the
// new value exists, but the old one doesn't, that indicates that the file was
// created.
func zipReadmes(prevReadmes []readme, newReadmes []readme) map[string]zippedReadmes {
oldMap := map[string]readme{}
for _, rm := range prevReadmes {
oldMap[rm.filePath] = rm
}
zipped := map[string]zippedReadmes{}
for _, rm := range newReadmes {
old, ok := oldMap[rm.filePath]
if ok {
zipped[rm.filePath] = zippedReadmes{
old: &old,
new: &rm,
}
} else {
zipped[rm.filePath] = zippedReadmes{
old: nil,
new: &rm,
}
}
}
for _, old := range oldMap {
_, ok := zipped[old.filePath]
if !ok {
zipped[old.filePath] = zippedReadmes{
old: &old,
new: nil,
}
}
}
return zipped
}
-114
View File
@@ -1,114 +0,0 @@
package main
import (
"errors"
"fmt"
"os"
"path"
)
func validateCoderResourceSubdirectory(dirPath string) []error {
errs := []error{}
dir, err := os.Stat(dirPath)
if err != nil {
// It's valid for a specific resource directory not to exist. It's just
// that if it does exist, it must follow specific rules
if !errors.Is(err, os.ErrNotExist) {
errs = append(errs, addFilePathToError(dirPath, err))
}
return errs
}
if !dir.IsDir() {
errs = append(errs, fmt.Errorf("%q: path is not a directory", dirPath))
return errs
}
files, err := os.ReadDir(dirPath)
if err != nil {
errs = append(errs, fmt.Errorf("%q: %v", dirPath, err))
return errs
}
for _, f := range files {
if !f.IsDir() {
continue
}
resourceReadmePath := path.Join(dirPath, f.Name(), "README.md")
_, err := os.Stat(resourceReadmePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("%q: README file does not exist", resourceReadmePath))
} else {
errs = append(errs, addFilePathToError(resourceReadmePath, err))
}
}
mainTerraformPath := path.Join(dirPath, f.Name(), "main.tf")
_, err = os.Stat(mainTerraformPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("%q: 'main.tf' file does not exist", mainTerraformPath))
} else {
errs = append(errs, addFilePathToError(mainTerraformPath, err))
}
}
}
return errs
}
func validateRegistryDirectory() []error {
dirEntries, err := os.ReadDir(rootRegistryPath)
if err != nil {
return []error{err}
}
problems := []error{}
for _, e := range dirEntries {
dirPath := path.Join(rootRegistryPath, e.Name())
if !e.IsDir() {
problems = append(problems, fmt.Errorf("detected non-directory file %q at base of main Registry directory", dirPath))
continue
}
readmePath := path.Join(dirPath, "README.md")
_, err := os.Stat(readmePath)
if err != nil {
problems = append(problems, err)
}
for _, rType := range supportedResourceTypes {
resourcePath := path.Join(dirPath, rType)
if errs := validateCoderResourceSubdirectory(resourcePath); len(errs) != 0 {
problems = append(problems, errs...)
}
}
}
return problems
}
func validateRepoStructure() error {
var problems []error
if errs := validateRegistryDirectory(); len(errs) != 0 {
problems = append(problems, errs...)
}
_, err := os.Stat("./.icons")
if err != nil {
problems = append(problems, err)
}
// Todo: figure out what other directories we want to make guarantees for
// and add them to this function
if len(problems) != 0 {
return validationPhaseError{
phase: validationPhaseFileStructureValidation,
errors: problems,
}
}
return nil
}
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"errors"
"fmt"
"os"
"path"
"slices"
"strings"
)
var (
supportedResourceTypes = []string{"modules", "templates"}
supportedUserNameSpaceDirectories = append(supportedResourceTypes[:], ".icons", ".images")
)
func validateCoderResourceSubdirectory(dirPath string) []error {
errs := []error{}
subDir, err := os.Stat(dirPath)
if err != nil {
// It's valid for a specific resource directory not to exist. It's just
// that if it does exist, it must follow specific rules
if !errors.Is(err, os.ErrNotExist) {
errs = append(errs, addFilePathToError(dirPath, err))
}
return errs
}
if !subDir.IsDir() {
errs = append(errs, fmt.Errorf("%q: path is not a directory", dirPath))
return errs
}
files, err := os.ReadDir(dirPath)
if err != nil {
errs = append(errs, addFilePathToError(dirPath, err))
return errs
}
for _, f := range files {
// The .coder subdirectories are sometimes generated as part of Bun
// tests. These subdirectories will never be committed to the repo, but
// in the off chance that they don't get cleaned up properly, we want to
// skip over them
if !f.IsDir() || f.Name() == ".coder" {
continue
}
resourceReadmePath := path.Join(dirPath, f.Name(), "README.md")
_, err := os.Stat(resourceReadmePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("%q: 'README.md' does not exist", resourceReadmePath))
} else {
errs = append(errs, addFilePathToError(resourceReadmePath, err))
}
}
mainTerraformPath := path.Join(dirPath, f.Name(), "main.tf")
_, err = os.Stat(mainTerraformPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("%q: 'main.tf' file does not exist", mainTerraformPath))
} else {
errs = append(errs, addFilePathToError(mainTerraformPath, err))
}
}
}
return errs
}
func validateRegistryDirectory() []error {
userDirs, err := os.ReadDir(rootRegistryPath)
if err != nil {
return []error{err}
}
allErrs := []error{}
for _, d := range userDirs {
dirPath := path.Join(rootRegistryPath, d.Name())
if !d.IsDir() {
allErrs = append(allErrs, fmt.Errorf("detected non-directory file %q at base of main Registry directory", dirPath))
continue
}
contributorReadmePath := path.Join(dirPath, "README.md")
_, err := os.Stat(contributorReadmePath)
if err != nil {
allErrs = append(allErrs, err)
}
files, err := os.ReadDir(dirPath)
if err != nil {
allErrs = append(allErrs, err)
continue
}
for _, f := range files {
// Todo: Decide if there's anything more formal that we want to
// ensure about non-directories scoped to user namespaces
if !f.IsDir() {
continue
}
segment := f.Name()
filePath := path.Join(dirPath, segment)
if !slices.Contains(supportedUserNameSpaceDirectories, segment) {
allErrs = append(allErrs, fmt.Errorf("%q: only these sub-directories are allowed at top of user namespace: [%s]", filePath, strings.Join(supportedUserNameSpaceDirectories, ", ")))
continue
}
if slices.Contains(supportedResourceTypes, segment) {
errs := validateCoderResourceSubdirectory(filePath)
if len(errs) != 0 {
allErrs = append(allErrs, errs...)
}
}
}
}
return allErrs
}
func validateRepoStructure() error {
var problems []error
if errs := validateRegistryDirectory(); len(errs) != 0 {
problems = append(problems, errs...)
}
_, err := os.Stat("./.icons")
if err != nil {
problems = append(problems, errors.New("missing top-level .icons directory (used for storing reusable Coder resource icons)"))
}
if len(problems) != 0 {
return validationPhaseError{
phase: validationPhaseFileStructureValidation,
errors: problems,
}
}
return nil
}
+2 -2
View File
@@ -11,10 +11,10 @@ BOLD='\033[0;1m'
printf "$${BOLD}Installing MODULE_NAME ...\n\n"
# Add code here
# Use varibles from the templatefile function in main.tf
# Use variables from the templatefile function in main.tf
# e.g. LOG_PATH, PORT, etc.
printf "🥳 Installation comlete!\n\n"
printf "🥳 Installation complete!\n\n"
printf "👷 Starting MODULE_NAME in background...\n\n"
# Start the app in here
-24
View File
@@ -3,27 +3,3 @@ module coder.com/coder-registry
go 1.23.2
require gopkg.in/yaml.v3 v3.0.1
require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-git/v5 v5.16.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)
-69
View File
@@ -1,73 +1,4 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git/v5 v5.16.0 h1:k3kuOEpkc0DeY7xlL6NaaNg39xdgQbtH5mwCafHO9AQ=
github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+3 -2
View File
@@ -1,9 +1,10 @@
{
"name": "modules",
"scripts": {
"fmt": "bun x prettier --write **/*.sh **/*.ts **/*.md *.md && terraform fmt -recursive -diff",
"fmt:ci": "bun x prettier --check **/*.sh **/*.ts **/*.md *.md && terraform fmt -check -recursive -diff",
"terraform-validate": "./scripts/terraform_validate.sh",
"test": "bun test",
"fmt": "bun x prettier -w **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt **/*.tf .sample/main.tf",
"fmt:ci": "bun x prettier --check **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt -check **/*.tf .sample/main.tf",
"update-version": "./update-version.sh"
},
"devDependencies": {
+2 -2
View File
@@ -22,7 +22,7 @@ module "claude-code" {
}
```
## Prerequisites
### Prerequisites
- Node.js and npm must be installed in your workspace to install Claude Code
- `screen` must be installed in your workspace to run Claude Code in the background
@@ -71,7 +71,7 @@ data "coder_parameter" "ai_prompt" {
resource "coder_agent" "main" {
# ...
env = {
CODER_MCP_CLAUDE_API_KEY = var.anthropic_api_key # or use a coder_parameter
CODER_MCP_CLAUDE_API_KEY = var.anthropic_api_key # or use a coder_parameter
CODER_MCP_CLAUDE_TASK_PROMPT = data.coder_parameter.ai_prompt.value
CODER_MCP_APP_STATUS_SLUG = "claude-code"
CODER_MCP_CLAUDE_SYSTEM_PROMPT = <<-EOT
-8
View File
@@ -1,8 +0,0 @@
---
display_name: HashiCorp
bio: HashiCorp, an IBM company, empowers organizations to automate and secure multi-cloud and hybrid environments with The Infrastructure Cloud™. Our suite of Infrastructure Lifecycle Management and Security Lifecycle Management solutions are built on projects with source code freely available at their core. The HashiCorp suite underpins the world's most critical applications, helping enterprises achieve efficiency, security, and scalability at any stage of their cloud journey.
github: hashicorp
linkedin: https://www.linkedin.com/company/hashicorp
website: https://www.hashicorp.com/
status: partner
---
-8
View File
@@ -1,8 +0,0 @@
---
display_name: Jfrog
bio: At JFrog, we are making endless software versions a thing of the past, with liquid software that flows continuously and automatically from build all the way through to production.
github: jfrog
linkedin: https://www.linkedin.com/company/jfrog-ltd
website: https://jfrog.com/
status: partner
---
+1 -1
View File
@@ -3,5 +3,5 @@ display_name: Nataindata
bio: Data engineer
github: nataindata
website: https://www.nataindata.com
status: community
status: partner
---
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
set -euo pipefail
validate_terraform_directory() {
local dir="$1"
echo "Running \`terraform validate\` in $dir"
pushd "$dir"
terraform init -upgrade
terraform validate
popd
}
main() {
# Get the directory of the script
local script_dir=$(dirname "$(readlink -f "$0")")
# Code assumes that registry directory will always be in same position
# relative to the main script directory
local registry_dir="$script_dir/../registry"
# Get all subdirectories in the registry directory. Code assumes that
# Terraform directories won't begin to appear until three levels deep into
# the registry (e.g., registry/coder/modules/coder-login, which will then
# have a main.tf file inside it)
local subdirs=$(find "$registry_dir" -mindepth 3 -type d | sort)
for dir in $subdirs; do
# Skip over any directories that obviously don't have the necessary
# files
if test -f "$dir/main.tf"; then
validate_terraform_directory "$dir"
fi
done
}
main