mirror of
https://github.com/coder/registry.git
synced 2026-06-03 13:08:14 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e5f3bfea1 | |||
| cc51af1b6e | |||
| 9e18a4e3a8 | |||
| 0ce1e7ab01 | |||
| 9404ad9a53 | |||
| d4d307451f | |||
| 7e8d4b7f27 | |||
| c731a5c64e | |||
| 45640689e4 | |||
| c1e196c8b0 | |||
| 36acd61e40 | |||
| 7d447d1672 | |||
| 9780217535 | |||
| 494a25e688 | |||
| 31c69e28a8 | |||
| fdc0f98c0f | |||
| eea1e059f0 | |||
| 1dcc645b14 | |||
| 812dd8faaf | |||
| 52c1f45da6 | |||
| 1a5c102c2f | |||
| 36d33895b7 | |||
| 404b1e0a46 |
+21
-17
@@ -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=
|
||||
|
||||
# ---
|
||||
|
||||
Regular → Executable
+128
-128
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -137,7 +137,6 @@ dist
|
||||
|
||||
# Things needed for CI
|
||||
/readmevalidation
|
||||
/readmevalidation-git
|
||||
|
||||
# Terraform files generated during testing
|
||||
.terraform*
|
||||
|
||||
+200
@@ -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,3 +1,3 @@
|
||||
# hub
|
||||
# Coder Registry
|
||||
|
||||
Publish Coder modules and templates for other developers to use.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
@@ -3,5 +3,5 @@ display_name: Nataindata
|
||||
bio: Data engineer
|
||||
github: nataindata
|
||||
website: https://www.nataindata.com
|
||||
status: community
|
||||
status: partner
|
||||
---
|
||||
|
||||
Executable
+37
@@ -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
|
||||
Reference in New Issue
Block a user