Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 603e68cc80 |
@@ -113,7 +113,7 @@ Coder emphasizes clear error handling, with specific patterns required:
|
||||
|
||||
All tests should run in parallel using `t.Parallel()` to ensure efficient testing and expose potential race conditions. The codebase is rigorously linted with golangci-lint to maintain consistent code quality.
|
||||
|
||||
Git contributions follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). See [CONTRIBUTING.md](docs/about/contributing/CONTRIBUTING.md#commit-messages) for full rules. PR titles are linted in CI.
|
||||
Git contributions follow a standard format with commit messages structured as `type: <message>`, where type is one of `feat`, `fix`, or `chore`.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
|
||||
@@ -189,8 +189,8 @@ func (q *sqlQuerier) UpdateUser(ctx context.Context, arg UpdateUserParams) (User
|
||||
### Common Debug Commands
|
||||
|
||||
```bash
|
||||
# Run tests (starts Postgres automatically if needed)
|
||||
make test
|
||||
# Check database connection
|
||||
make test-postgres
|
||||
|
||||
# Run specific database tests
|
||||
go test ./coderd/database/... -run TestSpecificFunction
|
||||
|
||||
@@ -4,13 +4,22 @@ This guide documents the PR description style used in the Coder repository, base
|
||||
|
||||
## PR Title Format
|
||||
|
||||
Format: `type(scope): description`. See [CONTRIBUTING.md](docs/about/contributing/CONTRIBUTING.md#commit-messages) for full rules. PR titles are linted in CI.
|
||||
Follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) format:
|
||||
|
||||
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
|
||||
- Scopes must be a real path (directory or file stem) containing all changed files
|
||||
- Omit scope if changes span multiple top-level directories
|
||||
```text
|
||||
type(scope): brief description
|
||||
```
|
||||
|
||||
Examples:
|
||||
**Common types:**
|
||||
|
||||
- `feat`: New features
|
||||
- `fix`: Bug fixes
|
||||
- `refactor`: Code refactoring without behavior change
|
||||
- `perf`: Performance improvements
|
||||
- `docs`: Documentation changes
|
||||
- `chore`: Dependency updates, tooling changes
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `feat: add tracing to aibridge`
|
||||
- `fix: move contexts to appropriate locations`
|
||||
|
||||
@@ -67,6 +67,7 @@ coderd/
|
||||
| `make test` | Run all Go tests |
|
||||
| `make test RUN=TestFunctionName` | Run specific test |
|
||||
| `go test -v ./path/to/package -run TestFunctionName` | Run test with verbose output |
|
||||
| `make test-postgres` | Run tests with Postgres database |
|
||||
| `make test-race` | Run tests with Go race detector |
|
||||
| `make test-e2e` | Run end-to-end tests |
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
|
||||
- Run full test suite: `make test`
|
||||
- Run specific test: `make test RUN=TestFunctionName`
|
||||
- Run with Postgres: `make test-postgres`
|
||||
- Run with race detector: `make test-race`
|
||||
- Run end-to-end tests: `make test-e2e`
|
||||
|
||||
@@ -136,11 +137,9 @@ Then make your changes and push normally. Don't use `git push --force` unless th
|
||||
|
||||
## Commit Style
|
||||
|
||||
Format: `type(scope): message`. See [CONTRIBUTING.md](docs/about/contributing/CONTRIBUTING.md#commit-messages) for full rules. PR titles are linted in CI.
|
||||
|
||||
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
|
||||
- Scopes must be a real path (directory or file stem) containing all changed files
|
||||
- Omit scope if changes span multiple top-level directories
|
||||
- Follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- Format: `type(scope): message`
|
||||
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
||||
- Keep message titles concise (~70 characters)
|
||||
- Use imperative, present tense in commit titles
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: "🐞 Bug"
|
||||
description: "File a bug report."
|
||||
title: "bug: "
|
||||
labels: ["needs-triage"]
|
||||
type: "Bug"
|
||||
body:
|
||||
- type: checkboxes
|
||||
|
||||
@@ -64,14 +64,17 @@ runs:
|
||||
TEST_PACKAGES: ${{ inputs.test-packages }}
|
||||
RACE_DETECTION: ${{ inputs.race-detection }}
|
||||
TS_DEBUG_DISCO: "true"
|
||||
TS_DEBUG_DERP: "true"
|
||||
LC_CTYPE: "en_US.UTF-8"
|
||||
LC_ALL: "en_US.UTF-8"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ${RACE_DETECTION} == true ]]; then
|
||||
make test-race
|
||||
gotestsum --junitfile="gotests.xml" --packages="${TEST_PACKAGES}" -- \
|
||||
-tags=testsmallbatch \
|
||||
-race \
|
||||
-parallel "${TEST_NUM_PARALLEL_TESTS}" \
|
||||
-p "${TEST_NUM_PARALLEL_PACKAGES}"
|
||||
else
|
||||
make test
|
||||
fi
|
||||
|
||||
+18
-78
@@ -366,9 +366,9 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
# This timeout must be greater than the timeout set by `go test` in
|
||||
# `make test` to ensure we receive a trace of running goroutines.
|
||||
# Setting this to the timeout +5m should work quite well even if
|
||||
# some of the preceding steps are slow.
|
||||
# `make test-postgres` to ensure we receive a trace of running
|
||||
# goroutines. Setting this to the timeout +5m should work quite well
|
||||
# even if some of the preceding steps are slow.
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -475,6 +475,11 @@ jobs:
|
||||
mkdir -p /tmp/tmpfs
|
||||
sudo mount_tmpfs -o noowners -s 8g /tmp/tmpfs
|
||||
|
||||
# Install google-chrome for scaletests.
|
||||
# As another concern, should we really have this kind of external dependency
|
||||
# requirement on standard CI?
|
||||
brew install google-chrome
|
||||
|
||||
# macOS will output "The default interactive shell is now zsh" intermittently in CI.
|
||||
touch ~/.bash_profile && echo "export BASH_SILENCE_DEPRECATION_WARNING=1" >> ~/.bash_profile
|
||||
|
||||
@@ -569,9 +574,9 @@ jobs:
|
||||
- changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
# This timeout must be greater than the timeout set by `go test` in
|
||||
# `make test` to ensure we receive a trace of running goroutines.
|
||||
# Setting this to the timeout +5m should work quite well even if
|
||||
# some of the preceding steps are slow.
|
||||
# `make test-postgres` to ensure we receive a trace of running
|
||||
# goroutines. Setting this to the timeout +5m should work quite well
|
||||
# even if some of the preceding steps are slow.
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -981,9 +986,6 @@ jobs:
|
||||
run: |
|
||||
make build/coder_docs_"$(./scripts/version.sh)".tgz
|
||||
|
||||
- name: Check for unstaged files
|
||||
run: ./scripts/check_unstaged.sh
|
||||
|
||||
required:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
@@ -1198,7 +1200,7 @@ jobs:
|
||||
make -j \
|
||||
build/coder_linux_{amd64,arm64,armv7} \
|
||||
build/coder_"$version"_windows_amd64.zip \
|
||||
build/coder_"$version"_linux_{amd64,arm64,armv7}.{tar.gz,deb}
|
||||
build/coder_"$version"_linux_amd64.{tar.gz,deb}
|
||||
env:
|
||||
# The Windows and Darwin slim binaries must be signed for Coder
|
||||
# Desktop to accept them.
|
||||
@@ -1216,28 +1218,11 @@ jobs:
|
||||
GCLOUD_ACCESS_TOKEN: ${{ steps.gcloud_auth.outputs.access_token }}
|
||||
JSIGN_PATH: /tmp/jsign-6.0.jar
|
||||
|
||||
# Free up disk space before building Docker images. The preceding
|
||||
# Build step produces ~2 GB of binaries and packages, the Go build
|
||||
# cache is ~1.3 GB, and node_modules is ~500 MB. Docker image
|
||||
# builds, pushes, and SBOM generation need headroom that isn't
|
||||
# available without reclaiming some of that space.
|
||||
- name: Clean up build cache
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
# Go caches are no longer needed — binaries are already compiled.
|
||||
go clean -cache -modcache
|
||||
# Remove .apk and .rpm packages that are not uploaded as
|
||||
# artifacts and were only built as make prerequisites.
|
||||
rm -f ./build/*.apk ./build/*.rpm
|
||||
|
||||
- name: Build Linux Docker images
|
||||
id: build-docker
|
||||
env:
|
||||
CODER_IMAGE_BASE: ghcr.io/coder/coder-preview
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
# Skip building .deb/.rpm/.apk/.tar.gz as prerequisites for
|
||||
# the Docker image targets — they were already built above.
|
||||
DOCKER_IMAGE_NO_PREREQUISITES: "true"
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
|
||||
@@ -1455,60 +1440,15 @@ jobs:
|
||||
^v
|
||||
prune-untagged: true
|
||||
|
||||
- name: Upload build artifact (coder-linux-amd64.tar.gz)
|
||||
- name: Upload build artifacts
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: coder-linux-amd64.tar.gz
|
||||
path: ./build/*_linux_amd64.tar.gz
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload build artifact (coder-linux-amd64.deb)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: coder-linux-amd64.deb
|
||||
path: ./build/*_linux_amd64.deb
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload build artifact (coder-linux-arm64.tar.gz)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: coder-linux-arm64.tar.gz
|
||||
path: ./build/*_linux_arm64.tar.gz
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload build artifact (coder-linux-arm64.deb)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: coder-linux-arm64.deb
|
||||
path: ./build/*_linux_arm64.deb
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload build artifact (coder-linux-armv7.tar.gz)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: coder-linux-armv7.tar.gz
|
||||
path: ./build/*_linux_armv7.tar.gz
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload build artifact (coder-linux-armv7.deb)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: coder-linux-armv7.deb
|
||||
path: ./build/*_linux_armv7.deb
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload build artifact (coder-windows-amd64.zip)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: coder-windows-amd64.zip
|
||||
path: ./build/*_windows_amd64.zip
|
||||
name: coder
|
||||
path: |
|
||||
./build/*.zip
|
||||
./build/*.tar.gz
|
||||
./build/*.deb
|
||||
retention-days: 7
|
||||
|
||||
# Deploy is handled in deploy.yaml so we can apply concurrency limits.
|
||||
|
||||
@@ -19,9 +19,6 @@ on:
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
classify-severity:
|
||||
name: AI Severity Classification
|
||||
@@ -35,6 +32,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- name: Determine Issue Context
|
||||
|
||||
@@ -31,9 +31,6 @@ on:
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
code-review:
|
||||
name: AI Code Review
|
||||
@@ -54,6 +51,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- name: Check if secrets are available
|
||||
|
||||
@@ -23,44 +23,6 @@ permissions:
|
||||
concurrency: pr-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
community-label:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'opened' &&
|
||||
github.event.pull_request.author_association != 'MEMBER' &&
|
||||
github.event.pull_request.author_association != 'COLLABORATOR' &&
|
||||
github.event.pull_request.author_association != 'OWNER'
|
||||
}}
|
||||
steps:
|
||||
- name: Add community label
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const params = {
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
}
|
||||
|
||||
const labels = context.payload.pull_request.labels.map((label) => label.name)
|
||||
if (labels.includes("community")) {
|
||||
console.log('PR already has "community" label.')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
'Adding "community" label for author association "%s".',
|
||||
context.payload.pull_request.author_association,
|
||||
)
|
||||
await github.rest.issues.addLabels({
|
||||
...params,
|
||||
labels: ["community"],
|
||||
})
|
||||
|
||||
cla:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -83,109 +45,6 @@ jobs:
|
||||
# Some users have signed a corporate CLA with Coder so are exempt from signing our community one.
|
||||
allowlist: "coryb,aaronlehmann,dependabot*,blink-so*,blinkagent*"
|
||||
|
||||
title:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'pull_request_target' }}
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const { pull_request } = context.payload;
|
||||
const title = pull_request.title;
|
||||
const repo = { owner: context.repo.owner, repo: context.repo.repo };
|
||||
|
||||
const allowedTypes = [
|
||||
"feat", "fix", "docs", "style", "refactor",
|
||||
"perf", "test", "build", "ci", "chore", "revert",
|
||||
];
|
||||
const expectedFormat = `"type(scope): description" or "type: description"`;
|
||||
const guidelinesLink = `See: https://github.com/coder/coder/blob/main/docs/about/contributing/CONTRIBUTING.md#commit-messages`;
|
||||
const scopeHint = (type) =>
|
||||
`Use a broader scope or no scope (e.g., "${type}: ...") for cross-cutting changes.\n` +
|
||||
guidelinesLink;
|
||||
|
||||
console.log("Title: %s", title);
|
||||
|
||||
// Parse conventional commit format: type(scope)!: description
|
||||
const match = title.match(/^(\w+)(\(([^)]*)\))?(!)?\s*:\s*.+/);
|
||||
if (!match) {
|
||||
core.setFailed(
|
||||
`PR title does not match conventional commit format.\n` +
|
||||
`Expected: ${expectedFormat}\n` +
|
||||
`Allowed types: ${allowedTypes.join(", ")}\n` +
|
||||
guidelinesLink
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = match[1];
|
||||
const scope = match[3]; // undefined if no parentheses
|
||||
|
||||
// Validate type.
|
||||
if (!allowedTypes.includes(type)) {
|
||||
core.setFailed(
|
||||
`PR title has invalid type "${type}".\n` +
|
||||
`Expected: ${expectedFormat}\n` +
|
||||
`Allowed types: ${allowedTypes.join(", ")}\n` +
|
||||
guidelinesLink
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no scope, we're done.
|
||||
if (!scope) {
|
||||
console.log("No scope provided, title is valid.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Scope: %s", scope);
|
||||
|
||||
// Fetch changed files.
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
...repo,
|
||||
pull_number: pull_request.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const changedPaths = files.map(f => f.filename);
|
||||
console.log("Changed files: %d", changedPaths.length);
|
||||
|
||||
// Derive scope type from the changed files. The diff is the
|
||||
// source of truth: if files exist under the scope, the path
|
||||
// exists on the PR branch. No need for Contents API calls.
|
||||
const isDir = changedPaths.some(f => f.startsWith(scope + "/"));
|
||||
const isFile = changedPaths.some(f => f === scope);
|
||||
const isStem = changedPaths.some(f => f.startsWith(scope + "."));
|
||||
|
||||
if (!isDir && !isFile && !isStem) {
|
||||
core.setFailed(
|
||||
`PR title scope "${scope}" does not match any files changed in this PR.\n` +
|
||||
`Scopes must reference a path (directory or file stem) that contains changed files.\n` +
|
||||
scopeHint(type)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify all changed files fall under the scope.
|
||||
const outsideFiles = changedPaths.filter(f => {
|
||||
if (isDir && f.startsWith(scope + "/")) return false;
|
||||
if (f === scope) return false;
|
||||
if (isStem && f.startsWith(scope + ".")) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (outsideFiles.length > 0) {
|
||||
const listed = outsideFiles.map(f => " - " + f).join("\n");
|
||||
core.setFailed(
|
||||
`PR title scope "${scope}" does not contain all changed files.\n` +
|
||||
`Files outside scope:\n${listed}\n\n` +
|
||||
scopeHint(type)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("PR title is valid.");
|
||||
|
||||
release-labels:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
if: needs.should-deploy.outputs.verdict == 'DEPLOY'
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # to authenticate to EKS cluster
|
||||
id-token: write
|
||||
packages: write # to retag image as dogfood
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -82,23 +82,27 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
|
||||
with:
|
||||
role-to-assume: ${{ vars.AWS_DOGFOOD_DEPLOY_ROLE }}
|
||||
aws-region: ${{ vars.AWS_DOGFOOD_DEPLOY_REGION }}
|
||||
workload_identity_provider: ${{ vars.GCP_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Get Cluster Credentials
|
||||
run: aws eks update-kubeconfig --name "$AWS_DOGFOOD_CLUSTER_NAME" --region "$AWS_DOGFOOD_DEPLOY_REGION"
|
||||
env:
|
||||
AWS_DOGFOOD_CLUSTER_NAME: ${{ vars.AWS_DOGFOOD_CLUSTER_NAME }}
|
||||
AWS_DOGFOOD_DEPLOY_REGION: ${{ vars.AWS_DOGFOOD_DEPLOY_REGION }}
|
||||
- name: Set up Google Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
|
||||
|
||||
- name: Set up Flux CLI
|
||||
uses: fluxcd/flux2/action@8454b02a32e48d775b9f563cb51fdcb1787b5b93 # v2.7.5
|
||||
with:
|
||||
# Keep this and the github action up to date with the version of flux installed in dogfood cluster
|
||||
version: "2.8.2"
|
||||
version: "2.7.0"
|
||||
|
||||
- name: Get Cluster Credentials
|
||||
uses: google-github-actions/get-gke-credentials@3da1e46a907576cefaa90c484278bb5b259dd395 # v3.0.0
|
||||
with:
|
||||
cluster_name: dogfood-v2
|
||||
location: us-central1-a
|
||||
project_id: coder-dogfood-v2
|
||||
|
||||
# Retag image as dogfood while maintaining the multi-arch manifest
|
||||
- name: Tag image as dogfood
|
||||
@@ -109,16 +113,16 @@ jobs:
|
||||
- name: Reconcile Flux
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
flux --namespace flux-system reconcile --verbose --timeout=5m source git flux-system
|
||||
flux --namespace flux-system reconcile --verbose --timeout=5m source git coder-main
|
||||
flux --namespace flux-system reconcile --verbose --timeout=5m kustomization flux-system
|
||||
flux --namespace flux-system reconcile --verbose --timeout=5m kustomization coder
|
||||
flux --namespace flux-system reconcile --verbose --timeout=5m source chart coder-coder
|
||||
flux --namespace flux-system reconcile --verbose --timeout=5m source chart coder-coder-provisioner
|
||||
flux --namespace coder reconcile --verbose --timeout=10m helmrelease coder
|
||||
flux --namespace coder reconcile --verbose --timeout=10m helmrelease coder-provisioner
|
||||
flux --namespace coder reconcile --verbose --timeout=10m helmrelease coder-provisioner-tagged
|
||||
flux --namespace coder reconcile --verbose --timeout=10m helmrelease coder-provisioner-tagged-prebuilds
|
||||
flux --namespace flux-system reconcile source git flux-system
|
||||
flux --namespace flux-system reconcile source git coder-main
|
||||
flux --namespace flux-system reconcile kustomization flux-system
|
||||
flux --namespace flux-system reconcile kustomization coder
|
||||
flux --namespace flux-system reconcile source chart coder-coder
|
||||
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
|
||||
|
||||
@@ -34,9 +34,6 @@ on:
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
doc-check:
|
||||
name: Analyze PR for Documentation Updates Needed
|
||||
@@ -59,6 +56,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- name: Check if secrets are available
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Linear Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# This event reads the workflow from the default branch (main), not the
|
||||
# release branch. No cherry-pick needed.
|
||||
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync issues to Linear release
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Sync issues
|
||||
id: sync
|
||||
uses: linear/linear-release-action@f64cdc603e6eb7a7ef934bc5492ae929f88c8d1a # v0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
command: sync
|
||||
|
||||
- name: Print release URL
|
||||
if: steps.sync.outputs.release-url
|
||||
run: echo "Synced to $RELEASE_URL"
|
||||
env:
|
||||
RELEASE_URL: ${{ steps.sync.outputs.release-url }}
|
||||
|
||||
complete:
|
||||
name: Complete Linear release
|
||||
if: github.event_name == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Complete release
|
||||
id: complete
|
||||
uses: linear/linear-release-action@f64cdc603e6eb7a7ef934bc5492ae929f88c8d1a # v0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
command: complete
|
||||
version: ${{ github.event.release.tag_name }}
|
||||
|
||||
- name: Print release URL
|
||||
if: steps.complete.outputs.release-url
|
||||
run: echo "Completed $RELEASE_URL"
|
||||
env:
|
||||
RELEASE_URL: ${{ steps.complete.outputs.release-url }}
|
||||
@@ -16,9 +16,9 @@ jobs:
|
||||
# when changing runner sizes
|
||||
runs-on: ${{ matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'depot-macos-latest' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'depot-windows-2022-16' || matrix.os }}
|
||||
# This timeout must be greater than the timeout set by `go test` in
|
||||
# `make test` to ensure we receive a trace of running goroutines.
|
||||
# Setting this to the timeout +5m should work quite well even if
|
||||
# some of the preceding steps are slow.
|
||||
# `make test-postgres` to ensure we receive a trace of running
|
||||
# goroutines. Setting this to the timeout +5m should work quite well
|
||||
# even if some of the preceding steps are slow.
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -700,9 +700,11 @@ jobs:
|
||||
name: Publish to Homebrew tap
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
if: ${{ !inputs.dry_run && inputs.release_channel == 'mainline' }}
|
||||
if: ${{ !inputs.dry_run }}
|
||||
|
||||
steps:
|
||||
# TODO: skip this if it's not a new release (i.e. a backport). This is
|
||||
# fine right now because it just makes a PR that we can close.
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
|
||||
@@ -26,9 +26,6 @@ on:
|
||||
default: "traiage"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
traiage:
|
||||
name: Triage GitHub Issue with Claude Code
|
||||
@@ -41,6 +38,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
# This is only required for testing locally using nektos/act, so leaving commented out.
|
||||
|
||||
@@ -30,22 +30,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Rewrite same-repo links for PR branch
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
# Rewrite same-repo blob/tree main links to the PR head SHA
|
||||
# so that files or directories introduced in the PR are
|
||||
# reachable during link checking.
|
||||
{
|
||||
echo 'replacementPatterns:'
|
||||
echo " - pattern: \"https://github.com/coder/coder/blob/main/\""
|
||||
echo " replacement: \"https://github.com/coder/coder/blob/${HEAD_SHA}/\""
|
||||
echo " - pattern: \"https://github.com/coder/coder/tree/main/\""
|
||||
echo " replacement: \"https://github.com/coder/coder/tree/${HEAD_SHA}/\""
|
||||
} >> .github/.linkspector.yml
|
||||
|
||||
- name: Check Markdown links
|
||||
uses: umbrelladocs/action-linkspector@652f85bc57bb1e7d4327260decc10aa68f7694c3 # v1.4.0
|
||||
id: markdown-link-check
|
||||
|
||||
@@ -38,7 +38,6 @@ site/.swc
|
||||
|
||||
# Make target for updating generated/golden files (any dir).
|
||||
.gen
|
||||
/_gen/
|
||||
.gen-golden
|
||||
|
||||
# Build
|
||||
|
||||
@@ -37,20 +37,19 @@ Only pause to ask for confirmation when:
|
||||
|
||||
## Essential Commands
|
||||
|
||||
| Task | Command | Notes |
|
||||
|-----------------|--------------------------|-------------------------------------|
|
||||
| **Development** | `./scripts/develop.sh` | ⚠️ Don't use manual build |
|
||||
| **Build** | `make build` | Fat binaries (includes server) |
|
||||
| **Build Slim** | `make build-slim` | Slim binaries |
|
||||
| **Test** | `make test` | Full test suite |
|
||||
| **Test Single** | `make test RUN=TestName` | Faster than full suite |
|
||||
| **Test Race** | `make test-race` | Run tests with Go race detector |
|
||||
| **Lint** | `make lint` | Always run after changes |
|
||||
| **Generate** | `make gen` | After database changes |
|
||||
| **Format** | `make fmt` | Auto-format code |
|
||||
| **Clean** | `make clean` | Clean build artifacts |
|
||||
| **Pre-commit** | `make pre-commit` | Fast CI checks (gen/fmt/lint/build) |
|
||||
| **Pre-push** | `make pre-push` | Heavier CI checks (allowlisted) |
|
||||
| Task | Command | Notes |
|
||||
|-------------------|--------------------------|----------------------------------|
|
||||
| **Development** | `./scripts/develop.sh` | ⚠️ Don't use manual build |
|
||||
| **Build** | `make build` | Fat binaries (includes server) |
|
||||
| **Build Slim** | `make build-slim` | Slim binaries |
|
||||
| **Test** | `make test` | Full test suite |
|
||||
| **Test Single** | `make test RUN=TestName` | Faster than full suite |
|
||||
| **Test Postgres** | `make test-postgres` | Run tests with Postgres database |
|
||||
| **Test Race** | `make test-race` | Run tests with Go race detector |
|
||||
| **Lint** | `make lint` | Always run after changes |
|
||||
| **Generate** | `make gen` | After database changes |
|
||||
| **Format** | `make fmt` | Auto-format code |
|
||||
| **Clean** | `make clean` | Clean build artifacts |
|
||||
|
||||
### Documentation Commands
|
||||
|
||||
@@ -100,66 +99,10 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestrict
|
||||
app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
|
||||
```
|
||||
|
||||
### API Design
|
||||
|
||||
- Add swagger annotations when introducing new HTTP endpoints. Do this in
|
||||
the same change as the handler so the docs do not get missed before
|
||||
release.
|
||||
- For user-scoped or resource-scoped routes, prefer path parameters over
|
||||
query parameters when that matches existing route patterns.
|
||||
- For experimental or unstable API paths, skip public doc generation with
|
||||
`// @x-apidocgen {"skip": true}` after the `@Router` annotation. This
|
||||
keeps them out of the published API reference until they stabilize.
|
||||
|
||||
### Database Query Naming
|
||||
|
||||
- Use `ByX` when `X` is the lookup or filter column.
|
||||
- Use `PerX` or `GroupedByX` when `X` is the aggregation or grouping
|
||||
dimension.
|
||||
- Avoid `ByX` names for grouped queries.
|
||||
|
||||
### Database-to-SDK Conversions
|
||||
|
||||
- Extract explicit db-to-SDK conversion helpers instead of inlining large
|
||||
conversion blocks inside handlers.
|
||||
- Keep nullable-field handling, type coercion, and response shaping in the
|
||||
converter so handlers stay focused on request flow and authorization.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Full workflows available in imported WORKFLOWS.md
|
||||
|
||||
### Git Hooks (MANDATORY - DO NOT SKIP)
|
||||
|
||||
**You MUST install and use the git hooks. NEVER bypass them with
|
||||
`--no-verify`. Skipping hooks wastes CI cycles and is unacceptable.**
|
||||
|
||||
The first run will be slow as caches warm up. Consecutive runs are
|
||||
**significantly faster** (often 10x) thanks to Go build cache,
|
||||
generated file timestamps, and warm node_modules. This is NOT a
|
||||
reason to skip them. Wait for hooks to complete before proceeding,
|
||||
no matter how long they take.
|
||||
|
||||
```sh
|
||||
git config core.hooksPath scripts/githooks
|
||||
```
|
||||
|
||||
Two hooks run automatically:
|
||||
|
||||
- **pre-commit**: `make pre-commit` (gen, fmt, lint, typos, build).
|
||||
Fast checks that catch most CI failures. Allow at least 5 minutes.
|
||||
- **pre-push**: `make pre-push` (heavier checks including tests).
|
||||
Allowlisted in `scripts/githooks/pre-push`. Runs only for developers
|
||||
who opt in. Allow at least 15 minutes.
|
||||
|
||||
`git commit` and `git push` will appear to hang while hooks run.
|
||||
This is normal. Do not interrupt, retry, or reduce the timeout.
|
||||
|
||||
NEVER run `git config core.hooksPath` to change or disable hooks.
|
||||
|
||||
If a hook fails, fix the issue and retry. Do not work around the
|
||||
failure by skipping the hook.
|
||||
|
||||
### Git Workflow
|
||||
|
||||
When working on existing PRs, check out the branch first:
|
||||
@@ -209,21 +152,6 @@ seems like it should use `time.Sleep`, read through https://github.com/coder/qua
|
||||
- Follow [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md)
|
||||
- Commit format: `type(scope): message`
|
||||
|
||||
### Frontend Patterns
|
||||
|
||||
- Prefer existing shared UI components and utilities over custom
|
||||
implementations. Reuse common primitives such as loading, table, and error
|
||||
handling components when they fit the use case.
|
||||
- Use Storybook stories for all component and page testing, including
|
||||
visual presentation, user interactions, keyboard navigation, focus
|
||||
management, and accessibility behavior. Do not create standalone
|
||||
vitest/RTL test files for components or pages. Stories double as living
|
||||
documentation, visual regression coverage, and interaction test suites
|
||||
via `play` functions. Reserve plain vitest files for pure logic only:
|
||||
utility functions, data transformations, hooks tested via
|
||||
`renderHook()` that do not require DOM assertions, and query/cache
|
||||
operations with no rendered output.
|
||||
|
||||
### Writing Comments
|
||||
|
||||
Code comments should be clear, well-formatted, and add meaningful context.
|
||||
|
||||
@@ -19,17 +19,6 @@ SHELL := bash
|
||||
.SHELLFLAGS := -ceu
|
||||
.ONESHELL:
|
||||
|
||||
# When MAKE_TIMED=1, replace SHELL with a wrapper that prints
|
||||
# elapsed wall-clock time for each recipe. pre-commit and pre-push
|
||||
# set this on their sub-makes so every parallel job reports its
|
||||
# duration. Ad-hoc usage: make MAKE_TIMED=1 test
|
||||
ifdef MAKE_TIMED
|
||||
SHELL := $(CURDIR)/scripts/lib/timed-shell.sh
|
||||
.SHELLFLAGS = $@ -ceu
|
||||
export MAKE_TIMED
|
||||
export MAKE_LOGDIR
|
||||
endif
|
||||
|
||||
# This doesn't work on directories.
|
||||
# See https://stackoverflow.com/questions/25752543/make-delete-on-error-for-directory-targets
|
||||
.DELETE_ON_ERROR:
|
||||
@@ -44,25 +33,6 @@ endif
|
||||
coderd/database/unique_constraint.go \
|
||||
coderd/database/dbmetrics/querymetrics.go \
|
||||
coderd/database/dbauthz/dbauthz.go \
|
||||
coderd/database/dbmock/dbmock.go \
|
||||
coderd/database/pubsub/psmock/psmock.go \
|
||||
agent/agentcontainers/acmock/acmock.go \
|
||||
coderd/httpmw/loggermw/loggermock/loggermock.go \
|
||||
codersdk/workspacesdk/agentconnmock/agentconnmock.go \
|
||||
tailnet/tailnettest/coordinatormock.go \
|
||||
tailnet/tailnettest/coordinateemock.go \
|
||||
tailnet/tailnettest/workspaceupdatesprovidermock.go \
|
||||
tailnet/tailnettest/subscriptionmock.go \
|
||||
enterprise/aibridged/aibridgedmock/clientmock.go \
|
||||
enterprise/aibridged/aibridgedmock/poolmock.go \
|
||||
tailnet/proto/tailnet.pb.go \
|
||||
agent/proto/agent.pb.go \
|
||||
agent/agentsocket/proto/agentsocket.pb.go \
|
||||
agent/boundarylogproxy/codec/boundary.pb.go \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
vpn/vpn.pb.go \
|
||||
enterprise/aibridged/proto/aibridged.pb.go \
|
||||
site/src/api/typesGenerated.ts \
|
||||
site/e2e/provisionerGenerated.ts \
|
||||
site/src/api/chatModelOptionsGenerated.json \
|
||||
@@ -80,23 +50,6 @@ endif
|
||||
codersdk/rbacresources_gen.go \
|
||||
codersdk/apikey_scopes_gen.go
|
||||
|
||||
# atomic_write runs a command, captures stdout into a temp file, and
|
||||
# atomically replaces $@. An optional second argument is a formatting
|
||||
# command that receives the temp file path as its argument.
|
||||
# Usage: $(call atomic_write,GENERATE_CMD[,FORMAT_CMD])
|
||||
define atomic_write
|
||||
tmpdir=$$(mktemp -d -p _gen) && tmpfile=$$(realpath "$$tmpdir")/$(notdir $@) && \
|
||||
$(1) > "$$tmpfile" && \
|
||||
$(if $(2),$(2) "$$tmpfile" &&) \
|
||||
mv "$$tmpfile" "$@" && rm -rf "$$tmpdir"
|
||||
endef
|
||||
|
||||
# Shared temp directory for atomic writes. Lives at the project root
|
||||
# so all targets share the same filesystem, and is gitignored.
|
||||
# Order-only prerequisite: recipes that need it depend on | _gen
|
||||
_gen:
|
||||
mkdir -p _gen
|
||||
|
||||
# Don't print the commands in the file unless you specify VERBOSE. This is
|
||||
# essentially the same as putting "@" at the start of each line.
|
||||
ifndef VERBOSE
|
||||
@@ -114,19 +67,11 @@ VERSION := $(shell ./scripts/version.sh)
|
||||
POSTGRES_VERSION ?= 17
|
||||
POSTGRES_IMAGE ?= us-docker.pkg.dev/coder-v2-images-public/public/postgres:$(POSTGRES_VERSION)
|
||||
|
||||
# Limit parallel Make jobs in pre-commit/pre-push. Defaults to
|
||||
# nproc/4 (min 2) since test, lint, and build targets have internal
|
||||
# parallelism. Override: make pre-push PARALLEL_JOBS=8
|
||||
PARALLEL_JOBS ?= $(shell n=$$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 8); echo $$(( n / 4 > 2 ? n / 4 : 2 )))
|
||||
|
||||
# Use the highest ZSTD compression level in release builds to
|
||||
# minimize artifact size. For non-release CI builds (e.g. main
|
||||
# branch preview), use multithreaded level 6 which is ~99% faster
|
||||
# at the cost of ~30% larger archives.
|
||||
ifeq ($(CODER_RELEASE),true)
|
||||
# Use the highest ZSTD compression level in CI.
|
||||
ifdef CI
|
||||
ZSTDFLAGS := -22 --ultra
|
||||
else
|
||||
ZSTDFLAGS := -6 -T0
|
||||
ZSTDFLAGS := -6
|
||||
endif
|
||||
|
||||
# Common paths to exclude from find commands, this rule is written so
|
||||
@@ -135,11 +80,19 @@ endif
|
||||
# Note, all find statements should be written with `.` or `./path` as
|
||||
# the search path so that these exclusions match.
|
||||
FIND_EXCLUSIONS= \
|
||||
-not \( \( -path '*/.git/*' -o -path './build/*' -o -path './vendor/*' -o -path './.coderv2/*' -o -path '*/node_modules/*' -o -path '*/out/*' -o -path './coderd/apidoc/*' -o -path '*/.next/*' -o -path '*/.terraform/*' -o -path './_gen/*' \) -prune \)
|
||||
|
||||
-not \( \( -path '*/.git/*' -o -path './build/*' -o -path './vendor/*' -o -path './.coderv2/*' -o -path '*/node_modules/*' -o -path '*/out/*' -o -path './coderd/apidoc/*' -o -path '*/.next/*' -o -path '*/.terraform/*' \) -prune \)
|
||||
# Source files used for make targets, evaluated on use.
|
||||
GO_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.go' -not -name '*_test.go')
|
||||
|
||||
# Same as GO_SRC_FILES but excluding certain files that have problematic
|
||||
# Makefile dependencies (e.g. pnpm).
|
||||
MOST_GO_SRC_FILES := $(shell \
|
||||
find . \
|
||||
$(FIND_EXCLUSIONS) \
|
||||
-type f \
|
||||
-name '*.go' \
|
||||
-not -name '*_test.go' \
|
||||
-not -wholename './agent/agentcontainers/dcspec/dcspec_gen.go' \
|
||||
)
|
||||
# All the shell files in the repo, excluding ignored files.
|
||||
SHELL_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.sh')
|
||||
|
||||
@@ -506,17 +459,8 @@ install: build/coder_$(VERSION)_$(GOOS)_$(GOARCH)$(GOOS_BIN_EXT)
|
||||
cp "$<" "$$output_file"
|
||||
.PHONY: install
|
||||
|
||||
# Only wildcard the go files in the develop directory to avoid rebuilds
|
||||
# when project files are changd. Technically changes to some imports may
|
||||
# not be detected, but it's unlikely to cause any issues.
|
||||
build/.bin/develop: go.mod go.sum $(wildcard scripts/develop/*.go)
|
||||
CGO_ENABLED=0 go build -o $@ ./scripts/develop
|
||||
|
||||
BOLD := $(shell tput bold 2>/dev/null)
|
||||
GREEN := $(shell tput setaf 2 2>/dev/null)
|
||||
RED := $(shell tput setaf 1 2>/dev/null)
|
||||
YELLOW := $(shell tput setaf 3 2>/dev/null)
|
||||
DIM := $(shell tput dim 2>/dev/null || tput setaf 8 2>/dev/null)
|
||||
RESET := $(shell tput sgr0 2>/dev/null)
|
||||
|
||||
fmt: fmt/ts fmt/go fmt/terraform fmt/shfmt fmt/biome fmt/markdown
|
||||
@@ -626,7 +570,7 @@ endif
|
||||
# GitHub Actions linters are run in a separate CI job (lint-actions) that only
|
||||
# triggers when workflow files change, so we skip them here when CI=true.
|
||||
LINT_ACTIONS_TARGETS := $(if $(CI),,lint/actions/actionlint)
|
||||
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/check-scopes lint/migrations lint/bootstrap $(LINT_ACTIONS_TARGETS)
|
||||
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/check-scopes lint/migrations $(LINT_ACTIONS_TARGETS)
|
||||
.PHONY: lint
|
||||
|
||||
lint/site-icons:
|
||||
@@ -641,7 +585,7 @@ lint/ts: site/node_modules/.installed
|
||||
lint/go:
|
||||
./scripts/check_enterprise_imports.sh
|
||||
./scripts/check_codersdk_imports.sh
|
||||
linter_ver=$$(grep -oE 'GOLANGCI_LINT_VERSION=\S+' dogfood/coder/Dockerfile | cut -d '=' -f 2)
|
||||
linter_ver=$(shell egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/coder/Dockerfile | cut -d '=' -f 2)
|
||||
go run github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver run
|
||||
go tool github.com/coder/paralleltestctx/cmd/paralleltestctx -custom-funcs="testutil.Context" ./...
|
||||
.PHONY: lint/go
|
||||
@@ -656,11 +600,6 @@ lint/shellcheck: $(SHELL_SRC_FILES)
|
||||
shellcheck --external-sources $(SHELL_SRC_FILES)
|
||||
.PHONY: lint/shellcheck
|
||||
|
||||
lint/bootstrap:
|
||||
bash scripts/check_bootstrap_quotes.sh
|
||||
.PHONY: lint/bootstrap
|
||||
|
||||
|
||||
lint/helm:
|
||||
cd helm/
|
||||
make lint
|
||||
@@ -695,102 +634,6 @@ lint/migrations:
|
||||
./scripts/check_pg_schema.sh "Fixtures" $(FIXTURE_FILES)
|
||||
.PHONY: lint/migrations
|
||||
|
||||
TYPOS_VERSION := $(shell grep -oP 'crate-ci/typos@\S+\s+\#\s+v\K[0-9.]+' .github/workflows/ci.yaml)
|
||||
|
||||
# Map uname values to typos release asset names.
|
||||
TYPOS_ARCH := $(shell uname -m)
|
||||
ifeq ($(shell uname -s),Darwin)
|
||||
TYPOS_OS := apple-darwin
|
||||
else
|
||||
TYPOS_OS := unknown-linux-musl
|
||||
endif
|
||||
|
||||
build/typos-$(TYPOS_VERSION):
|
||||
mkdir -p build/
|
||||
curl -sSfL "https://github.com/crate-ci/typos/releases/download/v$(TYPOS_VERSION)/typos-v$(TYPOS_VERSION)-$(TYPOS_ARCH)-$(TYPOS_OS).tar.gz" \
|
||||
| tar -xzf - -C build/ ./typos
|
||||
mv build/typos "$@"
|
||||
|
||||
lint/typos: build/typos-$(TYPOS_VERSION)
|
||||
build/typos-$(TYPOS_VERSION) --config .github/workflows/typos.toml
|
||||
.PHONY: lint/typos
|
||||
|
||||
# pre-commit and pre-push mirror CI checks locally.
|
||||
#
|
||||
# pre-commit runs checks that don't need external services (Docker,
|
||||
# Playwright). This is the git pre-commit hook default since Docker
|
||||
# and browser issues in the local environment would otherwise block
|
||||
# all commits.
|
||||
#
|
||||
# pre-push adds heavier checks: Go tests, JS tests, and site build.
|
||||
# The pre-push hook is allowlisted, see scripts/githooks/pre-push.
|
||||
#
|
||||
# pre-commit uses two phases: gen+fmt first, then lint+build. This
|
||||
# avoids races where gen's `go run` creates temporary .go files that
|
||||
# lint's find-based checks pick up. Within each phase, targets run in
|
||||
# parallel via -j. It fails if any tracked files have unstaged
|
||||
# changes afterward.
|
||||
|
||||
define check-unstaged
|
||||
unstaged="$$(git diff --name-only)"
|
||||
if [[ -n $$unstaged ]]; then
|
||||
echo "$(RED)✗ check unstaged changes$(RESET)"
|
||||
echo "$$unstaged" | sed 's/^/ - /'
|
||||
echo ""
|
||||
echo "$(DIM) Verify generated changes are correct before staging:$(RESET)"
|
||||
echo "$(DIM) git diff$(RESET)"
|
||||
echo "$(DIM) git add -u && git commit$(RESET)"
|
||||
exit 1
|
||||
fi
|
||||
endef
|
||||
define check-untracked
|
||||
untracked=$$(git ls-files --other --exclude-standard)
|
||||
if [[ -n $$untracked ]]; then
|
||||
echo "$(YELLOW)? check untracked files$(RESET)"
|
||||
echo "$$untracked" | sed 's/^/ - /'
|
||||
echo ""
|
||||
echo "$(DIM) Review if these should be committed or added to .gitignore.$(RESET)"
|
||||
fi
|
||||
endef
|
||||
|
||||
pre-commit:
|
||||
start=$$(date +%s)
|
||||
logdir=$$(mktemp -d "$${TMPDIR:-/tmp}/coder-pre-commit.XXXXXX")
|
||||
echo "$(BOLD)pre-commit$(RESET) ($$logdir)"
|
||||
echo "gen + fmt:"
|
||||
$(MAKE) --no-print-directory -j$(PARALLEL_JOBS) MAKE_TIMED=1 MAKE_LOGDIR=$$logdir gen fmt
|
||||
$(check-unstaged)
|
||||
echo "lint + build:"
|
||||
$(MAKE) --no-print-directory -j$(PARALLEL_JOBS) MAKE_TIMED=1 MAKE_LOGDIR=$$logdir \
|
||||
lint \
|
||||
lint/typos \
|
||||
build/coder-slim_$(GOOS)_$(GOARCH)$(GOOS_BIN_EXT)
|
||||
$(check-unstaged)
|
||||
$(check-untracked)
|
||||
rm -rf $$logdir
|
||||
echo "$(GREEN)✓ pre-commit passed$(RESET) ($$(( $$(date +%s) - $$start ))s)"
|
||||
.PHONY: pre-commit
|
||||
|
||||
pre-push:
|
||||
start=$$(date +%s)
|
||||
logdir=$$(mktemp -d "$${TMPDIR:-/tmp}/coder-pre-push.XXXXXX")
|
||||
echo "$(BOLD)pre-push$(RESET) ($$logdir)"
|
||||
echo "test + build site:"
|
||||
$(MAKE) --no-print-directory -j$(PARALLEL_JOBS) MAKE_TIMED=1 MAKE_LOGDIR=$$logdir \
|
||||
test \
|
||||
test-js \
|
||||
site/out/index.html
|
||||
rm -rf $$logdir
|
||||
echo "$(GREEN)✓ pre-push passed$(RESET) ($$(( $$(date +%s) - $$start ))s)"
|
||||
.PHONY: pre-push
|
||||
|
||||
offlinedocs/check: offlinedocs/node_modules/.installed
|
||||
cd offlinedocs/
|
||||
pnpm format:check
|
||||
pnpm lint
|
||||
pnpm export
|
||||
.PHONY: offlinedocs/check
|
||||
|
||||
# All files generated by the database should be added here, and this can be used
|
||||
# as a target for jobs that need to run after the database is generated.
|
||||
DB_GEN_FILES := \
|
||||
@@ -979,7 +822,7 @@ $(TAILNETTEST_MOCKS): tailnet/coordinator.go tailnet/service.go
|
||||
touch "$@"
|
||||
|
||||
tailnet/proto/tailnet.pb.go: tailnet/proto/tailnet.proto
|
||||
./scripts/atomic_protoc.sh \
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
@@ -987,7 +830,7 @@ tailnet/proto/tailnet.pb.go: tailnet/proto/tailnet.proto
|
||||
./tailnet/proto/tailnet.proto
|
||||
|
||||
agent/proto/agent.pb.go: agent/proto/agent.proto
|
||||
./scripts/atomic_protoc.sh \
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
@@ -995,7 +838,7 @@ agent/proto/agent.pb.go: agent/proto/agent.proto
|
||||
./agent/proto/agent.proto
|
||||
|
||||
agent/agentsocket/proto/agentsocket.pb.go: agent/agentsocket/proto/agentsocket.proto agent/proto/agent.proto
|
||||
./scripts/atomic_protoc.sh \
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
@@ -1003,7 +846,7 @@ agent/agentsocket/proto/agentsocket.pb.go: agent/agentsocket/proto/agentsocket.p
|
||||
./agent/agentsocket/proto/agentsocket.proto
|
||||
|
||||
provisionersdk/proto/provisioner.pb.go: provisionersdk/proto/provisioner.proto
|
||||
./scripts/atomic_protoc.sh \
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
@@ -1011,7 +854,7 @@ provisionersdk/proto/provisioner.pb.go: provisionersdk/proto/provisioner.proto
|
||||
./provisionersdk/proto/provisioner.proto
|
||||
|
||||
provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
|
||||
./scripts/atomic_protoc.sh \
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
@@ -1019,110 +862,132 @@ provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
|
||||
./provisionerd/proto/provisionerd.proto
|
||||
|
||||
vpn/vpn.pb.go: vpn/vpn.proto
|
||||
./scripts/atomic_protoc.sh \
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
./vpn/vpn.proto
|
||||
|
||||
agent/boundarylogproxy/codec/boundary.pb.go: agent/boundarylogproxy/codec/boundary.proto agent/proto/agent.proto
|
||||
./scripts/atomic_protoc.sh \
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
./agent/boundarylogproxy/codec/boundary.proto
|
||||
|
||||
enterprise/aibridged/proto/aibridged.pb.go: enterprise/aibridged/proto/aibridged.proto
|
||||
./scripts/atomic_protoc.sh \
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./enterprise/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') | _gen
|
||||
$(call atomic_write,go run -C ./scripts/apitypings main.go,./scripts/biome_format.sh)
|
||||
site/src/api/typesGenerated.ts: site/node_modules/.installed $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
|
||||
# Generate to a temp file, format it, then atomically move to
|
||||
# the target so that an interrupt never leaves a partial or
|
||||
# unformatted file in the working tree.
|
||||
tmpfile=$$(mktemp -d)/$(notdir $@) && \
|
||||
go run -C ./scripts/apitypings main.go > "$$tmpfile" && \
|
||||
./scripts/biome_format.sh "$$tmpfile" && \
|
||||
mv "$$tmpfile" "$@"
|
||||
|
||||
site/e2e/provisionerGenerated.ts: site/node_modules/.installed provisionerd/proto/provisionerd.pb.go provisionersdk/proto/provisioner.pb.go
|
||||
(cd site/ && pnpm run gen:provisioner)
|
||||
touch "$@"
|
||||
|
||||
site/src/theme/icons.json: site/node_modules/.installed $(wildcard scripts/gensite/*) $(wildcard site/static/icon/*) | _gen
|
||||
tmpdir=$$(mktemp -d -p _gen) && tmpfile=$$(realpath "$$tmpdir")/$(notdir $@) && \
|
||||
site/src/theme/icons.json: site/node_modules/.installed $(wildcard scripts/gensite/*) $(wildcard site/static/icon/*)
|
||||
tmpfile=$$(mktemp -d)/$(notdir $@) && \
|
||||
go run ./scripts/gensite/ -icons "$$tmpfile" && \
|
||||
./scripts/biome_format.sh "$$tmpfile" && \
|
||||
mv "$$tmpfile" "$@" && rm -rf "$$tmpdir"
|
||||
mv "$$tmpfile" "$@"
|
||||
|
||||
examples/examples.gen.json: scripts/examplegen/main.go examples/examples.go $(shell find ./examples/templates) | _gen
|
||||
$(call atomic_write,go run ./scripts/examplegen/main.go)
|
||||
examples/examples.gen.json: scripts/examplegen/main.go examples/examples.go $(shell find ./examples/templates)
|
||||
go run ./scripts/examplegen/main.go > "$@.tmp" && mv "$@.tmp" "$@"
|
||||
|
||||
coderd/rbac/object_gen.go: scripts/typegen/rbacobject.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go | _gen
|
||||
$(call atomic_write,go run ./scripts/typegen/main.go rbac object)
|
||||
coderd/rbac/object_gen.go: scripts/typegen/rbacobject.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
|
||||
tempdir=$(shell mktemp -d /tmp/typegen_rbac_object.XXXXXX)
|
||||
go run ./scripts/typegen/main.go rbac object > "$$tempdir/object_gen.go"
|
||||
mv -v "$$tempdir/object_gen.go" coderd/rbac/object_gen.go
|
||||
rmdir -v "$$tempdir"
|
||||
touch "$@"
|
||||
|
||||
# NOTE: depends on object_gen.go because `go run` compiles
|
||||
# coderd/rbac which includes it.
|
||||
coderd/rbac/scopes_constants_gen.go: scripts/typegen/scopenames.gotmpl scripts/typegen/main.go coderd/rbac/policy/policy.go \
|
||||
coderd/rbac/object_gen.go | _gen
|
||||
# Write to a temp file first to avoid truncating the package
|
||||
# during build since the generator imports the rbac package.
|
||||
$(call atomic_write,go run ./scripts/typegen/main.go rbac scopenames)
|
||||
coderd/rbac/object_gen.go
|
||||
# Generate typed low-level ScopeName constants from RBACPermissions
|
||||
# Write to a temp file first to avoid truncating the package during build
|
||||
# since the generator imports the rbac package.
|
||||
tempfile=$(shell mktemp /tmp/scopes_constants_gen.XXXXXX)
|
||||
go run ./scripts/typegen/main.go rbac scopenames > "$$tempfile"
|
||||
mv -v "$$tempfile" coderd/rbac/scopes_constants_gen.go
|
||||
touch "$@"
|
||||
|
||||
# NOTE: depends on object_gen.go and scopes_constants_gen.go because
|
||||
# `go run` compiles coderd/rbac which includes both.
|
||||
codersdk/rbacresources_gen.go: scripts/typegen/codersdk.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go \
|
||||
coderd/rbac/object_gen.go coderd/rbac/scopes_constants_gen.go | _gen
|
||||
# Write to a temp file to avoid truncating the target, which
|
||||
# would break the codersdk package and any parallel build targets.
|
||||
$(call atomic_write,go run scripts/typegen/main.go rbac codersdk)
|
||||
coderd/rbac/object_gen.go coderd/rbac/scopes_constants_gen.go
|
||||
# Do no overwrite codersdk/rbacresources_gen.go directly, as it would make the file empty, breaking
|
||||
# the `codersdk` package and any parallel build targets.
|
||||
go run scripts/typegen/main.go rbac codersdk > /tmp/rbacresources_gen.go
|
||||
mv /tmp/rbacresources_gen.go codersdk/rbacresources_gen.go
|
||||
touch "$@"
|
||||
|
||||
# NOTE: depends on object_gen.go and scopes_constants_gen.go because
|
||||
# `go run` compiles coderd/rbac which includes both.
|
||||
codersdk/apikey_scopes_gen.go: scripts/apikeyscopesgen/main.go coderd/rbac/scopes_catalog.go coderd/rbac/scopes.go \
|
||||
coderd/rbac/object_gen.go coderd/rbac/scopes_constants_gen.go | _gen
|
||||
coderd/rbac/object_gen.go coderd/rbac/scopes_constants_gen.go
|
||||
# Generate SDK constants for external API key scopes.
|
||||
$(call atomic_write,go run ./scripts/apikeyscopesgen)
|
||||
go run ./scripts/apikeyscopesgen > /tmp/apikey_scopes_gen.go
|
||||
mv /tmp/apikey_scopes_gen.go codersdk/apikey_scopes_gen.go
|
||||
touch "$@"
|
||||
|
||||
# NOTE: depends on object_gen.go and scopes_constants_gen.go because
|
||||
# `go run` compiles coderd/rbac which includes both.
|
||||
site/src/api/rbacresourcesGenerated.ts: site/node_modules/.installed scripts/typegen/codersdk.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go \
|
||||
coderd/rbac/object_gen.go coderd/rbac/scopes_constants_gen.go | _gen
|
||||
$(call atomic_write,go run scripts/typegen/main.go rbac typescript,./scripts/biome_format.sh)
|
||||
coderd/rbac/object_gen.go coderd/rbac/scopes_constants_gen.go
|
||||
tmpfile=$$(mktemp -d)/$(notdir $@) && \
|
||||
go run scripts/typegen/main.go rbac typescript > "$$tmpfile" && \
|
||||
./scripts/biome_format.sh "$$tmpfile" && \
|
||||
mv "$$tmpfile" "$@"
|
||||
|
||||
site/src/api/countriesGenerated.ts: site/node_modules/.installed scripts/typegen/countries.tstmpl scripts/typegen/main.go codersdk/countries.go | _gen
|
||||
$(call atomic_write,go run scripts/typegen/main.go countries,./scripts/biome_format.sh)
|
||||
site/src/api/countriesGenerated.ts: site/node_modules/.installed scripts/typegen/countries.tstmpl scripts/typegen/main.go codersdk/countries.go
|
||||
tmpfile=$$(mktemp -d)/$(notdir $@) && \
|
||||
go run scripts/typegen/main.go countries > "$$tmpfile" && \
|
||||
./scripts/biome_format.sh "$$tmpfile" && \
|
||||
mv "$$tmpfile" "$@"
|
||||
|
||||
site/src/api/chatModelOptionsGenerated.json: scripts/modeloptionsgen/main.go codersdk/chats.go | _gen
|
||||
$(call atomic_write,go run ./scripts/modeloptionsgen/main.go | tail -n +2,./scripts/biome_format.sh)
|
||||
site/src/api/chatModelOptionsGenerated.json: scripts/modeloptionsgen/main.go codersdk/chats.go
|
||||
tmpfile=$$(mktemp -d)/$(notdir $@) && \
|
||||
go run ./scripts/modeloptionsgen/main.go | tail -n +2 > "$$tmpfile" && \
|
||||
./scripts/biome_format.sh "$$tmpfile" && \
|
||||
mv "$$tmpfile" "$@"
|
||||
|
||||
scripts/metricsdocgen/generated_metrics: $(GO_SRC_FILES) | _gen
|
||||
$(call atomic_write,go run ./scripts/metricsdocgen/scanner)
|
||||
scripts/metricsdocgen/generated_metrics: $(GO_SRC_FILES)
|
||||
go run ./scripts/metricsdocgen/scanner > $@.tmp && mv $@.tmp $@
|
||||
|
||||
docs/admin/integrations/prometheus.md: node_modules/.installed scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics scripts/metricsdocgen/generated_metrics | _gen
|
||||
tmpdir=$$(mktemp -d -p _gen) && tmpfile=$$(realpath "$$tmpdir")/$(notdir $@) && cp "$@" "$$tmpfile" && \
|
||||
docs/admin/integrations/prometheus.md: node_modules/.installed scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics scripts/metricsdocgen/generated_metrics
|
||||
tmpfile=$$(mktemp -d)/$(notdir $@) && cp "$@" "$$tmpfile" && \
|
||||
go run scripts/metricsdocgen/main.go --prometheus-doc-file="$$tmpfile" && \
|
||||
pnpm exec markdownlint-cli2 --fix "$$tmpfile" && \
|
||||
pnpm exec markdown-table-formatter "$$tmpfile" && \
|
||||
mv "$$tmpfile" "$@" && rm -rf "$$tmpdir"
|
||||
mv "$$tmpfile" "$@"
|
||||
|
||||
docs/reference/cli/index.md: node_modules/.installed scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES) | _gen
|
||||
tmpdir=$$(mktemp -d -p _gen) && \
|
||||
tmpdir=$$(realpath "$$tmpdir") && \
|
||||
docs/reference/cli/index.md: node_modules/.installed scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES)
|
||||
tmpdir=$$(mktemp -d) && \
|
||||
mkdir -p "$$tmpdir/docs/reference/cli" && \
|
||||
cp docs/manifest.json "$$tmpdir/docs/manifest.json" && \
|
||||
CI=true DOCS_DIR="$$tmpdir/docs" go run ./scripts/clidocgen && \
|
||||
pnpm exec markdownlint-cli2 --fix "$$tmpdir/docs/reference/cli/*.md" && \
|
||||
pnpm exec markdown-table-formatter "$$tmpdir/docs/reference/cli/*.md" && \
|
||||
for f in "$$tmpdir/docs/reference/cli/"*.md; do mv "$$f" "docs/reference/cli/$$(basename "$$f")"; done && \
|
||||
cp "$$tmpdir/docs/reference/cli/"*.md docs/reference/cli/ && \
|
||||
rm -rf "$$tmpdir"
|
||||
|
||||
docs/admin/security/audit-logs.md: node_modules/.installed coderd/database/querier.go scripts/auditdocgen/main.go enterprise/audit/table.go coderd/rbac/object_gen.go | _gen
|
||||
tmpdir=$$(mktemp -d -p _gen) && tmpfile=$$(realpath "$$tmpdir")/$(notdir $@) && cp "$@" "$$tmpfile" && \
|
||||
docs/admin/security/audit-logs.md: node_modules/.installed coderd/database/querier.go scripts/auditdocgen/main.go enterprise/audit/table.go coderd/rbac/object_gen.go
|
||||
tmpfile=$$(mktemp -d)/$(notdir $@) && cp "$@" "$$tmpfile" && \
|
||||
go run scripts/auditdocgen/main.go --audit-doc-file="$$tmpfile" && \
|
||||
pnpm exec markdownlint-cli2 --fix "$$tmpfile" && \
|
||||
pnpm exec markdown-table-formatter "$$tmpfile" && \
|
||||
mv "$$tmpfile" "$@" && rm -rf "$$tmpdir"
|
||||
mv "$$tmpfile" "$@"
|
||||
|
||||
coderd/apidoc/.gen: \
|
||||
node_modules/.installed \
|
||||
@@ -1137,27 +1002,25 @@ coderd/apidoc/.gen: \
|
||||
scripts/apidocgen/generate.sh \
|
||||
scripts/apidocgen/swaginit/main.go \
|
||||
$(wildcard scripts/apidocgen/postprocess/*) \
|
||||
$(wildcard scripts/apidocgen/markdown-template/*) | _gen
|
||||
tmpdir=$$(mktemp -d -p _gen) && swagtmp=$$(mktemp -d -p _gen) && \
|
||||
tmpdir=$$(realpath "$$tmpdir") && swagtmp=$$(realpath "$$swagtmp") && \
|
||||
$(wildcard scripts/apidocgen/markdown-template/*)
|
||||
tmpdir=$$(mktemp -d) && swagtmp=$$(mktemp -d) && \
|
||||
mkdir -p "$$tmpdir/reference/api" && \
|
||||
cp docs/manifest.json "$$tmpdir/manifest.json" && \
|
||||
SWAG_OUTPUT_DIR="$$swagtmp" APIDOCGEN_DOCS_DIR="$$tmpdir" ./scripts/apidocgen/generate.sh && \
|
||||
pnpm exec markdownlint-cli2 --fix "$$tmpdir/reference/api/*.md" && \
|
||||
pnpm exec markdown-table-formatter "$$tmpdir/reference/api/*.md" && \
|
||||
./scripts/biome_format.sh "$$swagtmp/swagger.json" && \
|
||||
for f in "$$tmpdir/reference/api/"*.md; do mv "$$f" "docs/reference/api/$$(basename "$$f")"; done && \
|
||||
mv "$$tmpdir/manifest.json" _gen/manifest-staging.json && \
|
||||
mv "$$swagtmp/docs.go" coderd/apidoc/docs.go && \
|
||||
mv "$$swagtmp/swagger.json" coderd/apidoc/swagger.json && \
|
||||
cp "$$tmpdir/reference/api/"*.md docs/reference/api/ && \
|
||||
cp "$$tmpdir/manifest.json" docs/manifest.json && \
|
||||
cp "$$swagtmp/docs.go" coderd/apidoc/docs.go && \
|
||||
cp "$$swagtmp/swagger.json" coderd/apidoc/swagger.json && \
|
||||
rm -rf "$$tmpdir" "$$swagtmp"
|
||||
touch "$@"
|
||||
|
||||
docs/manifest.json: site/node_modules/.installed coderd/apidoc/.gen docs/reference/cli/index.md | _gen
|
||||
tmpdir=$$(mktemp -d -p _gen) && tmpfile=$$(realpath "$$tmpdir")/$(notdir $@) && \
|
||||
cp _gen/manifest-staging.json "$$tmpfile" && \
|
||||
docs/manifest.json: site/node_modules/.installed coderd/apidoc/.gen docs/reference/cli/index.md
|
||||
tmpfile=$$(mktemp -d)/$(notdir $@) && cp "$@" "$$tmpfile" && \
|
||||
./scripts/biome_format.sh "$$tmpfile" && \
|
||||
mv "$$tmpfile" "$@" && rm -rf "$$tmpdir"
|
||||
mv "$$tmpfile" "$@"
|
||||
|
||||
coderd/apidoc/swagger.json: site/node_modules/.installed coderd/apidoc/.gen
|
||||
touch "$@"
|
||||
@@ -1244,22 +1107,10 @@ else
|
||||
GOTESTSUM_RETRY_FLAGS :=
|
||||
endif
|
||||
|
||||
# Default to 8x8 parallelism to avoid overwhelming our workspaces.
|
||||
# Race detection defaults to 4x4 because the detector adds significant
|
||||
# CPU overhead. Override via TEST_NUM_PARALLEL_PACKAGES /
|
||||
# TEST_NUM_PARALLEL_TESTS.
|
||||
TEST_PARALLEL_PACKAGES := $(or $(TEST_NUM_PARALLEL_PACKAGES),8)
|
||||
TEST_PARALLEL_TESTS := $(or $(TEST_NUM_PARALLEL_TESTS),8)
|
||||
RACE_PARALLEL_PACKAGES := $(or $(TEST_NUM_PARALLEL_PACKAGES),4)
|
||||
RACE_PARALLEL_TESTS := $(or $(TEST_NUM_PARALLEL_TESTS),4)
|
||||
|
||||
# Use testsmallbatch tag to reduce wireguard memory allocation in tests
|
||||
# (from ~18GB to negligible). Recursively expanded so target-specific
|
||||
# overrides of TEST_PARALLEL_* take effect (e.g. test-race lowers
|
||||
# parallelism). CI job timeout is 25m (see test-go-pg in ci.yaml),
|
||||
# keep the Go timeout 5m shorter so tests produce goroutine dumps
|
||||
# instead of the CI runner killing the process with no output.
|
||||
GOTEST_FLAGS = -tags=testsmallbatch -v -timeout 20m -p $(TEST_PARALLEL_PACKAGES) -parallel=$(TEST_PARALLEL_TESTS)
|
||||
# default to 8x8 parallelism to avoid overwhelming our workspaces. Hopefully we can remove these defaults
|
||||
# when we get our test suite's resource utilization under control.
|
||||
# Use testsmallbatch tag to reduce wireguard memory allocation in tests (from ~18GB to negligible).
|
||||
GOTEST_FLAGS := -tags=testsmallbatch -v -p $(or $(TEST_NUM_PARALLEL_PACKAGES),"8") -parallel=$(or $(TEST_NUM_PARALLEL_TESTS),"8")
|
||||
|
||||
# The most common use is to set TEST_COUNT=1 to avoid Go's test cache.
|
||||
ifdef TEST_COUNT
|
||||
@@ -1285,34 +1136,13 @@ endif
|
||||
TEST_PACKAGES ?= ./...
|
||||
|
||||
test:
|
||||
$(GIT_FLAGS) gotestsum --format standard-quiet \
|
||||
$(GOTESTSUM_RETRY_FLAGS) \
|
||||
--packages="$(TEST_PACKAGES)" \
|
||||
-- \
|
||||
$(GOTEST_FLAGS)
|
||||
$(GIT_FLAGS) gotestsum --format standard-quiet $(GOTESTSUM_RETRY_FLAGS) --packages="$(TEST_PACKAGES)" -- $(GOTEST_FLAGS)
|
||||
.PHONY: test
|
||||
|
||||
test-race: TEST_PARALLEL_PACKAGES := $(RACE_PARALLEL_PACKAGES)
|
||||
test-race: TEST_PARALLEL_TESTS := $(RACE_PARALLEL_TESTS)
|
||||
test-race:
|
||||
$(GIT_FLAGS) gotestsum --format standard-quiet \
|
||||
--junitfile="gotests.xml" \
|
||||
$(GOTESTSUM_RETRY_FLAGS) \
|
||||
--packages="$(TEST_PACKAGES)" \
|
||||
-- \
|
||||
-race \
|
||||
$(GOTEST_FLAGS)
|
||||
.PHONY: test-race
|
||||
|
||||
test-cli:
|
||||
$(MAKE) test TEST_PACKAGES="./cli..."
|
||||
.PHONY: test-cli
|
||||
|
||||
test-js: site/node_modules/.installed
|
||||
cd site/
|
||||
pnpm test:ci
|
||||
.PHONY: test-js
|
||||
|
||||
# sqlc-cloud-is-setup will fail if no SQLc auth token is set. Use this as a
|
||||
# dependency for any sqlc-cloud related targets.
|
||||
sqlc-cloud-is-setup:
|
||||
@@ -1324,22 +1154,37 @@ sqlc-cloud-is-setup:
|
||||
|
||||
sqlc-push: sqlc-cloud-is-setup test-postgres-docker
|
||||
echo "--- sqlc push"
|
||||
SQLC_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/$$(go run scripts/migrate-ci/main.go)" \
|
||||
SQLC_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/$(shell go run scripts/migrate-ci/main.go)" \
|
||||
sqlc push -f coderd/database/sqlc.yaml && echo "Passed sqlc push"
|
||||
.PHONY: sqlc-push
|
||||
|
||||
sqlc-verify: sqlc-cloud-is-setup test-postgres-docker
|
||||
echo "--- sqlc verify"
|
||||
SQLC_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/$$(go run scripts/migrate-ci/main.go)" \
|
||||
SQLC_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/$(shell go run scripts/migrate-ci/main.go)" \
|
||||
sqlc verify -f coderd/database/sqlc.yaml && echo "Passed sqlc verify"
|
||||
.PHONY: sqlc-verify
|
||||
|
||||
sqlc-vet: test-postgres-docker
|
||||
echo "--- sqlc vet"
|
||||
SQLC_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/$$(go run scripts/migrate-ci/main.go)" \
|
||||
SQLC_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/$(shell go run scripts/migrate-ci/main.go)" \
|
||||
sqlc vet -f coderd/database/sqlc.yaml && echo "Passed sqlc vet"
|
||||
.PHONY: sqlc-vet
|
||||
|
||||
# When updating -timeout for this test, keep in sync with
|
||||
# test-go-postgres (.github/workflows/coder.yaml).
|
||||
# Do add coverage flags so that test caching works.
|
||||
test-postgres: test-postgres-docker
|
||||
# The postgres test is prone to failure, so we limit parallelism for
|
||||
# more consistent execution.
|
||||
$(GIT_FLAGS) gotestsum \
|
||||
--junitfile="gotests.xml" \
|
||||
--jsonfile="gotests.json" \
|
||||
$(GOTESTSUM_RETRY_FLAGS) \
|
||||
--packages="./..." -- \
|
||||
-tags=testsmallbatch \
|
||||
-timeout=20m \
|
||||
-count=1
|
||||
.PHONY: test-postgres
|
||||
|
||||
test-migrations: test-postgres-docker
|
||||
echo "--- test migrations"
|
||||
@@ -1355,24 +1200,13 @@ test-migrations: test-postgres-docker
|
||||
|
||||
# NOTE: we set --memory to the same size as a GitHub runner.
|
||||
test-postgres-docker:
|
||||
# If our container is already running, nothing to do.
|
||||
if docker ps --filter "name=test-postgres-docker-${POSTGRES_VERSION}" --format '{{.Names}}' | grep -q .; then \
|
||||
echo "test-postgres-docker-${POSTGRES_VERSION} is already running."; \
|
||||
exit 0; \
|
||||
fi
|
||||
# If something else is on 5432, warn but don't fail.
|
||||
if pg_isready -h 127.0.0.1 -q 2>/dev/null; then \
|
||||
echo "WARNING: PostgreSQL is already running on 127.0.0.1:5432 (not our container)."; \
|
||||
echo "Tests will use this instance. To use the Makefile's container, stop it first."; \
|
||||
exit 0; \
|
||||
fi
|
||||
docker rm -f test-postgres-docker-${POSTGRES_VERSION} || true
|
||||
|
||||
# Try pulling up to three times to avoid CI flakes.
|
||||
docker pull ${POSTGRES_IMAGE} || {
|
||||
retries=2
|
||||
for try in $$(seq 1 $${retries}); do
|
||||
echo "Failed to pull image, retrying ($${try}/$${retries})..."
|
||||
for try in $(seq 1 ${retries}); do
|
||||
echo "Failed to pull image, retrying (${try}/${retries})..."
|
||||
sleep 1
|
||||
if docker pull ${POSTGRES_IMAGE}; then
|
||||
break
|
||||
@@ -1413,11 +1247,16 @@ test-postgres-docker:
|
||||
-c log_statement=all
|
||||
while ! pg_isready -h 127.0.0.1
|
||||
do
|
||||
echo "$$(date) - waiting for database to start"
|
||||
echo "$(date) - waiting for database to start"
|
||||
sleep 0.5
|
||||
done
|
||||
.PHONY: test-postgres-docker
|
||||
|
||||
# Make sure to keep this in sync with test-go-race from .github/workflows/ci.yaml.
|
||||
test-race:
|
||||
$(GIT_FLAGS) gotestsum --junitfile="gotests.xml" -- -tags=testsmallbatch -race -count=1 -parallel 4 -p 4 ./...
|
||||
.PHONY: test-race
|
||||
|
||||
test-tailnet-integration:
|
||||
env \
|
||||
CODER_TAILNET_TESTS=true \
|
||||
@@ -1446,7 +1285,6 @@ site/e2e/bin/coder: go.mod go.sum $(GO_SRC_FILES)
|
||||
|
||||
test-e2e: site/e2e/bin/coder site/node_modules/.installed site/out/index.html
|
||||
cd site/
|
||||
pnpm playwright:install
|
||||
ifdef CI
|
||||
DEBUG=pw:api pnpm playwright:test --forbid-only --workers 1
|
||||
else
|
||||
@@ -1461,5 +1299,3 @@ dogfood/coder/nix.hash: flake.nix flake.lock
|
||||
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
|
||||
|
||||
.PHONY: count-test-databases
|
||||
|
||||
+3
-25
@@ -39,10 +39,8 @@ import (
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/clistat"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/agent/agentdesktop"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentfiles"
|
||||
"github.com/coder/coder/v2/agent/agentgit"
|
||||
"github.com/coder/coder/v2/agent/agentproc"
|
||||
"github.com/coder/coder/v2/agent/agentscripts"
|
||||
"github.com/coder/coder/v2/agent/agentsocket"
|
||||
@@ -104,7 +102,6 @@ type Options struct {
|
||||
Execer agentexec.Execer
|
||||
Devcontainers bool
|
||||
DevcontainerAPIOptions []agentcontainers.Option // Enable Devcontainers for these to be effective.
|
||||
GitAPIOptions []agentgit.Option
|
||||
Clock quartz.Clock
|
||||
SocketServerEnabled bool
|
||||
SocketPath string // Path for the agent socket server socket
|
||||
@@ -220,7 +217,6 @@ func New(options Options) Agent {
|
||||
|
||||
devcontainers: options.Devcontainers,
|
||||
containerAPIOptions: options.DevcontainerAPIOptions,
|
||||
gitAPIOptions: options.GitAPIOptions,
|
||||
socketPath: options.SocketPath,
|
||||
socketServerEnabled: options.SocketServerEnabled,
|
||||
boundaryLogProxySocketPath: options.BoundaryLogProxySocketPath,
|
||||
@@ -306,12 +302,9 @@ type agent struct {
|
||||
devcontainers bool
|
||||
containerAPIOptions []agentcontainers.Option
|
||||
containerAPI *agentcontainers.API
|
||||
gitAPIOptions []agentgit.Option
|
||||
|
||||
filesAPI *agentfiles.API
|
||||
gitAPI *agentgit.API
|
||||
processAPI *agentproc.API
|
||||
desktopAPI *agentdesktop.API
|
||||
|
||||
socketServerEnabled bool
|
||||
socketPath string
|
||||
@@ -383,20 +376,9 @@ func (a *agent) init() {
|
||||
|
||||
a.containerAPI = agentcontainers.NewAPI(a.logger.Named("containers"), containerAPIOpts...)
|
||||
|
||||
pathStore := agentgit.NewPathStore()
|
||||
a.filesAPI = agentfiles.NewAPI(a.logger.Named("files"), a.filesystem, pathStore)
|
||||
a.processAPI = agentproc.NewAPI(a.logger.Named("processes"), a.execer, a.updateCommandEnv, pathStore, func() string {
|
||||
if m := a.manifest.Load(); m != nil {
|
||||
return m.Directory
|
||||
}
|
||||
return ""
|
||||
})
|
||||
gitOpts := append([]agentgit.Option{agentgit.WithClock(a.clock)}, a.gitAPIOptions...)
|
||||
a.gitAPI = agentgit.NewAPI(a.logger.Named("git"), pathStore, gitOpts...)
|
||||
desktop := agentdesktop.NewPortableDesktop(
|
||||
a.logger.Named("desktop"), a.execer, a.scriptRunner.ScriptBinDir(),
|
||||
)
|
||||
a.desktopAPI = agentdesktop.NewAPI(a.logger.Named("desktop"), desktop, a.clock)
|
||||
a.filesAPI = agentfiles.NewAPI(a.logger.Named("files"), a.filesystem)
|
||||
a.processAPI = agentproc.NewAPI(a.logger.Named("processes"), a.execer, a.updateCommandEnv)
|
||||
|
||||
a.reconnectingPTYServer = reconnectingpty.NewServer(
|
||||
a.logger.Named("reconnecting-pty"),
|
||||
a.sshServer,
|
||||
@@ -2067,10 +2049,6 @@ func (a *agent) Close() error {
|
||||
a.logger.Error(a.hardCtx, "process API close", slog.Error(err))
|
||||
}
|
||||
|
||||
if err := a.desktopAPI.Close(); err != nil {
|
||||
a.logger.Error(a.hardCtx, "desktop API close", slog.Error(err))
|
||||
}
|
||||
|
||||
if a.boundaryLogProxy != nil {
|
||||
err = a.boundaryLogProxy.Close()
|
||||
if err != nil {
|
||||
|
||||
@@ -3040,62 +3040,6 @@ func TestAgent_Reconnect(t *testing.T) {
|
||||
closer.Close()
|
||||
}
|
||||
|
||||
func TestAgent_ReconnectNoLifecycleReemit(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
fCoordinator := tailnettest.NewFakeCoordinator()
|
||||
agentID := uuid.New()
|
||||
statsCh := make(chan *proto.Stats, 50)
|
||||
derpMap, _ := tailnettest.RunDERPAndSTUN(t)
|
||||
|
||||
client := agenttest.NewClient(t,
|
||||
logger,
|
||||
agentID,
|
||||
agentsdk.Manifest{
|
||||
DERPMap: derpMap,
|
||||
Scripts: []codersdk.WorkspaceAgentScript{{
|
||||
Script: "echo hello",
|
||||
Timeout: 30 * time.Second,
|
||||
RunOnStart: true,
|
||||
}},
|
||||
},
|
||||
statsCh,
|
||||
fCoordinator,
|
||||
)
|
||||
defer client.Close()
|
||||
|
||||
closer := agent.New(agent.Options{
|
||||
Client: client,
|
||||
Logger: logger.Named("agent"),
|
||||
})
|
||||
defer closer.Close()
|
||||
|
||||
// Wait for the agent to reach Ready state.
|
||||
require.Eventually(t, func() bool {
|
||||
return slices.Contains(client.GetLifecycleStates(), codersdk.WorkspaceAgentLifecycleReady)
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
statesBefore := slices.Clone(client.GetLifecycleStates())
|
||||
|
||||
// Disconnect by closing the coordinator response channel.
|
||||
call1 := testutil.RequireReceive(ctx, t, fCoordinator.CoordinateCalls)
|
||||
close(call1.Resps)
|
||||
|
||||
// Wait for reconnect.
|
||||
testutil.RequireReceive(ctx, t, fCoordinator.CoordinateCalls)
|
||||
|
||||
// Wait for a stats report as a deterministic steady-state proof.
|
||||
testutil.RequireReceive(ctx, t, statsCh)
|
||||
|
||||
statesAfter := client.GetLifecycleStates()
|
||||
require.Equal(t, statesBefore, statesAfter,
|
||||
"lifecycle states should not be re-reported after reconnect")
|
||||
|
||||
closer.Close()
|
||||
}
|
||||
|
||||
func TestAgent_WriteVSCodeConfigs(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
@@ -1,536 +0,0 @@
|
||||
package agentdesktop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/quartz"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// DesktopAction is the request body for the desktop action endpoint.
|
||||
type DesktopAction struct {
|
||||
Action string `json:"action"`
|
||||
Coordinate *[2]int `json:"coordinate,omitempty"`
|
||||
StartCoordinate *[2]int `json:"start_coordinate,omitempty"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
Duration *int `json:"duration,omitempty"`
|
||||
ScrollAmount *int `json:"scroll_amount,omitempty"`
|
||||
ScrollDirection *string `json:"scroll_direction,omitempty"`
|
||||
// ScaledWidth and ScaledHeight are the coordinate space the
|
||||
// model is using. When provided, coordinates are linearly
|
||||
// mapped from scaled → native before dispatching.
|
||||
ScaledWidth *int `json:"scaled_width,omitempty"`
|
||||
ScaledHeight *int `json:"scaled_height,omitempty"`
|
||||
}
|
||||
|
||||
// DesktopActionResponse is the response from the desktop action
|
||||
// endpoint.
|
||||
type DesktopActionResponse struct {
|
||||
Output string `json:"output,omitempty"`
|
||||
ScreenshotData string `json:"screenshot_data,omitempty"`
|
||||
ScreenshotWidth int `json:"screenshot_width,omitempty"`
|
||||
ScreenshotHeight int `json:"screenshot_height,omitempty"`
|
||||
}
|
||||
|
||||
// API exposes the desktop streaming HTTP routes for the agent.
|
||||
type API struct {
|
||||
logger slog.Logger
|
||||
desktop Desktop
|
||||
clock quartz.Clock
|
||||
}
|
||||
|
||||
// NewAPI creates a new desktop streaming API.
|
||||
func NewAPI(logger slog.Logger, desktop Desktop, clock quartz.Clock) *API {
|
||||
if clock == nil {
|
||||
clock = quartz.NewReal()
|
||||
}
|
||||
return &API{
|
||||
logger: logger,
|
||||
desktop: desktop,
|
||||
clock: clock,
|
||||
}
|
||||
}
|
||||
|
||||
// Routes returns the chi router for mounting at /api/v0/desktop.
|
||||
func (a *API) Routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/vnc", a.handleDesktopVNC)
|
||||
r.Post("/action", a.handleAction)
|
||||
return r
|
||||
}
|
||||
|
||||
func (a *API) handleDesktopVNC(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Start the desktop session (idempotent).
|
||||
_, err := a.desktop.Start(ctx)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to start desktop session.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get a VNC connection.
|
||||
vncConn, err := a.desktop.VNCConn(ctx)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to connect to VNC server.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer vncConn.Close()
|
||||
|
||||
// Accept WebSocket from coderd.
|
||||
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "failed to accept websocket", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// No read limit — RFB framebuffer updates can be large.
|
||||
conn.SetReadLimit(-1)
|
||||
|
||||
wsCtx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageBinary)
|
||||
defer wsNetConn.Close()
|
||||
|
||||
// Bicopy raw bytes between WebSocket and VNC TCP.
|
||||
agentssh.Bicopy(wsCtx, wsNetConn, vncConn)
|
||||
}
|
||||
|
||||
func (a *API) handleAction(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
handlerStart := a.clock.Now()
|
||||
|
||||
// Ensure the desktop is running and grab native dimensions.
|
||||
cfg, err := a.desktop.Start(ctx)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "handleAction: desktop.Start failed",
|
||||
slog.Error(err),
|
||||
slog.F("elapsed_ms", a.clock.Since(handlerStart).Milliseconds()),
|
||||
)
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to start desktop session.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var action DesktopAction
|
||||
if err := json.NewDecoder(r.Body).Decode(&action); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to decode request body.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info(ctx, "handleAction: started",
|
||||
slog.F("action", action.Action),
|
||||
slog.F("elapsed_ms", a.clock.Since(handlerStart).Milliseconds()),
|
||||
)
|
||||
|
||||
// Helper to scale a coordinate pair from the model's space to
|
||||
// native display pixels.
|
||||
scaleXY := func(x, y int) (int, int) {
|
||||
if action.ScaledWidth != nil && *action.ScaledWidth > 0 {
|
||||
x = scaleCoordinate(x, *action.ScaledWidth, cfg.Width)
|
||||
}
|
||||
if action.ScaledHeight != nil && *action.ScaledHeight > 0 {
|
||||
y = scaleCoordinate(y, *action.ScaledHeight, cfg.Height)
|
||||
}
|
||||
return x, y
|
||||
}
|
||||
|
||||
var resp DesktopActionResponse
|
||||
|
||||
switch action.Action {
|
||||
case "key":
|
||||
if action.Text == nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Missing \"text\" for key action.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := a.desktop.KeyPress(ctx, *action.Text); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Key press failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "key action performed"
|
||||
|
||||
case "type":
|
||||
if action.Text == nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Missing \"text\" for type action.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := a.desktop.Type(ctx, *action.Text); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Type action failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "type action performed"
|
||||
|
||||
case "cursor_position":
|
||||
x, y, err := a.desktop.CursorPosition(ctx)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Cursor position failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "x=" + strconv.Itoa(x) + ",y=" + strconv.Itoa(y)
|
||||
|
||||
case "mouse_move":
|
||||
x, y, err := coordFromAction(action)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
x, y = scaleXY(x, y)
|
||||
if err := a.desktop.Move(ctx, x, y); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Mouse move failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "mouse_move action performed"
|
||||
|
||||
case "left_click":
|
||||
x, y, err := coordFromAction(action)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
x, y = scaleXY(x, y)
|
||||
stepStart := a.clock.Now()
|
||||
if err := a.desktop.Click(ctx, x, y, MouseButtonLeft); err != nil {
|
||||
a.logger.Warn(ctx, "handleAction: Click failed",
|
||||
slog.F("action", "left_click"),
|
||||
slog.F("step", "click"),
|
||||
slog.F("step_ms", time.Since(stepStart).Milliseconds()),
|
||||
slog.F("elapsed_ms", a.clock.Since(handlerStart).Milliseconds()),
|
||||
slog.Error(err),
|
||||
)
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Left click failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
a.logger.Debug(ctx, "handleAction: Click completed",
|
||||
slog.F("action", "left_click"),
|
||||
slog.F("step_ms", time.Since(stepStart).Milliseconds()),
|
||||
slog.F("elapsed_ms", a.clock.Since(handlerStart).Milliseconds()),
|
||||
)
|
||||
resp.Output = "left_click action performed"
|
||||
|
||||
case "left_click_drag":
|
||||
if action.Coordinate == nil || action.StartCoordinate == nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Missing \"coordinate\" or \"start_coordinate\" for left_click_drag.",
|
||||
})
|
||||
return
|
||||
}
|
||||
sx, sy := scaleXY(action.StartCoordinate[0], action.StartCoordinate[1])
|
||||
ex, ey := scaleXY(action.Coordinate[0], action.Coordinate[1])
|
||||
if err := a.desktop.Drag(ctx, sx, sy, ex, ey); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Left click drag failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "left_click_drag action performed"
|
||||
|
||||
case "left_mouse_down":
|
||||
if err := a.desktop.ButtonDown(ctx, MouseButtonLeft); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Left mouse down failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "left_mouse_down action performed"
|
||||
|
||||
case "left_mouse_up":
|
||||
if err := a.desktop.ButtonUp(ctx, MouseButtonLeft); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Left mouse up failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "left_mouse_up action performed"
|
||||
|
||||
case "right_click":
|
||||
x, y, err := coordFromAction(action)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
x, y = scaleXY(x, y)
|
||||
if err := a.desktop.Click(ctx, x, y, MouseButtonRight); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Right click failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "right_click action performed"
|
||||
|
||||
case "middle_click":
|
||||
x, y, err := coordFromAction(action)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
x, y = scaleXY(x, y)
|
||||
if err := a.desktop.Click(ctx, x, y, MouseButtonMiddle); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Middle click failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "middle_click action performed"
|
||||
|
||||
case "double_click":
|
||||
x, y, err := coordFromAction(action)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
x, y = scaleXY(x, y)
|
||||
if err := a.desktop.DoubleClick(ctx, x, y, MouseButtonLeft); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Double click failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "double_click action performed"
|
||||
|
||||
case "triple_click":
|
||||
x, y, err := coordFromAction(action)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
x, y = scaleXY(x, y)
|
||||
for range 3 {
|
||||
if err := a.desktop.Click(ctx, x, y, MouseButtonLeft); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Triple click failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
resp.Output = "triple_click action performed"
|
||||
|
||||
case "scroll":
|
||||
x, y, err := coordFromAction(action)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
x, y = scaleXY(x, y)
|
||||
|
||||
amount := 3
|
||||
if action.ScrollAmount != nil {
|
||||
amount = *action.ScrollAmount
|
||||
}
|
||||
direction := "down"
|
||||
if action.ScrollDirection != nil {
|
||||
direction = *action.ScrollDirection
|
||||
}
|
||||
|
||||
var dx, dy int
|
||||
switch direction {
|
||||
case "up":
|
||||
dy = -amount
|
||||
case "down":
|
||||
dy = amount
|
||||
case "left":
|
||||
dx = -amount
|
||||
case "right":
|
||||
dx = amount
|
||||
default:
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid scroll direction: " + direction,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.desktop.Scroll(ctx, x, y, dx, dy); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Scroll failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "scroll action performed"
|
||||
|
||||
case "hold_key":
|
||||
if action.Text == nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Missing \"text\" for hold_key action.",
|
||||
})
|
||||
return
|
||||
}
|
||||
dur := 1000
|
||||
if action.Duration != nil {
|
||||
dur = *action.Duration
|
||||
}
|
||||
if err := a.desktop.KeyDown(ctx, *action.Text); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Key down failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
timer := a.clock.NewTimer(time.Duration(dur)*time.Millisecond, "agentdesktop", "hold_key")
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Context canceled; release the key immediately.
|
||||
if err := a.desktop.KeyUp(ctx, *action.Text); err != nil {
|
||||
a.logger.Warn(ctx, "handleAction: KeyUp after context cancel", slog.Error(err))
|
||||
}
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
if err := a.desktop.KeyUp(ctx, *action.Text); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Key up failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "hold_key action performed"
|
||||
|
||||
case "screenshot":
|
||||
var opts ScreenshotOptions
|
||||
if action.ScaledWidth != nil && *action.ScaledWidth > 0 {
|
||||
opts.TargetWidth = *action.ScaledWidth
|
||||
}
|
||||
if action.ScaledHeight != nil && *action.ScaledHeight > 0 {
|
||||
opts.TargetHeight = *action.ScaledHeight
|
||||
}
|
||||
result, err := a.desktop.Screenshot(ctx, opts)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Screenshot failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "screenshot"
|
||||
resp.ScreenshotData = result.Data
|
||||
if action.ScaledWidth != nil && *action.ScaledWidth > 0 && *action.ScaledWidth != cfg.Width {
|
||||
resp.ScreenshotWidth = *action.ScaledWidth
|
||||
} else {
|
||||
resp.ScreenshotWidth = cfg.Width
|
||||
}
|
||||
if action.ScaledHeight != nil && *action.ScaledHeight > 0 && *action.ScaledHeight != cfg.Height {
|
||||
resp.ScreenshotHeight = *action.ScaledHeight
|
||||
} else {
|
||||
resp.ScreenshotHeight = cfg.Height
|
||||
}
|
||||
|
||||
default:
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Unknown action: " + action.Action,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
elapsedMs := a.clock.Since(handlerStart).Milliseconds()
|
||||
if ctx.Err() != nil {
|
||||
a.logger.Error(ctx, "handleAction: context canceled before writing response",
|
||||
slog.F("action", action.Action),
|
||||
slog.F("elapsed_ms", elapsedMs),
|
||||
slog.Error(ctx.Err()),
|
||||
)
|
||||
return
|
||||
}
|
||||
a.logger.Info(ctx, "handleAction: writing response",
|
||||
slog.F("action", action.Action),
|
||||
slog.F("elapsed_ms", elapsedMs),
|
||||
)
|
||||
httpapi.Write(ctx, rw, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Close shuts down the desktop session if one is running.
|
||||
func (a *API) Close() error {
|
||||
return a.desktop.Close()
|
||||
}
|
||||
|
||||
// coordFromAction extracts the coordinate pair from a DesktopAction,
|
||||
// returning an error if the coordinate field is missing.
|
||||
func coordFromAction(action DesktopAction) (x, y int, err error) {
|
||||
if action.Coordinate == nil {
|
||||
return 0, 0, &missingFieldError{field: "coordinate", action: action.Action}
|
||||
}
|
||||
return action.Coordinate[0], action.Coordinate[1], nil
|
||||
}
|
||||
|
||||
// missingFieldError is returned when a required field is absent from
|
||||
// a DesktopAction.
|
||||
type missingFieldError struct {
|
||||
field string
|
||||
action string
|
||||
}
|
||||
|
||||
func (e *missingFieldError) Error() string {
|
||||
return "Missing \"" + e.field + "\" for " + e.action + " action."
|
||||
}
|
||||
|
||||
// scaleCoordinate maps a coordinate from scaled → native space.
|
||||
func scaleCoordinate(scaled, scaledDim, nativeDim int) int {
|
||||
if scaledDim == 0 || scaledDim == nativeDim {
|
||||
return scaled
|
||||
}
|
||||
native := (float64(scaled)+0.5)*float64(nativeDim)/float64(scaledDim) - 0.5
|
||||
// Clamp to valid range.
|
||||
native = math.Max(native, 0)
|
||||
native = math.Min(native, float64(nativeDim-1))
|
||||
return int(native)
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
package agentdesktop_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentdesktop"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
// Ensure fakeDesktop satisfies the Desktop interface at compile time.
|
||||
var _ agentdesktop.Desktop = (*fakeDesktop)(nil)
|
||||
|
||||
// fakeDesktop is a minimal Desktop implementation for unit tests.
|
||||
type fakeDesktop struct {
|
||||
startErr error
|
||||
startCfg agentdesktop.DisplayConfig
|
||||
vncConnErr error
|
||||
screenshotErr error
|
||||
screenshotRes agentdesktop.ScreenshotResult
|
||||
closed bool
|
||||
|
||||
// Track calls for assertions.
|
||||
lastMove [2]int
|
||||
lastClick [3]int // x, y, button
|
||||
lastScroll [4]int // x, y, dx, dy
|
||||
lastKey string
|
||||
lastTyped string
|
||||
lastKeyDown string
|
||||
lastKeyUp string
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) Start(context.Context) (agentdesktop.DisplayConfig, error) {
|
||||
return f.startCfg, f.startErr
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) VNCConn(context.Context) (net.Conn, error) {
|
||||
return nil, f.vncConnErr
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) Screenshot(_ context.Context, _ agentdesktop.ScreenshotOptions) (agentdesktop.ScreenshotResult, error) {
|
||||
return f.screenshotRes, f.screenshotErr
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) Move(_ context.Context, x, y int) error {
|
||||
f.lastMove = [2]int{x, y}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) Click(_ context.Context, x, y int, _ agentdesktop.MouseButton) error {
|
||||
f.lastClick = [3]int{x, y, 1}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) DoubleClick(_ context.Context, x, y int, _ agentdesktop.MouseButton) error {
|
||||
f.lastClick = [3]int{x, y, 2}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*fakeDesktop) ButtonDown(context.Context, agentdesktop.MouseButton) error { return nil }
|
||||
func (*fakeDesktop) ButtonUp(context.Context, agentdesktop.MouseButton) error { return nil }
|
||||
|
||||
func (f *fakeDesktop) Scroll(_ context.Context, x, y, dx, dy int) error {
|
||||
f.lastScroll = [4]int{x, y, dx, dy}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*fakeDesktop) Drag(context.Context, int, int, int, int) error { return nil }
|
||||
|
||||
func (f *fakeDesktop) KeyPress(_ context.Context, key string) error {
|
||||
f.lastKey = key
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) KeyDown(_ context.Context, key string) error {
|
||||
f.lastKeyDown = key
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) KeyUp(_ context.Context, key string) error {
|
||||
f.lastKeyUp = key
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) Type(_ context.Context, text string) error {
|
||||
f.lastTyped = text
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*fakeDesktop) CursorPosition(context.Context) (x int, y int, err error) {
|
||||
return 10, 20, nil
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) Close() error {
|
||||
f.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHandleDesktopVNC_StartError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{startErr: xerrors.New("no desktop")}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/vnc", nil)
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, rr.Code)
|
||||
|
||||
var resp codersdk.Response
|
||||
err := json.NewDecoder(rr.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Failed to start desktop session.", resp.Message)
|
||||
}
|
||||
|
||||
func TestHandleAction_Screenshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: workspacesdk.DesktopDisplayWidth, Height: workspacesdk.DesktopDisplayHeight},
|
||||
screenshotRes: agentdesktop.ScreenshotResult{Data: "base64data"},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
body := agentdesktop.DesktopAction{Action: "screenshot"}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var result agentdesktop.DesktopActionResponse
|
||||
err = json.NewDecoder(rr.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
// Dimensions come from DisplayConfig, not the screenshot CLI.
|
||||
assert.Equal(t, "screenshot", result.Output)
|
||||
assert.Equal(t, "base64data", result.ScreenshotData)
|
||||
assert.Equal(t, workspacesdk.DesktopDisplayWidth, result.ScreenshotWidth)
|
||||
assert.Equal(t, workspacesdk.DesktopDisplayHeight, result.ScreenshotHeight)
|
||||
}
|
||||
|
||||
func TestHandleAction_LeftClick(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
body := agentdesktop.DesktopAction{
|
||||
Action: "left_click",
|
||||
Coordinate: &[2]int{100, 200},
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var resp agentdesktop.DesktopActionResponse
|
||||
err = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "left_click action performed", resp.Output)
|
||||
assert.Equal(t, [3]int{100, 200, 1}, fake.lastClick)
|
||||
}
|
||||
|
||||
func TestHandleAction_UnknownAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
body := agentdesktop.DesktopAction{Action: "explode"}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
|
||||
func TestHandleAction_KeyAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
text := "Return"
|
||||
body := agentdesktop.DesktopAction{
|
||||
Action: "key",
|
||||
Text: &text,
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Equal(t, "Return", fake.lastKey)
|
||||
}
|
||||
|
||||
func TestHandleAction_TypeAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
text := "hello world"
|
||||
body := agentdesktop.DesktopAction{
|
||||
Action: "type",
|
||||
Text: &text,
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Equal(t, "hello world", fake.lastTyped)
|
||||
}
|
||||
|
||||
func TestHandleAction_HoldKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
mClk := quartz.NewMock(t)
|
||||
trap := mClk.Trap().NewTimer("agentdesktop", "hold_key")
|
||||
defer trap.Close()
|
||||
api := agentdesktop.NewAPI(logger, fake, mClk)
|
||||
defer api.Close()
|
||||
|
||||
text := "Shift_L"
|
||||
dur := 100
|
||||
body := agentdesktop.DesktopAction{
|
||||
Action: "hold_key",
|
||||
Text: &text,
|
||||
Duration: &dur,
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
handler.ServeHTTP(rr, req)
|
||||
}()
|
||||
|
||||
// Wait for the timer to be created, then advance past it.
|
||||
trap.MustWait(req.Context()).MustRelease(req.Context())
|
||||
mClk.Advance(time.Duration(dur) * time.Millisecond).MustWait(req.Context())
|
||||
|
||||
<-done
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var resp agentdesktop.DesktopActionResponse
|
||||
err = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hold_key action performed", resp.Output)
|
||||
assert.Equal(t, "Shift_L", fake.lastKeyDown)
|
||||
assert.Equal(t, "Shift_L", fake.lastKeyUp)
|
||||
}
|
||||
|
||||
func TestHandleAction_HoldKeyMissingText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
body := agentdesktop.DesktopAction{Action: "hold_key"}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
|
||||
var resp codersdk.Response
|
||||
err = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Missing \"text\" for hold_key action.", resp.Message)
|
||||
}
|
||||
|
||||
func TestHandleAction_ScrollDown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
dir := "down"
|
||||
amount := 5
|
||||
body := agentdesktop.DesktopAction{
|
||||
Action: "scroll",
|
||||
Coordinate: &[2]int{500, 400},
|
||||
ScrollDirection: &dir,
|
||||
ScrollAmount: &amount,
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
// dy should be positive 5 for "down".
|
||||
assert.Equal(t, [4]int{500, 400, 0, 5}, fake.lastScroll)
|
||||
}
|
||||
|
||||
func TestHandleAction_CoordinateScaling(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
// Native display is 1920x1080.
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
// Model is working in a 1280x720 coordinate space.
|
||||
sw := 1280
|
||||
sh := 720
|
||||
body := agentdesktop.DesktopAction{
|
||||
Action: "mouse_move",
|
||||
Coordinate: &[2]int{640, 360},
|
||||
ScaledWidth: &sw,
|
||||
ScaledHeight: &sh,
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
// 640 in 1280-space → 960 in 1920-space (midpoint maps to
|
||||
// midpoint).
|
||||
assert.Equal(t, 960, fake.lastMove[0])
|
||||
assert.Equal(t, 540, fake.lastMove[1])
|
||||
}
|
||||
|
||||
func TestClose_DelegatesToDesktop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
|
||||
err := api.Close()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, fake.closed)
|
||||
}
|
||||
|
||||
func TestClose_PreventsNewSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
// After Close(), Start() will return an error because the
|
||||
// underlying Desktop is closed.
|
||||
fake := &fakeDesktop{}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
|
||||
err := api.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate the closed desktop returning an error on Start().
|
||||
fake.startErr = xerrors.New("desktop is closed")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/vnc", nil)
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, rr.Code)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package agentdesktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
)
|
||||
|
||||
// Desktop abstracts a virtual desktop session running inside a workspace.
|
||||
type Desktop interface {
|
||||
// Start launches the desktop session. It is idempotent — calling
|
||||
// Start on an already-running session returns the existing
|
||||
// config. The returned DisplayConfig describes the running
|
||||
// session.
|
||||
Start(ctx context.Context) (DisplayConfig, error)
|
||||
|
||||
// VNCConn dials the desktop's VNC server and returns a raw
|
||||
// net.Conn carrying RFB binary frames. Each call returns a new
|
||||
// connection; multiple clients can connect simultaneously.
|
||||
// Start must be called before VNCConn.
|
||||
VNCConn(ctx context.Context) (net.Conn, error)
|
||||
|
||||
// Screenshot captures the current framebuffer as a PNG and
|
||||
// returns it base64-encoded. TargetWidth/TargetHeight in opts
|
||||
// are the desired output dimensions (the implementation
|
||||
// rescales); pass 0 to use native resolution.
|
||||
Screenshot(ctx context.Context, opts ScreenshotOptions) (ScreenshotResult, error)
|
||||
|
||||
// Mouse operations.
|
||||
|
||||
// Move moves the mouse cursor to absolute coordinates.
|
||||
Move(ctx context.Context, x, y int) error
|
||||
// Click performs a mouse button click at the given coordinates.
|
||||
Click(ctx context.Context, x, y int, button MouseButton) error
|
||||
// DoubleClick performs a double-click at the given coordinates.
|
||||
DoubleClick(ctx context.Context, x, y int, button MouseButton) error
|
||||
// ButtonDown presses and holds a mouse button.
|
||||
ButtonDown(ctx context.Context, button MouseButton) error
|
||||
// ButtonUp releases a mouse button.
|
||||
ButtonUp(ctx context.Context, button MouseButton) error
|
||||
// Scroll scrolls by (dx, dy) clicks at the given coordinates.
|
||||
Scroll(ctx context.Context, x, y, dx, dy int) error
|
||||
// Drag moves from (startX,startY) to (endX,endY) while holding
|
||||
// the left mouse button.
|
||||
Drag(ctx context.Context, startX, startY, endX, endY int) error
|
||||
|
||||
// Keyboard operations.
|
||||
|
||||
// KeyPress sends a key-down then key-up for a key combo string
|
||||
// (e.g. "Return", "ctrl+c").
|
||||
KeyPress(ctx context.Context, keys string) error
|
||||
// KeyDown presses and holds a key.
|
||||
KeyDown(ctx context.Context, key string) error
|
||||
// KeyUp releases a key.
|
||||
KeyUp(ctx context.Context, key string) error
|
||||
// Type types a string of text character-by-character.
|
||||
Type(ctx context.Context, text string) error
|
||||
|
||||
// CursorPosition returns the current cursor coordinates.
|
||||
CursorPosition(ctx context.Context) (x, y int, err error)
|
||||
|
||||
// Close shuts down the desktop session and cleans up resources.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// DisplayConfig describes a running desktop session.
|
||||
type DisplayConfig struct {
|
||||
Width int // native width in pixels
|
||||
Height int // native height in pixels
|
||||
VNCPort int // local TCP port for the VNC server
|
||||
Display int // X11 display number (e.g. 1 for :1), -1 if N/A
|
||||
}
|
||||
|
||||
// MouseButton identifies a mouse button.
|
||||
type MouseButton string
|
||||
|
||||
const (
|
||||
MouseButtonLeft MouseButton = "left"
|
||||
MouseButtonRight MouseButton = "right"
|
||||
MouseButtonMiddle MouseButton = "middle"
|
||||
)
|
||||
|
||||
// ScreenshotOptions configures a screenshot capture.
|
||||
type ScreenshotOptions struct {
|
||||
TargetWidth int // 0 = native
|
||||
TargetHeight int // 0 = native
|
||||
}
|
||||
|
||||
// ScreenshotResult is a captured screenshot.
|
||||
type ScreenshotResult struct {
|
||||
Data string // base64-encoded PNG
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
package agentdesktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
// portableDesktopOutput is the JSON output from
|
||||
// `portabledesktop up --json`.
|
||||
type portableDesktopOutput struct {
|
||||
VNCPort int `json:"vncPort"`
|
||||
Geometry string `json:"geometry"` // e.g. "1920x1080"
|
||||
}
|
||||
|
||||
// desktopSession tracks a running portabledesktop process.
|
||||
type desktopSession struct {
|
||||
cmd *exec.Cmd
|
||||
vncPort int
|
||||
width int // native width, parsed from geometry
|
||||
height int // native height, parsed from geometry
|
||||
display int // X11 display number, -1 if not available
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// cursorOutput is the JSON output from `portabledesktop cursor --json`.
|
||||
type cursorOutput struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
// screenshotOutput is the JSON output from
|
||||
// `portabledesktop screenshot --json`.
|
||||
type screenshotOutput struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// portableDesktop implements Desktop by shelling out to the
|
||||
// portabledesktop CLI via agentexec.Execer.
|
||||
type portableDesktop struct {
|
||||
logger slog.Logger
|
||||
execer agentexec.Execer
|
||||
scriptBinDir string // coder script bin directory
|
||||
|
||||
mu sync.Mutex
|
||||
session *desktopSession // nil until started
|
||||
binPath string // resolved path to binary, cached
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewPortableDesktop creates a Desktop backed by the portabledesktop
|
||||
// CLI binary, using execer to spawn child processes. scriptBinDir is
|
||||
// the coder script bin directory checked for the binary.
|
||||
func NewPortableDesktop(
|
||||
logger slog.Logger,
|
||||
execer agentexec.Execer,
|
||||
scriptBinDir string,
|
||||
) Desktop {
|
||||
return &portableDesktop{
|
||||
logger: logger,
|
||||
execer: execer,
|
||||
scriptBinDir: scriptBinDir,
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the desktop session (idempotent).
|
||||
func (p *portableDesktop) Start(ctx context.Context) (DisplayConfig, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.closed {
|
||||
return DisplayConfig{}, xerrors.New("desktop is closed")
|
||||
}
|
||||
|
||||
if err := p.ensureBinary(ctx); err != nil {
|
||||
return DisplayConfig{}, xerrors.Errorf("ensure portabledesktop binary: %w", err)
|
||||
}
|
||||
|
||||
// If we have an existing session, check if it's still alive.
|
||||
if p.session != nil {
|
||||
if !(p.session.cmd.ProcessState != nil && p.session.cmd.ProcessState.Exited()) {
|
||||
return DisplayConfig{
|
||||
Width: p.session.width,
|
||||
Height: p.session.height,
|
||||
VNCPort: p.session.vncPort,
|
||||
Display: p.session.display,
|
||||
}, nil
|
||||
}
|
||||
// Process died — clean up and recreate.
|
||||
p.logger.Warn(ctx, "portabledesktop process died, recreating session")
|
||||
p.session.cancel()
|
||||
p.session = nil
|
||||
}
|
||||
|
||||
// Spawn portabledesktop up --json.
|
||||
sessionCtx, sessionCancel := context.WithCancel(context.Background())
|
||||
|
||||
//nolint:gosec // portabledesktop is a trusted binary resolved via ensureBinary.
|
||||
cmd := p.execer.CommandContext(sessionCtx, p.binPath, "up", "--json",
|
||||
"--geometry", fmt.Sprintf("%dx%d", workspacesdk.DesktopDisplayWidth, workspacesdk.DesktopDisplayHeight))
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
sessionCancel()
|
||||
return DisplayConfig{}, xerrors.Errorf("create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
sessionCancel()
|
||||
return DisplayConfig{}, xerrors.Errorf("start portabledesktop: %w", err)
|
||||
}
|
||||
|
||||
// Parse the JSON output to get VNC port and geometry.
|
||||
var output portableDesktopOutput
|
||||
if err := json.NewDecoder(stdout).Decode(&output); err != nil {
|
||||
sessionCancel()
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return DisplayConfig{}, xerrors.Errorf("parse portabledesktop output: %w", err)
|
||||
}
|
||||
|
||||
if output.VNCPort == 0 {
|
||||
sessionCancel()
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return DisplayConfig{}, xerrors.New("portabledesktop returned port 0")
|
||||
}
|
||||
|
||||
var w, h int
|
||||
if output.Geometry != "" {
|
||||
if _, err := fmt.Sscanf(output.Geometry, "%dx%d", &w, &h); err != nil {
|
||||
p.logger.Warn(ctx, "failed to parse geometry, using defaults",
|
||||
slog.F("geometry", output.Geometry),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
p.logger.Info(ctx, "started portabledesktop session",
|
||||
slog.F("vnc_port", output.VNCPort),
|
||||
slog.F("width", w),
|
||||
slog.F("height", h),
|
||||
slog.F("pid", cmd.Process.Pid),
|
||||
)
|
||||
|
||||
p.session = &desktopSession{
|
||||
cmd: cmd,
|
||||
vncPort: output.VNCPort,
|
||||
width: w,
|
||||
height: h,
|
||||
display: -1,
|
||||
cancel: sessionCancel,
|
||||
}
|
||||
|
||||
return DisplayConfig{
|
||||
Width: w,
|
||||
Height: h,
|
||||
VNCPort: output.VNCPort,
|
||||
Display: -1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VNCConn dials the desktop's VNC server and returns a raw
|
||||
// net.Conn carrying RFB binary frames.
|
||||
func (p *portableDesktop) VNCConn(_ context.Context) (net.Conn, error) {
|
||||
p.mu.Lock()
|
||||
session := p.session
|
||||
p.mu.Unlock()
|
||||
|
||||
if session == nil {
|
||||
return nil, xerrors.New("desktop session not started")
|
||||
}
|
||||
|
||||
return net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", session.vncPort))
|
||||
}
|
||||
|
||||
// Screenshot captures the current framebuffer as a base64-encoded PNG.
|
||||
func (p *portableDesktop) Screenshot(ctx context.Context, opts ScreenshotOptions) (ScreenshotResult, error) {
|
||||
args := []string{"screenshot", "--json"}
|
||||
if opts.TargetWidth > 0 {
|
||||
args = append(args, "--target-width", strconv.Itoa(opts.TargetWidth))
|
||||
}
|
||||
if opts.TargetHeight > 0 {
|
||||
args = append(args, "--target-height", strconv.Itoa(opts.TargetHeight))
|
||||
}
|
||||
|
||||
out, err := p.runCmd(ctx, args...)
|
||||
if err != nil {
|
||||
return ScreenshotResult{}, err
|
||||
}
|
||||
|
||||
var result screenshotOutput
|
||||
if err := json.Unmarshal([]byte(out), &result); err != nil {
|
||||
return ScreenshotResult{}, xerrors.Errorf("parse screenshot output: %w", err)
|
||||
}
|
||||
|
||||
return ScreenshotResult(result), nil
|
||||
}
|
||||
|
||||
// Move moves the mouse cursor to absolute coordinates.
|
||||
func (p *portableDesktop) Move(ctx context.Context, x, y int) error {
|
||||
_, err := p.runCmd(ctx, "mouse", "move", strconv.Itoa(x), strconv.Itoa(y))
|
||||
return err
|
||||
}
|
||||
|
||||
// Click performs a mouse button click at the given coordinates.
|
||||
func (p *portableDesktop) Click(ctx context.Context, x, y int, button MouseButton) error {
|
||||
if _, err := p.runCmd(ctx, "mouse", "move", strconv.Itoa(x), strconv.Itoa(y)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := p.runCmd(ctx, "mouse", "click", string(button))
|
||||
return err
|
||||
}
|
||||
|
||||
// DoubleClick performs a double-click at the given coordinates.
|
||||
func (p *portableDesktop) DoubleClick(ctx context.Context, x, y int, button MouseButton) error {
|
||||
if _, err := p.runCmd(ctx, "mouse", "move", strconv.Itoa(x), strconv.Itoa(y)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := p.runCmd(ctx, "mouse", "click", string(button)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := p.runCmd(ctx, "mouse", "click", string(button))
|
||||
return err
|
||||
}
|
||||
|
||||
// ButtonDown presses and holds a mouse button.
|
||||
func (p *portableDesktop) ButtonDown(ctx context.Context, button MouseButton) error {
|
||||
_, err := p.runCmd(ctx, "mouse", "down", string(button))
|
||||
return err
|
||||
}
|
||||
|
||||
// ButtonUp releases a mouse button.
|
||||
func (p *portableDesktop) ButtonUp(ctx context.Context, button MouseButton) error {
|
||||
_, err := p.runCmd(ctx, "mouse", "up", string(button))
|
||||
return err
|
||||
}
|
||||
|
||||
// Scroll scrolls by (dx, dy) clicks at the given coordinates.
|
||||
func (p *portableDesktop) Scroll(ctx context.Context, x, y, dx, dy int) error {
|
||||
if _, err := p.runCmd(ctx, "mouse", "move", strconv.Itoa(x), strconv.Itoa(y)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := p.runCmd(ctx, "mouse", "scroll", strconv.Itoa(dx), strconv.Itoa(dy))
|
||||
return err
|
||||
}
|
||||
|
||||
// Drag moves from (startX,startY) to (endX,endY) while holding the
|
||||
// left mouse button.
|
||||
func (p *portableDesktop) Drag(ctx context.Context, startX, startY, endX, endY int) error {
|
||||
if _, err := p.runCmd(ctx, "mouse", "move", strconv.Itoa(startX), strconv.Itoa(startY)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := p.runCmd(ctx, "mouse", "down", string(MouseButtonLeft)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := p.runCmd(ctx, "mouse", "move", strconv.Itoa(endX), strconv.Itoa(endY)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := p.runCmd(ctx, "mouse", "up", string(MouseButtonLeft))
|
||||
return err
|
||||
}
|
||||
|
||||
// KeyPress sends a key-down then key-up for a key combo string.
|
||||
func (p *portableDesktop) KeyPress(ctx context.Context, keys string) error {
|
||||
_, err := p.runCmd(ctx, "keyboard", "key", keys)
|
||||
return err
|
||||
}
|
||||
|
||||
// KeyDown presses and holds a key.
|
||||
func (p *portableDesktop) KeyDown(ctx context.Context, key string) error {
|
||||
_, err := p.runCmd(ctx, "keyboard", "down", key)
|
||||
return err
|
||||
}
|
||||
|
||||
// KeyUp releases a key.
|
||||
func (p *portableDesktop) KeyUp(ctx context.Context, key string) error {
|
||||
_, err := p.runCmd(ctx, "keyboard", "up", key)
|
||||
return err
|
||||
}
|
||||
|
||||
// Type types a string of text character-by-character.
|
||||
func (p *portableDesktop) Type(ctx context.Context, text string) error {
|
||||
_, err := p.runCmd(ctx, "keyboard", "type", text)
|
||||
return err
|
||||
}
|
||||
|
||||
// CursorPosition returns the current cursor coordinates.
|
||||
func (p *portableDesktop) CursorPosition(ctx context.Context) (x int, y int, err error) {
|
||||
out, err := p.runCmd(ctx, "cursor", "--json")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
var result cursorOutput
|
||||
if err := json.Unmarshal([]byte(out), &result); err != nil {
|
||||
return 0, 0, xerrors.Errorf("parse cursor output: %w", err)
|
||||
}
|
||||
|
||||
return result.X, result.Y, nil
|
||||
}
|
||||
|
||||
// Close shuts down the desktop session and cleans up resources.
|
||||
func (p *portableDesktop) Close() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.closed = true
|
||||
if p.session != nil {
|
||||
p.session.cancel()
|
||||
// Xvnc is a child process — killing it cleans up the X
|
||||
// session.
|
||||
_ = p.session.cmd.Process.Kill()
|
||||
_ = p.session.cmd.Wait()
|
||||
p.session = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCmd executes a portabledesktop subcommand and returns combined
|
||||
// output. The caller must have previously called ensureBinary.
|
||||
func (p *portableDesktop) runCmd(ctx context.Context, args ...string) (string, error) {
|
||||
start := time.Now()
|
||||
//nolint:gosec // args are constructed by the caller, not user input.
|
||||
cmd := p.execer.CommandContext(ctx, p.binPath, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
p.logger.Warn(ctx, "portabledesktop command failed",
|
||||
slog.F("args", args),
|
||||
slog.F("elapsed_ms", elapsed.Milliseconds()),
|
||||
slog.Error(err),
|
||||
slog.F("output", string(out)),
|
||||
)
|
||||
return "", xerrors.Errorf("portabledesktop %s: %w: %s", args[0], err, string(out))
|
||||
}
|
||||
if elapsed > 5*time.Second {
|
||||
p.logger.Warn(ctx, "portabledesktop command slow",
|
||||
slog.F("args", args),
|
||||
slog.F("elapsed_ms", elapsed.Milliseconds()),
|
||||
)
|
||||
} else {
|
||||
p.logger.Debug(ctx, "portabledesktop command completed",
|
||||
slog.F("args", args),
|
||||
slog.F("elapsed_ms", elapsed.Milliseconds()),
|
||||
)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// ensureBinary resolves the portabledesktop binary from PATH or the
|
||||
// coder script bin directory. It must be called while p.mu is held.
|
||||
func (p *portableDesktop) ensureBinary(ctx context.Context) error {
|
||||
if p.binPath != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. Check PATH.
|
||||
if path, err := exec.LookPath("portabledesktop"); err == nil {
|
||||
p.logger.Info(ctx, "found portabledesktop in PATH",
|
||||
slog.F("path", path),
|
||||
)
|
||||
p.binPath = path
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. Check the coder script bin directory.
|
||||
scriptBinPath := filepath.Join(p.scriptBinDir, "portabledesktop")
|
||||
if info, err := os.Stat(scriptBinPath); err == nil && !info.IsDir() {
|
||||
// On Windows, permission bits don't indicate executability,
|
||||
// so accept any regular file.
|
||||
if runtime.GOOS == "windows" || info.Mode()&0o111 != 0 {
|
||||
p.logger.Info(ctx, "found portabledesktop in script bin directory",
|
||||
slog.F("path", scriptBinPath),
|
||||
)
|
||||
p.binPath = scriptBinPath
|
||||
return nil
|
||||
}
|
||||
p.logger.Warn(ctx, "portabledesktop found in script bin directory but not executable",
|
||||
slog.F("path", scriptBinPath),
|
||||
slog.F("mode", info.Mode().String()),
|
||||
)
|
||||
}
|
||||
|
||||
return xerrors.New("portabledesktop binary not found in PATH or script bin directory")
|
||||
}
|
||||
@@ -1,545 +0,0 @@
|
||||
package agentdesktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/pty"
|
||||
)
|
||||
|
||||
// recordedExecer implements agentexec.Execer by recording every
|
||||
// invocation and delegating to a real shell command built from a
|
||||
// caller-supplied mapping of subcommand → shell script body.
|
||||
type recordedExecer struct {
|
||||
mu sync.Mutex
|
||||
commands [][]string
|
||||
// scripts maps a subcommand keyword (e.g. "up", "screenshot")
|
||||
// to a shell snippet whose stdout will be the command output.
|
||||
scripts map[string]string
|
||||
}
|
||||
|
||||
func (r *recordedExecer) record(cmd string, args ...string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.commands = append(r.commands, append([]string{cmd}, args...))
|
||||
}
|
||||
|
||||
func (r *recordedExecer) allCommands() [][]string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([][]string, len(r.commands))
|
||||
copy(out, r.commands)
|
||||
return out
|
||||
}
|
||||
|
||||
// scriptFor finds the first matching script key present in args.
|
||||
func (r *recordedExecer) scriptFor(args []string) string {
|
||||
for _, a := range args {
|
||||
if s, ok := r.scripts[a]; ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
// Fallback: succeed silently.
|
||||
return "true"
|
||||
}
|
||||
|
||||
func (r *recordedExecer) CommandContext(ctx context.Context, cmd string, args ...string) *exec.Cmd {
|
||||
r.record(cmd, args...)
|
||||
script := r.scriptFor(args)
|
||||
//nolint:gosec // Test helper — script content is controlled by the test.
|
||||
return exec.CommandContext(ctx, "sh", "-c", script)
|
||||
}
|
||||
|
||||
func (r *recordedExecer) PTYCommandContext(ctx context.Context, cmd string, args ...string) *pty.Cmd {
|
||||
r.record(cmd, args...)
|
||||
return pty.CommandContext(ctx, "sh", "-c", r.scriptFor(args))
|
||||
}
|
||||
|
||||
// --- portableDesktop tests ---
|
||||
|
||||
func TestPortableDesktop_Start_ParsesOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
|
||||
// The "up" script prints the JSON line then sleeps until
|
||||
// the context is canceled (simulating a long-running process).
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"up": `printf '{"vncPort":5901,"geometry":"1920x1080"}\n' && sleep 120`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop", // pre-set so ensureBinary is a no-op
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
cfg, err := pd.Start(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1920, cfg.Width)
|
||||
assert.Equal(t, 1080, cfg.Height)
|
||||
assert.Equal(t, 5901, cfg.VNCPort)
|
||||
assert.Equal(t, -1, cfg.Display)
|
||||
|
||||
// Clean up the long-running process.
|
||||
require.NoError(t, pd.Close())
|
||||
}
|
||||
|
||||
func TestPortableDesktop_Start_Idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"up": `printf '{"vncPort":5901,"geometry":"1920x1080"}\n' && sleep 120`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop",
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
cfg1, err := pd.Start(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg2, err := pd.Start(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, cfg1, cfg2, "second Start should return the same config")
|
||||
|
||||
// The execer should have been called exactly once for "up".
|
||||
cmds := rec.allCommands()
|
||||
upCalls := 0
|
||||
for _, c := range cmds {
|
||||
for _, a := range c {
|
||||
if a == "up" {
|
||||
upCalls++
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, upCalls, "expected exactly one 'up' invocation")
|
||||
|
||||
require.NoError(t, pd.Close())
|
||||
}
|
||||
|
||||
func TestPortableDesktop_Screenshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"screenshot": `echo '{"data":"abc123"}'`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop",
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
result, err := pd.Screenshot(ctx, ScreenshotOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "abc123", result.Data)
|
||||
}
|
||||
|
||||
func TestPortableDesktop_Screenshot_WithTargetDimensions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"screenshot": `echo '{"data":"x"}'`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop",
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
_, err := pd.Screenshot(ctx, ScreenshotOptions{
|
||||
TargetWidth: 800,
|
||||
TargetHeight: 600,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cmds := rec.allCommands()
|
||||
require.NotEmpty(t, cmds)
|
||||
|
||||
// The last command should contain the target dimension flags.
|
||||
last := cmds[len(cmds)-1]
|
||||
joined := strings.Join(last, " ")
|
||||
assert.Contains(t, joined, "--target-width 800")
|
||||
assert.Contains(t, joined, "--target-height 600")
|
||||
}
|
||||
|
||||
func TestPortableDesktop_MouseMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Each sub-test verifies a single mouse method dispatches the
|
||||
// correct CLI arguments.
|
||||
tests := []struct {
|
||||
name string
|
||||
invoke func(context.Context, *portableDesktop) error
|
||||
wantArgs []string // substrings expected in a recorded command
|
||||
}{
|
||||
{
|
||||
name: "Move",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.Move(ctx, 42, 99)
|
||||
},
|
||||
wantArgs: []string{"mouse", "move", "42", "99"},
|
||||
},
|
||||
{
|
||||
name: "Click",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.Click(ctx, 10, 20, MouseButtonLeft)
|
||||
},
|
||||
// Click does move then click.
|
||||
wantArgs: []string{"mouse", "click", "left"},
|
||||
},
|
||||
{
|
||||
name: "DoubleClick",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.DoubleClick(ctx, 5, 6, MouseButtonRight)
|
||||
},
|
||||
wantArgs: []string{"mouse", "click", "right"},
|
||||
},
|
||||
{
|
||||
name: "ButtonDown",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.ButtonDown(ctx, MouseButtonMiddle)
|
||||
},
|
||||
wantArgs: []string{"mouse", "down", "middle"},
|
||||
},
|
||||
{
|
||||
name: "ButtonUp",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.ButtonUp(ctx, MouseButtonLeft)
|
||||
},
|
||||
wantArgs: []string{"mouse", "up", "left"},
|
||||
},
|
||||
{
|
||||
name: "Scroll",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.Scroll(ctx, 50, 60, 3, 4)
|
||||
},
|
||||
wantArgs: []string{"mouse", "scroll", "3", "4"},
|
||||
},
|
||||
{
|
||||
name: "Drag",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.Drag(ctx, 10, 20, 30, 40)
|
||||
},
|
||||
// Drag ends with mouse up left.
|
||||
wantArgs: []string{"mouse", "up", "left"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"mouse": `echo ok`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop",
|
||||
}
|
||||
|
||||
err := tt.invoke(t.Context(), pd)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmds := rec.allCommands()
|
||||
require.NotEmpty(t, cmds, "expected at least one command")
|
||||
|
||||
// Find at least one recorded command that contains
|
||||
// all expected argument substrings.
|
||||
found := false
|
||||
for _, cmd := range cmds {
|
||||
joined := strings.Join(cmd, " ")
|
||||
match := true
|
||||
for _, want := range tt.wantArgs {
|
||||
if !strings.Contains(joined, want) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found,
|
||||
"no recorded command matched %v; got %v", tt.wantArgs, cmds)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableDesktop_KeyboardMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
invoke func(context.Context, *portableDesktop) error
|
||||
wantArgs []string
|
||||
}{
|
||||
{
|
||||
name: "KeyPress",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.KeyPress(ctx, "Return")
|
||||
},
|
||||
wantArgs: []string{"keyboard", "key", "Return"},
|
||||
},
|
||||
{
|
||||
name: "KeyDown",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.KeyDown(ctx, "shift")
|
||||
},
|
||||
wantArgs: []string{"keyboard", "down", "shift"},
|
||||
},
|
||||
{
|
||||
name: "KeyUp",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.KeyUp(ctx, "shift")
|
||||
},
|
||||
wantArgs: []string{"keyboard", "up", "shift"},
|
||||
},
|
||||
{
|
||||
name: "Type",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.Type(ctx, "hello world")
|
||||
},
|
||||
wantArgs: []string{"keyboard", "type", "hello world"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"keyboard": `echo ok`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop",
|
||||
}
|
||||
|
||||
err := tt.invoke(t.Context(), pd)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmds := rec.allCommands()
|
||||
require.NotEmpty(t, cmds)
|
||||
|
||||
last := cmds[len(cmds)-1]
|
||||
joined := strings.Join(last, " ")
|
||||
for _, want := range tt.wantArgs {
|
||||
assert.Contains(t, joined, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableDesktop_CursorPosition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"cursor": `echo '{"x":100,"y":200}'`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop",
|
||||
}
|
||||
|
||||
x, y, err := pd.CursorPosition(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 100, x)
|
||||
assert.Equal(t, 200, y)
|
||||
}
|
||||
|
||||
func TestPortableDesktop_Close(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"up": `printf '{"vncPort":5901,"geometry":"1024x768"}\n' && sleep 120`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop",
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
_, err := pd.Start(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Session should exist.
|
||||
pd.mu.Lock()
|
||||
require.NotNil(t, pd.session)
|
||||
pd.mu.Unlock()
|
||||
|
||||
require.NoError(t, pd.Close())
|
||||
|
||||
// Session should be cleaned up.
|
||||
pd.mu.Lock()
|
||||
assert.Nil(t, pd.session)
|
||||
assert.True(t, pd.closed)
|
||||
pd.mu.Unlock()
|
||||
|
||||
// Subsequent Start must fail.
|
||||
_, err = pd.Start(ctx)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "desktop is closed")
|
||||
}
|
||||
|
||||
// --- ensureBinary tests ---
|
||||
|
||||
func TestEnsureBinary_UsesCachedBinPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// When binPath is already set, ensureBinary should return
|
||||
// immediately without doing any work.
|
||||
logger := slogtest.Make(t, nil)
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: agentexec.DefaultExecer,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "/already/set",
|
||||
}
|
||||
|
||||
err := pd.ensureBinary(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/already/set", pd.binPath)
|
||||
}
|
||||
|
||||
func TestEnsureBinary_UsesScriptBinDir(t *testing.T) {
|
||||
// Cannot use t.Parallel because t.Setenv modifies the process
|
||||
// environment.
|
||||
|
||||
scriptBinDir := t.TempDir()
|
||||
binPath := filepath.Join(scriptBinDir, "portabledesktop")
|
||||
require.NoError(t, os.WriteFile(binPath, []byte("#!/bin/sh\n"), 0o600))
|
||||
require.NoError(t, os.Chmod(binPath, 0o755))
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: agentexec.DefaultExecer,
|
||||
scriptBinDir: scriptBinDir,
|
||||
}
|
||||
|
||||
// Clear PATH so LookPath won't find a real binary.
|
||||
t.Setenv("PATH", "")
|
||||
|
||||
err := pd.ensureBinary(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, binPath, pd.binPath)
|
||||
}
|
||||
|
||||
func TestEnsureBinary_ScriptBinDirNotExecutable(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows does not support Unix permission bits")
|
||||
}
|
||||
// Cannot use t.Parallel because t.Setenv modifies the process
|
||||
// environment.
|
||||
|
||||
scriptBinDir := t.TempDir()
|
||||
binPath := filepath.Join(scriptBinDir, "portabledesktop")
|
||||
// Write without execute permission.
|
||||
require.NoError(t, os.WriteFile(binPath, []byte("#!/bin/sh\n"), 0o600))
|
||||
_ = binPath
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: agentexec.DefaultExecer,
|
||||
scriptBinDir: scriptBinDir,
|
||||
}
|
||||
|
||||
// Clear PATH so LookPath won't find a real binary.
|
||||
t.Setenv("PATH", "")
|
||||
|
||||
err := pd.ensureBinary(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
func TestEnsureBinary_NotFound(t *testing.T) {
|
||||
// Cannot use t.Parallel because t.Setenv modifies the process
|
||||
// environment.
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: agentexec.DefaultExecer,
|
||||
scriptBinDir: t.TempDir(), // empty directory
|
||||
}
|
||||
|
||||
// Clear PATH so LookPath won't find a real binary.
|
||||
t.Setenv("PATH", "")
|
||||
|
||||
err := pd.ensureBinary(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
// Ensure that portableDesktop satisfies the Desktop interface at
|
||||
// compile time. This uses the unexported type so it lives in the
|
||||
// internal test package.
|
||||
var _ Desktop = (*portableDesktop)(nil)
|
||||
@@ -7,21 +7,18 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentgit"
|
||||
)
|
||||
|
||||
// API exposes file-related operations performed through the agent.
|
||||
type API struct {
|
||||
logger slog.Logger
|
||||
filesystem afero.Fs
|
||||
pathStore *agentgit.PathStore
|
||||
}
|
||||
|
||||
func NewAPI(logger slog.Logger, filesystem afero.Fs, pathStore *agentgit.PathStore) *API {
|
||||
func NewAPI(logger slog.Logger, filesystem afero.Fs) *API {
|
||||
api := &API{
|
||||
logger: logger,
|
||||
filesystem: filesystem,
|
||||
pathStore: pathStore,
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
+38
-109
@@ -13,12 +13,10 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/afero"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentgit"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
@@ -303,13 +301,6 @@ func (api *API) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Track edited path for git watch.
|
||||
if api.pathStore != nil {
|
||||
if chatID, ancestorIDs, ok := agentgit.ExtractChatContext(r); ok {
|
||||
api.pathStore.AddPaths(append([]uuid.UUID{chatID}, ancestorIDs...), []string{path})
|
||||
}
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
|
||||
Message: fmt.Sprintf("Successfully wrote to %q", path),
|
||||
})
|
||||
@@ -389,17 +380,6 @@ func (api *API) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Track edited paths for git watch.
|
||||
if api.pathStore != nil {
|
||||
if chatID, ancestorIDs, ok := agentgit.ExtractChatContext(r); ok {
|
||||
filePaths := make([]string, 0, len(req.Files))
|
||||
for _, f := range req.Files {
|
||||
filePaths = append(filePaths, f.Path)
|
||||
}
|
||||
api.pathStore.AddPaths(append([]uuid.UUID{chatID}, ancestorIDs...), filePaths)
|
||||
}
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
|
||||
Message: "Successfully edited file(s)",
|
||||
})
|
||||
@@ -447,10 +427,13 @@ func (api *API) editFile(ctx context.Context, path string, edits []workspacesdk.
|
||||
content := string(data)
|
||||
|
||||
for _, edit := range edits {
|
||||
var err error
|
||||
content, err = fuzzyReplace(content, edit)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, xerrors.Errorf("edit %s: %w", path, err)
|
||||
var ok bool
|
||||
content, ok = fuzzyReplace(content, edit.Search, edit.Replace)
|
||||
if !ok {
|
||||
api.logger.Warn(ctx, "edit search string not found, skipping",
|
||||
slog.F("path", path),
|
||||
slog.F("search_preview", truncate(edit.Search, 64)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,92 +460,51 @@ func (api *API) editFile(ctx context.Context, path string, edits []workspacesdk.
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// fuzzyReplace attempts to find `search` inside `content` and replace it
|
||||
// with `replace`. It uses a cascading match strategy inspired by
|
||||
// fuzzyReplace attempts to find `search` inside `content` and replace its first
|
||||
// occurrence with `replace`. It uses a cascading match strategy inspired by
|
||||
// openai/codex's apply_patch:
|
||||
//
|
||||
// 1. Exact substring match (byte-for-byte).
|
||||
// 2. Line-by-line match ignoring trailing whitespace on each line.
|
||||
// 3. Line-by-line match ignoring all leading/trailing whitespace
|
||||
// (indentation-tolerant).
|
||||
// 3. Line-by-line match ignoring all leading/trailing whitespace (indentation-tolerant).
|
||||
//
|
||||
// When edit.ReplaceAll is false (the default), the search string must
|
||||
// match exactly one location. If multiple matches are found, an error
|
||||
// is returned asking the caller to include more context or set
|
||||
// replace_all.
|
||||
// When a fuzzy match is found (passes 2 or 3), the replacement is still applied
|
||||
// at the byte offsets of the original content so that surrounding text (including
|
||||
// indentation of untouched lines) is preserved.
|
||||
//
|
||||
// When a fuzzy match is found (passes 2 or 3), the replacement is still
|
||||
// applied at the byte offsets of the original content so that surrounding
|
||||
// text (including indentation of untouched lines) is preserved.
|
||||
func fuzzyReplace(content string, edit workspacesdk.FileEdit) (string, error) {
|
||||
search := edit.Search
|
||||
replace := edit.Replace
|
||||
|
||||
// Pass 1 – exact substring match.
|
||||
// Returns the (possibly modified) content and a bool indicating whether a match
|
||||
// was found.
|
||||
func fuzzyReplace(content, search, replace string) (string, bool) {
|
||||
// Pass 1 – exact substring (replace all occurrences).
|
||||
if strings.Contains(content, search) {
|
||||
if edit.ReplaceAll {
|
||||
return strings.ReplaceAll(content, search, replace), nil
|
||||
}
|
||||
count := strings.Count(content, search)
|
||||
if count > 1 {
|
||||
return "", xerrors.Errorf("search string matches %d occurrences "+
|
||||
"(expected exactly 1). Include more surrounding "+
|
||||
"context to make the match unique, or set "+
|
||||
"replace_all to true", count)
|
||||
}
|
||||
// Exactly one match.
|
||||
return strings.Replace(content, search, replace, 1), nil
|
||||
return strings.ReplaceAll(content, search, replace), true
|
||||
}
|
||||
|
||||
// For line-level fuzzy matching we split both content and search
|
||||
// into lines.
|
||||
// For line-level fuzzy matching we split both content and search into lines.
|
||||
contentLines := strings.SplitAfter(content, "\n")
|
||||
searchLines := strings.SplitAfter(search, "\n")
|
||||
|
||||
// A trailing newline in the search produces an empty final element
|
||||
// from SplitAfter. Drop it so it doesn't interfere with line
|
||||
// matching.
|
||||
// A trailing newline in the search produces an empty final element from
|
||||
// SplitAfter. Drop it so it doesn't interfere with line matching.
|
||||
if len(searchLines) > 0 && searchLines[len(searchLines)-1] == "" {
|
||||
searchLines = searchLines[:len(searchLines)-1]
|
||||
}
|
||||
|
||||
trimRight := func(a, b string) bool {
|
||||
return strings.TrimRight(a, " \t\r\n") == strings.TrimRight(b, " \t\r\n")
|
||||
}
|
||||
trimAll := func(a, b string) bool {
|
||||
return strings.TrimSpace(a) == strings.TrimSpace(b)
|
||||
}
|
||||
|
||||
// Pass 2 – trim trailing whitespace on each line.
|
||||
if start, end, ok := seekLines(contentLines, searchLines, trimRight); ok {
|
||||
if !edit.ReplaceAll {
|
||||
if count := countLineMatches(contentLines, searchLines, trimRight); count > 1 {
|
||||
return "", xerrors.Errorf("search string matches %d occurrences "+
|
||||
"(expected exactly 1). Include more surrounding "+
|
||||
"context to make the match unique, or set "+
|
||||
"replace_all to true", count)
|
||||
}
|
||||
}
|
||||
return spliceLines(contentLines, start, end, replace), nil
|
||||
if start, end, ok := seekLines(contentLines, searchLines, func(a, b string) bool {
|
||||
return strings.TrimRight(a, " \t\r\n") == strings.TrimRight(b, " \t\r\n")
|
||||
}); ok {
|
||||
return spliceLines(contentLines, start, end, replace), true
|
||||
}
|
||||
|
||||
// Pass 3 – trim all leading and trailing whitespace
|
||||
// (indentation-tolerant).
|
||||
if start, end, ok := seekLines(contentLines, searchLines, trimAll); ok {
|
||||
if !edit.ReplaceAll {
|
||||
if count := countLineMatches(contentLines, searchLines, trimAll); count > 1 {
|
||||
return "", xerrors.Errorf("search string matches %d occurrences "+
|
||||
"(expected exactly 1). Include more surrounding "+
|
||||
"context to make the match unique, or set "+
|
||||
"replace_all to true", count)
|
||||
}
|
||||
}
|
||||
return spliceLines(contentLines, start, end, replace), nil
|
||||
// Pass 3 – trim all leading and trailing whitespace (indentation-tolerant).
|
||||
if start, end, ok := seekLines(contentLines, searchLines, func(a, b string) bool {
|
||||
return strings.TrimSpace(a) == strings.TrimSpace(b)
|
||||
}); ok {
|
||||
return spliceLines(contentLines, start, end, replace), true
|
||||
}
|
||||
|
||||
return "", xerrors.New("search string not found in file. Verify the search " +
|
||||
"string matches the file content exactly, including whitespace " +
|
||||
"and indentation")
|
||||
return content, false
|
||||
}
|
||||
|
||||
// seekLines scans contentLines looking for a contiguous subsequence that matches
|
||||
@@ -587,26 +529,6 @@ outer:
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// countLineMatches counts how many non-overlapping contiguous
|
||||
// subsequences of contentLines match searchLines according to eq.
|
||||
func countLineMatches(contentLines, searchLines []string, eq func(a, b string) bool) int {
|
||||
count := 0
|
||||
if len(searchLines) == 0 || len(searchLines) > len(contentLines) {
|
||||
return count
|
||||
}
|
||||
outer:
|
||||
for i := 0; i <= len(contentLines)-len(searchLines); i++ {
|
||||
for j, sLine := range searchLines {
|
||||
if !eq(contentLines[i+j], sLine) {
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
count++
|
||||
i += len(searchLines) - 1 // skip past this match
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// spliceLines replaces contentLines[start:end] with replacement text, returning
|
||||
// the full content as a single string.
|
||||
func spliceLines(contentLines []string, start, end int, replacement string) string {
|
||||
@@ -620,3 +542,10 @@ func spliceLines(contentLines []string, start, end int, replacement string) stri
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
@@ -11,12 +11,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -24,7 +21,6 @@ import (
|
||||
"cdr.dev/slog/v3"
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentfiles"
|
||||
"github.com/coder/coder/v2/agent/agentgit"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@@ -120,7 +116,7 @@ func TestReadFile(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
api := agentfiles.NewAPI(logger, fs, nil)
|
||||
api := agentfiles.NewAPI(logger, fs)
|
||||
|
||||
dirPath := filepath.Join(tmpdir, "a-directory")
|
||||
err := fs.MkdirAll(dirPath, 0o755)
|
||||
@@ -300,7 +296,7 @@ func TestWriteFile(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
api := agentfiles.NewAPI(logger, fs, nil)
|
||||
api := agentfiles.NewAPI(logger, fs)
|
||||
|
||||
dirPath := filepath.Join(tmpdir, "directory")
|
||||
err := fs.MkdirAll(dirPath, 0o755)
|
||||
@@ -418,7 +414,7 @@ func TestEditFiles(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
api := agentfiles.NewAPI(logger, fs, nil)
|
||||
api := agentfiles.NewAPI(logger, fs)
|
||||
|
||||
dirPath := filepath.Join(tmpdir, "directory")
|
||||
err := fs.MkdirAll(dirPath, 0o755)
|
||||
@@ -576,9 +572,7 @@ func TestEditFiles(t *testing.T) {
|
||||
expected: map[string]string{filepath.Join(tmpdir, "edit1"): "bar bar"},
|
||||
},
|
||||
{
|
||||
// When the second edit creates ambiguity (two "bar"
|
||||
// occurrences), it should fail.
|
||||
name: "EditEditAmbiguous",
|
||||
name: "EditEdit", // Edits affect previous edits.
|
||||
contents: map[string]string{filepath.Join(tmpdir, "edit-edit"): "foo bar"},
|
||||
edits: []workspacesdk.FileEdits{
|
||||
{
|
||||
@@ -595,33 +589,7 @@ func TestEditFiles(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
errCode: http.StatusBadRequest,
|
||||
errors: []string{"matches 2 occurrences"},
|
||||
// File should not be modified on error.
|
||||
expected: map[string]string{filepath.Join(tmpdir, "edit-edit"): "foo bar"},
|
||||
},
|
||||
{
|
||||
// With replace_all the cascading edit replaces
|
||||
// both occurrences.
|
||||
name: "EditEditReplaceAll",
|
||||
contents: map[string]string{filepath.Join(tmpdir, "edit-edit-ra"): "foo bar"},
|
||||
edits: []workspacesdk.FileEdits{
|
||||
{
|
||||
Path: filepath.Join(tmpdir, "edit-edit-ra"),
|
||||
Edits: []workspacesdk.FileEdit{
|
||||
{
|
||||
Search: "foo",
|
||||
Replace: "bar",
|
||||
},
|
||||
{
|
||||
Search: "bar",
|
||||
Replace: "qux",
|
||||
ReplaceAll: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]string{filepath.Join(tmpdir, "edit-edit-ra"): "qux qux"},
|
||||
expected: map[string]string{filepath.Join(tmpdir, "edit-edit"): "qux qux"},
|
||||
},
|
||||
{
|
||||
name: "Multiline",
|
||||
@@ -748,7 +716,7 @@ func TestEditFiles(t *testing.T) {
|
||||
expected: map[string]string{filepath.Join(tmpdir, "exact-preferred"): "goodbye world"},
|
||||
},
|
||||
{
|
||||
name: "NoMatchErrors",
|
||||
name: "NoMatchStillSucceeds",
|
||||
contents: map[string]string{filepath.Join(tmpdir, "no-match"): "original content"},
|
||||
edits: []workspacesdk.FileEdits{
|
||||
{
|
||||
@@ -761,46 +729,9 @@ func TestEditFiles(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
errCode: http.StatusBadRequest,
|
||||
errors: []string{"search string not found in file"},
|
||||
// File should remain unchanged.
|
||||
expected: map[string]string{filepath.Join(tmpdir, "no-match"): "original content"},
|
||||
},
|
||||
{
|
||||
name: "AmbiguousExactMatch",
|
||||
contents: map[string]string{filepath.Join(tmpdir, "ambig-exact"): "foo bar foo baz foo"},
|
||||
edits: []workspacesdk.FileEdits{
|
||||
{
|
||||
Path: filepath.Join(tmpdir, "ambig-exact"),
|
||||
Edits: []workspacesdk.FileEdit{
|
||||
{
|
||||
Search: "foo",
|
||||
Replace: "qux",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
errCode: http.StatusBadRequest,
|
||||
errors: []string{"matches 3 occurrences"},
|
||||
expected: map[string]string{filepath.Join(tmpdir, "ambig-exact"): "foo bar foo baz foo"},
|
||||
},
|
||||
{
|
||||
name: "ReplaceAllExact",
|
||||
contents: map[string]string{filepath.Join(tmpdir, "ra-exact"): "foo bar foo baz foo"},
|
||||
edits: []workspacesdk.FileEdits{
|
||||
{
|
||||
Path: filepath.Join(tmpdir, "ra-exact"),
|
||||
Edits: []workspacesdk.FileEdit{
|
||||
{
|
||||
Search: "foo",
|
||||
Replace: "qux",
|
||||
ReplaceAll: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]string{filepath.Join(tmpdir, "ra-exact"): "qux bar qux baz qux"},
|
||||
},
|
||||
{
|
||||
name: "MixedWhitespaceMultiline",
|
||||
contents: map[string]string{filepath.Join(tmpdir, "mixed-ws"): "func main() {\n\tresult := compute()\n\tfmt.Println(result)\n}"},
|
||||
@@ -907,169 +838,6 @@ func TestEditFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWriteFile_ChatHeaders_UpdatesPathStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pathStore := agentgit.NewPathStore()
|
||||
logger := slogtest.Make(t, nil)
|
||||
fs := afero.NewMemMapFs()
|
||||
api := agentfiles.NewAPI(logger, fs, pathStore)
|
||||
|
||||
testPath := filepath.Join(os.TempDir(), "test.txt")
|
||||
|
||||
chatID := uuid.New()
|
||||
ancestorID := uuid.New()
|
||||
ancestorJSON, _ := json.Marshal([]string{ancestorID.String()})
|
||||
|
||||
body := strings.NewReader("hello world")
|
||||
req := httptest.NewRequest(http.MethodPost, "/write-file?path="+testPath, body)
|
||||
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
|
||||
req.Header.Set(workspacesdk.CoderAncestorChatIDsHeader, string(ancestorJSON))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Post("/write-file", api.HandleWriteFile)
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
// Verify PathStore was updated for both chat and ancestor.
|
||||
paths := pathStore.GetPaths(chatID)
|
||||
require.Equal(t, []string{testPath}, paths)
|
||||
|
||||
ancestorPaths := pathStore.GetPaths(ancestorID)
|
||||
require.Equal(t, []string{testPath}, ancestorPaths)
|
||||
}
|
||||
|
||||
func TestHandleWriteFile_NoChatHeaders_NoPathStoreUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pathStore := agentgit.NewPathStore()
|
||||
logger := slogtest.Make(t, nil)
|
||||
fs := afero.NewMemMapFs()
|
||||
api := agentfiles.NewAPI(logger, fs, pathStore)
|
||||
|
||||
testPath := filepath.Join(os.TempDir(), "test.txt")
|
||||
|
||||
body := strings.NewReader("hello world")
|
||||
req := httptest.NewRequest(http.MethodPost, "/write-file?path="+testPath, body)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Post("/write-file", api.HandleWriteFile)
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
// PathStore should be globally empty since no chat headers were set.
|
||||
require.Equal(t, 0, pathStore.Len())
|
||||
}
|
||||
|
||||
func TestHandleWriteFile_Failure_NoPathStoreUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pathStore := agentgit.NewPathStore()
|
||||
logger := slogtest.Make(t, nil)
|
||||
fs := afero.NewMemMapFs()
|
||||
api := agentfiles.NewAPI(logger, fs, pathStore)
|
||||
|
||||
chatID := uuid.New()
|
||||
|
||||
// Write to a relative path (should fail with 400).
|
||||
body := strings.NewReader("hello world")
|
||||
req := httptest.NewRequest(http.MethodPost, "/write-file?path=relative/path.txt", body)
|
||||
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Post("/write-file", api.HandleWriteFile)
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
|
||||
// PathStore should NOT be updated on failure.
|
||||
paths := pathStore.GetPaths(chatID)
|
||||
require.Empty(t, paths)
|
||||
}
|
||||
|
||||
func TestHandleEditFiles_ChatHeaders_UpdatesPathStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pathStore := agentgit.NewPathStore()
|
||||
logger := slogtest.Make(t, nil)
|
||||
fs := afero.NewMemMapFs()
|
||||
api := agentfiles.NewAPI(logger, fs, pathStore)
|
||||
|
||||
testPath := filepath.Join(os.TempDir(), "test.txt")
|
||||
|
||||
// Create the file first.
|
||||
require.NoError(t, afero.WriteFile(fs, testPath, []byte("hello"), 0o644))
|
||||
|
||||
chatID := uuid.New()
|
||||
editReq := workspacesdk.FileEditRequest{
|
||||
Files: []workspacesdk.FileEdits{
|
||||
{
|
||||
Path: testPath,
|
||||
Edits: []workspacesdk.FileEdit{
|
||||
{Search: "hello", Replace: "world"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(editReq)
|
||||
req := httptest.NewRequest(http.MethodPost, "/edit-files", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Post("/edit-files", api.HandleEditFiles)
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
paths := pathStore.GetPaths(chatID)
|
||||
require.Equal(t, []string{testPath}, paths)
|
||||
}
|
||||
|
||||
func TestHandleEditFiles_Failure_NoPathStoreUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pathStore := agentgit.NewPathStore()
|
||||
logger := slogtest.Make(t, nil)
|
||||
fs := afero.NewMemMapFs()
|
||||
api := agentfiles.NewAPI(logger, fs, pathStore)
|
||||
|
||||
chatID := uuid.New()
|
||||
|
||||
// Edit a non-existent file (should fail with 404).
|
||||
editReq := workspacesdk.FileEditRequest{
|
||||
Files: []workspacesdk.FileEdits{
|
||||
{
|
||||
Path: "/nonexistent/file.txt",
|
||||
Edits: []workspacesdk.FileEdit{
|
||||
{Search: "hello", Replace: "world"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(editReq)
|
||||
req := httptest.NewRequest(http.MethodPost, "/edit-files", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Post("/edit-files", api.HandleEditFiles)
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
require.NotEqual(t, http.StatusOK, rr.Code)
|
||||
|
||||
// PathStore should NOT be updated on failure.
|
||||
paths := pathStore.GetPaths(chatID)
|
||||
require.Empty(t, paths)
|
||||
}
|
||||
|
||||
func TestReadFileLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1083,7 +851,7 @@ func TestReadFileLines(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
api := agentfiles.NewAPI(logger, fs, nil)
|
||||
api := agentfiles.NewAPI(logger, fs)
|
||||
|
||||
dirPath := filepath.Join(tmpdir, "a-directory-lines")
|
||||
err := fs.MkdirAll(dirPath, 0o755)
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
// Package agentgit provides a WebSocket-based service for watching git
|
||||
// repository changes on the agent. It is mounted at /api/v0/git/watch
|
||||
// and allows clients to subscribe to file paths, triggering scans of
|
||||
// the corresponding git repositories.
|
||||
package agentgit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
// Option configures the git watch service.
|
||||
type Option func(*Handler)
|
||||
|
||||
// WithClock sets a controllable clock for testing. Defaults to
|
||||
// quartz.NewReal().
|
||||
func WithClock(c quartz.Clock) Option {
|
||||
return func(h *Handler) {
|
||||
h.clock = c
|
||||
}
|
||||
}
|
||||
|
||||
// WithGitBinary overrides the git binary path (for testing).
|
||||
func WithGitBinary(path string) Option {
|
||||
return func(h *Handler) {
|
||||
h.gitBin = path
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
// scanCooldown is the minimum interval between successive scans.
|
||||
scanCooldown = 1 * time.Second
|
||||
// fallbackPollInterval is the safety-net poll period used when no
|
||||
// filesystem events arrive.
|
||||
fallbackPollInterval = 30 * time.Second
|
||||
// maxTotalDiffSize is the maximum size of the combined
|
||||
// unified diff for an entire repository sent over the wire.
|
||||
// This must stay under the WebSocket message size limit.
|
||||
maxTotalDiffSize = 3 * 1024 * 1024 // 3 MiB
|
||||
)
|
||||
|
||||
// Handler manages per-connection git watch state.
|
||||
type Handler struct {
|
||||
logger slog.Logger
|
||||
clock quartz.Clock
|
||||
gitBin string // path to git binary; empty means "git" (from PATH)
|
||||
|
||||
mu sync.Mutex
|
||||
repoRoots map[string]struct{} // watched repo roots
|
||||
lastSnapshots map[string]repoSnapshot // last emitted snapshot per repo
|
||||
lastScanAt time.Time // when the last scan completed
|
||||
scanTrigger chan struct{} // buffered(1), poked by triggers
|
||||
}
|
||||
|
||||
// repoSnapshot captures the last emitted state for delta comparison.
|
||||
type repoSnapshot struct {
|
||||
branch string
|
||||
remoteOrigin string
|
||||
unifiedDiff string
|
||||
}
|
||||
|
||||
// NewHandler creates a new git watch handler.
|
||||
func NewHandler(logger slog.Logger, opts ...Option) *Handler {
|
||||
h := &Handler{
|
||||
logger: logger,
|
||||
clock: quartz.NewReal(),
|
||||
gitBin: "git",
|
||||
repoRoots: make(map[string]struct{}),
|
||||
lastSnapshots: make(map[string]repoSnapshot),
|
||||
scanTrigger: make(chan struct{}, 1),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(h)
|
||||
}
|
||||
|
||||
// Check if git is available.
|
||||
if _, err := exec.LookPath(h.gitBin); err != nil {
|
||||
h.logger.Warn(context.Background(), "git binary not found, git scanning disabled")
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// gitAvailable returns true if the configured git binary can be found
|
||||
// in PATH.
|
||||
func (h *Handler) gitAvailable() bool {
|
||||
_, err := exec.LookPath(h.gitBin)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Subscribe processes a subscribe message, resolving paths to git repo
|
||||
// roots and adding new repos to the watch set. Returns true if any new
|
||||
// repo roots were added.
|
||||
func (h *Handler) Subscribe(paths []string) bool {
|
||||
if !h.gitAvailable() {
|
||||
return false
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
added := false
|
||||
for _, p := range paths {
|
||||
if !filepath.IsAbs(p) {
|
||||
continue
|
||||
}
|
||||
p = filepath.Clean(p)
|
||||
|
||||
root, err := findRepoRoot(h.gitBin, p)
|
||||
if err != nil {
|
||||
// Not a git path — silently ignore.
|
||||
continue
|
||||
}
|
||||
if _, ok := h.repoRoots[root]; ok {
|
||||
continue
|
||||
}
|
||||
h.repoRoots[root] = struct{}{}
|
||||
added = true
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
// RequestScan pokes the scan trigger so the run loop performs a scan.
|
||||
func (h *Handler) RequestScan() {
|
||||
select {
|
||||
case h.scanTrigger <- struct{}{}:
|
||||
default:
|
||||
// Already pending.
|
||||
}
|
||||
}
|
||||
|
||||
// Scan performs a scan of all subscribed repos and computes deltas
|
||||
// against the previously emitted snapshots.
|
||||
func (h *Handler) Scan(ctx context.Context) *codersdk.WorkspaceAgentGitServerMessage {
|
||||
if !h.gitAvailable() {
|
||||
return nil
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
roots := make([]string, 0, len(h.repoRoots))
|
||||
for r := range h.repoRoots {
|
||||
roots = append(roots, r)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if len(roots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := h.clock.Now().UTC()
|
||||
var repos []codersdk.WorkspaceAgentRepoChanges
|
||||
|
||||
// Perform all I/O outside the lock to avoid blocking
|
||||
// AddPaths/GetPaths/Subscribe callers during disk-heavy scans.
|
||||
type scanResult struct {
|
||||
root string
|
||||
changes codersdk.WorkspaceAgentRepoChanges
|
||||
err error
|
||||
}
|
||||
results := make([]scanResult, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
changes, err := getRepoChanges(ctx, h.logger, h.gitBin, root)
|
||||
results = append(results, scanResult{root: root, changes: changes, err: err})
|
||||
}
|
||||
|
||||
// Re-acquire the lock only to commit snapshot updates.
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
for _, res := range results {
|
||||
if res.err != nil {
|
||||
if isRepoDeleted(h.gitBin, res.root) {
|
||||
// Repo root or .git directory was removed.
|
||||
// Emit a removal entry, then evict from watch set.
|
||||
removal := codersdk.WorkspaceAgentRepoChanges{
|
||||
RepoRoot: res.root,
|
||||
Removed: true,
|
||||
}
|
||||
delete(h.repoRoots, res.root)
|
||||
delete(h.lastSnapshots, res.root)
|
||||
repos = append(repos, removal)
|
||||
} else {
|
||||
// Transient error — log and skip without
|
||||
// removing the repo from the watch set.
|
||||
h.logger.Warn(ctx, "scan repo failed",
|
||||
slog.F("root", res.root),
|
||||
slog.Error(res.err),
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
prev, hasPrev := h.lastSnapshots[res.root]
|
||||
if hasPrev &&
|
||||
prev.branch == res.changes.Branch &&
|
||||
prev.remoteOrigin == res.changes.RemoteOrigin &&
|
||||
prev.unifiedDiff == res.changes.UnifiedDiff {
|
||||
// No change in this repo since last emit.
|
||||
continue
|
||||
}
|
||||
|
||||
// Update snapshot.
|
||||
h.lastSnapshots[res.root] = repoSnapshot{
|
||||
branch: res.changes.Branch,
|
||||
remoteOrigin: res.changes.RemoteOrigin,
|
||||
unifiedDiff: res.changes.UnifiedDiff,
|
||||
}
|
||||
|
||||
repos = append(repos, res.changes)
|
||||
}
|
||||
|
||||
h.lastScanAt = now
|
||||
|
||||
if len(repos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &codersdk.WorkspaceAgentGitServerMessage{
|
||||
Type: codersdk.WorkspaceAgentGitServerMessageTypeChanges,
|
||||
ScannedAt: &now,
|
||||
Repositories: repos,
|
||||
}
|
||||
}
|
||||
|
||||
// RunLoop runs the main event loop that listens for refresh requests
|
||||
// and fallback poll ticks. It calls scanFn whenever a scan should
|
||||
// happen (rate-limited to scanCooldown). It blocks until ctx is
|
||||
// canceled.
|
||||
func (h *Handler) RunLoop(ctx context.Context, scanFn func()) {
|
||||
fallbackTicker := h.clock.NewTicker(fallbackPollInterval)
|
||||
defer fallbackTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case <-h.scanTrigger:
|
||||
h.rateLimitedScan(ctx, scanFn)
|
||||
|
||||
case <-fallbackTicker.C:
|
||||
h.rateLimitedScan(ctx, scanFn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) rateLimitedScan(ctx context.Context, scanFn func()) {
|
||||
h.mu.Lock()
|
||||
elapsed := h.clock.Since(h.lastScanAt)
|
||||
if elapsed < scanCooldown {
|
||||
h.mu.Unlock()
|
||||
|
||||
// Wait for cooldown then scan.
|
||||
remaining := scanCooldown - elapsed
|
||||
timer := h.clock.NewTimer(remaining)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
scanFn()
|
||||
return
|
||||
}
|
||||
h.mu.Unlock()
|
||||
scanFn()
|
||||
}
|
||||
|
||||
// isRepoDeleted returns true when the repo root directory or its .git
|
||||
// entry no longer represents a valid git repository. This
|
||||
// distinguishes a genuine repo deletion from a transient scan error
|
||||
// (e.g. lock contention).
|
||||
//
|
||||
// It handles three deletion cases:
|
||||
// 1. The repo root directory itself was removed.
|
||||
// 2. The .git entry (directory or file) was removed.
|
||||
// 3. The .git entry is a file (worktree/submodule) whose target
|
||||
// gitdir was removed. In this case .git exists on disk but
|
||||
// `git rev-parse --git-dir` fails because the referenced
|
||||
// directory is gone.
|
||||
func isRepoDeleted(gitBin string, repoRoot string) bool {
|
||||
if _, err := os.Stat(repoRoot); os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
gitPath := filepath.Join(repoRoot, ".git")
|
||||
fi, err := os.Stat(gitPath)
|
||||
if os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
// If .git is a regular file (worktree or submodule), the actual
|
||||
// git object store lives elsewhere. Validate that the target is
|
||||
// still reachable by running git rev-parse.
|
||||
if err == nil && !fi.IsDir() {
|
||||
cmd := exec.CommandContext(context.Background(), gitBin, "-C", repoRoot, "rev-parse", "--git-dir")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findRepoRoot uses `git rev-parse --show-toplevel` to find the
|
||||
// repository root for the given path.
|
||||
func findRepoRoot(gitBin string, p string) (string, error) {
|
||||
// If p is a file, start from its parent directory.
|
||||
dir := p
|
||||
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
cmd := exec.CommandContext(context.Background(), gitBin, "rev-parse", "--show-toplevel")
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("no git repo found for %s", p)
|
||||
}
|
||||
root := filepath.FromSlash(strings.TrimSpace(string(out)))
|
||||
// Resolve symlinks and short (8.3) names on Windows so the
|
||||
// returned root matches paths produced by Go's filepath APIs.
|
||||
if resolved, evalErr := filepath.EvalSymlinks(root); evalErr == nil {
|
||||
root = resolved
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// getRepoChanges reads the current state of a git repository using
|
||||
// the git CLI. It returns branch, remote origin, and a unified diff.
|
||||
func getRepoChanges(ctx context.Context, logger slog.Logger, gitBin string, repoRoot string) (codersdk.WorkspaceAgentRepoChanges, error) {
|
||||
result := codersdk.WorkspaceAgentRepoChanges{
|
||||
RepoRoot: repoRoot,
|
||||
}
|
||||
|
||||
// Verify this is still a valid git repository before doing
|
||||
// anything else. This catches deleted repos early.
|
||||
verifyCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "rev-parse", "--git-dir")
|
||||
if err := verifyCmd.Run(); err != nil {
|
||||
return result, xerrors.Errorf("not a git repository: %w", err)
|
||||
}
|
||||
|
||||
// Read branch name.
|
||||
branchCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "symbolic-ref", "--short", "HEAD")
|
||||
if out, err := branchCmd.Output(); err == nil {
|
||||
result.Branch = strings.TrimSpace(string(out))
|
||||
} else {
|
||||
logger.Debug(ctx, "failed to read HEAD", slog.F("root", repoRoot), slog.Error(err))
|
||||
}
|
||||
|
||||
// Read remote origin URL.
|
||||
remoteCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "config", "--get", "remote.origin.url")
|
||||
if out, err := remoteCmd.Output(); err == nil {
|
||||
result.RemoteOrigin = strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// Compute unified diff.
|
||||
// `git diff HEAD` shows both staged and unstaged changes vs HEAD.
|
||||
// For repos with no commits yet, fall back to showing untracked
|
||||
// files only.
|
||||
diff, err := computeGitDiff(ctx, logger, gitBin, repoRoot)
|
||||
if err != nil {
|
||||
return result, xerrors.Errorf("compute diff: %w", err)
|
||||
}
|
||||
|
||||
result.UnifiedDiff = diff
|
||||
if len(result.UnifiedDiff) > maxTotalDiffSize {
|
||||
result.UnifiedDiff = "Total diff too large to show. Size: " + humanize.IBytes(uint64(len(result.UnifiedDiff))) + ". Showing branch and remote only."
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// computeGitDiff produces a unified diff string for the repository by
|
||||
// combining `git diff HEAD` (staged + unstaged changes) with diffs
|
||||
// for untracked files.
|
||||
func computeGitDiff(ctx context.Context, logger slog.Logger, gitBin string, repoRoot string) (string, error) {
|
||||
var diffParts []string
|
||||
|
||||
// Check if the repo has any commits.
|
||||
hasCommits := true
|
||||
checkCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "rev-parse", "HEAD")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
hasCommits = false
|
||||
}
|
||||
|
||||
if hasCommits {
|
||||
// `git diff HEAD` captures both staged and unstaged changes
|
||||
// relative to HEAD in a single unified diff.
|
||||
cmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "diff", "HEAD")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("git diff HEAD: %w", err)
|
||||
}
|
||||
if len(out) > 0 {
|
||||
diffParts = append(diffParts, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
// Show untracked files as diffs too.
|
||||
// `git ls-files --others --exclude-standard` lists untracked,
|
||||
// non-ignored files.
|
||||
lsCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "ls-files", "--others", "--exclude-standard")
|
||||
lsOut, err := lsCmd.Output()
|
||||
if err != nil {
|
||||
logger.Debug(ctx, "failed to list untracked files", slog.F("root", repoRoot), slog.Error(err))
|
||||
return strings.Join(diffParts, ""), nil
|
||||
}
|
||||
|
||||
untrackedFiles := strings.Split(strings.TrimSpace(string(lsOut)), "\n")
|
||||
for _, f := range untrackedFiles {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
// Use `git diff --no-index /dev/null <file>` to generate
|
||||
// a unified diff for untracked files.
|
||||
var stdout bytes.Buffer
|
||||
untrackedCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "diff", "--no-index", "--", "/dev/null", f)
|
||||
untrackedCmd.Stdout = &stdout
|
||||
// git diff --no-index exits with 1 when files differ,
|
||||
// which is expected. We ignore the error and check for
|
||||
// output instead.
|
||||
_ = untrackedCmd.Run()
|
||||
if stdout.Len() > 0 {
|
||||
diffParts = append(diffParts, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(diffParts, ""), nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,147 +0,0 @@
|
||||
package agentgit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/wsjson"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// API exposes the git watch HTTP routes for the agent.
|
||||
type API struct {
|
||||
logger slog.Logger
|
||||
opts []Option
|
||||
pathStore *PathStore
|
||||
}
|
||||
|
||||
// NewAPI creates a new git watch API.
|
||||
func NewAPI(logger slog.Logger, pathStore *PathStore, opts ...Option) *API {
|
||||
return &API{
|
||||
logger: logger,
|
||||
pathStore: pathStore,
|
||||
opts: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// Routes returns the chi router for mounting at /api/v0/git.
|
||||
func (a *API) Routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/watch", a.handleWatch)
|
||||
return r
|
||||
}
|
||||
|
||||
func (a *API) handleWatch(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
|
||||
CompressionMode: websocket.CompressionNoContextTakeover,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to accept WebSocket.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 4 MiB read limit — subscribe messages with many paths can exceed the
|
||||
// default 32 KB limit. Matches the SDK/proxy side.
|
||||
conn.SetReadLimit(1 << 22)
|
||||
|
||||
stream := wsjson.NewStream[
|
||||
codersdk.WorkspaceAgentGitClientMessage,
|
||||
codersdk.WorkspaceAgentGitServerMessage,
|
||||
](conn, websocket.MessageText, websocket.MessageText, a.logger)
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go httpapi.HeartbeatClose(ctx, a.logger, cancel, conn)
|
||||
|
||||
handler := NewHandler(a.logger, a.opts...)
|
||||
|
||||
// scanAndSend performs a scan and sends results if there are
|
||||
// changes.
|
||||
scanAndSend := func() {
|
||||
msg := handler.Scan(ctx)
|
||||
if msg != nil {
|
||||
if err := stream.Send(*msg); err != nil {
|
||||
a.logger.Debug(ctx, "failed to send changes", slog.Error(err))
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If a chat_id query parameter is provided and the PathStore is
|
||||
// available, subscribe to path updates for this chat.
|
||||
chatIDStr := r.URL.Query().Get("chat_id")
|
||||
if chatIDStr != "" && a.pathStore != nil {
|
||||
chatID, parseErr := uuid.Parse(chatIDStr)
|
||||
if parseErr == nil {
|
||||
// Subscribe to future path updates BEFORE reading
|
||||
// existing paths. This ordering guarantees no
|
||||
// notification from AddPaths is lost: any call that
|
||||
// lands before Subscribe is picked up by GetPaths
|
||||
// below, and any call after Subscribe delivers a
|
||||
// notification on the channel.
|
||||
notifyCh, unsubscribe := a.pathStore.Subscribe(chatID)
|
||||
defer unsubscribe()
|
||||
|
||||
// Load any paths that are already tracked for this chat.
|
||||
existingPaths := a.pathStore.GetPaths(chatID)
|
||||
if len(existingPaths) > 0 {
|
||||
handler.Subscribe(existingPaths)
|
||||
handler.RequestScan()
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-notifyCh:
|
||||
paths := a.pathStore.GetPaths(chatID)
|
||||
handler.Subscribe(paths)
|
||||
handler.RequestScan()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Start the main run loop in a goroutine.
|
||||
go handler.RunLoop(ctx, scanAndSend)
|
||||
|
||||
// Read client messages.
|
||||
updates := stream.Chan()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = stream.Close(websocket.StatusGoingAway)
|
||||
return
|
||||
case msg, ok := <-updates:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case codersdk.WorkspaceAgentGitClientMessageTypeRefresh:
|
||||
handler.RequestScan()
|
||||
default:
|
||||
if err := stream.Send(codersdk.WorkspaceAgentGitServerMessage{
|
||||
Type: codersdk.WorkspaceAgentGitServerMessageTypeError,
|
||||
Message: "unknown message type",
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package agentgit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
// ExtractChatContext reads chat identity headers from the request.
|
||||
// Returns zero values if headers are absent (non-chat request).
|
||||
func ExtractChatContext(r *http.Request) (chatID uuid.UUID, ancestorIDs []uuid.UUID, ok bool) {
|
||||
raw := r.Header.Get(workspacesdk.CoderChatIDHeader)
|
||||
if raw == "" {
|
||||
return uuid.Nil, nil, false
|
||||
}
|
||||
chatID, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, false
|
||||
}
|
||||
rawAncestors := r.Header.Get(workspacesdk.CoderAncestorChatIDsHeader)
|
||||
if rawAncestors != "" {
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(rawAncestors), &ids); err == nil {
|
||||
for _, s := range ids {
|
||||
if id, err := uuid.Parse(s); err == nil {
|
||||
ancestorIDs = append(ancestorIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return chatID, ancestorIDs, true
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package agentgit_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentgit"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
func TestExtractChatContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validID := uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
||||
ancestor1 := uuid.MustParse("11111111-2222-3333-4444-555555555555")
|
||||
ancestor2 := uuid.MustParse("66666666-7777-8888-9999-aaaaaaaaaaaa")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
chatID string // empty means header not set
|
||||
setChatID bool // whether to set the chat ID header at all
|
||||
ancestors string // empty means header not set
|
||||
setAncestors bool // whether to set the ancestor header at all
|
||||
wantChatID uuid.UUID
|
||||
wantAncestorIDs []uuid.UUID
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "NoHeadersPresent",
|
||||
setChatID: false,
|
||||
setAncestors: false,
|
||||
wantChatID: uuid.Nil,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "ValidChatID_NoAncestors",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
setAncestors: false,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "ValidChatID_ValidAncestors",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
ancestors: mustMarshalJSON(t, []string{
|
||||
ancestor1.String(),
|
||||
ancestor2.String(),
|
||||
}),
|
||||
setAncestors: true,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: []uuid.UUID{ancestor1, ancestor2},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "MalformedChatID",
|
||||
chatID: "not-a-uuid",
|
||||
setChatID: true,
|
||||
setAncestors: false,
|
||||
wantChatID: uuid.Nil,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "ValidChatID_MalformedAncestorJSON",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
ancestors: `{this is not json}`,
|
||||
setAncestors: true,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
// Only valid UUIDs in the array are returned; invalid
|
||||
// entries are silently skipped.
|
||||
name: "ValidChatID_PartialValidAncestorUUIDs",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
ancestors: mustMarshalJSON(t, []string{
|
||||
ancestor1.String(),
|
||||
"bad-uuid",
|
||||
ancestor2.String(),
|
||||
}),
|
||||
setAncestors: true,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: []uuid.UUID{ancestor1, ancestor2},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
// Header is explicitly set to an empty string, which
|
||||
// Header.Get returns as "".
|
||||
name: "EmptyChatIDHeader",
|
||||
chatID: "",
|
||||
setChatID: true,
|
||||
setAncestors: false,
|
||||
wantChatID: uuid.Nil,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "ValidChatID_EmptyAncestorHeader",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
ancestors: "",
|
||||
setAncestors: true,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
if tt.setChatID {
|
||||
r.Header.Set(workspacesdk.CoderChatIDHeader, tt.chatID)
|
||||
}
|
||||
if tt.setAncestors {
|
||||
r.Header.Set(workspacesdk.CoderAncestorChatIDsHeader, tt.ancestors)
|
||||
}
|
||||
|
||||
chatID, ancestorIDs, ok := agentgit.ExtractChatContext(r)
|
||||
|
||||
require.Equal(t, tt.wantOK, ok, "ok mismatch")
|
||||
require.Equal(t, tt.wantChatID, chatID, "chatID mismatch")
|
||||
require.Equal(t, tt.wantAncestorIDs, ancestorIDs, "ancestorIDs mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mustMarshalJSON marshals v to a JSON string, failing the test on error.
|
||||
func mustMarshalJSON(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package agentgit
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// PathStore tracks which file paths each chat has touched.
|
||||
// It is safe for concurrent use.
|
||||
type PathStore struct {
|
||||
mu sync.RWMutex
|
||||
chatPaths map[uuid.UUID]map[string]struct{}
|
||||
subscribers map[uuid.UUID][]chan<- struct{}
|
||||
}
|
||||
|
||||
// NewPathStore creates a new PathStore.
|
||||
func NewPathStore() *PathStore {
|
||||
return &PathStore{
|
||||
chatPaths: make(map[uuid.UUID]map[string]struct{}),
|
||||
subscribers: make(map[uuid.UUID][]chan<- struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// AddPaths adds paths to every chat in chatIDs and notifies
|
||||
// their subscribers. Zero-value UUIDs are silently skipped.
|
||||
func (ps *PathStore) AddPaths(chatIDs []uuid.UUID, paths []string) {
|
||||
affected := make([]uuid.UUID, 0, len(chatIDs))
|
||||
for _, id := range chatIDs {
|
||||
if id != uuid.Nil {
|
||||
affected = append(affected, id)
|
||||
}
|
||||
}
|
||||
if len(affected) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ps.mu.Lock()
|
||||
for _, id := range affected {
|
||||
m, ok := ps.chatPaths[id]
|
||||
if !ok {
|
||||
m = make(map[string]struct{})
|
||||
ps.chatPaths[id] = m
|
||||
}
|
||||
for _, p := range paths {
|
||||
m[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
ps.mu.Unlock()
|
||||
|
||||
ps.notifySubscribers(affected)
|
||||
}
|
||||
|
||||
// Notify sends a signal to all subscribers of the given chat IDs
|
||||
// without adding any paths. Zero-value UUIDs are silently skipped.
|
||||
func (ps *PathStore) Notify(chatIDs []uuid.UUID) {
|
||||
affected := make([]uuid.UUID, 0, len(chatIDs))
|
||||
for _, id := range chatIDs {
|
||||
if id != uuid.Nil {
|
||||
affected = append(affected, id)
|
||||
}
|
||||
}
|
||||
if len(affected) == 0 {
|
||||
return
|
||||
}
|
||||
ps.notifySubscribers(affected)
|
||||
}
|
||||
|
||||
// notifySubscribers sends a non-blocking signal to all subscriber
|
||||
// channels for the given chat IDs.
|
||||
func (ps *PathStore) notifySubscribers(chatIDs []uuid.UUID) {
|
||||
ps.mu.RLock()
|
||||
toNotify := make([]chan<- struct{}, 0)
|
||||
for _, id := range chatIDs {
|
||||
toNotify = append(toNotify, ps.subscribers[id]...)
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
|
||||
for _, ch := range toNotify {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetPaths returns all paths tracked for a chat, deduplicated
|
||||
// and sorted lexicographically.
|
||||
func (ps *PathStore) GetPaths(chatID uuid.UUID) []string {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
|
||||
m := ps.chatPaths[chatID]
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(m))
|
||||
for p := range m {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Len returns the number of chat IDs that have tracked paths.
|
||||
func (ps *PathStore) Len() int {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
return len(ps.chatPaths)
|
||||
}
|
||||
|
||||
// Subscribe returns a channel that receives a signal whenever
|
||||
// paths change for chatID, along with an unsubscribe function
|
||||
// that removes the channel.
|
||||
func (ps *PathStore) Subscribe(chatID uuid.UUID) (<-chan struct{}, func()) {
|
||||
ch := make(chan struct{}, 1)
|
||||
|
||||
ps.mu.Lock()
|
||||
ps.subscribers[chatID] = append(ps.subscribers[chatID], ch)
|
||||
ps.mu.Unlock()
|
||||
|
||||
unsub := func() {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
subs := ps.subscribers[chatID]
|
||||
for i, s := range subs {
|
||||
if s == ch {
|
||||
ps.subscribers[chatID] = append(subs[:i], subs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ch, unsub
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
package agentgit_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentgit"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestPathStore_AddPaths_StoresForChatAndAncestors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
ancestor1 := uuid.New()
|
||||
ancestor2 := uuid.New()
|
||||
|
||||
ps.AddPaths([]uuid.UUID{chatID, ancestor1, ancestor2}, []string{"/a", "/b"})
|
||||
|
||||
// All three IDs should see the paths.
|
||||
require.Equal(t, []string{"/a", "/b"}, ps.GetPaths(chatID))
|
||||
require.Equal(t, []string{"/a", "/b"}, ps.GetPaths(ancestor1))
|
||||
require.Equal(t, []string{"/a", "/b"}, ps.GetPaths(ancestor2))
|
||||
|
||||
// An unrelated chat should see nothing.
|
||||
require.Nil(t, ps.GetPaths(uuid.New()))
|
||||
}
|
||||
|
||||
func TestPathStore_AddPaths_SkipsNilUUIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
|
||||
// A nil chatID should be a no-op.
|
||||
ps.AddPaths([]uuid.UUID{uuid.Nil}, []string{"/x"})
|
||||
require.Nil(t, ps.GetPaths(uuid.Nil))
|
||||
|
||||
// A nil ancestor should be silently skipped.
|
||||
chatID := uuid.New()
|
||||
ps.AddPaths([]uuid.UUID{chatID, uuid.Nil}, []string{"/y"})
|
||||
require.Equal(t, []string{"/y"}, ps.GetPaths(chatID))
|
||||
require.Nil(t, ps.GetPaths(uuid.Nil))
|
||||
}
|
||||
|
||||
func TestPathStore_GetPaths_DeduplicatedSorted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
|
||||
ps.AddPaths([]uuid.UUID{chatID}, []string{"/z", "/a", "/m", "/a", "/z"})
|
||||
ps.AddPaths([]uuid.UUID{chatID}, []string{"/a", "/b"})
|
||||
|
||||
got := ps.GetPaths(chatID)
|
||||
require.Equal(t, []string{"/a", "/b", "/m", "/z"}, got)
|
||||
}
|
||||
|
||||
func TestPathStore_Subscribe_ReceivesNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
|
||||
ch, unsub := ps.Subscribe(chatID)
|
||||
defer unsub()
|
||||
|
||||
ps.AddPaths([]uuid.UUID{chatID}, []string{"/file"})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
select {
|
||||
case <-ch:
|
||||
// Success.
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timed out waiting for notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathStore_Subscribe_MultipleSubscribers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
|
||||
ch1, unsub1 := ps.Subscribe(chatID)
|
||||
defer unsub1()
|
||||
ch2, unsub2 := ps.Subscribe(chatID)
|
||||
defer unsub2()
|
||||
|
||||
ps.AddPaths([]uuid.UUID{chatID}, []string{"/file"})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
for i, ch := range []<-chan struct{}{ch1, ch2} {
|
||||
select {
|
||||
case <-ch:
|
||||
// OK
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("subscriber %d did not receive notification", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathStore_Unsubscribe_StopsNotifications(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
|
||||
ch, unsub := ps.Subscribe(chatID)
|
||||
unsub()
|
||||
|
||||
ps.AddPaths([]uuid.UUID{chatID}, []string{"/file"})
|
||||
|
||||
// AddPaths sends synchronously via a non-blocking send to the
|
||||
// buffered channel, so if a notification were going to arrive
|
||||
// it would already be in the channel by now.
|
||||
select {
|
||||
case <-ch:
|
||||
t.Fatal("received notification after unsubscribe")
|
||||
default:
|
||||
// Expected: no notification.
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathStore_Subscribe_AncestorNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
ancestor := uuid.New()
|
||||
|
||||
// Subscribe to the ancestor, then add paths via the child.
|
||||
ch, unsub := ps.Subscribe(ancestor)
|
||||
defer unsub()
|
||||
|
||||
ps.AddPaths([]uuid.UUID{chatID, ancestor}, []string{"/file"})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
select {
|
||||
case <-ch:
|
||||
// Success.
|
||||
case <-ctx.Done():
|
||||
t.Fatal("ancestor subscriber did not receive notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathStore_Notify_NotifiesWithoutAddingPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
|
||||
ch, unsub := ps.Subscribe(chatID)
|
||||
defer unsub()
|
||||
|
||||
ps.Notify([]uuid.UUID{chatID})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
select {
|
||||
case <-ch:
|
||||
// Success.
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timed out waiting for notification")
|
||||
}
|
||||
|
||||
require.Nil(t, ps.GetPaths(chatID))
|
||||
}
|
||||
|
||||
func TestPathStore_Notify_SkipsNilUUIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
|
||||
ch, unsub := ps.Subscribe(chatID)
|
||||
defer unsub()
|
||||
|
||||
ps.Notify([]uuid.UUID{uuid.Nil})
|
||||
|
||||
// Notify sends synchronously via a non-blocking send to the
|
||||
// buffered channel, so if a notification were going to arrive
|
||||
// it would already be in the channel by now.
|
||||
select {
|
||||
case <-ch:
|
||||
t.Fatal("received notification for nil UUID")
|
||||
default:
|
||||
// Expected: no notification.
|
||||
}
|
||||
|
||||
require.Nil(t, ps.GetPaths(chatID))
|
||||
}
|
||||
|
||||
func TestPathStore_Notify_AncestorNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
ancestorID := uuid.New()
|
||||
|
||||
// Subscribe to the ancestor, then notify via the child.
|
||||
ch, unsub := ps.Subscribe(ancestorID)
|
||||
defer unsub()
|
||||
|
||||
ps.Notify([]uuid.UUID{chatID, ancestorID})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
select {
|
||||
case <-ch:
|
||||
// Success.
|
||||
case <-ctx.Done():
|
||||
t.Fatal("ancestor subscriber did not receive notification")
|
||||
}
|
||||
|
||||
require.Nil(t, ps.GetPaths(ancestorID))
|
||||
}
|
||||
|
||||
func TestPathStore_ConcurrentSafety(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
const goroutines = 20
|
||||
const iterations = 50
|
||||
|
||||
chatIDs := make([]uuid.UUID, goroutines)
|
||||
for i := range chatIDs {
|
||||
chatIDs[i] = uuid.New()
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines * 2) // writers + readers
|
||||
|
||||
// Writers.
|
||||
for i := range goroutines {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
for j := range iterations {
|
||||
ancestors := []uuid.UUID{chatIDs[(idx+1)%goroutines]}
|
||||
path := []string{
|
||||
"/file-" + chatIDs[idx].String() + "-" + time.Now().Format(time.RFC3339Nano),
|
||||
"/iter-" + string(rune('0'+j%10)),
|
||||
}
|
||||
ps.AddPaths(append([]uuid.UUID{chatIDs[idx]}, ancestors...), path)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Readers.
|
||||
for i := range goroutines {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
for range iterations {
|
||||
_ = ps.GetPaths(chatIDs[idx])
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify every chat has at least the paths it wrote.
|
||||
for _, id := range chatIDs {
|
||||
paths := ps.GetPaths(id)
|
||||
require.NotEmpty(t, paths, "chat %s should have paths", id)
|
||||
}
|
||||
}
|
||||
+7
-55
@@ -5,14 +5,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentgit"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
@@ -20,17 +17,15 @@ import (
|
||||
|
||||
// API exposes process-related operations through the agent.
|
||||
type API struct {
|
||||
logger slog.Logger
|
||||
manager *manager
|
||||
pathStore *agentgit.PathStore
|
||||
logger slog.Logger
|
||||
manager *manager
|
||||
}
|
||||
|
||||
// NewAPI creates a new process API handler.
|
||||
func NewAPI(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error), pathStore *agentgit.PathStore, workingDir func() string) *API {
|
||||
func NewAPI(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error)) *API {
|
||||
return &API{
|
||||
logger: logger,
|
||||
manager: newManager(logger, execer, updateEnv, workingDir),
|
||||
pathStore: pathStore,
|
||||
logger: logger,
|
||||
manager: newManager(logger, execer, updateEnv),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +65,7 @@ func (api *API) handleStartProcess(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var chatID string
|
||||
if id, _, ok := agentgit.ExtractChatContext(r); ok {
|
||||
chatID = id.String()
|
||||
}
|
||||
|
||||
proc, err := api.manager.start(req, chatID)
|
||||
proc, err := api.manager.start(req)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to start process.",
|
||||
@@ -84,23 +74,6 @@ func (api *API) handleStartProcess(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Notify git watchers after the process finishes so that
|
||||
// file changes made by the command are visible in the scan.
|
||||
// If a workdir is provided, track it as a path as well.
|
||||
if api.pathStore != nil {
|
||||
if chatID, ancestorIDs, ok := agentgit.ExtractChatContext(r); ok {
|
||||
allIDs := append([]uuid.UUID{chatID}, ancestorIDs...)
|
||||
go func() {
|
||||
<-proc.done
|
||||
if req.WorkDir != "" {
|
||||
api.pathStore.AddPaths(allIDs, []string{req.WorkDir})
|
||||
} else {
|
||||
api.pathStore.Notify(allIDs)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, workspacesdk.StartProcessResponse{
|
||||
ID: proc.id,
|
||||
Started: true,
|
||||
@@ -111,28 +84,7 @@ func (api *API) handleStartProcess(rw http.ResponseWriter, r *http.Request) {
|
||||
func (api *API) handleListProcesses(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var chatID string
|
||||
if id, _, ok := agentgit.ExtractChatContext(r); ok {
|
||||
chatID = id.String()
|
||||
}
|
||||
|
||||
infos := api.manager.list(chatID)
|
||||
|
||||
// Sort by running state (running first), then by started_at
|
||||
// descending so the most recent processes appear first.
|
||||
sort.Slice(infos, func(i, j int) bool {
|
||||
if infos[i].Running != infos[j].Running {
|
||||
return infos[i].Running
|
||||
}
|
||||
return infos[i].StartedAt > infos[j].StartedAt
|
||||
})
|
||||
|
||||
// Cap the response to avoid bloating LLM context.
|
||||
const maxListProcesses = 10
|
||||
if len(infos) > maxListProcesses {
|
||||
infos = infos[:maxListProcesses]
|
||||
}
|
||||
|
||||
infos := api.manager.list()
|
||||
httpapi.Write(ctx, rw, http.StatusOK, workspacesdk.ListProcessesResponse{
|
||||
Processes: infos,
|
||||
})
|
||||
|
||||
+5
-347
@@ -7,20 +7,17 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentgit"
|
||||
"github.com/coder/coder/v2/agent/agentproc"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
@@ -28,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
// postStart sends a POST /start request and returns the recorder.
|
||||
func postStart(t *testing.T, handler http.Handler, req workspacesdk.StartProcessRequest, headers ...http.Header) *httptest.ResponseRecorder {
|
||||
func postStart(t *testing.T, handler http.Handler, req workspacesdk.StartProcessRequest) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
@@ -39,13 +36,6 @@ func postStart(t *testing.T, handler http.Handler, req workspacesdk.StartProcess
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/start", bytes.NewReader(body))
|
||||
for _, h := range headers {
|
||||
for k, vals := range h {
|
||||
for _, v := range vals {
|
||||
r.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
@@ -98,25 +88,18 @@ func postSignal(t *testing.T, handler http.Handler, id string, req workspacesdk.
|
||||
// execer, returning the handler and API.
|
||||
func newTestAPI(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
return newTestAPIWithOptions(t, nil, nil)
|
||||
return newTestAPIWithUpdateEnv(t, nil)
|
||||
}
|
||||
|
||||
// newTestAPIWithUpdateEnv creates a new API with an optional
|
||||
// updateEnv hook for testing environment injection.
|
||||
func newTestAPIWithUpdateEnv(t *testing.T, updateEnv func([]string) ([]string, error)) http.Handler {
|
||||
t.Helper()
|
||||
return newTestAPIWithOptions(t, updateEnv, nil)
|
||||
}
|
||||
|
||||
// newTestAPIWithOptions creates a new API with optional
|
||||
// updateEnv and workingDir hooks.
|
||||
func newTestAPIWithOptions(t *testing.T, updateEnv func([]string) ([]string, error), workingDir func() string) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{
|
||||
IgnoreErrors: true,
|
||||
}).Leveled(slog.LevelDebug)
|
||||
api := agentproc.NewAPI(logger, agentexec.DefaultExecer, updateEnv, nil, workingDir)
|
||||
api := agentproc.NewAPI(logger, agentexec.DefaultExecer, updateEnv)
|
||||
t.Cleanup(func() {
|
||||
_ = api.Close()
|
||||
})
|
||||
@@ -155,10 +138,10 @@ func waitForExit(t *testing.T, handler http.Handler, id string) workspacesdk.Pro
|
||||
|
||||
// startAndGetID is a helper that starts a process and returns
|
||||
// the process ID.
|
||||
func startAndGetID(t *testing.T, handler http.Handler, req workspacesdk.StartProcessRequest, headers ...http.Header) string {
|
||||
func startAndGetID(t *testing.T, handler http.Handler, req workspacesdk.StartProcessRequest) string {
|
||||
t.Helper()
|
||||
|
||||
w := postStart(t, handler, req, headers...)
|
||||
w := postStart(t, handler, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp workspacesdk.StartProcessResponse
|
||||
@@ -261,100 +244,6 @@ func TestStartProcess(t *testing.T) {
|
||||
require.Contains(t, resp.Output, "marker.txt")
|
||||
})
|
||||
|
||||
t.Run("DefaultWorkDirIsHome", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// No working directory closure, so the process
|
||||
// should fall back to $HOME. We verify through
|
||||
// the process list API which reports the resolved
|
||||
// working directory using native OS paths,
|
||||
// avoiding shell path format mismatches on
|
||||
// Windows (Git Bash returns POSIX paths).
|
||||
handler := newTestAPI(t)
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "echo ok",
|
||||
})
|
||||
|
||||
resp := waitForExit(t, handler, id)
|
||||
require.NotNil(t, resp.ExitCode)
|
||||
require.Equal(t, 0, *resp.ExitCode)
|
||||
|
||||
w := getList(t, handler)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var listResp workspacesdk.ListProcessesResponse
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&listResp))
|
||||
var proc *workspacesdk.ProcessInfo
|
||||
for i := range listResp.Processes {
|
||||
if listResp.Processes[i].ID == id {
|
||||
proc = &listResp.Processes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, proc, "process not found in list")
|
||||
require.Equal(t, homeDir, proc.WorkDir)
|
||||
})
|
||||
|
||||
t.Run("DefaultWorkDirFromClosure", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The closure provides a valid directory, so the
|
||||
// process should start there. Use the marker file
|
||||
// pattern to avoid path format mismatches on
|
||||
// Windows.
|
||||
tmpDir := t.TempDir()
|
||||
handler := newTestAPIWithOptions(t, nil, func() string {
|
||||
return tmpDir
|
||||
})
|
||||
|
||||
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "touch marker.txt && ls marker.txt",
|
||||
})
|
||||
|
||||
resp := waitForExit(t, handler, id)
|
||||
require.NotNil(t, resp.ExitCode)
|
||||
require.Equal(t, 0, *resp.ExitCode)
|
||||
require.Contains(t, resp.Output, "marker.txt")
|
||||
})
|
||||
|
||||
t.Run("DefaultWorkDirClosureNonExistentFallsBackToHome", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The closure returns a path that doesn't exist,
|
||||
// so the process should fall back to $HOME.
|
||||
handler := newTestAPIWithOptions(t, nil, func() string {
|
||||
return "/tmp/nonexistent-dir-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
})
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "echo ok",
|
||||
})
|
||||
|
||||
resp := waitForExit(t, handler, id)
|
||||
require.NotNil(t, resp.ExitCode)
|
||||
require.Equal(t, 0, *resp.ExitCode)
|
||||
|
||||
w := getList(t, handler)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var listResp workspacesdk.ListProcessesResponse
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&listResp))
|
||||
var proc *workspacesdk.ProcessInfo
|
||||
for i := range listResp.Processes {
|
||||
if listResp.Processes[i].ID == id {
|
||||
proc = &listResp.Processes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, proc, "process not found in list")
|
||||
require.Equal(t, homeDir, proc.WorkDir)
|
||||
})
|
||||
|
||||
t.Run("CustomEnv", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -442,180 +331,6 @@ func TestListProcesses(t *testing.T) {
|
||||
require.Empty(t, resp.Processes)
|
||||
})
|
||||
|
||||
t.Run("FilterByChatID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestAPI(t)
|
||||
|
||||
chatA := uuid.New().String()
|
||||
chatB := uuid.New().String()
|
||||
headersA := http.Header{workspacesdk.CoderChatIDHeader: {chatA}}
|
||||
headersB := http.Header{workspacesdk.CoderChatIDHeader: {chatB}}
|
||||
|
||||
// Start processes with different chat IDs.
|
||||
id1 := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "echo chat-a",
|
||||
}, headersA)
|
||||
waitForExit(t, handler, id1)
|
||||
|
||||
id2 := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "echo chat-b",
|
||||
}, headersB)
|
||||
waitForExit(t, handler, id2)
|
||||
|
||||
id3 := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "echo chat-a-2",
|
||||
}, headersA)
|
||||
waitForExit(t, handler, id3)
|
||||
|
||||
// List with chat A header should return 2 processes.
|
||||
w := getListWithChatHeader(t, handler, chatA)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp workspacesdk.ListProcessesResponse
|
||||
err := json.NewDecoder(w.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Processes, 2)
|
||||
|
||||
ids := make(map[string]bool)
|
||||
for _, p := range resp.Processes {
|
||||
ids[p.ID] = true
|
||||
}
|
||||
require.True(t, ids[id1])
|
||||
require.True(t, ids[id3])
|
||||
|
||||
// List with chat B header should return 1 process.
|
||||
w2 := getListWithChatHeader(t, handler, chatB)
|
||||
require.Equal(t, http.StatusOK, w2.Code)
|
||||
|
||||
var resp2 workspacesdk.ListProcessesResponse
|
||||
err = json.NewDecoder(w2.Body).Decode(&resp2)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp2.Processes, 1)
|
||||
require.Equal(t, id2, resp2.Processes[0].ID)
|
||||
|
||||
// List without chat header should return all 3.
|
||||
w3 := getList(t, handler)
|
||||
require.Equal(t, http.StatusOK, w3.Code)
|
||||
|
||||
var resp3 workspacesdk.ListProcessesResponse
|
||||
err = json.NewDecoder(w3.Body).Decode(&resp3)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp3.Processes, 3)
|
||||
})
|
||||
|
||||
t.Run("ChatIDFiltering", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestAPI(t)
|
||||
chatID := uuid.New().String()
|
||||
headers := http.Header{workspacesdk.CoderChatIDHeader: {chatID}}
|
||||
|
||||
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "echo with-chat",
|
||||
}, headers)
|
||||
waitForExit(t, handler, id)
|
||||
|
||||
// Listing with the same chat header should return
|
||||
// the process.
|
||||
w := getListWithChatHeader(t, handler, chatID)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp workspacesdk.ListProcessesResponse
|
||||
err := json.NewDecoder(w.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Processes, 1)
|
||||
require.Equal(t, id, resp.Processes[0].ID)
|
||||
|
||||
// Listing with a different chat header should not
|
||||
// return the process.
|
||||
w2 := getListWithChatHeader(t, handler, uuid.New().String())
|
||||
require.Equal(t, http.StatusOK, w2.Code)
|
||||
|
||||
var resp2 workspacesdk.ListProcessesResponse
|
||||
err = json.NewDecoder(w2.Body).Decode(&resp2)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, resp2.Processes)
|
||||
|
||||
// Listing without a chat header should return the
|
||||
// process (no filtering).
|
||||
w3 := getList(t, handler)
|
||||
require.Equal(t, http.StatusOK, w3.Code)
|
||||
|
||||
var resp3 workspacesdk.ListProcessesResponse
|
||||
err = json.NewDecoder(w3.Body).Decode(&resp3)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp3.Processes, 1)
|
||||
})
|
||||
|
||||
t.Run("SortAndLimit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestAPI(t)
|
||||
|
||||
// Start 12 short-lived processes so we exceed the
|
||||
// limit of 10.
|
||||
for i := 0; i < 12; i++ {
|
||||
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: fmt.Sprintf("echo proc-%d", i),
|
||||
})
|
||||
waitForExit(t, handler, id)
|
||||
}
|
||||
|
||||
w := getList(t, handler)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp workspacesdk.ListProcessesResponse
|
||||
err := json.NewDecoder(w.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Processes, 10, "should be capped at 10")
|
||||
|
||||
// All returned processes are exited, so they should
|
||||
// be sorted by StartedAt descending (newest first).
|
||||
for i := 1; i < len(resp.Processes); i++ {
|
||||
require.GreaterOrEqual(t, resp.Processes[i-1].StartedAt, resp.Processes[i].StartedAt,
|
||||
"processes should be sorted by started_at descending")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RunningProcessesSortedFirst", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestAPI(t)
|
||||
|
||||
// Start an exited process first.
|
||||
exitedID := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "echo done",
|
||||
})
|
||||
waitForExit(t, handler, exitedID)
|
||||
|
||||
// Start a running process after.
|
||||
runningID := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "sleep 300",
|
||||
Background: true,
|
||||
})
|
||||
|
||||
w := getList(t, handler)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp workspacesdk.ListProcessesResponse
|
||||
err := json.NewDecoder(w.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Processes, 2)
|
||||
|
||||
// Running process should come first regardless of
|
||||
// start order.
|
||||
require.Equal(t, runningID, resp.Processes[0].ID)
|
||||
require.True(t, resp.Processes[0].Running)
|
||||
require.Equal(t, exitedID, resp.Processes[1].ID)
|
||||
require.False(t, resp.Processes[1].Running)
|
||||
|
||||
// Clean up.
|
||||
postSignal(t, handler, runningID, workspacesdk.SignalProcessRequest{
|
||||
Signal: "kill",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("MixedRunningAndExited", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -664,23 +379,6 @@ func TestListProcesses(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// getListWithChatHeader sends a GET /list request with the
|
||||
// Coder-Chat-Id header set and returns the recorder.
|
||||
func getListWithChatHeader(t *testing.T, handler http.Handler, chatID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequestWithContext(ctx, http.MethodGet, "/list", nil)
|
||||
if chatID != "" {
|
||||
r.Header.Set(workspacesdk.CoderChatIDHeader, chatID)
|
||||
}
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestProcessOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -872,46 +570,6 @@ func TestSignalProcess(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleStartProcess_ChatHeaders_EmptyWorkDir_StillNotifies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pathStore := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
ch, unsub := pathStore.Subscribe(chatID)
|
||||
defer unsub()
|
||||
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
api := agentproc.NewAPI(logger, agentexec.DefaultExecer, func(current []string) ([]string, error) {
|
||||
return current, nil
|
||||
}, pathStore, nil)
|
||||
defer api.Close()
|
||||
|
||||
routes := api.Routes()
|
||||
|
||||
body, err := json.Marshal(workspacesdk.StartProcessRequest{
|
||||
Command: "echo hello",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/start", bytes.NewReader(body))
|
||||
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
|
||||
rw := httptest.NewRecorder()
|
||||
routes.ServeHTTP(rw, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rw.Code)
|
||||
|
||||
// The subscriber should be notified even though no paths
|
||||
// were added.
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(testutil.WaitShort):
|
||||
t.Fatal("timed out waiting for path store notification")
|
||||
}
|
||||
|
||||
// No paths should have been stored for this chat.
|
||||
require.Nil(t, pathStore.GetPaths(chatID))
|
||||
}
|
||||
|
||||
func TestProcessLifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package agentproc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// procSysProcAttr returns the SysProcAttr to use when spawning
|
||||
// processes. On Unix, Setpgid creates a new process group so
|
||||
// that signals can be delivered to the entire group (the shell
|
||||
// and all its children).
|
||||
func procSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// signalProcess sends a signal to the process group rooted at p.
|
||||
// Using the negative PID sends the signal to every process in the
|
||||
// group, ensuring child processes (e.g. from shell pipelines) are
|
||||
// also signaled.
|
||||
func signalProcess(p *os.Process, sig syscall.Signal) error {
|
||||
return syscall.Kill(-p.Pid, sig)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package agentproc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// procSysProcAttr returns the SysProcAttr to use when spawning
|
||||
// processes. On Windows, process groups are not supported in the
|
||||
// same way as Unix, so this returns an empty struct.
|
||||
func procSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{}
|
||||
}
|
||||
|
||||
// signalProcess sends a signal directly to the process. Windows
|
||||
// does not support process group signaling, so we fall back to
|
||||
// sending the signal to the process itself.
|
||||
func signalProcess(p *os.Process, _ syscall.Signal) error {
|
||||
return p.Kill()
|
||||
}
|
||||
+26
-74
@@ -21,10 +21,6 @@ import (
|
||||
var (
|
||||
errProcessNotFound = xerrors.New("process not found")
|
||||
errProcessNotRunning = xerrors.New("process is not running")
|
||||
|
||||
// exitedProcessReapAge is how long an exited process is
|
||||
// kept before being automatically removed from the map.
|
||||
exitedProcessReapAge = 5 * time.Minute
|
||||
)
|
||||
|
||||
// process represents a running or completed process.
|
||||
@@ -34,7 +30,6 @@ type process struct {
|
||||
command string
|
||||
workDir string
|
||||
background bool
|
||||
chatID string
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
buf *HeadTailBuffer
|
||||
@@ -70,25 +65,23 @@ func (p *process) output() (string, *workspacesdk.ProcessTruncation) {
|
||||
|
||||
// manager tracks processes spawned by the agent.
|
||||
type manager struct {
|
||||
mu sync.Mutex
|
||||
logger slog.Logger
|
||||
execer agentexec.Execer
|
||||
clock quartz.Clock
|
||||
procs map[string]*process
|
||||
closed bool
|
||||
updateEnv func(current []string) (updated []string, err error)
|
||||
workingDir func() string
|
||||
mu sync.Mutex
|
||||
logger slog.Logger
|
||||
execer agentexec.Execer
|
||||
clock quartz.Clock
|
||||
procs map[string]*process
|
||||
closed bool
|
||||
updateEnv func(current []string) (updated []string, err error)
|
||||
}
|
||||
|
||||
// newManager creates a new process manager.
|
||||
func newManager(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error), workingDir func() string) *manager {
|
||||
func newManager(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error)) *manager {
|
||||
return &manager{
|
||||
logger: logger,
|
||||
execer: execer,
|
||||
clock: quartz.NewReal(),
|
||||
procs: make(map[string]*process),
|
||||
updateEnv: updateEnv,
|
||||
workingDir: workingDir,
|
||||
logger: logger,
|
||||
execer: execer,
|
||||
clock: quartz.NewReal(),
|
||||
procs: make(map[string]*process),
|
||||
updateEnv: updateEnv,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +89,7 @@ func newManager(logger slog.Logger, execer agentexec.Execer, updateEnv func(curr
|
||||
// processes use a long-lived context so the process survives
|
||||
// the HTTP request lifecycle. The background flag only affects
|
||||
// client-side polling behavior.
|
||||
func (m *manager) start(req workspacesdk.StartProcessRequest, chatID string) (*process, error) {
|
||||
func (m *manager) start(req workspacesdk.StartProcessRequest) (*process, error) {
|
||||
m.mu.Lock()
|
||||
if m.closed {
|
||||
m.mu.Unlock()
|
||||
@@ -111,9 +104,10 @@ func (m *manager) start(req workspacesdk.StartProcessRequest, chatID string) (*p
|
||||
// the process is not tied to any HTTP request.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cmd := m.execer.CommandContext(ctx, "sh", "-c", req.Command)
|
||||
cmd.Dir = m.resolveWorkDir(req.WorkDir)
|
||||
if req.WorkDir != "" {
|
||||
cmd.Dir = req.WorkDir
|
||||
}
|
||||
cmd.Stdin = nil
|
||||
cmd.SysProcAttr = procSysProcAttr()
|
||||
|
||||
// WaitDelay ensures cmd.Wait returns promptly after
|
||||
// the process is killed, even if child processes are
|
||||
@@ -158,9 +152,8 @@ func (m *manager) start(req workspacesdk.StartProcessRequest, chatID string) (*p
|
||||
proc := &process{
|
||||
id: id,
|
||||
command: req.Command,
|
||||
workDir: cmd.Dir,
|
||||
workDir: req.WorkDir,
|
||||
background: req.Background,
|
||||
chatID: chatID,
|
||||
cmd: cmd,
|
||||
cancel: cancel,
|
||||
buf: buf,
|
||||
@@ -222,32 +215,14 @@ func (m *manager) get(id string) (*process, bool) {
|
||||
return proc, ok
|
||||
}
|
||||
|
||||
// list returns info about all tracked processes. Exited
|
||||
// processes older than exitedProcessReapAge are removed.
|
||||
// If chatID is non-empty, only processes belonging to that
|
||||
// chat are returned.
|
||||
func (m *manager) list(chatID string) []workspacesdk.ProcessInfo {
|
||||
// list returns info about all tracked processes.
|
||||
func (m *manager) list() []workspacesdk.ProcessInfo {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
now := m.clock.Now()
|
||||
infos := make([]workspacesdk.ProcessInfo, 0, len(m.procs))
|
||||
for id, proc := range m.procs {
|
||||
info := proc.info()
|
||||
// Reap processes that exited more than 5 minutes ago
|
||||
// to prevent unbounded map growth.
|
||||
if !info.Running && info.ExitedAt != nil {
|
||||
exitedAt := time.Unix(*info.ExitedAt, 0)
|
||||
if now.Sub(exitedAt) > exitedProcessReapAge {
|
||||
delete(m.procs, id)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Filter by chatID if provided.
|
||||
if chatID != "" && proc.chatID != chatID {
|
||||
continue
|
||||
}
|
||||
infos = append(infos, info)
|
||||
for _, proc := range m.procs {
|
||||
infos = append(infos, proc.info())
|
||||
}
|
||||
return infos
|
||||
}
|
||||
@@ -273,15 +248,13 @@ func (m *manager) signal(id string, sig string) error {
|
||||
|
||||
switch sig {
|
||||
case "kill":
|
||||
// Use process group kill to ensure child processes
|
||||
// (e.g. from shell pipelines) are also killed.
|
||||
if err := signalProcess(proc.cmd.Process, syscall.SIGKILL); err != nil {
|
||||
if err := proc.cmd.Process.Kill(); err != nil {
|
||||
return xerrors.Errorf("kill process: %w", err)
|
||||
}
|
||||
case "terminate":
|
||||
// Use process group signal to ensure child processes
|
||||
// are also terminated.
|
||||
if err := signalProcess(proc.cmd.Process, syscall.SIGTERM); err != nil {
|
||||
//nolint:revive // syscall.SIGTERM is portable enough
|
||||
// for our supported platforms.
|
||||
if err := proc.cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
return xerrors.Errorf("terminate process: %w", err)
|
||||
}
|
||||
default:
|
||||
@@ -319,24 +292,3 @@ func (m *manager) Close() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveWorkDir returns the directory a process should start in.
|
||||
// Priority: explicit request dir > agent configured dir > $HOME.
|
||||
// Falls through when a candidate is empty or does not exist on
|
||||
// disk, matching the behavior of SSH sessions.
|
||||
func (m *manager) resolveWorkDir(requested string) string {
|
||||
if requested != "" {
|
||||
return requested
|
||||
}
|
||||
if m.workingDir != nil {
|
||||
if dir := m.workingDir(); dir != "" {
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return home
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -110,11 +110,6 @@ type Config struct {
|
||||
// X11DisplayOffset is the offset to add to the X11 display number.
|
||||
// Default is 10.
|
||||
X11DisplayOffset *int
|
||||
// X11MaxPort overrides the highest port used for X11 forwarding
|
||||
// listeners. Defaults to X11MaxPort (6200). Useful in tests
|
||||
// to shrink the port range and reduce the number of sessions
|
||||
// required.
|
||||
X11MaxPort *int
|
||||
// BlockFileTransfer restricts use of file transfer applications.
|
||||
BlockFileTransfer bool
|
||||
// ReportConnection.
|
||||
@@ -163,10 +158,6 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
|
||||
offset := X11DefaultDisplayOffset
|
||||
config.X11DisplayOffset = &offset
|
||||
}
|
||||
if config.X11MaxPort == nil {
|
||||
maxPort := X11MaxPort
|
||||
config.X11MaxPort = &maxPort
|
||||
}
|
||||
if config.UpdateEnv == nil {
|
||||
config.UpdateEnv = func(current []string) ([]string, error) { return current, nil }
|
||||
}
|
||||
@@ -210,7 +201,6 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
|
||||
x11HandlerErrors: metrics.x11HandlerErrors,
|
||||
fs: fs,
|
||||
displayOffset: *config.X11DisplayOffset,
|
||||
maxPort: *config.X11MaxPort,
|
||||
sessions: make(map[*x11Session]struct{}),
|
||||
connections: make(map[net.Conn]struct{}),
|
||||
network: func() X11Network {
|
||||
|
||||
@@ -57,7 +57,6 @@ type x11Forwarder struct {
|
||||
x11HandlerErrors *prometheus.CounterVec
|
||||
fs afero.Fs
|
||||
displayOffset int
|
||||
maxPort int
|
||||
|
||||
// network creates X11 listener sockets. Defaults to osNet{}.
|
||||
network X11Network
|
||||
@@ -315,7 +314,7 @@ func (x *x11Forwarder) evictLeastRecentlyUsedSession() {
|
||||
// the next available port starting from X11StartPort and displayOffset.
|
||||
func (x *x11Forwarder) createX11Listener(ctx context.Context) (ln net.Listener, display int, err error) {
|
||||
// Look for an open port to listen on.
|
||||
for port := X11StartPort + x.displayOffset; port <= x.maxPort; port++ {
|
||||
for port := X11StartPort + x.displayOffset; port <= X11MaxPort; port++ {
|
||||
if ctx.Err() != nil {
|
||||
return nil, -1, ctx.Err()
|
||||
}
|
||||
|
||||
@@ -142,13 +142,8 @@ func TestServer_X11_EvictionLRU(t *testing.T) {
|
||||
// Use in-process networking for X11 forwarding.
|
||||
inproc := testutil.NewInProcNet()
|
||||
|
||||
// Limit port range so we only need a handful of sessions to fill it
|
||||
// (the default 190 ports may easily timeout or conflict with other
|
||||
// ports on the system).
|
||||
maxPort := agentssh.X11StartPort + agentssh.X11DefaultDisplayOffset + 5
|
||||
cfg := &agentssh.Config{
|
||||
X11Net: inproc,
|
||||
X11MaxPort: &maxPort,
|
||||
X11Net: inproc,
|
||||
}
|
||||
|
||||
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), fs, agentexec.DefaultExecer, cfg)
|
||||
@@ -177,7 +172,7 @@ func TestServer_X11_EvictionLRU(t *testing.T) {
|
||||
// configured port range.
|
||||
|
||||
startPort := agentssh.X11StartPort + agentssh.X11DefaultDisplayOffset
|
||||
maxSessions := maxPort - startPort + 1 - 1 // -1 for the blocked port
|
||||
maxSessions := agentssh.X11MaxPort - startPort + 1 - 1 // -1 for the blocked port
|
||||
require.Greater(t, maxSessions, 0, "expected a positive maxSessions value")
|
||||
|
||||
// shellSession holds references to the session and its standard streams so
|
||||
|
||||
@@ -28,9 +28,7 @@ func (a *agent) apiHandler() http.Handler {
|
||||
})
|
||||
|
||||
r.Mount("/api/v0", a.filesAPI.Routes())
|
||||
r.Mount("/api/v0/git", a.gitAPI.Routes())
|
||||
r.Mount("/api/v0/processes", a.processAPI.Routes())
|
||||
r.Mount("/api/v0/desktop", a.desktopAPI.Routes())
|
||||
|
||||
if a.devcontainers {
|
||||
r.Mount("/api/v0/containers", a.containerAPI.Routes())
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -22,6 +23,26 @@ import (
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// logSink captures structured log entries for testing.
|
||||
type logSink struct {
|
||||
mu sync.Mutex
|
||||
entries []slog.SinkEntry
|
||||
}
|
||||
|
||||
func (s *logSink) LogEntry(_ context.Context, e slog.SinkEntry) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.entries = append(s.entries, e)
|
||||
}
|
||||
|
||||
func (*logSink) Sync() {}
|
||||
|
||||
func (s *logSink) getEntries() []slog.SinkEntry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]slog.SinkEntry{}, s.entries...)
|
||||
}
|
||||
|
||||
// getField returns the value of a field by name from a slog.Map.
|
||||
func getField(fields slog.Map, name string) interface{} {
|
||||
for _, f := range fields {
|
||||
@@ -55,8 +76,8 @@ func TestBoundaryLogs_EndToEnd(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, srv.Close()) })
|
||||
|
||||
sink := testutil.NewFakeSink(t)
|
||||
logger := sink.Logger(slog.LevelInfo)
|
||||
sink := &logSink{}
|
||||
logger := slog.Make(sink)
|
||||
workspaceID := uuid.New()
|
||||
templateID := uuid.New()
|
||||
templateVersionID := uuid.New()
|
||||
@@ -97,10 +118,10 @@ func TestBoundaryLogs_EndToEnd(t *testing.T) {
|
||||
sendBoundaryLogsRequest(t, conn, req)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(sink.Entries()) >= 1
|
||||
return len(sink.getEntries()) >= 1
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
entries := sink.Entries()
|
||||
entries := sink.getEntries()
|
||||
require.Len(t, entries, 1)
|
||||
entry := entries[0]
|
||||
require.Equal(t, slog.LevelInfo, entry.Level)
|
||||
@@ -131,10 +152,10 @@ func TestBoundaryLogs_EndToEnd(t *testing.T) {
|
||||
sendBoundaryLogsRequest(t, conn, req2)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(sink.Entries()) >= 2
|
||||
return len(sink.getEntries()) >= 2
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
entries = sink.Entries()
|
||||
entries = sink.getEntries()
|
||||
entry = entries[1]
|
||||
require.Len(t, entries, 2)
|
||||
require.Equal(t, slog.LevelInfo, entry.Level)
|
||||
|
||||
@@ -156,7 +156,7 @@ func (fw *fsWatcher) loop(ctx context.Context) {
|
||||
|
||||
func (fw *fsWatcher) addRecursive(dir string) []FSEvent {
|
||||
var events []FSEvent
|
||||
if walkErr := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
_ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // best-effort
|
||||
}
|
||||
@@ -176,10 +176,7 @@ func (fw *fsWatcher) addRecursive(dir string) []FSEvent {
|
||||
}
|
||||
events = append(events, FSEvent{Op: OpCreate, Path: path, IsDir: false})
|
||||
return nil
|
||||
}); walkErr != nil {
|
||||
fw.logger.Warn(context.Background(), "failed to walk directory",
|
||||
slog.F("dir", dir), slog.Error(walkErr))
|
||||
}
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
|
||||
+4
-38
@@ -2,7 +2,6 @@ package reaper
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/go-reap"
|
||||
|
||||
@@ -43,42 +42,9 @@ func WithLogger(logger slog.Logger) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithReaperStop sets a channel that, when closed, stops the reaper
|
||||
// goroutine. Callers that invoke ForkReap more than once in the
|
||||
// same process (e.g. tests) should use this to prevent goroutine
|
||||
// accumulation.
|
||||
func WithReaperStop(ch chan struct{}) Option {
|
||||
return func(o *options) {
|
||||
o.ReaperStop = ch
|
||||
}
|
||||
}
|
||||
|
||||
// WithReaperStopped sets a channel that is closed after the
|
||||
// reaper goroutine has fully exited.
|
||||
func WithReaperStopped(ch chan struct{}) Option {
|
||||
return func(o *options) {
|
||||
o.ReaperStopped = ch
|
||||
}
|
||||
}
|
||||
|
||||
// WithReapLock sets a mutex shared between the reaper and Wait4.
|
||||
// The reaper holds the write lock while reaping, and ForkReap
|
||||
// holds the read lock during Wait4, preventing the reaper from
|
||||
// stealing the child's exit status. This is only needed for
|
||||
// tests with instant-exit children where the race window is
|
||||
// large.
|
||||
func WithReapLock(mu *sync.RWMutex) Option {
|
||||
return func(o *options) {
|
||||
o.ReapLock = mu
|
||||
}
|
||||
}
|
||||
|
||||
type options struct {
|
||||
ExecArgs []string
|
||||
PIDs reap.PidCh
|
||||
CatchSignals []os.Signal
|
||||
Logger slog.Logger
|
||||
ReaperStop chan struct{}
|
||||
ReaperStopped chan struct{}
|
||||
ReapLock *sync.RWMutex
|
||||
ExecArgs []string
|
||||
PIDs reap.PidCh
|
||||
CatchSignals []os.Signal
|
||||
Logger slog.Logger
|
||||
}
|
||||
|
||||
+23
-100
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -19,82 +18,25 @@ import (
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// subprocessEnvKey is set when a test re-execs itself as an
|
||||
// isolated subprocess. Tests that call ForkReap or send signals
|
||||
// to their own process check this to decide whether to run real
|
||||
// test logic or launch the subprocess and wait for it.
|
||||
const subprocessEnvKey = "CODER_REAPER_TEST_SUBPROCESS"
|
||||
|
||||
// runSubprocess re-execs the current test binary in a new process
|
||||
// running only the named test. This isolates ForkReap's
|
||||
// syscall.ForkExec and any process-directed signals (e.g. SIGINT)
|
||||
// from the parent test binary, making these tests safe to run in
|
||||
// CI and alongside other tests.
|
||||
// TestReap checks that's the reaper is successfully reaping
|
||||
// exited processes and passing the PIDs through the shared
|
||||
// channel.
|
||||
//
|
||||
// Returns true inside the subprocess (caller should proceed with
|
||||
// the real test logic). Returns false in the parent after the
|
||||
// subprocess exits successfully (caller should return).
|
||||
func runSubprocess(t *testing.T) bool {
|
||||
t.Helper()
|
||||
|
||||
if os.Getenv(subprocessEnvKey) == "1" {
|
||||
return true
|
||||
}
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
//nolint:gosec // Test-controlled arguments.
|
||||
cmd := exec.CommandContext(ctx, os.Args[0],
|
||||
"-test.run=^"+t.Name()+"$",
|
||||
"-test.v",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), subprocessEnvKey+"=1")
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
t.Logf("Subprocess output:\n%s", out)
|
||||
require.NoError(t, err, "subprocess failed")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// withDone returns options that stop the reaper goroutine when t
|
||||
// completes and wait for it to fully exit, preventing
|
||||
// overlapping reapers across sequential subtests.
|
||||
func withDone(t *testing.T) []reaper.Option {
|
||||
t.Helper()
|
||||
stop := make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
close(stop)
|
||||
<-stopped
|
||||
})
|
||||
return []reaper.Option{
|
||||
reaper.WithReaperStop(stop),
|
||||
reaper.WithReaperStopped(stopped),
|
||||
}
|
||||
}
|
||||
|
||||
// TestReap checks that the reaper successfully reaps exited
|
||||
// processes and passes their PIDs through the shared channel.
|
||||
//nolint:paralleltest
|
||||
func TestReap(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Don't run the reaper test in CI. It does weird
|
||||
// things like forkexecing which may have unintended
|
||||
// consequences in CI.
|
||||
if testutil.InCI() {
|
||||
t.Skip("Detected CI, skipping reaper tests")
|
||||
}
|
||||
if !runSubprocess(t) {
|
||||
return
|
||||
}
|
||||
|
||||
pids := make(reap.PidCh, 1)
|
||||
var reapLock sync.RWMutex
|
||||
opts := append([]reaper.Option{
|
||||
exitCode, err := reaper.ForkReap(
|
||||
reaper.WithPIDCallback(pids),
|
||||
// Provide some argument that immediately exits.
|
||||
reaper.WithExecArgs("/bin/sh", "-c", "exit 0"),
|
||||
reaper.WithReapLock(&reapLock),
|
||||
}, withDone(t)...)
|
||||
reapLock.RLock()
|
||||
exitCode, err := reaper.ForkReap(opts...)
|
||||
reapLock.RUnlock()
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, exitCode)
|
||||
|
||||
@@ -114,7 +56,7 @@ func TestReap(t *testing.T) {
|
||||
|
||||
expectedPIDs := []int{cmd.Process.Pid, cmd2.Process.Pid}
|
||||
|
||||
for range len(expectedPIDs) {
|
||||
for i := 0; i < len(expectedPIDs); i++ {
|
||||
select {
|
||||
case <-time.After(testutil.WaitShort):
|
||||
t.Fatalf("Timed out waiting for process")
|
||||
@@ -124,15 +66,11 @@ func TestReap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:tparallel // Subtests must be sequential, each starts its own reaper.
|
||||
//nolint:paralleltest
|
||||
func TestForkReapExitCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testutil.InCI() {
|
||||
t.Skip("Detected CI, skipping reaper tests")
|
||||
}
|
||||
if !runSubprocess(t) {
|
||||
return
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -147,35 +85,25 @@ func TestForkReapExitCodes(t *testing.T) {
|
||||
{"SIGTERM", "kill -15 $$", 128 + 15},
|
||||
}
|
||||
|
||||
//nolint:paralleltest // Subtests must be sequential, each starts its own reaper.
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var reapLock sync.RWMutex
|
||||
opts := append([]reaper.Option{
|
||||
exitCode, err := reaper.ForkReap(
|
||||
reaper.WithExecArgs("/bin/sh", "-c", tt.command),
|
||||
reaper.WithReapLock(&reapLock),
|
||||
}, withDone(t)...)
|
||||
reapLock.RLock()
|
||||
exitCode, err := reaper.ForkReap(opts...)
|
||||
reapLock.RUnlock()
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expectedCode, exitCode, "exit code mismatch for %q", tt.command)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReapInterrupt verifies that ForkReap forwards caught signals
|
||||
// to the child process. The test sends SIGINT to its own process
|
||||
// and checks that the child receives it. Running in a subprocess
|
||||
// ensures SIGINT cannot kill the parent test binary.
|
||||
//nolint:paralleltest // Signal handling.
|
||||
func TestReapInterrupt(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Don't run the reaper test in CI. It does weird
|
||||
// things like forkexecing which may have unintended
|
||||
// consequences in CI.
|
||||
if testutil.InCI() {
|
||||
t.Skip("Detected CI, skipping reaper tests")
|
||||
}
|
||||
if !runSubprocess(t) {
|
||||
return
|
||||
}
|
||||
|
||||
errC := make(chan error, 1)
|
||||
pids := make(reap.PidCh, 1)
|
||||
@@ -187,28 +115,23 @@ func TestReapInterrupt(t *testing.T) {
|
||||
defer signal.Stop(usrSig)
|
||||
|
||||
go func() {
|
||||
opts := append([]reaper.Option{
|
||||
exitCode, err := reaper.ForkReap(
|
||||
reaper.WithPIDCallback(pids),
|
||||
reaper.WithCatchSignals(os.Interrupt),
|
||||
// Signal propagation does not extend to children of children, so
|
||||
// we create a little bash script to ensure sleep is interrupted.
|
||||
reaper.WithExecArgs("/bin/sh", "-c", fmt.Sprintf(
|
||||
"pid=0; trap 'kill -USR2 %d; kill -TERM $pid' INT; sleep 10 &\npid=$!; kill -USR1 %d; wait",
|
||||
os.Getpid(), os.Getpid(),
|
||||
)),
|
||||
}, withDone(t)...)
|
||||
exitCode, err := reaper.ForkReap(opts...)
|
||||
reaper.WithExecArgs("/bin/sh", "-c", fmt.Sprintf("pid=0; trap 'kill -USR2 %d; kill -TERM $pid' INT; sleep 10 &\npid=$!; kill -USR1 %d; wait", os.Getpid(), os.Getpid())),
|
||||
)
|
||||
// The child exits with 128 + SIGTERM (15) = 143, but the trap catches
|
||||
// SIGINT and sends SIGTERM to the sleep process, so exit code varies.
|
||||
_ = exitCode
|
||||
errC <- err
|
||||
}()
|
||||
|
||||
require.Equal(t, syscall.SIGUSR1, <-usrSig)
|
||||
|
||||
require.Equal(t, <-usrSig, syscall.SIGUSR1)
|
||||
err := syscall.Kill(os.Getpid(), syscall.SIGINT)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, <-usrSig, syscall.SIGUSR2)
|
||||
|
||||
require.Equal(t, syscall.SIGUSR2, <-usrSig)
|
||||
require.NoError(t, <-errC)
|
||||
}
|
||||
|
||||
+14
-24
@@ -19,36 +19,31 @@ func IsInitProcess() bool {
|
||||
return os.Getpid() == 1
|
||||
}
|
||||
|
||||
// startSignalForwarding registers signal handlers synchronously
|
||||
// then forwards caught signals to the child in a background
|
||||
// goroutine. Registering before the goroutine starts ensures no
|
||||
// signal is lost between ForkExec and the handler being ready.
|
||||
func startSignalForwarding(logger slog.Logger, pid int, sigs []os.Signal) {
|
||||
func catchSignals(logger slog.Logger, pid int, sigs []os.Signal) {
|
||||
if len(sigs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sc := make(chan os.Signal, 1)
|
||||
signal.Notify(sc, sigs...)
|
||||
defer signal.Stop(sc)
|
||||
|
||||
logger.Info(context.Background(), "reaper catching signals",
|
||||
slog.F("signals", sigs),
|
||||
slog.F("child_pid", pid),
|
||||
)
|
||||
|
||||
go func() {
|
||||
defer signal.Stop(sc)
|
||||
for s := range sc {
|
||||
sig, ok := s.(syscall.Signal)
|
||||
if ok {
|
||||
logger.Info(context.Background(), "reaper caught signal, killing child process",
|
||||
slog.F("signal", sig.String()),
|
||||
slog.F("child_pid", pid),
|
||||
)
|
||||
_ = syscall.Kill(pid, sig)
|
||||
}
|
||||
for {
|
||||
s := <-sc
|
||||
sig, ok := s.(syscall.Signal)
|
||||
if ok {
|
||||
logger.Info(context.Background(), "reaper caught signal, killing child process",
|
||||
slog.F("signal", sig.String()),
|
||||
slog.F("child_pid", pid),
|
||||
)
|
||||
_ = syscall.Kill(pid, sig)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// ForkReap spawns a goroutine that reaps children. In order to avoid
|
||||
@@ -69,12 +64,7 @@ func ForkReap(opt ...Option) (int, error) {
|
||||
o(opts)
|
||||
}
|
||||
|
||||
go func() {
|
||||
reap.ReapChildren(opts.PIDs, nil, opts.ReaperStop, opts.ReapLock)
|
||||
if opts.ReaperStopped != nil {
|
||||
close(opts.ReaperStopped)
|
||||
}
|
||||
}()
|
||||
go reap.ReapChildren(opts.PIDs, nil, nil, nil)
|
||||
|
||||
pwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
@@ -100,7 +90,7 @@ func ForkReap(opt ...Option) (int, error) {
|
||||
return 1, xerrors.Errorf("fork exec: %w", err)
|
||||
}
|
||||
|
||||
startSignalForwarding(opts.Logger, pid, opts.CatchSignals)
|
||||
go catchSignals(opts.Logger, pid, opts.CatchSignals)
|
||||
|
||||
var wstatus syscall.WaitStatus
|
||||
_, err = syscall.Wait4(pid, &wstatus, 0, nil)
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
@@ -41,18 +40,6 @@ func New(t testing.TB, args ...string) (*serpent.Invocation, config.Root) {
|
||||
return NewWithCommand(t, cmd, args...)
|
||||
}
|
||||
|
||||
// NewWithClock is like New, but injects the given clock for
|
||||
// tests that are time-dependent.
|
||||
func NewWithClock(t testing.TB, clk quartz.Clock, args ...string) (*serpent.Invocation, config.Root) {
|
||||
var root cli.RootCmd
|
||||
root.SetClock(clk)
|
||||
|
||||
cmd, err := root.Command(root.AGPL())
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewWithCommand(t, cmd, args...)
|
||||
}
|
||||
|
||||
type logWriter struct {
|
||||
prefix string
|
||||
log slog.Logger
|
||||
|
||||
@@ -123,10 +123,6 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
|
||||
initialModel.height = defaultSelectModelHeight
|
||||
}
|
||||
|
||||
if idx := slices.Index(opts.Options, opts.Default); idx >= 0 {
|
||||
initialModel.cursor = idx
|
||||
}
|
||||
|
||||
initialModel.search.Prompt = ""
|
||||
initialModel.search.Focus()
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
autoUpdates string
|
||||
copyParametersFrom string
|
||||
useParameterDefaults bool
|
||||
noWait bool
|
||||
// Organization context is only required if more than 1 template
|
||||
// shares the same name across multiple organizations.
|
||||
orgContext = NewOrganizationContext()
|
||||
@@ -373,14 +372,6 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
|
||||
cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job)
|
||||
|
||||
if noWait {
|
||||
_, _ = fmt.Fprintf(inv.Stdout,
|
||||
"\nThe %s workspace has been created and is building in the background.\n",
|
||||
cliui.Keyword(workspace.Name),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("watch build: %w", err)
|
||||
@@ -454,12 +445,6 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
Description: "Automatically accept parameter defaults when no value is provided.",
|
||||
Value: serpent.BoolOf(&useParameterDefaults),
|
||||
},
|
||||
serpent.Option{
|
||||
Flag: "no-wait",
|
||||
Env: "CODER_CREATE_NO_WAIT",
|
||||
Description: "Return immediately after creating the workspace. The build will run in the background.",
|
||||
Value: serpent.BoolOf(&noWait),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
)
|
||||
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
|
||||
|
||||
@@ -603,81 +603,6 @@ func TestCreate(t *testing.T) {
|
||||
assert.Nil(t, ws.AutostartSchedule, "expected workspace autostart schedule to be nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NoWait", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
inv, root := clitest.New(t, "create", "my-workspace",
|
||||
"--template", template.Name,
|
||||
"-y",
|
||||
"--no-wait",
|
||||
)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatchContext(ctx, "building in the background")
|
||||
_ = testutil.TryReceive(ctx, t, doneChan)
|
||||
|
||||
// Verify workspace was actually created.
|
||||
ws, err := member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.TemplateName, template.Name)
|
||||
})
|
||||
|
||||
t.Run("NoWaitWithParameterDefaults", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses([]*proto.RichParameter{
|
||||
{Name: "region", Type: "string", DefaultValue: "us-east-1"},
|
||||
{Name: "instance_type", Type: "string", DefaultValue: "t3.micro"},
|
||||
}))
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
inv, root := clitest.New(t, "create", "my-workspace",
|
||||
"--template", template.Name,
|
||||
"-y",
|
||||
"--use-parameter-defaults",
|
||||
"--no-wait",
|
||||
)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatchContext(ctx, "building in the background")
|
||||
_ = testutil.TryReceive(ctx, t, doneChan)
|
||||
|
||||
// Verify workspace was created and parameters were applied.
|
||||
ws, err := member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.TemplateName, template.Name)
|
||||
|
||||
buildParams, err := member.WorkspaceBuildParameters(ctx, ws.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "region", Value: "us-east-1"})
|
||||
assert.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "instance_type", Value: "t3.micro"})
|
||||
})
|
||||
}
|
||||
|
||||
func prepareEchoResponses(parameters []*proto.RichParameter, presets ...*proto.Preset) *echo.Responses {
|
||||
|
||||
@@ -1000,12 +1000,6 @@ func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool
|
||||
Properties: sdkTool.Schema.Properties,
|
||||
Required: sdkTool.Schema.Required,
|
||||
},
|
||||
Annotations: mcp.ToolAnnotation{
|
||||
ReadOnlyHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.ReadOnlyHint),
|
||||
DestructiveHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.DestructiveHint),
|
||||
IdempotentHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.IdempotentHint),
|
||||
OpenWorldHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.OpenWorldHint),
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
+1
-16
@@ -81,13 +81,7 @@ func TestExpMcpServer(t *testing.T) {
|
||||
var toolsResponse struct {
|
||||
Result struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Annotations struct {
|
||||
ReadOnlyHint *bool `json:"readOnlyHint"`
|
||||
DestructiveHint *bool `json:"destructiveHint"`
|
||||
IdempotentHint *bool `json:"idempotentHint"`
|
||||
OpenWorldHint *bool `json:"openWorldHint"`
|
||||
} `json:"annotations"`
|
||||
Name string `json:"name"`
|
||||
} `json:"tools"`
|
||||
} `json:"result"`
|
||||
}
|
||||
@@ -100,15 +94,6 @@ func TestExpMcpServer(t *testing.T) {
|
||||
}
|
||||
slices.Sort(foundTools)
|
||||
require.Equal(t, []string{"coder_get_authenticated_user"}, foundTools)
|
||||
annotations := toolsResponse.Result.Tools[0].Annotations
|
||||
require.NotNil(t, annotations.ReadOnlyHint)
|
||||
require.NotNil(t, annotations.DestructiveHint)
|
||||
require.NotNil(t, annotations.IdempotentHint)
|
||||
require.NotNil(t, annotations.OpenWorldHint)
|
||||
assert.True(t, *annotations.ReadOnlyHint)
|
||||
assert.False(t, *annotations.DestructiveHint)
|
||||
assert.True(t, *annotations.IdempotentHint)
|
||||
assert.False(t, *annotations.OpenWorldHint)
|
||||
|
||||
// Call the tool and ensure it works.
|
||||
toolPayload := `{"jsonrpc":"2.0","id":3,"method":"tools/call", "params": {"name": "coder_get_authenticated_user", "arguments": {}}}`
|
||||
|
||||
+3
-3
@@ -109,13 +109,13 @@ func (RootCmd) promptExample() *serpent.Command {
|
||||
Options: []string{
|
||||
"Blue", "Green", "Yellow", "Red", "Something else",
|
||||
},
|
||||
Default: "Green",
|
||||
Default: "",
|
||||
Message: "Select your favorite color:",
|
||||
Size: 5,
|
||||
HideSearch: !useSearch,
|
||||
})
|
||||
if value == "Something else" {
|
||||
_, _ = fmt.Fprint(inv.Stdout, "I would have picked green.\n")
|
||||
_, _ = fmt.Fprint(inv.Stdout, "I would have picked blue.\n")
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s is a nice color.\n", value)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func (RootCmd) promptExample() *serpent.Command {
|
||||
Options: []string{
|
||||
"Car", "Bike", "Plane", "Boat", "Train",
|
||||
},
|
||||
Default: "Bike",
|
||||
Default: "Car",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+45
-74
@@ -1732,18 +1732,19 @@ const (
|
||||
|
||||
func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
var (
|
||||
workspaceCount int64
|
||||
workspaceJobTimeout time.Duration
|
||||
autostartBuildTimeout time.Duration
|
||||
autostartDelay time.Duration
|
||||
template string
|
||||
noCleanup bool
|
||||
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{
|
||||
@@ -1771,7 +1772,7 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse output flags: %w", err)
|
||||
return xerrors.Errorf("could not parse --output flags")
|
||||
}
|
||||
|
||||
tpl, err := parseTemplate(ctx, client, me.OrganizationIDs, template)
|
||||
@@ -1802,41 +1803,15 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
}
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := autostart.NewMetrics(reg)
|
||||
|
||||
setupBarrier := new(sync.WaitGroup)
|
||||
setupBarrier.Add(int(workspaceCount))
|
||||
|
||||
// The workspace-build-updates experiment must be enabled to use
|
||||
// the centralized pubsub channel for coordinating workspace builds.
|
||||
experiments, err := client.Experiments(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get experiments: %w", err)
|
||||
}
|
||||
if !experiments.Enabled(codersdk.ExperimentWorkspaceBuildUpdates) {
|
||||
return xerrors.New("the workspace-build-updates experiment must be enabled to run the autostart scaletest")
|
||||
}
|
||||
|
||||
workspaceNames := make([]string, 0, workspaceCount)
|
||||
resultSink := make(chan autostart.RunResult, workspaceCount)
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
for i := range workspaceCount {
|
||||
id := strconv.Itoa(int(i))
|
||||
workspaceNames = append(workspaceNames, loadtestutil.GenerateDeterministicWorkspaceName(id))
|
||||
}
|
||||
dispatcher := autostart.NewWorkspaceDispatcher(workspaceNames)
|
||||
|
||||
decoder, err := client.WatchAllWorkspaceBuilds(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("watch all workspace builds: %w", err)
|
||||
}
|
||||
defer decoder.Close()
|
||||
|
||||
// Start the dispatcher. It will run in a goroutine and automatically
|
||||
// close all workspace channels when the build updates channel closes.
|
||||
dispatcher.Start(ctx, decoder.Chan())
|
||||
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
for workspaceName, buildUpdatesChannel := range dispatcher.Channels {
|
||||
id := strings.TrimPrefix(workspaceName, loadtestutil.ScaleTestPrefix+"-")
|
||||
|
||||
config := autostart.Config{
|
||||
User: createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
@@ -1846,16 +1821,13 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
Request: codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: tpl.ID,
|
||||
RichParameterValues: richParameters,
|
||||
// Use deterministic workspace name so we can pre-create the channel.
|
||||
Name: workspaceName,
|
||||
},
|
||||
},
|
||||
WorkspaceJobTimeout: workspaceJobTimeout,
|
||||
AutostartBuildTimeout: autostartBuildTimeout,
|
||||
AutostartDelay: autostartDelay,
|
||||
SetupBarrier: setupBarrier,
|
||||
BuildUpdates: buildUpdatesChannel,
|
||||
ResultSink: resultSink,
|
||||
WorkspaceJobTimeout: workspaceJobTimeout,
|
||||
AutostartDelay: autostartDelay,
|
||||
AutostartTimeout: autostartTimeout,
|
||||
Metrics: metrics,
|
||||
SetupBarrier: setupBarrier,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
@@ -1877,11 +1849,18 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
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...")
|
||||
@@ -1892,40 +1871,31 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
// Collect all metrics from the channel.
|
||||
close(resultSink)
|
||||
var runResults []autostart.RunResult
|
||||
for r := range resultSink {
|
||||
runResults = append(runResults, r)
|
||||
// If the command was interrupted, skip stats.
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nAll %d autostart builds completed successfully (elapsed: %s)\n", res.TotalRuns, time.Duration(res.Elapsed).Round(time.Millisecond))
|
||||
|
||||
if len(runResults) > 0 {
|
||||
results := autostart.NewRunResults(runResults)
|
||||
for _, out := range outputs {
|
||||
if err := out.write(results.ToHarnessResults(), inv.Stdout); err != nil {
|
||||
return xerrors.Errorf("write output: %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(context.Background())
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Cleanup complete")
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nSkipping cleanup (--no-cleanup specified). Resources left running.")
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1948,13 +1918,6 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
Description: "Timeout for workspace jobs (e.g. build, start).",
|
||||
Value: serpent.DurationOf(&workspaceJobTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "autostart-build-timeout",
|
||||
Env: "CODER_SCALETEST_AUTOSTART_BUILD_TIMEOUT",
|
||||
Default: "15m",
|
||||
Description: "Timeout for the autostart build to complete. Must be longer than workspace-job-timeout to account for queueing time in high-load scenarios.",
|
||||
Value: serpent.DurationOf(&autostartBuildTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "autostart-delay",
|
||||
Env: "CODER_SCALETEST_AUTOSTART_DELAY",
|
||||
@@ -1962,6 +1925,13 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
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",
|
||||
@@ -1980,9 +1950,10 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
|
||||
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -57,9 +57,7 @@ func (*RootCmd) scaletestLLMMock() *serpent.Command {
|
||||
return xerrors.Errorf("start mock LLM server: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := srv.Stop(); err != nil {
|
||||
logger.Error(ctx, "failed to stop mock LLM server", slog.Error(err))
|
||||
}
|
||||
_ = srv.Stop()
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Mock LLM API server started on %s\n", srv.APIAddress())
|
||||
|
||||
+6
-12
@@ -19,18 +19,12 @@ func OverrideVSCodeConfigs(fs afero.Fs) error {
|
||||
return err
|
||||
}
|
||||
mutate := func(m map[string]interface{}) {
|
||||
// These defaults prevent VS Code from overriding
|
||||
// GIT_ASKPASS and using its own GitHub authentication,
|
||||
// which would circumvent cloning with Coder-configured
|
||||
// providers. We only set them if they are not already
|
||||
// present so that template authors can override them
|
||||
// via module settings (e.g. the vscode-web module).
|
||||
if _, ok := m["git.useIntegratedAskPass"]; !ok {
|
||||
m["git.useIntegratedAskPass"] = false
|
||||
}
|
||||
if _, ok := m["github.gitAuthentication"]; !ok {
|
||||
m["github.gitAuthentication"] = false
|
||||
}
|
||||
// This prevents VS Code from overriding GIT_ASKPASS, which
|
||||
// we use to automatically authenticate Git providers.
|
||||
m["git.useIntegratedAskPass"] = false
|
||||
// This prevents VS Code from using it's own GitHub authentication
|
||||
// which would circumvent cloning with Coder-configured providers.
|
||||
m["github.gitAuthentication"] = false
|
||||
}
|
||||
|
||||
for _, configPath := range []string{
|
||||
|
||||
@@ -61,31 +61,4 @@ func TestOverrideVSCodeConfigs(t *testing.T) {
|
||||
require.Equal(t, "something", mapping["hotdogs"])
|
||||
}
|
||||
})
|
||||
t.Run("NoOverwrite", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := afero.NewMemMapFs()
|
||||
mapping := map[string]interface{}{
|
||||
"git.useIntegratedAskPass": true,
|
||||
"github.gitAuthentication": true,
|
||||
"other.setting": "preserved",
|
||||
}
|
||||
data, err := json.Marshal(mapping)
|
||||
require.NoError(t, err)
|
||||
for _, configPath := range configPaths {
|
||||
err = afero.WriteFile(fs, configPath, data, 0o600)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
err = gitauth.OverrideVSCodeConfigs(fs)
|
||||
require.NoError(t, err)
|
||||
for _, configPath := range configPaths {
|
||||
data, err := afero.ReadFile(fs, configPath)
|
||||
require.NoError(t, err)
|
||||
mapping := map[string]interface{}{}
|
||||
err = json.Unmarshal(data, &mapping)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, mapping["git.useIntegratedAskPass"])
|
||||
require.Equal(t, true, mapping["github.gitAuthentication"])
|
||||
require.Equal(t, "preserved", mapping["other.setting"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+2
-2
@@ -58,7 +58,7 @@ func prepareTestGitSSH(ctx context.Context, t *testing.T) (*agentsdk.Client, str
|
||||
_ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) {
|
||||
o.Client = agentClient
|
||||
})
|
||||
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).WithContext(ctx).Wait()
|
||||
_ = coderdtest.AwaitWorkspaceAgents(t, client, r.Workspace.ID)
|
||||
return agentClient, r.AgentToken, pubkey
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ func TestGitSSH(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
writePrivateKeyToFile(t, idFile, privkey)
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitSuperLong)
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
client, token, coderPubkey := prepareTestGitSSH(setupCtx, t)
|
||||
|
||||
authkey := make(chan gossh.PublicKey, 1)
|
||||
|
||||
+1
-39
@@ -357,25 +357,6 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
}
|
||||
|
||||
sessionToken, _ := inv.ParsedFlags().GetString(varToken)
|
||||
tokenFlagProvided := inv.ParsedFlags().Changed(varToken)
|
||||
|
||||
// If CODER_SESSION_TOKEN is set in the environment, abort
|
||||
// interactive login unless --use-token-as-session or --token
|
||||
// is specified. The env var takes precedence over a token
|
||||
// stored on disk, so even if we complete login and write a
|
||||
// new token to the session file, subsequent CLI commands
|
||||
// would still use the environment variable value. When
|
||||
// --token is provided on the command line, the user
|
||||
// explicitly wants to authenticate with that token (common
|
||||
// in CI), so we skip this check.
|
||||
if !tokenFlagProvided && inv.Environ.Get(envSessionToken) != "" && !useTokenForSession {
|
||||
return xerrors.Errorf(
|
||||
"%s is set. This environment variable takes precedence over any session token stored on disk.\n\n"+
|
||||
"To log in, unset the environment variable and re-run this command:\n\n"+
|
||||
"\tunset %s",
|
||||
envSessionToken, envSessionToken,
|
||||
)
|
||||
}
|
||||
if sessionToken == "" {
|
||||
authURL := *serverURL
|
||||
// Don't use filepath.Join, we don't want to use the os separator
|
||||
@@ -494,26 +475,7 @@ func (r *RootCmd) loginToken() *serpent.Command {
|
||||
Long: "Print the session token for use in scripts and automation.",
|
||||
Middleware: serpent.RequireNArgs(0),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
if err := r.ensureClientURL(); err != nil {
|
||||
return err
|
||||
}
|
||||
// When using the file storage, a session token is stored for a single
|
||||
// deployment URL that the user is logged in to. They keyring can store
|
||||
// multiple deployment session tokens. Error if the requested URL doesn't
|
||||
// match the stored config URL when using file storage to avoid returning
|
||||
// a token for the wrong deployment.
|
||||
backend := r.ensureTokenBackend()
|
||||
if _, ok := backend.(*sessionstore.File); ok {
|
||||
conf := r.createConfig()
|
||||
storedURL, err := conf.URL().Read()
|
||||
if err == nil {
|
||||
storedURL = strings.TrimSpace(storedURL)
|
||||
if storedURL != r.clientURL.String() {
|
||||
return xerrors.Errorf("file session token storage only supports one server at a time: requested %s but logged into %s", r.clientURL.String(), storedURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
tok, err := backend.Read(r.clientURL)
|
||||
tok, err := r.ensureTokenBackend().Read(r.clientURL)
|
||||
if err != nil {
|
||||
if xerrors.Is(err, os.ErrNotExist) {
|
||||
return xerrors.New("no session token found - run 'coder login' first")
|
||||
|
||||
+1
-58
@@ -516,40 +516,6 @@ func TestLogin(t *testing.T) {
|
||||
require.NotEqual(t, client.SessionToken(), sessionFile)
|
||||
})
|
||||
|
||||
t.Run("SessionTokenEnvVar", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
root, _ := clitest.New(t, "login", client.URL.String())
|
||||
root.Environ.Set("CODER_SESSION_TOKEN", "invalid-token")
|
||||
err := root.Run()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "CODER_SESSION_TOKEN is set")
|
||||
require.Contains(t, err.Error(), "unset CODER_SESSION_TOKEN")
|
||||
})
|
||||
|
||||
t.Run("SessionTokenEnvVarWithUseTokenAsSession", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--use-token-as-session")
|
||||
root.Environ.Set("CODER_SESSION_TOKEN", client.SessionToken())
|
||||
err := root.Run()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("SessionTokenEnvVarWithTokenFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
// Using --token with CODER_SESSION_TOKEN set should succeed.
|
||||
// This is the standard pattern used by coder/setup-action.
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--token", client.SessionToken())
|
||||
root.Environ.Set("CODER_SESSION_TOKEN", client.SessionToken())
|
||||
err := root.Run()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("KeepOrganizationContext", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
@@ -592,33 +558,10 @@ func TestLoginToken(t *testing.T) {
|
||||
|
||||
t.Run("NoTokenStored", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
inv, _ := clitest.New(t, "login", "token", "--url", client.URL.String())
|
||||
inv, _ := clitest.New(t, "login", "token")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "no session token found")
|
||||
})
|
||||
|
||||
t.Run("NoURLProvided", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
inv, _ := clitest.New(t, "login", "token")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "You are not logged in")
|
||||
})
|
||||
|
||||
t.Run("URLMismatchFileBackend", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
inv, root := clitest.New(t, "login", "token", "--url", "https://other.example.com")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "file session token storage only supports one server")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ func (r *RootCmd) createOrganizationRole(orgContext *OrganizationContext) *serpe
|
||||
} else {
|
||||
updated, err = client.CreateOrganizationRole(ctx, customRole)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create role: %w", err)
|
||||
return xerrors.Errorf("patch role: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-39
@@ -39,7 +39,6 @@ import (
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/pretty"
|
||||
"github.com/coder/quartz"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
@@ -231,10 +230,6 @@ func (r *RootCmd) RunWithSubcommands(subcommands []*serpent.Command) {
|
||||
}
|
||||
|
||||
func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, error) {
|
||||
if r.clock == nil {
|
||||
r.clock = quartz.NewReal()
|
||||
}
|
||||
|
||||
fmtLong := `Coder %s — A tool for provisioning self-hosted development environments with Terraform.
|
||||
`
|
||||
hiddenAgentAuth := &AgentAuth{}
|
||||
@@ -553,45 +548,32 @@ type RootCmd struct {
|
||||
useKeyring bool
|
||||
keyringServiceName string
|
||||
useKeyringWithGlobalConfig bool
|
||||
|
||||
// clock is used for time-dependent operations. Initialized to
|
||||
// quartz.NewReal() in Command() if not set via SetClock.
|
||||
clock quartz.Clock
|
||||
}
|
||||
|
||||
// SetClock sets the clock used for time-dependent operations.
|
||||
// Must be called before Command() to take effect.
|
||||
func (r *RootCmd) SetClock(clk quartz.Clock) {
|
||||
r.clock = clk
|
||||
}
|
||||
|
||||
// ensureClientURL loads the client URL from the config file if it
|
||||
// wasn't provided via --url or CODER_URL.
|
||||
func (r *RootCmd) ensureClientURL() error {
|
||||
if r.clientURL != nil && r.clientURL.String() != "" {
|
||||
return nil
|
||||
}
|
||||
rawURL, err := r.createConfig().URL().Read()
|
||||
// If the configuration files are absent, the user is logged out.
|
||||
if os.IsNotExist(err) {
|
||||
binPath, err := os.Executable()
|
||||
if err != nil {
|
||||
binPath = "coder"
|
||||
}
|
||||
return xerrors.Errorf(notLoggedInMessage, binPath)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.clientURL, err = url.Parse(strings.TrimSpace(rawURL))
|
||||
return err
|
||||
}
|
||||
|
||||
// InitClient creates and configures a new client with authentication, telemetry,
|
||||
// and version checks.
|
||||
func (r *RootCmd) InitClient(inv *serpent.Invocation) (*codersdk.Client, error) {
|
||||
if err := r.ensureClientURL(); err != nil {
|
||||
return nil, err
|
||||
conf := r.createConfig()
|
||||
var err error
|
||||
// Read the client URL stored on disk.
|
||||
if r.clientURL == nil || r.clientURL.String() == "" {
|
||||
rawURL, err := conf.URL().Read()
|
||||
// If the configuration files are absent, the user is logged out
|
||||
if os.IsNotExist(err) {
|
||||
binPath, err := os.Executable()
|
||||
if err != nil {
|
||||
binPath = "coder"
|
||||
}
|
||||
return nil, xerrors.Errorf(notLoggedInMessage, binPath)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.clientURL, err = url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if r.token == "" {
|
||||
tok, err := r.ensureTokenBackend().Read(r.clientURL)
|
||||
|
||||
@@ -2909,8 +2909,6 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder
|
||||
provider.MCPToolDenyRegex = v.Value
|
||||
case "PKCE_METHODS":
|
||||
provider.CodeChallengeMethodsSupported = strings.Split(v.Value, " ")
|
||||
case "API_BASE_URL":
|
||||
provider.APIBaseURL = v.Value
|
||||
}
|
||||
providers[providerNum] = provider
|
||||
}
|
||||
|
||||
@@ -188,17 +188,16 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command {
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Creating user...")
|
||||
newUser, err = tx.InsertUser(ctx, database.InsertUserParams{
|
||||
ID: uuid.New(),
|
||||
Email: newUserEmail,
|
||||
Username: newUserUsername,
|
||||
Name: "Admin User",
|
||||
HashedPassword: []byte(hashedPassword),
|
||||
CreatedAt: dbtime.Now(),
|
||||
UpdatedAt: dbtime.Now(),
|
||||
RBACRoles: []string{rbac.RoleOwner().String()},
|
||||
LoginType: database.LoginTypePassword,
|
||||
Status: "",
|
||||
IsServiceAccount: false,
|
||||
ID: uuid.New(),
|
||||
Email: newUserEmail,
|
||||
Username: newUserUsername,
|
||||
Name: "Admin User",
|
||||
HashedPassword: []byte(hashedPassword),
|
||||
CreatedAt: dbtime.Now(),
|
||||
UpdatedAt: dbtime.Now(),
|
||||
RBACRoles: []string{rbac.RoleOwner().String()},
|
||||
LoginType: database.LoginTypePassword,
|
||||
Status: "",
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert user: %w", err)
|
||||
|
||||
@@ -108,29 +108,6 @@ func TestReadExternalAuthProvidersFromEnv(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadExternalAuthProvidersFromEnv_APIBaseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{
|
||||
"CODER_EXTERNAL_AUTH_0_TYPE=github",
|
||||
"CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxx",
|
||||
"CODER_EXTERNAL_AUTH_0_API_BASE_URL=https://ghes.corp.com/api/v3",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, providers, 1)
|
||||
assert.Equal(t, "https://ghes.corp.com/api/v3", providers[0].APIBaseURL)
|
||||
}
|
||||
|
||||
func TestReadExternalAuthProvidersFromEnv_APIBaseURLDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{
|
||||
"CODER_EXTERNAL_AUTH_0_TYPE=github",
|
||||
"CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxx",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, providers, 1)
|
||||
assert.Equal(t, "", providers[0].APIBaseURL)
|
||||
}
|
||||
|
||||
// TestReadGitAuthProvidersFromEnv ensures that the deprecated `CODER_GITAUTH_`
|
||||
// environment variables are still supported.
|
||||
func TestReadGitAuthProvidersFromEnv(t *testing.T) {
|
||||
|
||||
@@ -21,8 +21,9 @@ type storedCredentials map[string]struct {
|
||||
APIToken string `json:"api_token"`
|
||||
}
|
||||
|
||||
//nolint:paralleltest, tparallel // OS keyring is flaky under concurrent access
|
||||
func TestKeyring(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "windows" && runtime.GOOS != "darwin" {
|
||||
t.Skip("linux is not supported yet")
|
||||
}
|
||||
@@ -36,6 +37,8 @@ func TestKeyring(t *testing.T) {
|
||||
)
|
||||
|
||||
t.Run("ReadNonExistent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(testhelpers.KeyringServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
@@ -47,6 +50,8 @@ func TestKeyring(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("DeleteNonExistent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(testhelpers.KeyringServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
@@ -58,6 +63,8 @@ func TestKeyring(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("WriteAndRead", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(testhelpers.KeyringServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
@@ -84,6 +91,8 @@ func TestKeyring(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("WriteAndDelete", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(testhelpers.KeyringServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
@@ -106,6 +115,8 @@ func TestKeyring(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("OverwriteToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(testhelpers.KeyringServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
@@ -135,6 +146,8 @@ func TestKeyring(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("MultipleServers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(testhelpers.KeyringServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
@@ -186,6 +199,7 @@ func TestKeyring(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("StorageFormat", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// The storage format must remain consistent to ensure we don't break
|
||||
// compatibility with other Coder related applications that may read
|
||||
// or decode the same credential.
|
||||
|
||||
@@ -25,8 +25,9 @@ func readRawKeychainCredential(t *testing.T, serviceName string) []byte {
|
||||
return winCred.CredentialBlob
|
||||
}
|
||||
|
||||
//nolint:paralleltest, tparallel // OS keyring is flaky under concurrent access
|
||||
func TestWindowsKeyring_WriteReadDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const testURL = "http://127.0.0.1:1337"
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
+1
-7
@@ -357,13 +357,7 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
// search domain expansion, which can add 20-30s of
|
||||
// delay on corporate networks with search domains
|
||||
// configured.
|
||||
exists, ccErr := workspacesdk.ExistsViaCoderConnect(ctx, coderConnectHost+".")
|
||||
if ccErr != nil {
|
||||
logger.Debug(ctx, "failed to check coder connect",
|
||||
slog.F("hostname", coderConnectHost),
|
||||
slog.Error(ccErr),
|
||||
)
|
||||
}
|
||||
exists, _ := workspacesdk.ExistsViaCoderConnect(ctx, coderConnectHost+".")
|
||||
if exists {
|
||||
defer cancel()
|
||||
|
||||
|
||||
+16
-8
@@ -180,11 +180,15 @@ func TestSSH(t *testing.T) {
|
||||
|
||||
// Delay until workspace is starting, otherwise the agent may be
|
||||
// booted due to outdated build.
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
var err error
|
||||
for {
|
||||
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||
return err == nil && workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
require.NoError(t, err)
|
||||
if workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart {
|
||||
break
|
||||
}
|
||||
time.Sleep(testutil.IntervalFast)
|
||||
}
|
||||
|
||||
// When the agent connects, the workspace was started, and we should
|
||||
// have access to the shell.
|
||||
@@ -759,11 +763,15 @@ func TestSSH(t *testing.T) {
|
||||
|
||||
// Delay until workspace is starting, otherwise the agent may be
|
||||
// booted due to outdated build.
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
var err error
|
||||
for {
|
||||
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||
return err == nil && workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
require.NoError(t, err)
|
||||
if workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart {
|
||||
break
|
||||
}
|
||||
time.Sleep(testutil.IntervalFast)
|
||||
}
|
||||
|
||||
// When the agent connects, the workspace was started, and we should
|
||||
// have access to the shell.
|
||||
|
||||
@@ -79,29 +79,6 @@ func (r *RootCmd) start() *serpent.Command {
|
||||
)
|
||||
build = workspace.LatestBuild
|
||||
default:
|
||||
// If the last build was a failed start, run a stop
|
||||
// first to clean up any partially-provisioned
|
||||
// resources.
|
||||
if workspace.LatestBuild.Status == codersdk.WorkspaceStatusFailed &&
|
||||
workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "The last start build failed. Cleaning up before retrying...\n")
|
||||
stopBuild, stopErr := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionStop,
|
||||
})
|
||||
if stopErr != nil {
|
||||
return xerrors.Errorf("cleanup stop after failed start: %w", stopErr)
|
||||
}
|
||||
stopErr = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, stopBuild.ID)
|
||||
if stopErr != nil {
|
||||
return xerrors.Errorf("wait for cleanup stop: %w", stopErr)
|
||||
}
|
||||
// Re-fetch workspace after stop completes so
|
||||
// startWorkspace sees the latest state.
|
||||
workspace, err = namedWorkspace(inv.Context(), client, inv.Args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
build, err = startWorkspace(inv, client, workspace, parameterFlags, bflags, WorkspaceStart)
|
||||
// It's possible for a workspace build to fail due to the template requiring starting
|
||||
// workspaces with the active version.
|
||||
|
||||
@@ -534,55 +534,3 @@ func TestStart_WithReason(t *testing.T) {
|
||||
workspace = coderdtest.MustWorkspace(t, member, workspace.ID)
|
||||
require.Equal(t, codersdk.BuildReasonCLI, workspace.LatestBuild.Reason)
|
||||
}
|
||||
|
||||
func TestStart_FailedStartCleansUp(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
store, ps := dbtestutil.NewDB(t)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Database: store,
|
||||
Pubsub: ps,
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, memberClient, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// Insert a failed start build directly into the database so that
|
||||
// the workspace's latest build is a failed "start" transition.
|
||||
dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
ID: workspace.ID,
|
||||
OwnerID: member.ID,
|
||||
OrganizationID: owner.OrganizationID,
|
||||
TemplateID: template.ID,
|
||||
}).
|
||||
Seed(database.WorkspaceBuild{
|
||||
TemplateVersionID: version.ID,
|
||||
Transition: database.WorkspaceTransitionStart,
|
||||
BuildNumber: workspace.LatestBuild.BuildNumber + 1,
|
||||
}).
|
||||
Failed().
|
||||
Do()
|
||||
|
||||
inv, root := clitest.New(t, "start", workspace.Name)
|
||||
clitest.SetupConfig(t, memberClient, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
// The CLI should detect the failed start and clean up first.
|
||||
pty.ExpectMatch("Cleaning up before retrying")
|
||||
pty.ExpectMatch("workspace has been started")
|
||||
|
||||
_ = testutil.TryReceive(ctx, t, doneChan)
|
||||
}
|
||||
|
||||
+17
-26
@@ -113,20 +113,6 @@ func (r *RootCmd) supportBundle() *serpent.Command {
|
||||
)
|
||||
cliLog.Debug(inv.Context(), "invocation", slog.F("args", strings.Join(os.Args, " ")))
|
||||
|
||||
// Bypass rate limiting for support bundle collection since it makes many API calls.
|
||||
// Note: this can only be done by the owner user.
|
||||
if ok, err := support.CanGenerateFull(inv.Context(), client); err == nil && ok {
|
||||
cliLog.Debug(inv.Context(), "running as owner")
|
||||
client.HTTPClient.Transport = &codersdk.HeaderTransport{
|
||||
Transport: client.HTTPClient.Transport,
|
||||
Header: http.Header{codersdk.BypassRatelimitHeader: {"true"}},
|
||||
}
|
||||
} else if !ok {
|
||||
cliLog.Warn(inv.Context(), "not running as owner, not all information available")
|
||||
} else {
|
||||
cliLog.Error(inv.Context(), "failed to look up current user", slog.Error(err))
|
||||
}
|
||||
|
||||
// Check if we're running inside a workspace
|
||||
if val, found := os.LookupEnv("CODER"); found && val == "true" {
|
||||
cliui.Warn(inv.Stderr, "Running inside Coder workspace; this can affect results!")
|
||||
@@ -214,6 +200,12 @@ func (r *RootCmd) supportBundle() *serpent.Command {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "pprof data collection will take approximately 30 seconds...")
|
||||
}
|
||||
|
||||
// Bypass rate limiting for support bundle collection since it makes many API calls.
|
||||
client.HTTPClient.Transport = &codersdk.HeaderTransport{
|
||||
Transport: client.HTTPClient.Transport,
|
||||
Header: http.Header{codersdk.BypassRatelimitHeader: {"true"}},
|
||||
}
|
||||
|
||||
deps := support.Deps{
|
||||
Client: client,
|
||||
// Support adds a sink so we don't need to supply one ourselves.
|
||||
@@ -362,20 +354,19 @@ func summarizeBundle(inv *serpent.Invocation, bun *support.Bundle) {
|
||||
return
|
||||
}
|
||||
|
||||
var docsURL string
|
||||
if bun.Deployment.Config != nil {
|
||||
docsURL = bun.Deployment.Config.Values.DocsURL.String()
|
||||
} else {
|
||||
cliui.Warn(inv.Stdout, "No deployment configuration available. This may require the Owner role.")
|
||||
if bun.Deployment.Config == nil {
|
||||
cliui.Error(inv.Stdout, "No deployment configuration available!")
|
||||
return
|
||||
}
|
||||
|
||||
if bun.Deployment.HealthReport != nil {
|
||||
deployHealthSummary := bun.Deployment.HealthReport.Summarize(docsURL)
|
||||
if len(deployHealthSummary) > 0 {
|
||||
cliui.Warn(inv.Stdout, "Deployment health issues detected:", deployHealthSummary...)
|
||||
}
|
||||
} else {
|
||||
cliui.Warn(inv.Stdout, "No deployment health report available.")
|
||||
docsURL := bun.Deployment.Config.Values.DocsURL.String()
|
||||
if bun.Deployment.HealthReport == nil {
|
||||
cliui.Error(inv.Stdout, "No deployment health report available!")
|
||||
return
|
||||
}
|
||||
deployHealthSummary := bun.Deployment.HealthReport.Summarize(docsURL)
|
||||
if len(deployHealthSummary) > 0 {
|
||||
cliui.Warn(inv.Stdout, "Deployment health issues detected:", deployHealthSummary...)
|
||||
}
|
||||
|
||||
if bun.Network.Netcheck == nil {
|
||||
|
||||
+3
-30
@@ -132,35 +132,12 @@ func TestSupportBundle(t *testing.T) {
|
||||
assertBundleContents(t, path, true, false, []string{secretValue})
|
||||
})
|
||||
|
||||
t.Run("MemberCanGenerateBundle", func(t *testing.T) {
|
||||
t.Run("NoPrivilege", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := t.TempDir()
|
||||
path := filepath.Join(d, "bundle.zip")
|
||||
inv, root := clitest.New(t, "support", "bundle", memberWorkspace.Workspace.Name, "--output-file", path, "--yes")
|
||||
inv, root := clitest.New(t, "support", "bundle", memberWorkspace.Workspace.Name, "--yes")
|
||||
clitest.SetupConfig(t, memberClient, root)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
r, err := zip.OpenReader(path)
|
||||
require.NoError(t, err, "open zip file")
|
||||
defer r.Close()
|
||||
fileNames := make(map[string]struct{}, len(r.File))
|
||||
for _, f := range r.File {
|
||||
fileNames[f.Name] = struct{}{}
|
||||
}
|
||||
// These should always be present in the zip structure, even if
|
||||
// the content is null/empty for non-admin users.
|
||||
for _, name := range []string{
|
||||
"deployment/buildinfo.json",
|
||||
"deployment/config.json",
|
||||
"workspace/workspace.json",
|
||||
"logs.txt",
|
||||
"cli_logs.txt",
|
||||
"network/netcheck.json",
|
||||
"network/interfaces.json",
|
||||
} {
|
||||
require.Contains(t, fileNames, name)
|
||||
}
|
||||
require.ErrorContains(t, err, "failed authorization check")
|
||||
})
|
||||
|
||||
// This ensures that the CLI does not panic when trying to generate a support bundle
|
||||
@@ -182,10 +159,6 @@ func TestSupportBundle(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Logf("received request: %s %s", r.Method, r.URL)
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users/me":
|
||||
resp := codersdk.User{}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
assert.NoError(t, json.NewEncoder(w).Encode(resp))
|
||||
case "/api/v2/authcheck":
|
||||
// Fake auth check
|
||||
resp := codersdk.AuthorizationResponse{
|
||||
|
||||
+14
-12
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -102,13 +103,13 @@ func TestSyncCommands_Golden(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
client.Close()
|
||||
|
||||
outBuf := testutil.NewWaitBuffer()
|
||||
// Start a goroutine to complete the dependency after a short delay
|
||||
// This simulates the dependency being satisfied while start is waiting
|
||||
// The delay ensures the "Waiting..." message appears in the output
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
if err := outBuf.WaitFor(ctx, "Waiting"); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
// Wait a moment to let the start command begin waiting and print the message
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
compCtx := context.Background()
|
||||
compClient, err := agentsocket.NewClient(compCtx, agentsocket.WithPath(path))
|
||||
@@ -118,7 +119,7 @@ func TestSyncCommands_Golden(t *testing.T) {
|
||||
}
|
||||
defer compClient.Close()
|
||||
|
||||
// Start and complete the dependency unit.
|
||||
// Start and complete the dependency unit
|
||||
err = compClient.SyncStart(compCtx, "dep-unit")
|
||||
if err != nil {
|
||||
done <- err
|
||||
@@ -128,20 +129,21 @@ func TestSyncCommands_Golden(t *testing.T) {
|
||||
done <- err
|
||||
}()
|
||||
|
||||
var outBuf bytes.Buffer
|
||||
inv, _ := clitest.New(t, "exp", "sync", "start", "test-unit", "--socket-path", path)
|
||||
inv.Stdout = outBuf
|
||||
inv.Stderr = outBuf
|
||||
inv.Stdout = &outBuf
|
||||
inv.Stderr = &outBuf
|
||||
|
||||
// Run the start command - it should wait for the dependency.
|
||||
// Run the start command - it should wait for the dependency
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ensure the completion goroutine finished.
|
||||
// Ensure the completion goroutine finished
|
||||
select {
|
||||
case err := <-done:
|
||||
require.NoError(t, err, "complete dependency")
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timed out waiting for dependency completion goroutine")
|
||||
case <-time.After(time.Second):
|
||||
// Goroutine should have finished by now
|
||||
}
|
||||
|
||||
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/start_with_dependencies", outBuf.Bytes(), nil)
|
||||
|
||||
+12
-12
@@ -41,11 +41,11 @@ func Test_TaskLogs_Golden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", setup.task.Name, "--output", "json")
|
||||
inv, root := clitest.New(t, "task", "logs", task.Name, "--output", "json")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
@@ -64,11 +64,11 @@ func Test_TaskLogs_Golden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", setup.task.ID.String(), "--output", "json")
|
||||
inv, root := clitest.New(t, "task", "logs", task.ID.String(), "--output", "json")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
@@ -87,11 +87,11 @@ func Test_TaskLogs_Golden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", setup.task.ID.String())
|
||||
inv, root := clitest.New(t, "task", "logs", task.ID.String())
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
@@ -141,10 +141,10 @@ func Test_TaskLogs_Golden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsErr(assert.AnError))
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsErr(assert.AnError))
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", setup.task.ID.String())
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
inv, root := clitest.New(t, "task", "logs", task.ID.String())
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
|
||||
+26
-22
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@@ -20,12 +21,12 @@ func TestExpTaskPause(t *testing.T) {
|
||||
|
||||
// Given: A running task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, nil)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
|
||||
// When: We attempt to pause the task
|
||||
inv, root := clitest.New(t, "task", "pause", setup.task.Name, "--yes")
|
||||
inv, root := clitest.New(t, "task", "pause", task.Name, "--yes")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// Then: Expect the task to be paused
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
@@ -33,7 +34,7 @@ func TestExpTaskPause(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, output.Stdout(), "has been paused")
|
||||
|
||||
updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name)
|
||||
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusPaused, updated.Status)
|
||||
})
|
||||
@@ -45,13 +46,13 @@ func TestExpTaskPause(t *testing.T) {
|
||||
|
||||
// Given: A different user's running task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, nil)
|
||||
adminClient, _, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
|
||||
// When: We attempt to pause their task
|
||||
identifier := fmt.Sprintf("%s/%s", setup.task.OwnerName, setup.task.Name)
|
||||
identifier := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
|
||||
inv, root := clitest.New(t, "task", "pause", identifier, "--yes")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, setup.ownerClient, root)
|
||||
clitest.SetupConfig(t, adminClient, root)
|
||||
|
||||
// Then: We expect the task to be paused
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
@@ -59,7 +60,7 @@ func TestExpTaskPause(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, output.Stdout(), "has been paused")
|
||||
|
||||
updated, err := setup.ownerClient.TaskByIdentifier(ctx, identifier)
|
||||
updated, err := adminClient.TaskByIdentifier(ctx, identifier)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusPaused, updated.Status)
|
||||
})
|
||||
@@ -69,11 +70,11 @@ func TestExpTaskPause(t *testing.T) {
|
||||
|
||||
// Given: A running task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, nil)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
|
||||
// When: We attempt to pause the task
|
||||
inv, root := clitest.New(t, "task", "pause", setup.task.Name)
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
inv, root := clitest.New(t, "task", "pause", task.Name)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// And: We confirm we want to pause the task
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
@@ -87,7 +88,7 @@ func TestExpTaskPause(t *testing.T) {
|
||||
pty.ExpectMatchContext(ctx, "has been paused")
|
||||
require.NoError(t, w.Wait())
|
||||
|
||||
updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name)
|
||||
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusPaused, updated.Status)
|
||||
})
|
||||
@@ -97,11 +98,11 @@ func TestExpTaskPause(t *testing.T) {
|
||||
|
||||
// Given: A running task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, nil)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
|
||||
// When: We attempt to pause the task
|
||||
inv, root := clitest.New(t, "task", "pause", setup.task.Name)
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
inv, root := clitest.New(t, "task", "pause", task.Name)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// But: We say no at the confirmation screen
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
@@ -113,7 +114,7 @@ func TestExpTaskPause(t *testing.T) {
|
||||
require.Error(t, w.Wait())
|
||||
|
||||
// Then: We expect the task to not be paused
|
||||
updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name)
|
||||
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, codersdk.TaskStatusPaused, updated.Status)
|
||||
})
|
||||
@@ -123,18 +124,21 @@ func TestExpTaskPause(t *testing.T) {
|
||||
|
||||
// Given: A running task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, nil)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
|
||||
// And: We paused the running task
|
||||
pauseTask(setupCtx, t, setup.userClient, setup.task)
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
resp, err := userClient.PauseTask(ctx, task.OwnerName, task.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.WorkspaceBuild)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, resp.WorkspaceBuild.ID)
|
||||
|
||||
// When: We attempt to pause the task again
|
||||
inv, root := clitest.New(t, "task", "pause", setup.task.Name, "--yes")
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
inv, root := clitest.New(t, "task", "pause", task.Name, "--yes")
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// Then: We expect to get an error that the task is already paused
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.ErrorContains(t, err, "is already paused")
|
||||
})
|
||||
}
|
||||
|
||||
+43
-31
@@ -1,6 +1,7 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
@@ -16,18 +17,29 @@ import (
|
||||
func TestExpTaskResume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// pauseTask is a helper that pauses a task and waits for the stop
|
||||
// build to complete.
|
||||
pauseTask := func(ctx context.Context, t *testing.T, client *codersdk.Client, task codersdk.Task) {
|
||||
t.Helper()
|
||||
|
||||
pauseResp, err := client.PauseTask(ctx, task.OwnerName, task.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pauseResp.WorkspaceBuild)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID)
|
||||
}
|
||||
|
||||
t.Run("WithYesFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: A paused task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, setup.userClient, setup.task)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, userClient, task)
|
||||
|
||||
// When: We attempt to resume the task
|
||||
inv, root := clitest.New(t, "task", "resume", setup.task.Name, "--yes")
|
||||
inv, root := clitest.New(t, "task", "resume", task.Name, "--yes")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// Then: We expect the task to be resumed
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
@@ -35,7 +47,7 @@ func TestExpTaskResume(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, output.Stdout(), "has been resumed")
|
||||
|
||||
updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name)
|
||||
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusInitializing, updated.Status)
|
||||
})
|
||||
@@ -47,14 +59,14 @@ func TestExpTaskResume(t *testing.T) {
|
||||
|
||||
// Given: A different user's paused task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, setup.userClient, setup.task)
|
||||
adminClient, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, userClient, task)
|
||||
|
||||
// When: We attempt to resume their task
|
||||
identifier := fmt.Sprintf("%s/%s", setup.task.OwnerName, setup.task.Name)
|
||||
identifier := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
|
||||
inv, root := clitest.New(t, "task", "resume", identifier, "--yes")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, setup.ownerClient, root)
|
||||
clitest.SetupConfig(t, adminClient, root)
|
||||
|
||||
// Then: We expect the task to be resumed
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
@@ -62,7 +74,7 @@ func TestExpTaskResume(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, output.Stdout(), "has been resumed")
|
||||
|
||||
updated, err := setup.ownerClient.TaskByIdentifier(ctx, identifier)
|
||||
updated, err := adminClient.TaskByIdentifier(ctx, identifier)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusInitializing, updated.Status)
|
||||
})
|
||||
@@ -72,13 +84,13 @@ func TestExpTaskResume(t *testing.T) {
|
||||
|
||||
// Given: A paused task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, setup.userClient, setup.task)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, userClient, task)
|
||||
|
||||
// When: We attempt to resume the task (and specify no wait)
|
||||
inv, root := clitest.New(t, "task", "resume", setup.task.Name, "--yes", "--no-wait")
|
||||
inv, root := clitest.New(t, "task", "resume", task.Name, "--yes", "--no-wait")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// Then: We expect the task to be resumed in the background
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
@@ -87,11 +99,11 @@ func TestExpTaskResume(t *testing.T) {
|
||||
require.Contains(t, output.Stdout(), "in the background")
|
||||
|
||||
// And: The task to eventually be resumed
|
||||
require.True(t, setup.task.WorkspaceID.Valid, "task should have a workspace ID")
|
||||
ws := coderdtest.MustWorkspace(t, setup.userClient, setup.task.WorkspaceID.UUID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.userClient, ws.LatestBuild.ID)
|
||||
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
|
||||
ws := coderdtest.MustWorkspace(t, userClient, task.WorkspaceID.UUID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, ws.LatestBuild.ID)
|
||||
|
||||
updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name)
|
||||
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusInitializing, updated.Status)
|
||||
})
|
||||
@@ -101,12 +113,12 @@ func TestExpTaskResume(t *testing.T) {
|
||||
|
||||
// Given: A paused task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, setup.userClient, setup.task)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, userClient, task)
|
||||
|
||||
// When: We attempt to resume the task
|
||||
inv, root := clitest.New(t, "task", "resume", setup.task.Name)
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
inv, root := clitest.New(t, "task", "resume", task.Name)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// And: We confirm we want to resume the task
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
@@ -120,7 +132,7 @@ func TestExpTaskResume(t *testing.T) {
|
||||
pty.ExpectMatchContext(ctx, "has been resumed")
|
||||
require.NoError(t, w.Wait())
|
||||
|
||||
updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name)
|
||||
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusInitializing, updated.Status)
|
||||
})
|
||||
@@ -130,12 +142,12 @@ func TestExpTaskResume(t *testing.T) {
|
||||
|
||||
// Given: A paused task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, setup.userClient, setup.task)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, userClient, task)
|
||||
|
||||
// When: We attempt to resume the task
|
||||
inv, root := clitest.New(t, "task", "resume", setup.task.Name)
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
inv, root := clitest.New(t, "task", "resume", task.Name)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// But: Say no at the confirmation screen
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
@@ -147,7 +159,7 @@ func TestExpTaskResume(t *testing.T) {
|
||||
require.Error(t, w.Wait())
|
||||
|
||||
// Then: We expect the task to still be paused
|
||||
updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name)
|
||||
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusPaused, updated.Status)
|
||||
})
|
||||
@@ -157,11 +169,11 @@ func TestExpTaskResume(t *testing.T) {
|
||||
|
||||
// Given: A running task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, nil)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
|
||||
// When: We attempt to resume the task that is not paused
|
||||
inv, root := clitest.New(t, "task", "resume", setup.task.Name, "--yes")
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
inv, root := clitest.New(t, "task", "resume", task.Name, "--yes")
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// Then: We expect to get an error that the task is not paused
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
+9
-154
@@ -1,15 +1,10 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
@@ -20,15 +15,13 @@ func (r *RootCmd) taskSend() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "send <task> [<input> | --stdin]",
|
||||
Short: "Send input to a task",
|
||||
Long: `Send input to a task. If the task is paused, it will be automatically resumed before input is sent. If the task is initializing, it will wait for the task to become ready.
|
||||
` +
|
||||
FormatExamples(Example{
|
||||
Description: "Send direct input to a task",
|
||||
Command: `coder 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 task send task1 --stdin`,
|
||||
}),
|
||||
Long: FormatExamples(Example{
|
||||
Description: "Send direct input to a task.",
|
||||
Command: "coder 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 task send task1 --stdin",
|
||||
}),
|
||||
Middleware: serpent.RequireRangeArgs(1, 2),
|
||||
Options: serpent.OptionSet{
|
||||
{
|
||||
@@ -71,48 +64,8 @@ func (r *RootCmd) taskSend() *serpent.Command {
|
||||
return xerrors.Errorf("resolve task: %w", err)
|
||||
}
|
||||
|
||||
display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
|
||||
|
||||
// Before attempting to send, check the task status and
|
||||
// handle non-active states.
|
||||
var workspaceBuildID uuid.UUID
|
||||
|
||||
switch task.Status {
|
||||
case codersdk.TaskStatusActive:
|
||||
// Already active, no build to watch.
|
||||
|
||||
case codersdk.TaskStatusPaused:
|
||||
resp, err := client.ResumeTask(ctx, task.OwnerName, task.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resume task %q: %w", display, err)
|
||||
} else if resp.WorkspaceBuild == nil {
|
||||
return xerrors.Errorf("resume task %q", display)
|
||||
}
|
||||
|
||||
workspaceBuildID = resp.WorkspaceBuild.ID
|
||||
|
||||
case codersdk.TaskStatusInitializing:
|
||||
if !task.WorkspaceID.Valid {
|
||||
return xerrors.Errorf("send input to task %q: task has no backing workspace", display)
|
||||
}
|
||||
|
||||
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace for task %q: %w", display, err)
|
||||
}
|
||||
|
||||
workspaceBuildID = workspace.LatestBuild.ID
|
||||
|
||||
default:
|
||||
return xerrors.Errorf("task %q has status %s and cannot be sent input", display, task.Status)
|
||||
}
|
||||
|
||||
if err := waitForTaskIdle(ctx, inv, client, task, workspaceBuildID); err != nil {
|
||||
return xerrors.Errorf("wait for task %q to be idle: %w", display, err)
|
||||
}
|
||||
|
||||
if err := client.TaskSend(ctx, codersdk.Me, task.ID, codersdk.TaskSendRequest{Input: taskInput}); err != nil {
|
||||
return xerrors.Errorf("send input to task %q: %w", display, err)
|
||||
if err = client.TaskSend(ctx, codersdk.Me, task.ID, codersdk.TaskSendRequest{Input: taskInput}); err != nil {
|
||||
return xerrors.Errorf("send input to task: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -121,101 +74,3 @@ func (r *RootCmd) taskSend() *serpent.Command {
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// waitForTaskIdle optionally watches a workspace build to completion,
|
||||
// then polls until the task becomes active and its app state is idle.
|
||||
// This merges build-watching and idle-polling into a single loop so
|
||||
// that status changes (e.g. paused) are never missed between phases.
|
||||
func waitForTaskIdle(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, task codersdk.Task, workspaceBuildID uuid.UUID) error {
|
||||
if workspaceBuildID != uuid.Nil {
|
||||
if err := cliui.WorkspaceBuild(ctx, inv.Stdout, client, workspaceBuildID); err != nil {
|
||||
return xerrors.Errorf("watch workspace build: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cliui.Infof(inv.Stdout, "Waiting for task to become idle...")
|
||||
|
||||
// NOTE(DanielleMaywood):
|
||||
// It has been observed that the `TaskStatusError` state has
|
||||
// appeared during a typical healthy startup [^0]. To combat
|
||||
// this, we allow a 5 minute grace period where we allow
|
||||
// `TaskStatusError` to surface without immediately failing.
|
||||
//
|
||||
// TODO(DanielleMaywood):
|
||||
// Remove this grace period once the upstream agentapi health
|
||||
// check no longer reports transient error states during normal
|
||||
// startup.
|
||||
//
|
||||
// [0]: https://github.com/coder/coder/pull/22203#discussion_r2858002569
|
||||
const errorGracePeriod = 5 * time.Minute
|
||||
gracePeriodDeadline := time.Now().Add(errorGracePeriod)
|
||||
|
||||
// NOTE(DanielleMaywood):
|
||||
// On resume the MCP may not report an initial app status,
|
||||
// leaving CurrentState nil indefinitely. To avoid hanging
|
||||
// forever we treat Active with nil CurrentState as idle
|
||||
// after a grace period, giving the MCP time to report
|
||||
// during normal startup.
|
||||
const nilStateGracePeriod = 30 * time.Second
|
||||
var nilStateDeadline time.Time
|
||||
|
||||
// TODO(DanielleMaywood):
|
||||
// When we have a streaming Task API, this should be converted
|
||||
// away from polling.
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
task, err := client.TaskByID(ctx, task.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get task by id: %w", err)
|
||||
}
|
||||
|
||||
switch task.Status {
|
||||
case codersdk.TaskStatusInitializing,
|
||||
codersdk.TaskStatusPending:
|
||||
// Not yet active, keep polling.
|
||||
continue
|
||||
case codersdk.TaskStatusActive:
|
||||
// Task is active; check app state.
|
||||
if task.CurrentState == nil {
|
||||
// The MCP may not have reported state yet.
|
||||
// Start a grace period on first observation
|
||||
// and treat as idle once it expires.
|
||||
if nilStateDeadline.IsZero() {
|
||||
nilStateDeadline = time.Now().Add(nilStateGracePeriod)
|
||||
}
|
||||
if time.Now().After(nilStateDeadline) {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Reset nil-state deadline since we got a real
|
||||
// state report.
|
||||
nilStateDeadline = time.Time{}
|
||||
switch task.CurrentState.State {
|
||||
case codersdk.TaskStateIdle,
|
||||
codersdk.TaskStateComplete,
|
||||
codersdk.TaskStateFailed:
|
||||
return nil
|
||||
default:
|
||||
// Still working, keep polling.
|
||||
continue
|
||||
}
|
||||
case codersdk.TaskStatusError:
|
||||
if time.Now().After(gracePeriodDeadline) {
|
||||
return xerrors.Errorf("task entered %s state while waiting for it to become idle", task.Status)
|
||||
}
|
||||
case codersdk.TaskStatusPaused:
|
||||
return xerrors.Errorf("task was paused while waiting for it to become idle")
|
||||
case codersdk.TaskStatusUnknown:
|
||||
return xerrors.Errorf("task entered %s state while waiting for it to become idle", task.Status)
|
||||
default:
|
||||
return xerrors.Errorf("task entered unexpected state (%s) while waiting for it to become idle", task.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-224
@@ -12,14 +12,9 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
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/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
@@ -30,12 +25,12 @@ func Test_TaskSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "send", setup.task.Name, "carry on with the task")
|
||||
inv, root := clitest.New(t, "task", "send", task.Name, "carry on with the task")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
@@ -46,12 +41,12 @@ func Test_TaskSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "send", setup.task.ID.String(), "carry on with the task")
|
||||
inv, root := clitest.New(t, "task", "send", task.ID.String(), "carry on with the task")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
@@ -62,13 +57,13 @@ func Test_TaskSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "send", setup.task.Name, "--stdin")
|
||||
inv, root := clitest.New(t, "task", "send", task.Name, "--stdin")
|
||||
inv.Stdout = &stdout
|
||||
inv.Stdin = strings.NewReader("carry on with the task")
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
@@ -115,223 +110,17 @@ func Test_TaskSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendErr(assert.AnError))
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendErr(t, assert.AnError))
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input")
|
||||
inv, root := clitest.New(t, "task", "send", task.Name, "some task input")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.ErrorContains(t, err, assert.AnError.Error())
|
||||
})
|
||||
|
||||
t.Run("WaitsForInitializingTask", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "some task input", "some task response"))
|
||||
|
||||
// Close the first agent, pause, then resume the task so the
|
||||
// workspace is started but no agent is connected.
|
||||
// This puts the task in "initializing" state.
|
||||
require.NoError(t, setup.agent.Close())
|
||||
pauseTask(setupCtx, t, setup.userClient, setup.task)
|
||||
resumeTask(setupCtx, t, setup.userClient, setup.task)
|
||||
|
||||
// When: We attempt to send input to the initializing task.
|
||||
inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input")
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
inv = inv.WithContext(ctx)
|
||||
|
||||
// Use a pty so we can wait for the command to produce build
|
||||
// output, confirming it has entered the initializing code
|
||||
// path before we connect the agent.
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
|
||||
// Wait for the command to observe the initializing state and
|
||||
// start watching the workspace build. This ensures the command
|
||||
// has entered the waiting code path.
|
||||
pty.ExpectMatchContext(ctx, "Queued")
|
||||
|
||||
// Connect a new agent so the task can transition to active.
|
||||
agentClient := agentsdk.New(setup.userClient.URL, agentsdk.WithFixedToken(setup.agentToken))
|
||||
setup.agent = agenttest.New(t, setup.userClient.URL, setup.agentToken, func(o *agent.Options) {
|
||||
o.Client = agentClient
|
||||
})
|
||||
coderdtest.NewWorkspaceAgentWaiter(t, setup.userClient, setup.task.WorkspaceID.UUID).
|
||||
WaitFor(coderdtest.AgentsReady)
|
||||
|
||||
// Report the task app as idle so waitForTaskIdle can proceed.
|
||||
require.NoError(t, agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{
|
||||
AppSlug: "task-sidebar",
|
||||
State: codersdk.WorkspaceAppStatusStateIdle,
|
||||
Message: "ready",
|
||||
}))
|
||||
|
||||
// Then: The command should complete successfully.
|
||||
require.NoError(t, w.Wait())
|
||||
|
||||
updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusActive, updated.Status)
|
||||
})
|
||||
|
||||
t.Run("ResumesPausedTask", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "some task input", "some task response"))
|
||||
|
||||
// Close the first agent before pausing so it does not conflict
|
||||
// with the agent we reconnect after the workspace is resumed.
|
||||
require.NoError(t, setup.agent.Close())
|
||||
pauseTask(setupCtx, t, setup.userClient, setup.task)
|
||||
|
||||
// When: We attempt to send input to the paused task.
|
||||
inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input")
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
inv = inv.WithContext(ctx)
|
||||
|
||||
// Use a pty so we can wait for the command to produce build
|
||||
// output, confirming it has entered the paused code path and
|
||||
// triggered a resume before we connect the agent.
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
|
||||
// Wait for the command to observe the paused state, trigger
|
||||
// a resume, and start watching the workspace build.
|
||||
pty.ExpectMatchContext(ctx, "Queued")
|
||||
|
||||
// Connect a new agent so the task can transition to active.
|
||||
agentClient := agentsdk.New(setup.userClient.URL, agentsdk.WithFixedToken(setup.agentToken))
|
||||
setup.agent = agenttest.New(t, setup.userClient.URL, setup.agentToken, func(o *agent.Options) {
|
||||
o.Client = agentClient
|
||||
})
|
||||
coderdtest.NewWorkspaceAgentWaiter(t, setup.userClient, setup.task.WorkspaceID.UUID).
|
||||
WaitFor(coderdtest.AgentsReady)
|
||||
|
||||
// Report the task app as idle so waitForTaskIdle can proceed.
|
||||
require.NoError(t, agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{
|
||||
AppSlug: "task-sidebar",
|
||||
State: codersdk.WorkspaceAppStatusStateIdle,
|
||||
Message: "ready",
|
||||
}))
|
||||
|
||||
// Then: The command should complete successfully.
|
||||
require.NoError(t, w.Wait())
|
||||
|
||||
updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusActive, updated.Status)
|
||||
})
|
||||
|
||||
t.Run("PausedDuringWaitForReady", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: An initializing task (workspace running, no agent
|
||||
// connected).
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, nil)
|
||||
|
||||
require.NoError(t, setup.agent.Close())
|
||||
pauseTask(setupCtx, t, setup.userClient, setup.task)
|
||||
resumeTask(setupCtx, t, setup.userClient, setup.task)
|
||||
|
||||
// When: We attempt to send input to the initializing task.
|
||||
inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input")
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
inv = inv.WithContext(ctx)
|
||||
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
|
||||
// Wait for the command to enter the build-watching phase
|
||||
// of waitForTaskReady.
|
||||
pty.ExpectMatchContext(ctx, "Queued")
|
||||
|
||||
// Pause the task while waitForTaskReady is polling. Since
|
||||
// no agent is connected, the task stays initializing until
|
||||
// we pause it, at which point the status becomes paused.
|
||||
pauseTask(ctx, t, setup.userClient, setup.task)
|
||||
|
||||
// Then: The command should fail because the task was paused.
|
||||
err := w.Wait()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "was paused while waiting for it to become idle")
|
||||
})
|
||||
|
||||
t.Run("WaitsForWorkingAppState", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: An active task whose app is in "working" state.
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "some task input", "some task response"))
|
||||
|
||||
// Move the app into "working" state before running the command.
|
||||
agentClient := agentsdk.New(setup.userClient.URL, agentsdk.WithFixedToken(setup.agentToken))
|
||||
require.NoError(t, agentClient.PatchAppStatus(setupCtx, agentsdk.PatchAppStatus{
|
||||
AppSlug: "task-sidebar",
|
||||
State: codersdk.WorkspaceAppStatusStateWorking,
|
||||
Message: "busy",
|
||||
}))
|
||||
|
||||
// When: We send input while the app is working.
|
||||
inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input")
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
inv = inv.WithContext(ctx)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
|
||||
// Transition the app back to idle so waitForTaskIdle proceeds.
|
||||
require.NoError(t, agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{
|
||||
AppSlug: "task-sidebar",
|
||||
State: codersdk.WorkspaceAppStatusStateIdle,
|
||||
Message: "ready",
|
||||
}))
|
||||
|
||||
// Then: The command should complete successfully.
|
||||
require.NoError(t, w.Wait())
|
||||
})
|
||||
|
||||
t.Run("SendToNonIdleAppState", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, appState := range []codersdk.WorkspaceAppStatusState{
|
||||
codersdk.WorkspaceAppStatusStateComplete,
|
||||
codersdk.WorkspaceAppStatusStateFailure,
|
||||
} {
|
||||
t.Run(string(appState), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "some input", "some response"))
|
||||
|
||||
agentClient := agentsdk.New(setup.userClient.URL, agentsdk.WithFixedToken(setup.agentToken))
|
||||
require.NoError(t, agentClient.PatchAppStatus(setupCtx, agentsdk.PatchAppStatus{
|
||||
AppSlug: "task-sidebar",
|
||||
State: appState,
|
||||
Message: "done",
|
||||
}))
|
||||
|
||||
inv, root := clitest.New(t, "task", "send", setup.task.Name, "some input")
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func fakeAgentAPITaskSendOK(t *testing.T, expectMessage, returnMessage string) map[string]http.HandlerFunc {
|
||||
@@ -362,7 +151,7 @@ func fakeAgentAPITaskSendOK(t *testing.T, expectMessage, returnMessage string) m
|
||||
}
|
||||
}
|
||||
|
||||
func fakeAgentAPITaskSendErr(returnErr error) map[string]http.HandlerFunc {
|
||||
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")
|
||||
|
||||
+5
-5
@@ -90,7 +90,7 @@ func (r *RootCmd) taskStatus() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
tsr := toStatusRow(task, r.clock.Now())
|
||||
tsr := toStatusRow(task)
|
||||
out, err := formatter.Format(ctx, []taskStatusRow{tsr})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("format task status: %w", err)
|
||||
@@ -112,7 +112,7 @@ func (r *RootCmd) taskStatus() *serpent.Command {
|
||||
}
|
||||
|
||||
// Only print if something changed
|
||||
newStatusRow := toStatusRow(task, r.clock.Now())
|
||||
newStatusRow := toStatusRow(task)
|
||||
if !taskStatusRowEqual(lastStatusRow, newStatusRow) {
|
||||
out, err := formatter.Format(ctx, []taskStatusRow{newStatusRow})
|
||||
if err != nil {
|
||||
@@ -166,10 +166,10 @@ func taskStatusRowEqual(r1, r2 taskStatusRow) bool {
|
||||
taskStateEqual(r1.CurrentState, r2.CurrentState)
|
||||
}
|
||||
|
||||
func toStatusRow(task codersdk.Task, now time.Time) taskStatusRow {
|
||||
func toStatusRow(task codersdk.Task) taskStatusRow {
|
||||
tsr := taskStatusRow{
|
||||
Task: task,
|
||||
ChangedAgo: now.Sub(task.UpdatedAt).Truncate(time.Second).String() + " ago",
|
||||
ChangedAgo: time.Since(task.UpdatedAt).Truncate(time.Second).String() + " ago",
|
||||
}
|
||||
tsr.Healthy = task.WorkspaceAgentHealth != nil &&
|
||||
task.WorkspaceAgentHealth.Healthy &&
|
||||
@@ -178,7 +178,7 @@ func toStatusRow(task codersdk.Task, now time.Time) taskStatusRow {
|
||||
!task.WorkspaceAgentLifecycle.ShuttingDown()
|
||||
|
||||
if task.CurrentState != nil {
|
||||
tsr.ChangedAgo = now.Sub(task.CurrentState.Timestamp).Truncate(time.Second).String() + " ago"
|
||||
tsr.ChangedAgo = time.Since(task.CurrentState.Timestamp).Truncate(time.Second).String() + " ago"
|
||||
}
|
||||
return tsr
|
||||
}
|
||||
|
||||
+9
-12
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
func Test_TaskStatus(t *testing.T) {
|
||||
@@ -29,12 +28,12 @@ func Test_TaskStatus(t *testing.T) {
|
||||
args []string
|
||||
expectOutput string
|
||||
expectError string
|
||||
hf func(context.Context, quartz.Clock) func(http.ResponseWriter, *http.Request)
|
||||
hf func(context.Context, time.Time) func(http.ResponseWriter, *http.Request)
|
||||
}{
|
||||
{
|
||||
args: []string{"doesnotexist"},
|
||||
expectError: httpapi.ResourceNotFoundResponse.Message,
|
||||
hf: func(ctx context.Context, _ quartz.Clock) func(w http.ResponseWriter, r *http.Request) {
|
||||
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/tasks/me/doesnotexist":
|
||||
@@ -50,8 +49,7 @@ func Test_TaskStatus(t *testing.T) {
|
||||
args: []string{"exists"},
|
||||
expectOutput: `STATE CHANGED STATUS HEALTHY STATE MESSAGE
|
||||
0s ago active true working Thinking furiously...`,
|
||||
hf: func(ctx context.Context, clk quartz.Clock) func(w http.ResponseWriter, r *http.Request) {
|
||||
now := clk.Now()
|
||||
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/v2/tasks/me/exists":
|
||||
@@ -86,8 +84,7 @@ func Test_TaskStatus(t *testing.T) {
|
||||
4s ago active true
|
||||
3s ago active true working Reticulating splines...
|
||||
2s ago active true complete Splines reticulated successfully!`,
|
||||
hf: func(ctx context.Context, clk quartz.Clock) func(http.ResponseWriter, *http.Request) {
|
||||
now := clk.Now()
|
||||
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) {
|
||||
switch r.URL.Path {
|
||||
@@ -218,7 +215,7 @@ 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, _ quartz.Clock) func(http.ResponseWriter, *http.Request) {
|
||||
hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *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 {
|
||||
@@ -255,8 +252,8 @@ func Test_TaskStatus(t *testing.T) {
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
mClock = quartz.NewMock(t)
|
||||
srv = httptest.NewServer(http.HandlerFunc(tc.hf(ctx, mClock)))
|
||||
now = time.Now().UTC() // TODO: replace with quartz
|
||||
srv = httptest.NewServer(http.HandlerFunc(tc.hf(ctx, now)))
|
||||
client = codersdk.New(testutil.MustURL(t, srv.URL))
|
||||
sb = strings.Builder{}
|
||||
args = []string{"task", "status", "--watch-interval", testutil.IntervalFast.String()}
|
||||
@@ -264,10 +261,10 @@ func Test_TaskStatus(t *testing.T) {
|
||||
|
||||
t.Cleanup(srv.Close)
|
||||
args = append(args, tc.args...)
|
||||
inv, cfgDir := clitest.NewWithClock(t, mClock, args...)
|
||||
inv, root := clitest.New(t, args...)
|
||||
inv.Stdout = &sb
|
||||
inv.Stderr = &sb
|
||||
clitest.SetupConfig(t, client, cfgDir)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
if tc.expectError == "" {
|
||||
assert.NoError(t, err)
|
||||
|
||||
+5
-56
@@ -88,13 +88,6 @@ func Test_Tasks(t *testing.T) {
|
||||
o.Client = agentClient
|
||||
})
|
||||
coderdtest.NewWorkspaceAgentWaiter(t, userClient, tasks[0].WorkspaceID.UUID).WithContext(ctx).WaitFor(coderdtest.AgentsReady)
|
||||
// Report the task app as idle so that waitForTaskIdle
|
||||
// can proceed during the "send task message" step.
|
||||
require.NoError(t, agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{
|
||||
AppSlug: "task-sidebar",
|
||||
State: codersdk.WorkspaceAppStatusStateIdle,
|
||||
Message: "ready",
|
||||
}))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -279,19 +272,10 @@ func fakeAgentAPIEcho(ctx context.Context, t testing.TB, initMsg agentapisdk.Mes
|
||||
// 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.
|
||||
// setupCLITaskTestResult holds the return values from setupCLITaskTest.
|
||||
type setupCLITaskTestResult struct {
|
||||
ownerClient *codersdk.Client
|
||||
userClient *codersdk.Client
|
||||
task codersdk.Task
|
||||
agentToken string
|
||||
agent agent.Agent
|
||||
}
|
||||
|
||||
func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) setupCLITaskTestResult {
|
||||
func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (ownerClient *codersdk.Client, memberClient *codersdk.Client, task codersdk.Task) {
|
||||
t.Helper()
|
||||
|
||||
ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
ownerClient = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
|
||||
@@ -308,56 +292,21 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for the task's underlying workspace to be built.
|
||||
// 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, userClient, workspace.LatestBuild.ID)
|
||||
|
||||
agentClient := agentsdk.New(userClient.URL, agentsdk.WithFixedToken(authToken))
|
||||
agt := agenttest.New(t, userClient.URL, authToken, func(o *agent.Options) {
|
||||
_ = agenttest.New(t, userClient.URL, authToken, func(o *agent.Options) {
|
||||
o.Client = agentClient
|
||||
})
|
||||
|
||||
coderdtest.NewWorkspaceAgentWaiter(t, userClient, workspace.ID).
|
||||
WaitFor(coderdtest.AgentsReady)
|
||||
|
||||
// Report the task app as idle so that waitForTaskIdle can proceed.
|
||||
err = agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{
|
||||
AppSlug: "task-sidebar",
|
||||
State: codersdk.WorkspaceAppStatusStateIdle,
|
||||
Message: "ready",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return setupCLITaskTestResult{
|
||||
ownerClient: ownerClient,
|
||||
userClient: userClient,
|
||||
task: task,
|
||||
agentToken: authToken,
|
||||
agent: agt,
|
||||
}
|
||||
}
|
||||
|
||||
// pauseTask pauses the task and waits for the stop build to complete.
|
||||
func pauseTask(ctx context.Context, t *testing.T, client *codersdk.Client, task codersdk.Task) {
|
||||
t.Helper()
|
||||
|
||||
pauseResp, err := client.PauseTask(ctx, task.OwnerName, task.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pauseResp.WorkspaceBuild)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID)
|
||||
}
|
||||
|
||||
// resumeTask resumes the task waits for the start build to complete. The task
|
||||
// will be in "initializing" state after this returns because no agent is connected.
|
||||
func resumeTask(ctx context.Context, t *testing.T, client *codersdk.Client, task codersdk.Task) {
|
||||
t.Helper()
|
||||
|
||||
resumeResp, err := client.ResumeTask(ctx, task.OwnerName, task.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resumeResp.WorkspaceBuild)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, resumeResp.WorkspaceBuild.ID)
|
||||
return ownerClient, userClient, task
|
||||
}
|
||||
|
||||
// setupCLITaskTestWithSnapshot creates a task in the specified status with a log snapshot.
|
||||
|
||||
-4
@@ -20,10 +20,6 @@ OPTIONS:
|
||||
--copy-parameters-from string, $CODER_WORKSPACE_COPY_PARAMETERS_FROM
|
||||
Specify the source workspace name to copy parameters from.
|
||||
|
||||
--no-wait bool, $CODER_CREATE_NO_WAIT
|
||||
Return immediately after creating the workspace. The build will run in
|
||||
the background.
|
||||
|
||||
--parameter string-array, $CODER_RICH_PARAMETER
|
||||
Rich parameter value in the format "name=value".
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"last_seen_at": "====[timestamp]=====",
|
||||
"name": "test-daemon",
|
||||
"version": "v0.0.0-devel",
|
||||
"api_version": "1.16",
|
||||
"api_version": "1.15",
|
||||
"provisioners": [
|
||||
"echo"
|
||||
],
|
||||
|
||||
+5
@@ -143,6 +143,11 @@ AI BRIDGE OPTIONS:
|
||||
--aibridge-enabled bool, $CODER_AIBRIDGE_ENABLED (default: false)
|
||||
Whether to start an in-memory aibridged instance.
|
||||
|
||||
--aibridge-inject-coder-mcp-tools bool, $CODER_AIBRIDGE_INJECT_CODER_MCP_TOOLS (default: false)
|
||||
Whether to inject Coder's MCP tools into intercepted AI Bridge
|
||||
requests (requires the "oauth2" and "mcp-server-http" experiments to
|
||||
be enabled).
|
||||
|
||||
--aibridge-max-concurrency int, $CODER_AIBRIDGE_MAX_CONCURRENCY (default: 0)
|
||||
Maximum number of concurrent AI Bridge requests per replica. Set to 0
|
||||
to disable (unlimited).
|
||||
|
||||
+2
-5
@@ -5,14 +5,11 @@ USAGE:
|
||||
|
||||
Send input to a task
|
||||
|
||||
Send input to a task. If the task is paused, it will be automatically resumed
|
||||
before input is sent. If the task is initializing, it will wait for the task
|
||||
to become ready.
|
||||
- Send direct input to a task:
|
||||
- Send direct input to a task.:
|
||||
|
||||
$ coder task send task1 "Please also add unit tests"
|
||||
|
||||
- Send input from stdin to a task:
|
||||
- Send input from stdin to a task.:
|
||||
|
||||
$ echo "Please also add unit tests" | coder task send task1 --stdin
|
||||
|
||||
|
||||
@@ -24,10 +24,6 @@ OPTIONS:
|
||||
-p, --password string
|
||||
Specifies a password for the new user.
|
||||
|
||||
--service-account bool
|
||||
Create a user account intended to be used by a service or as an
|
||||
intermediary rather than by a human.
|
||||
|
||||
-u, --username string
|
||||
Specifies a username for the new user.
|
||||
|
||||
|
||||
+2
-9
@@ -752,11 +752,6 @@ workspace_prebuilds:
|
||||
# limit; disabled when set to zero.
|
||||
# (default: 3, type: int)
|
||||
failure_hard_limit: 3
|
||||
# Configure the background chat processing daemon.
|
||||
chat:
|
||||
# How many pending chats a worker should acquire per polling cycle.
|
||||
# (default: 10, type: int)
|
||||
acquireBatchSize: 10
|
||||
aibridge:
|
||||
# Whether to start an in-memory aibridged instance.
|
||||
# (default: false, type: bool)
|
||||
@@ -783,10 +778,8 @@ aibridge:
|
||||
# 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
|
||||
# Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a
|
||||
# future release. Whether to inject Coder's MCP tools into intercepted AI Bridge
|
||||
# requests (requires the "oauth2" and "mcp-server-http" experiments to be
|
||||
# enabled).
|
||||
# Whether to inject Coder's MCP tools into intercepted AI Bridge requests
|
||||
# (requires the "oauth2" and "mcp-server-http" experiments to be enabled).
|
||||
# (default: false, type: bool)
|
||||
inject_coder_mcp_tools: false
|
||||
# Length of time to retain data such as interceptions and all related records
|
||||
|
||||
+2
-13
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
@@ -185,22 +184,12 @@ func TestTokens(t *testing.T) {
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf = new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
|
||||
// Precondition: validate token is not expired before expiring
|
||||
var expiredAtBefore time.Time
|
||||
token, err := client.APIKeyByName(ctx, secondUser.ID.String(), "token-two")
|
||||
require.NoError(t, err)
|
||||
now := dbtime.Now()
|
||||
require.True(t, token.ExpiresAt.After(now), "token should not be expired yet (expiresAt=%s, now=%s)", token.ExpiresAt.UTC(), now)
|
||||
expiredAtBefore = token.ExpiresAt
|
||||
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
// Validate that token was expired
|
||||
if token, err := client.APIKeyByName(ctx, secondUser.ID.String(), "token-two"); assert.NoError(t, err) {
|
||||
now := dbtime.Now()
|
||||
require.NotEqual(t, token.ExpiresAt, expiredAtBefore, "token expiresAt is the same as before expiring, but should have been updated")
|
||||
require.False(t, token.ExpiresAt.After(now), "token expiresAt should not be in the future after expiring, but was %s (now=%s)", token.ExpiresAt.UTC(), now)
|
||||
now := time.Now()
|
||||
require.False(t, token.ExpiresAt.After(now), "token expiresAt should not be in the future, but was %s (now=%s)", token.ExpiresAt, now)
|
||||
}
|
||||
|
||||
// Delete by ID (explicit delete flag)
|
||||
|
||||
+12
-37
@@ -17,14 +17,13 @@ import (
|
||||
|
||||
func (r *RootCmd) userCreate() *serpent.Command {
|
||||
var (
|
||||
email string
|
||||
username string
|
||||
name string
|
||||
password string
|
||||
disableLogin bool
|
||||
loginType string
|
||||
serviceAccount bool
|
||||
orgContext = NewOrganizationContext()
|
||||
email string
|
||||
username string
|
||||
name string
|
||||
password string
|
||||
disableLogin bool
|
||||
loginType string
|
||||
orgContext = NewOrganizationContext()
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Use: "create",
|
||||
@@ -33,23 +32,6 @@ func (r *RootCmd) userCreate() *serpent.Command {
|
||||
serpent.RequireNArgs(0),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
if serviceAccount {
|
||||
switch {
|
||||
case loginType != "":
|
||||
return xerrors.New("You cannot use --login-type with --service-account")
|
||||
case password != "":
|
||||
return xerrors.New("You cannot use --password with --service-account")
|
||||
case email != "":
|
||||
return xerrors.New("You cannot use --email with --service-account")
|
||||
case disableLogin:
|
||||
return xerrors.New("You cannot use --disable-login with --service-account")
|
||||
}
|
||||
}
|
||||
|
||||
if disableLogin && loginType != "" {
|
||||
return xerrors.New("You cannot specify both --disable-login and --login-type")
|
||||
}
|
||||
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -77,7 +59,7 @@ func (r *RootCmd) userCreate() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if email == "" && !serviceAccount {
|
||||
if email == "" {
|
||||
email, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Email:",
|
||||
Validate: func(s string) error {
|
||||
@@ -105,7 +87,10 @@ func (r *RootCmd) userCreate() *serpent.Command {
|
||||
}
|
||||
}
|
||||
userLoginType := codersdk.LoginTypePassword
|
||||
if disableLogin || serviceAccount {
|
||||
if disableLogin && loginType != "" {
|
||||
return xerrors.New("You cannot specify both --disable-login and --login-type")
|
||||
}
|
||||
if disableLogin {
|
||||
userLoginType = codersdk.LoginTypeNone
|
||||
} else if loginType != "" {
|
||||
userLoginType = codersdk.LoginType(loginType)
|
||||
@@ -126,7 +111,6 @@ func (r *RootCmd) userCreate() *serpent.Command {
|
||||
Password: password,
|
||||
OrganizationIDs: []uuid.UUID{organization.ID},
|
||||
UserLoginType: userLoginType,
|
||||
ServiceAccount: serviceAccount,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -143,10 +127,6 @@ func (r *RootCmd) userCreate() *serpent.Command {
|
||||
case codersdk.LoginTypeOIDC:
|
||||
authenticationMethod = `Login is authenticated through the configured OIDC provider.`
|
||||
}
|
||||
if serviceAccount {
|
||||
email = "n/a"
|
||||
authenticationMethod = "Service accounts must authenticate with a token and cannot log in."
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, `A new user has been created!
|
||||
Share the instructions below to get them started.
|
||||
@@ -214,11 +194,6 @@ Create a workspace `+pretty.Sprint(cliui.DefaultStyles.Code, "coder create")+`!
|
||||
)),
|
||||
Value: serpent.StringOf(&loginType),
|
||||
},
|
||||
{
|
||||
Flag: "service-account",
|
||||
Description: "Create a user account intended to be used by a service or as an intermediary rather than by a human.",
|
||||
Value: serpent.BoolOf(&serviceAccount),
|
||||
},
|
||||
}
|
||||
|
||||
orgContext.AttachOptions(cmd)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
@@ -125,56 +124,4 @@ func TestUserCreate(t *testing.T) {
|
||||
assert.Equal(t, args[5], created.Username)
|
||||
assert.Empty(t, created.Name)
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ServiceAccount",
|
||||
args: []string{"--service-account", "-u", "dean"},
|
||||
},
|
||||
{
|
||||
name: "ServiceAccountLoginType",
|
||||
args: []string{"--service-account", "-u", "dean", "--login-type", "none"},
|
||||
err: "You cannot use --login-type with --service-account",
|
||||
},
|
||||
{
|
||||
name: "ServiceAccountDisableLogin",
|
||||
args: []string{"--service-account", "-u", "dean", "--disable-login"},
|
||||
err: "You cannot use --disable-login with --service-account",
|
||||
},
|
||||
{
|
||||
name: "ServiceAccountEmail",
|
||||
args: []string{"--service-account", "-u", "dean", "--email", "dean@coder.com"},
|
||||
err: "You cannot use --email with --service-account",
|
||||
},
|
||||
{
|
||||
name: "ServiceAccountPassword",
|
||||
args: []string{"--service-account", "-u", "dean", "--password", "1n5ecureP4ssw0rd!"},
|
||||
err: "You cannot use --password with --service-account",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
inv, root := clitest.New(t, append([]string{"users", "create"}, tt.args...)...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
err := inv.Run()
|
||||
if tt.err == "" {
|
||||
require.NoError(t, err)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
created, err := client.User(ctx, "dean")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, codersdk.LoginTypeNone, created.LoginType)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, tt.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,10 +116,10 @@ func TestWorkspaceActivityBump(t *testing.T) {
|
||||
// is required. The Activity Bump behavior is also coupled with
|
||||
// Last Used, so it would be obvious to the user if we
|
||||
// are falsely recognizing activity.
|
||||
require.Never(t, func() bool {
|
||||
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||
return err == nil && !workspace.LatestBuild.Deadline.Time.Equal(firstDeadline)
|
||||
}, testutil.IntervalMedium, testutil.IntervalFast, "deadline should not change")
|
||||
time.Sleep(testutil.IntervalMedium)
|
||||
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, workspace.LatestBuild.Deadline.Time, firstDeadline)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -134,12 +134,9 @@ func (a *LifecycleAPI) UpdateLifecycle(ctx context.Context, req *agentproto.Upda
|
||||
case database.WorkspaceAgentLifecycleStateReady,
|
||||
database.WorkspaceAgentLifecycleStateStartTimeout,
|
||||
database.WorkspaceAgentLifecycleStateStartError:
|
||||
// Only emit metrics for the parent agent, this metric is not intended to measure devcontainer durations.
|
||||
if !workspaceAgent.ParentID.Valid {
|
||||
a.emitMetricsOnce.Do(func() {
|
||||
a.emitBuildDurationMetric(ctx, workspaceAgent.ResourceID)
|
||||
})
|
||||
}
|
||||
a.emitMetricsOnce.Do(func() {
|
||||
a.emitBuildDurationMetric(ctx, workspaceAgent.ResourceID)
|
||||
})
|
||||
}
|
||||
|
||||
return req.Lifecycle, nil
|
||||
|
||||
@@ -582,64 +582,6 @@ func TestUpdateLifecycle(t *testing.T) {
|
||||
require.Equal(t, uint64(1), got.GetSampleCount())
|
||||
require.Equal(t, expectedDuration, got.GetSampleSum())
|
||||
})
|
||||
|
||||
t.Run("SubAgentDoesNotEmitMetric", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
parentID := uuid.New()
|
||||
subAgent := database.WorkspaceAgent{
|
||||
ID: uuid.New(),
|
||||
ParentID: uuid.NullUUID{UUID: parentID, Valid: true},
|
||||
LifecycleState: database.WorkspaceAgentLifecycleStateStarting,
|
||||
StartedAt: sql.NullTime{Valid: true, Time: someTime},
|
||||
ReadyAt: sql.NullTime{Valid: false},
|
||||
}
|
||||
lifecycle := &agentproto.Lifecycle{
|
||||
State: agentproto.Lifecycle_READY,
|
||||
ChangedAt: timestamppb.New(now),
|
||||
}
|
||||
dbM := dbmock.NewMockStore(gomock.NewController(t))
|
||||
dbM.EXPECT().UpdateWorkspaceAgentLifecycleStateByID(gomock.Any(), database.UpdateWorkspaceAgentLifecycleStateByIDParams{
|
||||
ID: subAgent.ID,
|
||||
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
|
||||
StartedAt: subAgent.StartedAt,
|
||||
ReadyAt: sql.NullTime{
|
||||
Time: now,
|
||||
Valid: true,
|
||||
},
|
||||
}).Return(nil)
|
||||
// GetWorkspaceBuildMetricsByResourceID should NOT be called
|
||||
// because sub-agents should be skipped before querying.
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := agentapi.NewLifecycleMetrics(reg)
|
||||
api := &agentapi.LifecycleAPI{
|
||||
AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) {
|
||||
return subAgent, nil
|
||||
},
|
||||
WorkspaceID: workspaceID,
|
||||
Database: dbM,
|
||||
Log: testutil.Logger(t),
|
||||
Metrics: metrics,
|
||||
PublishWorkspaceUpdateFn: nil,
|
||||
}
|
||||
resp, err := api.UpdateLifecycle(context.Background(), &agentproto.UpdateLifecycleRequest{
|
||||
Lifecycle: lifecycle,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, lifecycle, resp)
|
||||
|
||||
// We don't expect the metric to be emitted for sub-agents, by default this will fail anyway but it doesn't hurt
|
||||
// to document the test explicitly.
|
||||
dbM.EXPECT().GetWorkspaceBuildMetricsByResourceID(gomock.Any(), gomock.Any()).Times(0)
|
||||
|
||||
// If we were emitting the metric we would have failed by now since it would include a call to the database that we're not expecting.
|
||||
pm, err := reg.Gather()
|
||||
require.NoError(t, err)
|
||||
for _, m := range pm {
|
||||
if m.GetName() == fullMetricName {
|
||||
t.Fatal("metric should not be emitted for sub-agent")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateStartup(t *testing.T) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user