Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0cd32c2c4 | |||
| c2414d5287 | |||
| ff69ed69df |
@@ -91,9 +91,6 @@
|
||||
|
||||
## Systematic Debugging Approach
|
||||
|
||||
YOU MUST ALWAYS find the root cause of any issue you are debugging
|
||||
YOU MUST NEVER fix a symptom or add a workaround instead of finding a root cause, even if it is faster.
|
||||
|
||||
### Multi-Issue Problem Solving
|
||||
|
||||
When facing multiple failing tests or complex integration issues:
|
||||
@@ -101,21 +98,16 @@ When facing multiple failing tests or complex integration issues:
|
||||
1. **Identify Root Causes**:
|
||||
- Run failing tests individually to isolate issues
|
||||
- Use LSP tools to trace through call chains
|
||||
- Read Error Messages Carefully: Check both compilation and runtime errors
|
||||
- Reproduce Consistently: Ensure you can reliably reproduce the issue before investigating
|
||||
- Check Recent Changes: What changed that could have caused this? Git diff, recent commits, etc.
|
||||
- When You Don't Know: Say "I don't understand X" rather than pretending to know
|
||||
- Check both compilation and runtime errors
|
||||
|
||||
2. **Fix in Logical Order**:
|
||||
- Address compilation issues first (imports, syntax)
|
||||
- Fix authorization and RBAC issues next
|
||||
- Resolve business logic and validation issues
|
||||
- Handle edge cases and race conditions last
|
||||
- IF your first fix doesn't work, STOP and re-analyze rather than adding more fixes
|
||||
|
||||
3. **Verification Strategy**:
|
||||
- Always Test each fix individually before moving to next issue
|
||||
- Verify Before Continuing: Did your test work? If not, form new hypothesis - don't add more fixes
|
||||
- Test each fix individually before moving to next issue
|
||||
- Use `make lint` and `make gen` after database changes
|
||||
- Verify RFC compliance with actual specifications
|
||||
- Run comprehensive test suites before considering complete
|
||||
|
||||
@@ -40,15 +40,11 @@
|
||||
- Use proper error types
|
||||
- Pattern: `xerrors.Errorf("failed to X: %w", err)`
|
||||
|
||||
## Naming Conventions
|
||||
### Naming Conventions
|
||||
|
||||
- Names MUST tell what code does, not how it's implemented or its history
|
||||
- Follow Go and TypeScript naming conventions
|
||||
- When changing code, never document the old behavior or the behavior change
|
||||
- NEVER use implementation details in names (e.g., "ZodValidator", "MCPWrapper", "JSONParser")
|
||||
- NEVER use temporal/historical context in names (e.g., "LegacyHandler", "UnifiedTool", "ImprovedInterface", "EnhancedParser")
|
||||
- NEVER use pattern names unless they add clarity (e.g., prefer "Tool" over "ToolFactory")
|
||||
- Use clear, descriptive names
|
||||
- Abbreviate only when obvious
|
||||
- Follow Go and TypeScript naming conventions
|
||||
|
||||
### Comments
|
||||
|
||||
|
||||
@@ -10,12 +10,8 @@ install_devcontainer_cli() {
|
||||
|
||||
install_ssh_config() {
|
||||
echo "🔑 Installing SSH configuration..."
|
||||
if [ -d /mnt/home/coder/.ssh ]; then
|
||||
rsync -a /mnt/home/coder/.ssh/ ~/.ssh/
|
||||
chmod 0700 ~/.ssh
|
||||
else
|
||||
echo "⚠️ SSH directory not found."
|
||||
fi
|
||||
rsync -a /mnt/home/coder/.ssh/ ~/.ssh/
|
||||
chmod 0700 ~/.ssh
|
||||
}
|
||||
|
||||
install_git_config() {
|
||||
|
||||
@@ -26,6 +26,5 @@ ignorePatterns:
|
||||
- pattern: "claude.ai"
|
||||
- pattern: "splunk.com"
|
||||
- pattern: "stackoverflow.com/questions"
|
||||
- pattern: "developer.hashicorp.com/terraform/language"
|
||||
aliveStatusCodes:
|
||||
- 200
|
||||
|
||||
@@ -4,7 +4,7 @@ description: |
|
||||
inputs:
|
||||
version:
|
||||
description: "The Go version to use."
|
||||
default: "1.24.10"
|
||||
default: "1.24.6"
|
||||
use-preinstalled-go:
|
||||
description: "Whether to use preinstalled Go."
|
||||
default: "false"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
app = "sao-paulo-coder"
|
||||
primary_region = "gru"
|
||||
|
||||
[experimental]
|
||||
entrypoint = ["/bin/sh", "-c", "CODER_DERP_SERVER_RELAY_URL=\"http://[${FLY_PRIVATE_IP}]:3000\" /opt/coder wsproxy server"]
|
||||
auto_rollback = true
|
||||
|
||||
[build]
|
||||
image = "ghcr.io/coder/coder-preview:main"
|
||||
|
||||
[env]
|
||||
CODER_ACCESS_URL = "https://sao-paulo.fly.dev.coder.com"
|
||||
CODER_HTTP_ADDRESS = "0.0.0.0:3000"
|
||||
CODER_PRIMARY_ACCESS_URL = "https://dev.coder.com"
|
||||
CODER_WILDCARD_ACCESS_URL = "*--apps.sao-paulo.fly.dev.coder.com"
|
||||
CODER_VERBOSE = "true"
|
||||
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
force_https = true
|
||||
auto_stop_machines = true
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
|
||||
# Ref: https://fly.io/docs/reference/configuration/#http_service-concurrency
|
||||
[http_service.concurrency]
|
||||
type = "requests"
|
||||
soft_limit = 50
|
||||
hard_limit = 100
|
||||
|
||||
[[vm]]
|
||||
cpu_kind = "shared"
|
||||
cpus = 2
|
||||
memory_mb = 512
|
||||
+27
-13
@@ -181,7 +181,7 @@ jobs:
|
||||
echo "LINT_CACHE_DIR=$dir" >> "$GITHUB_ENV"
|
||||
|
||||
- name: golangci-lint cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.LINT_CACHE_DIR }}
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
|
||||
# Check for any typos
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@80c8a4945eec0f6d464eaf9e65ed98ef085283d1 # v1.38.1
|
||||
uses: crate-ci/typos@85f62a8a84f939ae994ab3763f01a0296d61a7ee # v1.36.2
|
||||
with:
|
||||
config: .github/workflows/typos.toml
|
||||
|
||||
@@ -376,6 +376,13 @@ jobs:
|
||||
id: go-paths
|
||||
uses: ./.github/actions/setup-go-paths
|
||||
|
||||
- name: Download Go Build Cache
|
||||
id: download-go-build-cache
|
||||
uses: ./.github/actions/test-cache/download
|
||||
with:
|
||||
key-prefix: test-go-build-${{ runner.os }}-${{ runner.arch }}
|
||||
cache-path: ${{ steps.go-paths.outputs.cached-dirs }}
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
with:
|
||||
@@ -383,7 +390,8 @@ jobs:
|
||||
# download the toolchain configured in go.mod, so we don't
|
||||
# need to reinstall it. It's faster on Windows runners.
|
||||
use-preinstalled-go: ${{ runner.os == 'Windows' }}
|
||||
use-cache: true
|
||||
# Cache is already downloaded above
|
||||
use-cache: false
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: ./.github/actions/setup-tf
|
||||
@@ -492,11 +500,17 @@ jobs:
|
||||
make test
|
||||
|
||||
- name: Upload failed test db dumps
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: failed-test-db-dump-${{matrix.os}}
|
||||
path: "**/*.test.sql"
|
||||
|
||||
- name: Upload Go Build Cache
|
||||
uses: ./.github/actions/test-cache/upload
|
||||
with:
|
||||
cache-key: ${{ steps.download-go-build-cache.outputs.cache-key }}
|
||||
cache-path: ${{ steps.go-paths.outputs.cached-dirs }}
|
||||
|
||||
- name: Upload Test Cache
|
||||
uses: ./.github/actions/test-cache/upload
|
||||
with:
|
||||
@@ -748,7 +762,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright Failed Tests
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: failed-test-videos${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/test-results/**/*.webm
|
||||
@@ -756,7 +770,7 @@ jobs:
|
||||
|
||||
- name: Upload pprof dumps
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: debug-pprof-dumps${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/test-results/**/debug-pprof-*.txt
|
||||
@@ -792,7 +806,7 @@ jobs:
|
||||
# the check to pass. This is desired in PRs, but not in mainline.
|
||||
- name: Publish to Chromatic (non-mainline)
|
||||
if: github.ref != 'refs/heads/main' && github.repository_owner == 'coder'
|
||||
uses: chromaui/action@bc2d84ad2b60813a67d995c5582d696104a19383 # v13.3.2
|
||||
uses: chromaui/action@20c7e42e1b2f6becd5d188df9acb02f3e2f51519 # v13.2.0
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
STORYBOOK: true
|
||||
@@ -824,7 +838,7 @@ jobs:
|
||||
# infinitely "in progress" in mainline unless we re-review each build.
|
||||
- name: Publish to Chromatic (mainline)
|
||||
if: github.ref == 'refs/heads/main' && github.repository_owner == 'coder'
|
||||
uses: chromaui/action@bc2d84ad2b60813a67d995c5582d696104a19383 # v13.3.2
|
||||
uses: chromaui/action@20c7e42e1b2f6becd5d188df9acb02f3e2f51519 # v13.2.0
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
STORYBOOK: true
|
||||
@@ -1022,7 +1036,7 @@ jobs:
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: ${{ github.repository_owner == 'coder' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: dylibs
|
||||
path: |
|
||||
@@ -1109,7 +1123,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -1187,7 +1201,7 @@ jobs:
|
||||
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
|
||||
|
||||
- name: Download dylibs
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: dylibs
|
||||
path: ./build
|
||||
@@ -1454,7 +1468,7 @@ jobs:
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: coder
|
||||
path: |
|
||||
@@ -1523,7 +1537,7 @@ jobs:
|
||||
steps:
|
||||
- name: Send Slack notification
|
||||
run: |
|
||||
ESCAPED_PROMPT=$(printf "%s" "<@U09LQ75AHKR> $BLINK_CI_FAILURE_PROMPT" | jq -Rsa .)
|
||||
ESCAPED_PROMPT=$(printf "%s" "<@U08TJ4YNCA3> $BLINK_CI_FAILURE_PROMPT" | jq -Rsa .)
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data '{
|
||||
"blocks": [
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
|
||||
|
||||
- name: Set up Flux CLI
|
||||
uses: fluxcd/flux2/action@4a15fa6a023259353ef750acf1c98fe88407d4d0 # v2.7.2
|
||||
uses: fluxcd/flux2/action@6bf37f6a560fd84982d67f853162e4b3c2235edb # v2.6.4
|
||||
with:
|
||||
# Keep this and the github action up to date with the version of flux installed in dogfood cluster
|
||||
version: "2.7.0"
|
||||
@@ -121,8 +121,6 @@ jobs:
|
||||
flux --namespace flux-system reconcile source chart coder-coder-provisioner
|
||||
flux --namespace coder reconcile helmrelease coder
|
||||
flux --namespace coder reconcile helmrelease coder-provisioner
|
||||
flux --namespace coder reconcile helmrelease coder-provisioner-tagged
|
||||
flux --namespace coder reconcile helmrelease coder-provisioner-tagged-prebuilds
|
||||
|
||||
# Just updating Flux is usually not enough. The Helm release may get
|
||||
# redeployed, but unless something causes the Deployment to update the
|
||||
@@ -138,8 +136,6 @@ jobs:
|
||||
kubectl --namespace coder rollout status deployment/coder-provisioner
|
||||
kubectl --namespace coder rollout restart deployment/coder-provisioner-tagged
|
||||
kubectl --namespace coder rollout status deployment/coder-provisioner-tagged
|
||||
kubectl --namespace coder rollout restart deployment/coder-provisioner-tagged-prebuilds
|
||||
kubectl --namespace coder rollout status deployment/coder-provisioner-tagged-prebuilds
|
||||
|
||||
deploy-wsproxies:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -163,10 +159,12 @@ jobs:
|
||||
run: |
|
||||
flyctl deploy --image "$IMAGE" --app paris-coder --config ./.github/fly-wsproxies/paris-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_PARIS" --yes
|
||||
flyctl deploy --image "$IMAGE" --app sydney-coder --config ./.github/fly-wsproxies/sydney-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_SYDNEY" --yes
|
||||
flyctl deploy --image "$IMAGE" --app sao-paulo-coder --config ./.github/fly-wsproxies/sao-paulo-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_SAO_PAULO" --yes
|
||||
flyctl deploy --image "$IMAGE" --app jnb-coder --config ./.github/fly-wsproxies/jnb-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_JNB" --yes
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
IMAGE: ${{ inputs.image }}
|
||||
TOKEN_PARIS: ${{ secrets.FLY_PARIS_CODER_PROXY_SESSION_TOKEN }}
|
||||
TOKEN_SYDNEY: ${{ secrets.FLY_SYDNEY_CODER_PROXY_SESSION_TOKEN }}
|
||||
TOKEN_SAO_PAULO: ${{ secrets.FLY_SAO_PAULO_CODER_PROXY_SESSION_TOKEN }}
|
||||
TOKEN_JNB: ${{ secrets.FLY_JNB_CODER_PROXY_SESSION_TOKEN }}
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- uses: tj-actions/changed-files@dbf178ceecb9304128c8e0648591d71208c6e2c9 # v45.0.7
|
||||
- uses: tj-actions/changed-files@4563c729c555b4141fac99c80f699f571219b836 # v45.0.7
|
||||
id: changed-files
|
||||
with:
|
||||
files: |
|
||||
|
||||
@@ -36,11 +36,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Nix
|
||||
uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
uses: nixbuild/nix-quick-install-action@1f095fee853b33114486cfdeae62fa099cda35a9 # v33
|
||||
with:
|
||||
# Pinning to 2.28 here, as Nix gets a "error: [json.exception.type_error.302] type must be array, but is string"
|
||||
# on version 2.29 and above.
|
||||
nix_version: "2.28.5"
|
||||
nix_version: "2.28.4"
|
||||
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
with:
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
steps:
|
||||
- name: Send Slack notification
|
||||
run: |
|
||||
ESCAPED_PROMPT=$(printf "%s" "<@U09LQ75AHKR> $BLINK_CI_FAILURE_PROMPT" | jq -Rsa .)
|
||||
ESCAPED_PROMPT=$(printf "%s" "<@U08TJ4YNCA3> $BLINK_CI_FAILURE_PROMPT" | jq -Rsa .)
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data '{
|
||||
"blocks": [
|
||||
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ needs.get_info.outputs.PR_NUMBER }}
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR
|
||||
id: comment_id
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
with:
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
issue-number: ${{ needs.get_info.outputs.PR_NUMBER }}
|
||||
@@ -248,7 +248,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-sqlc
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -491,7 +491,7 @@ jobs:
|
||||
PASSWORD: ${{ steps.setup_deployment.outputs.password }}
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ env.PR_NUMBER }}
|
||||
@@ -500,7 +500,7 @@ jobs:
|
||||
direction: last
|
||||
|
||||
- name: Comment on PR
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
env:
|
||||
STATUS: ${{ needs.get_info.outputs.NEW == 'true' && 'Created' || 'Updated' }}
|
||||
with:
|
||||
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: dylibs
|
||||
path: |
|
||||
@@ -239,7 +239,7 @@ jobs:
|
||||
cat "$CODER_RELEASE_NOTES_FILE"
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -327,7 +327,7 @@ jobs:
|
||||
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
|
||||
|
||||
- name: Download dylibs
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: dylibs
|
||||
path: ./build
|
||||
@@ -761,7 +761,7 @@ jobs:
|
||||
|
||||
- name: Upload artifacts to actions (if dry-run)
|
||||
if: ${{ inputs.dry_run }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: release-artifacts
|
||||
path: |
|
||||
@@ -777,7 +777,7 @@ jobs:
|
||||
|
||||
- name: Upload latest sbom artifact to actions (if dry-run)
|
||||
if: inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: latest-sbom-artifact
|
||||
path: ./coder_latest_sbom.spdx.json
|
||||
@@ -785,7 +785,7 @@ jobs:
|
||||
|
||||
- name: Send repository-dispatch event
|
||||
if: ${{ !inputs.dry_run }}
|
||||
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0
|
||||
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
|
||||
with:
|
||||
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
repository: coder/packages
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
# Upload the results as artifacts.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
@@ -47,6 +47,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v3.29.5
|
||||
uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5
|
||||
with:
|
||||
languages: go, javascript
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
rm Makefile
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v3.29.5
|
||||
uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5
|
||||
|
||||
- name: Send Slack notification on failure
|
||||
if: ${{ failure() }}
|
||||
@@ -154,13 +154,13 @@ jobs:
|
||||
severity: "CRITICAL,HIGH"
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: "Trivy"
|
||||
|
||||
- name: Upload Trivy scan results as an artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: trivy
|
||||
path: trivy-results.sarif
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: stale
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
|
||||
with:
|
||||
stale-issue-label: "stale"
|
||||
stale-pr-label: "stale"
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Delete PR Cleanup workflow runs
|
||||
uses: Mattraks/delete-workflow-runs@ab482449ba468316e9a8801e092d0405715c5e6d # v2.1.0
|
||||
uses: Mattraks/delete-workflow-runs@39f0bbed25d76b34de5594dceab824811479e5de # v2.0.6
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
delete_workflow_pattern: pr-cleanup.yaml
|
||||
|
||||
- name: Delete PR Deploy workflow skipped runs
|
||||
uses: Mattraks/delete-workflow-runs@ab482449ba468316e9a8801e092d0405715c5e6d # v2.1.0
|
||||
uses: Mattraks/delete-workflow-runs@39f0bbed25d76b34de5594dceab824811479e5de # v2.0.6
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
+82
-114
@@ -1,9 +1,6 @@
|
||||
name: AI Triage Automation
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_url:
|
||||
@@ -13,122 +10,50 @@ on:
|
||||
template_name:
|
||||
description: "Coder template to use for workspace"
|
||||
required: true
|
||||
default: "coder"
|
||||
default: "traiage"
|
||||
type: string
|
||||
template_preset:
|
||||
description: "Template preset to use"
|
||||
required: true
|
||||
default: "none"
|
||||
default: "Default"
|
||||
type: string
|
||||
prefix:
|
||||
description: "Prefix for workspace name"
|
||||
required: false
|
||||
default: "traiage"
|
||||
type: string
|
||||
cleanup:
|
||||
description: "Cleanup workspace after triage."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
traiage:
|
||||
name: Triage GitHub Issue with Claude Code
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.label.name == 'traiage' || github.event_name == 'workflow_dispatch'
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CODER_URL: ${{ secrets.TRAIAGE_CODER_URL }}
|
||||
CODER_SESSION_TOKEN: ${{ secrets.TRAIAGE_CODER_SESSION_TOKEN }}
|
||||
TEMPLATE_NAME: ${{ inputs.template_name }}
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
# This is only required for testing locally using nektos/act, so leaving commented out.
|
||||
# An alternative is to use a larger or custom image.
|
||||
# - name: Install Github CLI
|
||||
# id: install-gh
|
||||
# run: |
|
||||
# (type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y)) \
|
||||
# && sudo mkdir -p -m 755 /etc/apt/keyrings \
|
||||
# && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
# && cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
|
||||
# && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||
# && sudo mkdir -p -m 755 /etc/apt/sources.list.d \
|
||||
# && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
||||
# && sudo apt update \
|
||||
# && sudo apt install gh -y
|
||||
|
||||
- name: Determine Inputs
|
||||
id: determine-inputs
|
||||
if: always()
|
||||
env:
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
GITHUB_EVENT_ISSUE_HTML_URL: ${{ github.event.issue.html_url }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_EVENT_USER_ID: ${{ github.event.sender.id }}
|
||||
GITHUB_EVENT_USER_LOGIN: ${{ github.event.sender.login }}
|
||||
INPUTS_ISSUE_URL: ${{ inputs.issue_url }}
|
||||
INPUTS_TEMPLATE_NAME: ${{ inputs.template_name || 'coder' }}
|
||||
INPUTS_TEMPLATE_PRESET: ${{ inputs.template_preset || 'none'}}
|
||||
INPUTS_PREFIX: ${{ inputs.prefix || 'traiage' }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "Using template name: ${INPUTS_TEMPLATE_NAME}"
|
||||
echo "template_name=${INPUTS_TEMPLATE_NAME}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "Using template preset: ${INPUTS_TEMPLATE_PRESET}"
|
||||
echo "template_preset=${INPUTS_TEMPLATE_PRESET}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "Using prefix: ${INPUTS_PREFIX}"
|
||||
echo "prefix=${INPUTS_PREFIX}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# For workflow_dispatch, use the actor who triggered it
|
||||
# For issues events, use the issue author.
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
if ! GITHUB_USER_ID=$(gh api "users/${GITHUB_ACTOR}" --jq '.id'); then
|
||||
echo "::error::Failed to get GitHub user ID for actor ${GITHUB_ACTOR}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using workflow_dispatch actor: ${GITHUB_ACTOR} (ID: ${GITHUB_USER_ID})"
|
||||
echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}"
|
||||
echo "github_username=${GITHUB_ACTOR}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "Using issue URL: ${INPUTS_ISSUE_URL}"
|
||||
echo "issue_url=${INPUTS_ISSUE_URL}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
exit 0
|
||||
elif [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then
|
||||
GITHUB_USER_ID=${GITHUB_EVENT_USER_ID}
|
||||
echo "Using issue author: ${GITHUB_EVENT_USER_LOGIN} (ID: ${GITHUB_USER_ID})"
|
||||
echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}"
|
||||
echo "github_username=${GITHUB_EVENT_USER_LOGIN}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "Using issue URL: ${GITHUB_EVENT_ISSUE_HTML_URL}"
|
||||
echo "issue_url=${GITHUB_EVENT_ISSUE_HTML_URL}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
exit 0
|
||||
else
|
||||
echo "::error::Unsupported event type: ${GITHUB_EVENT_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify push access
|
||||
env:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_USERNAME: ${{ steps.determine-inputs.outputs.github_username }}
|
||||
GITHUB_USER_ID: ${{ steps.determine-inputs.outputs.github_user_id }}
|
||||
run: |
|
||||
# Query the actor’s permission on this repo
|
||||
can_push="$(gh api "/repos/${GITHUB_REPOSITORY}/collaborators/${GITHUB_USERNAME}/permission" --jq '.user.permissions.push')"
|
||||
if [[ "${can_push}" != "true" ]]; then
|
||||
echo "::error title=Access Denied::${GITHUB_USERNAME} does not have push access to ${GITHUB_REPOSITORY}"
|
||||
exit 1
|
||||
fi
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract context key from issue
|
||||
id: extract-context
|
||||
env:
|
||||
ISSUE_URL: ${{ steps.determine-inputs.outputs.issue_url }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ISSUE_URL: ${{ inputs.issue_url }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
issue_number="$(gh issue view "${ISSUE_URL}" --json number --jq '.number')"
|
||||
context_key="gh-${issue_number}"
|
||||
@@ -157,9 +82,11 @@ jobs:
|
||||
id: get-coder-username
|
||||
env:
|
||||
CODER_SESSION_TOKEN: ${{ secrets.TRAIAGE_CODER_SESSION_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_USER_ID: ${{ steps.determine-inputs.outputs.github_user_id }}
|
||||
GITHUB_USER_ID: ${{
|
||||
(github.event_name == 'workflow_dispatch' && github.actor_id)
|
||||
}}
|
||||
run: |
|
||||
[[ -z "${GITHUB_USER_ID}" || "${GITHUB_USER_ID}" == "null" ]] && echo "No GitHub actor ID found" && exit 1
|
||||
user_json=$(
|
||||
coder users list --github-user-id="${GITHUB_USER_ID}" --output=json
|
||||
)
|
||||
@@ -167,39 +94,29 @@ jobs:
|
||||
[[ -z "${coder_username}" || "${coder_username}" == "null" ]] && echo "No Coder user with GitHub user ID ${GITHUB_USER_ID} found" && exit 1
|
||||
echo "coder_username=${coder_username}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
# TODO(Cian): this is a good use-case for 'recipes'
|
||||
- name: Create Coder task
|
||||
id: create-task
|
||||
env:
|
||||
CODER_USERNAME: ${{ steps.get-coder-username.outputs.coder_username }}
|
||||
CONTEXT_KEY: ${{ steps.extract-context.outputs.context_key }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
ISSUE_URL: ${{ steps.determine-inputs.outputs.issue_url }}
|
||||
PREFIX: ${{ steps.determine-inputs.outputs.prefix }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
ISSUE_URL: ${{ inputs.issue_url }}
|
||||
PREFIX: ${{ inputs.prefix }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
TEMPLATE_NAME: ${{ steps.determine-inputs.outputs.template_name }}
|
||||
TEMPLATE_PARAMETERS: ${{ secrets.TRAIAGE_TEMPLATE_PARAMETERS }}
|
||||
TEMPLATE_PRESET: ${{ steps.determine-inputs.outputs.template_preset }}
|
||||
TEMPLATE_PRESET: ${{ inputs.template_preset }}
|
||||
run: |
|
||||
# Fetch issue description using `gh` CLI
|
||||
#shellcheck disable=SC2016 # The template string should not be subject to shell expansion
|
||||
issue_description=$(gh issue view "${ISSUE_URL}" \
|
||||
--json 'title,body,comments' \
|
||||
--template '{{printf "%s\n\n%s\n\nComments:\n" .title .body}}{{range $k, $v := .comments}} - {{index $v.author "login"}}: {{printf "%s\n" $v.body}}{{end}}')
|
||||
issue_description=$(gh issue view "${ISSUE_URL}")
|
||||
|
||||
# Write a prompt to PROMPT_FILE
|
||||
PROMPT=$(cat <<EOF
|
||||
Fix ${ISSUE_URL}
|
||||
|
||||
Analyze the below GitHub issue description, understand the root cause, and make appropriate changes to resolve the issue.
|
||||
---
|
||||
|
||||
ISSUE URL: ${ISSUE_URL}
|
||||
ISSUE DESCRIPTION BELOW:
|
||||
|
||||
${issue_description}
|
||||
EOF
|
||||
)
|
||||
@@ -208,10 +125,61 @@ jobs:
|
||||
export TASK_NAME="${PREFIX}-${CONTEXT_KEY}-${RUN_ID}"
|
||||
echo "Creating task: $TASK_NAME"
|
||||
./scripts/traiage.sh create
|
||||
coder exp task status "${CODER_USERNAME}/$TASK_NAME" --watch
|
||||
echo "TASK_NAME=${CODER_USERNAME}/${TASK_NAME}" >> "${GITHUB_OUTPUT}"
|
||||
echo "TASK_NAME=${CODER_USERNAME}/${TASK_NAME}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Create and upload archive
|
||||
id: create-archive
|
||||
if: inputs.cleanup
|
||||
env:
|
||||
BUCKET_PREFIX: "gs://coder-traiage-outputs/traiage"
|
||||
run: |
|
||||
echo "Creating archive for workspace: $TASK_NAME"
|
||||
./scripts/traiage.sh archive
|
||||
echo "archive_url=${BUCKET_PREFIX%%/}/$TASK_NAME.tar.gz" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Generate a summary of the changes and post a comment on GitHub.
|
||||
id: generate-summary
|
||||
if: inputs.cleanup
|
||||
env:
|
||||
ARCHIVE_URL: ${{ steps.create-archive.outputs.archive_url }}
|
||||
BUCKET_PREFIX: "gs://coder-traiage-outputs/traiage"
|
||||
CONTEXT_KEY: ${{ steps.extract-context.outputs.context_key }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
ISSUE_URL: ${{ inputs.issue_url }}
|
||||
TASK_NAME: ${{ steps.create-task.outputs.TASK_NAME }}
|
||||
run: |
|
||||
SUMMARY_FILE=$(mktemp)
|
||||
trap 'rm -f "${SUMMARY_FILE}"' EXIT
|
||||
AUTO_SUMMARY=$(./scripts/traiage.sh summary)
|
||||
{
|
||||
echo "## TrAIage Results"
|
||||
echo "- **Issue URL:** ${ISSUE_URL}"
|
||||
echo "- **Context Key:** ${CONTEXT_KEY}"
|
||||
echo "- **Workspace:** ${TASK_NAME}"
|
||||
echo "- **Archive URL:** ${ARCHIVE_URL}"
|
||||
echo
|
||||
echo "${AUTO_SUMMARY}"
|
||||
echo
|
||||
echo "To fetch the output to your own workspace:"
|
||||
echo
|
||||
echo '```bash'
|
||||
echo "BUCKET_PREFIX=${BUCKET_PREFIX} TASK_NAME=${TASK_NAME} ./scripts/traiage.sh resume"
|
||||
echo '```'
|
||||
echo
|
||||
} >> "${SUMMARY_FILE}"
|
||||
|
||||
if [[ "${ISSUE_URL}" == "https://github.com/${GITHUB_REPOSITORY}"* ]]; then
|
||||
gh issue comment "${ISSUE_URL}" --body "Task created: https://dev.coder.com/tasks/${CODER_USERNAME}/${TASK_NAME}" --create-if-none --edit-last
|
||||
gh issue comment "${ISSUE_URL}" --body-file "${SUMMARY_FILE}" --create-if-none --edit-last
|
||||
else
|
||||
echo "Skipping comment on other repo."
|
||||
fi
|
||||
echo "TASK_NAME=${CODER_USERNAME}/${TASK_NAME}" >> "${GITHUB_OUTPUT}"
|
||||
echo "TASK_NAME=${CODER_USERNAME}/${TASK_NAME}" >> "${GITHUB_ENV}"
|
||||
cat "${SUMMARY_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Cleanup task
|
||||
if: inputs.cleanup && steps.create-task.outputs.TASK_NAME != '' && steps.create-archive.outputs.archive_url != ''
|
||||
run: |
|
||||
echo "Cleaning up task: $TASK_NAME"
|
||||
./scripts/traiage.sh delete || true
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check Markdown links
|
||||
uses: umbrelladocs/action-linkspector@652f85bc57bb1e7d4327260decc10aa68f7694c3 # v1.4.0
|
||||
uses: umbrelladocs/action-linkspector@874d01cae9fd488e3077b08952093235bd626977 # v1.3.7
|
||||
id: markdown-link-check
|
||||
# checks all markdown files from /docs including all subfolders
|
||||
with:
|
||||
|
||||
@@ -12,9 +12,6 @@ node_modules/
|
||||
vendor/
|
||||
yarn-error.log
|
||||
|
||||
# Test output files
|
||||
test-output/
|
||||
|
||||
# VSCode settings.
|
||||
**/.vscode/*
|
||||
# Allow VSCode recommendations and default settings in project root.
|
||||
@@ -89,5 +86,3 @@ result
|
||||
__debug_bin*
|
||||
|
||||
**/.claude/settings.local.json
|
||||
|
||||
/.env
|
||||
|
||||
+1
-11
@@ -169,16 +169,6 @@ linters-settings:
|
||||
- name: var-declaration
|
||||
- name: var-naming
|
||||
- name: waitgroup-by-value
|
||||
usetesting:
|
||||
# Only os-setenv is enabled because we migrated to usetesting from another linter that
|
||||
# only covered os-setenv.
|
||||
os-setenv: true
|
||||
os-create-temp: false
|
||||
os-mkdir-temp: false
|
||||
os-temp-dir: false
|
||||
os-chdir: false
|
||||
context-background: false
|
||||
context-todo: false
|
||||
|
||||
# irrelevant as of Go v1.22: https://go.dev/blog/loopvar-preview
|
||||
govet:
|
||||
@@ -262,6 +252,7 @@ linters:
|
||||
# - wastedassign
|
||||
|
||||
- staticcheck
|
||||
- tenv
|
||||
# In Go, it's possible for a package to test it's internal functionality
|
||||
# without testing any exported functions. This is enabled to promote
|
||||
# decomposing a package before testing it's internals. A function caller
|
||||
@@ -274,5 +265,4 @@ linters:
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- usetesting
|
||||
- dupl
|
||||
|
||||
Vendored
+1
-2
@@ -61,6 +61,5 @@
|
||||
"typos.config": ".github/workflows/typos.toml",
|
||||
"[markdown]": {
|
||||
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
|
||||
},
|
||||
"biome.lsp.bin": "site/node_modules/.bin/biome"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,11 @@
|
||||
# Coder Development Guidelines
|
||||
|
||||
You are an experienced, pragmatic software engineer. You don't over-engineer a solution when a simple one is possible.
|
||||
Rule #1: If you want exception to ANY rule, YOU MUST STOP and get explicit permission first. BREAKING THE LETTER OR SPIRIT OF THE RULES IS FAILURE.
|
||||
|
||||
## Foundational rules
|
||||
|
||||
- Doing it right is better than doing it fast. You are not in a rush. NEVER skip steps or take shortcuts.
|
||||
- Tedious, systematic work is often the correct solution. Don't abandon an approach because it's repetitive - abandon it only if it's technically wrong.
|
||||
- Honesty is a core value.
|
||||
|
||||
## Our relationship
|
||||
|
||||
- Act as a critical peer reviewer. Your job is to disagree with me when I'm wrong, not to please me. Prioritize accuracy and reasoning over agreement.
|
||||
- YOU MUST speak up immediately when you don't know something or we're in over our heads
|
||||
- YOU MUST call out bad ideas, unreasonable expectations, and mistakes - I depend on this
|
||||
- NEVER be agreeable just to be nice - I NEED your HONEST technical judgment
|
||||
- NEVER write the phrase "You're absolutely right!" You are not a sycophant. We're working together because I value your opinion. Do not agree with me unless you can justify it with evidence or reasoning.
|
||||
- YOU MUST ALWAYS STOP and ask for clarification rather than making assumptions.
|
||||
- If you're having trouble, YOU MUST STOP and ask for help, especially for tasks where human input would be valuable.
|
||||
- When you disagree with my approach, YOU MUST push back. Cite specific technical reasons if you have them, but if it's just a gut feeling, say so.
|
||||
- If you're uncomfortable pushing back out loud, just say "Houston, we have a problem". I'll know what you mean
|
||||
- We discuss architectutral decisions (framework changes, major refactoring, system design) together before implementation. Routine fixes and clear implementations don't need discussion.
|
||||
|
||||
## Proactiveness
|
||||
|
||||
When asked to do something, just do it - including obvious follow-up actions needed to complete the task properly.
|
||||
Only pause to ask for confirmation when:
|
||||
|
||||
- Multiple valid approaches exist and the choice matters
|
||||
- The action would delete or significantly restructure existing code
|
||||
- You genuinely don't understand what's being asked
|
||||
- Your partner asked a question (answer the question, don't jump to implementation)
|
||||
|
||||
@.claude/docs/WORKFLOWS.md
|
||||
@.cursorrules
|
||||
@README.md
|
||||
@package.json
|
||||
|
||||
## Essential Commands
|
||||
## 🚀 Essential Commands
|
||||
|
||||
| Task | Command | Notes |
|
||||
|-------------------|--------------------------|----------------------------------|
|
||||
@@ -51,13 +21,22 @@ Only pause to ask for confirmation when:
|
||||
| **Format** | `make fmt` | Auto-format code |
|
||||
| **Clean** | `make clean` | Clean build artifacts |
|
||||
|
||||
### Frontend Commands (site directory)
|
||||
|
||||
- `pnpm build` - Build frontend
|
||||
- `pnpm dev` - Run development server
|
||||
- `pnpm check` - Run code checks
|
||||
- `pnpm format` - Format frontend code
|
||||
- `pnpm lint` - Lint frontend code
|
||||
- `pnpm test` - Run frontend tests
|
||||
|
||||
### Documentation Commands
|
||||
|
||||
- `pnpm run format-docs` - Format markdown tables in docs
|
||||
- `pnpm run lint-docs` - Lint and fix markdown files
|
||||
- `pnpm run storybook` - Run Storybook (from site directory)
|
||||
|
||||
## Critical Patterns
|
||||
## 🔧 Critical Patterns
|
||||
|
||||
### Database Changes (ALWAYS FOLLOW)
|
||||
|
||||
@@ -99,7 +78,7 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestrict
|
||||
app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
## 📋 Quick Reference
|
||||
|
||||
### Full workflows available in imported WORKFLOWS.md
|
||||
|
||||
@@ -109,14 +88,14 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
|
||||
- [ ] Check if feature touches database - you'll need migrations
|
||||
- [ ] Check if feature touches audit logs - update `enterprise/audit/table.go`
|
||||
|
||||
## Architecture
|
||||
## 🏗️ Architecture
|
||||
|
||||
- **coderd**: Main API service
|
||||
- **provisionerd**: Infrastructure provisioning
|
||||
- **Agents**: Workspace services (SSH, port forwarding)
|
||||
- **Database**: PostgreSQL with `dbauthz` authorization
|
||||
|
||||
## Testing
|
||||
## 🧪 Testing
|
||||
|
||||
### Race Condition Prevention
|
||||
|
||||
@@ -133,21 +112,21 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
|
||||
NEVER use `time.Sleep` to mitigate timing issues. If an issue
|
||||
seems like it should use `time.Sleep`, read through https://github.com/coder/quartz and specifically the [README](https://github.com/coder/quartz/blob/main/README.md) to better understand how to handle timing issues.
|
||||
|
||||
## Code Style
|
||||
## 🎯 Code Style
|
||||
|
||||
### Detailed guidelines in imported WORKFLOWS.md
|
||||
|
||||
- Follow [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md)
|
||||
- Commit format: `type(scope): message`
|
||||
|
||||
## Detailed Development Guides
|
||||
## 📚 Detailed Development Guides
|
||||
|
||||
@.claude/docs/OAUTH2.md
|
||||
@.claude/docs/TESTING.md
|
||||
@.claude/docs/TROUBLESHOOTING.md
|
||||
@.claude/docs/DATABASE.md
|
||||
|
||||
## Common Pitfalls
|
||||
## 🚨 Common Pitfalls
|
||||
|
||||
1. **Audit table errors** → Update `enterprise/audit/table.go`
|
||||
2. **OAuth2 errors** → Return RFC-compliant format
|
||||
|
||||
+12
@@ -18,6 +18,18 @@ coderd/rbac/ @Emyrk
|
||||
scripts/apitypings/ @Emyrk
|
||||
scripts/gensite/ @aslilac
|
||||
|
||||
site/ @aslilac @Parkreiner
|
||||
site/src/hooks/ @Parkreiner
|
||||
# These rules intentionally do not specify any owners. More specific rules
|
||||
# override less specific rules, so these files are "ignored" by the site/ rule.
|
||||
site/e2e/google/protobuf/timestampGenerated.ts
|
||||
site/e2e/provisionerGenerated.ts
|
||||
site/src/api/countriesGenerated.ts
|
||||
site/src/api/rbacresourcesGenerated.ts
|
||||
site/src/api/typesGenerated.ts
|
||||
site/src/testHelpers/entities.ts
|
||||
site/CLAUDE.md
|
||||
|
||||
# The blood and guts of the autostop algorithm, which is quite complex and
|
||||
# requires elite ball knowledge of most of the scheduling code to make changes
|
||||
# without inadvertently affecting other parts of the codebase.
|
||||
|
||||
@@ -636,8 +636,8 @@ TAILNETTEST_MOCKS := \
|
||||
tailnet/tailnettest/subscriptionmock.go
|
||||
|
||||
AIBRIDGED_MOCKS := \
|
||||
enterprise/aibridged/aibridgedmock/clientmock.go \
|
||||
enterprise/aibridged/aibridgedmock/poolmock.go
|
||||
enterprise/x/aibridged/aibridgedmock/clientmock.go \
|
||||
enterprise/x/aibridged/aibridgedmock/poolmock.go
|
||||
|
||||
GEN_FILES := \
|
||||
tailnet/proto/tailnet.pb.go \
|
||||
@@ -645,7 +645,7 @@ GEN_FILES := \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
vpn/vpn.pb.go \
|
||||
enterprise/aibridged/proto/aibridged.pb.go \
|
||||
enterprise/x/aibridged/proto/aibridged.pb.go \
|
||||
$(DB_GEN_FILES) \
|
||||
$(SITE_GEN_FILES) \
|
||||
coderd/rbac/object_gen.go \
|
||||
@@ -676,7 +676,6 @@ gen/db: $(DB_GEN_FILES)
|
||||
.PHONY: gen/db
|
||||
|
||||
gen/golden-files: \
|
||||
agent/unit/testdata/.gen-golden \
|
||||
cli/testdata/.gen-golden \
|
||||
coderd/.gen-golden \
|
||||
coderd/notifications/.gen-golden \
|
||||
@@ -697,7 +696,7 @@ gen/mark-fresh:
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
vpn/vpn.pb.go \
|
||||
enterprise/aibridged/proto/aibridged.pb.go \
|
||||
enterprise/x/aibridged/proto/aibridged.pb.go \
|
||||
coderd/database/dump.sql \
|
||||
$(DB_GEN_FILES) \
|
||||
site/src/api/typesGenerated.ts \
|
||||
@@ -768,8 +767,8 @@ codersdk/workspacesdk/agentconnmock/agentconnmock.go: codersdk/workspacesdk/agen
|
||||
go generate ./codersdk/workspacesdk/agentconnmock/
|
||||
touch "$@"
|
||||
|
||||
$(AIBRIDGED_MOCKS): enterprise/aibridged/client.go enterprise/aibridged/pool.go
|
||||
go generate ./enterprise/aibridged/aibridgedmock/
|
||||
$(AIBRIDGED_MOCKS): enterprise/x/aibridged/client.go enterprise/x/aibridged/pool.go
|
||||
go generate ./enterprise/x/aibridged/aibridgedmock/
|
||||
touch "$@"
|
||||
|
||||
agent/agentcontainers/dcspec/dcspec_gen.go: \
|
||||
@@ -822,13 +821,13 @@ vpn/vpn.pb.go: vpn/vpn.proto
|
||||
--go_opt=paths=source_relative \
|
||||
./vpn/vpn.proto
|
||||
|
||||
enterprise/aibridged/proto/aibridged.pb.go: enterprise/aibridged/proto/aibridged.proto
|
||||
enterprise/x/aibridged/proto/aibridged.pb.go: enterprise/x/aibridged/proto/aibridged.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./enterprise/aibridged/proto/aibridged.proto
|
||||
./enterprise/x/aibridged/proto/aibridged.proto
|
||||
|
||||
site/src/api/typesGenerated.ts: site/node_modules/.installed $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
|
||||
# -C sets the directory for the go run command
|
||||
@@ -953,10 +952,6 @@ clean/golden-files:
|
||||
-type f -name '*.golden' -delete
|
||||
.PHONY: clean/golden-files
|
||||
|
||||
agent/unit/testdata/.gen-golden: $(wildcard agent/unit/testdata/*.golden) $(GO_SRC_FILES) $(wildcard agent/unit/*_test.go)
|
||||
TZ=UTC go test ./agent/unit -run="TestGraph" -update
|
||||
touch "$@"
|
||||
|
||||
cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard cli/*_test.go)
|
||||
TZ=UTC go test ./cli -run="Test(CommandHelp|ServerYAML|ErrorExamples|.*Golden)" -update
|
||||
touch "$@"
|
||||
@@ -1025,11 +1020,19 @@ endif
|
||||
|
||||
TEST_PACKAGES ?= ./...
|
||||
|
||||
test:
|
||||
warm-go-cache-db-cleaner:
|
||||
# ensure Go's build cache for the cleanercmd is fresh so that tests don't have to build from scratch. This
|
||||
# could take some time and counts against the test's timeout, which can lead to flakes.
|
||||
# c.f. https://github.com/coder/internal/issues/1026
|
||||
mkdir -p build
|
||||
$(GIT_FLAGS) go build -o ./build/cleaner github.com/coder/coder/v2/coderd/database/dbtestutil/cleanercmd
|
||||
.PHONY: warm-go-cache-db-cleaner
|
||||
|
||||
test: warm-go-cache-db-cleaner
|
||||
$(GIT_FLAGS) gotestsum --format standard-quiet $(GOTESTSUM_RETRY_FLAGS) --packages="$(TEST_PACKAGES)" -- $(GOTEST_FLAGS)
|
||||
.PHONY: test
|
||||
|
||||
test-cli:
|
||||
test-cli: warm-go-cache-db-cleaner
|
||||
$(MAKE) test TEST_PACKAGES="./cli..."
|
||||
.PHONY: test-cli
|
||||
|
||||
@@ -1182,8 +1185,3 @@ endif
|
||||
|
||||
dogfood/coder/nix.hash: flake.nix flake.lock
|
||||
sha256sum flake.nix flake.lock >./dogfood/coder/nix.hash
|
||||
|
||||
# Count the number of test databases created per test package.
|
||||
count-test-databases:
|
||||
PGPASSWORD=postgres psql -h localhost -U postgres -d coder_testing -P pager=off -c 'SELECT test_package, count(*) as count from test_databases GROUP BY test_package ORDER BY count DESC'
|
||||
.PHONY: count-test-databases
|
||||
|
||||
+3
-14
@@ -781,15 +781,11 @@ func (a *agent) reportConnectionsLoop(ctx context.Context, aAPI proto.DRPCAgentC
|
||||
logger.Debug(ctx, "reporting connection")
|
||||
_, err := aAPI.ReportConnection(ctx, payload)
|
||||
if err != nil {
|
||||
// Do not fail the loop if we fail to report a connection, just
|
||||
// log a warning.
|
||||
// Related to https://github.com/coder/coder/issues/20194
|
||||
logger.Warn(ctx, "failed to report connection to server", slog.Error(err))
|
||||
// keep going, we still need to remove it from the slice
|
||||
} else {
|
||||
logger.Debug(ctx, "successfully reported connection")
|
||||
return xerrors.Errorf("failed to report connection: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "successfully reported connection")
|
||||
|
||||
// Remove the payload we sent.
|
||||
a.reportConnectionsMu.Lock()
|
||||
a.reportConnections[0] = nil // Release the pointer from the underlying array.
|
||||
@@ -820,13 +816,6 @@ func (a *agent) reportConnection(id uuid.UUID, connectionType proto.Connection_T
|
||||
ip = host
|
||||
}
|
||||
|
||||
// If the IP is "localhost" (which it can be in some cases), set it to
|
||||
// 127.0.0.1 instead.
|
||||
// Related to https://github.com/coder/coder/issues/20194
|
||||
if ip == "localhost" {
|
||||
ip = "127.0.0.1"
|
||||
}
|
||||
|
||||
a.reportConnectionsMu.Lock()
|
||||
defer a.reportConnectionsMu.Unlock()
|
||||
|
||||
|
||||
+35
-80
@@ -1807,12 +1807,11 @@ func TestAgent_ReconnectingPTY(t *testing.T) {
|
||||
|
||||
//nolint:dogsled
|
||||
conn, agentClient, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
idConnectionReport := uuid.New()
|
||||
id := uuid.New()
|
||||
|
||||
// Test that the connection is reported. This must be tested in the
|
||||
// first connection because we care about verifying all of these.
|
||||
netConn0, err := conn.ReconnectingPTY(ctx, idConnectionReport, 80, 80, "bash --norc")
|
||||
netConn0, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash --norc")
|
||||
require.NoError(t, err)
|
||||
_ = netConn0.Close()
|
||||
assertConnectionReport(t, agentClient, proto.Connection_RECONNECTING_PTY, 0, "")
|
||||
@@ -2028,8 +2027,7 @@ func runSubAgentMain() int {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "agent connection failed: %v\n", err)
|
||||
return 11
|
||||
@@ -3462,7 +3460,11 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
registry := prometheus.NewRegistry()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
// Make sure we always get a DERP connection for
|
||||
// currently_reachable_peers.
|
||||
DisableDirectConnections: true,
|
||||
}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
o.PrometheusRegistry = registry
|
||||
})
|
||||
|
||||
@@ -3477,31 +3479,16 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
err = session.Shell()
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := []struct {
|
||||
Name string
|
||||
Type proto.Stats_Metric_Type
|
||||
CheckFn func(float64) error
|
||||
Labels []*proto.Stats_Metric_Label
|
||||
}{
|
||||
expected := []*proto.Stats_Metric{
|
||||
{
|
||||
Name: "agent_reconnecting_pty_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 0, got %f", v)
|
||||
},
|
||||
Name: "agent_reconnecting_pty_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "agent_sessions_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 1 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 1, got %f", v)
|
||||
},
|
||||
Name: "agent_sessions_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 1,
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "magic_type",
|
||||
@@ -3514,44 +3501,24 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_failed_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 0, got %f", v)
|
||||
},
|
||||
Name: "agent_ssh_server_failed_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_sftp_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 0, got %f", v)
|
||||
},
|
||||
Name: "agent_ssh_server_sftp_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_sftp_server_errors_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 0, got %f", v)
|
||||
},
|
||||
Name: "agent_ssh_server_sftp_server_errors_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
CheckFn: func(float64) error {
|
||||
// We can't reliably ping a peer here, and networking is out of
|
||||
// scope of this test, so we just test that the metric exists
|
||||
// with the correct labels.
|
||||
return nil
|
||||
},
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 1,
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "connection_type",
|
||||
@@ -3560,11 +3527,9 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
CheckFn: func(float64) error {
|
||||
return nil
|
||||
},
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 0,
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "connection_type",
|
||||
@@ -3573,20 +3538,9 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_startup_script_seconds",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
CheckFn: func(f float64) error {
|
||||
if f >= 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected >= 0, got %f", f)
|
||||
},
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "success",
|
||||
Value: "true",
|
||||
},
|
||||
},
|
||||
Name: "coderd_agentstats_startup_script_seconds",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 1,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3608,10 +3562,11 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
for _, m := range mf.GetMetric() {
|
||||
assert.Equal(t, expected[i].Name, mf.GetName())
|
||||
assert.Equal(t, expected[i].Type.String(), mf.GetType().String())
|
||||
// Value is max expected
|
||||
if expected[i].Type == proto.Stats_Metric_GAUGE {
|
||||
assert.NoError(t, expected[i].CheckFn(m.GetGauge().GetValue()), "check fn for %s failed", expected[i].Name)
|
||||
assert.GreaterOrEqualf(t, expected[i].Value, m.GetGauge().GetValue(), "expected %s to be greater than or equal to %f, got %f", expected[i].Name, expected[i].Value, m.GetGauge().GetValue())
|
||||
} else if expected[i].Type == proto.Stats_Metric_COUNTER {
|
||||
assert.NoError(t, expected[i].CheckFn(m.GetCounter().GetValue()), "check fn for %s failed", expected[i].Name)
|
||||
assert.GreaterOrEqualf(t, expected[i].Value, m.GetCounter().GetValue(), "expected %s to be greater than or equal to %f, got %f", expected[i].Name, expected[i].Value, m.GetCounter().GetValue())
|
||||
}
|
||||
for j, lbl := range expected[i].Labels {
|
||||
assert.Equal(t, m.GetLabel()[j], &promgo.LabelPair{
|
||||
|
||||
@@ -682,6 +682,8 @@ func (api *API) updaterLoop() {
|
||||
} else {
|
||||
prevErr = nil
|
||||
}
|
||||
default:
|
||||
api.logger.Debug(api.ctx, "updater loop ticker skipped, update in progress")
|
||||
}
|
||||
|
||||
return nil // Always nil to keep the ticker going.
|
||||
|
||||
+1
-2
@@ -63,7 +63,6 @@ func NewAppHealthReporterWithClock(
|
||||
// run a ticker for each app health check.
|
||||
var mu sync.RWMutex
|
||||
failures := make(map[uuid.UUID]int, 0)
|
||||
client := &http.Client{}
|
||||
for _, nextApp := range apps {
|
||||
if !shouldStartTicker(nextApp) {
|
||||
continue
|
||||
@@ -92,7 +91,7 @@ func NewAppHealthReporterWithClock(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
|
||||
// screenReconnectingPTY provides a reconnectable PTY via `screen`.
|
||||
type screenReconnectingPTY struct {
|
||||
logger slog.Logger
|
||||
execer agentexec.Execer
|
||||
command *pty.Cmd
|
||||
|
||||
@@ -63,7 +62,6 @@ type screenReconnectingPTY struct {
|
||||
// own which causes it to spawn with the specified size.
|
||||
func newScreen(ctx context.Context, logger slog.Logger, execer agentexec.Execer, cmd *pty.Cmd, options *Options) *screenReconnectingPTY {
|
||||
rpty := &screenReconnectingPTY{
|
||||
logger: logger,
|
||||
execer: execer,
|
||||
command: cmd,
|
||||
metrics: options.Metrics,
|
||||
@@ -175,7 +173,6 @@ func (rpty *screenReconnectingPTY) Attach(ctx context.Context, _ string, conn ne
|
||||
|
||||
ptty, process, err := rpty.doAttach(ctx, conn, height, width, logger)
|
||||
if err != nil {
|
||||
logger.Debug(ctx, "unable to attach to screen reconnecting pty", slog.Error(err))
|
||||
if errors.Is(err, context.Canceled) {
|
||||
// Likely the process was too short-lived and canceled the version command.
|
||||
// TODO: Is it worth distinguishing between that and a cancel from the
|
||||
@@ -185,7 +182,6 @@ func (rpty *screenReconnectingPTY) Attach(ctx context.Context, _ string, conn ne
|
||||
}
|
||||
return err
|
||||
}
|
||||
logger.Debug(ctx, "attached to screen reconnecting pty")
|
||||
|
||||
defer func() {
|
||||
// Log only for debugging since the process might have already exited on its
|
||||
@@ -407,7 +403,6 @@ func (rpty *screenReconnectingPTY) Wait() {
|
||||
}
|
||||
|
||||
func (rpty *screenReconnectingPTY) Close(err error) {
|
||||
rpty.logger.Debug(context.Background(), "closing screen reconnecting pty", slog.Error(err))
|
||||
// The closing state change will be handled by the lifecycle.
|
||||
rpty.state.setState(StateClosing, err)
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"gonum.org/v1/gonum/graph/encoding/dot"
|
||||
"gonum.org/v1/gonum/graph/simple"
|
||||
"gonum.org/v1/gonum/graph/topo"
|
||||
)
|
||||
|
||||
// Graph provides a bidirectional interface over gonum's directed graph implementation.
|
||||
// While the underlying gonum graph is directed, we overlay bidirectional semantics
|
||||
// by distinguishing between forward and reverse edges. Wanting and being wanted by
|
||||
// other units are related but different concepts that have different graph traversal
|
||||
// implications when Units update their status.
|
||||
//
|
||||
// The graph stores edge types to represent different relationships between units,
|
||||
// allowing for domain-specific semantics beyond simple connectivity.
|
||||
type Graph[EdgeType, VertexType comparable] struct {
|
||||
mu sync.RWMutex
|
||||
// The underlying gonum graph. It stores vertices and edges without knowing about the types of the vertices and edges.
|
||||
gonumGraph *simple.DirectedGraph
|
||||
// Maps vertices to their IDs so that a gonum vertex ID can be used to lookup the vertex type.
|
||||
vertexToID map[VertexType]int64
|
||||
// Maps vertex IDs to their types so that a vertex type can be used to lookup the gonum vertex ID.
|
||||
idToVertex map[int64]VertexType
|
||||
// The next ID to assign to a vertex.
|
||||
nextID int64
|
||||
// Store edge types by "fromID->toID" key. This is used to lookup the edge type for a given edge.
|
||||
edgeTypes map[string]EdgeType
|
||||
}
|
||||
|
||||
// Edge is a convenience type for representing an edge in the graph.
|
||||
// It encapsulates the from and to vertices and the edge type itself.
|
||||
type Edge[EdgeType, VertexType comparable] struct {
|
||||
From VertexType
|
||||
To VertexType
|
||||
Edge EdgeType
|
||||
}
|
||||
|
||||
// AddEdge adds an edge to the graph. It initializes the graph and metadata on first use,
|
||||
// checks for cycles, and adds the edge to the gonum graph.
|
||||
func (g *Graph[EdgeType, VertexType]) AddEdge(from, to VertexType, edge EdgeType) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.gonumGraph == nil {
|
||||
g.gonumGraph = simple.NewDirectedGraph()
|
||||
g.vertexToID = make(map[VertexType]int64)
|
||||
g.idToVertex = make(map[int64]VertexType)
|
||||
g.edgeTypes = make(map[string]EdgeType)
|
||||
g.nextID = 1
|
||||
}
|
||||
|
||||
fromID := g.getOrCreateVertexID(from)
|
||||
toID := g.getOrCreateVertexID(to)
|
||||
|
||||
if g.canReach(to, from) {
|
||||
return xerrors.Errorf("adding edge (%v -> %v) would create a cycle", from, to)
|
||||
}
|
||||
|
||||
g.gonumGraph.SetEdge(simple.Edge{F: simple.Node(fromID), T: simple.Node(toID)})
|
||||
|
||||
edgeKey := fmt.Sprintf("%d->%d", fromID, toID)
|
||||
g.edgeTypes[edgeKey] = edge
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetForwardAdjacentVertices returns all the edges that originate from the given vertex.
|
||||
func (g *Graph[EdgeType, VertexType]) GetForwardAdjacentVertices(from VertexType) []Edge[EdgeType, VertexType] {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
fromID, exists := g.vertexToID[from]
|
||||
if !exists {
|
||||
return []Edge[EdgeType, VertexType]{}
|
||||
}
|
||||
|
||||
edges := []Edge[EdgeType, VertexType]{}
|
||||
toNodes := g.gonumGraph.From(fromID)
|
||||
for toNodes.Next() {
|
||||
toID := toNodes.Node().ID()
|
||||
to := g.idToVertex[toID]
|
||||
|
||||
// Get the edge type
|
||||
edgeKey := fmt.Sprintf("%d->%d", fromID, toID)
|
||||
edgeType := g.edgeTypes[edgeKey]
|
||||
|
||||
edges = append(edges, Edge[EdgeType, VertexType]{From: from, To: to, Edge: edgeType})
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
// GetReverseAdjacentVertices returns all the edges that terminate at the given vertex.
|
||||
func (g *Graph[EdgeType, VertexType]) GetReverseAdjacentVertices(to VertexType) []Edge[EdgeType, VertexType] {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
toID, exists := g.vertexToID[to]
|
||||
if !exists {
|
||||
return []Edge[EdgeType, VertexType]{}
|
||||
}
|
||||
|
||||
edges := []Edge[EdgeType, VertexType]{}
|
||||
fromNodes := g.gonumGraph.To(toID)
|
||||
for fromNodes.Next() {
|
||||
fromID := fromNodes.Node().ID()
|
||||
from := g.idToVertex[fromID]
|
||||
|
||||
// Get the edge type
|
||||
edgeKey := fmt.Sprintf("%d->%d", fromID, toID)
|
||||
edgeType := g.edgeTypes[edgeKey]
|
||||
|
||||
edges = append(edges, Edge[EdgeType, VertexType]{From: from, To: to, Edge: edgeType})
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
// getOrCreateVertexID returns the ID for a vertex, creating it if it doesn't exist.
|
||||
func (g *Graph[EdgeType, VertexType]) getOrCreateVertexID(vertex VertexType) int64 {
|
||||
if id, exists := g.vertexToID[vertex]; exists {
|
||||
return id
|
||||
}
|
||||
|
||||
id := g.nextID
|
||||
g.nextID++
|
||||
g.vertexToID[vertex] = id
|
||||
g.idToVertex[id] = vertex
|
||||
|
||||
// Add the node to the gonum graph
|
||||
g.gonumGraph.AddNode(simple.Node(id))
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// canReach checks if there is a path from the start vertex to the end vertex.
|
||||
func (g *Graph[EdgeType, VertexType]) canReach(start, end VertexType) bool {
|
||||
if start == end {
|
||||
return true
|
||||
}
|
||||
|
||||
startID, startExists := g.vertexToID[start]
|
||||
endID, endExists := g.vertexToID[end]
|
||||
|
||||
if !startExists || !endExists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Use gonum's built-in path existence check
|
||||
return topo.PathExistsIn(g.gonumGraph, simple.Node(startID), simple.Node(endID))
|
||||
}
|
||||
|
||||
// ToDOT exports the graph to DOT format for visualization
|
||||
func (g *Graph[EdgeType, VertexType]) ToDOT(name string) (string, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
if g.gonumGraph == nil {
|
||||
return "", xerrors.New("graph is not initialized")
|
||||
}
|
||||
|
||||
// Marshal the graph to DOT format
|
||||
dotBytes, err := dot.Marshal(g.gonumGraph, name, "", " ")
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("failed to marshal graph to DOT: %w", err)
|
||||
}
|
||||
|
||||
return string(dotBytes), nil
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
// Package unit_test provides tests for the unit package.
|
||||
//
|
||||
// DOT Graph Testing:
|
||||
// The graph tests use golden files for DOT representation verification.
|
||||
// To update the golden files:
|
||||
// make gen/golden-files
|
||||
//
|
||||
// The golden files contain the expected DOT representation and can be easily
|
||||
// inspected, version controlled, and updated when the graph structure changes.
|
||||
package unit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
)
|
||||
|
||||
type testGraphEdge string
|
||||
|
||||
const (
|
||||
testEdgeStarted testGraphEdge = "started"
|
||||
testEdgeCompleted testGraphEdge = "completed"
|
||||
)
|
||||
|
||||
type testGraphVertex struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type (
|
||||
testGraph = unit.Graph[testGraphEdge, *testGraphVertex]
|
||||
testEdge = unit.Edge[testGraphEdge, *testGraphVertex]
|
||||
)
|
||||
|
||||
// randInt generates a random integer in the range [0, limit).
|
||||
func randInt(limit int) int {
|
||||
if limit <= 0 {
|
||||
return 0
|
||||
}
|
||||
n, err := cryptorand.Int63n(int64(limit))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(n)
|
||||
}
|
||||
|
||||
// UpdateGoldenFiles indicates golden files should be updated.
|
||||
// To update the golden files:
|
||||
// make gen/golden-files
|
||||
var UpdateGoldenFiles = flag.Bool("update", false, "update .golden files")
|
||||
|
||||
// assertDOTGraph requires that the graph's DOT representation matches the golden file
|
||||
func assertDOTGraph(t *testing.T, graph *testGraph, goldenName string) {
|
||||
t.Helper()
|
||||
|
||||
dot, err := graph.ToDOT(goldenName)
|
||||
require.NoError(t, err)
|
||||
|
||||
goldenFile := filepath.Join("testdata", goldenName+".golden")
|
||||
if *UpdateGoldenFiles {
|
||||
t.Logf("update golden file for: %q: %s", goldenName, goldenFile)
|
||||
err := os.MkdirAll(filepath.Dir(goldenFile), 0o755)
|
||||
require.NoError(t, err, "want no error creating golden file directory")
|
||||
err = os.WriteFile(goldenFile, []byte(dot), 0o600)
|
||||
require.NoError(t, err, "update golden file")
|
||||
}
|
||||
|
||||
expected, err := os.ReadFile(goldenFile)
|
||||
require.NoError(t, err, "read golden file, run \"make gen/golden-files\" and commit the changes")
|
||||
|
||||
// Normalize line endings for cross-platform compatibility
|
||||
expected = normalizeLineEndings(expected)
|
||||
normalizedDot := normalizeLineEndings([]byte(dot))
|
||||
|
||||
assert.Empty(t, cmp.Diff(string(expected), string(normalizedDot)), "golden file mismatch (-want +got): %s, run \"make gen/golden-files\", verify and commit the changes", goldenFile)
|
||||
}
|
||||
|
||||
// normalizeLineEndings ensures that all line endings are normalized to \n.
|
||||
// Required for Windows compatibility.
|
||||
func normalizeLineEndings(content []byte) []byte {
|
||||
content = bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n"))
|
||||
content = bytes.ReplaceAll(content, []byte("\r"), []byte("\n"))
|
||||
return content
|
||||
}
|
||||
|
||||
func TestGraph(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testFuncs := map[string]func(t *testing.T) *unit.Graph[testGraphEdge, *testGraphVertex]{
|
||||
"ForwardAndReverseEdges": func(t *testing.T) *unit.Graph[testGraphEdge, *testGraphVertex] {
|
||||
graph := &unit.Graph[testGraphEdge, *testGraphVertex]{}
|
||||
unit1 := &testGraphVertex{Name: "unit1"}
|
||||
unit2 := &testGraphVertex{Name: "unit2"}
|
||||
unit3 := &testGraphVertex{Name: "unit3"}
|
||||
err := graph.AddEdge(unit1, unit2, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unit1, unit3, testEdgeStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check for forward edge
|
||||
vertices := graph.GetForwardAdjacentVertices(unit1)
|
||||
require.Len(t, vertices, 2)
|
||||
// Unit 1 depends on the completion of Unit2
|
||||
require.Contains(t, vertices, testEdge{
|
||||
From: unit1,
|
||||
To: unit2,
|
||||
Edge: testEdgeCompleted,
|
||||
})
|
||||
// Unit 1 depends on the start of Unit3
|
||||
require.Contains(t, vertices, testEdge{
|
||||
From: unit1,
|
||||
To: unit3,
|
||||
Edge: testEdgeStarted,
|
||||
})
|
||||
|
||||
// Check for reverse edges
|
||||
unit2ReverseEdges := graph.GetReverseAdjacentVertices(unit2)
|
||||
require.Len(t, unit2ReverseEdges, 1)
|
||||
// Unit 2 must be completed before Unit 1 can start
|
||||
require.Contains(t, unit2ReverseEdges, testEdge{
|
||||
From: unit1,
|
||||
To: unit2,
|
||||
Edge: testEdgeCompleted,
|
||||
})
|
||||
|
||||
unit3ReverseEdges := graph.GetReverseAdjacentVertices(unit3)
|
||||
require.Len(t, unit3ReverseEdges, 1)
|
||||
// Unit 3 must be started before Unit 1 can complete
|
||||
require.Contains(t, unit3ReverseEdges, testEdge{
|
||||
From: unit1,
|
||||
To: unit3,
|
||||
Edge: testEdgeStarted,
|
||||
})
|
||||
|
||||
return graph
|
||||
},
|
||||
"SelfReference": func(t *testing.T) *testGraph {
|
||||
graph := &testGraph{}
|
||||
unit1 := &testGraphVertex{Name: "unit1"}
|
||||
err := graph.AddEdge(unit1, unit1, testEdgeCompleted)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, fmt.Sprintf("adding edge (%v -> %v) would create a cycle", unit1, unit1))
|
||||
|
||||
return graph
|
||||
},
|
||||
"Cycle": func(t *testing.T) *testGraph {
|
||||
graph := &testGraph{}
|
||||
unit1 := &testGraphVertex{Name: "unit1"}
|
||||
unit2 := &testGraphVertex{Name: "unit2"}
|
||||
err := graph.AddEdge(unit1, unit2, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unit2, unit1, testEdgeStarted)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, fmt.Sprintf("adding edge (%v -> %v) would create a cycle", unit2, unit1))
|
||||
|
||||
return graph
|
||||
},
|
||||
"MultipleDependenciesSameStatus": func(t *testing.T) *testGraph {
|
||||
graph := &testGraph{}
|
||||
unit1 := &testGraphVertex{Name: "unit1"}
|
||||
unit2 := &testGraphVertex{Name: "unit2"}
|
||||
unit3 := &testGraphVertex{Name: "unit3"}
|
||||
unit4 := &testGraphVertex{Name: "unit4"}
|
||||
|
||||
// Unit1 depends on completion of both unit2 and unit3 (same status type)
|
||||
err := graph.AddEdge(unit1, unit2, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unit1, unit3, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unit1 also depends on starting of unit4 (different status type)
|
||||
err = graph.AddEdge(unit1, unit4, testEdgeStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that unit1 has 3 forward dependencies
|
||||
forwardEdges := graph.GetForwardAdjacentVertices(unit1)
|
||||
require.Len(t, forwardEdges, 3)
|
||||
|
||||
// Verify all expected dependencies exist
|
||||
expectedDependencies := []testEdge{
|
||||
{From: unit1, To: unit2, Edge: testEdgeCompleted},
|
||||
{From: unit1, To: unit3, Edge: testEdgeCompleted},
|
||||
{From: unit1, To: unit4, Edge: testEdgeStarted},
|
||||
}
|
||||
|
||||
for _, expected := range expectedDependencies {
|
||||
require.Contains(t, forwardEdges, expected)
|
||||
}
|
||||
|
||||
// Check reverse dependencies
|
||||
unit2ReverseEdges := graph.GetReverseAdjacentVertices(unit2)
|
||||
require.Len(t, unit2ReverseEdges, 1)
|
||||
require.Contains(t, unit2ReverseEdges, testEdge{
|
||||
From: unit1, To: unit2, Edge: testEdgeCompleted,
|
||||
})
|
||||
|
||||
unit3ReverseEdges := graph.GetReverseAdjacentVertices(unit3)
|
||||
require.Len(t, unit3ReverseEdges, 1)
|
||||
require.Contains(t, unit3ReverseEdges, testEdge{
|
||||
From: unit1, To: unit3, Edge: testEdgeCompleted,
|
||||
})
|
||||
|
||||
unit4ReverseEdges := graph.GetReverseAdjacentVertices(unit4)
|
||||
require.Len(t, unit4ReverseEdges, 1)
|
||||
require.Contains(t, unit4ReverseEdges, testEdge{
|
||||
From: unit1, To: unit4, Edge: testEdgeStarted,
|
||||
})
|
||||
|
||||
return graph
|
||||
},
|
||||
}
|
||||
|
||||
for testName, testFunc := range testFuncs {
|
||||
var graph *testGraph
|
||||
t.Run(testName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
graph = testFunc(t)
|
||||
assertDOTGraph(t, graph, testName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphThreadSafety(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ConcurrentReadWrite", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
graph := &testGraph{}
|
||||
var wg sync.WaitGroup
|
||||
const numWriters = 50
|
||||
const numReaders = 100
|
||||
const operationsPerWriter = 1000
|
||||
const operationsPerReader = 2000
|
||||
|
||||
barrier := make(chan struct{})
|
||||
// Launch writers
|
||||
for i := 0; i < numWriters; i++ {
|
||||
wg.Add(1)
|
||||
go func(writerID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
for j := 0; j < operationsPerWriter; j++ {
|
||||
from := &testGraphVertex{Name: fmt.Sprintf("writer-%d-%d", writerID, j)}
|
||||
to := &testGraphVertex{Name: fmt.Sprintf("writer-%d-%d", writerID, j+1)}
|
||||
graph.AddEdge(from, to, testEdgeCompleted)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Launch readers
|
||||
readerResults := make([]struct {
|
||||
panicked bool
|
||||
readCount int
|
||||
}, numReaders)
|
||||
|
||||
for i := 0; i < numReaders; i++ {
|
||||
wg.Add(1)
|
||||
go func(readerID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
readerResults[readerID].panicked = true
|
||||
}
|
||||
}()
|
||||
|
||||
readCount := 0
|
||||
for j := 0; j < operationsPerReader; j++ {
|
||||
// Create a test vertex and read
|
||||
testUnit := &testGraphVertex{Name: fmt.Sprintf("test-reader-%d-%d", readerID, j)}
|
||||
forwardEdges := graph.GetForwardAdjacentVertices(testUnit)
|
||||
reverseEdges := graph.GetReverseAdjacentVertices(testUnit)
|
||||
|
||||
// Just verify no panics (results may be nil for non-existent vertices)
|
||||
_ = forwardEdges
|
||||
_ = reverseEdges
|
||||
readCount++
|
||||
}
|
||||
readerResults[readerID].readCount = readCount
|
||||
}(i)
|
||||
}
|
||||
|
||||
close(barrier)
|
||||
wg.Wait()
|
||||
|
||||
// Verify no panics occurred in readers
|
||||
for i, result := range readerResults {
|
||||
require.False(t, result.panicked, "reader %d panicked", i)
|
||||
require.Equal(t, operationsPerReader, result.readCount, "reader %d should have performed expected reads", i)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ConcurrentCycleDetection", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
graph := &testGraph{}
|
||||
|
||||
// Pre-create chain: A→B→C→D
|
||||
unitA := &testGraphVertex{Name: "A"}
|
||||
unitB := &testGraphVertex{Name: "B"}
|
||||
unitC := &testGraphVertex{Name: "C"}
|
||||
unitD := &testGraphVertex{Name: "D"}
|
||||
|
||||
err := graph.AddEdge(unitA, unitB, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unitB, unitC, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unitC, unitD, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
|
||||
barrier := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
const numGoroutines = 50
|
||||
cycleErrors := make([]error, numGoroutines)
|
||||
|
||||
// Launch goroutines trying to add D→A (creates cycle)
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
err := graph.AddEdge(unitD, unitA, testEdgeCompleted)
|
||||
cycleErrors[goroutineID] = err
|
||||
}(i)
|
||||
}
|
||||
|
||||
close(barrier)
|
||||
wg.Wait()
|
||||
|
||||
// Verify all attempts correctly returned cycle error
|
||||
for i, err := range cycleErrors {
|
||||
require.Error(t, err, "goroutine %d should have detected cycle", i)
|
||||
require.Contains(t, err.Error(), "would create a cycle")
|
||||
}
|
||||
|
||||
// Verify graph remains valid (original chain intact)
|
||||
dot, err := graph.ToDOT("test")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, dot)
|
||||
})
|
||||
|
||||
t.Run("ConcurrentToDOT", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
graph := &testGraph{}
|
||||
|
||||
// Pre-populate graph
|
||||
for i := 0; i < 20; i++ {
|
||||
from := &testGraphVertex{Name: fmt.Sprintf("dot-unit-%d", i)}
|
||||
to := &testGraphVertex{Name: fmt.Sprintf("dot-unit-%d", i+1)}
|
||||
err := graph.AddEdge(from, to, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
barrier := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
const numReaders = 100
|
||||
const numWriters = 20
|
||||
dotResults := make([]string, numReaders)
|
||||
|
||||
// Launch readers calling ToDOT
|
||||
dotErrors := make([]error, numReaders)
|
||||
for i := 0; i < numReaders; i++ {
|
||||
wg.Add(1)
|
||||
go func(readerID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
dot, err := graph.ToDOT(fmt.Sprintf("test-%d", readerID))
|
||||
dotErrors[readerID] = err
|
||||
if err == nil {
|
||||
dotResults[readerID] = dot
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Launch writers adding edges
|
||||
for i := 0; i < numWriters; i++ {
|
||||
wg.Add(1)
|
||||
go func(writerID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
from := &testGraphVertex{Name: fmt.Sprintf("writer-dot-%d", writerID)}
|
||||
to := &testGraphVertex{Name: fmt.Sprintf("writer-dot-target-%d", writerID)}
|
||||
graph.AddEdge(from, to, testEdgeCompleted)
|
||||
}(i)
|
||||
}
|
||||
|
||||
close(barrier)
|
||||
wg.Wait()
|
||||
|
||||
// Verify no errors occurred during DOT generation
|
||||
for i, err := range dotErrors {
|
||||
require.NoError(t, err, "DOT generation error at index %d", i)
|
||||
}
|
||||
|
||||
// Verify all DOT results are valid
|
||||
for i, dot := range dotResults {
|
||||
require.NotEmpty(t, dot, "DOT result %d should not be empty", i)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkGraph_ConcurrentMixedOperations(b *testing.B) {
|
||||
graph := &testGraph{}
|
||||
var wg sync.WaitGroup
|
||||
const numGoroutines = 200
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Launch goroutines performing random operations
|
||||
for j := 0; j < numGoroutines; j++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
operationCount := 0
|
||||
|
||||
for operationCount < 50 {
|
||||
operation := float32(randInt(100)) / 100.0
|
||||
|
||||
if operation < 0.6 { // 60% reads
|
||||
// Read operation
|
||||
testUnit := &testGraphVertex{Name: fmt.Sprintf("bench-read-%d-%d", goroutineID, operationCount)}
|
||||
forwardEdges := graph.GetForwardAdjacentVertices(testUnit)
|
||||
reverseEdges := graph.GetReverseAdjacentVertices(testUnit)
|
||||
|
||||
// Just verify no panics (results may be nil for non-existent vertices)
|
||||
_ = forwardEdges
|
||||
_ = reverseEdges
|
||||
} else { // 40% writes
|
||||
// Write operation
|
||||
from := &testGraphVertex{Name: fmt.Sprintf("bench-write-%d-%d", goroutineID, operationCount)}
|
||||
to := &testGraphVertex{Name: fmt.Sprintf("bench-write-target-%d-%d", goroutineID, operationCount)}
|
||||
graph.AddEdge(from, to, testEdgeCompleted)
|
||||
}
|
||||
|
||||
operationCount++
|
||||
}
|
||||
}(j)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
strict digraph Cycle {
|
||||
// Node definitions.
|
||||
1;
|
||||
2;
|
||||
|
||||
// Edge definitions.
|
||||
1 -> 2;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
strict digraph ForwardAndReverseEdges {
|
||||
// Node definitions.
|
||||
1;
|
||||
2;
|
||||
3;
|
||||
|
||||
// Edge definitions.
|
||||
1 -> 2;
|
||||
1 -> 3;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
strict digraph MultipleDependenciesSameStatus {
|
||||
// Node definitions.
|
||||
1;
|
||||
2;
|
||||
3;
|
||||
4;
|
||||
|
||||
// Edge definitions.
|
||||
1 -> 2;
|
||||
1 -> 3;
|
||||
1 -> 4;
|
||||
}
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
strict digraph SelfReference {
|
||||
// Node definitions.
|
||||
1;
|
||||
}
|
||||
+11
-5
@@ -6,7 +6,10 @@
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!**/pnpm-lock.yaml"],
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/pnpm-lock.yaml"
|
||||
],
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
"linter": {
|
||||
@@ -45,14 +48,13 @@
|
||||
"options": {
|
||||
"paths": {
|
||||
"@mui/material": "Use @mui/material/<name> instead. See: https://material-ui.com/guides/minimizing-bundle-size/.",
|
||||
"@mui/icons-material": "Use @mui/icons-material/<name> instead. See: https://material-ui.com/guides/minimizing-bundle-size/.",
|
||||
"@mui/material/Avatar": "Use components/Avatar/Avatar instead.",
|
||||
"@mui/material/Alert": "Use components/Alert/Alert instead.",
|
||||
"@mui/material/Popover": "Use components/Popover/Popover instead.",
|
||||
"@mui/material/Typography": "Use native HTML elements instead. Eg: <span>, <p>, <h1>, etc.",
|
||||
"@mui/material/Box": "Use a <div> instead.",
|
||||
"@mui/material/Button": "Use a components/Button/Button instead.",
|
||||
"@mui/material/styles": "Import from @emotion/react instead.",
|
||||
"@mui/material/Table*": "Import from components/Table/Table instead.",
|
||||
"lodash": "Use lodash/<name> instead."
|
||||
}
|
||||
}
|
||||
@@ -67,7 +69,11 @@
|
||||
"noConsole": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"allow": ["error", "info", "warn"]
|
||||
"allow": [
|
||||
"error",
|
||||
"info",
|
||||
"warn"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -76,5 +82,5 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json"
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json"
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
var (
|
||||
_ pflag.SliceValue = &AllowListFlag{}
|
||||
_ pflag.Value = &AllowListFlag{}
|
||||
)
|
||||
|
||||
// AllowListFlag implements pflag.SliceValue for codersdk.APIAllowListTarget entries.
|
||||
type AllowListFlag []codersdk.APIAllowListTarget
|
||||
|
||||
func AllowListFlagOf(al *[]codersdk.APIAllowListTarget) *AllowListFlag {
|
||||
return (*AllowListFlag)(al)
|
||||
}
|
||||
|
||||
func (a AllowListFlag) String() string {
|
||||
return strings.Join(a.GetSlice(), ",")
|
||||
}
|
||||
|
||||
func (a AllowListFlag) Value() []codersdk.APIAllowListTarget {
|
||||
return []codersdk.APIAllowListTarget(a)
|
||||
}
|
||||
|
||||
func (AllowListFlag) Type() string { return "allow-list" }
|
||||
|
||||
func (a *AllowListFlag) Set(set string) error {
|
||||
values, err := csv.NewReader(strings.NewReader(set)).Read()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse allow list entries as csv: %w", err)
|
||||
}
|
||||
for _, v := range values {
|
||||
if err := a.Append(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AllowListFlag) Append(value string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return xerrors.New("allow list entry cannot be empty")
|
||||
}
|
||||
var target codersdk.APIAllowListTarget
|
||||
if err := target.UnmarshalText([]byte(value)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*a = append(*a, target)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AllowListFlag) Replace(items []string) error {
|
||||
*a = []codersdk.APIAllowListTarget{}
|
||||
for _, item := range items {
|
||||
if err := a.Append(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AllowListFlag) GetSlice() []string {
|
||||
out := make([]string, len(*a))
|
||||
for i, entry := range *a {
|
||||
out[i] = entry.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
+1
-26
@@ -53,9 +53,6 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
t := time.NewTimer(0)
|
||||
defer t.Stop()
|
||||
|
||||
startTime := time.Now()
|
||||
baseInterval := opts.FetchInterval
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -71,11 +68,7 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
return
|
||||
}
|
||||
fetchedAgent <- fetchAgent{agent: agent}
|
||||
|
||||
// Adjust the interval based on how long we've been waiting.
|
||||
elapsed := time.Since(startTime)
|
||||
currentInterval := GetProgressiveInterval(baseInterval, elapsed)
|
||||
t.Reset(currentInterval)
|
||||
t.Reset(opts.FetchInterval)
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -300,24 +293,6 @@ func safeDuration(sw *stageWriter, a, b *time.Time) time.Duration {
|
||||
return a.Sub(*b)
|
||||
}
|
||||
|
||||
// GetProgressiveInterval returns an interval that increases over time.
|
||||
// The interval starts at baseInterval and increases to
|
||||
// a maximum of baseInterval * 16 over time.
|
||||
func GetProgressiveInterval(baseInterval time.Duration, elapsed time.Duration) time.Duration {
|
||||
switch {
|
||||
case elapsed < 60*time.Second:
|
||||
return baseInterval // 500ms for first 60 seconds
|
||||
case elapsed < 2*time.Minute:
|
||||
return baseInterval * 2 // 1s for next 1 minute
|
||||
case elapsed < 5*time.Minute:
|
||||
return baseInterval * 4 // 2s for next 3 minutes
|
||||
case elapsed < 10*time.Minute:
|
||||
return baseInterval * 8 // 4s for next 5 minutes
|
||||
default:
|
||||
return baseInterval * 16 // 8s after 10 minutes
|
||||
}
|
||||
}
|
||||
|
||||
type closeFunc func() error
|
||||
|
||||
func (c closeFunc) Close() error {
|
||||
|
||||
@@ -866,31 +866,3 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProgressiveInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseInterval := 500 * time.Millisecond
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
elapsed time.Duration
|
||||
expected time.Duration
|
||||
}{
|
||||
{"first_minute", 30 * time.Second, baseInterval},
|
||||
{"second_minute", 90 * time.Second, baseInterval * 2},
|
||||
{"third_to_fifth_minute", 3 * time.Minute, baseInterval * 4},
|
||||
{"sixth_to_tenth_minute", 7 * time.Minute, baseInterval * 8},
|
||||
{"after_ten_minutes", 15 * time.Minute, baseInterval * 16},
|
||||
{"boundary_first_minute", 59 * time.Second, baseInterval},
|
||||
{"boundary_second_minute", 61 * time.Second, baseInterval * 2},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := cliui.GetProgressiveInterval(baseInterval, tc.elapsed)
|
||||
require.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+9
-18
@@ -296,23 +296,22 @@ func renderTable(out any, sort string, headers table.Row, filterColumns []string
|
||||
// returned. If the table tag is malformed, an error is returned.
|
||||
//
|
||||
// The returned name is transformed from "snake_case" to "normal text".
|
||||
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, noSortOpt, recursive, skipParentName, emptyNil bool, err error) {
|
||||
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, noSortOpt, recursive, skipParentName bool, err error) {
|
||||
tags, err := structtag.Parse(string(field.Tag))
|
||||
if err != nil {
|
||||
return "", false, false, false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||
return "", false, false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||
}
|
||||
|
||||
tag, err := tags.Get("table")
|
||||
if err != nil || tag.Name == "-" {
|
||||
// tags.Get only returns an error if the tag is not found.
|
||||
return "", false, false, false, false, false, nil
|
||||
return "", false, false, false, false, nil
|
||||
}
|
||||
|
||||
defaultSortOpt := false
|
||||
noSortOpt = false
|
||||
recursiveOpt := false
|
||||
skipParentNameOpt := false
|
||||
emptyNilOpt := false
|
||||
for _, opt := range tag.Options {
|
||||
switch opt {
|
||||
case "default_sort":
|
||||
@@ -327,14 +326,12 @@ func parseTableStructTag(field reflect.StructField) (name string, defaultSort, n
|
||||
// make sure the child name is unique across all nested structs in the parent.
|
||||
recursiveOpt = true
|
||||
skipParentNameOpt = true
|
||||
case "empty_nil":
|
||||
emptyNilOpt = true
|
||||
default:
|
||||
return "", false, false, false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||
return "", false, false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, noSortOpt, recursiveOpt, skipParentNameOpt, emptyNilOpt, nil
|
||||
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, noSortOpt, recursiveOpt, skipParentNameOpt, nil
|
||||
}
|
||||
|
||||
func isStructOrStructPointer(t reflect.Type) bool {
|
||||
@@ -361,7 +358,7 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string,
|
||||
noSortOpt := false
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
name, defaultSort, noSort, recursive, skip, _, err := parseTableStructTag(field)
|
||||
name, defaultSort, noSort, recursive, skip, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
|
||||
}
|
||||
@@ -438,7 +435,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Type().Field(i)
|
||||
fieldVal := val.Field(i)
|
||||
name, _, _, recursive, skip, emptyNil, err := parseTableStructTag(field)
|
||||
name, _, _, recursive, skip, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
|
||||
}
|
||||
@@ -446,14 +443,8 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldType := field.Type
|
||||
|
||||
// If empty_nil is set and this is a nil pointer, use a zero value.
|
||||
if emptyNil && fieldVal.Kind() == reflect.Pointer && fieldVal.IsNil() {
|
||||
fieldVal = reflect.New(fieldType.Elem())
|
||||
}
|
||||
|
||||
// Recurse if it's a struct.
|
||||
fieldType := field.Type
|
||||
if recursive {
|
||||
if !isStructOrStructPointer(fieldType) {
|
||||
return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, fieldType.String())
|
||||
@@ -476,7 +467,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
}
|
||||
|
||||
// Otherwise, we just use the field value.
|
||||
row[name] = fieldVal.Interface()
|
||||
row[name] = val.Field(i).Interface()
|
||||
}
|
||||
|
||||
return row, nil
|
||||
|
||||
@@ -400,78 +400,6 @@ foo <nil> 10 [a, b, c] foo1 11 foo2 12 fo
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("EmptyNil", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type emptyNilTest struct {
|
||||
Name string `table:"name,default_sort"`
|
||||
EmptyOnNil *string `table:"empty_on_nil,empty_nil"`
|
||||
NormalBehavior *string `table:"normal_behavior"`
|
||||
}
|
||||
|
||||
value := "value"
|
||||
in := []emptyNilTest{
|
||||
{
|
||||
Name: "has_value",
|
||||
EmptyOnNil: &value,
|
||||
NormalBehavior: &value,
|
||||
},
|
||||
{
|
||||
Name: "has_nil",
|
||||
EmptyOnNil: nil,
|
||||
NormalBehavior: nil,
|
||||
},
|
||||
}
|
||||
|
||||
expected := `
|
||||
NAME EMPTY ON NIL NORMAL BEHAVIOR
|
||||
has_nil <nil>
|
||||
has_value value value
|
||||
`
|
||||
|
||||
out, err := cliui.DisplayTable(in, "", nil)
|
||||
log.Println("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
t.Run("EmptyNilWithRecursiveInline", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type nestedData struct {
|
||||
Name string `table:"name"`
|
||||
}
|
||||
|
||||
type inlineTest struct {
|
||||
Nested *nestedData `table:"ignored,recursive_inline,empty_nil"`
|
||||
Count int `table:"count,default_sort"`
|
||||
}
|
||||
|
||||
in := []inlineTest{
|
||||
{
|
||||
Nested: &nestedData{
|
||||
Name: "alice",
|
||||
},
|
||||
Count: 1,
|
||||
},
|
||||
{
|
||||
Nested: nil,
|
||||
Count: 2,
|
||||
},
|
||||
}
|
||||
|
||||
expected := `
|
||||
NAME COUNT
|
||||
alice 1
|
||||
2
|
||||
`
|
||||
|
||||
out, err := cliui.DisplayTable(in, "", nil)
|
||||
log.Println("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
}
|
||||
|
||||
// compareTables normalizes the incoming table lines
|
||||
|
||||
@@ -185,6 +185,9 @@ func TestDelete(t *testing.T) {
|
||||
|
||||
t.Run("WarnNoProvisioners", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("this test requires postgres")
|
||||
}
|
||||
|
||||
store, ps, db := dbtestutil.NewDBWithSQLDB(t)
|
||||
client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{
|
||||
@@ -225,6 +228,9 @@ func TestDelete(t *testing.T) {
|
||||
|
||||
t.Run("Prebuilt workspace delete permissions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("this test requires postgres")
|
||||
}
|
||||
|
||||
// Setup
|
||||
db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
|
||||
|
||||
+2
-249
@@ -33,7 +33,6 @@ import (
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/scaletest/agentconn"
|
||||
"github.com/coder/coder/v2/scaletest/autostart"
|
||||
"github.com/coder/coder/v2/scaletest/createusers"
|
||||
"github.com/coder/coder/v2/scaletest/createworkspaces"
|
||||
"github.com/coder/coder/v2/scaletest/dashboard"
|
||||
@@ -58,13 +57,9 @@ func (r *RootCmd) scaletestCmd() *serpent.Command {
|
||||
Children: []*serpent.Command{
|
||||
r.scaletestCleanup(),
|
||||
r.scaletestDashboard(),
|
||||
r.scaletestDynamicParameters(),
|
||||
r.scaletestCreateWorkspaces(),
|
||||
r.scaletestWorkspaceUpdates(),
|
||||
r.scaletestWorkspaceTraffic(),
|
||||
r.scaletestAutostart(),
|
||||
r.scaletestNotifications(),
|
||||
r.scaletestSMTP(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1687,239 +1682,6 @@ func (r *RootCmd) scaletestDashboard() *serpent.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
const (
|
||||
autostartTestName = "autostart"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
var (
|
||||
workspaceCount int64
|
||||
workspaceJobTimeout time.Duration
|
||||
autostartDelay time.Duration
|
||||
autostartTimeout time.Duration
|
||||
template string
|
||||
noCleanup bool
|
||||
|
||||
parameterFlags workspaceParameterFlags
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "autostart",
|
||||
Short: "Replicate a thundering herd of autostarting workspaces",
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...) // Checked later.
|
||||
defer stop()
|
||||
ctx = notifyCtx
|
||||
|
||||
me, err := requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if workspaceCount <= 0 {
|
||||
return xerrors.Errorf("--workspace-count must be greater than zero")
|
||||
}
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags")
|
||||
}
|
||||
|
||||
tpl, err := parseTemplate(ctx, client, me.OrganizationIDs, template)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse template: %w", err)
|
||||
}
|
||||
|
||||
cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("can't parse given parameter values: %w", err)
|
||||
}
|
||||
|
||||
richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
|
||||
Action: WorkspaceCreate,
|
||||
TemplateVersionID: tpl.ActiveVersionID,
|
||||
|
||||
RichParameterFile: parameterFlags.richParameterFile,
|
||||
RichParameters: cliRichParameters,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("prepare build: %w", err)
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := autostart.NewMetrics(reg)
|
||||
|
||||
setupBarrier := new(sync.WaitGroup)
|
||||
setupBarrier.Add(int(workspaceCount))
|
||||
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
for i := range workspaceCount {
|
||||
id := strconv.Itoa(int(i))
|
||||
config := autostart.Config{
|
||||
User: createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
},
|
||||
Workspace: workspacebuild.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
Request: codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: tpl.ID,
|
||||
RichParameterValues: richParameters,
|
||||
},
|
||||
},
|
||||
WorkspaceJobTimeout: workspaceJobTimeout,
|
||||
AutostartDelay: autostartDelay,
|
||||
AutostartTimeout: autostartTimeout,
|
||||
Metrics: metrics,
|
||||
SetupBarrier: setupBarrier,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
var runner harness.Runnable = autostart.NewRunner(client, config)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: fmt.Sprintf("%s/%s", autostartTestName, id),
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
th.AddRun(autostartTestName, id, runner)
|
||||
}
|
||||
|
||||
logger := inv.Logger
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
defer func() {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
// Wait for prometheus metrics to be scraped
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Running autostart load test...")
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
err = th.Run(testCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
// If the command was interrupted, skip stats.
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !noCleanup {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up...")
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "workspace-count",
|
||||
FlagShorthand: "c",
|
||||
Env: "CODER_SCALETEST_WORKSPACE_COUNT",
|
||||
Description: "Required: Total number of workspaces to create.",
|
||||
Value: serpent.Int64Of(&workspaceCount),
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Flag: "workspace-job-timeout",
|
||||
Env: "CODER_SCALETEST_WORKSPACE_JOB_TIMEOUT",
|
||||
Default: "5m",
|
||||
Description: "Timeout for workspace jobs (e.g. build, start).",
|
||||
Value: serpent.DurationOf(&workspaceJobTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "autostart-delay",
|
||||
Env: "CODER_SCALETEST_AUTOSTART_DELAY",
|
||||
Default: "2m",
|
||||
Description: "How long after all the workspaces have been stopped to schedule them to be started again.",
|
||||
Value: serpent.DurationOf(&autostartDelay),
|
||||
},
|
||||
{
|
||||
Flag: "autostart-timeout",
|
||||
Env: "CODER_SCALETEST_AUTOSTART_TIMEOUT",
|
||||
Default: "5m",
|
||||
Description: "Timeout for the autostart build to be initiated after the scheduled start time.",
|
||||
Value: serpent.DurationOf(&autostartTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "template",
|
||||
FlagShorthand: "t",
|
||||
Env: "CODER_SCALETEST_TEMPLATE",
|
||||
Description: "Required: Name or ID of the template to use for workspaces.",
|
||||
Value: serpent.StringOf(&template),
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Flag: "no-cleanup",
|
||||
Env: "CODER_SCALETEST_NO_CLEANUP",
|
||||
Description: "Do not clean up resources after the test completes.",
|
||||
Value: serpent.BoolOf(&noCleanup),
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
type runnableTraceWrapper struct {
|
||||
tracer trace.Tracer
|
||||
spanName string
|
||||
@@ -1929,9 +1691,8 @@ type runnableTraceWrapper struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ harness.Runnable = &runnableTraceWrapper{}
|
||||
_ harness.Cleanable = &runnableTraceWrapper{}
|
||||
_ harness.Collectable = &runnableTraceWrapper{}
|
||||
_ harness.Runnable = &runnableTraceWrapper{}
|
||||
_ harness.Cleanable = &runnableTraceWrapper{}
|
||||
)
|
||||
|
||||
func (r *runnableTraceWrapper) Run(ctx context.Context, id string, logs io.Writer) error {
|
||||
@@ -1973,14 +1734,6 @@ func (r *runnableTraceWrapper) Cleanup(ctx context.Context, id string, logs io.W
|
||||
return c.Cleanup(ctx, id, logs)
|
||||
}
|
||||
|
||||
func (r *runnableTraceWrapper) GetMetrics() map[string]any {
|
||||
c, ok := r.runner.(harness.Collectable)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return c.GetMetrics()
|
||||
}
|
||||
|
||||
func getScaletestWorkspaces(ctx context.Context, client *codersdk.Client, owner, template string) ([]codersdk.Workspace, int, error) {
|
||||
var (
|
||||
pageNumber = 0
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/serpent"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/scaletest/dynamicparameters"
|
||||
"github.com/coder/coder/v2/scaletest/harness"
|
||||
)
|
||||
|
||||
const (
|
||||
dynamicParametersTestName = "dynamic-parameters"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestDynamicParameters() *serpent.Command {
|
||||
var (
|
||||
templateName string
|
||||
provisionerTags []string
|
||||
numEvals int64
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
// This test requires unlimited concurrency
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
)
|
||||
orgContext := NewOrganizationContext()
|
||||
output := &scaletestOutputFlags{}
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "dynamic-parameters",
|
||||
Short: "Generates load on the Coder server evaluating dynamic parameters",
|
||||
Long: `It is recommended that all rate limits are disabled on the server before running this scaletest. This test generates many login events which will be rate limited against the (most likely single) IP.`,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags")
|
||||
}
|
||||
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if templateName == "" {
|
||||
return xerrors.Errorf("template cannot be empty")
|
||||
}
|
||||
|
||||
tags, err := ParseProvisionerTags(provisionerTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
org, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := dynamicparameters.NewMetrics(reg, "concurrent_evaluations")
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelDebug)
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Allow time for traces to flush even if command context is
|
||||
// canceled. This is a no-op if tracing is not enabled.
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
// Wait for prometheus metrics to be scraped
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
partitions, err := dynamicparameters.SetupPartitions(ctx, client, org.ID, templateName, tags, numEvals, logger)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("setup dynamic parameters partitions: %w", err)
|
||||
}
|
||||
|
||||
th := harness.NewTestHarness(
|
||||
timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}),
|
||||
// there is no cleanup since it's just a connection that we sever.
|
||||
nil)
|
||||
|
||||
for i, part := range partitions {
|
||||
for j := range part.ConcurrentEvaluations {
|
||||
cfg := dynamicparameters.Config{
|
||||
TemplateVersion: part.TemplateVersion.ID,
|
||||
Metrics: metrics,
|
||||
MetricLabelValues: []string{fmt.Sprintf("%d", part.ConcurrentEvaluations)},
|
||||
}
|
||||
var runner harness.Runnable = dynamicparameters.NewRunner(client, cfg)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: fmt.Sprintf("%s/%d/%d", dynamicParametersTestName, i, j),
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
th.AddRun(dynamicParametersTestName, fmt.Sprintf("%d/%d", j, i), runner)
|
||||
}
|
||||
}
|
||||
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
err = th.Run(testCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness: %w", err)
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "template",
|
||||
Description: "Name of the template to use. If it does not exist, it will be created.",
|
||||
Default: "scaletest-dynamic-parameters",
|
||||
Value: serpent.StringOf(&templateName),
|
||||
},
|
||||
{
|
||||
Flag: "concurrent-evaluations",
|
||||
Description: "Number of concurrent dynamic parameter evaluations to perform.",
|
||||
Default: "100",
|
||||
Value: serpent.Int64Of(&numEvals),
|
||||
},
|
||||
{
|
||||
Flag: "provisioner-tag",
|
||||
Description: "Specify a set of tags to target provisioner daemons.",
|
||||
Value: serpent.StringArrayOf(&provisionerTags),
|
||||
},
|
||||
}
|
||||
orgContext.AttachOptions(cmd)
|
||||
output.attach(&cmd.Options)
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
notificationsLib "github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/scaletest/createusers"
|
||||
"github.com/coder/coder/v2/scaletest/harness"
|
||||
"github.com/coder/coder/v2/scaletest/notifications"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestNotifications() *serpent.Command {
|
||||
var (
|
||||
userCount int64
|
||||
ownerUserPercentage float64
|
||||
notificationTimeout time.Duration
|
||||
dialTimeout time.Duration
|
||||
noCleanup bool
|
||||
smtpAPIURL string
|
||||
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
|
||||
// This test requires unlimited concurrency.
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "notifications",
|
||||
Short: "Simulate notification delivery by creating many users listening to notifications.",
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
|
||||
defer stop()
|
||||
ctx = notifyCtx
|
||||
|
||||
me, err := requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if userCount <= 0 {
|
||||
return xerrors.Errorf("--user-count must be greater than 0")
|
||||
}
|
||||
|
||||
if ownerUserPercentage < 0 || ownerUserPercentage > 100 {
|
||||
return xerrors.Errorf("--owner-user-percentage must be between 0 and 100")
|
||||
}
|
||||
|
||||
if smtpAPIURL != "" && !strings.HasPrefix(smtpAPIURL, "http://") && !strings.HasPrefix(smtpAPIURL, "https://") {
|
||||
return xerrors.Errorf("--smtp-api-url must start with http:// or https://")
|
||||
}
|
||||
|
||||
ownerUserCount := int64(float64(userCount) * ownerUserPercentage / 100)
|
||||
if ownerUserCount == 0 && ownerUserPercentage > 0 {
|
||||
ownerUserCount = 1
|
||||
}
|
||||
regularUserCount := userCount - ownerUserCount
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Distribution plan:\n")
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Total users: %d\n", userCount)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Owner users: %d (%.1f%%)\n", ownerUserCount, ownerUserPercentage)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Regular users: %d (%.1f%%)\n", regularUserCount, 100.0-ownerUserPercentage)
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags")
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := notifications.NewMetrics(reg)
|
||||
|
||||
logger := inv.Logger
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
defer func() {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
// Wait for prometheus metrics to be scraped
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Creating users...")
|
||||
|
||||
dialBarrier := &sync.WaitGroup{}
|
||||
ownerWatchBarrier := &sync.WaitGroup{}
|
||||
dialBarrier.Add(int(userCount))
|
||||
ownerWatchBarrier.Add(int(ownerUserCount))
|
||||
|
||||
expectedNotificationIDs := map[uuid.UUID]struct{}{
|
||||
notificationsLib.TemplateUserAccountCreated: {},
|
||||
notificationsLib.TemplateUserAccountDeleted: {},
|
||||
}
|
||||
|
||||
triggerTimes := make(map[uuid.UUID]chan time.Time, len(expectedNotificationIDs))
|
||||
for id := range expectedNotificationIDs {
|
||||
triggerTimes[id] = make(chan time.Time, 1)
|
||||
}
|
||||
|
||||
configs := make([]notifications.Config, 0, userCount)
|
||||
for range ownerUserCount {
|
||||
config := notifications.Config{
|
||||
User: createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
},
|
||||
Roles: []string{codersdk.RoleOwner},
|
||||
NotificationTimeout: notificationTimeout,
|
||||
DialTimeout: dialTimeout,
|
||||
DialBarrier: dialBarrier,
|
||||
ReceivingWatchBarrier: ownerWatchBarrier,
|
||||
ExpectedNotificationsIDs: expectedNotificationIDs,
|
||||
Metrics: metrics,
|
||||
SMTPApiURL: smtpAPIURL,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
configs = append(configs, config)
|
||||
}
|
||||
for range regularUserCount {
|
||||
config := notifications.Config{
|
||||
User: createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
},
|
||||
Roles: []string{},
|
||||
NotificationTimeout: notificationTimeout,
|
||||
DialTimeout: dialTimeout,
|
||||
DialBarrier: dialBarrier,
|
||||
ReceivingWatchBarrier: ownerWatchBarrier,
|
||||
Metrics: metrics,
|
||||
SMTPApiURL: smtpAPIURL,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
configs = append(configs, config)
|
||||
}
|
||||
|
||||
go triggerUserNotifications(
|
||||
ctx,
|
||||
logger,
|
||||
client,
|
||||
me.OrganizationIDs[0],
|
||||
dialBarrier,
|
||||
dialTimeout,
|
||||
triggerTimes,
|
||||
)
|
||||
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
|
||||
for i, config := range configs {
|
||||
id := strconv.Itoa(i)
|
||||
name := fmt.Sprintf("notifications-%s", id)
|
||||
var runner harness.Runnable = notifications.NewRunner(client, config)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: name,
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
th.AddRun(name, id, runner)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Running notification delivery scaletest...")
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
err = th.Run(testCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
// If the command was interrupted, skip stats.
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
|
||||
if err := computeNotificationLatencies(ctx, logger, triggerTimes, res, metrics); err != nil {
|
||||
return xerrors.Errorf("compute notification latencies: %w", err)
|
||||
}
|
||||
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !noCleanup {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up...")
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "user-count",
|
||||
FlagShorthand: "c",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_USER_COUNT",
|
||||
Description: "Required: Total number of users to create.",
|
||||
Value: serpent.Int64Of(&userCount),
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Flag: "owner-user-percentage",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_OWNER_USER_PERCENTAGE",
|
||||
Default: "20.0",
|
||||
Description: "Percentage of users to assign Owner role to (0-100).",
|
||||
Value: serpent.Float64Of(&ownerUserPercentage),
|
||||
},
|
||||
{
|
||||
Flag: "notification-timeout",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_TIMEOUT",
|
||||
Default: "5m",
|
||||
Description: "How long to wait for notifications after triggering.",
|
||||
Value: serpent.DurationOf(¬ificationTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "dial-timeout",
|
||||
Env: "CODER_SCALETEST_DIAL_TIMEOUT",
|
||||
Default: "2m",
|
||||
Description: "Timeout for dialing the notification websocket endpoint.",
|
||||
Value: serpent.DurationOf(&dialTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "no-cleanup",
|
||||
Env: "CODER_SCALETEST_NO_CLEANUP",
|
||||
Description: "Do not clean up resources after the test completes.",
|
||||
Value: serpent.BoolOf(&noCleanup),
|
||||
},
|
||||
{
|
||||
Flag: "smtp-api-url",
|
||||
Env: "CODER_SCALETEST_SMTP_API_URL",
|
||||
Description: "SMTP mock HTTP API address.",
|
||||
Value: serpent.StringOf(&smtpAPIURL),
|
||||
},
|
||||
}
|
||||
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func computeNotificationLatencies(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
expectedNotifications map[uuid.UUID]chan time.Time,
|
||||
results harness.Results,
|
||||
metrics *notifications.Metrics,
|
||||
) error {
|
||||
triggerTimes := make(map[uuid.UUID]time.Time)
|
||||
for notificationID, triggerTimeChan := range expectedNotifications {
|
||||
select {
|
||||
case triggerTime := <-triggerTimeChan:
|
||||
triggerTimes[notificationID] = triggerTime
|
||||
logger.Info(ctx, "received trigger time",
|
||||
slog.F("notification_id", notificationID),
|
||||
slog.F("trigger_time", triggerTime))
|
||||
default:
|
||||
logger.Warn(ctx, "no trigger time received for notification",
|
||||
slog.F("notification_id", notificationID))
|
||||
}
|
||||
}
|
||||
|
||||
if len(triggerTimes) == 0 {
|
||||
logger.Warn(ctx, "no trigger times available, skipping latency computation")
|
||||
return nil
|
||||
}
|
||||
|
||||
var totalLatencies int
|
||||
for runID, runResult := range results.Runs {
|
||||
if runResult.Error != nil {
|
||||
logger.Debug(ctx, "skipping failed run for latency computation",
|
||||
slog.F("run_id", runID))
|
||||
continue
|
||||
}
|
||||
|
||||
if runResult.Metrics == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Process websocket notifications.
|
||||
if wsReceiptTimes, ok := runResult.Metrics[notifications.WebsocketNotificationReceiptTimeMetric].(map[uuid.UUID]time.Time); ok {
|
||||
for notificationID, receiptTime := range wsReceiptTimes {
|
||||
if triggerTime, ok := triggerTimes[notificationID]; ok {
|
||||
latency := receiptTime.Sub(triggerTime)
|
||||
metrics.RecordLatency(latency, notificationID.String(), notifications.NotificationTypeWebsocket)
|
||||
totalLatencies++
|
||||
logger.Debug(ctx, "computed websocket latency",
|
||||
slog.F("run_id", runID),
|
||||
slog.F("notification_id", notificationID),
|
||||
slog.F("latency", latency))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process SMTP notifications
|
||||
if smtpReceiptTimes, ok := runResult.Metrics[notifications.SMTPNotificationReceiptTimeMetric].(map[uuid.UUID]time.Time); ok {
|
||||
for notificationID, receiptTime := range smtpReceiptTimes {
|
||||
if triggerTime, ok := triggerTimes[notificationID]; ok {
|
||||
latency := receiptTime.Sub(triggerTime)
|
||||
metrics.RecordLatency(latency, notificationID.String(), notifications.NotificationTypeSMTP)
|
||||
totalLatencies++
|
||||
logger.Debug(ctx, "computed SMTP latency",
|
||||
slog.F("run_id", runID),
|
||||
slog.F("notification_id", notificationID),
|
||||
slog.F("latency", latency))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info(ctx, "finished computing notification latencies",
|
||||
slog.F("total_runs", results.TotalRuns),
|
||||
slog.F("total_latencies_computed", totalLatencies))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// triggerUserNotifications waits for all test users to connect,
|
||||
// then creates and deletes a test user to trigger notification events for testing.
|
||||
func triggerUserNotifications(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
client *codersdk.Client,
|
||||
orgID uuid.UUID,
|
||||
dialBarrier *sync.WaitGroup,
|
||||
dialTimeout time.Duration,
|
||||
expectedNotifications map[uuid.UUID]chan time.Time,
|
||||
) {
|
||||
logger.Info(ctx, "waiting for all users to connect")
|
||||
|
||||
// Wait for all users to connect
|
||||
waitCtx, cancel := context.WithTimeout(ctx, dialTimeout+30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
dialBarrier.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
logger.Info(ctx, "all users connected")
|
||||
case <-waitCtx.Done():
|
||||
if waitCtx.Err() == context.DeadlineExceeded {
|
||||
logger.Error(ctx, "timeout waiting for users to connect")
|
||||
} else {
|
||||
logger.Info(ctx, "context canceled while waiting for users")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const (
|
||||
triggerUsername = "scaletest-trigger-user"
|
||||
triggerEmail = "scaletest-trigger@example.com"
|
||||
)
|
||||
|
||||
logger.Info(ctx, "creating test user to test notifications",
|
||||
slog.F("username", triggerUsername),
|
||||
slog.F("email", triggerEmail),
|
||||
slog.F("org_id", orgID))
|
||||
|
||||
testUser, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
||||
OrganizationIDs: []uuid.UUID{orgID},
|
||||
Username: triggerUsername,
|
||||
Email: triggerEmail,
|
||||
Password: "test-password-123",
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "create test user", slog.Error(err))
|
||||
return
|
||||
}
|
||||
expectedNotifications[notificationsLib.TemplateUserAccountCreated] <- time.Now()
|
||||
|
||||
err = client.DeleteUser(ctx, testUser.ID)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "delete test user", slog.Error(err))
|
||||
return
|
||||
}
|
||||
expectedNotifications[notificationsLib.TemplateUserAccountDeleted] <- time.Now()
|
||||
close(expectedNotifications[notificationsLib.TemplateUserAccountCreated])
|
||||
close(expectedNotifications[notificationsLib.TemplateUserAccountDeleted])
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/v2/scaletest/smtpmock"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (*RootCmd) scaletestSMTP() *serpent.Command {
|
||||
var (
|
||||
hostAddress string
|
||||
smtpPort int64
|
||||
apiPort int64
|
||||
purgeAtCount int64
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Use: "smtp",
|
||||
Short: "Start a mock SMTP server for testing",
|
||||
Long: `Start a mock SMTP server with an HTTP API server that can be used to purge
|
||||
messages and get messages by email.`,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
|
||||
defer stop()
|
||||
ctx = notifyCtx
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelInfo)
|
||||
config := smtpmock.Config{
|
||||
HostAddress: hostAddress,
|
||||
SMTPPort: int(smtpPort),
|
||||
APIPort: int(apiPort),
|
||||
Logger: logger,
|
||||
}
|
||||
srv := new(smtpmock.Server)
|
||||
|
||||
if err := srv.Start(ctx, config); err != nil {
|
||||
return xerrors.Errorf("start mock SMTP server: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = srv.Stop()
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Mock SMTP server started on %s\n", srv.SMTPAddress())
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "HTTP API server started on %s\n", srv.APIAddress())
|
||||
if purgeAtCount > 0 {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, " Auto-purge when message count reaches %d\n", purgeAtCount)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "\nTotal messages received since last purge: %d\n", srv.MessageCount())
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
count := srv.MessageCount()
|
||||
if count > 0 {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Messages received: %d\n", count)
|
||||
}
|
||||
|
||||
if purgeAtCount > 0 && int64(count) >= purgeAtCount {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Message count (%d) reached threshold (%d). Purging...\n", count, purgeAtCount)
|
||||
srv.Purge()
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = []serpent.Option{
|
||||
{
|
||||
Flag: "host-address",
|
||||
Env: "CODER_SCALETEST_SMTP_HOST_ADDRESS",
|
||||
Default: "localhost",
|
||||
Description: "Host address to bind the mock SMTP and API servers.",
|
||||
Value: serpent.StringOf(&hostAddress),
|
||||
},
|
||||
{
|
||||
Flag: "smtp-port",
|
||||
Env: "CODER_SCALETEST_SMTP_PORT",
|
||||
Description: "Port for the mock SMTP server. Uses a random port if not specified.",
|
||||
Value: serpent.Int64Of(&smtpPort),
|
||||
},
|
||||
{
|
||||
Flag: "api-port",
|
||||
Env: "CODER_SCALETEST_SMTP_API_PORT",
|
||||
Description: "Port for the HTTP API server. Uses a random port if not specified.",
|
||||
Value: serpent.Int64Of(&apiPort),
|
||||
},
|
||||
{
|
||||
Flag: "purge-at-count",
|
||||
Env: "CODER_SCALETEST_SMTP_PURGE_AT_COUNT",
|
||||
Default: "100000",
|
||||
Description: "Maximum number of messages to keep before auto-purging. Set to 0 to disable.",
|
||||
Value: serpent.Int64Of(&purgeAtCount),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -29,28 +29,6 @@ func (r *RootCmd) taskCreate() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "create [input]",
|
||||
Short: "Create an experimental task",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Create a task with direct input",
|
||||
Command: "coder exp task create \"Add authentication to the user service\"",
|
||||
},
|
||||
Example{
|
||||
Description: "Create a task with stdin input",
|
||||
Command: "echo \"Add authentication to the user service\" | coder exp task create",
|
||||
},
|
||||
Example{
|
||||
Description: "Create a task with a specific name",
|
||||
Command: "coder exp task create --name task1 \"Add authentication to the user service\"",
|
||||
},
|
||||
Example{
|
||||
Description: "Create a task from a specific template / preset",
|
||||
Command: "coder exp task create --template backend-dev --preset \"My Preset\" \"Add authentication to the user service\"",
|
||||
},
|
||||
Example{
|
||||
Description: "Create a task for another user (requires appropriate permissions)",
|
||||
Command: "coder exp task create --owner user@example.com \"Add authentication to the user service\"",
|
||||
},
|
||||
),
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireRangeArgs(0, 1),
|
||||
),
|
||||
|
||||
+34
-24
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/pretty"
|
||||
@@ -18,20 +19,6 @@ func (r *RootCmd) taskDelete() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "delete <task> [<task> ...]",
|
||||
Short: "Delete experimental tasks",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Delete a single task.",
|
||||
Command: "$ coder exp task delete task1",
|
||||
},
|
||||
Example{
|
||||
Description: "Delete multiple tasks.",
|
||||
Command: "$ coder exp task delete task1 task2 task3",
|
||||
},
|
||||
Example{
|
||||
Description: "Delete a task without confirmation.",
|
||||
Command: "$ coder exp task delete task4 --yes",
|
||||
},
|
||||
),
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireRangeArgs(1, -1),
|
||||
),
|
||||
@@ -46,19 +33,43 @@ func (r *RootCmd) taskDelete() *serpent.Command {
|
||||
}
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
|
||||
var tasks []codersdk.Task
|
||||
type toDelete struct {
|
||||
ID uuid.UUID
|
||||
Owner string
|
||||
Display string
|
||||
}
|
||||
|
||||
var items []toDelete
|
||||
for _, identifier := range inv.Args {
|
||||
task, err := exp.TaskByIdentifier(ctx, identifier)
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if identifier == "" {
|
||||
return xerrors.New("task identifier cannot be empty or whitespace")
|
||||
}
|
||||
|
||||
// Check task identifier, try UUID first.
|
||||
if id, err := uuid.Parse(identifier); err == nil {
|
||||
task, err := exp.TaskByID(ctx, id)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task %q: %w", identifier, err)
|
||||
}
|
||||
display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
|
||||
items = append(items, toDelete{ID: id, Display: display, Owner: task.OwnerName})
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-UUID, treat as a workspace identifier (name or owner/name).
|
||||
ws, err := namedWorkspace(ctx, client, identifier)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task %q: %w", identifier, err)
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
display := ws.FullName()
|
||||
items = append(items, toDelete{ID: ws.ID, Display: display, Owner: ws.OwnerName})
|
||||
}
|
||||
|
||||
// Confirm deletion of the tasks.
|
||||
var displayList []string
|
||||
for _, task := range tasks {
|
||||
displayList = append(displayList, fmt.Sprintf("%s/%s", task.OwnerName, task.Name))
|
||||
for _, it := range items {
|
||||
displayList = append(displayList, it.Display)
|
||||
}
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Delete these tasks: %s?", pretty.Sprint(cliui.DefaultStyles.Code, strings.Join(displayList, ", "))),
|
||||
@@ -69,13 +80,12 @@ func (r *RootCmd) taskDelete() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, task := range tasks {
|
||||
display := displayList[i]
|
||||
if err := exp.DeleteTask(ctx, task.OwnerName, task.ID); err != nil {
|
||||
return xerrors.Errorf("delete task %q: %w", display, err)
|
||||
for _, item := range items {
|
||||
if err := exp.DeleteTask(ctx, item.Owner, item.ID); err != nil {
|
||||
return xerrors.Errorf("delete task %q: %w", item.Display, err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(
|
||||
inv.Stdout, "Deleted task "+pretty.Sprint(cliui.DefaultStyles.Keyword, display)+" at "+cliui.Timestamp(time.Now()),
|
||||
inv.Stdout, "Deleted task "+pretty.Sprint(cliui.DefaultStyles.Keyword, item.Display)+" at "+cliui.Timestamp(time.Now()),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+17
-41
@@ -56,18 +56,12 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
taskID := uuid.MustParse(id1)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/exists":
|
||||
c.nameResolves.Add(1)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{{
|
||||
ID: taskID,
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
}},
|
||||
Count: 1,
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{
|
||||
ID: taskID,
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id1:
|
||||
c.deleteCalls.Add(1)
|
||||
@@ -110,18 +104,12 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
firstID := uuid.MustParse(id3)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/first":
|
||||
c.nameResolves.Add(1)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{{
|
||||
ID: firstID,
|
||||
Name: "first",
|
||||
OwnerName: "me",
|
||||
}},
|
||||
Count: 1,
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{
|
||||
ID: firstID,
|
||||
Name: "first",
|
||||
OwnerName: "me",
|
||||
})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id4:
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
|
||||
@@ -151,14 +139,8 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
buildHandler: func(_ *testCounters) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{},
|
||||
Count: 0,
|
||||
})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/doesnotexist":
|
||||
httpapi.ResourceNotFound(w)
|
||||
default:
|
||||
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
|
||||
}
|
||||
@@ -174,18 +156,12 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
taskID := uuid.MustParse(id5)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/bad":
|
||||
c.nameResolves.Add(1)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{{
|
||||
ID: taskID,
|
||||
Name: "bad",
|
||||
OwnerName: "me",
|
||||
}},
|
||||
Count: 1,
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{
|
||||
ID: taskID,
|
||||
Name: "bad",
|
||||
OwnerName: "me",
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id5:
|
||||
httpapi.InternalServerError(w, xerrors.New("boom"))
|
||||
|
||||
+5
-28
@@ -8,7 +8,6 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
@@ -68,30 +67,8 @@ func (r *RootCmd) taskList() *serpent.Command {
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "list",
|
||||
Short: "List experimental tasks",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "List tasks for the current user.",
|
||||
Command: "coder exp task list",
|
||||
},
|
||||
Example{
|
||||
Description: "List tasks for a specific user.",
|
||||
Command: "coder exp task list --user someone-else",
|
||||
},
|
||||
Example{
|
||||
Description: "List all tasks you can view.",
|
||||
Command: "coder exp task list --all",
|
||||
},
|
||||
Example{
|
||||
Description: "List all your running tasks.",
|
||||
Command: "coder exp task list --status running",
|
||||
},
|
||||
Example{
|
||||
Description: "As above, but only show IDs.",
|
||||
Command: "coder exp task list --status running --quiet",
|
||||
},
|
||||
),
|
||||
Use: "list",
|
||||
Short: "List experimental tasks",
|
||||
Aliases: []string{"ls"},
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
@@ -99,10 +76,10 @@ func (r *RootCmd) taskList() *serpent.Command {
|
||||
Options: serpent.OptionSet{
|
||||
{
|
||||
Name: "status",
|
||||
Description: "Filter by task status.",
|
||||
Description: "Filter by task status (e.g. running, failed, etc).",
|
||||
Flag: "status",
|
||||
Default: "",
|
||||
Value: serpent.EnumOf(&statusFilter, slice.ToStrings(codersdk.AllTaskStatuses())...),
|
||||
Value: serpent.StringOf(&statusFilter),
|
||||
},
|
||||
{
|
||||
Name: "all",
|
||||
@@ -144,7 +121,7 @@ func (r *RootCmd) taskList() *serpent.Command {
|
||||
|
||||
tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{
|
||||
Owner: targetUser,
|
||||
Status: codersdk.TaskStatus(statusFilter),
|
||||
Status: statusFilter,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("list tasks: %w", err)
|
||||
|
||||
+15
-36
@@ -22,7 +22,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
@@ -30,7 +29,7 @@ import (
|
||||
)
|
||||
|
||||
// makeAITask creates an AI-task workspace.
|
||||
func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UUID, transition database.WorkspaceTransition, prompt string) database.Task {
|
||||
func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UUID, transition database.WorkspaceTransition, prompt string) (workspace database.WorkspaceTable) {
|
||||
t.Helper()
|
||||
|
||||
tv := dbfake.TemplateVersion(t, db).
|
||||
@@ -92,27 +91,7 @@ func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UU
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a task record in the tasks table for the new data model.
|
||||
task := dbgen.Task(t, db, database.TaskTable{
|
||||
OrganizationID: orgID,
|
||||
OwnerID: ownerID,
|
||||
Name: build.Workspace.Name,
|
||||
WorkspaceID: uuid.NullUUID{UUID: build.Workspace.ID, Valid: true},
|
||||
TemplateVersionID: tv.TemplateVersion.ID,
|
||||
TemplateParameters: []byte("{}"),
|
||||
Prompt: prompt,
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
|
||||
// Link the task to the workspace app.
|
||||
dbgen.TaskWorkspaceApp(t, db, database.TaskWorkspaceApp{
|
||||
TaskID: task.ID,
|
||||
WorkspaceBuildNumber: build.Build.BuildNumber,
|
||||
WorkspaceAgentID: uuid.NullUUID{UUID: agentID, Valid: true},
|
||||
WorkspaceAppID: uuid.NullUUID{UUID: app.ID, Valid: true},
|
||||
})
|
||||
|
||||
return task
|
||||
return build.Workspace
|
||||
}
|
||||
|
||||
func TestExpTaskList(t *testing.T) {
|
||||
@@ -149,7 +128,7 @@ func TestExpTaskList(t *testing.T) {
|
||||
memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
wantPrompt := "build me a web app"
|
||||
task := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, wantPrompt)
|
||||
ws := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, wantPrompt)
|
||||
|
||||
inv, root := clitest.New(t, "exp", "task", "list", "--column", "id,name,status,initial prompt")
|
||||
clitest.SetupConfig(t, memberClient, root)
|
||||
@@ -161,8 +140,8 @@ func TestExpTaskList(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate the table includes the task and status.
|
||||
pty.ExpectMatch(task.Name)
|
||||
pty.ExpectMatch("initializing")
|
||||
pty.ExpectMatch(ws.Name)
|
||||
pty.ExpectMatch("running")
|
||||
pty.ExpectMatch(wantPrompt)
|
||||
})
|
||||
|
||||
@@ -175,12 +154,12 @@ func TestExpTaskList(t *testing.T) {
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
// Create two AI tasks: one initializing, one paused.
|
||||
initializingTask := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me initializing")
|
||||
pausedTask := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please")
|
||||
// Create two AI tasks: one running, one stopped.
|
||||
running := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me running")
|
||||
stopped := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please")
|
||||
|
||||
// Use JSON output to reliably validate filtering.
|
||||
inv, root := clitest.New(t, "exp", "task", "list", "--status=paused", "--output=json")
|
||||
inv, root := clitest.New(t, "exp", "task", "list", "--status=stopped", "--output=json")
|
||||
clitest.SetupConfig(t, memberClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
@@ -194,10 +173,10 @@ func TestExpTaskList(t *testing.T) {
|
||||
var tasks []codersdk.Task
|
||||
require.NoError(t, json.Unmarshal(stdout.Bytes(), &tasks))
|
||||
|
||||
// Only the paused task is returned.
|
||||
// Only the stopped task is returned.
|
||||
require.Len(t, tasks, 1, "expected one task after filtering")
|
||||
require.Equal(t, pausedTask.ID, tasks[0].ID)
|
||||
require.NotEqual(t, initializingTask.ID, tasks[0].ID)
|
||||
require.Equal(t, stopped.ID, tasks[0].ID)
|
||||
require.NotEqual(t, running.ID, tasks[0].ID)
|
||||
})
|
||||
|
||||
t.Run("UserFlag_Me_Table", func(t *testing.T) {
|
||||
@@ -209,7 +188,7 @@ func TestExpTaskList(t *testing.T) {
|
||||
_, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
_ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "other-task")
|
||||
task := makeAITask(t, db, owner.OrganizationID, owner.UserID, owner.UserID, database.WorkspaceTransitionStart, "me-task")
|
||||
ws := makeAITask(t, db, owner.OrganizationID, owner.UserID, owner.UserID, database.WorkspaceTransitionStart, "me-task")
|
||||
|
||||
inv, root := clitest.New(t, "exp", "task", "list", "--user", "me")
|
||||
//nolint:gocritic // Owner client is intended here smoke test the member task not showing up.
|
||||
@@ -221,7 +200,7 @@ func TestExpTaskList(t *testing.T) {
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
pty.ExpectMatch(task.Name)
|
||||
pty.ExpectMatch(ws.Name)
|
||||
})
|
||||
|
||||
t.Run("Quiet", func(t *testing.T) {
|
||||
@@ -234,7 +213,7 @@ func TestExpTaskList(t *testing.T) {
|
||||
memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
// Given: We have two tasks
|
||||
task1 := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me active")
|
||||
task1 := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me running")
|
||||
task2 := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please")
|
||||
|
||||
// Given: We add the `--quiet` flag
|
||||
|
||||
+15
-12
@@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
@@ -25,11 +26,6 @@ func (r *RootCmd) taskLogs() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "logs <task>",
|
||||
Short: "Show a task's logs",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Show logs for a given task.",
|
||||
Command: "coder exp task logs task1",
|
||||
}),
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
),
|
||||
@@ -40,17 +36,24 @@ func (r *RootCmd) taskLogs() *serpent.Command {
|
||||
}
|
||||
|
||||
var (
|
||||
ctx = inv.Context()
|
||||
exp = codersdk.NewExperimentalClient(client)
|
||||
identifier = inv.Args[0]
|
||||
ctx = inv.Context()
|
||||
exp = codersdk.NewExperimentalClient(client)
|
||||
task = inv.Args[0]
|
||||
taskID uuid.UUID
|
||||
)
|
||||
|
||||
task, err := exp.TaskByIdentifier(ctx, identifier)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task %q: %w", identifier, err)
|
||||
if id, err := uuid.Parse(task); err == nil {
|
||||
taskID = id
|
||||
} else {
|
||||
ws, err := namedWorkspace(ctx, client, task)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task %q: %w", task, err)
|
||||
}
|
||||
|
||||
taskID = ws.ID
|
||||
}
|
||||
|
||||
logs, err := exp.TaskLogs(ctx, codersdk.Me, task.ID)
|
||||
logs, err := exp.TaskLogs(ctx, codersdk.Me, taskID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get task logs: %w", err)
|
||||
}
|
||||
|
||||
+163
-150
@@ -1,8 +1,11 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -11,10 +14,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
agentapisdk "github.com/coder/agentapi-sdk-go"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@@ -23,165 +23,178 @@ import (
|
||||
func Test_TaskLogs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testMessages := []agentapisdk.Message{
|
||||
var (
|
||||
clock = time.Date(2025, 8, 26, 12, 34, 56, 0, time.UTC)
|
||||
|
||||
taskID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
taskName = "task-workspace"
|
||||
|
||||
taskLogs = []codersdk.TaskLogEntry{
|
||||
{
|
||||
ID: 0,
|
||||
Content: "What is 1 + 1?",
|
||||
Type: codersdk.TaskLogTypeInput,
|
||||
Time: clock,
|
||||
},
|
||||
{
|
||||
ID: 1,
|
||||
Content: "2",
|
||||
Type: codersdk.TaskLogTypeOutput,
|
||||
Time: clock.Add(1 * time.Second),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
args []string
|
||||
expectTable string
|
||||
expectLogs []codersdk.TaskLogEntry
|
||||
expectError string
|
||||
handler func(t *testing.T, ctx context.Context) http.HandlerFunc
|
||||
}{
|
||||
{
|
||||
Id: 0,
|
||||
Role: agentapisdk.RoleUser,
|
||||
Content: "What is 1 + 1?",
|
||||
Time: time.Now().Add(-2 * time.Minute),
|
||||
args: []string{taskName, "--output", "json"},
|
||||
expectLogs: taskLogs,
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/v2/users/me/workspace/%s", taskName):
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: taskID,
|
||||
})
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", taskID.String()):
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.TaskLogsResponse{
|
||||
Logs: taskLogs,
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: 1,
|
||||
Role: agentapisdk.RoleAgent,
|
||||
Content: "2",
|
||||
Time: time.Now().Add(-1 * time.Minute),
|
||||
args: []string{taskID.String(), "--output", "json"},
|
||||
expectLogs: taskLogs,
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", taskID.String()):
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.TaskLogsResponse{
|
||||
Logs: taskLogs,
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{taskID.String()},
|
||||
expectTable: `
|
||||
TYPE CONTENT
|
||||
input What is 1 + 1?
|
||||
output 2`,
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", taskID.String()):
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.TaskLogsResponse{
|
||||
Logs: taskLogs,
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{"doesnotexist"},
|
||||
expectError: httpapi.ResourceNotFoundResponse.Message,
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users/me/workspace/doesnotexist":
|
||||
httpapi.ResourceNotFound(w)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{uuid.Nil.String()}, // uuid does not exist
|
||||
expectError: httpapi.ResourceNotFoundResponse.Message,
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", uuid.Nil.String()):
|
||||
httpapi.ResourceNotFound(w)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{"err-fetching-logs"},
|
||||
expectError: assert.AnError.Error(),
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users/me/workspace/err-fetching-logs":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: taskID,
|
||||
})
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", taskID.String()):
|
||||
httpapi.InternalServerError(w, assert.AnError)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("ByTaskName_JSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
for _, tt := range tests {
|
||||
t.Run(strings.Join(tt.args, ","), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
userClient := client // user already has access to their own workspace
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
srv = httptest.NewServer(tt.handler(t, ctx))
|
||||
client = codersdk.New(testutil.MustURL(t, srv.URL))
|
||||
args = []string{"exp", "task", "logs"}
|
||||
stdout strings.Builder
|
||||
err error
|
||||
)
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "exp", "task", "logs", task.Name, "--output", "json")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
inv, root := clitest.New(t, append(args, tt.args...)...)
|
||||
inv.Stdout = &stdout
|
||||
inv.Stderr = &stdout
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
var logs []codersdk.TaskLogEntry
|
||||
err = json.NewDecoder(strings.NewReader(stdout.String())).Decode(&logs)
|
||||
require.NoError(t, err)
|
||||
err = inv.WithContext(ctx).Run()
|
||||
if tt.expectError == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.ErrorContains(t, err, tt.expectError)
|
||||
}
|
||||
|
||||
require.Len(t, logs, 2)
|
||||
require.Equal(t, "What is 1 + 1?", logs[0].Content)
|
||||
require.Equal(t, codersdk.TaskLogTypeInput, logs[0].Type)
|
||||
require.Equal(t, "2", logs[1].Content)
|
||||
require.Equal(t, codersdk.TaskLogTypeOutput, logs[1].Type)
|
||||
})
|
||||
if tt.expectTable != "" {
|
||||
if diff := tableDiff(tt.expectTable, stdout.String()); diff != "" {
|
||||
t.Errorf("unexpected output diff (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("ByTaskID_JSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
if tt.expectLogs != nil {
|
||||
var logs []codersdk.TaskLogEntry
|
||||
err = json.NewDecoder(strings.NewReader(stdout.String())).Decode(&logs)
|
||||
require.NoError(t, err)
|
||||
|
||||
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
userClient := client
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String(), "--output", "json")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
var logs []codersdk.TaskLogEntry
|
||||
err = json.NewDecoder(strings.NewReader(stdout.String())).Decode(&logs)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, logs, 2)
|
||||
require.Equal(t, "What is 1 + 1?", logs[0].Content)
|
||||
require.Equal(t, codersdk.TaskLogTypeInput, logs[0].Type)
|
||||
require.Equal(t, "2", logs[1].Content)
|
||||
require.Equal(t, codersdk.TaskLogTypeOutput, logs[1].Type)
|
||||
})
|
||||
|
||||
t.Run("ByTaskID_Table", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
userClient := client
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String())
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
output := stdout.String()
|
||||
require.Contains(t, output, "What is 1 + 1?")
|
||||
require.Contains(t, output, "2")
|
||||
require.Contains(t, output, "input")
|
||||
require.Contains(t, output, "output")
|
||||
})
|
||||
|
||||
t.Run("TaskNotFound_ByName", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "exp", "task", "logs", "doesnotexist")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, httpapi.ResourceNotFoundResponse.Message)
|
||||
})
|
||||
|
||||
t.Run("TaskNotFound_ByID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "exp", "task", "logs", uuid.Nil.String())
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, httpapi.ResourceNotFoundResponse.Message)
|
||||
})
|
||||
|
||||
t.Run("ErrorFetchingLogs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsErr(assert.AnError))
|
||||
userClient := client
|
||||
|
||||
inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String())
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.ErrorContains(t, err, assert.AnError.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func fakeAgentAPITaskLogsOK(messages []agentapisdk.Message) map[string]http.HandlerFunc {
|
||||
return map[string]http.HandlerFunc{
|
||||
"/messages": func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"messages": messages,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func fakeAgentAPITaskLogsErr(err error) map[string]http.HandlerFunc {
|
||||
return map[string]http.HandlerFunc{
|
||||
"/messages": func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
},
|
||||
assert.Equal(t, tt.expectLogs, logs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+17
-16
@@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -13,15 +14,8 @@ func (r *RootCmd) taskSend() *serpent.Command {
|
||||
var stdin bool
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "send <task> [<input> | --stdin]",
|
||||
Short: "Send input to a task",
|
||||
Long: FormatExamples(Example{
|
||||
Description: "Send direct input to a task.",
|
||||
Command: "coder exp task send task1 \"Please also add unit tests\"",
|
||||
}, Example{
|
||||
Description: "Send input from stdin to a task.",
|
||||
Command: "echo \"Please also add unit tests\" | coder exp task send task1 --stdin",
|
||||
}),
|
||||
Use: "send <task> [<input> | --stdin]",
|
||||
Short: "Send input to a task",
|
||||
Middleware: serpent.RequireRangeArgs(1, 2),
|
||||
Options: serpent.OptionSet{
|
||||
{
|
||||
@@ -38,11 +32,12 @@ func (r *RootCmd) taskSend() *serpent.Command {
|
||||
}
|
||||
|
||||
var (
|
||||
ctx = inv.Context()
|
||||
exp = codersdk.NewExperimentalClient(client)
|
||||
identifier = inv.Args[0]
|
||||
ctx = inv.Context()
|
||||
exp = codersdk.NewExperimentalClient(client)
|
||||
task = inv.Args[0]
|
||||
|
||||
taskInput string
|
||||
taskID uuid.UUID
|
||||
)
|
||||
|
||||
if stdin {
|
||||
@@ -60,12 +55,18 @@ func (r *RootCmd) taskSend() *serpent.Command {
|
||||
taskInput = inv.Args[1]
|
||||
}
|
||||
|
||||
task, err := exp.TaskByIdentifier(ctx, identifier)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task: %w", err)
|
||||
if id, err := uuid.Parse(task); err == nil {
|
||||
taskID = id
|
||||
} else {
|
||||
ws, err := namedWorkspace(ctx, client, task)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task: %w", err)
|
||||
}
|
||||
|
||||
taskID = ws.ID
|
||||
}
|
||||
|
||||
if err = exp.TaskSend(ctx, codersdk.Me, task.ID, codersdk.TaskSendRequest{Input: taskInput}); err != nil {
|
||||
if err = exp.TaskSend(ctx, codersdk.Me, taskID, codersdk.TaskSendRequest{Input: taskInput}); err != nil {
|
||||
return xerrors.Errorf("send input to task: %w", err)
|
||||
}
|
||||
|
||||
|
||||
+145
-143
@@ -1,171 +1,173 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
agentapisdk "github.com/coder/agentapi-sdk-go"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func Test_TaskSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ByTaskName_WithArgument", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
var (
|
||||
taskName = "task-workspace"
|
||||
taskID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
)
|
||||
|
||||
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
userClient := client
|
||||
tests := []struct {
|
||||
args []string
|
||||
stdin string
|
||||
expectError string
|
||||
handler func(t *testing.T, ctx context.Context) http.HandlerFunc
|
||||
}{
|
||||
{
|
||||
args: []string{taskName, "carry on with the task"},
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/v2/users/me/workspace/%s", taskName):
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: taskID,
|
||||
})
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/send", taskID.String()):
|
||||
var req codersdk.TaskSendRequest
|
||||
if !httpapi.Read(ctx, w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "exp", "task", "send", task.Name, "carry on with the task")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
assert.Equal(t, "carry on with the task", req.Input)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("ByTaskID_WithArgument", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
userClient := client
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "exp", "task", "send", task.ID.String(), "carry on with the task")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("ByTaskName_WithStdin", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
userClient := client
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "exp", "task", "send", task.Name, "--stdin")
|
||||
inv.Stdout = &stdout
|
||||
inv.Stdin = strings.NewReader("carry on with the task")
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("TaskNotFound_ByName", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "exp", "task", "send", "doesnotexist", "some task input")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, httpapi.ResourceNotFoundResponse.Message)
|
||||
})
|
||||
|
||||
t.Run("TaskNotFound_ByID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "exp", "task", "send", uuid.Nil.String(), "some task input")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, httpapi.ResourceNotFoundResponse.Message)
|
||||
})
|
||||
|
||||
t.Run("SendError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
userClient, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendErr(t, assert.AnError))
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "exp", "task", "send", task.Name, "some task input")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.ErrorContains(t, err, assert.AnError.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func fakeAgentAPITaskSendOK(t *testing.T, expectMessage, returnMessage string) map[string]http.HandlerFunc {
|
||||
return map[string]http.HandlerFunc{
|
||||
"/status": func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "stable",
|
||||
})
|
||||
httpapi.Write(ctx, w, http.StatusNoContent, nil)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"/message": func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
var msg agentapisdk.PostMessageParams
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, expectMessage, msg.Content)
|
||||
message := agentapisdk.Message{
|
||||
Id: 999,
|
||||
Role: agentapisdk.RoleAgent,
|
||||
Content: returnMessage,
|
||||
Time: time.Now(),
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(message)
|
||||
{
|
||||
args: []string{taskID.String(), "carry on with the task"},
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/send", taskID.String()):
|
||||
var req codersdk.TaskSendRequest
|
||||
if !httpapi.Read(ctx, w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "carry on with the task", req.Input)
|
||||
|
||||
httpapi.Write(ctx, w, http.StatusNoContent, nil)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{taskName, "--stdin"},
|
||||
stdin: "carry on with the task",
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/v2/users/me/workspace/%s", taskName):
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: taskID,
|
||||
})
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/send", taskID.String()):
|
||||
var req codersdk.TaskSendRequest
|
||||
if !httpapi.Read(ctx, w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "carry on with the task", req.Input)
|
||||
|
||||
httpapi.Write(ctx, w, http.StatusNoContent, nil)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{"doesnotexist", "some task input"},
|
||||
expectError: httpapi.ResourceNotFoundResponse.Message,
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users/me/workspace/doesnotexist":
|
||||
httpapi.ResourceNotFound(w)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{uuid.Nil.String(), "some task input"},
|
||||
expectError: httpapi.ResourceNotFoundResponse.Message,
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/send", uuid.Nil.String()):
|
||||
httpapi.ResourceNotFound(w)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{uuid.Nil.String(), "some task input"},
|
||||
expectError: assert.AnError.Error(),
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/send", uuid.Nil.String()):
|
||||
httpapi.InternalServerError(w, assert.AnError)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func fakeAgentAPITaskSendErr(t *testing.T, returnErr error) map[string]http.HandlerFunc {
|
||||
return map[string]http.HandlerFunc{
|
||||
"/status": func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "stable",
|
||||
})
|
||||
},
|
||||
"/message": func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
for _, tt := range tests {
|
||||
t.Run(strings.Join(tt.args, ","), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
srv = httptest.NewServer(tt.handler(t, ctx))
|
||||
client = codersdk.New(testutil.MustURL(t, srv.URL))
|
||||
args = []string{"exp", "task", "send"}
|
||||
err error
|
||||
)
|
||||
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
inv, root := clitest.New(t, append(args, tt.args...)...)
|
||||
inv.Stdin = strings.NewReader(tt.stdin)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
err = inv.WithContext(ctx).Run()
|
||||
if tt.expectError == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.ErrorContains(t, err, tt.expectError)
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(returnErr.Error()))
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+32
-33
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
@@ -43,17 +44,7 @@ func (r *RootCmd) taskStatus() *serpent.Command {
|
||||
watchIntervalArg time.Duration
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Short: "Show the status of a task.",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Show the status of a given task.",
|
||||
Command: "coder exp task status task1",
|
||||
},
|
||||
Example{
|
||||
Description: "Watch the status of a given task until it completes (idle or stopped).",
|
||||
Command: "coder exp task status task1 --watch",
|
||||
},
|
||||
),
|
||||
Short: "Show the status of a task.",
|
||||
Use: "status",
|
||||
Aliases: []string{"stat"},
|
||||
Options: serpent.OptionSet{
|
||||
@@ -83,10 +74,21 @@ func (r *RootCmd) taskStatus() *serpent.Command {
|
||||
}
|
||||
|
||||
ctx := i.Context()
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
ec := codersdk.NewExperimentalClient(client)
|
||||
identifier := i.Args[0]
|
||||
|
||||
task, err := exp.TaskByIdentifier(ctx, identifier)
|
||||
taskID, err := uuid.Parse(identifier)
|
||||
if err != nil {
|
||||
// Try to resolve the task as a named workspace
|
||||
// TODO: right now tasks are still "workspaces" under the hood.
|
||||
// We should update this once we have a proper task model.
|
||||
ws, err := namedWorkspace(ctx, client, identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskID = ws.ID
|
||||
}
|
||||
task, err := ec.TaskByID(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -107,7 +109,7 @@ func (r *RootCmd) taskStatus() *serpent.Command {
|
||||
// TODO: implement streaming updates instead of polling
|
||||
lastStatusRow := tsr
|
||||
for range t.C {
|
||||
task, err := exp.TaskByID(ctx, task.ID)
|
||||
task, err := ec.TaskByID(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -140,7 +142,7 @@ func (r *RootCmd) taskStatus() *serpent.Command {
|
||||
}
|
||||
|
||||
func taskWatchIsEnded(task codersdk.Task) bool {
|
||||
if task.WorkspaceStatus == codersdk.WorkspaceStatusStopped {
|
||||
if task.Status == codersdk.WorkspaceStatusStopped {
|
||||
return true
|
||||
}
|
||||
if task.WorkspaceAgentHealth == nil || !task.WorkspaceAgentHealth.Healthy {
|
||||
@@ -156,21 +158,28 @@ func taskWatchIsEnded(task codersdk.Task) bool {
|
||||
}
|
||||
|
||||
type taskStatusRow struct {
|
||||
codersdk.Task `table:"r,recursive_inline"`
|
||||
ChangedAgo string `json:"-" table:"state changed"`
|
||||
Healthy bool `json:"-" table:"healthy"`
|
||||
codersdk.Task `table:"-"`
|
||||
ChangedAgo string `json:"-" table:"state changed,default_sort"`
|
||||
Timestamp time.Time `json:"-" table:"-"`
|
||||
TaskStatus string `json:"-" table:"status"`
|
||||
Healthy bool `json:"-" table:"healthy"`
|
||||
TaskState string `json:"-" table:"state"`
|
||||
Message string `json:"-" table:"message"`
|
||||
}
|
||||
|
||||
func taskStatusRowEqual(r1, r2 taskStatusRow) bool {
|
||||
return r1.Status == r2.Status &&
|
||||
return r1.TaskStatus == r2.TaskStatus &&
|
||||
r1.Healthy == r2.Healthy &&
|
||||
taskStateEqual(r1.CurrentState, r2.CurrentState)
|
||||
r1.TaskState == r2.TaskState &&
|
||||
r1.Message == r2.Message
|
||||
}
|
||||
|
||||
func toStatusRow(task codersdk.Task) taskStatusRow {
|
||||
tsr := taskStatusRow{
|
||||
Task: task,
|
||||
ChangedAgo: time.Since(task.UpdatedAt).Truncate(time.Second).String() + " ago",
|
||||
Timestamp: task.UpdatedAt,
|
||||
TaskStatus: string(task.Status),
|
||||
}
|
||||
tsr.Healthy = task.WorkspaceAgentHealth != nil &&
|
||||
task.WorkspaceAgentHealth.Healthy &&
|
||||
@@ -180,19 +189,9 @@ func toStatusRow(task codersdk.Task) taskStatusRow {
|
||||
|
||||
if task.CurrentState != nil {
|
||||
tsr.ChangedAgo = time.Since(task.CurrentState.Timestamp).Truncate(time.Second).String() + " ago"
|
||||
tsr.Timestamp = task.CurrentState.Timestamp
|
||||
tsr.TaskState = string(task.CurrentState.State)
|
||||
tsr.Message = task.CurrentState.Message
|
||||
}
|
||||
return tsr
|
||||
}
|
||||
|
||||
func taskStateEqual(se1, se2 *codersdk.TaskStateEntry) bool {
|
||||
var s1, m1, s2, m2 string
|
||||
if se1 != nil {
|
||||
s1 = string(se1.State)
|
||||
m1 = se1.Message
|
||||
}
|
||||
if se2 != nil {
|
||||
s2 = string(se2.State)
|
||||
m2 = se2.Message
|
||||
}
|
||||
return s1 == s2 && m1 == m2
|
||||
}
|
||||
|
||||
+69
-150
@@ -36,17 +36,26 @@ func Test_TaskStatus(t *testing.T) {
|
||||
hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/experimental/tasks":
|
||||
if r.URL.Query().Get("q") == "owner:\"me\"" {
|
||||
httpapi.Write(ctx, w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{},
|
||||
Count: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "/api/v2/users/me/workspace/doesnotexist":
|
||||
httpapi.ResourceNotFound(w)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{"err-fetching-workspace"},
|
||||
expectError: assert.AnError.Error(),
|
||||
hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users/me/workspace/err-fetching-workspace":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
})
|
||||
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
|
||||
httpapi.InternalServerError(w, assert.AnError)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
@@ -55,45 +64,21 @@ func Test_TaskStatus(t *testing.T) {
|
||||
},
|
||||
{
|
||||
args: []string{"exists"},
|
||||
expectOutput: `STATE CHANGED STATUS HEALTHY STATE MESSAGE
|
||||
0s ago active true working Thinking furiously...`,
|
||||
expectOutput: `STATE CHANGED STATUS HEALTHY STATE MESSAGE
|
||||
0s ago running true working Thinking furiously...`,
|
||||
hf: func(ctx context.Context, now time.Time) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/experimental/tasks":
|
||||
if r.URL.Query().Get("q") == "owner:\"me\"" {
|
||||
httpapi.Write(ctx, w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
CurrentState: &codersdk.TaskStateEntry{
|
||||
State: codersdk.TaskStateWorking,
|
||||
Timestamp: now,
|
||||
Message: "Thinking furiously...",
|
||||
},
|
||||
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
|
||||
Healthy: true,
|
||||
},
|
||||
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
|
||||
Status: codersdk.TaskStatusActive,
|
||||
}},
|
||||
Count: 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "/api/v2/users/me/workspace/exists":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
})
|
||||
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Status: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
CurrentState: &codersdk.TaskStateEntry{
|
||||
State: codersdk.TaskStateWorking,
|
||||
Timestamp: now,
|
||||
@@ -103,9 +88,7 @@ func Test_TaskStatus(t *testing.T) {
|
||||
Healthy: true,
|
||||
},
|
||||
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
|
||||
Status: codersdk.TaskStatusActive,
|
||||
})
|
||||
return
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
@@ -114,77 +97,50 @@ func Test_TaskStatus(t *testing.T) {
|
||||
},
|
||||
{
|
||||
args: []string{"exists", "--watch"},
|
||||
expectOutput: `STATE CHANGED STATUS HEALTHY STATE MESSAGE
|
||||
5s ago pending true
|
||||
4s ago initializing true
|
||||
4s ago active true
|
||||
3s ago active true working Reticulating splines...
|
||||
2s ago active true complete Splines reticulated successfully!`,
|
||||
expectOutput: `
|
||||
STATE CHANGED STATUS HEALTHY STATE MESSAGE
|
||||
4s ago running true
|
||||
3s ago running true working Reticulating splines...
|
||||
2s ago running true complete Splines reticulated successfully!`,
|
||||
hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *http.Request) {
|
||||
var calls atomic.Int64
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer calls.Add(1)
|
||||
switch r.URL.Path {
|
||||
case "/api/experimental/tasks":
|
||||
if r.URL.Query().Get("q") == "owner:\"me\"" {
|
||||
// Return initial task state for --watch test
|
||||
httpapi.Write(ctx, w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusPending,
|
||||
CreatedAt: now.Add(-5 * time.Second),
|
||||
UpdatedAt: now.Add(-5 * time.Second),
|
||||
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
|
||||
Healthy: true,
|
||||
},
|
||||
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
|
||||
Status: codersdk.TaskStatusPending,
|
||||
}},
|
||||
Count: 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "/api/v2/users/me/workspace/exists":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
})
|
||||
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
|
||||
defer calls.Add(1)
|
||||
switch calls.Load() {
|
||||
case 0:
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now.Add(-5 * time.Second),
|
||||
UpdatedAt: now.Add(-4 * time.Second),
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Status: codersdk.WorkspaceStatusPending,
|
||||
CreatedAt: now.Add(-5 * time.Second),
|
||||
UpdatedAt: now.Add(-5 * time.Second),
|
||||
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
|
||||
Healthy: true,
|
||||
},
|
||||
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
|
||||
Status: codersdk.TaskStatusInitializing,
|
||||
})
|
||||
return
|
||||
case 1:
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now.Add(-5 * time.Second),
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Status: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now.Add(-5 * time.Second),
|
||||
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
|
||||
Healthy: true,
|
||||
},
|
||||
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
|
||||
UpdatedAt: now.Add(-4 * time.Second),
|
||||
Status: codersdk.TaskStatusActive,
|
||||
})
|
||||
return
|
||||
case 2:
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now.Add(-5 * time.Second),
|
||||
UpdatedAt: now.Add(-4 * time.Second),
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Status: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now.Add(-5 * time.Second),
|
||||
UpdatedAt: now.Add(-4 * time.Second),
|
||||
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
|
||||
Healthy: true,
|
||||
},
|
||||
@@ -194,15 +150,13 @@ func Test_TaskStatus(t *testing.T) {
|
||||
Timestamp: now.Add(-3 * time.Second),
|
||||
Message: "Reticulating splines...",
|
||||
},
|
||||
Status: codersdk.TaskStatusActive,
|
||||
})
|
||||
return
|
||||
case 3:
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now.Add(-5 * time.Second),
|
||||
UpdatedAt: now.Add(-4 * time.Second),
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Status: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now.Add(-5 * time.Second),
|
||||
UpdatedAt: now.Add(-4 * time.Second),
|
||||
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
|
||||
Healthy: true,
|
||||
},
|
||||
@@ -212,16 +166,13 @@ func Test_TaskStatus(t *testing.T) {
|
||||
Timestamp: now.Add(-2 * time.Second),
|
||||
Message: "Splines reticulated successfully!",
|
||||
},
|
||||
Status: codersdk.TaskStatusActive,
|
||||
})
|
||||
return
|
||||
default:
|
||||
httpapi.InternalServerError(w, xerrors.New("too many calls!"))
|
||||
return
|
||||
}
|
||||
default:
|
||||
httpapi.InternalServerError(w, xerrors.Errorf("unexpected path: %q", r.URL.Path))
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -232,24 +183,18 @@ func Test_TaskStatus(t *testing.T) {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"organization_id": "00000000-0000-0000-0000-000000000000",
|
||||
"owner_id": "00000000-0000-0000-0000-000000000000",
|
||||
"owner_name": "me",
|
||||
"name": "exists",
|
||||
"owner_name": "",
|
||||
"name": "",
|
||||
"template_id": "00000000-0000-0000-0000-000000000000",
|
||||
"template_version_id": "00000000-0000-0000-0000-000000000000",
|
||||
"template_name": "",
|
||||
"template_display_name": "",
|
||||
"template_icon": "",
|
||||
"workspace_id": null,
|
||||
"workspace_name": "",
|
||||
"workspace_status": "running",
|
||||
"workspace_agent_id": null,
|
||||
"workspace_agent_lifecycle": "ready",
|
||||
"workspace_agent_health": {
|
||||
"healthy": true
|
||||
},
|
||||
"workspace_app_id": null,
|
||||
"workspace_agent_lifecycle": null,
|
||||
"workspace_agent_health": null,
|
||||
"initial_prompt": "",
|
||||
"status": "active",
|
||||
"status": "running",
|
||||
"current_state": {
|
||||
"timestamp": "2025-08-26T12:34:57Z",
|
||||
"state": "working",
|
||||
@@ -259,52 +204,26 @@ func Test_TaskStatus(t *testing.T) {
|
||||
"created_at": "2025-08-26T12:34:56Z",
|
||||
"updated_at": "2025-08-26T12:34:56Z"
|
||||
}`,
|
||||
hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *http.Request) {
|
||||
hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) {
|
||||
ts := time.Date(2025, 8, 26, 12, 34, 56, 0, time.UTC)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/experimental/tasks":
|
||||
if r.URL.Query().Get("q") == "owner:\"me\"" {
|
||||
httpapi.Write(ctx, w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: ts,
|
||||
UpdatedAt: ts,
|
||||
CurrentState: &codersdk.TaskStateEntry{
|
||||
State: codersdk.TaskStateWorking,
|
||||
Timestamp: ts.Add(time.Second),
|
||||
Message: "Thinking furiously...",
|
||||
},
|
||||
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
|
||||
Healthy: true,
|
||||
},
|
||||
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
|
||||
Status: codersdk.TaskStatusActive,
|
||||
}},
|
||||
Count: 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "/api/v2/users/me/workspace/exists":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
})
|
||||
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: ts,
|
||||
UpdatedAt: ts,
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Status: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: ts,
|
||||
UpdatedAt: ts,
|
||||
CurrentState: &codersdk.TaskStateEntry{
|
||||
State: codersdk.TaskStateWorking,
|
||||
Timestamp: ts.Add(time.Second),
|
||||
Message: "Thinking furiously...",
|
||||
},
|
||||
Status: codersdk.TaskStatusActive,
|
||||
})
|
||||
return
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
agentapisdk "github.com/coder/agentapi-sdk-go"
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// This test performs an integration-style test for tasks functionality.
|
||||
//
|
||||
//nolint:tparallel // The sub-tests of this test must be run sequentially.
|
||||
func Test_Tasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: a template configured for tasks
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitLong)
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner = coderdtest.CreateFirstUser(t, client)
|
||||
userClient, _ = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
initMsg = agentapisdk.Message{
|
||||
Content: "test task input for " + t.Name(),
|
||||
Id: 0,
|
||||
Role: "user",
|
||||
Time: time.Now().UTC(),
|
||||
}
|
||||
authToken = uuid.NewString()
|
||||
echoAgentAPI = startFakeAgentAPI(t, fakeAgentAPIEcho(ctx, t, initMsg, "hello"))
|
||||
taskTpl = createAITaskTemplate(t, client, owner.OrganizationID, withAgentToken(authToken), withSidebarURL(echoAgentAPI.URL()))
|
||||
taskName = strings.ReplaceAll(testutil.GetRandomName(t), "_", "-")
|
||||
)
|
||||
|
||||
//nolint:paralleltest // The sub-tests of this test must be run sequentially.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmdArgs []string
|
||||
assertFn func(stdout string, userClient *codersdk.Client)
|
||||
}{
|
||||
{
|
||||
name: "create task",
|
||||
cmdArgs: []string{"exp", "task", "create", "test task input for " + t.Name(), "--name", taskName, "--template", taskTpl.Name},
|
||||
assertFn: func(stdout string, userClient *codersdk.Client) {
|
||||
require.Contains(t, stdout, taskName, "task name should be in output")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list tasks after create",
|
||||
cmdArgs: []string{"exp", "task", "list", "--output", "json"},
|
||||
assertFn: func(stdout string, userClient *codersdk.Client) {
|
||||
var tasks []codersdk.Task
|
||||
err := json.NewDecoder(strings.NewReader(stdout)).Decode(&tasks)
|
||||
require.NoError(t, err, "list output should unmarshal properly")
|
||||
require.Len(t, tasks, 1, "expected one task")
|
||||
require.Equal(t, taskName, tasks[0].Name, "task name should match")
|
||||
require.Equal(t, initMsg.Content, tasks[0].InitialPrompt, "initial prompt should match")
|
||||
require.True(t, tasks[0].WorkspaceID.Valid, "workspace should be created")
|
||||
// For the next test, we need to wait for the workspace to be healthy
|
||||
ws := coderdtest.MustWorkspace(t, userClient, tasks[0].WorkspaceID.UUID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken))
|
||||
_ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) {
|
||||
o.Client = agentClient
|
||||
})
|
||||
coderdtest.NewWorkspaceAgentWaiter(t, userClient, tasks[0].WorkspaceID.UUID).WithContext(ctx).WaitFor(coderdtest.AgentsReady)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get task status after create",
|
||||
cmdArgs: []string{"exp", "task", "status", taskName, "--output", "json"},
|
||||
assertFn: func(stdout string, userClient *codersdk.Client) {
|
||||
var task codersdk.Task
|
||||
require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&task), "should unmarshal task status")
|
||||
require.Equal(t, task.Name, taskName, "task name should match")
|
||||
require.Equal(t, codersdk.TaskStatusActive, task.Status, "task should be active")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send task message",
|
||||
cmdArgs: []string{"exp", "task", "send", taskName, "hello"},
|
||||
// Assertions for this happen in the fake agent API handler.
|
||||
},
|
||||
{
|
||||
name: "read task logs",
|
||||
cmdArgs: []string{"exp", "task", "logs", taskName, "--output", "json"},
|
||||
assertFn: func(stdout string, userClient *codersdk.Client) {
|
||||
var logs []codersdk.TaskLogEntry
|
||||
require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&logs), "should unmarshal task logs")
|
||||
require.Len(t, logs, 3, "should have 3 logs")
|
||||
require.Equal(t, logs[0].Content, initMsg.Content, "first message should be the init message")
|
||||
require.Equal(t, logs[0].Type, codersdk.TaskLogTypeInput, "first message should be an input")
|
||||
require.Equal(t, logs[1].Content, "hello", "second message should be the sent message")
|
||||
require.Equal(t, logs[1].Type, codersdk.TaskLogTypeInput, "second message should be an input")
|
||||
require.Equal(t, logs[2].Content, "hello", "third message should be the echoed message")
|
||||
require.Equal(t, logs[2].Type, codersdk.TaskLogTypeOutput, "third message should be an output")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete task",
|
||||
cmdArgs: []string{"exp", "task", "delete", taskName, "--yes"},
|
||||
assertFn: func(stdout string, userClient *codersdk.Client) {
|
||||
// The task should eventually no longer show up in the list of tasks
|
||||
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
|
||||
expClient := codersdk.NewExperimentalClient(userClient)
|
||||
tasks, err := expClient.Tasks(ctx, &codersdk.TasksFilter{})
|
||||
if !assert.NoError(t, err) {
|
||||
return false
|
||||
}
|
||||
return slices.IndexFunc(tasks, func(task codersdk.Task) bool {
|
||||
return task.Name == taskName
|
||||
}) == -1
|
||||
}, testutil.IntervalMedium)
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, tc.cmdArgs...)
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
require.NoError(t, inv.WithContext(ctx).Run())
|
||||
if tc.assertFn != nil {
|
||||
tc.assertFn(stdout.String(), userClient)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fakeAgentAPIEcho(ctx context.Context, t testing.TB, initMsg agentapisdk.Message, want ...string) map[string]http.HandlerFunc {
|
||||
t.Helper()
|
||||
var mmu sync.RWMutex
|
||||
msgs := []agentapisdk.Message{initMsg}
|
||||
wantCpy := make([]string, len(want))
|
||||
copy(wantCpy, want)
|
||||
t.Cleanup(func() {
|
||||
mmu.Lock()
|
||||
defer mmu.Unlock()
|
||||
if !t.Failed() {
|
||||
assert.Empty(t, wantCpy, "not all expected messages received: missing %v", wantCpy)
|
||||
}
|
||||
})
|
||||
writeAgentAPIError := func(w http.ResponseWriter, err error, status int) {
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(agentapisdk.ErrorModel{
|
||||
Errors: ptr.Ref([]agentapisdk.ErrorDetail{
|
||||
{
|
||||
Message: ptr.Ref(err.Error()),
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
return map[string]http.HandlerFunc{
|
||||
"/status": func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(agentapisdk.GetStatusResponse{
|
||||
Status: "stable",
|
||||
})
|
||||
},
|
||||
"/messages": func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
mmu.RLock()
|
||||
defer mmu.RUnlock()
|
||||
bs, err := json.Marshal(agentapisdk.GetMessagesResponse{
|
||||
Messages: msgs,
|
||||
})
|
||||
if err != nil {
|
||||
writeAgentAPIError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(bs)
|
||||
},
|
||||
"/message": func(w http.ResponseWriter, r *http.Request) {
|
||||
mmu.Lock()
|
||||
defer mmu.Unlock()
|
||||
var params agentapisdk.PostMessageParams
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
err := json.NewDecoder(r.Body).Decode(¶ms)
|
||||
if !assert.NoError(t, err, "decode message") {
|
||||
writeAgentAPIError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(wantCpy) == 0 {
|
||||
assert.Fail(t, "unexpected message", "received message %v, but no more expected messages", params)
|
||||
writeAgentAPIError(w, xerrors.New("no more expected messages"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
exp := wantCpy[0]
|
||||
wantCpy = wantCpy[1:]
|
||||
|
||||
if !assert.Equal(t, exp, params.Content, "message content mismatch") {
|
||||
writeAgentAPIError(w, xerrors.New("unexpected message content: expected "+exp+", got "+params.Content), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
msgs = append(msgs, agentapisdk.Message{
|
||||
Id: int64(len(msgs) + 1),
|
||||
Content: params.Content,
|
||||
Role: agentapisdk.RoleUser,
|
||||
Time: time.Now().UTC(),
|
||||
})
|
||||
msgs = append(msgs, agentapisdk.Message{
|
||||
Id: int64(len(msgs) + 1),
|
||||
Content: params.Content,
|
||||
Role: agentapisdk.RoleAgent,
|
||||
Time: time.Now().UTC(),
|
||||
})
|
||||
assert.NoError(t, json.NewEncoder(w).Encode(agentapisdk.PostMessageResponse{
|
||||
Ok: true,
|
||||
}))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// setupCLITaskTest creates a test workspace with an AI task template and agent,
|
||||
// with a fake agent API configured with the provided set of handlers.
|
||||
// Returns the user client and workspace.
|
||||
func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (*codersdk.Client, codersdk.Task) {
|
||||
t.Helper()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
fakeAPI := startFakeAgentAPI(t, agentAPIHandlers)
|
||||
|
||||
authToken := uuid.NewString()
|
||||
template := createAITaskTemplate(t, client, owner.OrganizationID, withSidebarURL(fakeAPI.URL()), withAgentToken(authToken))
|
||||
|
||||
wantPrompt := "test prompt"
|
||||
exp := codersdk.NewExperimentalClient(userClient)
|
||||
task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: wantPrompt,
|
||||
Name: "test-task",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for the task's underlying workspace to be built
|
||||
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
|
||||
workspace, err := userClient.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken))
|
||||
_ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) {
|
||||
o.Client = agentClient
|
||||
})
|
||||
|
||||
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).
|
||||
WaitFor(coderdtest.AgentsReady)
|
||||
|
||||
return userClient, task
|
||||
}
|
||||
|
||||
// createAITaskTemplate creates a template configured for AI tasks with a sidebar app.
|
||||
func createAITaskTemplate(t *testing.T, client *codersdk.Client, orgID uuid.UUID, opts ...aiTemplateOpt) codersdk.Template {
|
||||
t.Helper()
|
||||
|
||||
opt := aiTemplateOpts{
|
||||
authToken: uuid.NewString(),
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
taskAppID := uuid.New()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: []*proto.Response{
|
||||
{
|
||||
Type: &proto.Response_Plan{
|
||||
Plan: &proto.PlanComplete{
|
||||
Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}},
|
||||
HasAiTasks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ProvisionApply: []*proto.Response{
|
||||
{
|
||||
Type: &proto.Response_Apply{
|
||||
Apply: &proto.ApplyComplete{
|
||||
Resources: []*proto.Resource{
|
||||
{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{
|
||||
{
|
||||
Id: uuid.NewString(),
|
||||
Name: "example",
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: opt.authToken,
|
||||
},
|
||||
Apps: []*proto.App{
|
||||
{
|
||||
Id: taskAppID.String(),
|
||||
Slug: "task-sidebar",
|
||||
DisplayName: "Task Sidebar",
|
||||
Url: opt.appURL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AiTasks: []*proto.AITask{
|
||||
{
|
||||
SidebarApp: &proto.AITaskSidebarApp{
|
||||
Id: taskAppID.String(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, orgID, version.ID)
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
// fakeAgentAPI implements a fake AgentAPI HTTP server for testing.
|
||||
type fakeAgentAPI struct {
|
||||
t *testing.T
|
||||
server *httptest.Server
|
||||
handlers map[string]http.HandlerFunc
|
||||
called map[string]bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// startFakeAgentAPI starts an HTTP server that implements the AgentAPI endpoints.
|
||||
// handlers is a map of path -> handler function.
|
||||
func startFakeAgentAPI(t *testing.T, handlers map[string]http.HandlerFunc) *fakeAgentAPI {
|
||||
t.Helper()
|
||||
|
||||
fake := &fakeAgentAPI{
|
||||
t: t,
|
||||
handlers: handlers,
|
||||
called: make(map[string]bool),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Register all provided handlers with call tracking
|
||||
for path, handler := range handlers {
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
fake.mu.Lock()
|
||||
fake.called[path] = true
|
||||
fake.mu.Unlock()
|
||||
handler(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
knownEndpoints := []string{"/status", "/messages", "/message"}
|
||||
for _, endpoint := range knownEndpoints {
|
||||
if handlers[endpoint] == nil {
|
||||
endpoint := endpoint // capture loop variable
|
||||
mux.HandleFunc(endpoint, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected call to %s %s - no handler defined", r.Method, endpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
// Default handler for unknown endpoints should cause the test to fail.
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected call to %s %s - no handler defined", r.Method, r.URL.Path)
|
||||
})
|
||||
|
||||
fake.server = httptest.NewServer(mux)
|
||||
|
||||
// Register cleanup to check that all defined handlers were called
|
||||
t.Cleanup(func() {
|
||||
fake.server.Close()
|
||||
fake.mu.Lock()
|
||||
for path := range handlers {
|
||||
if !fake.called[path] {
|
||||
t.Errorf("handler for %s was defined but never called", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
return fake
|
||||
}
|
||||
|
||||
func (f *fakeAgentAPI) URL() string {
|
||||
return f.server.URL
|
||||
}
|
||||
|
||||
type aiTemplateOpts struct {
|
||||
appURL string
|
||||
authToken string
|
||||
}
|
||||
|
||||
type aiTemplateOpt func(*aiTemplateOpts)
|
||||
|
||||
func withSidebarURL(url string) aiTemplateOpt {
|
||||
return func(o *aiTemplateOpts) { o.appURL = url }
|
||||
}
|
||||
|
||||
func withAgentToken(token string) aiTemplateOpt {
|
||||
return func(o *aiTemplateOpts) { o.authToken = token }
|
||||
}
|
||||
+4
-21
@@ -43,9 +43,8 @@ func (r *RootCmd) provisionerJobsList() *serpent.Command {
|
||||
cliui.TableFormat([]provisionerJobRow{}, []string{"created at", "id", "type", "template display name", "status", "queue", "tags"}),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
status []string
|
||||
limit int64
|
||||
initiator string
|
||||
status []string
|
||||
limit int64
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
@@ -66,18 +65,9 @@ func (r *RootCmd) provisionerJobsList() *serpent.Command {
|
||||
return xerrors.Errorf("current organization: %w", err)
|
||||
}
|
||||
|
||||
if initiator != "" {
|
||||
user, err := client.User(ctx, initiator)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("initiator not found: %s", initiator)
|
||||
}
|
||||
initiator = user.ID.String()
|
||||
}
|
||||
|
||||
jobs, err := client.OrganizationProvisionerJobs(ctx, org.ID, &codersdk.OrganizationProvisionerJobsOptions{
|
||||
Status: slice.StringEnums[codersdk.ProvisionerJobStatus](status),
|
||||
Limit: int(limit),
|
||||
Initiator: initiator,
|
||||
Status: slice.StringEnums[codersdk.ProvisionerJobStatus](status),
|
||||
Limit: int(limit),
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("list provisioner jobs: %w", err)
|
||||
@@ -132,13 +122,6 @@ func (r *RootCmd) provisionerJobsList() *serpent.Command {
|
||||
Default: "50",
|
||||
Value: serpent.Int64Of(&limit),
|
||||
},
|
||||
{
|
||||
Flag: "initiator",
|
||||
FlagShorthand: "i",
|
||||
Env: "CODER_PROVISIONER_JOB_LIST_INITIATOR",
|
||||
Description: "Filter by initiator (user ID or username).",
|
||||
Value: serpent.StringOf(&initiator),
|
||||
},
|
||||
}...)
|
||||
|
||||
orgContext.AttachOptions(cmd)
|
||||
|
||||
+24
-168
@@ -5,7 +5,6 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -27,32 +26,33 @@ import (
|
||||
func TestProvisionerJobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
client, _, coderdAPI := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: false,
|
||||
Database: db,
|
||||
Pubsub: ps,
|
||||
})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
templateAdminClient, templateAdmin := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(owner.OrganizationID))
|
||||
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
// These CLI tests are related to provisioner job CRUD operations and as such
|
||||
// do not require the overhead of starting a provisioner. Other provisioner job
|
||||
// functionalities (acquisition etc.) are tested elsewhere.
|
||||
template := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
CreatedBy: owner.UserID,
|
||||
AllowUserCancelWorkspaceJobs: true,
|
||||
})
|
||||
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
CreatedBy: owner.UserID,
|
||||
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
|
||||
})
|
||||
|
||||
t.Run("Cancel", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
client, _, coderdAPI := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: false,
|
||||
Database: db,
|
||||
Pubsub: ps,
|
||||
})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
templateAdminClient, templateAdmin := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(owner.OrganizationID))
|
||||
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
// These CLI tests are related to provisioner job CRUD operations and as such
|
||||
// do not require the overhead of starting a provisioner. Other provisioner job
|
||||
// functionalities (acquisition etc.) are tested elsewhere.
|
||||
template := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
CreatedBy: owner.UserID,
|
||||
AllowUserCancelWorkspaceJobs: true,
|
||||
})
|
||||
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
CreatedBy: owner.UserID,
|
||||
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
|
||||
})
|
||||
// Test helper to create a provisioner job of a given type with a given input.
|
||||
prepareJob := func(t *testing.T, jobType database.ProvisionerJobType, input json.RawMessage) database.ProvisionerJob {
|
||||
t.Helper()
|
||||
@@ -178,148 +178,4 @@ func TestProvisionerJobs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("List", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
client, _, coderdAPI := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: false,
|
||||
Database: db,
|
||||
Pubsub: ps,
|
||||
})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
_, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
// These CLI tests are related to provisioner job CRUD operations and as such
|
||||
// do not require the overhead of starting a provisioner. Other provisioner job
|
||||
// functionalities (acquisition etc.) are tested elsewhere.
|
||||
template := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
CreatedBy: owner.UserID,
|
||||
AllowUserCancelWorkspaceJobs: true,
|
||||
})
|
||||
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
CreatedBy: owner.UserID,
|
||||
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
|
||||
})
|
||||
// Create some test jobs
|
||||
job1 := dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
InitiatorID: owner.UserID,
|
||||
Type: database.ProvisionerJobTypeTemplateVersionImport,
|
||||
Input: []byte(`{"template_version_id":"` + version.ID.String() + `"}`),
|
||||
Tags: database.StringMap{provisionersdk.TagScope: provisionersdk.ScopeOrganization},
|
||||
})
|
||||
|
||||
job2 := dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
InitiatorID: member.ID,
|
||||
Type: database.ProvisionerJobTypeWorkspaceBuild,
|
||||
Input: []byte(`{"workspace_build_id":"` + uuid.New().String() + `"}`),
|
||||
Tags: database.StringMap{provisionersdk.TagScope: provisionersdk.ScopeOrganization},
|
||||
})
|
||||
// Test basic list command
|
||||
t.Run("Basic", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv, root := clitest.New(t, "provisioner", "jobs", "list")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
var buf bytes.Buffer
|
||||
inv.Stdout = &buf
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should contain both jobs
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, job1.ID.String())
|
||||
assert.Contains(t, output, job2.ID.String())
|
||||
})
|
||||
|
||||
// Test list with JSON output
|
||||
t.Run("JSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv, root := clitest.New(t, "provisioner", "jobs", "list", "--output", "json")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
var buf bytes.Buffer
|
||||
inv.Stdout = &buf
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse JSON output
|
||||
var jobs []codersdk.ProvisionerJob
|
||||
err = json.Unmarshal(buf.Bytes(), &jobs)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should contain both jobs
|
||||
jobIDs := make([]uuid.UUID, len(jobs))
|
||||
for i, job := range jobs {
|
||||
jobIDs[i] = job.ID
|
||||
}
|
||||
assert.Contains(t, jobIDs, job1.ID)
|
||||
assert.Contains(t, jobIDs, job2.ID)
|
||||
})
|
||||
|
||||
// Test list with limit
|
||||
t.Run("Limit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv, root := clitest.New(t, "provisioner", "jobs", "list", "--limit", "1")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
var buf bytes.Buffer
|
||||
inv.Stdout = &buf
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should contain at most 1 job
|
||||
output := buf.String()
|
||||
jobCount := 0
|
||||
if strings.Contains(output, job1.ID.String()) {
|
||||
jobCount++
|
||||
}
|
||||
if strings.Contains(output, job2.ID.String()) {
|
||||
jobCount++
|
||||
}
|
||||
assert.LessOrEqual(t, jobCount, 1)
|
||||
})
|
||||
|
||||
// Test list with initiator filter
|
||||
t.Run("InitiatorFilter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Get owner user details to access username
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ownerUser, err := client.User(ctx, owner.UserID.String())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test filtering by initiator (using username)
|
||||
inv, root := clitest.New(t, "provisioner", "jobs", "list", "--initiator", ownerUser.Username)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
var buf bytes.Buffer
|
||||
inv.Stdout = &buf
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should only contain job1 (initiated by owner)
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, job1.ID.String())
|
||||
assert.NotContains(t, output, job2.ID.String())
|
||||
})
|
||||
|
||||
// Test list with invalid user
|
||||
t.Run("InvalidUser", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test with non-existent user
|
||||
inv, root := clitest.New(t, "provisioner", "jobs", "list", "--initiator", "nonexistent-user")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
var buf bytes.Buffer
|
||||
inv.Stdout = &buf
|
||||
err := inv.Run()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "initiator not found: nonexistent-user")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -176,22 +176,6 @@ func (r *RootCmd) scheduleStart() *serpent.Command {
|
||||
}
|
||||
|
||||
schedStr = ptr.Ref(sched.String())
|
||||
|
||||
// Check if the template has autostart requirements that may conflict
|
||||
// with the user's schedule.
|
||||
template, err := client.Template(inv.Context(), workspace.TemplateID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template: %w", err)
|
||||
}
|
||||
|
||||
if len(template.AutostartRequirement.DaysOfWeek) > 0 {
|
||||
_, _ = fmt.Fprintf(
|
||||
inv.Stderr,
|
||||
"Warning: your workspace template restricts autostart to the following days: %s.\n"+
|
||||
"Your workspace may only autostart on these days.\n",
|
||||
strings.Join(template.AutostartRequirement.DaysOfWeek, ", "),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
err = client.UpdateWorkspaceAutostart(inv.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
|
||||
|
||||
@@ -373,67 +373,3 @@ func TestScheduleOverride(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // t.Setenv
|
||||
func TestScheduleStart_TemplateAutostartRequirement(t *testing.T) {
|
||||
t.Setenv("TZ", "UTC")
|
||||
loc, err := tz.TimezoneIANA()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "UTC", loc.String())
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
// Update template to have autostart requirement
|
||||
// Note: In AGPL, this will be ignored and all days will be allowed (enterprise feature).
|
||||
template, err = client.UpdateTemplateMeta(context.Background(), template.ID, codersdk.UpdateTemplateMeta{
|
||||
AutostartRequirement: &codersdk.TemplateAutostartRequirement{
|
||||
DaysOfWeek: []string{"monday", "wednesday", "friday"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the template - in AGPL, AutostartRequirement will have all days (enterprise feature)
|
||||
template, err = client.Template(context.Background(), template.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, template.AutostartRequirement.DaysOfWeek, "template should have autostart requirement days")
|
||||
|
||||
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
t.Run("ShowsWarning", func(t *testing.T) {
|
||||
// When: user sets autostart schedule
|
||||
inv, root := clitest.New(t,
|
||||
"schedule", "start", workspace.Name, "9:30AM", "Mon-Fri",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
require.NoError(t, inv.Run())
|
||||
|
||||
// Then: warning should be shown
|
||||
// In AGPL, this will show all days (enterprise feature defaults to all days allowed)
|
||||
pty.ExpectMatch("Warning")
|
||||
pty.ExpectMatch("may only autostart")
|
||||
})
|
||||
|
||||
t.Run("NoWarningWhenManual", func(t *testing.T) {
|
||||
// When: user sets manual schedule
|
||||
inv, root := clitest.New(t,
|
||||
"schedule", "start", workspace.Name, "manual",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
var stderrBuf bytes.Buffer
|
||||
inv.Stderr = &stderrBuf
|
||||
|
||||
require.NoError(t, inv.Run())
|
||||
|
||||
// Then: no warning should be shown on stderr
|
||||
stderrOutput := stderrBuf.String()
|
||||
require.NotContains(t, stderrOutput, "Warning")
|
||||
})
|
||||
}
|
||||
|
||||
+39
-75
@@ -29,7 +29,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -1378,7 +1377,6 @@ func IsLocalURL(ctx context.Context, u *url.URL) (bool, error) {
|
||||
}
|
||||
|
||||
func shutdownWithTimeout(shutdown func(context.Context) error, timeout time.Duration) error {
|
||||
// nolint:gocritic // The magic number is parameterized.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return shutdown(ctx)
|
||||
@@ -2136,83 +2134,50 @@ func startBuiltinPostgres(ctx context.Context, cfg config.Root, logger slog.Logg
|
||||
return "", nil, xerrors.New("The built-in PostgreSQL cannot run as the root user. Create a non-root user and run again!")
|
||||
}
|
||||
|
||||
// Ensure a password and port have been generated!
|
||||
connectionURL, err := embeddedPostgresURL(cfg)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
pgPassword, err := cfg.PostgresPassword().Read()
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("read postgres password: %w", err)
|
||||
}
|
||||
pgPortRaw, err := cfg.PostgresPort().Read()
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("read postgres port: %w", err)
|
||||
}
|
||||
pgPort, err := strconv.ParseUint(pgPortRaw, 10, 16)
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("parse postgres port: %w", err)
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(cfg.PostgresPath(), "cache")
|
||||
if customCacheDir != "" {
|
||||
cachePath = filepath.Join(customCacheDir, "postgres")
|
||||
}
|
||||
stdlibLogger := slog.Stdlib(ctx, logger.Named("postgres"), slog.LevelDebug)
|
||||
|
||||
// If the port is not defined, an available port will be found dynamically.
|
||||
maxAttempts := 1
|
||||
_, err = cfg.PostgresPort().Read()
|
||||
retryPortDiscovery := errors.Is(err, os.ErrNotExist) && testing.Testing()
|
||||
if retryPortDiscovery {
|
||||
// There is no way to tell Postgres to use an ephemeral port, so in order to avoid
|
||||
// flaky tests in CI we need to retry EmbeddedPostgres.Start in case of a race
|
||||
// condition where the port we quickly listen on and close in embeddedPostgresURL()
|
||||
// is not free by the time the embedded postgres starts up. This maximum_should
|
||||
// cover most cases where port conflicts occur in CI and cause flaky tests.
|
||||
maxAttempts = 3
|
||||
ep := embeddedpostgres.NewDatabase(
|
||||
embeddedpostgres.DefaultConfig().
|
||||
Version(embeddedpostgres.V13).
|
||||
BinariesPath(filepath.Join(cfg.PostgresPath(), "bin")).
|
||||
// Default BinaryRepositoryURL repo1.maven.org is flaky.
|
||||
BinaryRepositoryURL("https://repo.maven.apache.org/maven2").
|
||||
DataPath(filepath.Join(cfg.PostgresPath(), "data")).
|
||||
RuntimePath(filepath.Join(cfg.PostgresPath(), "runtime")).
|
||||
CachePath(cachePath).
|
||||
Username("coder").
|
||||
Password(pgPassword).
|
||||
Database("coder").
|
||||
Encoding("UTF8").
|
||||
Port(uint32(pgPort)).
|
||||
Logger(stdlibLogger.Writer()),
|
||||
)
|
||||
err = ep.Start()
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("Failed to start built-in PostgreSQL. Optionally, specify an external deployment with `--postgres-url`: %w", err)
|
||||
}
|
||||
|
||||
var startErr error
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
// Ensure a password and port have been generated.
|
||||
connectionURL, err := embeddedPostgresURL(cfg)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
pgPassword, err := cfg.PostgresPassword().Read()
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("read postgres password: %w", err)
|
||||
}
|
||||
pgPortRaw, err := cfg.PostgresPort().Read()
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("read postgres port: %w", err)
|
||||
}
|
||||
pgPort, err := strconv.ParseUint(pgPortRaw, 10, 16)
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("parse postgres port: %w", err)
|
||||
}
|
||||
|
||||
ep := embeddedpostgres.NewDatabase(
|
||||
embeddedpostgres.DefaultConfig().
|
||||
Version(embeddedpostgres.V13).
|
||||
BinariesPath(filepath.Join(cfg.PostgresPath(), "bin")).
|
||||
// Default BinaryRepositoryURL repo1.maven.org is flaky.
|
||||
BinaryRepositoryURL("https://repo.maven.apache.org/maven2").
|
||||
DataPath(filepath.Join(cfg.PostgresPath(), "data")).
|
||||
RuntimePath(filepath.Join(cfg.PostgresPath(), "runtime")).
|
||||
CachePath(cachePath).
|
||||
Username("coder").
|
||||
Password(pgPassword).
|
||||
Database("coder").
|
||||
Encoding("UTF8").
|
||||
Port(uint32(pgPort)).
|
||||
Logger(stdlibLogger.Writer()),
|
||||
)
|
||||
|
||||
startErr = ep.Start()
|
||||
if startErr == nil {
|
||||
return connectionURL, ep.Stop, nil
|
||||
}
|
||||
|
||||
logger.Warn(ctx, "failed to start embedded postgres",
|
||||
slog.F("attempt", attempt+1),
|
||||
slog.F("max_attempts", maxAttempts),
|
||||
slog.F("port", pgPort),
|
||||
slog.Error(startErr),
|
||||
)
|
||||
|
||||
if retryPortDiscovery {
|
||||
// Since a retry is needed, we wipe the port stored here at the beginning of the loop.
|
||||
_ = cfg.PostgresPort().Delete()
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil, xerrors.Errorf("failed to start built-in PostgreSQL after %d attempts. "+
|
||||
"Optionally, specify an external deployment. See https://coder.com/docs/tutorials/external-database "+
|
||||
"for more details: %w", maxAttempts, startErr)
|
||||
return connectionURL, ep.Stop, nil
|
||||
}
|
||||
|
||||
func ConfigureHTTPClient(ctx context.Context, clientCertFile, clientKeyFile string, tlsClientCAFile string) (context.Context, *http.Client, error) {
|
||||
@@ -2321,7 +2286,7 @@ func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, d
|
||||
var err error
|
||||
var sqlDB *sql.DB
|
||||
dbNeedsClosing := true
|
||||
// nolint:gocritic // Try to connect for 30 seconds.
|
||||
// Try to connect for 30 seconds.
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -2417,7 +2382,6 @@ func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, d
|
||||
}
|
||||
|
||||
func pingPostgres(ctx context.Context, db *sql.DB) error {
|
||||
// nolint:gocritic // This is a reasonable magic number for a ping timeout.
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
return db.PingContext(ctx)
|
||||
|
||||
@@ -17,6 +17,9 @@ import (
|
||||
|
||||
func TestRegenerateVapidKeypair(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("this test is only supported on postgres")
|
||||
}
|
||||
|
||||
t.Run("NoExistingVAPIDKeys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
+13
-4
@@ -348,6 +348,9 @@ func TestServer(t *testing.T) {
|
||||
|
||||
runGitHubProviderTest := func(t *testing.T, tc testCase) {
|
||||
t.Parallel()
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("test requires postgres")
|
||||
}
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(testutil.Context(t, testutil.WaitLong))
|
||||
defer cancelFunc()
|
||||
@@ -1251,9 +1254,8 @@ func TestServer(t *testing.T) {
|
||||
t.Logf("error creating request: %s", err.Error())
|
||||
return false
|
||||
}
|
||||
client := &http.Client{}
|
||||
// nolint:bodyclose
|
||||
res, err := client.Do(req)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Logf("error hitting prometheus endpoint: %s", err.Error())
|
||||
return false
|
||||
@@ -1314,9 +1316,8 @@ func TestServer(t *testing.T) {
|
||||
t.Logf("error creating request: %s", err.Error())
|
||||
return false
|
||||
}
|
||||
client := &http.Client{}
|
||||
// nolint:bodyclose
|
||||
res, err := client.Do(req)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Logf("error hitting prometheus endpoint: %s", err.Error())
|
||||
return false
|
||||
@@ -2139,6 +2140,10 @@ func TestServerYAMLConfig(t *testing.T) {
|
||||
func TestConnectToPostgres(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("this test does not make sense without postgres")
|
||||
}
|
||||
|
||||
t.Run("Migrate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2249,6 +2254,10 @@ type runServerOpts struct {
|
||||
func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("this test requires postgres")
|
||||
}
|
||||
|
||||
telemetryServerURL, deployment, snapshot := mockTelemetryServer(t)
|
||||
dbConnURL, err := dbtestutil.Open(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -54,7 +54,6 @@ func TestSharingShare(t *testing.T) {
|
||||
MinimalUser: codersdk.MinimalUser{
|
||||
ID: toShareWithUser.ID,
|
||||
Username: toShareWithUser.Username,
|
||||
Name: toShareWithUser.Name,
|
||||
AvatarURL: toShareWithUser.AvatarURL,
|
||||
},
|
||||
Role: codersdk.WorkspaceRole("use"),
|
||||
@@ -104,7 +103,6 @@ func TestSharingShare(t *testing.T) {
|
||||
MinimalUser: codersdk.MinimalUser{
|
||||
ID: toShareWithUser1.ID,
|
||||
Username: toShareWithUser1.Username,
|
||||
Name: toShareWithUser1.Name,
|
||||
AvatarURL: toShareWithUser1.AvatarURL,
|
||||
},
|
||||
Role: codersdk.WorkspaceRoleUse,
|
||||
@@ -113,7 +111,6 @@ func TestSharingShare(t *testing.T) {
|
||||
MinimalUser: codersdk.MinimalUser{
|
||||
ID: toShareWithUser2.ID,
|
||||
Username: toShareWithUser2.Username,
|
||||
Name: toShareWithUser2.Name,
|
||||
AvatarURL: toShareWithUser2.AvatarURL,
|
||||
},
|
||||
Role: codersdk.WorkspaceRoleUse,
|
||||
@@ -158,7 +155,6 @@ func TestSharingShare(t *testing.T) {
|
||||
MinimalUser: codersdk.MinimalUser{
|
||||
ID: toShareWithUser.ID,
|
||||
Username: toShareWithUser.Username,
|
||||
Name: toShareWithUser.Name,
|
||||
AvatarURL: toShareWithUser.AvatarURL,
|
||||
},
|
||||
Role: codersdk.WorkspaceRoleAdmin,
|
||||
|
||||
-47
@@ -109,51 +109,6 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
}
|
||||
},
|
||||
),
|
||||
CompletionHandler: func(inv *serpent.Invocation) []string {
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
res, err := client.Workspaces(inv.Context(), codersdk.WorkspaceFilter{
|
||||
Owner: codersdk.Me,
|
||||
})
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var completions []string
|
||||
var wg sync.WaitGroup
|
||||
for _, ws := range res.Workspaces {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
resources, err := client.TemplateVersionResources(inv.Context(), ws.LatestBuild.TemplateVersionID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var agents []codersdk.WorkspaceAgent
|
||||
for _, resource := range resources {
|
||||
agents = append(agents, resource.Agents...)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(agents) == 1 {
|
||||
completions = append(completions, ws.Name)
|
||||
} else {
|
||||
for _, agent := range agents {
|
||||
completions = append(completions, fmt.Sprintf("%s.%s", ws.Name, agent.Name))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
slices.Sort(completions)
|
||||
return completions
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) (retErr error) {
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
@@ -951,8 +906,6 @@ func GetWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("start workspace with active template version: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Unable to start the workspace with template version from last build. Your workspace has been updated to the current active template version.")
|
||||
default:
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("start workspace with current template version: %w", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("start workspace with current template version: %w", err)
|
||||
|
||||
+1
-98
@@ -1242,8 +1242,7 @@ func TestSSH(t *testing.T) {
|
||||
// true exits the loop.
|
||||
return true
|
||||
}
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Logf("HTTP GET http://localhost:8222/ %s", err)
|
||||
return false
|
||||
@@ -2447,99 +2446,3 @@ func tempDirUnixSocket(t *testing.T) string {
|
||||
|
||||
return t.TempDir()
|
||||
}
|
||||
|
||||
func TestSSH_Completion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("SingleAgent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
inv, root := clitest.New(t, "ssh", "")
|
||||
inv.Stdout = &stdout
|
||||
inv.Environ.Set("COMPLETION_MODE", "1")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancel()
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// For single-agent workspaces, the only completion should be the
|
||||
// bare workspace name.
|
||||
output := stdout.String()
|
||||
t.Logf("Completion output: %q", output)
|
||||
require.Contains(t, output, workspace.Name)
|
||||
})
|
||||
|
||||
t.Run("MultiAgent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, store := coderdtest.NewWithDatabase(t, nil)
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
|
||||
r.Username = "multiuser"
|
||||
})
|
||||
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
Name: "multiworkspace",
|
||||
OrganizationID: first.OrganizationID,
|
||||
OwnerID: user.ID,
|
||||
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
||||
return []*proto.Agent{
|
||||
{
|
||||
Name: "agent1",
|
||||
Auth: &proto.Agent_Token{},
|
||||
},
|
||||
{
|
||||
Name: "agent2",
|
||||
Auth: &proto.Agent_Token{},
|
||||
},
|
||||
}
|
||||
}).Do()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
inv, root := clitest.New(t, "ssh", "")
|
||||
inv.Stdout = &stdout
|
||||
inv.Environ.Set("COMPLETION_MODE", "1")
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancel()
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// For multi-agent workspaces, completions should include the
|
||||
// workspace.agent format but NOT the bare workspace name.
|
||||
output := stdout.String()
|
||||
t.Logf("Completion output: %q", output)
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
require.NotContains(t, lines, r.Workspace.Name)
|
||||
require.Contains(t, output, r.Workspace.Name+".agent1")
|
||||
require.Contains(t, output, r.Workspace.Name+".agent2")
|
||||
})
|
||||
|
||||
t.Run("NetworkError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
inv, _ := clitest.New(t, "ssh", "")
|
||||
inv.Stdout = &stdout
|
||||
inv.Environ.Set("COMPLETION_MODE", "1")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
output := stdout.String()
|
||||
require.Empty(t, output)
|
||||
})
|
||||
}
|
||||
|
||||
+3
-158
@@ -9,7 +9,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -462,14 +461,10 @@ func createValidTemplateVersion(inv *serpent.Invocation, args createValidTemplat
|
||||
})
|
||||
if err != nil {
|
||||
var jobErr *cliui.ProvisionerJobError
|
||||
if errors.As(err, &jobErr) {
|
||||
if codersdk.JobIsMissingRequiredTemplateVariableErrorCode(jobErr.Code) {
|
||||
return handleMissingTemplateVariables(inv, args, version.ID)
|
||||
}
|
||||
if !codersdk.JobIsMissingParameterErrorCode(jobErr.Code) {
|
||||
return nil, err
|
||||
}
|
||||
if errors.As(err, &jobErr) && !codersdk.JobIsMissingParameterErrorCode(jobErr.Code) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
version, err = client.TemplateVersion(inv.Context(), version.ID)
|
||||
@@ -533,153 +528,3 @@ func prettyDirectoryPath(dir string) string {
|
||||
}
|
||||
return prettyDir
|
||||
}
|
||||
|
||||
func handleMissingTemplateVariables(inv *serpent.Invocation, args createValidTemplateVersionArgs, failedVersionID uuid.UUID) (*codersdk.TemplateVersion, error) {
|
||||
client := args.Client
|
||||
|
||||
templateVariables, err := client.TemplateVersionVariables(inv.Context(), failedVersionID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("fetch template variables: %w", err)
|
||||
}
|
||||
|
||||
existingValues := make(map[string]string)
|
||||
for _, v := range args.UserVariableValues {
|
||||
existingValues[v.Name] = v.Value
|
||||
}
|
||||
|
||||
var missingVariables []codersdk.TemplateVersionVariable
|
||||
for _, variable := range templateVariables {
|
||||
if !variable.Required {
|
||||
continue
|
||||
}
|
||||
|
||||
if existingValue, exists := existingValues[variable.Name]; exists && existingValue != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only prompt for variables that don't have a default value or have a redacted default
|
||||
// Sensitive variables have a default value of "*redacted*"
|
||||
// See: https://github.com/coder/coder/blob/a78790c632974e04babfef6de0e2ddf044787a7a/coderd/provisionerdserver/provisionerdserver.go#L3206
|
||||
if variable.DefaultValue == "" || (variable.Sensitive && variable.DefaultValue == "*redacted*") {
|
||||
missingVariables = append(missingVariables, variable)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingVariables) == 0 {
|
||||
return nil, xerrors.New("no missing required variables found")
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Found %d missing required variables:\n", len(missingVariables))
|
||||
for _, v := range missingVariables {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " - %s (%s): %s\n", v.Name, v.Type, v.Description)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nThe template requires values for the following variables:")
|
||||
|
||||
var promptedValues []codersdk.VariableValue
|
||||
for _, variable := range missingVariables {
|
||||
value, err := promptForTemplateVariable(inv, variable)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("prompt for variable %q: %w", variable.Name, err)
|
||||
}
|
||||
promptedValues = append(promptedValues, codersdk.VariableValue{
|
||||
Name: variable.Name,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
combinedValues := codersdk.CombineVariableValues(args.UserVariableValues, promptedValues)
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nRetrying template build with provided variables...")
|
||||
|
||||
retryArgs := args
|
||||
retryArgs.UserVariableValues = combinedValues
|
||||
|
||||
return createValidTemplateVersion(inv, retryArgs)
|
||||
}
|
||||
|
||||
func promptForTemplateVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
|
||||
displayVariableInfo(inv, variable)
|
||||
|
||||
switch variable.Type {
|
||||
case "bool":
|
||||
return promptForBoolVariable(inv, variable)
|
||||
case "number":
|
||||
return promptForNumberVariable(inv, variable)
|
||||
default:
|
||||
return promptForStringVariable(inv, variable)
|
||||
}
|
||||
}
|
||||
|
||||
func displayVariableInfo(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "var.%s", cliui.Bold(variable.Name))
|
||||
if variable.Required {
|
||||
_, _ = fmt.Fprint(inv.Stderr, pretty.Sprint(cliui.DefaultStyles.Error, " (required)"))
|
||||
}
|
||||
if variable.Sensitive {
|
||||
_, _ = fmt.Fprint(inv.Stderr, pretty.Sprint(cliui.DefaultStyles.Warn, ", sensitive"))
|
||||
}
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "")
|
||||
|
||||
if variable.Description != "" {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Description: %s\n", variable.Description)
|
||||
}
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Type: %s\n", variable.Type)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Current value: %s\n", pretty.Sprint(cliui.DefaultStyles.Placeholder, "<empty>"))
|
||||
}
|
||||
|
||||
func promptForBoolVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
|
||||
defaultValue := variable.DefaultValue
|
||||
if defaultValue == "" {
|
||||
defaultValue = "false"
|
||||
}
|
||||
|
||||
return cliui.Select(inv, cliui.SelectOptions{
|
||||
Options: []string{"true", "false"},
|
||||
Default: defaultValue,
|
||||
Message: "Select value:",
|
||||
})
|
||||
}
|
||||
|
||||
func promptForNumberVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
|
||||
prompt := "Enter value:"
|
||||
if !variable.Required && variable.DefaultValue != "" {
|
||||
prompt = fmt.Sprintf("Enter value (default: %q):", variable.DefaultValue)
|
||||
}
|
||||
|
||||
return cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: prompt,
|
||||
Default: variable.DefaultValue,
|
||||
Validate: createVariableValidator(variable),
|
||||
})
|
||||
}
|
||||
|
||||
func promptForStringVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
|
||||
prompt := "Enter value:"
|
||||
if !variable.Sensitive {
|
||||
if !variable.Required && variable.DefaultValue != "" {
|
||||
prompt = fmt.Sprintf("Enter value (default: %q):", variable.DefaultValue)
|
||||
}
|
||||
}
|
||||
|
||||
return cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: prompt,
|
||||
Default: variable.DefaultValue,
|
||||
Secret: variable.Sensitive,
|
||||
Validate: createVariableValidator(variable),
|
||||
})
|
||||
}
|
||||
|
||||
func createVariableValidator(variable codersdk.TemplateVersionVariable) func(string) error {
|
||||
return func(s string) error {
|
||||
if variable.Required && s == "" && variable.DefaultValue == "" {
|
||||
return xerrors.New("value is required")
|
||||
}
|
||||
if variable.Type == "number" && s != "" {
|
||||
if _, err := strconv.ParseFloat(s, 64); err != nil {
|
||||
return xerrors.Errorf("must be a valid number, got: %q", s)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
+48
-234
@@ -852,6 +852,54 @@ func TestTemplatePush(t *testing.T) {
|
||||
require.Equal(t, "foobar", templateVariables[1].Value)
|
||||
})
|
||||
|
||||
t.Run("VariableIsRequiredButNotProvided", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
||||
templateVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, createEchoResponsesWithTemplateVariables(initialTemplateVariables))
|
||||
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, templateVersion.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, templateVersion.ID)
|
||||
|
||||
// Test the cli command.
|
||||
//nolint:gocritic
|
||||
modifiedTemplateVariables := append(initialTemplateVariables,
|
||||
&proto.TemplateVariable{
|
||||
Name: "second_variable",
|
||||
Description: "This is the second variable.",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
},
|
||||
)
|
||||
source := clitest.CreateTemplateVersionSource(t, createEchoResponsesWithTemplateVariables(modifiedTemplateVariables))
|
||||
inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", "example")
|
||||
clitest.SetupConfig(t, templateAdmin, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- inv.Run()
|
||||
}()
|
||||
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "Upload", write: "yes"},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
|
||||
wantErr := <-execDone
|
||||
require.Error(t, wantErr)
|
||||
require.Contains(t, wantErr.Error(), "required template variables need values")
|
||||
})
|
||||
|
||||
t.Run("VariableIsOptionalButNotProvided", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
@@ -1067,240 +1115,6 @@ func TestTemplatePush(t *testing.T) {
|
||||
require.Len(t, templateVersions, 2)
|
||||
require.Equal(t, "example", templateVersions[1].Name)
|
||||
})
|
||||
|
||||
t.Run("PromptForDifferentRequiredTypes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
||||
templateVariables := []*proto.TemplateVariable{
|
||||
{
|
||||
Name: "string_var",
|
||||
Description: "A string variable",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Name: "number_var",
|
||||
Description: "A number variable",
|
||||
Type: "number",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Name: "bool_var",
|
||||
Description: "A boolean variable",
|
||||
Type: "bool",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Name: "sensitive_var",
|
||||
Description: "A sensitive variable",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Sensitive: true,
|
||||
},
|
||||
}
|
||||
|
||||
source := clitest.CreateTemplateVersionSource(t, createEchoResponsesWithTemplateVariables(templateVariables))
|
||||
inv, root := clitest.New(t, "templates", "push", "test-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho))
|
||||
clitest.SetupConfig(t, templateAdmin, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- inv.Run()
|
||||
}()
|
||||
|
||||
// Select "Yes" for the "Upload <template_path>" prompt
|
||||
pty.ExpectMatch("Upload")
|
||||
pty.WriteLine("yes")
|
||||
|
||||
pty.ExpectMatch("var.string_var")
|
||||
pty.ExpectMatch("Enter value:")
|
||||
pty.WriteLine("test-string")
|
||||
|
||||
pty.ExpectMatch("var.number_var")
|
||||
pty.ExpectMatch("Enter value:")
|
||||
pty.WriteLine("42")
|
||||
|
||||
// Boolean variable automatically selects the first option ("true")
|
||||
pty.ExpectMatch("var.bool_var")
|
||||
|
||||
pty.ExpectMatch("var.sensitive_var")
|
||||
pty.ExpectMatch("Enter value:")
|
||||
pty.WriteLine("secret-value")
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
})
|
||||
|
||||
t.Run("ValidateNumberInput", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
||||
templateVariables := []*proto.TemplateVariable{
|
||||
{
|
||||
Name: "number_var",
|
||||
Description: "A number that requires validation",
|
||||
Type: "number",
|
||||
Required: true,
|
||||
},
|
||||
}
|
||||
|
||||
source := clitest.CreateTemplateVersionSource(t, createEchoResponsesWithTemplateVariables(templateVariables))
|
||||
inv, root := clitest.New(t, "templates", "push", "test-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho))
|
||||
clitest.SetupConfig(t, templateAdmin, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- inv.Run()
|
||||
}()
|
||||
|
||||
// Select "Yes" for the "Upload <template_path>" prompt
|
||||
pty.ExpectMatch("Upload")
|
||||
pty.WriteLine("yes")
|
||||
|
||||
pty.ExpectMatch("var.number_var")
|
||||
|
||||
pty.WriteLine("not-a-number")
|
||||
pty.ExpectMatch("must be a valid number")
|
||||
|
||||
pty.WriteLine("123.45")
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
})
|
||||
|
||||
t.Run("DontPromptForDefaultValues", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
||||
templateVariables := []*proto.TemplateVariable{
|
||||
{
|
||||
Name: "with_default",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
DefaultValue: "default-value",
|
||||
},
|
||||
{
|
||||
Name: "without_default",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
},
|
||||
}
|
||||
|
||||
source := clitest.CreateTemplateVersionSource(t, createEchoResponsesWithTemplateVariables(templateVariables))
|
||||
inv, root := clitest.New(t, "templates", "push", "test-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho))
|
||||
clitest.SetupConfig(t, templateAdmin, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- inv.Run()
|
||||
}()
|
||||
|
||||
// Select "Yes" for the "Upload <template_path>" prompt
|
||||
pty.ExpectMatch("Upload")
|
||||
pty.WriteLine("yes")
|
||||
|
||||
pty.ExpectMatch("var.without_default")
|
||||
pty.WriteLine("test-value")
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
})
|
||||
|
||||
t.Run("VariableSourcesPriority", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
||||
templateVariables := []*proto.TemplateVariable{
|
||||
{
|
||||
Name: "cli_flag_var",
|
||||
Description: "Variable provided via CLI flag",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Name: "file_var",
|
||||
Description: "Variable provided via file",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Name: "prompt_var",
|
||||
Description: "Variable provided via prompt",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Name: "cli_overrides_file_var",
|
||||
Description: "Variable in both CLI and file",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
},
|
||||
}
|
||||
|
||||
source := clitest.CreateTemplateVersionSource(t, createEchoResponsesWithTemplateVariables(templateVariables))
|
||||
|
||||
// Create a temporary variables file.
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
variablesFile, err := os.CreateTemp(tempDir, "variables*.yaml")
|
||||
require.NoError(t, err)
|
||||
_, err = variablesFile.WriteString(`file_var: from-file
|
||||
cli_overrides_file_var: from-file`)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, variablesFile.Close())
|
||||
|
||||
inv, root := clitest.New(t, "templates", "push", "test-template",
|
||||
"--directory", source,
|
||||
"--test.provisioner", string(database.ProvisionerTypeEcho),
|
||||
"--variables-file", variablesFile.Name(),
|
||||
"--variable", "cli_flag_var=from-cli-flag",
|
||||
"--variable", "cli_overrides_file_var=from-cli-override",
|
||||
)
|
||||
clitest.SetupConfig(t, templateAdmin, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- inv.Run()
|
||||
}()
|
||||
|
||||
// Select "Yes" for the "Upload <template_path>" prompt
|
||||
pty.ExpectMatch("Upload")
|
||||
pty.WriteLine("yes")
|
||||
|
||||
// Only check for prompt_var, other variables should not prompt
|
||||
pty.ExpectMatch("var.prompt_var")
|
||||
pty.ExpectMatch("Enter value:")
|
||||
pty.WriteLine("from-prompt")
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
|
||||
template, err := client.TemplateByName(context.Background(), owner.OrganizationID, "test-template")
|
||||
require.NoError(t, err)
|
||||
|
||||
templateVersionVars, err := client.TemplateVersionVariables(context.Background(), template.ActiveVersionID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, templateVersionVars, 4)
|
||||
|
||||
varMap := make(map[string]string)
|
||||
for _, tv := range templateVersionVars {
|
||||
varMap[tv.Name] = tv.Value
|
||||
}
|
||||
|
||||
require.Equal(t, "from-cli-flag", varMap["cli_flag_var"])
|
||||
require.Equal(t, "from-file", varMap["file_var"])
|
||||
require.Equal(t, "from-prompt", varMap["prompt_var"])
|
||||
require.Equal(t, "from-cli-override", varMap["cli_overrides_file_var"])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -45,7 +45,6 @@
|
||||
"queue_position": 0,
|
||||
"queue_size": 0,
|
||||
"organization_id": "===========[first org ID]===========",
|
||||
"initiator_id": "==========[first user ID]===========",
|
||||
"input": {
|
||||
"workspace_build_id": "========[workspace build ID]========"
|
||||
},
|
||||
@@ -90,7 +89,6 @@
|
||||
"allow_renames": false,
|
||||
"favorite": false,
|
||||
"next_start_at": "====[timestamp]=====",
|
||||
"is_prebuild": false,
|
||||
"task_id": null
|
||||
"is_prebuild": false
|
||||
}
|
||||
]
|
||||
|
||||
+1
-4
@@ -11,12 +11,9 @@ OPTIONS:
|
||||
-O, --org string, $CODER_ORGANIZATION
|
||||
Select which organization (uuid or name) to use.
|
||||
|
||||
-c, --column [id|created at|started at|completed at|canceled at|error|error code|status|worker id|worker name|file id|tags|queue position|queue size|organization id|initiator id|template version id|workspace build id|type|available workers|template version name|template id|template name|template display name|template icon|workspace id|workspace name|logs overflowed|organization|queue] (default: created at,id,type,template display name,status,queue,tags)
|
||||
-c, --column [id|created at|started at|completed at|canceled at|error|error code|status|worker id|worker name|file id|tags|queue position|queue size|organization id|template version id|workspace build id|type|available workers|template version name|template id|template name|template display name|template icon|workspace id|workspace name|logs overflowed|organization|queue] (default: created at,id,type,template display name,status,queue,tags)
|
||||
Columns to display in table output.
|
||||
|
||||
-i, --initiator string, $CODER_PROVISIONER_JOB_LIST_INITIATOR
|
||||
Filter by initiator (user ID or username).
|
||||
|
||||
-l, --limit int, $CODER_PROVISIONER_JOB_LIST_LIMIT (default: 50)
|
||||
Limit the number of jobs returned.
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"queue_position": 0,
|
||||
"queue_size": 0,
|
||||
"organization_id": "===========[first org ID]===========",
|
||||
"initiator_id": "==========[first user ID]===========",
|
||||
"input": {
|
||||
"template_version_id": "============[version ID]============"
|
||||
},
|
||||
@@ -46,7 +45,6 @@
|
||||
"queue_position": 0,
|
||||
"queue_size": 0,
|
||||
"organization_id": "===========[first org ID]===========",
|
||||
"initiator_id": "==========[first user ID]===========",
|
||||
"input": {
|
||||
"workspace_build_id": "========[workspace build ID]========"
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"last_seen_at": "====[timestamp]=====",
|
||||
"name": "test-daemon",
|
||||
"version": "v0.0.0-devel",
|
||||
"api_version": "1.11",
|
||||
"api_version": "1.10",
|
||||
"provisioners": [
|
||||
"echo"
|
||||
],
|
||||
|
||||
-35
@@ -80,41 +80,6 @@ OPTIONS:
|
||||
Periodically check for new releases of Coder and inform the owner. The
|
||||
check is performed once per day.
|
||||
|
||||
AIBRIDGE OPTIONS:
|
||||
--aibridge-anthropic-base-url string, $CODER_AIBRIDGE_ANTHROPIC_BASE_URL (default: https://api.anthropic.com/)
|
||||
The base URL of the Anthropic API.
|
||||
|
||||
--aibridge-anthropic-key string, $CODER_AIBRIDGE_ANTHROPIC_KEY
|
||||
The key to authenticate against the Anthropic API.
|
||||
|
||||
--aibridge-bedrock-access-key string, $CODER_AIBRIDGE_BEDROCK_ACCESS_KEY
|
||||
The access key to authenticate against the AWS Bedrock API.
|
||||
|
||||
--aibridge-bedrock-access-key-secret string, $CODER_AIBRIDGE_BEDROCK_ACCESS_KEY_SECRET
|
||||
The access key secret to use with the access key to authenticate
|
||||
against the AWS Bedrock API.
|
||||
|
||||
--aibridge-bedrock-model string, $CODER_AIBRIDGE_BEDROCK_MODEL (default: global.anthropic.claude-sonnet-4-5-20250929-v1:0)
|
||||
The model to use when making requests to the AWS Bedrock API.
|
||||
|
||||
--aibridge-bedrock-region string, $CODER_AIBRIDGE_BEDROCK_REGION
|
||||
The AWS Bedrock API region.
|
||||
|
||||
--aibridge-bedrock-small-fastmodel string, $CODER_AIBRIDGE_BEDROCK_SMALL_FAST_MODEL (default: global.anthropic.claude-haiku-4-5-20251001-v1:0)
|
||||
The small fast model to use when making requests to the AWS Bedrock
|
||||
API. Claude Code uses Haiku-class models to perform background tasks.
|
||||
See
|
||||
https://docs.claude.com/en/docs/claude-code/settings#environment-variables.
|
||||
|
||||
--aibridge-enabled bool, $CODER_AIBRIDGE_ENABLED (default: false)
|
||||
Whether to start an in-memory aibridged instance.
|
||||
|
||||
--aibridge-openai-base-url string, $CODER_AIBRIDGE_OPENAI_BASE_URL (default: https://api.openai.com/v1/)
|
||||
The base URL of the OpenAI API.
|
||||
|
||||
--aibridge-openai-key string, $CODER_AIBRIDGE_OPENAI_KEY
|
||||
The key to authenticate against the OpenAI API.
|
||||
|
||||
CLIENT OPTIONS:
|
||||
These options change the behavior of how clients interact with the Coder.
|
||||
Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ USAGE:
|
||||
Get started with a templated template.
|
||||
|
||||
OPTIONS:
|
||||
--id aws-devcontainer|aws-linux|aws-windows|azure-linux|digitalocean-linux|docker|docker-devcontainer|docker-envbuilder|gcp-devcontainer|gcp-linux|gcp-vm-container|gcp-windows|kubernetes|kubernetes-devcontainer|nomad-docker|scratch|tasks-docker
|
||||
--id aws-devcontainer|aws-linux|aws-windows|azure-linux|digitalocean-linux|docker|docker-devcontainer|docker-envbuilder|gcp-devcontainer|gcp-linux|gcp-vm-container|gcp-windows|kubernetes|kubernetes-devcontainer|nomad-docker|scratch
|
||||
Specify a given example template by ID.
|
||||
|
||||
———
|
||||
|
||||
-5
@@ -16,10 +16,6 @@ USAGE:
|
||||
|
||||
$ coder tokens ls
|
||||
|
||||
- Create a scoped token:
|
||||
|
||||
$ coder tokens create --scope workspace:read --allow workspace:<uuid>
|
||||
|
||||
- Remove a token by ID:
|
||||
|
||||
$ coder tokens rm WuoWs4ZsMX
|
||||
@@ -28,7 +24,6 @@ SUBCOMMANDS:
|
||||
create Create a token
|
||||
list List tokens
|
||||
remove Delete a token
|
||||
view Display detailed information about a token
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-9
@@ -6,20 +6,12 @@ USAGE:
|
||||
Create a token
|
||||
|
||||
OPTIONS:
|
||||
--allow allow-list
|
||||
Repeatable allow-list entry (<type>:<uuid>, e.g. workspace:1234-...).
|
||||
|
||||
--lifetime string, $CODER_TOKEN_LIFETIME
|
||||
Duration for the token lifetime. Supports standard Go duration units
|
||||
(ns, us, ms, s, m, h) plus d (days) and y (years). Examples: 8h, 30d,
|
||||
1y, 1d12h30m.
|
||||
Specify a duration for the lifetime of the token.
|
||||
|
||||
-n, --name string, $CODER_TOKEN_NAME
|
||||
Specify a human-readable name.
|
||||
|
||||
--scope string-array
|
||||
Repeatable scope to attach to the token (e.g. workspace:read).
|
||||
|
||||
-u, --user string, $CODER_TOKEN_USER
|
||||
Specify the user to create the token for (Only works if logged in user
|
||||
is admin).
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ OPTIONS:
|
||||
Specifies whether all users' tokens will be listed or not (must have
|
||||
Owner role to see all tokens).
|
||||
|
||||
-c, --column [id|name|scopes|allow list|last used|expires at|created at|owner] (default: id,name,scopes,allow list,last used,expires at,created at)
|
||||
-c, --column [id|name|last used|expires at|created at|owner] (default: id,name,last used,expires at,created at)
|
||||
Columns to display in table output.
|
||||
|
||||
-o, --output table|json (default: table)
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder tokens view [flags] <name|id>
|
||||
|
||||
Display detailed information about a token
|
||||
|
||||
OPTIONS:
|
||||
-c, --column [id|name|scopes|allow list|last used|expires at|created at|owner] (default: id,name,scopes,allow list,last used,expires at,created at,owner)
|
||||
Columns to display in table output.
|
||||
|
||||
-o, --output table|json (default: table)
|
||||
Output format.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
+1
-1
@@ -8,7 +8,7 @@ USAGE:
|
||||
Aliases: ls
|
||||
|
||||
OPTIONS:
|
||||
-c, --column [id|username|name|email|created at|updated at|status] (default: username,email,created at,status)
|
||||
-c, --column [id|username|email|created at|updated at|status] (default: username,email,created at,status)
|
||||
Columns to display in table output.
|
||||
|
||||
--github-user-id int
|
||||
|
||||
+4
-21
@@ -714,7 +714,8 @@ workspace_prebuilds:
|
||||
# (default: 3, type: int)
|
||||
failure_hard_limit: 3
|
||||
aibridge:
|
||||
# Whether to start an in-memory aibridged instance.
|
||||
# Whether to start an in-memory aibridged instance ("aibridge" experiment must be
|
||||
# enabled, too).
|
||||
# (default: false, type: bool)
|
||||
enabled: false
|
||||
# The base URL of the OpenAI API.
|
||||
@@ -725,25 +726,7 @@ aibridge:
|
||||
openai_key: ""
|
||||
# The base URL of the Anthropic API.
|
||||
# (default: https://api.anthropic.com/, type: string)
|
||||
anthropic_base_url: https://api.anthropic.com/
|
||||
base_url: https://api.anthropic.com/
|
||||
# The key to authenticate against the Anthropic API.
|
||||
# (default: <unset>, type: string)
|
||||
anthropic_key: ""
|
||||
# The AWS Bedrock API region.
|
||||
# (default: <unset>, type: string)
|
||||
bedrock_region: ""
|
||||
# The access key to authenticate against the AWS Bedrock API.
|
||||
# (default: <unset>, type: string)
|
||||
bedrock_access_key: ""
|
||||
# The access key secret to use with the access key to authenticate against the AWS
|
||||
# Bedrock API.
|
||||
# (default: <unset>, type: string)
|
||||
bedrock_access_key_secret: ""
|
||||
# The model to use when making requests to the AWS Bedrock API.
|
||||
# (default: global.anthropic.claude-sonnet-4-5-20250929-v1:0, type: string)
|
||||
bedrock_model: global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# The small fast model to use when making requests to the AWS Bedrock API. Claude
|
||||
# Code uses Haiku-class models to perform background tasks. See
|
||||
# https://docs.claude.com/en/docs/claude-code/settings#environment-variables.
|
||||
# (default: global.anthropic.claude-haiku-4-5-20251001-v1:0, type: string)
|
||||
bedrock_small_fast_model: global.anthropic.claude-haiku-4-5-20251001-v1:0
|
||||
key: ""
|
||||
|
||||
+6
-104
@@ -4,14 +4,12 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
@@ -29,10 +27,6 @@ func (r *RootCmd) tokens() *serpent.Command {
|
||||
Description: "List your tokens",
|
||||
Command: "coder tokens ls",
|
||||
},
|
||||
Example{
|
||||
Description: "Create a scoped token",
|
||||
Command: "coder tokens create --scope workspace:read --allow workspace:<uuid>",
|
||||
},
|
||||
Example{
|
||||
Description: "Remove a token by ID",
|
||||
Command: "coder tokens rm WuoWs4ZsMX",
|
||||
@@ -45,7 +39,6 @@ func (r *RootCmd) tokens() *serpent.Command {
|
||||
Children: []*serpent.Command{
|
||||
r.createToken(),
|
||||
r.listTokens(),
|
||||
r.viewToken(),
|
||||
r.removeToken(),
|
||||
},
|
||||
}
|
||||
@@ -57,8 +50,6 @@ func (r *RootCmd) createToken() *serpent.Command {
|
||||
tokenLifetime string
|
||||
name string
|
||||
user string
|
||||
scopes []string
|
||||
allowList []codersdk.APIAllowListTarget
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Use: "create",
|
||||
@@ -97,18 +88,10 @@ func (r *RootCmd) createToken() *serpent.Command {
|
||||
}
|
||||
}
|
||||
|
||||
req := codersdk.CreateTokenRequest{
|
||||
res, err := client.CreateToken(inv.Context(), userID, codersdk.CreateTokenRequest{
|
||||
Lifetime: parsedLifetime,
|
||||
TokenName: name,
|
||||
}
|
||||
if len(req.Scopes) == 0 {
|
||||
req.Scopes = slice.StringEnums[codersdk.APIKeyScope](scopes)
|
||||
}
|
||||
if len(allowList) > 0 {
|
||||
req.AllowList = append([]codersdk.APIAllowListTarget(nil), allowList...)
|
||||
}
|
||||
|
||||
res, err := client.CreateToken(inv.Context(), userID, req)
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tokens: %w", err)
|
||||
}
|
||||
@@ -123,7 +106,7 @@ func (r *RootCmd) createToken() *serpent.Command {
|
||||
{
|
||||
Flag: "lifetime",
|
||||
Env: "CODER_TOKEN_LIFETIME",
|
||||
Description: "Duration for the token lifetime. Supports standard Go duration units (ns, us, ms, s, m, h) plus d (days) and y (years). Examples: 8h, 30d, 1y, 1d12h30m.",
|
||||
Description: "Specify a duration for the lifetime of the token.",
|
||||
Value: serpent.StringOf(&tokenLifetime),
|
||||
},
|
||||
{
|
||||
@@ -140,16 +123,6 @@ func (r *RootCmd) createToken() *serpent.Command {
|
||||
Description: "Specify the user to create the token for (Only works if logged in user is admin).",
|
||||
Value: serpent.StringOf(&user),
|
||||
},
|
||||
{
|
||||
Flag: "scope",
|
||||
Description: "Repeatable scope to attach to the token (e.g. workspace:read).",
|
||||
Value: serpent.StringArrayOf(&scopes),
|
||||
},
|
||||
{
|
||||
Flag: "allow",
|
||||
Description: "Repeatable allow-list entry (<type>:<uuid>, e.g. workspace:1234-...).",
|
||||
Value: AllowListFlagOf(&allowList),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
@@ -163,8 +136,6 @@ type tokenListRow struct {
|
||||
// For table format:
|
||||
ID string `json:"-" table:"id,default_sort"`
|
||||
TokenName string `json:"token_name" table:"name"`
|
||||
Scopes string `json:"-" table:"scopes"`
|
||||
Allow string `json:"-" table:"allow list"`
|
||||
LastUsed time.Time `json:"-" table:"last used"`
|
||||
ExpiresAt time.Time `json:"-" table:"expires at"`
|
||||
CreatedAt time.Time `json:"-" table:"created at"`
|
||||
@@ -172,47 +143,20 @@ type tokenListRow struct {
|
||||
}
|
||||
|
||||
func tokenListRowFromToken(token codersdk.APIKeyWithOwner) tokenListRow {
|
||||
return tokenListRowFromKey(token.APIKey, token.Username)
|
||||
}
|
||||
|
||||
func tokenListRowFromKey(token codersdk.APIKey, owner string) tokenListRow {
|
||||
return tokenListRow{
|
||||
APIKey: token,
|
||||
APIKey: token.APIKey,
|
||||
ID: token.ID,
|
||||
TokenName: token.TokenName,
|
||||
Scopes: joinScopes(token.Scopes),
|
||||
Allow: joinAllowList(token.AllowList),
|
||||
LastUsed: token.LastUsed,
|
||||
ExpiresAt: token.ExpiresAt,
|
||||
CreatedAt: token.CreatedAt,
|
||||
Owner: owner,
|
||||
Owner: token.Username,
|
||||
}
|
||||
}
|
||||
|
||||
func joinScopes(scopes []codersdk.APIKeyScope) string {
|
||||
if len(scopes) == 0 {
|
||||
return ""
|
||||
}
|
||||
vals := slice.ToStrings(scopes)
|
||||
sort.Strings(vals)
|
||||
return strings.Join(vals, ", ")
|
||||
}
|
||||
|
||||
func joinAllowList(entries []codersdk.APIAllowListTarget) string {
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
vals := make([]string, len(entries))
|
||||
for i, entry := range entries {
|
||||
vals[i] = entry.String()
|
||||
}
|
||||
sort.Strings(vals)
|
||||
return strings.Join(vals, ", ")
|
||||
}
|
||||
|
||||
func (r *RootCmd) listTokens() *serpent.Command {
|
||||
// we only display the 'owner' column if the --all argument is passed in
|
||||
defaultCols := []string{"id", "name", "scopes", "allow list", "last used", "expires at", "created at"}
|
||||
defaultCols := []string{"id", "name", "last used", "expires at", "created at"}
|
||||
if slices.Contains(os.Args, "-a") || slices.Contains(os.Args, "--all") {
|
||||
defaultCols = append(defaultCols, "owner")
|
||||
}
|
||||
@@ -282,48 +226,6 @@ func (r *RootCmd) listTokens() *serpent.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) viewToken() *serpent.Command {
|
||||
formatter := cliui.NewOutputFormatter(
|
||||
cliui.TableFormat([]tokenListRow{}, []string{"id", "name", "scopes", "allow list", "last used", "expires at", "created at", "owner"}),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "view <name|id>",
|
||||
Short: "Display detailed information about a token",
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tokenName := inv.Args[0]
|
||||
token, err := client.APIKeyByName(inv.Context(), codersdk.Me, tokenName)
|
||||
if err != nil {
|
||||
maybeID := strings.Split(tokenName, "-")[0]
|
||||
token, err = client.APIKeyByID(inv.Context(), codersdk.Me, maybeID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch api key by name or id: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
row := tokenListRowFromKey(*token, "")
|
||||
out, err := formatter.Format(inv.Context(), []tokenListRow{row})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(inv.Stdout, out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
formatter.AttachOptions(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) removeToken() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "remove <name|id|token>",
|
||||
|
||||
+3
-56
@@ -4,13 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -49,18 +46,6 @@ func TestTokens(t *testing.T) {
|
||||
require.NotEmpty(t, res)
|
||||
id := res[:10]
|
||||
|
||||
allowWorkspaceID := uuid.New()
|
||||
allowSpec := fmt.Sprintf("workspace:%s", allowWorkspaceID.String())
|
||||
inv, root = clitest.New(t, "tokens", "create", "--name", "scoped-token", "--scope", string(codersdk.APIKeyScopeWorkspaceRead), "--allow", allowSpec)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf = new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
res = buf.String()
|
||||
require.NotEmpty(t, res)
|
||||
scopedTokenID := res[:10]
|
||||
|
||||
// Test creating a token for second user from first user's (admin) session
|
||||
inv, root = clitest.New(t, "tokens", "create", "--name", "token-two", "--user", secondUser.ID.String())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
@@ -82,7 +67,7 @@ func TestTokens(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
res = buf.String()
|
||||
require.NotEmpty(t, res)
|
||||
// Result should only contain the tokens created for the admin user
|
||||
// Result should only contain the token created for the admin user
|
||||
require.Contains(t, res, "ID")
|
||||
require.Contains(t, res, "EXPIRES AT")
|
||||
require.Contains(t, res, "CREATED AT")
|
||||
@@ -91,16 +76,6 @@ func TestTokens(t *testing.T) {
|
||||
// Result should not contain the token created for the second user
|
||||
require.NotContains(t, res, secondTokenID)
|
||||
|
||||
inv, root = clitest.New(t, "tokens", "view", "scoped-token")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf = new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
res = buf.String()
|
||||
require.Contains(t, res, string(codersdk.APIKeyScopeWorkspaceRead))
|
||||
require.Contains(t, res, allowSpec)
|
||||
|
||||
// Test listing tokens from the second user's session
|
||||
inv, root = clitest.New(t, "tokens", "ls")
|
||||
clitest.SetupConfig(t, secondUserClient, root)
|
||||
@@ -126,14 +101,6 @@ func TestTokens(t *testing.T) {
|
||||
// User (non-admin) should not be able to create a token for another user
|
||||
require.Error(t, err)
|
||||
|
||||
inv, root = clitest.New(t, "tokens", "create", "--name", "invalid-allow", "--allow", "badvalue")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf = new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid allow_list entry")
|
||||
|
||||
inv, root = clitest.New(t, "tokens", "ls", "--output=json")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf = new(bytes.Buffer)
|
||||
@@ -143,17 +110,8 @@ func TestTokens(t *testing.T) {
|
||||
|
||||
var tokens []codersdk.APIKey
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &tokens))
|
||||
require.Len(t, tokens, 2)
|
||||
tokenByName := make(map[string]codersdk.APIKey, len(tokens))
|
||||
for _, tk := range tokens {
|
||||
tokenByName[tk.TokenName] = tk
|
||||
}
|
||||
require.Contains(t, tokenByName, "token-one")
|
||||
require.Contains(t, tokenByName, "scoped-token")
|
||||
scopedToken := tokenByName["scoped-token"]
|
||||
require.Contains(t, scopedToken.Scopes, codersdk.APIKeyScopeWorkspaceRead)
|
||||
require.Len(t, scopedToken.AllowList, 1)
|
||||
require.Equal(t, allowSpec, scopedToken.AllowList[0].String())
|
||||
require.Len(t, tokens, 1)
|
||||
require.Equal(t, id, tokens[0].ID)
|
||||
|
||||
// Delete by name
|
||||
inv, root = clitest.New(t, "tokens", "rm", "token-one")
|
||||
@@ -177,17 +135,6 @@ func TestTokens(t *testing.T) {
|
||||
require.NotEmpty(t, res)
|
||||
require.Contains(t, res, "deleted")
|
||||
|
||||
// Delete scoped token by ID
|
||||
inv, root = clitest.New(t, "tokens", "rm", scopedTokenID)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf = new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
res = buf.String()
|
||||
require.NotEmpty(t, res)
|
||||
require.Contains(t, res, "deleted")
|
||||
|
||||
// Create third token
|
||||
inv, root = clitest.New(t, "tokens", "create", "--name", "token-three")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
@@ -239,10 +239,6 @@ func (a *API) Serve(ctx context.Context, l net.Listener) error {
|
||||
return xerrors.Errorf("create agent API server: %w", err)
|
||||
}
|
||||
|
||||
if err := a.ResourcesMonitoringAPI.InitMonitors(ctx); err != nil {
|
||||
return xerrors.Errorf("initialize resource monitoring: %w", err)
|
||||
}
|
||||
|
||||
return server.Serve(ctx, l)
|
||||
}
|
||||
|
||||
|
||||
@@ -61,14 +61,6 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor
|
||||
return nil, xerrors.Errorf("get workspace by agent id: %w", err)
|
||||
}
|
||||
|
||||
// Some older clients may incorrectly report "localhost" as the IP address.
|
||||
// Related to https://github.com/coder/coder/issues/20194
|
||||
logIPRaw := req.GetConnection().GetIp()
|
||||
if logIPRaw == "localhost" {
|
||||
logIPRaw = "127.0.0.1"
|
||||
}
|
||||
logIP := database.ParseIP(logIPRaw) // will return null if invalid
|
||||
|
||||
reason := req.GetConnection().GetReason()
|
||||
connLogger := *a.ConnectionLogger.Load()
|
||||
err = connLogger.Upsert(ctx, database.UpsertConnectionLogParams{
|
||||
@@ -81,7 +73,7 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor
|
||||
AgentName: workspaceAgent.Name,
|
||||
Type: connectionType,
|
||||
Code: code,
|
||||
Ip: logIP,
|
||||
Ip: database.ParseIP(req.GetConnection().GetIp()),
|
||||
ConnectionID: uuid.NullUUID{
|
||||
UUID: connectionID,
|
||||
Valid: true,
|
||||
|
||||
@@ -3,11 +3,13 @@ package agentapi_test
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
@@ -73,9 +75,6 @@ func TestConnectionLog(t *testing.T) {
|
||||
action: agentproto.Connection_CONNECT.Enum(),
|
||||
typ: agentproto.Connection_JETBRAINS.Enum(),
|
||||
time: dbtime.Now(),
|
||||
// Sometimes, JetBrains clients report as localhost, see
|
||||
// https://github.com/coder/coder/issues/20194
|
||||
ip: "localhost",
|
||||
},
|
||||
{
|
||||
name: "Reconnecting PTY Connect",
|
||||
@@ -130,12 +129,6 @@ func TestConnectionLog(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
expectedIPRaw := tt.ip
|
||||
if expectedIPRaw == "localhost" {
|
||||
expectedIPRaw = "127.0.0.1"
|
||||
}
|
||||
expectedIP := database.ParseIP(expectedIPRaw)
|
||||
|
||||
require.True(t, connLogger.Contains(t, database.UpsertConnectionLogParams{
|
||||
Time: dbtime.Time(tt.time).In(time.UTC),
|
||||
OrganizationID: workspace.OrganizationID,
|
||||
@@ -153,7 +146,7 @@ func TestConnectionLog(t *testing.T) {
|
||||
Int32: tt.status,
|
||||
Valid: *tt.action == agentproto.Connection_DISCONNECT,
|
||||
},
|
||||
Ip: expectedIP,
|
||||
Ip: pqtype.Inet{Valid: true, IPNet: net.IPNet{IP: net.ParseIP(tt.ip), Mask: net.CIDRMask(32, 32)}},
|
||||
Type: agentProtoConnectionTypeToConnectionLog(t, *tt.typ),
|
||||
DisconnectReason: sql.NullString{
|
||||
String: tt.reason,
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
@@ -34,60 +33,42 @@ type ResourcesMonitoringAPI struct {
|
||||
|
||||
Debounce time.Duration
|
||||
Config resourcesmonitor.Config
|
||||
|
||||
// Cache resource monitors on first call to avoid millions of DB queries per day.
|
||||
memoryMonitor database.WorkspaceAgentMemoryResourceMonitor
|
||||
volumeMonitors []database.WorkspaceAgentVolumeResourceMonitor
|
||||
monitorsLock sync.RWMutex
|
||||
}
|
||||
|
||||
// InitMonitors fetches resource monitors from the database and caches them.
|
||||
// This must be called once after creating a ResourcesMonitoringAPI, the context should be
|
||||
// the agent per-RPC connection context. If fetching fails with a real error (not sql.ErrNoRows), the
|
||||
// connection should be torn down.
|
||||
func (a *ResourcesMonitoringAPI) InitMonitors(ctx context.Context) error {
|
||||
memMon, err := a.Database.FetchMemoryResourceMonitorsByAgentID(ctx, a.AgentID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return xerrors.Errorf("fetch memory resource monitor: %w", err)
|
||||
}
|
||||
// If sql.ErrNoRows, memoryMonitor stays as zero value (CreatedAt.IsZero() = true).
|
||||
// Otherwise, store the fetched monitor.
|
||||
if err == nil {
|
||||
a.memoryMonitor = memMon
|
||||
func (a *ResourcesMonitoringAPI) GetResourcesMonitoringConfiguration(ctx context.Context, _ *proto.GetResourcesMonitoringConfigurationRequest) (*proto.GetResourcesMonitoringConfigurationResponse, error) {
|
||||
memoryMonitor, memoryErr := a.Database.FetchMemoryResourceMonitorsByAgentID(ctx, a.AgentID)
|
||||
if memoryErr != nil && !errors.Is(memoryErr, sql.ErrNoRows) {
|
||||
return nil, xerrors.Errorf("failed to fetch memory resource monitor: %w", memoryErr)
|
||||
}
|
||||
|
||||
volMons, err := a.Database.FetchVolumesResourceMonitorsByAgentID(ctx, a.AgentID)
|
||||
volumeMonitors, err := a.Database.FetchVolumesResourceMonitorsByAgentID(ctx, a.AgentID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch volume resource monitors: %w", err)
|
||||
return nil, xerrors.Errorf("failed to fetch volume resource monitors: %w", err)
|
||||
}
|
||||
// 0 length is valid, indicating none configured, since the volume monitors in the DB can be many.
|
||||
a.volumeMonitors = volMons
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ResourcesMonitoringAPI) GetResourcesMonitoringConfiguration(_ context.Context, _ *proto.GetResourcesMonitoringConfigurationRequest) (*proto.GetResourcesMonitoringConfigurationResponse, error) {
|
||||
return &proto.GetResourcesMonitoringConfigurationResponse{
|
||||
Config: &proto.GetResourcesMonitoringConfigurationResponse_Config{
|
||||
CollectionIntervalSeconds: int32(a.Config.CollectionInterval.Seconds()),
|
||||
NumDatapoints: a.Config.NumDatapoints,
|
||||
},
|
||||
Memory: func() *proto.GetResourcesMonitoringConfigurationResponse_Memory {
|
||||
if a.memoryMonitor.CreatedAt.IsZero() {
|
||||
if memoryErr != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &proto.GetResourcesMonitoringConfigurationResponse_Memory{
|
||||
Enabled: a.memoryMonitor.Enabled,
|
||||
Enabled: memoryMonitor.Enabled,
|
||||
}
|
||||
}(),
|
||||
Volumes: func() []*proto.GetResourcesMonitoringConfigurationResponse_Volume {
|
||||
volumes := make([]*proto.GetResourcesMonitoringConfigurationResponse_Volume, 0, len(a.volumeMonitors))
|
||||
for _, monitor := range a.volumeMonitors {
|
||||
volumes := make([]*proto.GetResourcesMonitoringConfigurationResponse_Volume, 0, len(volumeMonitors))
|
||||
for _, monitor := range volumeMonitors {
|
||||
volumes = append(volumes, &proto.GetResourcesMonitoringConfigurationResponse_Volume{
|
||||
Enabled: monitor.Enabled,
|
||||
Path: monitor.Path,
|
||||
})
|
||||
}
|
||||
|
||||
return volumes
|
||||
}(),
|
||||
}, nil
|
||||
@@ -96,10 +77,6 @@ func (a *ResourcesMonitoringAPI) GetResourcesMonitoringConfiguration(_ context.C
|
||||
func (a *ResourcesMonitoringAPI) PushResourcesMonitoringUsage(ctx context.Context, req *proto.PushResourcesMonitoringUsageRequest) (*proto.PushResourcesMonitoringUsageResponse, error) {
|
||||
var err error
|
||||
|
||||
// Lock for the entire push operation since calls are sequential from the agent
|
||||
a.monitorsLock.Lock()
|
||||
defer a.monitorsLock.Unlock()
|
||||
|
||||
if memoryErr := a.monitorMemory(ctx, req.Datapoints); memoryErr != nil {
|
||||
err = errors.Join(err, xerrors.Errorf("monitor memory: %w", memoryErr))
|
||||
}
|
||||
@@ -112,7 +89,18 @@ func (a *ResourcesMonitoringAPI) PushResourcesMonitoringUsage(ctx context.Contex
|
||||
}
|
||||
|
||||
func (a *ResourcesMonitoringAPI) monitorMemory(ctx context.Context, datapoints []*proto.PushResourcesMonitoringUsageRequest_Datapoint) error {
|
||||
if !a.memoryMonitor.Enabled {
|
||||
monitor, err := a.Database.FetchMemoryResourceMonitorsByAgentID(ctx, a.AgentID)
|
||||
if err != nil {
|
||||
// It is valid for an agent to not have a memory monitor, so we
|
||||
// do not want to treat it as an error.
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return xerrors.Errorf("fetch memory resource monitor: %w", err)
|
||||
}
|
||||
|
||||
if !monitor.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -121,15 +109,15 @@ func (a *ResourcesMonitoringAPI) monitorMemory(ctx context.Context, datapoints [
|
||||
usageDatapoints = append(usageDatapoints, datapoint.Memory)
|
||||
}
|
||||
|
||||
usageStates := resourcesmonitor.CalculateMemoryUsageStates(a.memoryMonitor, usageDatapoints)
|
||||
usageStates := resourcesmonitor.CalculateMemoryUsageStates(monitor, usageDatapoints)
|
||||
|
||||
oldState := a.memoryMonitor.State
|
||||
oldState := monitor.State
|
||||
newState := resourcesmonitor.NextState(a.Config, oldState, usageStates)
|
||||
|
||||
debouncedUntil, shouldNotify := a.memoryMonitor.Debounce(a.Debounce, a.Clock.Now(), oldState, newState)
|
||||
debouncedUntil, shouldNotify := monitor.Debounce(a.Debounce, a.Clock.Now(), oldState, newState)
|
||||
|
||||
//nolint:gocritic // We need to be able to update the resource monitor here.
|
||||
err := a.Database.UpdateMemoryResourceMonitor(dbauthz.AsResourceMonitor(ctx), database.UpdateMemoryResourceMonitorParams{
|
||||
err = a.Database.UpdateMemoryResourceMonitor(dbauthz.AsResourceMonitor(ctx), database.UpdateMemoryResourceMonitorParams{
|
||||
AgentID: a.AgentID,
|
||||
State: newState,
|
||||
UpdatedAt: dbtime.Time(a.Clock.Now()),
|
||||
@@ -139,11 +127,6 @@ func (a *ResourcesMonitoringAPI) monitorMemory(ctx context.Context, datapoints [
|
||||
return xerrors.Errorf("update workspace monitor: %w", err)
|
||||
}
|
||||
|
||||
// Update cached state
|
||||
a.memoryMonitor.State = newState
|
||||
a.memoryMonitor.DebouncedUntil = dbtime.Time(debouncedUntil)
|
||||
a.memoryMonitor.UpdatedAt = dbtime.Time(a.Clock.Now())
|
||||
|
||||
if !shouldNotify {
|
||||
return nil
|
||||
}
|
||||
@@ -160,7 +143,7 @@ func (a *ResourcesMonitoringAPI) monitorMemory(ctx context.Context, datapoints [
|
||||
notifications.TemplateWorkspaceOutOfMemory,
|
||||
map[string]string{
|
||||
"workspace": workspace.Name,
|
||||
"threshold": fmt.Sprintf("%d%%", a.memoryMonitor.Threshold),
|
||||
"threshold": fmt.Sprintf("%d%%", monitor.Threshold),
|
||||
},
|
||||
map[string]any{
|
||||
// NOTE(DanielleMaywood):
|
||||
@@ -186,9 +169,14 @@ func (a *ResourcesMonitoringAPI) monitorMemory(ctx context.Context, datapoints [
|
||||
}
|
||||
|
||||
func (a *ResourcesMonitoringAPI) monitorVolumes(ctx context.Context, datapoints []*proto.PushResourcesMonitoringUsageRequest_Datapoint) error {
|
||||
volumeMonitors, err := a.Database.FetchVolumesResourceMonitorsByAgentID(ctx, a.AgentID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get or insert volume monitor: %w", err)
|
||||
}
|
||||
|
||||
outOfDiskVolumes := make([]map[string]any, 0)
|
||||
|
||||
for i, monitor := range a.volumeMonitors {
|
||||
for _, monitor := range volumeMonitors {
|
||||
if !monitor.Enabled {
|
||||
continue
|
||||
}
|
||||
@@ -231,11 +219,6 @@ func (a *ResourcesMonitoringAPI) monitorVolumes(ctx context.Context, datapoints
|
||||
}); err != nil {
|
||||
return xerrors.Errorf("update workspace monitor: %w", err)
|
||||
}
|
||||
|
||||
// Update cached state
|
||||
a.volumeMonitors[i].State = newState
|
||||
a.volumeMonitors[i].DebouncedUntil = dbtime.Time(debouncedUntil)
|
||||
a.volumeMonitors[i].UpdatedAt = dbtime.Time(a.Clock.Now())
|
||||
}
|
||||
|
||||
if len(outOfDiskVolumes) == 0 {
|
||||
|
||||
@@ -101,9 +101,6 @@ func TestMemoryResourceMonitorDebounce(t *testing.T) {
|
||||
Threshold: 80,
|
||||
})
|
||||
|
||||
// Initialize API to fetch and cache the monitors
|
||||
require.NoError(t, api.InitMonitors(context.Background()))
|
||||
|
||||
// When: The monitor is given a state that will trigger NOK
|
||||
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
|
||||
Datapoints: []*agentproto.PushResourcesMonitoringUsageRequest_Datapoint{
|
||||
@@ -307,9 +304,6 @@ func TestMemoryResourceMonitor(t *testing.T) {
|
||||
Threshold: 80,
|
||||
})
|
||||
|
||||
// Initialize API to fetch and cache the monitors
|
||||
require.NoError(t, api.InitMonitors(context.Background()))
|
||||
|
||||
clock.Set(collectedAt)
|
||||
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
|
||||
Datapoints: datapoints,
|
||||
@@ -343,8 +337,6 @@ func TestMemoryResourceMonitorMissingData(t *testing.T) {
|
||||
State: database.WorkspaceAgentMonitorStateOK,
|
||||
Threshold: 80,
|
||||
})
|
||||
// Initialize API to fetch and cache the monitors
|
||||
require.NoError(t, api.InitMonitors(context.Background()))
|
||||
|
||||
// When: A datapoint is missing, surrounded by two NOK datapoints.
|
||||
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
|
||||
@@ -395,9 +387,6 @@ func TestMemoryResourceMonitorMissingData(t *testing.T) {
|
||||
Threshold: 80,
|
||||
})
|
||||
|
||||
// Initialize API to fetch and cache the monitors
|
||||
require.NoError(t, api.InitMonitors(context.Background()))
|
||||
|
||||
// When: A datapoint is missing, surrounded by two OK datapoints.
|
||||
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
|
||||
Datapoints: []*agentproto.PushResourcesMonitoringUsageRequest_Datapoint{
|
||||
@@ -477,9 +466,6 @@ func TestVolumeResourceMonitorDebounce(t *testing.T) {
|
||||
Threshold: 80,
|
||||
})
|
||||
|
||||
// Initialize API to fetch and cache the monitors
|
||||
require.NoError(t, api.InitMonitors(context.Background()))
|
||||
|
||||
// When:
|
||||
// - First monitor is in a NOK state
|
||||
// - Second monitor is in an OK state
|
||||
@@ -756,9 +742,6 @@ func TestVolumeResourceMonitor(t *testing.T) {
|
||||
Threshold: tt.thresholdPercent,
|
||||
})
|
||||
|
||||
// Initialize API to fetch and cache the monitors
|
||||
require.NoError(t, api.InitMonitors(context.Background()))
|
||||
|
||||
clock.Set(collectedAt)
|
||||
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
|
||||
Datapoints: datapoints,
|
||||
@@ -797,9 +780,6 @@ func TestVolumeResourceMonitorMultiple(t *testing.T) {
|
||||
Threshold: 80,
|
||||
})
|
||||
|
||||
// Initialize API to fetch and cache the monitors
|
||||
require.NoError(t, api.InitMonitors(context.Background()))
|
||||
|
||||
// When: both of them move to a NOK state
|
||||
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
|
||||
Datapoints: []*agentproto.PushResourcesMonitoringUsageRequest_Datapoint{
|
||||
@@ -852,9 +832,6 @@ func TestVolumeResourceMonitorMissingData(t *testing.T) {
|
||||
Threshold: 80,
|
||||
})
|
||||
|
||||
// Initialize API to fetch and cache the monitors
|
||||
require.NoError(t, api.InitMonitors(context.Background()))
|
||||
|
||||
// When: A datapoint is missing, surrounded by two NOK datapoints.
|
||||
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
|
||||
Datapoints: []*agentproto.PushResourcesMonitoringUsageRequest_Datapoint{
|
||||
@@ -914,9 +891,6 @@ func TestVolumeResourceMonitorMissingData(t *testing.T) {
|
||||
Threshold: 80,
|
||||
})
|
||||
|
||||
// Initialize API to fetch and cache the monitors
|
||||
require.NoError(t, api.InitMonitors(context.Background()))
|
||||
|
||||
// When: A datapoint is missing, surrounded by two OK datapoints.
|
||||
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
|
||||
Datapoints: []*agentproto.PushResourcesMonitoringUsageRequest_Datapoint{
|
||||
|
||||
+515
-409
File diff suppressed because it is too large
Load Diff
+268
-633
File diff suppressed because it is too large
Load Diff
Generated
+16
-1109
File diff suppressed because it is too large
Load Diff
Generated
+16
-1066
File diff suppressed because it is too large
Load Diff
@@ -116,37 +116,6 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
|
||||
TokenName: tokenName,
|
||||
}
|
||||
|
||||
if len(createToken.AllowList) > 0 {
|
||||
rbacAllowListElements := make([]rbac.AllowListElement, 0, len(createToken.AllowList))
|
||||
for _, t := range createToken.AllowList {
|
||||
entry, err := rbac.NewAllowListElement(string(t.Type), t.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to create API key.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
rbacAllowListElements = append(rbacAllowListElements, entry)
|
||||
}
|
||||
|
||||
rbacAllowList, err := rbac.NormalizeAllowList(rbacAllowListElements)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to create API key.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dbAllowList := make(database.AllowList, 0, len(rbacAllowList))
|
||||
for _, e := range rbacAllowList {
|
||||
dbAllowList = append(dbAllowList, rbac.AllowListElement{Type: e.Type, ID: e.ID})
|
||||
}
|
||||
|
||||
params.AllowList = dbAllowList
|
||||
}
|
||||
|
||||
if createToken.Lifetime != 0 {
|
||||
err := api.validateAPIKeyLifetime(ctx, user.ID, createToken.Lifetime)
|
||||
if err != nil {
|
||||
|
||||
+16
-37
@@ -2,7 +2,6 @@ package apikey
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
)
|
||||
|
||||
@@ -36,26 +34,18 @@ type CreateParams struct {
|
||||
Scopes database.APIKeyScopes
|
||||
TokenName string
|
||||
RemoteAddr string
|
||||
// AllowList is an optional, normalized allow-list
|
||||
// of resource type and uuid entries. If empty, defaults to wildcard.
|
||||
AllowList database.AllowList
|
||||
}
|
||||
|
||||
// Generate generates an API key, returning the key as a string as well as the
|
||||
// database representation. It is the responsibility of the caller to insert it
|
||||
// into the database.
|
||||
func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) {
|
||||
// Length of an API Key ID.
|
||||
keyID, err := cryptorand.String(10)
|
||||
keyID, keySecret, err := generateKey()
|
||||
if err != nil {
|
||||
return database.InsertAPIKeyParams{}, "", xerrors.Errorf("generate API key ID: %w", err)
|
||||
return database.InsertAPIKeyParams{}, "", xerrors.Errorf("generate API key: %w", err)
|
||||
}
|
||||
|
||||
// Length of an API Key secret.
|
||||
keySecret, hashedSecret, err := GenerateSecret(22)
|
||||
if err != nil {
|
||||
return database.InsertAPIKeyParams{}, "", xerrors.Errorf("generate API key secret: %w", err)
|
||||
}
|
||||
hashed := sha256.Sum256([]byte(keySecret))
|
||||
|
||||
// Default expires at to now+lifetime, or use the configured value if not
|
||||
// set.
|
||||
@@ -71,10 +61,6 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error)
|
||||
params.LifetimeSeconds = int64(time.Until(params.ExpiresAt).Seconds())
|
||||
}
|
||||
|
||||
if len(params.AllowList) == 0 {
|
||||
params.AllowList = database.AllowList{{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol}}
|
||||
}
|
||||
|
||||
ip := net.ParseIP(params.RemoteAddr)
|
||||
if ip == nil {
|
||||
ip = net.IPv4(0, 0, 0, 0)
|
||||
@@ -126,32 +112,25 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error)
|
||||
ExpiresAt: params.ExpiresAt.UTC(),
|
||||
CreatedAt: dbtime.Now(),
|
||||
UpdatedAt: dbtime.Now(),
|
||||
HashedSecret: hashedSecret,
|
||||
HashedSecret: hashed[:],
|
||||
LoginType: params.LoginType,
|
||||
Scopes: scopes,
|
||||
AllowList: params.AllowList,
|
||||
AllowList: database.AllowList{database.AllowListWildcard()},
|
||||
TokenName: params.TokenName,
|
||||
}, token, nil
|
||||
}
|
||||
|
||||
func GenerateSecret(length int) (secret string, hashed []byte, err error) {
|
||||
secret, err = cryptorand.String(length)
|
||||
// generateKey a new ID and secret for an API key.
|
||||
func generateKey() (id string, secret string, err error) {
|
||||
// Length of an API Key ID.
|
||||
id, err = cryptorand.String(10)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", "", err
|
||||
}
|
||||
hash := HashSecret(secret)
|
||||
return secret, hash, nil
|
||||
}
|
||||
|
||||
// ValidateHash compares a secret against an expected hashed secret.
|
||||
func ValidateHash(hashedSecret []byte, secret string) bool {
|
||||
hash := HashSecret(secret)
|
||||
return subtle.ConstantTimeCompare(hashedSecret, hash) == 1
|
||||
}
|
||||
|
||||
// HashSecret is the single function used to hash API key secrets.
|
||||
// Use this to ensure a consistent hashing algorithm.
|
||||
func HashSecret(secret string) []byte {
|
||||
hash := sha256.Sum256([]byte(secret))
|
||||
return hash[:]
|
||||
// Length of an API Key secret.
|
||||
secret, err = cryptorand.String(22)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return id, secret, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apikey_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -125,8 +126,8 @@ func TestGenerate(t *testing.T) {
|
||||
require.Equal(t, key.ID, keytokens[0])
|
||||
|
||||
// Assert that the hashed secret is correct.
|
||||
equal := apikey.ValidateHash(key.HashedSecret, keytokens[1])
|
||||
require.True(t, equal, "valid secret")
|
||||
hashed := sha256.Sum256([]byte(keytokens[1]))
|
||||
assert.ElementsMatch(t, hashed, key.HashedSecret)
|
||||
|
||||
assert.Equal(t, tc.params.UserID, key.UserID)
|
||||
assert.WithinDuration(t, dbtime.Now(), key.CreatedAt, time.Second*5)
|
||||
@@ -172,17 +173,3 @@ func TestGenerate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalid just ensures the false case is asserted by some tests.
|
||||
// Otherwise, a function that just `returns true` might pass all tests incorrectly.
|
||||
func TestInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Falsef(t, apikey.ValidateHash([]byte{}, "secret"), "empty hash")
|
||||
|
||||
secret, hash, err := apikey.GenerateSecret(10)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Falsef(t, apikey.ValidateHash(hash, secret+"_"), "different secret")
|
||||
require.Falsef(t, apikey.ValidateHash(hash[:len(hash)-1], secret), "different hash length")
|
||||
}
|
||||
|
||||
@@ -51,8 +51,6 @@ func TestTokenCRUD(t *testing.T) {
|
||||
require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*24*6))
|
||||
require.Less(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*24*8))
|
||||
require.Equal(t, codersdk.APIKeyScopeAll, keys[0].Scope)
|
||||
require.Len(t, keys[0].AllowList, 1)
|
||||
require.Equal(t, "*:*", keys[0].AllowList[0].String())
|
||||
|
||||
// no update
|
||||
|
||||
@@ -88,8 +86,6 @@ func TestTokenScoped(t *testing.T) {
|
||||
require.EqualValues(t, len(keys), 1)
|
||||
require.Contains(t, res.Key, keys[0].ID)
|
||||
require.Equal(t, keys[0].Scope, codersdk.APIKeyScopeApplicationConnect)
|
||||
require.Len(t, keys[0].AllowList, 1)
|
||||
require.Equal(t, "*:*", keys[0].AllowList[0].String())
|
||||
}
|
||||
|
||||
// Ensure backward-compat: when a token is created using the legacy singular
|
||||
@@ -136,8 +132,6 @@ func TestTokenLegacySingularScopeCompat(t *testing.T) {
|
||||
require.Len(t, keys, 1)
|
||||
require.Equal(t, tc.scope, keys[0].Scope)
|
||||
require.ElementsMatch(t, keys[0].Scopes, tc.scopes)
|
||||
require.Len(t, keys[0].AllowList, 1)
|
||||
require.Equal(t, "*:*", keys[0].AllowList[0].String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,14 +420,6 @@ func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.Get
|
||||
api.Logger.Error(ctx, "unable to fetch oauth2 app secret", slog.Error(err))
|
||||
}
|
||||
return false
|
||||
case database.ResourceTypeTask:
|
||||
task, err := api.Database.GetTaskByID(ctx, alog.AuditLog.ResourceID)
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
return true
|
||||
} else if err != nil {
|
||||
api.Logger.Error(ctx, "unable to fetch task", slog.Error(err))
|
||||
}
|
||||
return task.DeletedAt.Valid && task.DeletedAt.Time.Before(time.Now())
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -504,17 +496,6 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit
|
||||
}
|
||||
return fmt.Sprintf("/deployment/oauth2-provider/apps/%s", secret.AppID)
|
||||
|
||||
case database.ResourceTypeTask:
|
||||
task, err := api.Database.GetTaskByID(ctx, alog.AuditLog.ResourceID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
user, err := api.Database.GetUserByID(ctx, task.OwnerID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("/tasks/%s/%s", user.Username, task.ID)
|
||||
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -31,8 +31,7 @@ type Auditable interface {
|
||||
database.NotificationTemplate |
|
||||
idpsync.OrganizationSyncSettings |
|
||||
idpsync.GroupSyncSettings |
|
||||
idpsync.RoleSyncSettings |
|
||||
database.TaskTable
|
||||
idpsync.RoleSyncSettings
|
||||
}
|
||||
|
||||
// Map is a map of changed fields in an audited resource. It maps field names to
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user