Compare commits
390 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c3144fc32 | |||
| b5360a9180 | |||
| 2e2d0dde44 | |||
| 2314e4a94e | |||
| bd76c602e4 | |||
| 59cdd7e21f | |||
| ba71b321bc | |||
| c94c470aae | |||
| 8430dd648a | |||
| 0bd0990e14 | |||
| 10e70f8c51 | |||
| abe66a38eb | |||
| cd9d3ef46f | |||
| c0a2522bd6 | |||
| dfa25d5f00 | |||
| c87c33f7dd | |||
| a9261577bc | |||
| 9c2f94b574 | |||
| 6238a99275 | |||
| c266bb830c | |||
| b0e8384b82 | |||
| 956cbe7751 | |||
| 4863812d8c | |||
| e340560164 | |||
| e189dc1f81 | |||
| 2f399eafae | |||
| 02bac71421 | |||
| b255827a52 | |||
| 37fc6646ad | |||
| 5213023fe5 | |||
| ae2c94b322 | |||
| ad8ba4aac6 | |||
| 3011207519 | |||
| e8bf074022 | |||
| 7fd9a450c1 | |||
| 82f525baf3 | |||
| afd40436f0 | |||
| 823009d9ea | |||
| c12303f0b2 | |||
| 658e8c34a9 | |||
| ca560d36ce | |||
| 6c2900f138 | |||
| f08cb2f059 | |||
| 3847f3b297 | |||
| 42a24b7334 | |||
| ba242b5e77 | |||
| 0c43789f3a | |||
| cefe07d074 | |||
| c6631e1e50 | |||
| 6882c43b39 | |||
| 6d41bfad81 | |||
| bc4838dc88 | |||
| 636408906f | |||
| 8c83ab90cf | |||
| cf11996640 | |||
| 19d11f100b | |||
| 09393f2746 | |||
| 754ffb243e | |||
| 443b0c851d | |||
| a6581c7157 | |||
| 855fb8704c | |||
| d0e4432fca | |||
| d8e30c0982 | |||
| ee07687cae | |||
| 28a3e4c2c5 | |||
| b4cc982cc2 | |||
| a61b8bc5ce | |||
| e0a32e04e8 | |||
| 2a9afc77de | |||
| 2840fdcb54 | |||
| 5a7d4f69f6 | |||
| 83966e346a | |||
| 3fe29ecf89 | |||
| ddcc841bdc | |||
| d004710a74 | |||
| c2319e5b4e | |||
| 0cd33d1abb | |||
| 426cc98f7c | |||
| 007f2df079 | |||
| 48b8e22502 | |||
| 18bef5ea2f | |||
| b4cb490c72 | |||
| 7615c2792b | |||
| 753e125758 | |||
| 17edeeaf04 | |||
| 500c17e257 | |||
| 35b9df86b3 | |||
| aff208048e | |||
| a10c5ff381 | |||
| f6556fce9f | |||
| 8e22cd707a | |||
| 8ee6e9457e | |||
| 0bbb7dd0a3 | |||
| 5ea1353d46 | |||
| 52f8143ad3 | |||
| 085370ec6d | |||
| c12bba40ad | |||
| 158243d146 | |||
| eb644732d7 | |||
| a83328c1f0 | |||
| a2728439ff | |||
| 16b8e6072f | |||
| 355150072b | |||
| ad3e8885e4 | |||
| 0b0813e30c | |||
| 430c8c2dd2 | |||
| 1c15534c98 | |||
| 04cf5f8690 | |||
| 897286f335 | |||
| 5ee39e88a3 | |||
| fa314fe7e5 | |||
| 1483fd11ff | |||
| 6067aa3aa1 | |||
| f23836d426 | |||
| da9214e212 | |||
| 79d46769fe | |||
| 86c4948445 | |||
| 6bafbb7bc5 | |||
| b31d09865e | |||
| fe3b825b86 | |||
| edf056babc | |||
| 9ca5b44b56 | |||
| 14f08444a9 | |||
| a3c851c0e6 | |||
| 5bfbb0301f | |||
| f559e51d7f | |||
| a8f2a8a44d | |||
| 04727c06e8 | |||
| ac2c161636 | |||
| 9149c1e9f2 | |||
| 5e85663ce3 | |||
| c47b437c12 | |||
| 7c8deaf0d6 | |||
| c69eb7c157 | |||
| d49545070c | |||
| f59763968f | |||
| 6dbde523ae | |||
| ca94588bd5 | |||
| e61b0fcf42 | |||
| 04f809f2d0 | |||
| f543a87b78 | |||
| e49c917bb0 | |||
| 903c045b9c | |||
| 3bb7975a17 | |||
| dc21699e8c | |||
| 086d6b8719 | |||
| f1fad60f9f | |||
| b6935c3528 | |||
| e96ab0ef59 | |||
| c21b3e49b3 | |||
| 47c703aa4d | |||
| f2a1a7e8c3 | |||
| 616db320d7 | |||
| 02974935b9 | |||
| 476df47d98 | |||
| ea00a993cd | |||
| 51bac75584 | |||
| 219db42984 | |||
| 4cbba17f10 | |||
| 5587dbc622 | |||
| 3819f33693 | |||
| 0e21480794 | |||
| 991831b1dd | |||
| 81c3375670 | |||
| b6d4ef2158 | |||
| 1d1e1f92b0 | |||
| ce04f6cc5d | |||
| 32e504cb2a | |||
| db76541522 | |||
| 7dc75637a1 | |||
| b0f3effe2c | |||
| 2cec1dbbfa | |||
| ab93943493 | |||
| 34f6e72879 | |||
| 46b2f3df8e | |||
| dec2c4c4e2 | |||
| b3f651d62f | |||
| daad93967a | |||
| afd7fc8687 | |||
| 4fa5e6ee3b | |||
| a34897817f | |||
| 9c22ae847e | |||
| 891274838e | |||
| a6b0eae38d | |||
| 1961252918 | |||
| 11f24119c5 | |||
| 3d411ddf4c | |||
| 26d029022d | |||
| 2a5d86e2aa | |||
| eef18424e3 | |||
| 926369b9f2 | |||
| e17b445e55 | |||
| dc5b877f26 | |||
| cb5ddec5c5 | |||
| eb020611a3 | |||
| 697b3a0a06 | |||
| acd6fe7aeb | |||
| 0b214ad7f6 | |||
| 17438d9730 | |||
| 279288affe | |||
| c571995a42 | |||
| bea2f8633a | |||
| dc9166b4cd | |||
| db22227f08 | |||
| 0eb8e904a1 | |||
| 2734123ac2 | |||
| 37432aefa6 | |||
| cf746f3a87 | |||
| ea533aa522 | |||
| 979df63788 | |||
| c4e9749146 | |||
| fb785d3524 | |||
| a899fc57a6 | |||
| 77e2521fa0 | |||
| c627a68e96 | |||
| 7ae3fdc749 | |||
| 7b6e72438b | |||
| 8f78baddb1 | |||
| 0f8f67ec6f | |||
| 9298e7e073 | |||
| 7182c53df7 | |||
| 37222199c3 | |||
| 9c47733e16 | |||
| 139dab7cfe | |||
| d306a2d7e5 | |||
| 30d2fc8bfc | |||
| d80b5fc8ed | |||
| 197b422a31 | |||
| 38017010ce | |||
| 984a834e81 | |||
| 2bcf08457b | |||
| 73dedcc765 | |||
| 94f6e83cfa | |||
| bc0c4ebaa7 | |||
| dc277618ee | |||
| b90c74a94d | |||
| ff532d9bf3 | |||
| 54497f4f6b | |||
| 9629d873fb | |||
| 643fe38b1e | |||
| c827a08c11 | |||
| 1b6556c2f6 | |||
| 859e94d67a | |||
| 50749d131b | |||
| 9986dc0c38 | |||
| 92b63871ca | |||
| 303e9ef7de | |||
| 1ebc217624 | |||
| 06dbadab11 | |||
| 566146af72 | |||
| 7e8fcb4b0f | |||
| dd28eef5b4 | |||
| 2f886ce8d0 | |||
| dcfd6d6f73 | |||
| b20fd6f2c1 | |||
| 2294c55bd9 | |||
| aad1b401c1 | |||
| a8294872a3 | |||
| 95a1ca898f | |||
| c3e3bb58f2 | |||
| 0d765f56f7 | |||
| 8b6f55c312 | |||
| 40fc337659 | |||
| f6df4c0ed8 | |||
| 924afb753f | |||
| 45c43d4ec4 | |||
| a1e7e105a4 | |||
| cf93c34172 | |||
| 659f89e079 | |||
| e4e4669feb | |||
| a1fa58ac17 | |||
| 88b7372e7f | |||
| dec6d310a8 | |||
| e720afa9d0 | |||
| d18441debe | |||
| 4f7b279fd8 | |||
| c3cbd977f1 | |||
| d8b1ca70d6 | |||
| 5a3ceb38f0 | |||
| cadf1352b4 | |||
| ed3d6fa9e3 | |||
| d9c40d61c2 | |||
| 90b64c5e04 | |||
| d0fb4599f0 | |||
| ffe22a0ffc | |||
| a1161b79a7 | |||
| dd92fbc83c | |||
| 10d2844cce | |||
| 277b2d21ca | |||
| af3ff825a1 | |||
| 50ba223aa1 | |||
| 6318520501 | |||
| f3f83540df | |||
| 10ef5933f4 | |||
| c8538769a2 | |||
| 5743149396 | |||
| 9780d0295c | |||
| b89093031e | |||
| 87045fc27b | |||
| b8a0f97cab | |||
| 7bad7e35ae | |||
| cd0a2849d0 | |||
| f6e86c6fdb | |||
| c301a0d804 | |||
| 6c621364f8 | |||
| 51d3abb904 | |||
| c6e551f538 | |||
| f684831f56 | |||
| f947a34103 | |||
| fb9d8e3030 | |||
| e60112e54f | |||
| e8e31dcb2c | |||
| 40e1784846 | |||
| 5a31c590e6 | |||
| e13a34c145 | |||
| 33b42fca7a | |||
| 86ef3fb497 | |||
| 13ca9ead3a | |||
| 906149317d | |||
| 6187acff8a | |||
| a106d67c07 | |||
| 2c6cbf15e2 | |||
| 1cb2ac65e5 | |||
| c6f63990cf | |||
| 9855460524 | |||
| 79728c30fa | |||
| 8daf4f35b1 | |||
| 5c802c2627 | |||
| 0f342ecc04 | |||
| e62c5db678 | |||
| 4244b20823 | |||
| 70cc3dd14a | |||
| d455f6ea2b | |||
| 4bd7c7b7e0 | |||
| 5f97ad0988 | |||
| 48f77d0c01 | |||
| da31a4bed9 | |||
| 9730c86f17 | |||
| 5ecab7b5f0 | |||
| df3b1bb6c7 | |||
| caeca1097b | |||
| 823b14aa34 | |||
| f2a410566c | |||
| aa689cbb39 | |||
| 1230cacf78 | |||
| 7bbeef4999 | |||
| f64ac8f5f7 | |||
| 69c2c40512 | |||
| 9da60a9dc5 | |||
| e73f9d356b | |||
| 87ce021035 | |||
| 86f0f39863 | |||
| 650dc860bd | |||
| c2dcf9348a | |||
| ea261a1f7c | |||
| 01ff28db11 | |||
| 77e8d2b887 | |||
| ccf0b34872 | |||
| 0652b18ebc | |||
| 5a18cf4c86 | |||
| 6a3bf6ff53 | |||
| b022ccefa7 | |||
| 66f1603f6a | |||
| 2e45236d31 | |||
| 0c2288d802 | |||
| 712d036192 | |||
| c1f8465de6 | |||
| 88851d248c | |||
| a13f29ff95 | |||
| e92b4fe13d | |||
| 784592a2dc | |||
| 251f787743 | |||
| 1a766a271f | |||
| cbaa97cb78 | |||
| 141ef23c81 | |||
| b0a045cba0 | |||
| f6526b789a | |||
| cfbbcfc65a | |||
| aec3e9e5ab | |||
| a6461ab7ff | |||
| 838fbc18dc | |||
| a833b7af9d | |||
| 09a41f3d1f | |||
| 16a863eab6 | |||
| 1a104751e4 | |||
| 9ee3a9174c | |||
| e8f0e3e4c5 | |||
| a1fa5c8c96 | |||
| dc6e50d6b7 | |||
| 14e80022c9 |
@@ -91,6 +91,9 @@
|
||||
|
||||
## Systematic Debugging Approach
|
||||
|
||||
YOU MUST ALWAYS find the root cause of any issue you are debugging
|
||||
YOU MUST NEVER fix a symptom or add a workaround instead of finding a root cause, even if it is faster.
|
||||
|
||||
### Multi-Issue Problem Solving
|
||||
|
||||
When facing multiple failing tests or complex integration issues:
|
||||
@@ -98,16 +101,21 @@ When facing multiple failing tests or complex integration issues:
|
||||
1. **Identify Root Causes**:
|
||||
- Run failing tests individually to isolate issues
|
||||
- Use LSP tools to trace through call chains
|
||||
- Check both compilation and runtime errors
|
||||
- Read Error Messages Carefully: Check both compilation and runtime errors
|
||||
- Reproduce Consistently: Ensure you can reliably reproduce the issue before investigating
|
||||
- Check Recent Changes: What changed that could have caused this? Git diff, recent commits, etc.
|
||||
- When You Don't Know: Say "I don't understand X" rather than pretending to know
|
||||
|
||||
2. **Fix in Logical Order**:
|
||||
- Address compilation issues first (imports, syntax)
|
||||
- Fix authorization and RBAC issues next
|
||||
- Resolve business logic and validation issues
|
||||
- Handle edge cases and race conditions last
|
||||
- IF your first fix doesn't work, STOP and re-analyze rather than adding more fixes
|
||||
|
||||
3. **Verification Strategy**:
|
||||
- Test each fix individually before moving to next issue
|
||||
- Always Test each fix individually before moving to next issue
|
||||
- Verify Before Continuing: Did your test work? If not, form new hypothesis - don't add more fixes
|
||||
- Use `make lint` and `make gen` after database changes
|
||||
- Verify RFC compliance with actual specifications
|
||||
- Run comprehensive test suites before considering complete
|
||||
|
||||
@@ -40,11 +40,15 @@
|
||||
- Use proper error types
|
||||
- Pattern: `xerrors.Errorf("failed to X: %w", err)`
|
||||
|
||||
### Naming Conventions
|
||||
## Naming Conventions
|
||||
|
||||
- Use clear, descriptive names
|
||||
- Abbreviate only when obvious
|
||||
- Names MUST tell what code does, not how it's implemented or its history
|
||||
- Follow Go and TypeScript naming conventions
|
||||
- When changing code, never document the old behavior or the behavior change
|
||||
- NEVER use implementation details in names (e.g., "ZodValidator", "MCPWrapper", "JSONParser")
|
||||
- NEVER use temporal/historical context in names (e.g., "LegacyHandler", "UnifiedTool", "ImprovedInterface", "EnhancedParser")
|
||||
- NEVER use pattern names unless they add clarity (e.g., prefer "Tool" over "ToolFactory")
|
||||
- Abbreviate only when obvious
|
||||
|
||||
### Comments
|
||||
|
||||
|
||||
@@ -27,5 +27,7 @@ ignorePatterns:
|
||||
- pattern: "splunk.com"
|
||||
- pattern: "stackoverflow.com/questions"
|
||||
- pattern: "developer.hashicorp.com/terraform/language"
|
||||
- pattern: "platform.openai.com"
|
||||
- pattern: "api.openai.com"
|
||||
aliveStatusCodes:
|
||||
- 200
|
||||
|
||||
@@ -4,7 +4,7 @@ description: |
|
||||
inputs:
|
||||
version:
|
||||
description: "The Go version to use."
|
||||
default: "1.24.6"
|
||||
default: "1.24.10"
|
||||
use-preinstalled-go:
|
||||
description: "Whether to use preinstalled Go."
|
||||
default: "false"
|
||||
|
||||
@@ -5,6 +5,13 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup sqlc
|
||||
uses: sqlc-dev/setup-sqlc@c0209b9199cd1cce6a14fc27cabcec491b651761 # v4.0.0
|
||||
with:
|
||||
sqlc-version: "1.27.0"
|
||||
# uses: sqlc-dev/setup-sqlc@c0209b9199cd1cce6a14fc27cabcec491b651761 # v4.0.0
|
||||
# with:
|
||||
# sqlc-version: "1.30.0"
|
||||
|
||||
# Switched to coder/sqlc fork to fix ambiguous column bug, see:
|
||||
# - https://github.com/coder/sqlc/pull/1
|
||||
# - https://github.com/sqlc-dev/sqlc/pull/4159
|
||||
shell: bash
|
||||
run: |
|
||||
CGO_ENABLED=1 go install github.com/coder/sqlc/cmd/sqlc@aab4e865a51df0c43e1839f81a9d349b41d14f05
|
||||
|
||||
@@ -7,5 +7,5 @@ runs:
|
||||
- name: Install Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
with:
|
||||
terraform_version: 1.13.0
|
||||
terraform_version: 1.13.4
|
||||
terraform_wrapper: false
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
app = "sao-paulo-coder"
|
||||
primary_region = "gru"
|
||||
|
||||
[experimental]
|
||||
entrypoint = ["/bin/sh", "-c", "CODER_DERP_SERVER_RELAY_URL=\"http://[${FLY_PRIVATE_IP}]:3000\" /opt/coder wsproxy server"]
|
||||
auto_rollback = true
|
||||
|
||||
[build]
|
||||
image = "ghcr.io/coder/coder-preview:main"
|
||||
|
||||
[env]
|
||||
CODER_ACCESS_URL = "https://sao-paulo.fly.dev.coder.com"
|
||||
CODER_HTTP_ADDRESS = "0.0.0.0:3000"
|
||||
CODER_PRIMARY_ACCESS_URL = "https://dev.coder.com"
|
||||
CODER_WILDCARD_ACCESS_URL = "*--apps.sao-paulo.fly.dev.coder.com"
|
||||
CODER_VERBOSE = "true"
|
||||
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
force_https = true
|
||||
auto_stop_machines = true
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
|
||||
# Ref: https://fly.io/docs/reference/configuration/#http_service-concurrency
|
||||
[http_service.concurrency]
|
||||
type = "requests"
|
||||
soft_limit = 50
|
||||
hard_limit = 100
|
||||
|
||||
[[vm]]
|
||||
cpu_kind = "shared"
|
||||
cpus = 2
|
||||
memory_mb = 512
|
||||
+41
-45
@@ -35,7 +35,7 @@ jobs:
|
||||
tailnet-integration: ${{ steps.filter.outputs.tailnet-integration }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
echo "LINT_CACHE_DIR=$dir" >> "$GITHUB_ENV"
|
||||
|
||||
- name: golangci-lint cache
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
${{ env.LINT_CACHE_DIR }}
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
|
||||
# Check for any typos
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@85f62a8a84f939ae994ab3763f01a0296d61a7ee # v1.36.2
|
||||
uses: crate-ci/typos@626c4bedb751ce0b7f03262ca97ddda9a076ae1c # v1.39.2
|
||||
with:
|
||||
config: .github/workflows/typos.toml
|
||||
|
||||
@@ -230,12 +230,12 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
gen:
|
||||
timeout-minutes: 8
|
||||
timeout-minutes: 20
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -271,6 +271,7 @@ jobs:
|
||||
popd
|
||||
|
||||
- name: make gen
|
||||
timeout-minutes: 8
|
||||
run: |
|
||||
# Remove golden files to detect discrepancy in generated files.
|
||||
make clean/golden-files
|
||||
@@ -288,10 +289,10 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.offlinedocs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 7
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -315,6 +316,7 @@ jobs:
|
||||
run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0
|
||||
|
||||
- name: make fmt
|
||||
timeout-minutes: 7
|
||||
run: |
|
||||
PATH="${PATH}:$(go env GOPATH)/bin" \
|
||||
make --output-sync -j -B fmt
|
||||
@@ -341,7 +343,7 @@ jobs:
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -376,13 +378,6 @@ jobs:
|
||||
id: go-paths
|
||||
uses: ./.github/actions/setup-go-paths
|
||||
|
||||
- name: Download Go Build Cache
|
||||
id: download-go-build-cache
|
||||
uses: ./.github/actions/test-cache/download
|
||||
with:
|
||||
key-prefix: test-go-build-${{ runner.os }}-${{ runner.arch }}
|
||||
cache-path: ${{ steps.go-paths.outputs.cached-dirs }}
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
with:
|
||||
@@ -390,8 +385,7 @@ jobs:
|
||||
# download the toolchain configured in go.mod, so we don't
|
||||
# need to reinstall it. It's faster on Windows runners.
|
||||
use-preinstalled-go: ${{ runner.os == 'Windows' }}
|
||||
# Cache is already downloaded above
|
||||
use-cache: false
|
||||
use-cache: true
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: ./.github/actions/setup-tf
|
||||
@@ -500,17 +494,11 @@ jobs:
|
||||
make test
|
||||
|
||||
- name: Upload failed test db dumps
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: failed-test-db-dump-${{matrix.os}}
|
||||
path: "**/*.test.sql"
|
||||
|
||||
- name: Upload Go Build Cache
|
||||
uses: ./.github/actions/test-cache/upload
|
||||
with:
|
||||
cache-key: ${{ steps.download-go-build-cache.outputs.cache-key }}
|
||||
cache-path: ${{ steps.go-paths.outputs.cached-dirs }}
|
||||
|
||||
- name: Upload Test Cache
|
||||
uses: ./.github/actions/test-cache/upload
|
||||
with:
|
||||
@@ -544,7 +532,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -593,7 +581,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -653,7 +641,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -680,7 +668,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -713,7 +701,7 @@ jobs:
|
||||
name: ${{ matrix.variant.name }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -762,15 +750,23 @@ jobs:
|
||||
|
||||
- name: Upload Playwright Failed Tests
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: failed-test-videos${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/test-results/**/*.webm
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload debug log
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: coderd-debug-logs${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/e2e/test-results/debug.log
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload pprof dumps
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: debug-pprof-dumps${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/test-results/**/debug-pprof-*.txt
|
||||
@@ -785,7 +781,7 @@ jobs:
|
||||
if: needs.changes.outputs.site == 'true' || needs.changes.outputs.ci == 'true'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -806,7 +802,7 @@ jobs:
|
||||
# the check to pass. This is desired in PRs, but not in mainline.
|
||||
- name: Publish to Chromatic (non-mainline)
|
||||
if: github.ref != 'refs/heads/main' && github.repository_owner == 'coder'
|
||||
uses: chromaui/action@20c7e42e1b2f6becd5d188df9acb02f3e2f51519 # v13.2.0
|
||||
uses: chromaui/action@ac86f2ff0a458ffbce7b40698abd44c0fa34d4b6 # v13.3.3
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
STORYBOOK: true
|
||||
@@ -838,7 +834,7 @@ jobs:
|
||||
# infinitely "in progress" in mainline unless we re-review each build.
|
||||
- name: Publish to Chromatic (mainline)
|
||||
if: github.ref == 'refs/heads/main' && github.repository_owner == 'coder'
|
||||
uses: chromaui/action@20c7e42e1b2f6becd5d188df9acb02f3e2f51519 # v13.2.0
|
||||
uses: chromaui/action@ac86f2ff0a458ffbce7b40698abd44c0fa34d4b6 # v13.3.3
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
STORYBOOK: true
|
||||
@@ -866,7 +862,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -937,7 +933,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1036,7 +1032,7 @@ jobs:
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: ${{ github.repository_owner == 'coder' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: dylibs
|
||||
path: |
|
||||
@@ -1057,7 +1053,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1112,7 +1108,7 @@ jobs:
|
||||
IMAGE: ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1123,7 +1119,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -1201,7 +1197,7 @@ jobs:
|
||||
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
|
||||
|
||||
- name: Download dylibs
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: dylibs
|
||||
path: ./build
|
||||
@@ -1468,7 +1464,7 @@ jobs:
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: coder
|
||||
path: |
|
||||
@@ -1509,7 +1505,7 @@ jobs:
|
||||
if: needs.changes.outputs.db == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1537,7 +1533,7 @@ jobs:
|
||||
steps:
|
||||
- name: Send Slack notification
|
||||
run: |
|
||||
ESCAPED_PROMPT=$(printf "%s" "<@U08TJ4YNCA3> $BLINK_CI_FAILURE_PROMPT" | jq -Rsa .)
|
||||
ESCAPED_PROMPT=$(printf "%s" "<@U09LQ75AHKR> $BLINK_CI_FAILURE_PROMPT" | jq -Rsa .)
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data '{
|
||||
"blocks": [
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
verdict: ${{ steps.check.outputs.verdict }} # DEPLOY or NOOP
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
packages: write # to retag image as dogfood
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
|
||||
|
||||
- name: Set up Flux CLI
|
||||
uses: fluxcd/flux2/action@6bf37f6a560fd84982d67f853162e4b3c2235edb # v2.6.4
|
||||
uses: fluxcd/flux2/action@b6e76ca2534f76dcb8dd94fb057cdfa923c3b641 # v2.7.3
|
||||
with:
|
||||
# Keep this and the github action up to date with the version of flux installed in dogfood cluster
|
||||
version: "2.7.0"
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
needs: deploy
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -163,12 +163,10 @@ jobs:
|
||||
run: |
|
||||
flyctl deploy --image "$IMAGE" --app paris-coder --config ./.github/fly-wsproxies/paris-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_PARIS" --yes
|
||||
flyctl deploy --image "$IMAGE" --app sydney-coder --config ./.github/fly-wsproxies/sydney-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_SYDNEY" --yes
|
||||
flyctl deploy --image "$IMAGE" --app sao-paulo-coder --config ./.github/fly-wsproxies/sao-paulo-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_SAO_PAULO" --yes
|
||||
flyctl deploy --image "$IMAGE" --app jnb-coder --config ./.github/fly-wsproxies/jnb-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_JNB" --yes
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
IMAGE: ${{ inputs.image }}
|
||||
TOKEN_PARIS: ${{ secrets.FLY_PARIS_CODER_PROXY_SESSION_TOKEN }}
|
||||
TOKEN_SYDNEY: ${{ secrets.FLY_SYDNEY_CODER_PROXY_SESSION_TOKEN }}
|
||||
TOKEN_SAO_PAULO: ${{ secrets.FLY_SAO_PAULO_CODER_PROXY_SESSION_TOKEN }}
|
||||
TOKEN_JNB: ${{ secrets.FLY_JNB_CODER_PROXY_SESSION_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# This workflow checks if a PR requires documentation updates.
|
||||
# It creates a Coder Task that uses AI to analyze the PR changes,
|
||||
# search existing docs, and comment with recommendations.
|
||||
#
|
||||
# Triggered by: Adding the "doc-check" label to a PR, or manual dispatch.
|
||||
|
||||
name: AI Documentation Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- labeled
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_url:
|
||||
description: "Pull Request URL to check"
|
||||
required: true
|
||||
type: string
|
||||
template_preset:
|
||||
description: "Template preset to use"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
doc-check:
|
||||
name: Analyze PR for Documentation Updates Needed
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
(github.event.label.name == 'doc-check' || github.event_name == 'workflow_dispatch') &&
|
||||
(github.event.pull_request.draft == false || github.event_name == 'workflow_dispatch')
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CODER_URL: ${{ secrets.DOC_CHECK_CODER_URL }}
|
||||
CODER_SESSION_TOKEN: ${{ secrets.DOC_CHECK_CODER_SESSION_TOKEN }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- name: Determine PR Context
|
||||
id: determine-context
|
||||
env:
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_EVENT_PR_HTML_URL: ${{ github.event.pull_request.html_url }}
|
||||
GITHUB_EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GITHUB_EVENT_SENDER_ID: ${{ github.event.sender.id }}
|
||||
GITHUB_EVENT_SENDER_LOGIN: ${{ github.event.sender.login }}
|
||||
INPUTS_PR_URL: ${{ inputs.pr_url }}
|
||||
INPUTS_TEMPLATE_PRESET: ${{ inputs.template_preset || '' }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "Using template preset: ${INPUTS_TEMPLATE_PRESET}"
|
||||
echo "template_preset=${INPUTS_TEMPLATE_PRESET}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# For workflow_dispatch, use the provided PR URL
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
if ! GITHUB_USER_ID=$(gh api "users/${GITHUB_ACTOR}" --jq '.id'); then
|
||||
echo "::error::Failed to get GitHub user ID for actor ${GITHUB_ACTOR}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using workflow_dispatch actor: ${GITHUB_ACTOR} (ID: ${GITHUB_USER_ID})"
|
||||
echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}"
|
||||
echo "github_username=${GITHUB_ACTOR}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "Using PR URL: ${INPUTS_PR_URL}"
|
||||
# Convert /pull/ to /issues/ for create-task-action compatibility
|
||||
ISSUE_URL="${INPUTS_PR_URL/\/pull\//\/issues\/}"
|
||||
echo "pr_url=${ISSUE_URL}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Extract PR number from URL for later use
|
||||
PR_NUMBER=$(echo "${INPUTS_PR_URL}" | grep -oP '(?<=pull/)\d+')
|
||||
echo "pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then
|
||||
GITHUB_USER_ID=${GITHUB_EVENT_SENDER_ID}
|
||||
echo "Using label adder: ${GITHUB_EVENT_SENDER_LOGIN} (ID: ${GITHUB_USER_ID})"
|
||||
echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}"
|
||||
echo "github_username=${GITHUB_EVENT_SENDER_LOGIN}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "Using PR URL: ${GITHUB_EVENT_PR_HTML_URL}"
|
||||
# Convert /pull/ to /issues/ for create-task-action compatibility
|
||||
ISSUE_URL="${GITHUB_EVENT_PR_HTML_URL/\/pull\//\/issues\/}"
|
||||
echo "pr_url=${ISSUE_URL}" >> "${GITHUB_OUTPUT}"
|
||||
echo "pr_number=${GITHUB_EVENT_PR_NUMBER}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
else
|
||||
echo "::error::Unsupported event type: ${GITHUB_EVENT_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Extract changed files and build prompt
|
||||
id: extract-context
|
||||
env:
|
||||
PR_URL: ${{ steps.determine-context.outputs.pr_url }}
|
||||
PR_NUMBER: ${{ steps.determine-context.outputs.pr_number }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "Analyzing PR #${PR_NUMBER}"
|
||||
|
||||
# Build task prompt - using unquoted heredoc so variables expand
|
||||
TASK_PROMPT=$(cat <<EOF
|
||||
Review PR #${PR_NUMBER} and determine if documentation needs updating or creating.
|
||||
|
||||
PR URL: ${PR_URL}
|
||||
|
||||
WORKFLOW:
|
||||
1. Setup (repo is pre-cloned at ~/coder)
|
||||
cd ~/coder
|
||||
git fetch origin pull/${PR_NUMBER}/head:pr-${PR_NUMBER}
|
||||
git checkout pr-${PR_NUMBER}
|
||||
|
||||
2. Get PR info
|
||||
Use GitHub MCP tools to get PR title, body, and diff
|
||||
Or use: git diff main...pr-${PR_NUMBER}
|
||||
|
||||
3. Understand Changes
|
||||
Read the diff and identify what changed
|
||||
Ask: Is this user-facing? Does it change behavior? Is it a new feature?
|
||||
|
||||
4. Search for Related Docs
|
||||
cat ~/coder/docs/manifest.json | jq '.routes[] | {title, path}' | head -50
|
||||
grep -ri "relevant_term" ~/coder/docs/ --include="*.md"
|
||||
|
||||
5. Decide
|
||||
NEEDS DOCS if: New feature, API change, CLI change, behavior change, user-visible
|
||||
NO DOCS if: Internal refactor, test-only, already documented, non-user-facing, dependency updates
|
||||
FIRST check: Did this PR already update docs? If yes and complete, say "No Changes Needed"
|
||||
|
||||
6. Comment on the PR using this format
|
||||
|
||||
COMMENT FORMAT:
|
||||
## 📚 Documentation Check
|
||||
|
||||
### ✅ Updates Needed
|
||||
- **[docs/path/file.md](github_link)** - Brief what needs changing
|
||||
|
||||
### 📝 New Docs Needed
|
||||
- **docs/suggested/location.md** - What should be documented
|
||||
|
||||
### ✨ No Changes Needed
|
||||
[Reason: Documents already updated in PR | Internal changes only | Test-only | No user-facing impact]
|
||||
|
||||
---
|
||||
*This comment was generated by an AI Agent through [Coder Tasks](https://coder.com/docs/ai-coder/tasks)*
|
||||
|
||||
DOCS STRUCTURE:
|
||||
Read ~/coder/docs/manifest.json for the complete documentation structure.
|
||||
Common areas include: reference/, admin/, user-guides/, ai-coder/, install/, tutorials/
|
||||
But check manifest.json - it has everything.
|
||||
|
||||
EOF
|
||||
)
|
||||
|
||||
# Output the prompt
|
||||
{
|
||||
echo "task_prompt<<EOFOUTPUT"
|
||||
echo "${TASK_PROMPT}"
|
||||
echo "EOFOUTPUT"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Checkout create-task-action
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
path: ./.github/actions/create-task-action
|
||||
persist-credentials: false
|
||||
ref: main
|
||||
repository: coder/create-task-action
|
||||
|
||||
- name: Create Coder Task for Documentation Check
|
||||
id: create_task
|
||||
uses: ./.github/actions/create-task-action
|
||||
with:
|
||||
coder-url: ${{ secrets.DOC_CHECK_CODER_URL }}
|
||||
coder-token: ${{ secrets.DOC_CHECK_CODER_SESSION_TOKEN }}
|
||||
coder-organization: "default"
|
||||
coder-template-name: coder
|
||||
coder-template-preset: ${{ steps.determine-context.outputs.template_preset }}
|
||||
coder-task-name-prefix: doc-check
|
||||
coder-task-prompt: ${{ steps.extract-context.outputs.task_prompt }}
|
||||
github-user-id: ${{ steps.determine-context.outputs.github_user_id }}
|
||||
github-token: ${{ github.token }}
|
||||
github-issue-url: ${{ steps.determine-context.outputs.pr_url }}
|
||||
comment-on-issue: true
|
||||
|
||||
- name: Write outputs
|
||||
env:
|
||||
TASK_CREATED: ${{ steps.create_task.outputs.task-created }}
|
||||
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
|
||||
TASK_URL: ${{ steps.create_task.outputs.task-url }}
|
||||
PR_URL: ${{ steps.determine-context.outputs.pr_url }}
|
||||
run: |
|
||||
{
|
||||
echo "## Documentation Check Task"
|
||||
echo ""
|
||||
echo "**PR:** ${PR_URL}"
|
||||
echo "**Task created:** ${TASK_CREATED}"
|
||||
echo "**Task name:** ${TASK_NAME}"
|
||||
echo "**Task URL:** ${TASK_URL}"
|
||||
echo ""
|
||||
echo "The Coder task is analyzing the PR changes and will comment with documentation recommendations."
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
if: github.repository_owner == 'coder'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- uses: tj-actions/changed-files@4563c729c555b4141fac99c80f699f571219b836 # v45.0.7
|
||||
- uses: tj-actions/changed-files@70069877f29101175ed2b055d210fe8b1d54d7d7 # v45.0.7
|
||||
id: changed-files
|
||||
with:
|
||||
files: |
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -36,11 +36,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Nix
|
||||
uses: nixbuild/nix-quick-install-action@1f095fee853b33114486cfdeae62fa099cda35a9 # v33
|
||||
uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
with:
|
||||
# Pinning to 2.28 here, as Nix gets a "error: [json.exception.type_error.302] type must be array, but is string"
|
||||
# on version 2.29 and above.
|
||||
nix_version: "2.28.4"
|
||||
nix_version: "2.28.5"
|
||||
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
with:
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
steps:
|
||||
- name: Send Slack notification
|
||||
run: |
|
||||
ESCAPED_PROMPT=$(printf "%s" "<@U08TJ4YNCA3> $BLINK_CI_FAILURE_PROMPT" | jq -Rsa .)
|
||||
ESCAPED_PROMPT=$(printf "%s" "<@U09LQ75AHKR> $BLINK_CI_FAILURE_PROMPT" | jq -Rsa .)
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data '{
|
||||
"blocks": [
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
PR_OPEN: ${{ steps.check_pr.outputs.pr_open }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -184,12 +184,12 @@ jobs:
|
||||
pull-requests: write # needed for commenting on PRs
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ needs.get_info.outputs.PR_NUMBER }}
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR
|
||||
id: comment_id
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
with:
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
issue-number: ${{ needs.get_info.outputs.PR_NUMBER }}
|
||||
@@ -228,7 +228,7 @@ jobs:
|
||||
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -248,7 +248,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-sqlc
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
PR_HOSTNAME: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -491,7 +491,7 @@ jobs:
|
||||
PASSWORD: ${{ steps.setup_deployment.outputs.password }}
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ env.PR_NUMBER }}
|
||||
@@ -500,7 +500,7 @@ jobs:
|
||||
direction: last
|
||||
|
||||
- name: Comment on PR
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
env:
|
||||
STATUS: ${{ needs.get_info.outputs.NEW == 'true' && 'Created' || 'Updated' }}
|
||||
with:
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: dylibs
|
||||
path: |
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -239,7 +239,7 @@ jobs:
|
||||
cat "$CODER_RELEASE_NOTES_FILE"
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -327,7 +327,7 @@ jobs:
|
||||
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
|
||||
|
||||
- name: Download dylibs
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: dylibs
|
||||
path: ./build
|
||||
@@ -761,7 +761,7 @@ jobs:
|
||||
|
||||
- name: Upload artifacts to actions (if dry-run)
|
||||
if: ${{ inputs.dry_run }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: release-artifacts
|
||||
path: |
|
||||
@@ -777,7 +777,7 @@ jobs:
|
||||
|
||||
- name: Upload latest sbom artifact to actions (if dry-run)
|
||||
if: inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: latest-sbom-artifact
|
||||
path: ./coder_latest_sbom.spdx.json
|
||||
@@ -785,7 +785,7 @@ jobs:
|
||||
|
||||
- name: Send repository-dispatch event
|
||||
if: ${{ !inputs.dry_run }}
|
||||
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
repository: coder/packages
|
||||
@@ -802,7 +802,7 @@ jobs:
|
||||
# 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -878,7 +878,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -971,7 +971,7 @@ jobs:
|
||||
if: ${{ !inputs.dry_run }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
# Upload the results as artifacts.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
@@ -47,6 +47,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@014f16e7ab1402f30e7c3329d33797e7948572db # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5
|
||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v3.29.5
|
||||
with:
|
||||
languages: go, javascript
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
rm Makefile
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5
|
||||
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v3.29.5
|
||||
|
||||
- name: Send Slack notification on failure
|
||||
if: ${{ failure() }}
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -154,13 +154,13 @@ jobs:
|
||||
severity: "CRITICAL,HIGH"
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@014f16e7ab1402f30e7c3329d33797e7948572db # v3.29.5
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: "Trivy"
|
||||
|
||||
- name: Upload Trivy scan results as an artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: trivy
|
||||
path: trivy-results.sarif
|
||||
|
||||
@@ -18,12 +18,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: stale
|
||||
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
stale-issue-label: "stale"
|
||||
stale-pr-label: "stale"
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -120,12 +120,12 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Delete PR Cleanup workflow runs
|
||||
uses: Mattraks/delete-workflow-runs@39f0bbed25d76b34de5594dceab824811479e5de # v2.0.6
|
||||
uses: Mattraks/delete-workflow-runs@5bf9a1dac5c4d041c029f0a8370ddf0c5cb5aeb7 # v2.1.0
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
delete_workflow_pattern: pr-cleanup.yaml
|
||||
|
||||
- name: Delete PR Deploy workflow skipped runs
|
||||
uses: Mattraks/delete-workflow-runs@39f0bbed25d76b34de5594dceab824811479e5de # v2.0.6
|
||||
uses: Mattraks/delete-workflow-runs@5bf9a1dac5c4d041c029f0a8370ddf0c5cb5aeb7 # v2.1.0
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
@@ -13,12 +13,12 @@ on:
|
||||
template_name:
|
||||
description: "Coder template to use for workspace"
|
||||
required: true
|
||||
default: "traiage"
|
||||
default: "coder"
|
||||
type: string
|
||||
template_preset:
|
||||
description: "Template preset to use"
|
||||
required: true
|
||||
default: "Default"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
prefix:
|
||||
description: "Prefix for workspace name"
|
||||
@@ -66,8 +66,8 @@ jobs:
|
||||
GITHUB_EVENT_USER_ID: ${{ github.event.sender.id }}
|
||||
GITHUB_EVENT_USER_LOGIN: ${{ github.event.sender.login }}
|
||||
INPUTS_ISSUE_URL: ${{ inputs.issue_url }}
|
||||
INPUTS_TEMPLATE_NAME: ${{ inputs.template_name || 'traiage' }}
|
||||
INPUTS_TEMPLATE_PRESET: ${{ inputs.template_preset || 'Default'}}
|
||||
INPUTS_TEMPLATE_NAME: ${{ inputs.template_name || 'coder' }}
|
||||
INPUTS_TEMPLATE_PRESET: ${{ inputs.template_preset || ''}}
|
||||
INPUTS_PREFIX: ${{ inputs.prefix || 'traiage' }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Extract context key from issue
|
||||
- name: Extract context key and description from issue
|
||||
id: extract-context
|
||||
env:
|
||||
ISSUE_URL: ${{ steps.determine-inputs.outputs.issue_url }}
|
||||
@@ -132,86 +132,59 @@ jobs:
|
||||
run: |
|
||||
issue_number="$(gh issue view "${ISSUE_URL}" --json number --jq '.number')"
|
||||
context_key="gh-${issue_number}"
|
||||
echo "context_key=${context_key}" >> "${GITHUB_OUTPUT}"
|
||||
echo "CONTEXT_KEY=${context_key}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Download and install Coder binary
|
||||
shell: bash
|
||||
env:
|
||||
CODER_URL: ${{ secrets.TRAIAGE_CODER_URL }}
|
||||
run: |
|
||||
if [ "${{ runner.arch }}" == "ARM64" ]; then
|
||||
ARCH="arm64"
|
||||
else
|
||||
ARCH="amd64"
|
||||
fi
|
||||
mkdir -p "${HOME}/.local/bin"
|
||||
curl -fsSL --compressed "$CODER_URL/bin/coder-linux-${ARCH}" -o "${HOME}/.local/bin/coder"
|
||||
chmod +x "${HOME}/.local/bin/coder"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
coder version
|
||||
coder whoami
|
||||
echo "$HOME/.local/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Get Coder username from GitHub actor
|
||||
id: get-coder-username
|
||||
env:
|
||||
CODER_SESSION_TOKEN: ${{ secrets.TRAIAGE_CODER_SESSION_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_USER_ID: ${{ steps.determine-inputs.outputs.github_user_id }}
|
||||
run: |
|
||||
user_json=$(
|
||||
coder users list --github-user-id="${GITHUB_USER_ID}" --output=json
|
||||
)
|
||||
coder_username=$(jq -r 'first | .username' <<< "$user_json")
|
||||
[[ -z "${coder_username}" || "${coder_username}" == "null" ]] && echo "No Coder user with GitHub user ID ${GITHUB_USER_ID} found" && exit 1
|
||||
echo "coder_username=${coder_username}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
# TODO(Cian): this is a good use-case for 'recipes'
|
||||
- name: Create Coder task
|
||||
id: create-task
|
||||
env:
|
||||
CODER_USERNAME: ${{ steps.get-coder-username.outputs.coder_username }}
|
||||
CONTEXT_KEY: ${{ steps.extract-context.outputs.context_key }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
ISSUE_URL: ${{ steps.determine-inputs.outputs.issue_url }}
|
||||
PREFIX: ${{ steps.determine-inputs.outputs.prefix }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
TEMPLATE_NAME: ${{ steps.determine-inputs.outputs.template_name }}
|
||||
TEMPLATE_PARAMETERS: ${{ secrets.TRAIAGE_TEMPLATE_PARAMETERS }}
|
||||
TEMPLATE_PRESET: ${{ steps.determine-inputs.outputs.template_preset }}
|
||||
run: |
|
||||
# Fetch issue description using `gh` CLI
|
||||
#shellcheck disable=SC2016 # The template string should not be subject to shell expansion
|
||||
issue_description=$(gh issue view "${ISSUE_URL}" \
|
||||
--json 'title,body,comments' \
|
||||
--template '{{printf "%s\n\n%s\n\nComments:\n" .title .body}}{{range $k, $v := .comments}} - {{index $v.author "login"}}: {{printf "%s\n" $v.body}}{{end}}')
|
||||
|
||||
# Write a prompt to PROMPT_FILE
|
||||
PROMPT=$(cat <<EOF
|
||||
TASK_PROMPT=$(cat <<EOF
|
||||
Fix ${ISSUE_URL}
|
||||
|
||||
Analyze the below GitHub issue description, understand the root cause, and make appropriate changes to resolve the issue.
|
||||
---
|
||||
${issue_description}
|
||||
1. Use the gh CLI to read the issue description and comments.
|
||||
2. Think carefully and try to understand the root cause. If the issue is unclear or not well defined, ask me to clarify and provide more information.
|
||||
3. Write a proposed implementation plan to PLAN.md for me to review before starting implementation. Your plan should use TDD and only make the minimal changes necessary to fix the root cause.
|
||||
4. When I approve your plan, start working on it. If you encounter issues with the plan, ask me for clarification and update the plan as required.
|
||||
5. When you have finished implementation according to the plan, commit and push your changes, and create a PR using the gh CLI for me to review.
|
||||
|
||||
EOF
|
||||
)
|
||||
export PROMPT
|
||||
|
||||
export TASK_NAME="${PREFIX}-${CONTEXT_KEY}-${RUN_ID}"
|
||||
echo "Creating task: $TASK_NAME"
|
||||
./scripts/traiage.sh create
|
||||
if [[ "${ISSUE_URL}" == "https://github.com/${GITHUB_REPOSITORY}"* ]]; then
|
||||
gh issue comment "${ISSUE_URL}" --body "Task created: https://dev.coder.com/tasks/${CODER_USERNAME}/${TASK_NAME}" --create-if-none --edit-last
|
||||
else
|
||||
echo "Skipping comment on other repo."
|
||||
fi
|
||||
echo "TASK_NAME=${CODER_USERNAME}/${TASK_NAME}" >> "${GITHUB_OUTPUT}"
|
||||
echo "TASK_NAME=${CODER_USERNAME}/${TASK_NAME}" >> "${GITHUB_ENV}"
|
||||
echo "context_key=${context_key}" >> "${GITHUB_OUTPUT}"
|
||||
{
|
||||
echo "TASK_PROMPT<<EOF"
|
||||
echo "${TASK_PROMPT}"
|
||||
echo "EOF"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
path: ./.github/actions/create-task-action
|
||||
persist-credentials: false
|
||||
ref: main
|
||||
repository: coder/create-task-action
|
||||
|
||||
- name: Create Coder Task
|
||||
id: create_task
|
||||
uses: ./.github/actions/create-task-action
|
||||
with:
|
||||
coder-url: ${{ secrets.TRAIAGE_CODER_URL }}
|
||||
coder-token: ${{ secrets.TRAIAGE_CODER_SESSION_TOKEN }}
|
||||
coder-organization: "default"
|
||||
coder-template-name: coder
|
||||
coder-template-preset: ${{ steps.determine-inputs.outputs.template_preset }}
|
||||
coder-task-name-prefix: gh-coder
|
||||
coder-task-prompt: ${{ steps.extract-context.outputs.task_prompt }}
|
||||
github-user-id: ${{ steps.determine-inputs.outputs.github_user_id }}
|
||||
github-token: ${{ github.token }}
|
||||
github-issue-url: ${{ steps.determine-inputs.outputs.issue_url }}
|
||||
comment-on-issue: ${{ startsWith(steps.determine-inputs.outputs.issue_url, format('{0}/{1}', github.server_url, github.repository)) }}
|
||||
|
||||
- name: Write outputs
|
||||
env:
|
||||
TASK_CREATED: ${{ steps.create_task.outputs.task-created }}
|
||||
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
|
||||
TASK_URL: ${{ steps.create_task.outputs.task-url }}
|
||||
run: |
|
||||
{
|
||||
echo "**Task created:** ${TASK_CREATED}"
|
||||
echo "**Task name:** ${TASK_NAME}"
|
||||
echo "**Task URL**: ${TASK_URL}"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
@@ -9,6 +9,7 @@ IST = "IST"
|
||||
MacOS = "macOS"
|
||||
AKS = "AKS"
|
||||
O_WRONLY = "O_WRONLY"
|
||||
AIBridge = "AI Bridge"
|
||||
|
||||
[default.extend-words]
|
||||
AKS = "AKS"
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write # required to post PR review comments by the action
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check Markdown links
|
||||
uses: umbrelladocs/action-linkspector@874d01cae9fd488e3077b08952093235bd626977 # v1.3.7
|
||||
uses: umbrelladocs/action-linkspector@652f85bc57bb1e7d4327260decc10aa68f7694c3 # v1.4.0
|
||||
id: markdown-link-check
|
||||
# checks all markdown files from /docs including all subfolders
|
||||
with:
|
||||
|
||||
@@ -12,6 +12,9 @@ node_modules/
|
||||
vendor/
|
||||
yarn-error.log
|
||||
|
||||
# Test output files
|
||||
test-output/
|
||||
|
||||
# VSCode settings.
|
||||
**/.vscode/*
|
||||
# Allow VSCode recommendations and default settings in project root.
|
||||
@@ -86,3 +89,8 @@ result
|
||||
__debug_bin*
|
||||
|
||||
**/.claude/settings.local.json
|
||||
|
||||
/.env
|
||||
|
||||
# Ignore plans written by AI agents.
|
||||
PLAN.md
|
||||
|
||||
+11
-1
@@ -169,6 +169,16 @@ linters-settings:
|
||||
- name: var-declaration
|
||||
- name: var-naming
|
||||
- name: waitgroup-by-value
|
||||
usetesting:
|
||||
# Only os-setenv is enabled because we migrated to usetesting from another linter that
|
||||
# only covered os-setenv.
|
||||
os-setenv: true
|
||||
os-create-temp: false
|
||||
os-mkdir-temp: false
|
||||
os-temp-dir: false
|
||||
os-chdir: false
|
||||
context-background: false
|
||||
context-todo: false
|
||||
|
||||
# irrelevant as of Go v1.22: https://go.dev/blog/loopvar-preview
|
||||
govet:
|
||||
@@ -252,7 +262,6 @@ linters:
|
||||
# - wastedassign
|
||||
|
||||
- staticcheck
|
||||
- tenv
|
||||
# In Go, it's possible for a package to test it's internal functionality
|
||||
# without testing any exported functions. This is enabled to promote
|
||||
# decomposing a package before testing it's internals. A function caller
|
||||
@@ -265,4 +274,5 @@ linters:
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- usetesting
|
||||
- dupl
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
# Coder Development Guidelines
|
||||
|
||||
You are an experienced, pragmatic software engineer. You don't over-engineer a solution when a simple one is possible.
|
||||
Rule #1: If you want exception to ANY rule, YOU MUST STOP and get explicit permission first. BREAKING THE LETTER OR SPIRIT OF THE RULES IS FAILURE.
|
||||
|
||||
## Foundational rules
|
||||
|
||||
- Doing it right is better than doing it fast. You are not in a rush. NEVER skip steps or take shortcuts.
|
||||
- Tedious, systematic work is often the correct solution. Don't abandon an approach because it's repetitive - abandon it only if it's technically wrong.
|
||||
- Honesty is a core value.
|
||||
|
||||
## Our relationship
|
||||
|
||||
- Act as a critical peer reviewer. Your job is to disagree with me when I'm wrong, not to please me. Prioritize accuracy and reasoning over agreement.
|
||||
- YOU MUST speak up immediately when you don't know something or we're in over our heads
|
||||
- YOU MUST call out bad ideas, unreasonable expectations, and mistakes - I depend on this
|
||||
- NEVER be agreeable just to be nice - I NEED your HONEST technical judgment
|
||||
- NEVER write the phrase "You're absolutely right!" You are not a sycophant. We're working together because I value your opinion. Do not agree with me unless you can justify it with evidence or reasoning.
|
||||
- YOU MUST ALWAYS STOP and ask for clarification rather than making assumptions.
|
||||
- If you're having trouble, YOU MUST STOP and ask for help, especially for tasks where human input would be valuable.
|
||||
- When you disagree with my approach, YOU MUST push back. Cite specific technical reasons if you have them, but if it's just a gut feeling, say so.
|
||||
- If you're uncomfortable pushing back out loud, just say "Houston, we have a problem". I'll know what you mean
|
||||
- We discuss architectutral decisions (framework changes, major refactoring, system design) together before implementation. Routine fixes and clear implementations don't need discussion.
|
||||
|
||||
## Proactiveness
|
||||
|
||||
When asked to do something, just do it - including obvious follow-up actions needed to complete the task properly.
|
||||
Only pause to ask for confirmation when:
|
||||
|
||||
- Multiple valid approaches exist and the choice matters
|
||||
- The action would delete or significantly restructure existing code
|
||||
- You genuinely don't understand what's being asked
|
||||
- Your partner asked a question (answer the question, don't jump to implementation)
|
||||
|
||||
@.claude/docs/WORKFLOWS.md
|
||||
@.cursorrules
|
||||
@README.md
|
||||
@package.json
|
||||
|
||||
## 🚀 Essential Commands
|
||||
## Essential Commands
|
||||
|
||||
| Task | Command | Notes |
|
||||
|-------------------|--------------------------|----------------------------------|
|
||||
@@ -21,22 +51,13 @@
|
||||
| **Format** | `make fmt` | Auto-format code |
|
||||
| **Clean** | `make clean` | Clean build artifacts |
|
||||
|
||||
### Frontend Commands (site directory)
|
||||
|
||||
- `pnpm build` - Build frontend
|
||||
- `pnpm dev` - Run development server
|
||||
- `pnpm check` - Run code checks
|
||||
- `pnpm format` - Format frontend code
|
||||
- `pnpm lint` - Lint frontend code
|
||||
- `pnpm test` - Run frontend tests
|
||||
|
||||
### Documentation Commands
|
||||
|
||||
- `pnpm run format-docs` - Format markdown tables in docs
|
||||
- `pnpm run lint-docs` - Lint and fix markdown files
|
||||
- `pnpm run storybook` - Run Storybook (from site directory)
|
||||
|
||||
## 🔧 Critical Patterns
|
||||
## Critical Patterns
|
||||
|
||||
### Database Changes (ALWAYS FOLLOW)
|
||||
|
||||
@@ -78,7 +99,7 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestrict
|
||||
app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
|
||||
```
|
||||
|
||||
## 📋 Quick Reference
|
||||
## Quick Reference
|
||||
|
||||
### Full workflows available in imported WORKFLOWS.md
|
||||
|
||||
@@ -88,14 +109,14 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
|
||||
- [ ] Check if feature touches database - you'll need migrations
|
||||
- [ ] Check if feature touches audit logs - update `enterprise/audit/table.go`
|
||||
|
||||
## 🏗️ Architecture
|
||||
## Architecture
|
||||
|
||||
- **coderd**: Main API service
|
||||
- **provisionerd**: Infrastructure provisioning
|
||||
- **Agents**: Workspace services (SSH, port forwarding)
|
||||
- **Database**: PostgreSQL with `dbauthz` authorization
|
||||
|
||||
## 🧪 Testing
|
||||
## Testing
|
||||
|
||||
### Race Condition Prevention
|
||||
|
||||
@@ -112,21 +133,21 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
|
||||
NEVER use `time.Sleep` to mitigate timing issues. If an issue
|
||||
seems like it should use `time.Sleep`, read through https://github.com/coder/quartz and specifically the [README](https://github.com/coder/quartz/blob/main/README.md) to better understand how to handle timing issues.
|
||||
|
||||
## 🎯 Code Style
|
||||
## Code Style
|
||||
|
||||
### Detailed guidelines in imported WORKFLOWS.md
|
||||
|
||||
- Follow [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md)
|
||||
- Commit format: `type(scope): message`
|
||||
|
||||
## 📚 Detailed Development Guides
|
||||
## Detailed Development Guides
|
||||
|
||||
@.claude/docs/OAUTH2.md
|
||||
@.claude/docs/TESTING.md
|
||||
@.claude/docs/TROUBLESHOOTING.md
|
||||
@.claude/docs/DATABASE.md
|
||||
|
||||
## 🚨 Common Pitfalls
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Audit table errors** → Update `enterprise/audit/table.go`
|
||||
2. **OAuth2 errors** → Return RFC-compliant format
|
||||
|
||||
+2
-12
@@ -18,18 +18,6 @@ coderd/rbac/ @Emyrk
|
||||
scripts/apitypings/ @Emyrk
|
||||
scripts/gensite/ @aslilac
|
||||
|
||||
site/ @aslilac @Parkreiner
|
||||
site/src/hooks/ @Parkreiner
|
||||
# These rules intentionally do not specify any owners. More specific rules
|
||||
# override less specific rules, so these files are "ignored" by the site/ rule.
|
||||
site/e2e/google/protobuf/timestampGenerated.ts
|
||||
site/e2e/provisionerGenerated.ts
|
||||
site/src/api/countriesGenerated.ts
|
||||
site/src/api/rbacresourcesGenerated.ts
|
||||
site/src/api/typesGenerated.ts
|
||||
site/src/testHelpers/entities.ts
|
||||
site/CLAUDE.md
|
||||
|
||||
# The blood and guts of the autostop algorithm, which is quite complex and
|
||||
# requires elite ball knowledge of most of the scheduling code to make changes
|
||||
# without inadvertently affecting other parts of the codebase.
|
||||
@@ -39,3 +27,5 @@ coderd/schedule/autostop.go @deansheather @DanielleMaywood
|
||||
# well as guidance from revenue.
|
||||
coderd/usage/ @deansheather @spikecurtis
|
||||
enterprise/coderd/usage/ @deansheather @spikecurtis
|
||||
|
||||
.github/ @jdomeracki-coder
|
||||
|
||||
@@ -69,6 +69,9 @@ MOST_GO_SRC_FILES := $(shell \
|
||||
# All the shell files in the repo, excluding ignored files.
|
||||
SHELL_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.sh')
|
||||
|
||||
MIGRATION_FILES := $(shell find ./coderd/database/migrations/ -maxdepth 1 $(FIND_EXCLUSIONS) -type f -name '*.sql')
|
||||
FIXTURE_FILES := $(shell find ./coderd/database/migrations/testdata/fixtures/ $(FIND_EXCLUSIONS) -type f -name '*.sql')
|
||||
|
||||
# Ensure we don't use the user's git configs which might cause side-effects
|
||||
GIT_FLAGS = GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
|
||||
|
||||
@@ -561,7 +564,7 @@ endif
|
||||
|
||||
# Note: we don't run zizmor in the lint target because it takes a while. CI
|
||||
# runs it explicitly.
|
||||
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/actions/actionlint lint/check-scopes
|
||||
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/actions/actionlint lint/check-scopes lint/migrations
|
||||
.PHONY: lint
|
||||
|
||||
lint/site-icons:
|
||||
@@ -619,6 +622,12 @@ lint/check-scopes: coderd/database/dump.sql
|
||||
go run ./scripts/check-scopes
|
||||
.PHONY: lint/check-scopes
|
||||
|
||||
# Verify migrations do not hardcode the public schema.
|
||||
lint/migrations:
|
||||
./scripts/check_pg_schema.sh "Migrations" $(MIGRATION_FILES)
|
||||
./scripts/check_pg_schema.sh "Fixtures" $(FIXTURE_FILES)
|
||||
.PHONY: lint/migrations
|
||||
|
||||
# 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 := \
|
||||
@@ -636,16 +645,17 @@ TAILNETTEST_MOCKS := \
|
||||
tailnet/tailnettest/subscriptionmock.go
|
||||
|
||||
AIBRIDGED_MOCKS := \
|
||||
enterprise/x/aibridged/aibridgedmock/clientmock.go \
|
||||
enterprise/x/aibridged/aibridgedmock/poolmock.go
|
||||
enterprise/aibridged/aibridgedmock/clientmock.go \
|
||||
enterprise/aibridged/aibridgedmock/poolmock.go
|
||||
|
||||
GEN_FILES := \
|
||||
tailnet/proto/tailnet.pb.go \
|
||||
agent/proto/agent.pb.go \
|
||||
agent/agentsocket/proto/agentsocket.pb.go \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
vpn/vpn.pb.go \
|
||||
enterprise/x/aibridged/proto/aibridged.pb.go \
|
||||
enterprise/aibridged/proto/aibridged.pb.go \
|
||||
$(DB_GEN_FILES) \
|
||||
$(SITE_GEN_FILES) \
|
||||
coderd/rbac/object_gen.go \
|
||||
@@ -676,6 +686,7 @@ gen/db: $(DB_GEN_FILES)
|
||||
.PHONY: gen/db
|
||||
|
||||
gen/golden-files: \
|
||||
agent/unit/testdata/.gen-golden \
|
||||
cli/testdata/.gen-golden \
|
||||
coderd/.gen-golden \
|
||||
coderd/notifications/.gen-golden \
|
||||
@@ -695,8 +706,9 @@ gen/mark-fresh:
|
||||
agent/proto/agent.pb.go \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
agent/agentsocket/proto/agentsocket.pb.go \
|
||||
vpn/vpn.pb.go \
|
||||
enterprise/x/aibridged/proto/aibridged.pb.go \
|
||||
enterprise/aibridged/proto/aibridged.pb.go \
|
||||
coderd/database/dump.sql \
|
||||
$(DB_GEN_FILES) \
|
||||
site/src/api/typesGenerated.ts \
|
||||
@@ -767,8 +779,8 @@ codersdk/workspacesdk/agentconnmock/agentconnmock.go: codersdk/workspacesdk/agen
|
||||
go generate ./codersdk/workspacesdk/agentconnmock/
|
||||
touch "$@"
|
||||
|
||||
$(AIBRIDGED_MOCKS): enterprise/x/aibridged/client.go enterprise/x/aibridged/pool.go
|
||||
go generate ./enterprise/x/aibridged/aibridgedmock/
|
||||
$(AIBRIDGED_MOCKS): enterprise/aibridged/client.go enterprise/aibridged/pool.go
|
||||
go generate ./enterprise/aibridged/aibridgedmock/
|
||||
touch "$@"
|
||||
|
||||
agent/agentcontainers/dcspec/dcspec_gen.go: \
|
||||
@@ -799,6 +811,14 @@ agent/proto/agent.pb.go: agent/proto/agent.proto
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./agent/proto/agent.proto
|
||||
|
||||
agent/agentsocket/proto/agentsocket.pb.go: agent/agentsocket/proto/agentsocket.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./agent/agentsocket/proto/agentsocket.proto
|
||||
|
||||
provisionersdk/proto/provisioner.pb.go: provisionersdk/proto/provisioner.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
@@ -821,13 +841,13 @@ vpn/vpn.pb.go: vpn/vpn.proto
|
||||
--go_opt=paths=source_relative \
|
||||
./vpn/vpn.proto
|
||||
|
||||
enterprise/x/aibridged/proto/aibridged.pb.go: enterprise/x/aibridged/proto/aibridged.proto
|
||||
enterprise/aibridged/proto/aibridged.pb.go: enterprise/aibridged/proto/aibridged.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./enterprise/x/aibridged/proto/aibridged.proto
|
||||
./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')
|
||||
# -C sets the directory for the go run command
|
||||
@@ -952,6 +972,10 @@ clean/golden-files:
|
||||
-type f -name '*.golden' -delete
|
||||
.PHONY: clean/golden-files
|
||||
|
||||
agent/unit/testdata/.gen-golden: $(wildcard agent/unit/testdata/*.golden) $(GO_SRC_FILES) $(wildcard agent/unit/*_test.go)
|
||||
TZ=UTC go test ./agent/unit -run="TestGraph" -update
|
||||
touch "$@"
|
||||
|
||||
cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard cli/*_test.go)
|
||||
TZ=UTC go test ./cli -run="Test(CommandHelp|ServerYAML|ErrorExamples|.*Golden)" -update
|
||||
touch "$@"
|
||||
@@ -1177,3 +1201,8 @@ endif
|
||||
|
||||
dogfood/coder/nix.hash: flake.nix flake.lock
|
||||
sha256sum flake.nix flake.lock >./dogfood/coder/nix.hash
|
||||
|
||||
# Count the number of test databases created per test package.
|
||||
count-test-databases:
|
||||
PGPASSWORD=postgres psql -h localhost -U postgres -d coder_testing -P pager=off -c 'SELECT test_package, count(*) as count from test_databases GROUP BY test_package ORDER BY count DESC'
|
||||
.PHONY: count-test-databases
|
||||
|
||||
+96
-49
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -40,6 +41,7 @@ import (
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentscripts"
|
||||
"github.com/coder/coder/v2/agent/agentsocket"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/agent/proto/resourcesmonitor"
|
||||
@@ -70,16 +72,21 @@ const (
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Filesystem afero.Fs
|
||||
LogDir string
|
||||
TempDir string
|
||||
ScriptDataDir string
|
||||
Client Client
|
||||
ReconnectingPTYTimeout time.Duration
|
||||
EnvironmentVariables map[string]string
|
||||
Logger slog.Logger
|
||||
IgnorePorts map[int]string
|
||||
PortCacheDuration time.Duration
|
||||
Filesystem afero.Fs
|
||||
LogDir string
|
||||
TempDir string
|
||||
ScriptDataDir string
|
||||
Client Client
|
||||
ReconnectingPTYTimeout time.Duration
|
||||
EnvironmentVariables map[string]string
|
||||
Logger slog.Logger
|
||||
// IgnorePorts tells the api handler which ports to ignore when
|
||||
// listing all listening ports. This is helpful to hide ports that
|
||||
// are used by the agent, that the user does not care about.
|
||||
IgnorePorts map[int]string
|
||||
// ListeningPortsGetter is used to get the list of listening ports. Only
|
||||
// tests should set this. If unset, a default that queries the OS will be used.
|
||||
ListeningPortsGetter ListeningPortsGetter
|
||||
SSHMaxTimeout time.Duration
|
||||
TailnetListenPort uint16
|
||||
Subsystems []codersdk.AgentSubsystem
|
||||
@@ -91,6 +98,8 @@ type Options struct {
|
||||
Devcontainers bool
|
||||
DevcontainerAPIOptions []agentcontainers.Option // Enable Devcontainers for these to be effective.
|
||||
Clock quartz.Clock
|
||||
SocketServerEnabled bool
|
||||
SocketPath string // Path for the agent socket server socket
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
@@ -137,9 +146,7 @@ func New(options Options) Agent {
|
||||
if options.ServiceBannerRefreshInterval == 0 {
|
||||
options.ServiceBannerRefreshInterval = 2 * time.Minute
|
||||
}
|
||||
if options.PortCacheDuration == 0 {
|
||||
options.PortCacheDuration = 1 * time.Second
|
||||
}
|
||||
|
||||
if options.Clock == nil {
|
||||
options.Clock = quartz.NewReal()
|
||||
}
|
||||
@@ -153,30 +160,38 @@ func New(options Options) Agent {
|
||||
options.Execer = agentexec.DefaultExecer
|
||||
}
|
||||
|
||||
if options.ListeningPortsGetter == nil {
|
||||
options.ListeningPortsGetter = &osListeningPortsGetter{
|
||||
cacheDuration: 1 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
hardCtx, hardCancel := context.WithCancel(context.Background())
|
||||
gracefulCtx, gracefulCancel := context.WithCancel(hardCtx)
|
||||
a := &agent{
|
||||
clock: options.Clock,
|
||||
tailnetListenPort: options.TailnetListenPort,
|
||||
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
|
||||
logger: options.Logger,
|
||||
gracefulCtx: gracefulCtx,
|
||||
gracefulCancel: gracefulCancel,
|
||||
hardCtx: hardCtx,
|
||||
hardCancel: hardCancel,
|
||||
coordDisconnected: make(chan struct{}),
|
||||
environmentVariables: options.EnvironmentVariables,
|
||||
client: options.Client,
|
||||
filesystem: options.Filesystem,
|
||||
logDir: options.LogDir,
|
||||
tempDir: options.TempDir,
|
||||
scriptDataDir: options.ScriptDataDir,
|
||||
lifecycleUpdate: make(chan struct{}, 1),
|
||||
lifecycleReported: make(chan codersdk.WorkspaceAgentLifecycle, 1),
|
||||
lifecycleStates: []agentsdk.PostLifecycleRequest{{State: codersdk.WorkspaceAgentLifecycleCreated}},
|
||||
reportConnectionsUpdate: make(chan struct{}, 1),
|
||||
ignorePorts: options.IgnorePorts,
|
||||
portCacheDuration: options.PortCacheDuration,
|
||||
clock: options.Clock,
|
||||
tailnetListenPort: options.TailnetListenPort,
|
||||
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
|
||||
logger: options.Logger,
|
||||
gracefulCtx: gracefulCtx,
|
||||
gracefulCancel: gracefulCancel,
|
||||
hardCtx: hardCtx,
|
||||
hardCancel: hardCancel,
|
||||
coordDisconnected: make(chan struct{}),
|
||||
environmentVariables: options.EnvironmentVariables,
|
||||
client: options.Client,
|
||||
filesystem: options.Filesystem,
|
||||
logDir: options.LogDir,
|
||||
tempDir: options.TempDir,
|
||||
scriptDataDir: options.ScriptDataDir,
|
||||
lifecycleUpdate: make(chan struct{}, 1),
|
||||
lifecycleReported: make(chan codersdk.WorkspaceAgentLifecycle, 1),
|
||||
lifecycleStates: []agentsdk.PostLifecycleRequest{{State: codersdk.WorkspaceAgentLifecycleCreated}},
|
||||
reportConnectionsUpdate: make(chan struct{}, 1),
|
||||
listeningPortsHandler: listeningPortsHandler{
|
||||
getter: options.ListeningPortsGetter,
|
||||
ignorePorts: maps.Clone(options.IgnorePorts),
|
||||
},
|
||||
reportMetadataInterval: options.ReportMetadataInterval,
|
||||
announcementBannersRefreshInterval: options.ServiceBannerRefreshInterval,
|
||||
sshMaxTimeout: options.SSHMaxTimeout,
|
||||
@@ -190,6 +205,8 @@ func New(options Options) Agent {
|
||||
|
||||
devcontainers: options.Devcontainers,
|
||||
containerAPIOptions: options.DevcontainerAPIOptions,
|
||||
socketPath: options.SocketPath,
|
||||
socketServerEnabled: options.SocketServerEnabled,
|
||||
}
|
||||
// Initially, we have a closed channel, reflecting the fact that we are not initially connected.
|
||||
// Each time we connect we replace the channel (while holding the closeMutex) with a new one
|
||||
@@ -202,20 +219,16 @@ func New(options Options) Agent {
|
||||
}
|
||||
|
||||
type agent struct {
|
||||
clock quartz.Clock
|
||||
logger slog.Logger
|
||||
client Client
|
||||
tailnetListenPort uint16
|
||||
filesystem afero.Fs
|
||||
logDir string
|
||||
tempDir string
|
||||
scriptDataDir string
|
||||
// ignorePorts tells the api handler which ports to ignore when
|
||||
// listing all listening ports. This is helpful to hide ports that
|
||||
// are used by the agent, that the user does not care about.
|
||||
ignorePorts map[int]string
|
||||
portCacheDuration time.Duration
|
||||
subsystems []codersdk.AgentSubsystem
|
||||
clock quartz.Clock
|
||||
logger slog.Logger
|
||||
client Client
|
||||
tailnetListenPort uint16
|
||||
filesystem afero.Fs
|
||||
logDir string
|
||||
tempDir string
|
||||
scriptDataDir string
|
||||
listeningPortsHandler listeningPortsHandler
|
||||
subsystems []codersdk.AgentSubsystem
|
||||
|
||||
reconnectingPTYTimeout time.Duration
|
||||
reconnectingPTYServer *reconnectingpty.Server
|
||||
@@ -271,6 +284,10 @@ type agent struct {
|
||||
devcontainers bool
|
||||
containerAPIOptions []agentcontainers.Option
|
||||
containerAPI *agentcontainers.API
|
||||
|
||||
socketServerEnabled bool
|
||||
socketPath string
|
||||
socketServer *agentsocket.Server
|
||||
}
|
||||
|
||||
func (a *agent) TailnetConn() *tailnet.Conn {
|
||||
@@ -350,9 +367,32 @@ func (a *agent) init() {
|
||||
s.ExperimentalContainers = a.devcontainers
|
||||
},
|
||||
)
|
||||
|
||||
a.initSocketServer()
|
||||
|
||||
go a.runLoop()
|
||||
}
|
||||
|
||||
// initSocketServer initializes server that allows direct communication with a workspace agent using IPC.
|
||||
func (a *agent) initSocketServer() {
|
||||
if !a.socketServerEnabled {
|
||||
a.logger.Info(a.hardCtx, "socket server is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
server, err := agentsocket.NewServer(
|
||||
a.logger.Named("socket"),
|
||||
agentsocket.WithPath(a.socketPath),
|
||||
)
|
||||
if err != nil {
|
||||
a.logger.Warn(a.hardCtx, "failed to create socket server", slog.Error(err), slog.F("path", a.socketPath))
|
||||
return
|
||||
}
|
||||
|
||||
a.socketServer = server
|
||||
a.logger.Debug(a.hardCtx, "socket server started", slog.F("path", a.socketPath))
|
||||
}
|
||||
|
||||
// runLoop attempts to start the agent in a retry loop.
|
||||
// Coder may be offline temporarily, a connection issue
|
||||
// may be happening, but regardless after the intermittent
|
||||
@@ -1087,7 +1127,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch metadata: %w", err)
|
||||
}
|
||||
a.logger.Info(ctx, "fetched manifest", slog.F("manifest", mp))
|
||||
a.logger.Info(ctx, "fetched manifest")
|
||||
manifest, err := agentsdk.ManifestFromProto(mp)
|
||||
if err != nil {
|
||||
a.logger.Critical(ctx, "failed to convert manifest", slog.F("manifest", mp), slog.Error(err))
|
||||
@@ -1920,6 +1960,7 @@ func (a *agent) Close() error {
|
||||
lifecycleState = codersdk.WorkspaceAgentLifecycleShutdownError
|
||||
}
|
||||
}
|
||||
|
||||
a.setLifecycle(lifecycleState)
|
||||
|
||||
err = a.scriptRunner.Close()
|
||||
@@ -1927,6 +1968,12 @@ func (a *agent) Close() error {
|
||||
a.logger.Error(a.hardCtx, "script runner close", slog.Error(err))
|
||||
}
|
||||
|
||||
if a.socketServer != nil {
|
||||
if err := a.socketServer.Close(); err != nil {
|
||||
a.logger.Error(a.hardCtx, "socket server close", slog.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.containerAPI.Close(); err != nil {
|
||||
a.logger.Error(a.hardCtx, "container API close", slog.Error(err))
|
||||
}
|
||||
|
||||
+76
-33
@@ -3462,11 +3462,7 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
registry := prometheus.NewRegistry()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
// Make sure we always get a DERP connection for
|
||||
// currently_reachable_peers.
|
||||
DisableDirectConnections: true,
|
||||
}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
o.PrometheusRegistry = registry
|
||||
})
|
||||
|
||||
@@ -3481,16 +3477,31 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
err = session.Shell()
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := []*proto.Stats_Metric{
|
||||
expected := []struct {
|
||||
Name string
|
||||
Type proto.Stats_Metric_Type
|
||||
CheckFn func(float64) error
|
||||
Labels []*proto.Stats_Metric_Label
|
||||
}{
|
||||
{
|
||||
Name: "agent_reconnecting_pty_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
Name: "agent_reconnecting_pty_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 0, got %f", v)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "agent_sessions_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 1,
|
||||
Name: "agent_sessions_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 1 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 1, got %f", v)
|
||||
},
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "magic_type",
|
||||
@@ -3503,24 +3514,44 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_failed_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
Name: "agent_ssh_server_failed_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 0, got %f", v)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_sftp_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
Name: "agent_ssh_server_sftp_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 0, got %f", v)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_sftp_server_errors_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
Name: "agent_ssh_server_sftp_server_errors_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 0, got %f", v)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 1,
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
CheckFn: func(float64) error {
|
||||
// We can't reliably ping a peer here, and networking is out of
|
||||
// scope of this test, so we just test that the metric exists
|
||||
// with the correct labels.
|
||||
return nil
|
||||
},
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "connection_type",
|
||||
@@ -3529,9 +3560,11 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 0,
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
CheckFn: func(float64) error {
|
||||
return nil
|
||||
},
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "connection_type",
|
||||
@@ -3540,9 +3573,20 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_startup_script_seconds",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 1,
|
||||
Name: "coderd_agentstats_startup_script_seconds",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
CheckFn: func(f float64) error {
|
||||
if f >= 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected >= 0, got %f", f)
|
||||
},
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "success",
|
||||
Value: "true",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3564,11 +3608,10 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
for _, m := range mf.GetMetric() {
|
||||
assert.Equal(t, expected[i].Name, mf.GetName())
|
||||
assert.Equal(t, expected[i].Type.String(), mf.GetType().String())
|
||||
// Value is max expected
|
||||
if expected[i].Type == proto.Stats_Metric_GAUGE {
|
||||
assert.GreaterOrEqualf(t, expected[i].Value, m.GetGauge().GetValue(), "expected %s to be greater than or equal to %f, got %f", expected[i].Name, expected[i].Value, m.GetGauge().GetValue())
|
||||
assert.NoError(t, expected[i].CheckFn(m.GetGauge().GetValue()), "check fn for %s failed", expected[i].Name)
|
||||
} else if expected[i].Type == proto.Stats_Metric_COUNTER {
|
||||
assert.GreaterOrEqualf(t, expected[i].Value, m.GetCounter().GetValue(), "expected %s to be greater than or equal to %f, got %f", expected[i].Name, expected[i].Value, m.GetCounter().GetValue())
|
||||
assert.NoError(t, expected[i].CheckFn(m.GetCounter().GetValue()), "check fn for %s failed", expected[i].Name)
|
||||
}
|
||||
for j, lbl := range expected[i].Labels {
|
||||
assert.Equal(t, m.GetLabel()[j], &promgo.LabelPair{
|
||||
|
||||
@@ -682,8 +682,6 @@ func (api *API) updaterLoop() {
|
||||
} else {
|
||||
prevErr = nil
|
||||
}
|
||||
default:
|
||||
api.logger.Debug(api.ctx, "updater loop ticker skipped, update in progress")
|
||||
}
|
||||
|
||||
return nil // Always nil to keep the ticker going.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package agentsocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"storj.io/drpc"
|
||||
"storj.io/drpc/drpcconn"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentsocket/proto"
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
)
|
||||
|
||||
// Option represents a configuration option for NewClient.
|
||||
type Option func(*options)
|
||||
|
||||
type options struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// WithPath sets the socket path. If not provided or empty, the client will
|
||||
// auto-discover the default socket path.
|
||||
func WithPath(path string) Option {
|
||||
return func(opts *options) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
opts.path = path
|
||||
}
|
||||
}
|
||||
|
||||
// Client provides a client for communicating with the workspace agentsocket API.
|
||||
type Client struct {
|
||||
client proto.DRPCAgentSocketClient
|
||||
conn drpc.Conn
|
||||
}
|
||||
|
||||
// NewClient creates a new socket client and opens a connection to the socket.
|
||||
// If path is not provided via WithPath or is empty, it will auto-discover the
|
||||
// default socket path.
|
||||
func NewClient(ctx context.Context, opts ...Option) (*Client, error) {
|
||||
options := &options{}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
conn, err := dialSocket(ctx, options.path)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("connect to socket: %w", err)
|
||||
}
|
||||
|
||||
drpcConn := drpcconn.New(conn)
|
||||
client := proto.NewDRPCAgentSocketClient(drpcConn)
|
||||
|
||||
return &Client{
|
||||
client: client,
|
||||
conn: drpcConn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close closes the socket connection.
|
||||
func (c *Client) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
// Ping sends a ping request to the agent.
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
_, err := c.client.Ping(ctx, &proto.PingRequest{})
|
||||
return err
|
||||
}
|
||||
|
||||
// SyncStart starts a unit in the dependency graph.
|
||||
func (c *Client) SyncStart(ctx context.Context, unitName unit.ID) error {
|
||||
_, err := c.client.SyncStart(ctx, &proto.SyncStartRequest{
|
||||
Unit: string(unitName),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SyncWant declares a dependency between units.
|
||||
func (c *Client) SyncWant(ctx context.Context, unitName, dependsOn unit.ID) error {
|
||||
_, err := c.client.SyncWant(ctx, &proto.SyncWantRequest{
|
||||
Unit: string(unitName),
|
||||
DependsOn: string(dependsOn),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SyncComplete marks a unit as complete in the dependency graph.
|
||||
func (c *Client) SyncComplete(ctx context.Context, unitName unit.ID) error {
|
||||
_, err := c.client.SyncComplete(ctx, &proto.SyncCompleteRequest{
|
||||
Unit: string(unitName),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SyncReady requests whether a unit is ready to be started. That is, all dependencies are satisfied.
|
||||
func (c *Client) SyncReady(ctx context.Context, unitName unit.ID) (bool, error) {
|
||||
resp, err := c.client.SyncReady(ctx, &proto.SyncReadyRequest{
|
||||
Unit: string(unitName),
|
||||
})
|
||||
return resp.Ready, err
|
||||
}
|
||||
|
||||
// SyncStatus gets the status of a unit and its dependencies.
|
||||
func (c *Client) SyncStatus(ctx context.Context, unitName unit.ID) (SyncStatusResponse, error) {
|
||||
resp, err := c.client.SyncStatus(ctx, &proto.SyncStatusRequest{
|
||||
Unit: string(unitName),
|
||||
})
|
||||
if err != nil {
|
||||
return SyncStatusResponse{}, err
|
||||
}
|
||||
|
||||
var dependencies []DependencyInfo
|
||||
for _, dep := range resp.Dependencies {
|
||||
dependencies = append(dependencies, DependencyInfo{
|
||||
DependsOn: unit.ID(dep.DependsOn),
|
||||
RequiredStatus: unit.Status(dep.RequiredStatus),
|
||||
CurrentStatus: unit.Status(dep.CurrentStatus),
|
||||
IsSatisfied: dep.IsSatisfied,
|
||||
})
|
||||
}
|
||||
|
||||
return SyncStatusResponse{
|
||||
UnitName: unitName,
|
||||
Status: unit.Status(resp.Status),
|
||||
IsReady: resp.IsReady,
|
||||
Dependencies: dependencies,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SyncStatusResponse contains the status information for a unit.
|
||||
type SyncStatusResponse struct {
|
||||
UnitName unit.ID `table:"unit,default_sort" json:"unit_name"`
|
||||
Status unit.Status `table:"status" json:"status"`
|
||||
IsReady bool `table:"ready" json:"is_ready"`
|
||||
Dependencies []DependencyInfo `table:"dependencies" json:"dependencies"`
|
||||
}
|
||||
|
||||
// DependencyInfo contains information about a unit dependency.
|
||||
type DependencyInfo struct {
|
||||
DependsOn unit.ID `table:"depends on,default_sort" json:"depends_on"`
|
||||
RequiredStatus unit.Status `table:"required status" json:"required_status"`
|
||||
CurrentStatus unit.Status `table:"current status" json:"current_status"`
|
||||
IsSatisfied bool `table:"satisfied" json:"is_satisfied"`
|
||||
}
|
||||
@@ -0,0 +1,968 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.30.0
|
||||
// protoc v4.23.4
|
||||
// source: agent/agentsocket/proto/agentsocket.proto
|
||||
|
||||
package proto
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type PingRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *PingRequest) Reset() {
|
||||
*x = PingRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *PingRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PingRequest) ProtoMessage() {}
|
||||
|
||||
func (x *PingRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PingRequest.ProtoReflect.Descriptor instead.
|
||||
func (*PingRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
type PingResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *PingResponse) Reset() {
|
||||
*x = PingResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *PingResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PingResponse) ProtoMessage() {}
|
||||
|
||||
func (x *PingResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[1]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PingResponse.ProtoReflect.Descriptor instead.
|
||||
func (*PingResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
type SyncStartRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncStartRequest) Reset() {
|
||||
*x = SyncStartRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncStartRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncStartRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStartRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[2]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncStartRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStartRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *SyncStartRequest) GetUnit() string {
|
||||
if x != nil {
|
||||
return x.Unit
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SyncStartResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *SyncStartResponse) Reset() {
|
||||
*x = SyncStartResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncStartResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncStartResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStartResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[3]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncStartResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStartResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
type SyncWantRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
DependsOn string `protobuf:"bytes,2,opt,name=depends_on,json=dependsOn,proto3" json:"depends_on,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncWantRequest) Reset() {
|
||||
*x = SyncWantRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncWantRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncWantRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncWantRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[4]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncWantRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SyncWantRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *SyncWantRequest) GetUnit() string {
|
||||
if x != nil {
|
||||
return x.Unit
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SyncWantRequest) GetDependsOn() string {
|
||||
if x != nil {
|
||||
return x.DependsOn
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SyncWantResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *SyncWantResponse) Reset() {
|
||||
*x = SyncWantResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncWantResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncWantResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncWantResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[5]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncWantResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SyncWantResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
type SyncCompleteRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncCompleteRequest) Reset() {
|
||||
*x = SyncCompleteRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncCompleteRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncCompleteRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncCompleteRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[6]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncCompleteRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SyncCompleteRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *SyncCompleteRequest) GetUnit() string {
|
||||
if x != nil {
|
||||
return x.Unit
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SyncCompleteResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *SyncCompleteResponse) Reset() {
|
||||
*x = SyncCompleteResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncCompleteResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncCompleteResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncCompleteResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[7]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncCompleteResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SyncCompleteResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
type SyncReadyRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncReadyRequest) Reset() {
|
||||
*x = SyncReadyRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncReadyRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncReadyRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncReadyRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[8]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncReadyRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SyncReadyRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *SyncReadyRequest) GetUnit() string {
|
||||
if x != nil {
|
||||
return x.Unit
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SyncReadyResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Ready bool `protobuf:"varint,1,opt,name=ready,proto3" json:"ready,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncReadyResponse) Reset() {
|
||||
*x = SyncReadyResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncReadyResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncReadyResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncReadyResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[9]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncReadyResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SyncReadyResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
func (x *SyncReadyResponse) GetReady() bool {
|
||||
if x != nil {
|
||||
return x.Ready
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type SyncStatusRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncStatusRequest) Reset() {
|
||||
*x = SyncStatusRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncStatusRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncStatusRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStatusRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[10]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncStatusRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStatusRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{10}
|
||||
}
|
||||
|
||||
func (x *SyncStatusRequest) GetUnit() string {
|
||||
if x != nil {
|
||||
return x.Unit
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type DependencyInfo struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
DependsOn string `protobuf:"bytes,2,opt,name=depends_on,json=dependsOn,proto3" json:"depends_on,omitempty"`
|
||||
RequiredStatus string `protobuf:"bytes,3,opt,name=required_status,json=requiredStatus,proto3" json:"required_status,omitempty"`
|
||||
CurrentStatus string `protobuf:"bytes,4,opt,name=current_status,json=currentStatus,proto3" json:"current_status,omitempty"`
|
||||
IsSatisfied bool `protobuf:"varint,5,opt,name=is_satisfied,json=isSatisfied,proto3" json:"is_satisfied,omitempty"`
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) Reset() {
|
||||
*x = DependencyInfo{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*DependencyInfo) ProtoMessage() {}
|
||||
|
||||
func (x *DependencyInfo) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[11]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use DependencyInfo.ProtoReflect.Descriptor instead.
|
||||
func (*DependencyInfo) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) GetUnit() string {
|
||||
if x != nil {
|
||||
return x.Unit
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) GetDependsOn() string {
|
||||
if x != nil {
|
||||
return x.DependsOn
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) GetRequiredStatus() string {
|
||||
if x != nil {
|
||||
return x.RequiredStatus
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) GetCurrentStatus() string {
|
||||
if x != nil {
|
||||
return x.CurrentStatus
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) GetIsSatisfied() bool {
|
||||
if x != nil {
|
||||
return x.IsSatisfied
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type SyncStatusResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"`
|
||||
IsReady bool `protobuf:"varint,2,opt,name=is_ready,json=isReady,proto3" json:"is_ready,omitempty"`
|
||||
Dependencies []*DependencyInfo `protobuf:"bytes,3,rep,name=dependencies,proto3" json:"dependencies,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncStatusResponse) Reset() {
|
||||
*x = SyncStatusResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncStatusResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncStatusResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStatusResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[12]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncStatusResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStatusResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *SyncStatusResponse) GetStatus() string {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SyncStatusResponse) GetIsReady() bool {
|
||||
if x != nil {
|
||||
return x.IsReady
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *SyncStatusResponse) GetDependencies() []*DependencyInfo {
|
||||
if x != nil {
|
||||
return x.Dependencies
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_agent_agentsocket_proto_agentsocket_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_agent_agentsocket_proto_agentsocket_proto_rawDesc = []byte{
|
||||
0x0a, 0x29, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73,
|
||||
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x14, 0x63, 0x6f, 0x64,
|
||||
0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76,
|
||||
0x31, 0x22, 0x0d, 0x0a, 0x0b, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x22, 0x0e, 0x0a, 0x0c, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x22, 0x26, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x22, 0x13, 0x0a, 0x11, 0x53, 0x79, 0x6e, 0x63,
|
||||
0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x44, 0x0a,
|
||||
0x0f, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
|
||||
0x75, 0x6e, 0x69, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x73, 0x5f,
|
||||
0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64,
|
||||
0x73, 0x4f, 0x6e, 0x22, 0x12, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x29, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x43,
|
||||
0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12,
|
||||
0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e,
|
||||
0x69, 0x74, 0x22, 0x16, 0x0a, 0x14, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65,
|
||||
0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x0a, 0x10, 0x53, 0x79,
|
||||
0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12,
|
||||
0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e,
|
||||
0x69, 0x74, 0x22, 0x29, 0x0a, 0x11, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x61, 0x64, 0x79,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x72, 0x65, 0x61, 0x64, 0x79, 0x22, 0x27, 0x0a,
|
||||
0x11, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x22, 0xb6, 0x01, 0x0a, 0x0e, 0x44, 0x65, 0x70, 0x65, 0x6e,
|
||||
0x64, 0x65, 0x6e, 0x63, 0x79, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69,
|
||||
0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x12, 0x1d, 0x0a,
|
||||
0x0a, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x73, 0x5f, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x09, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x73, 0x4f, 0x6e, 0x12, 0x27, 0x0a, 0x0f,
|
||||
0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x53,
|
||||
0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74,
|
||||
0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63,
|
||||
0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x21, 0x0a, 0x0c,
|
||||
0x69, 0x73, 0x5f, 0x73, 0x61, 0x74, 0x69, 0x73, 0x66, 0x69, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01,
|
||||
0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x53, 0x61, 0x74, 0x69, 0x73, 0x66, 0x69, 0x65, 0x64, 0x22,
|
||||
0x91, 0x01, 0x0a, 0x12, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x19,
|
||||
0x0a, 0x08, 0x69, 0x73, 0x5f, 0x72, 0x65, 0x61, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08,
|
||||
0x52, 0x07, 0x69, 0x73, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12, 0x48, 0x0a, 0x0c, 0x64, 0x65, 0x70,
|
||||
0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32,
|
||||
0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63,
|
||||
0x79, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0c, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63,
|
||||
0x69, 0x65, 0x73, 0x32, 0xbb, 0x04, 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x12, 0x4d, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x21, 0x2e, 0x63, 0x6f,
|
||||
0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e,
|
||||
0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22,
|
||||
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b,
|
||||
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12,
|
||||
0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
|
||||
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53,
|
||||
0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x59, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63,
|
||||
0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74,
|
||||
0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
|
||||
0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57,
|
||||
0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a, 0x0c, 0x53,
|
||||
0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x29, 0x2e, 0x63, 0x6f,
|
||||
0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e,
|
||||
0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
|
||||
0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79,
|
||||
0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12,
|
||||
0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
|
||||
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53,
|
||||
0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x5f, 0x0a, 0x0a, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x27,
|
||||
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b,
|
||||
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
|
||||
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53,
|
||||
0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
|
||||
0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61,
|
||||
0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74,
|
||||
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDescOnce sync.Once
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDescData = file_agent_agentsocket_proto_agentsocket_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP() []byte {
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDescOnce.Do(func() {
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDescData = protoimpl.X.CompressGZIP(file_agent_agentsocket_proto_agentsocket_proto_rawDescData)
|
||||
})
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_agent_agentsocket_proto_agentsocket_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
|
||||
var file_agent_agentsocket_proto_agentsocket_proto_goTypes = []interface{}{
|
||||
(*PingRequest)(nil), // 0: coder.agentsocket.v1.PingRequest
|
||||
(*PingResponse)(nil), // 1: coder.agentsocket.v1.PingResponse
|
||||
(*SyncStartRequest)(nil), // 2: coder.agentsocket.v1.SyncStartRequest
|
||||
(*SyncStartResponse)(nil), // 3: coder.agentsocket.v1.SyncStartResponse
|
||||
(*SyncWantRequest)(nil), // 4: coder.agentsocket.v1.SyncWantRequest
|
||||
(*SyncWantResponse)(nil), // 5: coder.agentsocket.v1.SyncWantResponse
|
||||
(*SyncCompleteRequest)(nil), // 6: coder.agentsocket.v1.SyncCompleteRequest
|
||||
(*SyncCompleteResponse)(nil), // 7: coder.agentsocket.v1.SyncCompleteResponse
|
||||
(*SyncReadyRequest)(nil), // 8: coder.agentsocket.v1.SyncReadyRequest
|
||||
(*SyncReadyResponse)(nil), // 9: coder.agentsocket.v1.SyncReadyResponse
|
||||
(*SyncStatusRequest)(nil), // 10: coder.agentsocket.v1.SyncStatusRequest
|
||||
(*DependencyInfo)(nil), // 11: coder.agentsocket.v1.DependencyInfo
|
||||
(*SyncStatusResponse)(nil), // 12: coder.agentsocket.v1.SyncStatusResponse
|
||||
}
|
||||
var file_agent_agentsocket_proto_agentsocket_proto_depIdxs = []int32{
|
||||
11, // 0: coder.agentsocket.v1.SyncStatusResponse.dependencies:type_name -> coder.agentsocket.v1.DependencyInfo
|
||||
0, // 1: coder.agentsocket.v1.AgentSocket.Ping:input_type -> coder.agentsocket.v1.PingRequest
|
||||
2, // 2: coder.agentsocket.v1.AgentSocket.SyncStart:input_type -> coder.agentsocket.v1.SyncStartRequest
|
||||
4, // 3: coder.agentsocket.v1.AgentSocket.SyncWant:input_type -> coder.agentsocket.v1.SyncWantRequest
|
||||
6, // 4: coder.agentsocket.v1.AgentSocket.SyncComplete:input_type -> coder.agentsocket.v1.SyncCompleteRequest
|
||||
8, // 5: coder.agentsocket.v1.AgentSocket.SyncReady:input_type -> coder.agentsocket.v1.SyncReadyRequest
|
||||
10, // 6: coder.agentsocket.v1.AgentSocket.SyncStatus:input_type -> coder.agentsocket.v1.SyncStatusRequest
|
||||
1, // 7: coder.agentsocket.v1.AgentSocket.Ping:output_type -> coder.agentsocket.v1.PingResponse
|
||||
3, // 8: coder.agentsocket.v1.AgentSocket.SyncStart:output_type -> coder.agentsocket.v1.SyncStartResponse
|
||||
5, // 9: coder.agentsocket.v1.AgentSocket.SyncWant:output_type -> coder.agentsocket.v1.SyncWantResponse
|
||||
7, // 10: coder.agentsocket.v1.AgentSocket.SyncComplete:output_type -> coder.agentsocket.v1.SyncCompleteResponse
|
||||
9, // 11: coder.agentsocket.v1.AgentSocket.SyncReady:output_type -> coder.agentsocket.v1.SyncReadyResponse
|
||||
12, // 12: coder.agentsocket.v1.AgentSocket.SyncStatus:output_type -> coder.agentsocket.v1.SyncStatusResponse
|
||||
7, // [7:13] is the sub-list for method output_type
|
||||
1, // [1:7] is the sub-list for method input_type
|
||||
1, // [1:1] is the sub-list for extension type_name
|
||||
1, // [1:1] is the sub-list for extension extendee
|
||||
0, // [0:1] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_agent_agentsocket_proto_agentsocket_proto_init() }
|
||||
func file_agent_agentsocket_proto_agentsocket_proto_init() {
|
||||
if File_agent_agentsocket_proto_agentsocket_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*PingRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*PingResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncStartRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncStartResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncWantRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncWantResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncCompleteRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncCompleteResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncReadyRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncReadyResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncStatusRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*DependencyInfo); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncStatusResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_agent_agentsocket_proto_agentsocket_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 13,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_agent_agentsocket_proto_agentsocket_proto_goTypes,
|
||||
DependencyIndexes: file_agent_agentsocket_proto_agentsocket_proto_depIdxs,
|
||||
MessageInfos: file_agent_agentsocket_proto_agentsocket_proto_msgTypes,
|
||||
}.Build()
|
||||
File_agent_agentsocket_proto_agentsocket_proto = out.File
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDesc = nil
|
||||
file_agent_agentsocket_proto_agentsocket_proto_goTypes = nil
|
||||
file_agent_agentsocket_proto_agentsocket_proto_depIdxs = nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
syntax = "proto3";
|
||||
option go_package = "github.com/coder/coder/v2/agent/agentsocket/proto";
|
||||
|
||||
package coder.agentsocket.v1;
|
||||
|
||||
message PingRequest {}
|
||||
|
||||
message PingResponse {}
|
||||
|
||||
message SyncStartRequest {
|
||||
string unit = 1;
|
||||
}
|
||||
|
||||
message SyncStartResponse {}
|
||||
|
||||
message SyncWantRequest {
|
||||
string unit = 1;
|
||||
string depends_on = 2;
|
||||
}
|
||||
|
||||
message SyncWantResponse {}
|
||||
|
||||
message SyncCompleteRequest {
|
||||
string unit = 1;
|
||||
}
|
||||
|
||||
message SyncCompleteResponse {}
|
||||
|
||||
message SyncReadyRequest {
|
||||
string unit = 1;
|
||||
}
|
||||
|
||||
message SyncReadyResponse {
|
||||
bool ready = 1;
|
||||
}
|
||||
|
||||
message SyncStatusRequest {
|
||||
string unit = 1;
|
||||
}
|
||||
|
||||
message DependencyInfo {
|
||||
string unit = 1;
|
||||
string depends_on = 2;
|
||||
string required_status = 3;
|
||||
string current_status = 4;
|
||||
bool is_satisfied = 5;
|
||||
}
|
||||
|
||||
message SyncStatusResponse {
|
||||
string status = 1;
|
||||
bool is_ready = 2;
|
||||
repeated DependencyInfo dependencies = 3;
|
||||
}
|
||||
|
||||
// AgentSocket provides direct access to the agent over local IPC.
|
||||
service AgentSocket {
|
||||
// Ping the agent to check if it is alive.
|
||||
rpc Ping(PingRequest) returns (PingResponse);
|
||||
// Report the start of a unit.
|
||||
rpc SyncStart(SyncStartRequest) returns (SyncStartResponse);
|
||||
// Declare a dependency between units.
|
||||
rpc SyncWant(SyncWantRequest) returns (SyncWantResponse);
|
||||
// Report the completion of a unit.
|
||||
rpc SyncComplete(SyncCompleteRequest) returns (SyncCompleteResponse);
|
||||
// Request whether a unit is ready to be started. That is, all dependencies are satisfied.
|
||||
rpc SyncReady(SyncReadyRequest) returns (SyncReadyResponse);
|
||||
// Get the status of a unit and list its dependencies.
|
||||
rpc SyncStatus(SyncStatusRequest) returns (SyncStatusResponse);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Code generated by protoc-gen-go-drpc. DO NOT EDIT.
|
||||
// protoc-gen-go-drpc version: v0.0.34
|
||||
// source: agent/agentsocket/proto/agentsocket.proto
|
||||
|
||||
package proto
|
||||
|
||||
import (
|
||||
context "context"
|
||||
errors "errors"
|
||||
protojson "google.golang.org/protobuf/encoding/protojson"
|
||||
proto "google.golang.org/protobuf/proto"
|
||||
drpc "storj.io/drpc"
|
||||
drpcerr "storj.io/drpc/drpcerr"
|
||||
)
|
||||
|
||||
type drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto struct{}
|
||||
|
||||
func (drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto) Marshal(msg drpc.Message) ([]byte, error) {
|
||||
return proto.Marshal(msg.(proto.Message))
|
||||
}
|
||||
|
||||
func (drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto) MarshalAppend(buf []byte, msg drpc.Message) ([]byte, error) {
|
||||
return proto.MarshalOptions{}.MarshalAppend(buf, msg.(proto.Message))
|
||||
}
|
||||
|
||||
func (drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto) Unmarshal(buf []byte, msg drpc.Message) error {
|
||||
return proto.Unmarshal(buf, msg.(proto.Message))
|
||||
}
|
||||
|
||||
func (drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto) JSONMarshal(msg drpc.Message) ([]byte, error) {
|
||||
return protojson.Marshal(msg.(proto.Message))
|
||||
}
|
||||
|
||||
func (drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto) JSONUnmarshal(buf []byte, msg drpc.Message) error {
|
||||
return protojson.Unmarshal(buf, msg.(proto.Message))
|
||||
}
|
||||
|
||||
type DRPCAgentSocketClient interface {
|
||||
DRPCConn() drpc.Conn
|
||||
|
||||
Ping(ctx context.Context, in *PingRequest) (*PingResponse, error)
|
||||
SyncStart(ctx context.Context, in *SyncStartRequest) (*SyncStartResponse, error)
|
||||
SyncWant(ctx context.Context, in *SyncWantRequest) (*SyncWantResponse, error)
|
||||
SyncComplete(ctx context.Context, in *SyncCompleteRequest) (*SyncCompleteResponse, error)
|
||||
SyncReady(ctx context.Context, in *SyncReadyRequest) (*SyncReadyResponse, error)
|
||||
SyncStatus(ctx context.Context, in *SyncStatusRequest) (*SyncStatusResponse, error)
|
||||
}
|
||||
|
||||
type drpcAgentSocketClient struct {
|
||||
cc drpc.Conn
|
||||
}
|
||||
|
||||
func NewDRPCAgentSocketClient(cc drpc.Conn) DRPCAgentSocketClient {
|
||||
return &drpcAgentSocketClient{cc}
|
||||
}
|
||||
|
||||
func (c *drpcAgentSocketClient) DRPCConn() drpc.Conn { return c.cc }
|
||||
|
||||
func (c *drpcAgentSocketClient) Ping(ctx context.Context, in *PingRequest) (*PingResponse, error) {
|
||||
out := new(PingResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/Ping", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentSocketClient) SyncStart(ctx context.Context, in *SyncStartRequest) (*SyncStartResponse, error) {
|
||||
out := new(SyncStartResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/SyncStart", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentSocketClient) SyncWant(ctx context.Context, in *SyncWantRequest) (*SyncWantResponse, error) {
|
||||
out := new(SyncWantResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/SyncWant", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentSocketClient) SyncComplete(ctx context.Context, in *SyncCompleteRequest) (*SyncCompleteResponse, error) {
|
||||
out := new(SyncCompleteResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/SyncComplete", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentSocketClient) SyncReady(ctx context.Context, in *SyncReadyRequest) (*SyncReadyResponse, error) {
|
||||
out := new(SyncReadyResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/SyncReady", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentSocketClient) SyncStatus(ctx context.Context, in *SyncStatusRequest) (*SyncStatusResponse, error) {
|
||||
out := new(SyncStatusResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/SyncStatus", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type DRPCAgentSocketServer interface {
|
||||
Ping(context.Context, *PingRequest) (*PingResponse, error)
|
||||
SyncStart(context.Context, *SyncStartRequest) (*SyncStartResponse, error)
|
||||
SyncWant(context.Context, *SyncWantRequest) (*SyncWantResponse, error)
|
||||
SyncComplete(context.Context, *SyncCompleteRequest) (*SyncCompleteResponse, error)
|
||||
SyncReady(context.Context, *SyncReadyRequest) (*SyncReadyResponse, error)
|
||||
SyncStatus(context.Context, *SyncStatusRequest) (*SyncStatusResponse, error)
|
||||
}
|
||||
|
||||
type DRPCAgentSocketUnimplementedServer struct{}
|
||||
|
||||
func (s *DRPCAgentSocketUnimplementedServer) Ping(context.Context, *PingRequest) (*PingResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentSocketUnimplementedServer) SyncStart(context.Context, *SyncStartRequest) (*SyncStartResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentSocketUnimplementedServer) SyncWant(context.Context, *SyncWantRequest) (*SyncWantResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentSocketUnimplementedServer) SyncComplete(context.Context, *SyncCompleteRequest) (*SyncCompleteResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentSocketUnimplementedServer) SyncReady(context.Context, *SyncReadyRequest) (*SyncReadyResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentSocketUnimplementedServer) SyncStatus(context.Context, *SyncStatusRequest) (*SyncStatusResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
type DRPCAgentSocketDescription struct{}
|
||||
|
||||
func (DRPCAgentSocketDescription) NumMethods() int { return 6 }
|
||||
|
||||
func (DRPCAgentSocketDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) {
|
||||
switch n {
|
||||
case 0:
|
||||
return "/coder.agentsocket.v1.AgentSocket/Ping", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentSocketServer).
|
||||
Ping(
|
||||
ctx,
|
||||
in1.(*PingRequest),
|
||||
)
|
||||
}, DRPCAgentSocketServer.Ping, true
|
||||
case 1:
|
||||
return "/coder.agentsocket.v1.AgentSocket/SyncStart", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentSocketServer).
|
||||
SyncStart(
|
||||
ctx,
|
||||
in1.(*SyncStartRequest),
|
||||
)
|
||||
}, DRPCAgentSocketServer.SyncStart, true
|
||||
case 2:
|
||||
return "/coder.agentsocket.v1.AgentSocket/SyncWant", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentSocketServer).
|
||||
SyncWant(
|
||||
ctx,
|
||||
in1.(*SyncWantRequest),
|
||||
)
|
||||
}, DRPCAgentSocketServer.SyncWant, true
|
||||
case 3:
|
||||
return "/coder.agentsocket.v1.AgentSocket/SyncComplete", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentSocketServer).
|
||||
SyncComplete(
|
||||
ctx,
|
||||
in1.(*SyncCompleteRequest),
|
||||
)
|
||||
}, DRPCAgentSocketServer.SyncComplete, true
|
||||
case 4:
|
||||
return "/coder.agentsocket.v1.AgentSocket/SyncReady", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentSocketServer).
|
||||
SyncReady(
|
||||
ctx,
|
||||
in1.(*SyncReadyRequest),
|
||||
)
|
||||
}, DRPCAgentSocketServer.SyncReady, true
|
||||
case 5:
|
||||
return "/coder.agentsocket.v1.AgentSocket/SyncStatus", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentSocketServer).
|
||||
SyncStatus(
|
||||
ctx,
|
||||
in1.(*SyncStatusRequest),
|
||||
)
|
||||
}, DRPCAgentSocketServer.SyncStatus, true
|
||||
default:
|
||||
return "", nil, nil, nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func DRPCRegisterAgentSocket(mux drpc.Mux, impl DRPCAgentSocketServer) error {
|
||||
return mux.Register(impl, DRPCAgentSocketDescription{})
|
||||
}
|
||||
|
||||
type DRPCAgentSocket_PingStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*PingResponse) error
|
||||
}
|
||||
|
||||
type drpcAgentSocket_PingStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgentSocket_PingStream) SendAndClose(m *PingResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgentSocket_SyncStartStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*SyncStartResponse) error
|
||||
}
|
||||
|
||||
type drpcAgentSocket_SyncStartStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgentSocket_SyncStartStream) SendAndClose(m *SyncStartResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgentSocket_SyncWantStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*SyncWantResponse) error
|
||||
}
|
||||
|
||||
type drpcAgentSocket_SyncWantStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgentSocket_SyncWantStream) SendAndClose(m *SyncWantResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgentSocket_SyncCompleteStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*SyncCompleteResponse) error
|
||||
}
|
||||
|
||||
type drpcAgentSocket_SyncCompleteStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgentSocket_SyncCompleteStream) SendAndClose(m *SyncCompleteResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgentSocket_SyncReadyStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*SyncReadyResponse) error
|
||||
}
|
||||
|
||||
type drpcAgentSocket_SyncReadyStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgentSocket_SyncReadyStream) SendAndClose(m *SyncReadyResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgentSocket_SyncStatusStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*SyncStatusResponse) error
|
||||
}
|
||||
|
||||
type drpcAgentSocket_SyncStatusStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgentSocket_SyncStatusStream) SendAndClose(m *SyncStatusResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package proto
|
||||
|
||||
import "github.com/coder/coder/v2/apiversion"
|
||||
|
||||
// Version history:
|
||||
//
|
||||
// API v1.0:
|
||||
// - Initial release
|
||||
// - Ping
|
||||
// - Sync operations: SyncStart, SyncWant, SyncComplete, SyncWait, SyncStatus
|
||||
|
||||
const (
|
||||
CurrentMajor = 1
|
||||
CurrentMinor = 0
|
||||
)
|
||||
|
||||
var CurrentVersion = apiversion.New(CurrentMajor, CurrentMinor)
|
||||
@@ -0,0 +1,138 @@
|
||||
package agentsocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"storj.io/drpc/drpcmux"
|
||||
"storj.io/drpc/drpcserver"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent/agentsocket/proto"
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
"github.com/coder/coder/v2/codersdk/drpcsdk"
|
||||
)
|
||||
|
||||
// Server provides access to the DRPCAgentSocketService via a Unix domain socket.
|
||||
// Do not invoke Server{} directly. Use NewServer() instead.
|
||||
type Server struct {
|
||||
logger slog.Logger
|
||||
path string
|
||||
drpcServer *drpcserver.Server
|
||||
service *DRPCAgentSocketService
|
||||
|
||||
mu sync.Mutex
|
||||
listener net.Listener
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewServer creates a new agent socket server.
|
||||
func NewServer(logger slog.Logger, opts ...Option) (*Server, error) {
|
||||
options := &options{}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
logger = logger.Named("agentsocket-server")
|
||||
server := &Server{
|
||||
logger: logger,
|
||||
path: options.path,
|
||||
service: &DRPCAgentSocketService{
|
||||
logger: logger,
|
||||
unitManager: unit.NewManager(),
|
||||
},
|
||||
}
|
||||
|
||||
mux := drpcmux.New()
|
||||
err := proto.DRPCRegisterAgentSocket(mux, server.service)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to register drpc service: %w", err)
|
||||
}
|
||||
|
||||
server.drpcServer = drpcserver.NewWithOptions(mux, drpcserver.Options{
|
||||
Manager: drpcsdk.DefaultDRPCOptions(nil),
|
||||
Log: func(err error) {
|
||||
if errors.Is(err, context.Canceled) ||
|
||||
errors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
}
|
||||
logger.Debug(context.Background(), "drpc server error", slog.Error(err))
|
||||
},
|
||||
})
|
||||
|
||||
listener, err := createSocket(server.path)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create socket: %w", err)
|
||||
}
|
||||
|
||||
server.listener = listener
|
||||
|
||||
// This context is canceled by server.Close().
|
||||
// canceling it will close all connections.
|
||||
server.ctx, server.cancel = context.WithCancel(context.Background())
|
||||
|
||||
server.logger.Info(server.ctx, "agent socket server started", slog.F("path", server.path))
|
||||
|
||||
server.wg.Add(1)
|
||||
go func() {
|
||||
defer server.wg.Done()
|
||||
server.acceptConnections()
|
||||
}()
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
// Close stops the server and cleans up resources.
|
||||
func (s *Server) Close() error {
|
||||
s.mu.Lock()
|
||||
|
||||
if s.listener == nil {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info(s.ctx, "stopping agent socket server")
|
||||
|
||||
s.cancel()
|
||||
|
||||
if err := s.listener.Close(); err != nil {
|
||||
s.logger.Warn(s.ctx, "error closing socket listener", slog.Error(err))
|
||||
}
|
||||
|
||||
s.listener = nil
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
// Wait for all connections to finish
|
||||
s.wg.Wait()
|
||||
|
||||
if err := cleanupSocket(s.path); err != nil {
|
||||
s.logger.Warn(s.ctx, "error cleaning up socket file", slog.Error(err))
|
||||
}
|
||||
|
||||
s.logger.Info(s.ctx, "agent socket server stopped")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) acceptConnections() {
|
||||
// In an edge case, Close() might race with acceptConnections() and set s.listener to nil.
|
||||
// Therefore, we grab a copy of the listener under a lock. We might still get a nil listener,
|
||||
// but then we know close has already run and we can return early.
|
||||
s.mu.Lock()
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
if listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := s.drpcServer.Serve(s.ctx, listener)
|
||||
if err != nil {
|
||||
s.logger.Warn(s.ctx, "error serving drpc server", slog.Error(err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package agentsocket_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agentsocket"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
"github.com/coder/coder/v2/tailnet/tailnettest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestServer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("agentsocket is not supported on Windows")
|
||||
}
|
||||
|
||||
t.Run("StartStop", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(t.TempDir(), "test.sock")
|
||||
logger := slog.Make().Leveled(slog.LevelDebug)
|
||||
server, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, server.Close())
|
||||
})
|
||||
|
||||
t.Run("AlreadyStarted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(t.TempDir(), "test.sock")
|
||||
logger := slog.Make().Leveled(slog.LevelDebug)
|
||||
server1, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
|
||||
require.NoError(t, err)
|
||||
defer server1.Close()
|
||||
_, err = agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
|
||||
require.ErrorContains(t, err, "create socket")
|
||||
})
|
||||
|
||||
t.Run("AutoSocketPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(t.TempDir(), "test.sock")
|
||||
logger := slog.Make().Leveled(slog.LevelDebug)
|
||||
server, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, server.Close())
|
||||
})
|
||||
}
|
||||
|
||||
func TestServerWindowsNotSupported(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("this test only runs on Windows")
|
||||
}
|
||||
|
||||
t.Run("NewServer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(t.TempDir(), "test.sock")
|
||||
logger := slog.Make().Leveled(slog.LevelDebug)
|
||||
_, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
|
||||
require.ErrorContains(t, err, "agentsocket is not supported on Windows")
|
||||
})
|
||||
|
||||
t.Run("NewClient", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := agentsocket.NewClient(context.Background(), agentsocket.WithPath("test.sock"))
|
||||
require.ErrorContains(t, err, "agentsocket is not supported on Windows")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentInitializesOnWindowsWithoutSocketServer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("this test only runs on Windows")
|
||||
}
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
logger := testutil.Logger(t).Named("agent")
|
||||
|
||||
derpMap, _ := tailnettest.RunDERPAndSTUN(t)
|
||||
|
||||
coordinator := tailnet.NewCoordinator(logger)
|
||||
t.Cleanup(func() {
|
||||
_ = coordinator.Close()
|
||||
})
|
||||
|
||||
statsCh := make(chan *agentproto.Stats, 50)
|
||||
agentID := uuid.New()
|
||||
manifest := agentsdk.Manifest{
|
||||
AgentID: agentID,
|
||||
AgentName: "test-agent",
|
||||
WorkspaceName: "test-workspace",
|
||||
OwnerName: "test-user",
|
||||
WorkspaceID: uuid.New(),
|
||||
DERPMap: derpMap,
|
||||
}
|
||||
|
||||
client := agenttest.NewClient(t, logger.Named("agenttest"), agentID, manifest, statsCh, coordinator)
|
||||
t.Cleanup(client.Close)
|
||||
|
||||
options := agent.Options{
|
||||
Client: client,
|
||||
Filesystem: afero.NewMemMapFs(),
|
||||
Logger: logger.Named("agent"),
|
||||
ReconnectingPTYTimeout: testutil.WaitShort,
|
||||
EnvironmentVariables: map[string]string{},
|
||||
SocketPath: "",
|
||||
}
|
||||
|
||||
agnt := agent.New(options)
|
||||
t.Cleanup(func() {
|
||||
_ = agnt.Close()
|
||||
})
|
||||
|
||||
startup := testutil.TryReceive(ctx, t, client.GetStartup())
|
||||
require.NotNil(t, startup, "agent should send startup message")
|
||||
|
||||
err := agnt.Close()
|
||||
require.NoError(t, err, "agent should close cleanly")
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package agentsocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent/agentsocket/proto"
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
)
|
||||
|
||||
var _ proto.DRPCAgentSocketServer = (*DRPCAgentSocketService)(nil)
|
||||
|
||||
var ErrUnitManagerNotAvailable = xerrors.New("unit manager not available")
|
||||
|
||||
// DRPCAgentSocketService implements the DRPC agent socket service.
|
||||
type DRPCAgentSocketService struct {
|
||||
unitManager *unit.Manager
|
||||
logger slog.Logger
|
||||
}
|
||||
|
||||
// Ping responds to a ping request to check if the service is alive.
|
||||
func (*DRPCAgentSocketService) Ping(_ context.Context, _ *proto.PingRequest) (*proto.PingResponse, error) {
|
||||
return &proto.PingResponse{}, nil
|
||||
}
|
||||
|
||||
// SyncStart starts a unit in the dependency graph.
|
||||
func (s *DRPCAgentSocketService) SyncStart(_ context.Context, req *proto.SyncStartRequest) (*proto.SyncStartResponse, error) {
|
||||
if s.unitManager == nil {
|
||||
return nil, xerrors.Errorf("SyncStart: %w", ErrUnitManagerNotAvailable)
|
||||
}
|
||||
|
||||
unitID := unit.ID(req.Unit)
|
||||
|
||||
if err := s.unitManager.Register(unitID); err != nil {
|
||||
if !errors.Is(err, unit.ErrUnitAlreadyRegistered) {
|
||||
return nil, xerrors.Errorf("SyncStart: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
isReady, err := s.unitManager.IsReady(unitID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("cannot check readiness: %w", err)
|
||||
}
|
||||
if !isReady {
|
||||
return nil, xerrors.Errorf("cannot start unit %q: unit not ready", req.Unit)
|
||||
}
|
||||
|
||||
err = s.unitManager.UpdateStatus(unitID, unit.StatusStarted)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("cannot start unit %q: %w", req.Unit, err)
|
||||
}
|
||||
|
||||
return &proto.SyncStartResponse{}, nil
|
||||
}
|
||||
|
||||
// SyncWant declares a dependency between units.
|
||||
func (s *DRPCAgentSocketService) SyncWant(_ context.Context, req *proto.SyncWantRequest) (*proto.SyncWantResponse, error) {
|
||||
if s.unitManager == nil {
|
||||
return nil, xerrors.Errorf("cannot add dependency: %w", ErrUnitManagerNotAvailable)
|
||||
}
|
||||
|
||||
unitID := unit.ID(req.Unit)
|
||||
dependsOnID := unit.ID(req.DependsOn)
|
||||
|
||||
if err := s.unitManager.Register(unitID); err != nil && !errors.Is(err, unit.ErrUnitAlreadyRegistered) {
|
||||
return nil, xerrors.Errorf("cannot add dependency: %w", err)
|
||||
}
|
||||
|
||||
if err := s.unitManager.AddDependency(unitID, dependsOnID, unit.StatusComplete); err != nil {
|
||||
return nil, xerrors.Errorf("cannot add dependency: %w", err)
|
||||
}
|
||||
|
||||
return &proto.SyncWantResponse{}, nil
|
||||
}
|
||||
|
||||
// SyncComplete marks a unit as complete in the dependency graph.
|
||||
func (s *DRPCAgentSocketService) SyncComplete(_ context.Context, req *proto.SyncCompleteRequest) (*proto.SyncCompleteResponse, error) {
|
||||
if s.unitManager == nil {
|
||||
return nil, xerrors.Errorf("cannot complete unit: %w", ErrUnitManagerNotAvailable)
|
||||
}
|
||||
|
||||
unitID := unit.ID(req.Unit)
|
||||
|
||||
if err := s.unitManager.UpdateStatus(unitID, unit.StatusComplete); err != nil {
|
||||
return nil, xerrors.Errorf("cannot complete unit %q: %w", req.Unit, err)
|
||||
}
|
||||
|
||||
return &proto.SyncCompleteResponse{}, nil
|
||||
}
|
||||
|
||||
// SyncReady checks whether a unit is ready to be started. That is, all dependencies are satisfied.
|
||||
func (s *DRPCAgentSocketService) SyncReady(_ context.Context, req *proto.SyncReadyRequest) (*proto.SyncReadyResponse, error) {
|
||||
if s.unitManager == nil {
|
||||
return nil, xerrors.Errorf("cannot check readiness: %w", ErrUnitManagerNotAvailable)
|
||||
}
|
||||
|
||||
unitID := unit.ID(req.Unit)
|
||||
isReady, err := s.unitManager.IsReady(unitID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("cannot check readiness: %w", err)
|
||||
}
|
||||
|
||||
return &proto.SyncReadyResponse{
|
||||
Ready: isReady,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SyncStatus gets the status of a unit and lists its dependencies.
|
||||
func (s *DRPCAgentSocketService) SyncStatus(_ context.Context, req *proto.SyncStatusRequest) (*proto.SyncStatusResponse, error) {
|
||||
if s.unitManager == nil {
|
||||
return nil, xerrors.Errorf("cannot get status for unit %q: %w", req.Unit, ErrUnitManagerNotAvailable)
|
||||
}
|
||||
|
||||
unitID := unit.ID(req.Unit)
|
||||
|
||||
isReady, err := s.unitManager.IsReady(unitID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("cannot check readiness: %w", err)
|
||||
}
|
||||
|
||||
dependencies, err := s.unitManager.GetAllDependencies(unitID)
|
||||
switch {
|
||||
case errors.Is(err, unit.ErrUnitNotFound):
|
||||
dependencies = []unit.Dependency{}
|
||||
case err != nil:
|
||||
return nil, xerrors.Errorf("cannot get dependencies: %w", err)
|
||||
}
|
||||
|
||||
var depInfos []*proto.DependencyInfo
|
||||
for _, dep := range dependencies {
|
||||
depInfos = append(depInfos, &proto.DependencyInfo{
|
||||
Unit: string(dep.Unit),
|
||||
DependsOn: string(dep.DependsOn),
|
||||
RequiredStatus: string(dep.RequiredStatus),
|
||||
CurrentStatus: string(dep.CurrentStatus),
|
||||
IsSatisfied: dep.IsSatisfied,
|
||||
})
|
||||
}
|
||||
|
||||
u, err := s.unitManager.Unit(unitID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("cannot get status for unit %q: %w", req.Unit, err)
|
||||
}
|
||||
return &proto.SyncStatusResponse{
|
||||
Status: string(u.Status()),
|
||||
IsReady: isReady,
|
||||
Dependencies: depInfos,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package agentsocket_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent/agentsocket"
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// tempDirUnixSocket returns a temporary directory that can safely hold unix
|
||||
// sockets (probably).
|
||||
//
|
||||
// During tests on darwin we hit the max path length limit for unix sockets
|
||||
// pretty easily in the default location, so this function uses /tmp instead to
|
||||
// get shorter paths. To keep paths short, we use a hash of the test name
|
||||
// instead of the full test name.
|
||||
func tempDirUnixSocket(t *testing.T) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "darwin" {
|
||||
// Use a short hash of the test name to keep the path under 104 chars
|
||||
hash := sha256.Sum256([]byte(t.Name()))
|
||||
hashStr := hex.EncodeToString(hash[:])[:8] // Use first 8 chars of hash
|
||||
dir, err := os.MkdirTemp("/tmp", fmt.Sprintf("c-%s-", hashStr))
|
||||
require.NoError(t, err, "create temp dir for unix socket test")
|
||||
t.Cleanup(func() {
|
||||
err := os.RemoveAll(dir)
|
||||
assert.NoError(t, err, "remove temp dir", dir)
|
||||
})
|
||||
return dir
|
||||
}
|
||||
return t.TempDir()
|
||||
}
|
||||
|
||||
// newSocketClient creates a DRPC client connected to the Unix socket at the given path.
|
||||
func newSocketClient(ctx context.Context, t *testing.T, socketPath string) *agentsocket.Client {
|
||||
t.Helper()
|
||||
|
||||
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(socketPath))
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func TestDRPCAgentSocketService(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("agentsocket is not supported on Windows")
|
||||
}
|
||||
|
||||
t.Run("Ping", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
err = client.Ping(ctx)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("SyncStart", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("NewUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err := client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusStarted, status.Status)
|
||||
})
|
||||
|
||||
t.Run("UnitAlreadyStarted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
// First Start
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
status, err := client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusStarted, status.Status)
|
||||
|
||||
// Second Start
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.ErrorContains(t, err, unit.ErrSameStatusAlreadySet.Error())
|
||||
|
||||
status, err = client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusStarted, status.Status)
|
||||
})
|
||||
|
||||
t.Run("UnitAlreadyCompleted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
// First start
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err := client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusStarted, status.Status)
|
||||
|
||||
// Complete the unit
|
||||
err = client.SyncComplete(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err = client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusComplete, status.Status)
|
||||
|
||||
// Second start
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err = client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusStarted, status.Status)
|
||||
})
|
||||
|
||||
t.Run("UnitNotReady", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.ErrorContains(t, err, "unit not ready")
|
||||
|
||||
status, err := client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusPending, status.Status)
|
||||
require.False(t, status.IsReady)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("SyncWant", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("NewUnits", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
// If dependency units are not registered, they are registered automatically
|
||||
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err := client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, status.Dependencies, 1)
|
||||
require.Equal(t, unit.ID("dependency-unit"), status.Dependencies[0].DependsOn)
|
||||
require.Equal(t, unit.StatusComplete, status.Dependencies[0].RequiredStatus)
|
||||
})
|
||||
|
||||
t.Run("DependencyAlreadyRegistered", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
// Start the dependency unit
|
||||
err = client.SyncStart(ctx, "dependency-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err := client.SyncStatus(ctx, "dependency-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusStarted, status.Status)
|
||||
|
||||
// Add the dependency after the dependency unit has already started
|
||||
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
|
||||
|
||||
// Dependencies can be added even if the dependency unit has already started
|
||||
require.NoError(t, err)
|
||||
|
||||
// The dependency is now reflected in the test unit's status
|
||||
status, err = client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.ID("dependency-unit"), status.Dependencies[0].DependsOn)
|
||||
require.Equal(t, unit.StatusComplete, status.Dependencies[0].RequiredStatus)
|
||||
})
|
||||
|
||||
t.Run("DependencyAddedAfterDependentStarted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
// Start the dependent unit
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err := client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusStarted, status.Status)
|
||||
|
||||
// Add the dependency after the dependency unit has already started
|
||||
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
|
||||
|
||||
// Dependencies can be added even if the dependent unit has already started.
|
||||
// The dependency applies the next time a unit is started. The current status is not updated.
|
||||
// This is to allow flexible dependency management. It does mean that users of this API should
|
||||
// take care to add dependencies before they start their dependent units.
|
||||
require.NoError(t, err)
|
||||
|
||||
// The dependency is now reflected in the test unit's status
|
||||
status, err = client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.ID("dependency-unit"), status.Dependencies[0].DependsOn)
|
||||
require.Equal(t, unit.StatusComplete, status.Dependencies[0].RequiredStatus)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("SyncReady", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("UnregisteredUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
ready, err := client.SyncReady(ctx, "unregistered-unit")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ready)
|
||||
})
|
||||
|
||||
t.Run("UnitNotReady", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
// Register a unit with an unsatisfied dependency
|
||||
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check readiness - should be false because dependency is not satisfied
|
||||
ready, err := client.SyncReady(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.False(t, ready)
|
||||
})
|
||||
|
||||
t.Run("UnitReady", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
// Register a unit with no dependencies - should be ready immediately
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check readiness - should be true
|
||||
ready, err := client.SyncReady(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ready)
|
||||
|
||||
// Also test a unit with satisfied dependencies
|
||||
err = client.SyncWant(ctx, "dependent-unit", "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Complete the dependency
|
||||
err = client.SyncComplete(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Now dependent-unit should be ready
|
||||
ready, err = client.SyncReady(ctx, "dependent-unit")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ready)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//go:build !windows
|
||||
|
||||
package agentsocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
const defaultSocketPath = "/tmp/coder-agent.sock"
|
||||
|
||||
func createSocket(path string) (net.Listener, error) {
|
||||
if path == "" {
|
||||
path = defaultSocketPath
|
||||
}
|
||||
|
||||
if !isSocketAvailable(path) {
|
||||
return nil, xerrors.Errorf("socket path %s is not available", path)
|
||||
}
|
||||
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return nil, xerrors.Errorf("remove existing socket: %w", err)
|
||||
}
|
||||
|
||||
parentDir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(parentDir, 0o700); err != nil {
|
||||
return nil, xerrors.Errorf("create socket directory: %w", err)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("listen on unix socket: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
_ = listener.Close()
|
||||
return nil, xerrors.Errorf("set socket permissions: %w", err)
|
||||
}
|
||||
return listener, nil
|
||||
}
|
||||
|
||||
func cleanupSocket(path string) error {
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func isSocketAvailable(path string) bool {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Try to connect to see if it's actually listening.
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := dialer.Dial("unix", path)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
_ = conn.Close()
|
||||
return false
|
||||
}
|
||||
|
||||
func dialSocket(ctx context.Context, path string) (net.Conn, error) {
|
||||
if path == "" {
|
||||
path = defaultSocketPath
|
||||
}
|
||||
|
||||
dialer := net.Dialer{}
|
||||
return dialer.DialContext(ctx, "unix", path)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build windows
|
||||
|
||||
package agentsocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func createSocket(_ string) (net.Listener, error) {
|
||||
return nil, xerrors.New("agentsocket is not supported on Windows")
|
||||
}
|
||||
|
||||
func cleanupSocket(_ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func dialSocket(_ context.Context, _ string) (net.Conn, error) {
|
||||
return nil, xerrors.New("agentsocket is not supported on Windows")
|
||||
}
|
||||
+33
-31
@@ -2,41 +2,31 @@ package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw/loggermw"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/httpmw"
|
||||
)
|
||||
|
||||
func (a *agent) apiHandler() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(
|
||||
httpmw.Recover(a.logger),
|
||||
tracing.StatusWriterMiddleware,
|
||||
loggermw.Logger(a.logger),
|
||||
)
|
||||
r.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
|
||||
Message: "Hello from the agent!",
|
||||
})
|
||||
})
|
||||
|
||||
// Make a copy to ensure the map is not modified after the handler is
|
||||
// created.
|
||||
cpy := make(map[int]string)
|
||||
for k, b := range a.ignorePorts {
|
||||
cpy[k] = b
|
||||
}
|
||||
|
||||
cacheDuration := 1 * time.Second
|
||||
if a.portCacheDuration > 0 {
|
||||
cacheDuration = a.portCacheDuration
|
||||
}
|
||||
|
||||
lp := &listeningPortsHandler{
|
||||
ignorePorts: cpy,
|
||||
cacheDuration: cacheDuration,
|
||||
}
|
||||
|
||||
if a.devcontainers {
|
||||
r.Mount("/api/v0/containers", a.containerAPI.Routes())
|
||||
} else if manifest := a.manifest.Load(); manifest != nil && manifest.ParentID != uuid.Nil {
|
||||
@@ -57,7 +47,7 @@ func (a *agent) apiHandler() http.Handler {
|
||||
|
||||
promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger)
|
||||
|
||||
r.Get("/api/v0/listening-ports", lp.handler)
|
||||
r.Get("/api/v0/listening-ports", a.listeningPortsHandler.handler)
|
||||
r.Get("/api/v0/netcheck", a.HandleNetcheck)
|
||||
r.Post("/api/v0/list-directory", a.HandleLS)
|
||||
r.Get("/api/v0/read-file", a.HandleReadFile)
|
||||
@@ -72,22 +62,21 @@ func (a *agent) apiHandler() http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
type listeningPortsHandler struct {
|
||||
ignorePorts map[int]string
|
||||
cacheDuration time.Duration
|
||||
type ListeningPortsGetter interface {
|
||||
GetListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error)
|
||||
}
|
||||
|
||||
//nolint: unused // used on some but not all platforms
|
||||
mut sync.Mutex
|
||||
//nolint: unused // used on some but not all platforms
|
||||
ports []codersdk.WorkspaceAgentListeningPort
|
||||
//nolint: unused // used on some but not all platforms
|
||||
mtime time.Time
|
||||
type listeningPortsHandler struct {
|
||||
// In production code, this is set to an osListeningPortsGetter, but it can be overridden for
|
||||
// testing.
|
||||
getter ListeningPortsGetter
|
||||
ignorePorts map[int]string
|
||||
}
|
||||
|
||||
// handler returns a list of listening ports. This is tested by coderd's
|
||||
// TestWorkspaceAgentListeningPorts test.
|
||||
func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request) {
|
||||
ports, err := lp.getListeningPorts()
|
||||
ports, err := lp.getter.GetListeningPorts()
|
||||
if err != nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Could not scan for listening ports.",
|
||||
@@ -96,7 +85,20 @@ func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
filteredPorts := make([]codersdk.WorkspaceAgentListeningPort, 0, len(ports))
|
||||
for _, port := range ports {
|
||||
if port.Port < workspacesdk.AgentMinimumListeningPort {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ignore ports that we've been told to ignore.
|
||||
if _, ok := lp.ignorePorts[int(port.Port)]; ok {
|
||||
continue
|
||||
}
|
||||
filteredPorts = append(filteredPorts, port)
|
||||
}
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.WorkspaceAgentListeningPortsResponse{
|
||||
Ports: ports,
|
||||
Ports: filteredPorts,
|
||||
})
|
||||
}
|
||||
|
||||
+3
-1
@@ -250,7 +250,9 @@ func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.
|
||||
transforms[i] = replace.String(edit.Search, edit.Replace)
|
||||
}
|
||||
|
||||
tmpfile, err := afero.TempFile(a.filesystem, "", filepath.Base(path))
|
||||
// Create an adjacent file to ensure it will be on the same device and can be
|
||||
// moved atomically.
|
||||
tmpfile, err := afero.TempFile(a.filesystem, filepath.Dir(path), filepath.Base(path))
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
@@ -3,16 +3,23 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cakturk/go-netstat/netstat"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
type osListeningPortsGetter struct {
|
||||
cacheDuration time.Duration
|
||||
mut sync.Mutex
|
||||
ports []codersdk.WorkspaceAgentListeningPort
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
func (lp *osListeningPortsGetter) GetListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
lp.mut.Lock()
|
||||
defer lp.mut.Unlock()
|
||||
|
||||
@@ -33,12 +40,7 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentL
|
||||
seen := make(map[uint16]struct{}, len(tabs))
|
||||
ports := []codersdk.WorkspaceAgentListeningPort{}
|
||||
for _, tab := range tabs {
|
||||
if tab.LocalAddr == nil || tab.LocalAddr.Port < workspacesdk.AgentMinimumListeningPort {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ignore ports that we've been told to ignore.
|
||||
if _, ok := lp.ignorePorts[int(tab.LocalAddr.Port)]; ok {
|
||||
if tab.LocalAddr == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//go:build linux || (windows && amd64)
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOSListeningPortsGetter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
uut := &osListeningPortsGetter{
|
||||
cacheDuration: 1 * time.Hour,
|
||||
}
|
||||
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
|
||||
ports, err := uut.GetListeningPorts()
|
||||
require.NoError(t, err)
|
||||
found := false
|
||||
for _, port := range ports {
|
||||
// #nosec G115 - Safe conversion as TCP port numbers are within uint16 range (0-65535)
|
||||
if port.Port == uint16(l.Addr().(*net.TCPAddr).Port) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found)
|
||||
|
||||
// check that we cache the ports
|
||||
err = l.Close()
|
||||
require.NoError(t, err)
|
||||
portsNew, err := uut.GetListeningPorts()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports, portsNew)
|
||||
|
||||
// note that it's unsafe to try to assert that a port does not exist in the response
|
||||
// because the OS may reallocate the port very quickly.
|
||||
}
|
||||
@@ -2,9 +2,17 @@
|
||||
|
||||
package agent
|
||||
|
||||
import "github.com/coder/coder/v2/codersdk"
|
||||
import (
|
||||
"time"
|
||||
|
||||
func (*listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
type osListeningPortsGetter struct {
|
||||
cacheDuration time.Duration
|
||||
}
|
||||
|
||||
func (*osListeningPortsGetter) GetListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
// Can't scan for ports on non-linux or non-windows_amd64 systems at the
|
||||
// moment. The UI will not show any "no ports found" message to the user, so
|
||||
// the user won't suspect a thing.
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"gonum.org/v1/gonum/graph/encoding/dot"
|
||||
"gonum.org/v1/gonum/graph/simple"
|
||||
"gonum.org/v1/gonum/graph/topo"
|
||||
)
|
||||
|
||||
// Graph provides a bidirectional interface over gonum's directed graph implementation.
|
||||
// While the underlying gonum graph is directed, we overlay bidirectional semantics
|
||||
// by distinguishing between forward and reverse edges. Wanting and being wanted by
|
||||
// other units are related but different concepts that have different graph traversal
|
||||
// implications when Units update their status.
|
||||
//
|
||||
// The graph stores edge types to represent different relationships between units,
|
||||
// allowing for domain-specific semantics beyond simple connectivity.
|
||||
type Graph[EdgeType, VertexType comparable] struct {
|
||||
mu sync.RWMutex
|
||||
// The underlying gonum graph. It stores vertices and edges without knowing about the types of the vertices and edges.
|
||||
gonumGraph *simple.DirectedGraph
|
||||
// Maps vertices to their IDs so that a gonum vertex ID can be used to lookup the vertex type.
|
||||
vertexToID map[VertexType]int64
|
||||
// Maps vertex IDs to their types so that a vertex type can be used to lookup the gonum vertex ID.
|
||||
idToVertex map[int64]VertexType
|
||||
// The next ID to assign to a vertex.
|
||||
nextID int64
|
||||
// Store edge types by "fromID->toID" key. This is used to lookup the edge type for a given edge.
|
||||
edgeTypes map[string]EdgeType
|
||||
}
|
||||
|
||||
// Edge is a convenience type for representing an edge in the graph.
|
||||
// It encapsulates the from and to vertices and the edge type itself.
|
||||
type Edge[EdgeType, VertexType comparable] struct {
|
||||
From VertexType
|
||||
To VertexType
|
||||
Edge EdgeType
|
||||
}
|
||||
|
||||
// AddEdge adds an edge to the graph. It initializes the graph and metadata on first use,
|
||||
// checks for cycles, and adds the edge to the gonum graph.
|
||||
func (g *Graph[EdgeType, VertexType]) AddEdge(from, to VertexType, edge EdgeType) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.gonumGraph == nil {
|
||||
g.gonumGraph = simple.NewDirectedGraph()
|
||||
g.vertexToID = make(map[VertexType]int64)
|
||||
g.idToVertex = make(map[int64]VertexType)
|
||||
g.edgeTypes = make(map[string]EdgeType)
|
||||
g.nextID = 1
|
||||
}
|
||||
|
||||
fromID := g.getOrCreateVertexID(from)
|
||||
toID := g.getOrCreateVertexID(to)
|
||||
|
||||
if g.canReach(to, from) {
|
||||
return xerrors.Errorf("adding edge (%v -> %v): %w", from, to, ErrCycleDetected)
|
||||
}
|
||||
|
||||
g.gonumGraph.SetEdge(simple.Edge{F: simple.Node(fromID), T: simple.Node(toID)})
|
||||
|
||||
edgeKey := fmt.Sprintf("%d->%d", fromID, toID)
|
||||
g.edgeTypes[edgeKey] = edge
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetForwardAdjacentVertices returns all the edges that originate from the given vertex.
|
||||
func (g *Graph[EdgeType, VertexType]) GetForwardAdjacentVertices(from VertexType) []Edge[EdgeType, VertexType] {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
fromID, exists := g.vertexToID[from]
|
||||
if !exists {
|
||||
return []Edge[EdgeType, VertexType]{}
|
||||
}
|
||||
|
||||
edges := []Edge[EdgeType, VertexType]{}
|
||||
toNodes := g.gonumGraph.From(fromID)
|
||||
for toNodes.Next() {
|
||||
toID := toNodes.Node().ID()
|
||||
to := g.idToVertex[toID]
|
||||
|
||||
// Get the edge type
|
||||
edgeKey := fmt.Sprintf("%d->%d", fromID, toID)
|
||||
edgeType := g.edgeTypes[edgeKey]
|
||||
|
||||
edges = append(edges, Edge[EdgeType, VertexType]{From: from, To: to, Edge: edgeType})
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
// GetReverseAdjacentVertices returns all the edges that terminate at the given vertex.
|
||||
func (g *Graph[EdgeType, VertexType]) GetReverseAdjacentVertices(to VertexType) []Edge[EdgeType, VertexType] {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
toID, exists := g.vertexToID[to]
|
||||
if !exists {
|
||||
return []Edge[EdgeType, VertexType]{}
|
||||
}
|
||||
|
||||
edges := []Edge[EdgeType, VertexType]{}
|
||||
fromNodes := g.gonumGraph.To(toID)
|
||||
for fromNodes.Next() {
|
||||
fromID := fromNodes.Node().ID()
|
||||
from := g.idToVertex[fromID]
|
||||
|
||||
// Get the edge type
|
||||
edgeKey := fmt.Sprintf("%d->%d", fromID, toID)
|
||||
edgeType := g.edgeTypes[edgeKey]
|
||||
|
||||
edges = append(edges, Edge[EdgeType, VertexType]{From: from, To: to, Edge: edgeType})
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
// getOrCreateVertexID returns the ID for a vertex, creating it if it doesn't exist.
|
||||
func (g *Graph[EdgeType, VertexType]) getOrCreateVertexID(vertex VertexType) int64 {
|
||||
if id, exists := g.vertexToID[vertex]; exists {
|
||||
return id
|
||||
}
|
||||
|
||||
id := g.nextID
|
||||
g.nextID++
|
||||
g.vertexToID[vertex] = id
|
||||
g.idToVertex[id] = vertex
|
||||
|
||||
// Add the node to the gonum graph
|
||||
g.gonumGraph.AddNode(simple.Node(id))
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// canReach checks if there is a path from the start vertex to the end vertex.
|
||||
func (g *Graph[EdgeType, VertexType]) canReach(start, end VertexType) bool {
|
||||
if start == end {
|
||||
return true
|
||||
}
|
||||
|
||||
startID, startExists := g.vertexToID[start]
|
||||
endID, endExists := g.vertexToID[end]
|
||||
|
||||
if !startExists || !endExists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Use gonum's built-in path existence check
|
||||
return topo.PathExistsIn(g.gonumGraph, simple.Node(startID), simple.Node(endID))
|
||||
}
|
||||
|
||||
// ToDOT exports the graph to DOT format for visualization
|
||||
func (g *Graph[EdgeType, VertexType]) ToDOT(name string) (string, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
if g.gonumGraph == nil {
|
||||
return "", xerrors.New("graph is not initialized")
|
||||
}
|
||||
|
||||
// Marshal the graph to DOT format
|
||||
dotBytes, err := dot.Marshal(g.gonumGraph, name, "", " ")
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("failed to marshal graph to DOT: %w", err)
|
||||
}
|
||||
|
||||
return string(dotBytes), nil
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
// Package unit_test provides tests for the unit package.
|
||||
//
|
||||
// DOT Graph Testing:
|
||||
// The graph tests use golden files for DOT representation verification.
|
||||
// To update the golden files:
|
||||
// make gen/golden-files
|
||||
//
|
||||
// The golden files contain the expected DOT representation and can be easily
|
||||
// inspected, version controlled, and updated when the graph structure changes.
|
||||
package unit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
)
|
||||
|
||||
type testGraphEdge string
|
||||
|
||||
const (
|
||||
testEdgeStarted testGraphEdge = "started"
|
||||
testEdgeCompleted testGraphEdge = "completed"
|
||||
)
|
||||
|
||||
type testGraphVertex struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type (
|
||||
testGraph = unit.Graph[testGraphEdge, *testGraphVertex]
|
||||
testEdge = unit.Edge[testGraphEdge, *testGraphVertex]
|
||||
)
|
||||
|
||||
// randInt generates a random integer in the range [0, limit).
|
||||
func randInt(limit int) int {
|
||||
if limit <= 0 {
|
||||
return 0
|
||||
}
|
||||
n, err := cryptorand.Int63n(int64(limit))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(n)
|
||||
}
|
||||
|
||||
// UpdateGoldenFiles indicates golden files should be updated.
|
||||
// To update the golden files:
|
||||
// make gen/golden-files
|
||||
var UpdateGoldenFiles = flag.Bool("update", false, "update .golden files")
|
||||
|
||||
// assertDOTGraph requires that the graph's DOT representation matches the golden file
|
||||
func assertDOTGraph(t *testing.T, graph *testGraph, goldenName string) {
|
||||
t.Helper()
|
||||
|
||||
dot, err := graph.ToDOT(goldenName)
|
||||
require.NoError(t, err)
|
||||
|
||||
goldenFile := filepath.Join("testdata", goldenName+".golden")
|
||||
if *UpdateGoldenFiles {
|
||||
t.Logf("update golden file for: %q: %s", goldenName, goldenFile)
|
||||
err := os.MkdirAll(filepath.Dir(goldenFile), 0o755)
|
||||
require.NoError(t, err, "want no error creating golden file directory")
|
||||
err = os.WriteFile(goldenFile, []byte(dot), 0o600)
|
||||
require.NoError(t, err, "update golden file")
|
||||
}
|
||||
|
||||
expected, err := os.ReadFile(goldenFile)
|
||||
require.NoError(t, err, "read golden file, run \"make gen/golden-files\" and commit the changes")
|
||||
|
||||
// Normalize line endings for cross-platform compatibility
|
||||
expected = normalizeLineEndings(expected)
|
||||
normalizedDot := normalizeLineEndings([]byte(dot))
|
||||
|
||||
assert.Empty(t, cmp.Diff(string(expected), string(normalizedDot)), "golden file mismatch (-want +got): %s, run \"make gen/golden-files\", verify and commit the changes", goldenFile)
|
||||
}
|
||||
|
||||
// normalizeLineEndings ensures that all line endings are normalized to \n.
|
||||
// Required for Windows compatibility.
|
||||
func normalizeLineEndings(content []byte) []byte {
|
||||
content = bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n"))
|
||||
content = bytes.ReplaceAll(content, []byte("\r"), []byte("\n"))
|
||||
return content
|
||||
}
|
||||
|
||||
func TestGraph(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testFuncs := map[string]func(t *testing.T) *unit.Graph[testGraphEdge, *testGraphVertex]{
|
||||
"ForwardAndReverseEdges": func(t *testing.T) *unit.Graph[testGraphEdge, *testGraphVertex] {
|
||||
graph := &unit.Graph[testGraphEdge, *testGraphVertex]{}
|
||||
unit1 := &testGraphVertex{Name: "unit1"}
|
||||
unit2 := &testGraphVertex{Name: "unit2"}
|
||||
unit3 := &testGraphVertex{Name: "unit3"}
|
||||
err := graph.AddEdge(unit1, unit2, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unit1, unit3, testEdgeStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check for forward edge
|
||||
vertices := graph.GetForwardAdjacentVertices(unit1)
|
||||
require.Len(t, vertices, 2)
|
||||
// Unit 1 depends on the completion of Unit2
|
||||
require.Contains(t, vertices, testEdge{
|
||||
From: unit1,
|
||||
To: unit2,
|
||||
Edge: testEdgeCompleted,
|
||||
})
|
||||
// Unit 1 depends on the start of Unit3
|
||||
require.Contains(t, vertices, testEdge{
|
||||
From: unit1,
|
||||
To: unit3,
|
||||
Edge: testEdgeStarted,
|
||||
})
|
||||
|
||||
// Check for reverse edges
|
||||
unit2ReverseEdges := graph.GetReverseAdjacentVertices(unit2)
|
||||
require.Len(t, unit2ReverseEdges, 1)
|
||||
// Unit 2 must be completed before Unit 1 can start
|
||||
require.Contains(t, unit2ReverseEdges, testEdge{
|
||||
From: unit1,
|
||||
To: unit2,
|
||||
Edge: testEdgeCompleted,
|
||||
})
|
||||
|
||||
unit3ReverseEdges := graph.GetReverseAdjacentVertices(unit3)
|
||||
require.Len(t, unit3ReverseEdges, 1)
|
||||
// Unit 3 must be started before Unit 1 can complete
|
||||
require.Contains(t, unit3ReverseEdges, testEdge{
|
||||
From: unit1,
|
||||
To: unit3,
|
||||
Edge: testEdgeStarted,
|
||||
})
|
||||
|
||||
return graph
|
||||
},
|
||||
"SelfReference": func(t *testing.T) *testGraph {
|
||||
graph := &testGraph{}
|
||||
unit1 := &testGraphVertex{Name: "unit1"}
|
||||
err := graph.AddEdge(unit1, unit1, testEdgeCompleted)
|
||||
require.ErrorIs(t, err, unit.ErrCycleDetected)
|
||||
|
||||
return graph
|
||||
},
|
||||
"Cycle": func(t *testing.T) *testGraph {
|
||||
graph := &testGraph{}
|
||||
unit1 := &testGraphVertex{Name: "unit1"}
|
||||
unit2 := &testGraphVertex{Name: "unit2"}
|
||||
err := graph.AddEdge(unit1, unit2, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unit2, unit1, testEdgeStarted)
|
||||
require.ErrorIs(t, err, unit.ErrCycleDetected)
|
||||
|
||||
return graph
|
||||
},
|
||||
"MultipleDependenciesSameStatus": func(t *testing.T) *testGraph {
|
||||
graph := &testGraph{}
|
||||
unit1 := &testGraphVertex{Name: "unit1"}
|
||||
unit2 := &testGraphVertex{Name: "unit2"}
|
||||
unit3 := &testGraphVertex{Name: "unit3"}
|
||||
unit4 := &testGraphVertex{Name: "unit4"}
|
||||
|
||||
// Unit1 depends on completion of both unit2 and unit3 (same status type)
|
||||
err := graph.AddEdge(unit1, unit2, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unit1, unit3, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unit1 also depends on starting of unit4 (different status type)
|
||||
err = graph.AddEdge(unit1, unit4, testEdgeStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that unit1 has 3 forward dependencies
|
||||
forwardEdges := graph.GetForwardAdjacentVertices(unit1)
|
||||
require.Len(t, forwardEdges, 3)
|
||||
|
||||
// Verify all expected dependencies exist
|
||||
expectedDependencies := []testEdge{
|
||||
{From: unit1, To: unit2, Edge: testEdgeCompleted},
|
||||
{From: unit1, To: unit3, Edge: testEdgeCompleted},
|
||||
{From: unit1, To: unit4, Edge: testEdgeStarted},
|
||||
}
|
||||
|
||||
for _, expected := range expectedDependencies {
|
||||
require.Contains(t, forwardEdges, expected)
|
||||
}
|
||||
|
||||
// Check reverse dependencies
|
||||
unit2ReverseEdges := graph.GetReverseAdjacentVertices(unit2)
|
||||
require.Len(t, unit2ReverseEdges, 1)
|
||||
require.Contains(t, unit2ReverseEdges, testEdge{
|
||||
From: unit1, To: unit2, Edge: testEdgeCompleted,
|
||||
})
|
||||
|
||||
unit3ReverseEdges := graph.GetReverseAdjacentVertices(unit3)
|
||||
require.Len(t, unit3ReverseEdges, 1)
|
||||
require.Contains(t, unit3ReverseEdges, testEdge{
|
||||
From: unit1, To: unit3, Edge: testEdgeCompleted,
|
||||
})
|
||||
|
||||
unit4ReverseEdges := graph.GetReverseAdjacentVertices(unit4)
|
||||
require.Len(t, unit4ReverseEdges, 1)
|
||||
require.Contains(t, unit4ReverseEdges, testEdge{
|
||||
From: unit1, To: unit4, Edge: testEdgeStarted,
|
||||
})
|
||||
|
||||
return graph
|
||||
},
|
||||
}
|
||||
|
||||
for testName, testFunc := range testFuncs {
|
||||
var graph *testGraph
|
||||
t.Run(testName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
graph = testFunc(t)
|
||||
assertDOTGraph(t, graph, testName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphThreadSafety(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ConcurrentReadWrite", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
graph := &testGraph{}
|
||||
var wg sync.WaitGroup
|
||||
const numWriters = 50
|
||||
const numReaders = 100
|
||||
const operationsPerWriter = 1000
|
||||
const operationsPerReader = 2000
|
||||
|
||||
barrier := make(chan struct{})
|
||||
// Launch writers
|
||||
for i := 0; i < numWriters; i++ {
|
||||
wg.Add(1)
|
||||
go func(writerID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
for j := 0; j < operationsPerWriter; j++ {
|
||||
from := &testGraphVertex{Name: fmt.Sprintf("writer-%d-%d", writerID, j)}
|
||||
to := &testGraphVertex{Name: fmt.Sprintf("writer-%d-%d", writerID, j+1)}
|
||||
graph.AddEdge(from, to, testEdgeCompleted)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Launch readers
|
||||
readerResults := make([]struct {
|
||||
panicked bool
|
||||
readCount int
|
||||
}, numReaders)
|
||||
|
||||
for i := 0; i < numReaders; i++ {
|
||||
wg.Add(1)
|
||||
go func(readerID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
readerResults[readerID].panicked = true
|
||||
}
|
||||
}()
|
||||
|
||||
readCount := 0
|
||||
for j := 0; j < operationsPerReader; j++ {
|
||||
// Create a test vertex and read
|
||||
testUnit := &testGraphVertex{Name: fmt.Sprintf("test-reader-%d-%d", readerID, j)}
|
||||
forwardEdges := graph.GetForwardAdjacentVertices(testUnit)
|
||||
reverseEdges := graph.GetReverseAdjacentVertices(testUnit)
|
||||
|
||||
// Just verify no panics (results may be nil for non-existent vertices)
|
||||
_ = forwardEdges
|
||||
_ = reverseEdges
|
||||
readCount++
|
||||
}
|
||||
readerResults[readerID].readCount = readCount
|
||||
}(i)
|
||||
}
|
||||
|
||||
close(barrier)
|
||||
wg.Wait()
|
||||
|
||||
// Verify no panics occurred in readers
|
||||
for i, result := range readerResults {
|
||||
require.False(t, result.panicked, "reader %d panicked", i)
|
||||
require.Equal(t, operationsPerReader, result.readCount, "reader %d should have performed expected reads", i)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ConcurrentCycleDetection", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
graph := &testGraph{}
|
||||
|
||||
// Pre-create chain: A→B→C→D
|
||||
unitA := &testGraphVertex{Name: "A"}
|
||||
unitB := &testGraphVertex{Name: "B"}
|
||||
unitC := &testGraphVertex{Name: "C"}
|
||||
unitD := &testGraphVertex{Name: "D"}
|
||||
|
||||
err := graph.AddEdge(unitA, unitB, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unitB, unitC, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unitC, unitD, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
|
||||
barrier := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
const numGoroutines = 50
|
||||
cycleErrors := make([]error, numGoroutines)
|
||||
|
||||
// Launch goroutines trying to add D→A (creates cycle)
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
err := graph.AddEdge(unitD, unitA, testEdgeCompleted)
|
||||
cycleErrors[goroutineID] = err
|
||||
}(i)
|
||||
}
|
||||
|
||||
close(barrier)
|
||||
wg.Wait()
|
||||
|
||||
// Verify all attempts correctly returned cycle error
|
||||
for i, err := range cycleErrors {
|
||||
require.Error(t, err, "goroutine %d should have detected cycle", i)
|
||||
require.ErrorIs(t, err, unit.ErrCycleDetected)
|
||||
}
|
||||
|
||||
// Verify graph remains valid (original chain intact)
|
||||
dot, err := graph.ToDOT("test")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, dot)
|
||||
})
|
||||
|
||||
t.Run("ConcurrentToDOT", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
graph := &testGraph{}
|
||||
|
||||
// Pre-populate graph
|
||||
for i := 0; i < 20; i++ {
|
||||
from := &testGraphVertex{Name: fmt.Sprintf("dot-unit-%d", i)}
|
||||
to := &testGraphVertex{Name: fmt.Sprintf("dot-unit-%d", i+1)}
|
||||
err := graph.AddEdge(from, to, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
barrier := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
const numReaders = 100
|
||||
const numWriters = 20
|
||||
dotResults := make([]string, numReaders)
|
||||
|
||||
// Launch readers calling ToDOT
|
||||
dotErrors := make([]error, numReaders)
|
||||
for i := 0; i < numReaders; i++ {
|
||||
wg.Add(1)
|
||||
go func(readerID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
dot, err := graph.ToDOT(fmt.Sprintf("test-%d", readerID))
|
||||
dotErrors[readerID] = err
|
||||
if err == nil {
|
||||
dotResults[readerID] = dot
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Launch writers adding edges
|
||||
for i := 0; i < numWriters; i++ {
|
||||
wg.Add(1)
|
||||
go func(writerID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
from := &testGraphVertex{Name: fmt.Sprintf("writer-dot-%d", writerID)}
|
||||
to := &testGraphVertex{Name: fmt.Sprintf("writer-dot-target-%d", writerID)}
|
||||
graph.AddEdge(from, to, testEdgeCompleted)
|
||||
}(i)
|
||||
}
|
||||
|
||||
close(barrier)
|
||||
wg.Wait()
|
||||
|
||||
// Verify no errors occurred during DOT generation
|
||||
for i, err := range dotErrors {
|
||||
require.NoError(t, err, "DOT generation error at index %d", i)
|
||||
}
|
||||
|
||||
// Verify all DOT results are valid
|
||||
for i, dot := range dotResults {
|
||||
require.NotEmpty(t, dot, "DOT result %d should not be empty", i)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkGraph_ConcurrentMixedOperations(b *testing.B) {
|
||||
graph := &testGraph{}
|
||||
var wg sync.WaitGroup
|
||||
const numGoroutines = 200
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Launch goroutines performing random operations
|
||||
for j := 0; j < numGoroutines; j++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
operationCount := 0
|
||||
|
||||
for operationCount < 50 {
|
||||
operation := float32(randInt(100)) / 100.0
|
||||
|
||||
if operation < 0.6 { // 60% reads
|
||||
// Read operation
|
||||
testUnit := &testGraphVertex{Name: fmt.Sprintf("bench-read-%d-%d", goroutineID, operationCount)}
|
||||
forwardEdges := graph.GetForwardAdjacentVertices(testUnit)
|
||||
reverseEdges := graph.GetReverseAdjacentVertices(testUnit)
|
||||
|
||||
// Just verify no panics (results may be nil for non-existent vertices)
|
||||
_ = forwardEdges
|
||||
_ = reverseEdges
|
||||
} else { // 40% writes
|
||||
// Write operation
|
||||
from := &testGraphVertex{Name: fmt.Sprintf("bench-write-%d-%d", goroutineID, operationCount)}
|
||||
to := &testGraphVertex{Name: fmt.Sprintf("bench-write-target-%d-%d", goroutineID, operationCount)}
|
||||
graph.AddEdge(from, to, testEdgeCompleted)
|
||||
}
|
||||
|
||||
operationCount++
|
||||
}
|
||||
}(j)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnitIDRequired = xerrors.New("unit name is required")
|
||||
ErrUnitNotFound = xerrors.New("unit not found")
|
||||
ErrUnitAlreadyRegistered = xerrors.New("unit already registered")
|
||||
ErrCannotUpdateOtherUnit = xerrors.New("cannot update other unit's status")
|
||||
ErrDependenciesNotSatisfied = xerrors.New("unit dependencies not satisfied")
|
||||
ErrSameStatusAlreadySet = xerrors.New("same status already set")
|
||||
ErrCycleDetected = xerrors.New("cycle detected")
|
||||
ErrFailedToAddDependency = xerrors.New("failed to add dependency")
|
||||
)
|
||||
|
||||
// Status represents the status of a unit.
|
||||
type Status string
|
||||
|
||||
var _ fmt.Stringer = Status("")
|
||||
|
||||
func (s Status) String() string {
|
||||
if s == StatusNotRegistered {
|
||||
return "not registered"
|
||||
}
|
||||
return string(s)
|
||||
}
|
||||
|
||||
// Status constants for dependency tracking.
|
||||
const (
|
||||
StatusNotRegistered Status = ""
|
||||
StatusPending Status = "pending"
|
||||
StatusStarted Status = "started"
|
||||
StatusComplete Status = "completed"
|
||||
)
|
||||
|
||||
// ID provides a type narrowed representation of the unique identifier of a unit.
|
||||
type ID string
|
||||
|
||||
// Unit represents a point-in-time snapshot of a vertex in the dependency graph.
|
||||
// Units may depend on other units, or be depended on by other units. The unit struct
|
||||
// is not aware of updates made to the dependency graph after it is initialized and should
|
||||
// not be cached.
|
||||
type Unit struct {
|
||||
id ID
|
||||
status Status
|
||||
// ready is true if all dependencies are satisfied.
|
||||
// It does not have an accessor method on Unit, because a unit cannot know whether it is ready.
|
||||
// Only the Manager can calculate whether a unit is ready based on knowledge of the dependency graph.
|
||||
// To discourage use of an outdated readiness value, only the Manager should set and return this field.
|
||||
ready bool
|
||||
}
|
||||
|
||||
func (u Unit) ID() ID {
|
||||
return u.id
|
||||
}
|
||||
|
||||
func (u Unit) Status() Status {
|
||||
return u.status
|
||||
}
|
||||
|
||||
// Dependency represents a dependency relationship between units.
|
||||
type Dependency struct {
|
||||
Unit ID
|
||||
DependsOn ID
|
||||
RequiredStatus Status
|
||||
CurrentStatus Status
|
||||
IsSatisfied bool
|
||||
}
|
||||
|
||||
// Manager provides reactive dependency tracking over a Graph.
|
||||
// It manages Unit registration, dependency relationships, and status updates
|
||||
// with automatic recalculation of readiness when dependencies are satisfied.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// The underlying graph that stores dependency relationships
|
||||
graph *Graph[Status, ID]
|
||||
|
||||
// Store vertex instances for each unit to ensure consistent references
|
||||
units map[ID]Unit
|
||||
}
|
||||
|
||||
// NewManager creates a new Manager instance.
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
graph: &Graph[Status, ID]{},
|
||||
units: make(map[ID]Unit),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a unit to the manager if it is not already registered.
|
||||
// If a Unit is already registered (per the ID field), it is not updated.
|
||||
func (m *Manager) Register(id ID) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if id == "" {
|
||||
return xerrors.Errorf("registering unit %q: %w", id, ErrUnitIDRequired)
|
||||
}
|
||||
|
||||
if m.registered(id) {
|
||||
return xerrors.Errorf("registering unit %q: %w", id, ErrUnitAlreadyRegistered)
|
||||
}
|
||||
|
||||
m.units[id] = Unit{
|
||||
id: id,
|
||||
status: StatusPending,
|
||||
ready: true,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// registered checks if a unit is registered in the manager.
|
||||
func (m *Manager) registered(id ID) bool {
|
||||
return m.units[id].status != StatusNotRegistered
|
||||
}
|
||||
|
||||
// Unit fetches a unit from the manager. If the unit does not exist,
|
||||
// it returns the Unit zero-value as a placeholder unit, because
|
||||
// units may depend on other units that have not yet been created.
|
||||
func (m *Manager) Unit(id ID) (Unit, error) {
|
||||
if id == "" {
|
||||
return Unit{}, xerrors.Errorf("unit ID cannot be empty: %w", ErrUnitIDRequired)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
return m.units[id], nil
|
||||
}
|
||||
|
||||
func (m *Manager) IsReady(id ID) (bool, error) {
|
||||
if id == "" {
|
||||
return false, xerrors.Errorf("unit ID cannot be empty: %w", ErrUnitIDRequired)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if !m.registered(id) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return m.units[id].ready, nil
|
||||
}
|
||||
|
||||
// AddDependency adds a dependency relationship between units.
|
||||
// The unit depends on the dependsOn unit reaching the requiredStatus.
|
||||
func (m *Manager) AddDependency(unit ID, dependsOn ID, requiredStatus Status) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
switch {
|
||||
case unit == "":
|
||||
return xerrors.Errorf("dependent name cannot be empty: %w", ErrUnitIDRequired)
|
||||
case dependsOn == "":
|
||||
return xerrors.Errorf("dependency name cannot be empty: %w", ErrUnitIDRequired)
|
||||
case !m.registered(unit):
|
||||
return xerrors.Errorf("dependent unit %q must be registered first: %w", unit, ErrUnitNotFound)
|
||||
}
|
||||
|
||||
// Add the dependency edge to the graph
|
||||
// The edge goes from unit to dependsOn, representing the dependency
|
||||
err := m.graph.AddEdge(unit, dependsOn, requiredStatus)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("adding edge for unit %q: %w", unit, errors.Join(ErrFailedToAddDependency, err))
|
||||
}
|
||||
|
||||
// Recalculate readiness for the unit since it now has a new dependency
|
||||
m.recalculateReadinessUnsafe(unit)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStatus updates a unit's status and recalculates readiness for affected dependents.
|
||||
func (m *Manager) UpdateStatus(unit ID, newStatus Status) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
switch {
|
||||
case unit == "":
|
||||
return xerrors.Errorf("updating status for unit %q: %w", unit, ErrUnitIDRequired)
|
||||
case !m.registered(unit):
|
||||
return xerrors.Errorf("unit %q must be registered first: %w", unit, ErrUnitNotFound)
|
||||
}
|
||||
|
||||
u := m.units[unit]
|
||||
if u.status == newStatus {
|
||||
return xerrors.Errorf("checking status for unit %q: %w", unit, ErrSameStatusAlreadySet)
|
||||
}
|
||||
|
||||
u.status = newStatus
|
||||
m.units[unit] = u
|
||||
|
||||
// Get all units that depend on this one (reverse adjacent vertices)
|
||||
dependents := m.graph.GetReverseAdjacentVertices(unit)
|
||||
|
||||
// Recalculate readiness for all dependents
|
||||
for _, dependent := range dependents {
|
||||
m.recalculateReadinessUnsafe(dependent.From)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// recalculateReadinessUnsafe recalculates the readiness state for a unit.
|
||||
// This method assumes the caller holds the write lock.
|
||||
func (m *Manager) recalculateReadinessUnsafe(unit ID) {
|
||||
u := m.units[unit]
|
||||
dependencies := m.graph.GetForwardAdjacentVertices(unit)
|
||||
|
||||
allSatisfied := true
|
||||
for _, dependency := range dependencies {
|
||||
requiredStatus := dependency.Edge
|
||||
dependsOnUnit := m.units[dependency.To]
|
||||
if dependsOnUnit.status != requiredStatus {
|
||||
allSatisfied = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
u.ready = allSatisfied
|
||||
m.units[unit] = u
|
||||
}
|
||||
|
||||
// GetGraph returns the underlying graph for visualization and debugging.
|
||||
// This should be used carefully as it exposes the internal graph structure.
|
||||
func (m *Manager) GetGraph() *Graph[Status, ID] {
|
||||
return m.graph
|
||||
}
|
||||
|
||||
// GetAllDependencies returns all dependencies for a unit, both satisfied and unsatisfied.
|
||||
func (m *Manager) GetAllDependencies(unit ID) ([]Dependency, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if unit == "" {
|
||||
return nil, xerrors.Errorf("unit ID cannot be empty: %w", ErrUnitIDRequired)
|
||||
}
|
||||
|
||||
if !m.registered(unit) {
|
||||
return nil, xerrors.Errorf("checking registration for unit %q: %w", unit, ErrUnitNotFound)
|
||||
}
|
||||
|
||||
dependencies := m.graph.GetForwardAdjacentVertices(unit)
|
||||
|
||||
var allDependencies []Dependency
|
||||
|
||||
for _, dependency := range dependencies {
|
||||
dependsOnUnit := m.units[dependency.To]
|
||||
requiredStatus := dependency.Edge
|
||||
allDependencies = append(allDependencies, Dependency{
|
||||
Unit: unit,
|
||||
DependsOn: dependency.To,
|
||||
RequiredStatus: requiredStatus,
|
||||
CurrentStatus: dependsOnUnit.status,
|
||||
IsSatisfied: dependsOnUnit.status == requiredStatus,
|
||||
})
|
||||
}
|
||||
|
||||
return allDependencies, nil
|
||||
}
|
||||
|
||||
// GetUnmetDependencies returns a list of unsatisfied dependencies for a unit.
|
||||
func (m *Manager) GetUnmetDependencies(unit ID) ([]Dependency, error) {
|
||||
allDependencies, err := m.GetAllDependencies(unit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var unmetDependencies []Dependency = slice.Filter(allDependencies, func(dependency Dependency) bool {
|
||||
return !dependency.IsSatisfied
|
||||
})
|
||||
|
||||
return unmetDependencies, nil
|
||||
}
|
||||
|
||||
// ExportDOT exports the dependency graph to DOT format for visualization.
|
||||
func (m *Manager) ExportDOT(name string) (string, error) {
|
||||
return m.graph.ToDOT(name)
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
package unit_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
)
|
||||
|
||||
const (
|
||||
unitA unit.ID = "serviceA"
|
||||
unitB unit.ID = "serviceB"
|
||||
unitC unit.ID = "serviceC"
|
||||
unitD unit.ID = "serviceD"
|
||||
)
|
||||
|
||||
func TestManager_UnitValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Empty Unit Name", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
err := manager.Register("")
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
err = manager.AddDependency("", unitA, unit.StatusStarted)
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
err = manager.AddDependency(unitA, "", unit.StatusStarted)
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
dependencies, err := manager.GetAllDependencies("")
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
require.Len(t, dependencies, 0)
|
||||
unmetDependencies, err := manager.GetUnmetDependencies("")
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
require.Len(t, unmetDependencies, 0)
|
||||
err = manager.UpdateStatus("", unit.StatusStarted)
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
isReady, err := manager.IsReady("")
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
require.False(t, isReady)
|
||||
u, err := manager.Unit("")
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
assert.Equal(t, unit.Unit{}, u)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_Register(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("RegisterNewUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: a unit is registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: the unit should be ready (no dependencies)
|
||||
u, err := manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unitA, u.ID())
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
|
||||
t.Run("RegisterDuplicateUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: a unit is registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Newly registered units have StatusPending. We update the unit status to StatusStarted,
|
||||
// so we can later assert that it is not overwritten back to StatusPending by the second
|
||||
// register call
|
||||
manager.UpdateStatus(unitA, unit.StatusStarted)
|
||||
|
||||
// When: the unit is registered again
|
||||
err = manager.Register(unitA)
|
||||
|
||||
// Then: a descriptive error should be returned
|
||||
require.ErrorIs(t, err, unit.ErrUnitAlreadyRegistered)
|
||||
|
||||
// Then: the unit status should not be overwritten
|
||||
u, err := manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusStarted, u.Status())
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
|
||||
t.Run("RegisterMultipleUnits", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: multiple units are registered
|
||||
unitIDs := []unit.ID{unitA, unitB, unitC}
|
||||
for _, unit := range unitIDs {
|
||||
err := manager.Register(unit)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Then: all units should be ready initially
|
||||
for _, unitID := range unitIDs {
|
||||
u, err := manager.Unit(unitID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err := manager.IsReady(unitID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_AddDependency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("AddDependencyBetweenRegisteredUnits", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: units A and B are registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should not be ready (depends on B)
|
||||
u, err := manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// Then: Unit B should still be ready (no dependencies)
|
||||
u, err = manager.Unit(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because its dependency is now in the desired state.
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
|
||||
// When: Unit B is stopped
|
||||
err = manager.UpdateStatus(unitB, unit.StatusPending)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should no longer be ready, because its dependency is not in the desired state.
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
})
|
||||
|
||||
t.Run("AddDependencyByAnUnregisteredDependentUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given Unit B is registered
|
||||
err := manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given Unit A depends on Unit B being started
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
|
||||
// Then: a descriptive error communicates that the dependency cannot be added
|
||||
// because the dependent unit must be registered first.
|
||||
require.ErrorIs(t, err, unit.ErrUnitNotFound)
|
||||
})
|
||||
|
||||
t.Run("AddDependencyOnAnUnregisteredUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given unit A is registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given Unit B is not yet registered
|
||||
// And Unit A depends on Unit B being started
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: The dependency should be visible in Unit A's status
|
||||
dependencies, err := manager.GetAllDependencies(unitA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, dependencies, 1)
|
||||
assert.Equal(t, unitB, dependencies[0].DependsOn)
|
||||
assert.Equal(t, unit.StatusStarted, dependencies[0].RequiredStatus)
|
||||
assert.False(t, dependencies[0].IsSatisfied)
|
||||
|
||||
u, err := manager.Unit(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusNotRegistered, u.Status())
|
||||
|
||||
// Then: Unit A should not be ready, because it depends on Unit B
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// When: Unit B is registered
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should still not be ready.
|
||||
// Unit B is not registered, but it has not been started as required by the dependency.
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because its dependency is now in the desired state.
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
|
||||
t.Run("AddDependencyCreatesACyclicDependency", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register units
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitC)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitD)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A depends on B
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
// B depends on C
|
||||
err = manager.AddDependency(unitB, unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// C depends on D
|
||||
err = manager.AddDependency(unitC, unitD, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to make D depend on A (creates indirect cycle)
|
||||
err = manager.AddDependency(unitD, unitA, unit.StatusStarted)
|
||||
require.ErrorIs(t, err, unit.ErrCycleDetected)
|
||||
})
|
||||
|
||||
t.Run("UpdatingADependency", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given units A and B are registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: The dependency is updated to unit.StatusComplete
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should only have one dependency, and it should be unit.StatusComplete
|
||||
dependencies, err := manager.GetAllDependencies(unitA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, dependencies, 1)
|
||||
assert.Equal(t, unit.StatusComplete, dependencies[0].RequiredStatus)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_UpdateStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("UpdateStatusTriggersReadinessRecalculation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given units A and B are registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should not be ready (depends on B)
|
||||
u, err := manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because its dependency is now in the desired state.
|
||||
u, err = manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
|
||||
t.Run("UpdateStatusWithUnregisteredUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given Unit A is not registered
|
||||
// When: Unit A is updated to unit.StatusStarted
|
||||
err := manager.UpdateStatus(unitA, unit.StatusStarted)
|
||||
|
||||
// Then: a descriptive error communicates that the unit must be registered first.
|
||||
require.ErrorIs(t, err, unit.ErrUnitNotFound)
|
||||
})
|
||||
|
||||
t.Run("LinearChainDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given units A, B, and C are registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitC)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create chain: A depends on B being "started", B depends on C being "completed"
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitB, unitC, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: only Unit C should be ready (no dependencies)
|
||||
u, err := manager.Unit(unitC)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err := manager.IsReady(unitC)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
|
||||
u, err = manager.Unit(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
u, err = manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// When: Unit C is completed
|
||||
err = manager.UpdateStatus(unitC, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit B should be ready, because its dependency is now in the desired state.
|
||||
u, err = manager.Unit(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
|
||||
u, err = manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
u, err = manager.Unit(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because its dependency is now in the desired state.
|
||||
u, err = manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_GetUnmetDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("GetUnmetDependenciesForUnitWithNoDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: Unit A is registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A has no dependencies
|
||||
// Then: Unit A should have no unmet dependencies
|
||||
unmet, err := manager.GetUnmetDependencies(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, unmet)
|
||||
})
|
||||
|
||||
t.Run("GetUnmetDependenciesForUnitWithUnsatisfiedDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
unmet, err := manager.GetUnmetDependencies(unitA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, unmet, 1)
|
||||
|
||||
assert.Equal(t, unitA, unmet[0].Unit)
|
||||
assert.Equal(t, unitB, unmet[0].DependsOn)
|
||||
assert.Equal(t, unit.StatusStarted, unmet[0].RequiredStatus)
|
||||
assert.False(t, unmet[0].IsSatisfied)
|
||||
})
|
||||
|
||||
t.Run("GetUnmetDependenciesForUnitWithSatisfiedDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: Unit A and Unit B are registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should have no unmet dependencies
|
||||
unmet, err := manager.GetUnmetDependencies(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, unmet)
|
||||
})
|
||||
|
||||
t.Run("GetUnmetDependenciesForUnregisteredUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// When: Unit A is requested
|
||||
unmet, err := manager.GetUnmetDependencies(unitA)
|
||||
|
||||
// Then: a descriptive error communicates that the unit must be registered first.
|
||||
require.ErrorIs(t, err, unit.ErrUnitNotFound)
|
||||
assert.Nil(t, unmet)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_MultipleDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("UnitWithMultipleDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register all units
|
||||
units := []unit.ID{unitA, unitB, unitC, unitD}
|
||||
for _, unit := range units {
|
||||
err := manager.Register(unit)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// A depends on B being unit.StatusStarted AND C being "started"
|
||||
err := manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitA, unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A should not be ready (depends on both B and C)
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// Update B to unit.StatusStarted - A should still not be ready (needs C too)
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// Update C to "started" - A should now be ready
|
||||
err = manager.UpdateStatus(unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
|
||||
t.Run("ComplexDependencyChain", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register all units
|
||||
units := []unit.ID{unitA, unitB, unitC, unitD}
|
||||
for _, unit := range units {
|
||||
err := manager.Register(unit)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create complex dependency graph:
|
||||
// A depends on B being unit.StatusStarted AND C being "started"
|
||||
err := manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitA, unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
// B depends on D being "completed"
|
||||
err = manager.AddDependency(unitB, unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
// C depends on D being "completed"
|
||||
err = manager.AddDependency(unitC, unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Initially only D is ready
|
||||
isReady, err := manager.IsReady(unitD)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
isReady, err = manager.IsReady(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
isReady, err = manager.IsReady(unitC)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// Update D to "completed" - B and C should become ready
|
||||
err = manager.UpdateStatus(unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
isReady, err = manager.IsReady(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
isReady, err = manager.IsReady(unitC)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// Update B to unit.StatusStarted - A should still not be ready (needs C)
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// Update C to "started" - A should now be ready
|
||||
err = manager.UpdateStatus(unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
|
||||
t.Run("DifferentStatusTypes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register units
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitC)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
// Given: Unit A depends on Unit C being "completed"
|
||||
err = manager.AddDependency(unitA, unitC, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should not be ready, because only one of its dependencies is in the desired state.
|
||||
// It still requires Unit C to be completed.
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// When: Unit C is completed
|
||||
err = manager.UpdateStatus(unitC, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because both of its dependencies are in the desired state.
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_IsReady(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("IsReadyWithUnregisteredUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: a unit is not registered
|
||||
u, err := manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusNotRegistered, u.Status())
|
||||
// Then: the unit is not ready
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_ToDOT(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ExportSimpleGraph", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register units
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add dependency
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
dot, err := manager.ExportDOT("test")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, dot)
|
||||
assert.Contains(t, dot, "digraph")
|
||||
})
|
||||
|
||||
t.Run("ExportComplexGraph", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register all units
|
||||
units := []unit.ID{unitA, unitB, unitC, unitD}
|
||||
for _, unit := range units {
|
||||
err := manager.Register(unit)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create complex dependency graph
|
||||
// A depends on B and C, B depends on D, C depends on D
|
||||
err := manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitA, unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitB, unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitC, unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
dot, err := manager.ExportDOT("complex")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, dot)
|
||||
assert.Contains(t, dot, "digraph")
|
||||
})
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
strict digraph Cycle {
|
||||
// Node definitions.
|
||||
1;
|
||||
2;
|
||||
|
||||
// Edge definitions.
|
||||
1 -> 2;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
strict digraph ForwardAndReverseEdges {
|
||||
// Node definitions.
|
||||
1;
|
||||
2;
|
||||
3;
|
||||
|
||||
// Edge definitions.
|
||||
1 -> 2;
|
||||
1 -> 3;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
strict digraph MultipleDependenciesSameStatus {
|
||||
// Node definitions.
|
||||
1;
|
||||
2;
|
||||
3;
|
||||
4;
|
||||
|
||||
// Edge definitions.
|
||||
1 -> 2;
|
||||
1 -> 3;
|
||||
1 -> 4;
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
strict digraph SelfReference {
|
||||
// Node definitions.
|
||||
1;
|
||||
}
|
||||
+62
-17
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -56,6 +57,8 @@ func workspaceAgent() *serpent.Command {
|
||||
devcontainers bool
|
||||
devcontainerProjectDiscovery bool
|
||||
devcontainerDiscoveryAutostart bool
|
||||
socketServerEnabled bool
|
||||
socketPath string
|
||||
)
|
||||
agentAuth := &AgentAuth{}
|
||||
cmd := &serpent.Command{
|
||||
@@ -201,18 +204,15 @@ func workspaceAgent() *serpent.Command {
|
||||
// Enable pprof handler
|
||||
// This prevents the pprof import from being accidentally deleted.
|
||||
_ = pprof.Handler
|
||||
pprofSrvClose := ServeHandler(ctx, logger, nil, pprofAddress, "pprof")
|
||||
defer pprofSrvClose()
|
||||
if port, err := extractPort(pprofAddress); err == nil {
|
||||
ignorePorts[port] = "pprof"
|
||||
}
|
||||
if pprofAddress != "" {
|
||||
pprofSrvClose := ServeHandler(ctx, logger, nil, pprofAddress, "pprof")
|
||||
defer pprofSrvClose()
|
||||
|
||||
if port, err := extractPort(prometheusAddress); err == nil {
|
||||
ignorePorts[port] = "prometheus"
|
||||
}
|
||||
|
||||
if port, err := extractPort(debugAddress); err == nil {
|
||||
ignorePorts[port] = "debug"
|
||||
if port, err := extractPort(pprofAddress); err == nil {
|
||||
ignorePorts[port] = "pprof"
|
||||
}
|
||||
} else {
|
||||
logger.Debug(ctx, "pprof address is empty, disabling pprof server")
|
||||
}
|
||||
|
||||
executablePath, err := os.Executable()
|
||||
@@ -276,6 +276,28 @@ func workspaceAgent() *serpent.Command {
|
||||
for {
|
||||
prometheusRegistry := prometheus.NewRegistry()
|
||||
|
||||
promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
|
||||
var serverClose []func()
|
||||
if prometheusAddress != "" {
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promHandler, prometheusAddress, "prometheus")
|
||||
serverClose = append(serverClose, prometheusSrvClose)
|
||||
|
||||
if port, err := extractPort(prometheusAddress); err == nil {
|
||||
ignorePorts[port] = "prometheus"
|
||||
}
|
||||
} else {
|
||||
logger.Debug(ctx, "prometheus address is empty, disabling prometheus server")
|
||||
}
|
||||
|
||||
if debugAddress != "" {
|
||||
// ServerHandle depends on `agnt.HTTPDebug()`, but `agnt`
|
||||
// depends on `ignorePorts`. Keep this if statement in sync
|
||||
// with below.
|
||||
if port, err := extractPort(debugAddress); err == nil {
|
||||
ignorePorts[port] = "debug"
|
||||
}
|
||||
}
|
||||
|
||||
agnt := agent.New(agent.Options{
|
||||
Client: client,
|
||||
Logger: logger,
|
||||
@@ -297,12 +319,19 @@ func workspaceAgent() *serpent.Command {
|
||||
agentcontainers.WithProjectDiscovery(devcontainerProjectDiscovery),
|
||||
agentcontainers.WithDiscoveryAutostart(devcontainerDiscoveryAutostart),
|
||||
},
|
||||
SocketPath: socketPath,
|
||||
SocketServerEnabled: socketServerEnabled,
|
||||
})
|
||||
|
||||
promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promHandler, prometheusAddress, "prometheus")
|
||||
|
||||
debugSrvClose := ServeHandler(ctx, logger, agnt.HTTPDebug(), debugAddress, "debug")
|
||||
if debugAddress != "" {
|
||||
// ServerHandle depends on `agnt.HTTPDebug()`, but `agnt`
|
||||
// depends on `ignorePorts`. Keep this if statement in sync
|
||||
// with above.
|
||||
debugSrvClose := ServeHandler(ctx, logger, agnt.HTTPDebug(), debugAddress, "debug")
|
||||
serverClose = append(serverClose, debugSrvClose)
|
||||
} else {
|
||||
logger.Debug(ctx, "debug address is empty, disabling debug server")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -314,8 +343,11 @@ func workspaceAgent() *serpent.Command {
|
||||
}
|
||||
|
||||
lastErr = agnt.Close()
|
||||
debugSrvClose()
|
||||
prometheusSrvClose()
|
||||
|
||||
slices.Reverse(serverClose)
|
||||
for _, closeFunc := range serverClose {
|
||||
closeFunc()
|
||||
}
|
||||
|
||||
if mustExit {
|
||||
break
|
||||
@@ -449,6 +481,19 @@ func workspaceAgent() *serpent.Command {
|
||||
Description: "Allow the agent to autostart devcontainer projects it discovers based on their configuration.",
|
||||
Value: serpent.BoolOf(&devcontainerDiscoveryAutostart),
|
||||
},
|
||||
{
|
||||
Flag: "socket-server-enabled",
|
||||
Default: "false",
|
||||
Env: "CODER_AGENT_SOCKET_SERVER_ENABLED",
|
||||
Description: "Enable the agent socket server.",
|
||||
Value: serpent.BoolOf(&socketServerEnabled),
|
||||
},
|
||||
{
|
||||
Flag: "socket-path",
|
||||
Env: "CODER_AGENT_SOCKET_PATH",
|
||||
Description: "Specify the path for the agent socket.",
|
||||
Value: serpent.StringOf(&socketPath),
|
||||
},
|
||||
}
|
||||
agentAuth.AttachOptions(cmd, false)
|
||||
return cmd
|
||||
|
||||
@@ -178,6 +178,51 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
require.Greater(t, atomic.LoadInt64(&called), int64(0), "expected coderd to be reached with custom headers")
|
||||
require.Greater(t, atomic.LoadInt64(&derpCalled), int64(0), "expected /derp to be called with custom headers")
|
||||
})
|
||||
|
||||
t.Run("DisabledServers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
logDir := t.TempDir()
|
||||
inv, _ := clitest.New(t,
|
||||
"agent",
|
||||
"--auth", "token",
|
||||
"--agent-token", r.AgentToken,
|
||||
"--agent-url", client.URL.String(),
|
||||
"--log-dir", logDir,
|
||||
"--pprof-address", "",
|
||||
"--prometheus-address", "",
|
||||
"--debug-address", "",
|
||||
)
|
||||
|
||||
clitest.Start(t, inv)
|
||||
|
||||
// Verify the agent is connected and working.
|
||||
resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).
|
||||
MatchResources(matchAgentWithVersion).Wait()
|
||||
require.Len(t, resources, 1)
|
||||
require.Len(t, resources[0].Agents, 1)
|
||||
require.NotEmpty(t, resources[0].Agents[0].Version)
|
||||
|
||||
// Verify the servers are not listening by checking the log for disabled
|
||||
// messages.
|
||||
require.Eventually(t, func() bool {
|
||||
logContent, err := os.ReadFile(filepath.Join(logDir, "coder-agent.log"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
logStr := string(logContent)
|
||||
return strings.Contains(logStr, "pprof address is empty, disabling pprof server") &&
|
||||
strings.Contains(logStr, "prometheus address is empty, disabling prometheus server") &&
|
||||
strings.Contains(logStr, "debug address is empty, disabling debug server")
|
||||
}, testutil.WaitLong, testutil.IntervalMedium)
|
||||
})
|
||||
}
|
||||
|
||||
func matchAgentWithVersion(rs []codersdk.WorkspaceResource) bool {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
var (
|
||||
_ pflag.SliceValue = &AllowListFlag{}
|
||||
_ pflag.Value = &AllowListFlag{}
|
||||
)
|
||||
|
||||
// AllowListFlag implements pflag.SliceValue for codersdk.APIAllowListTarget entries.
|
||||
type AllowListFlag []codersdk.APIAllowListTarget
|
||||
|
||||
func AllowListFlagOf(al *[]codersdk.APIAllowListTarget) *AllowListFlag {
|
||||
return (*AllowListFlag)(al)
|
||||
}
|
||||
|
||||
func (a AllowListFlag) String() string {
|
||||
return strings.Join(a.GetSlice(), ",")
|
||||
}
|
||||
|
||||
func (a AllowListFlag) Value() []codersdk.APIAllowListTarget {
|
||||
return []codersdk.APIAllowListTarget(a)
|
||||
}
|
||||
|
||||
func (AllowListFlag) Type() string { return "allow-list" }
|
||||
|
||||
func (a *AllowListFlag) Set(set string) error {
|
||||
values, err := csv.NewReader(strings.NewReader(set)).Read()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse allow list entries as csv: %w", err)
|
||||
}
|
||||
for _, v := range values {
|
||||
if err := a.Append(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AllowListFlag) Append(value string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return xerrors.New("allow list entry cannot be empty")
|
||||
}
|
||||
var target codersdk.APIAllowListTarget
|
||||
if err := target.UnmarshalText([]byte(value)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*a = append(*a, target)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AllowListFlag) Replace(items []string) error {
|
||||
*a = []codersdk.APIAllowListTarget{}
|
||||
for _, item := range items {
|
||||
if err := a.Append(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AllowListFlag) GetSlice() []string {
|
||||
out := make([]string, len(*a))
|
||||
for i, entry := range *a {
|
||||
out[i] = entry.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
+20
-4
@@ -28,7 +28,9 @@ import (
|
||||
)
|
||||
|
||||
// New creates a CLI instance with a configuration pointed to a
|
||||
// temporary testing directory.
|
||||
// temporary testing directory. The invocation is set up to use a
|
||||
// global config directory for the given testing.TB, and keyring
|
||||
// usage disabled.
|
||||
func New(t testing.TB, args ...string) (*serpent.Invocation, config.Root) {
|
||||
var root cli.RootCmd
|
||||
|
||||
@@ -59,6 +61,15 @@ func NewWithCommand(
|
||||
t testing.TB, cmd *serpent.Command, args ...string,
|
||||
) (*serpent.Invocation, config.Root) {
|
||||
configDir := config.Root(t.TempDir())
|
||||
// Keyring usage is disabled here when --global-config is set because many existing
|
||||
// tests expect the session token to be stored on disk and is not properly instrumented
|
||||
// for parallel testing against the actual operating system keyring.
|
||||
invArgs := append([]string{"--global-config", string(configDir)}, args...)
|
||||
return setupInvocation(t, cmd, invArgs...), configDir
|
||||
}
|
||||
|
||||
func setupInvocation(t testing.TB, cmd *serpent.Command, args ...string,
|
||||
) *serpent.Invocation {
|
||||
// I really would like to fail test on error logs, but realistically, turning on by default
|
||||
// in all our CLI tests is going to create a lot of flaky noise.
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
|
||||
@@ -66,16 +77,21 @@ func NewWithCommand(
|
||||
Named("cli")
|
||||
i := &serpent.Invocation{
|
||||
Command: cmd,
|
||||
Args: append([]string{"--global-config", string(configDir)}, args...),
|
||||
Args: args,
|
||||
Stdin: io.LimitReader(nil, 0),
|
||||
Stdout: (&logWriter{prefix: "stdout", log: logger}),
|
||||
Stderr: (&logWriter{prefix: "stderr", log: logger}),
|
||||
Logger: logger,
|
||||
}
|
||||
t.Logf("invoking command: %s %s", cmd.Name(), strings.Join(i.Args, " "))
|
||||
return i
|
||||
}
|
||||
|
||||
// These can be overridden by the test.
|
||||
return i, configDir
|
||||
func NewWithDefaultKeyringCommand(t testing.TB, cmd *serpent.Command, args ...string,
|
||||
) (*serpent.Invocation, config.Root) {
|
||||
configDir := config.Root(t.TempDir())
|
||||
invArgs := append([]string{"--global-config", string(configDir)}, args...)
|
||||
return setupInvocation(t, cmd, invArgs...), configDir
|
||||
}
|
||||
|
||||
// SetupConfig applies the URL and SessionToken of the client to the config.
|
||||
|
||||
+26
-1
@@ -53,6 +53,9 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
t := time.NewTimer(0)
|
||||
defer t.Stop()
|
||||
|
||||
startTime := time.Now()
|
||||
baseInterval := opts.FetchInterval
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -68,7 +71,11 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
return
|
||||
}
|
||||
fetchedAgent <- fetchAgent{agent: agent}
|
||||
t.Reset(opts.FetchInterval)
|
||||
|
||||
// Adjust the interval based on how long we've been waiting.
|
||||
elapsed := time.Since(startTime)
|
||||
currentInterval := GetProgressiveInterval(baseInterval, elapsed)
|
||||
t.Reset(currentInterval)
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -293,6 +300,24 @@ func safeDuration(sw *stageWriter, a, b *time.Time) time.Duration {
|
||||
return a.Sub(*b)
|
||||
}
|
||||
|
||||
// GetProgressiveInterval returns an interval that increases over time.
|
||||
// The interval starts at baseInterval and increases to
|
||||
// a maximum of baseInterval * 16 over time.
|
||||
func GetProgressiveInterval(baseInterval time.Duration, elapsed time.Duration) time.Duration {
|
||||
switch {
|
||||
case elapsed < 60*time.Second:
|
||||
return baseInterval // 500ms for first 60 seconds
|
||||
case elapsed < 2*time.Minute:
|
||||
return baseInterval * 2 // 1s for next 1 minute
|
||||
case elapsed < 5*time.Minute:
|
||||
return baseInterval * 4 // 2s for next 3 minutes
|
||||
case elapsed < 10*time.Minute:
|
||||
return baseInterval * 8 // 4s for next 5 minutes
|
||||
default:
|
||||
return baseInterval * 16 // 8s after 10 minutes
|
||||
}
|
||||
}
|
||||
|
||||
type closeFunc func() error
|
||||
|
||||
func (c closeFunc) Close() error {
|
||||
|
||||
@@ -866,3 +866,31 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProgressiveInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseInterval := 500 * time.Millisecond
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
elapsed time.Duration
|
||||
expected time.Duration
|
||||
}{
|
||||
{"first_minute", 30 * time.Second, baseInterval},
|
||||
{"second_minute", 90 * time.Second, baseInterval * 2},
|
||||
{"third_to_fifth_minute", 3 * time.Minute, baseInterval * 4},
|
||||
{"sixth_to_tenth_minute", 7 * time.Minute, baseInterval * 8},
|
||||
{"after_ten_minutes", 15 * time.Minute, baseInterval * 16},
|
||||
{"boundary_first_minute", 59 * time.Second, baseInterval},
|
||||
{"boundary_second_minute", 61 * time.Second, baseInterval * 2},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := cliui.GetProgressiveInterval(baseInterval, tc.elapsed)
|
||||
require.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+18
-9
@@ -296,22 +296,23 @@ func renderTable(out any, sort string, headers table.Row, filterColumns []string
|
||||
// returned. If the table tag is malformed, an error is returned.
|
||||
//
|
||||
// The returned name is transformed from "snake_case" to "normal text".
|
||||
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, noSortOpt, recursive, skipParentName bool, err error) {
|
||||
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, noSortOpt, recursive, skipParentName, emptyNil bool, err error) {
|
||||
tags, err := structtag.Parse(string(field.Tag))
|
||||
if err != nil {
|
||||
return "", false, false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||
return "", false, false, false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||
}
|
||||
|
||||
tag, err := tags.Get("table")
|
||||
if err != nil || tag.Name == "-" {
|
||||
// tags.Get only returns an error if the tag is not found.
|
||||
return "", false, false, false, false, nil
|
||||
return "", false, false, false, false, false, nil
|
||||
}
|
||||
|
||||
defaultSortOpt := false
|
||||
noSortOpt = false
|
||||
recursiveOpt := false
|
||||
skipParentNameOpt := false
|
||||
emptyNilOpt := false
|
||||
for _, opt := range tag.Options {
|
||||
switch opt {
|
||||
case "default_sort":
|
||||
@@ -326,12 +327,14 @@ func parseTableStructTag(field reflect.StructField) (name string, defaultSort, n
|
||||
// make sure the child name is unique across all nested structs in the parent.
|
||||
recursiveOpt = true
|
||||
skipParentNameOpt = true
|
||||
case "empty_nil":
|
||||
emptyNilOpt = true
|
||||
default:
|
||||
return "", false, false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||
return "", false, false, false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, noSortOpt, recursiveOpt, skipParentNameOpt, nil
|
||||
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, noSortOpt, recursiveOpt, skipParentNameOpt, emptyNilOpt, nil
|
||||
}
|
||||
|
||||
func isStructOrStructPointer(t reflect.Type) bool {
|
||||
@@ -358,7 +361,7 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string,
|
||||
noSortOpt := false
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
name, defaultSort, noSort, recursive, skip, err := parseTableStructTag(field)
|
||||
name, defaultSort, noSort, recursive, skip, _, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
|
||||
}
|
||||
@@ -435,7 +438,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Type().Field(i)
|
||||
fieldVal := val.Field(i)
|
||||
name, _, _, recursive, skip, err := parseTableStructTag(field)
|
||||
name, _, _, recursive, skip, emptyNil, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
|
||||
}
|
||||
@@ -443,8 +446,14 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Recurse if it's a struct.
|
||||
fieldType := field.Type
|
||||
|
||||
// If empty_nil is set and this is a nil pointer, use a zero value.
|
||||
if emptyNil && fieldVal.Kind() == reflect.Pointer && fieldVal.IsNil() {
|
||||
fieldVal = reflect.New(fieldType.Elem())
|
||||
}
|
||||
|
||||
// Recurse if it's a struct.
|
||||
if recursive {
|
||||
if !isStructOrStructPointer(fieldType) {
|
||||
return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, fieldType.String())
|
||||
@@ -467,7 +476,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
}
|
||||
|
||||
// Otherwise, we just use the field value.
|
||||
row[name] = val.Field(i).Interface()
|
||||
row[name] = fieldVal.Interface()
|
||||
}
|
||||
|
||||
return row, nil
|
||||
|
||||
@@ -400,6 +400,78 @@ foo <nil> 10 [a, b, c] foo1 11 foo2 12 fo
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("EmptyNil", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type emptyNilTest struct {
|
||||
Name string `table:"name,default_sort"`
|
||||
EmptyOnNil *string `table:"empty_on_nil,empty_nil"`
|
||||
NormalBehavior *string `table:"normal_behavior"`
|
||||
}
|
||||
|
||||
value := "value"
|
||||
in := []emptyNilTest{
|
||||
{
|
||||
Name: "has_value",
|
||||
EmptyOnNil: &value,
|
||||
NormalBehavior: &value,
|
||||
},
|
||||
{
|
||||
Name: "has_nil",
|
||||
EmptyOnNil: nil,
|
||||
NormalBehavior: nil,
|
||||
},
|
||||
}
|
||||
|
||||
expected := `
|
||||
NAME EMPTY ON NIL NORMAL BEHAVIOR
|
||||
has_nil <nil>
|
||||
has_value value value
|
||||
`
|
||||
|
||||
out, err := cliui.DisplayTable(in, "", nil)
|
||||
log.Println("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
t.Run("EmptyNilWithRecursiveInline", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type nestedData struct {
|
||||
Name string `table:"name"`
|
||||
}
|
||||
|
||||
type inlineTest struct {
|
||||
Nested *nestedData `table:"ignored,recursive_inline,empty_nil"`
|
||||
Count int `table:"count,default_sort"`
|
||||
}
|
||||
|
||||
in := []inlineTest{
|
||||
{
|
||||
Nested: &nestedData{
|
||||
Name: "alice",
|
||||
},
|
||||
Count: 1,
|
||||
},
|
||||
{
|
||||
Nested: nil,
|
||||
Count: 2,
|
||||
},
|
||||
}
|
||||
|
||||
expected := `
|
||||
NAME COUNT
|
||||
alice 1
|
||||
2
|
||||
`
|
||||
|
||||
out, err := cliui.DisplayTable(in, "", nil)
|
||||
log.Println("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
}
|
||||
|
||||
// compareTables normalizes the incoming table lines
|
||||
|
||||
+48
-44
@@ -577,53 +577,57 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
|
||||
return nil, xerrors.Errorf("template version git auth: %w", err)
|
||||
}
|
||||
|
||||
// Run a dry-run with the given parameters to check correctness
|
||||
dryRun, err := client.CreateTemplateVersionDryRun(inv.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
RichParameterValues: buildParameters,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("begin workspace dry-run: %w", err)
|
||||
}
|
||||
// Only perform dry-run for workspace creation and updates
|
||||
// Skip for start and restart to avoid unnecessary delays
|
||||
if args.Action == WorkspaceCreate || args.Action == WorkspaceUpdate {
|
||||
// Run a dry-run with the given parameters to check correctness
|
||||
dryRun, err := client.CreateTemplateVersionDryRun(inv.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
RichParameterValues: buildParameters,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("begin workspace dry-run: %w", err)
|
||||
}
|
||||
|
||||
matchedProvisioners, err := client.TemplateVersionDryRunMatchedProvisioners(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get matched provisioners: %w", err)
|
||||
}
|
||||
cliutil.WarnMatchedProvisioners(inv.Stdout, &matchedProvisioners, dryRun)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Planning workspace...")
|
||||
err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
return client.TemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelTemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
|
||||
return client.TemplateVersionDryRunLogsAfter(inv.Context(), templateVersion.ID, dryRun.ID, 0)
|
||||
},
|
||||
// Don't show log output for the dry-run unless there's an error.
|
||||
Silent: true,
|
||||
})
|
||||
if err != nil {
|
||||
// TODO (Dean): reprompt for parameter values if we deem it to
|
||||
// be a validation error
|
||||
return nil, xerrors.Errorf("dry-run workspace: %w", err)
|
||||
}
|
||||
matchedProvisioners, err := client.TemplateVersionDryRunMatchedProvisioners(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get matched provisioners: %w", err)
|
||||
}
|
||||
cliutil.WarnMatchedProvisioners(inv.Stdout, &matchedProvisioners, dryRun)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Planning workspace...")
|
||||
err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
return client.TemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelTemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
|
||||
return client.TemplateVersionDryRunLogsAfter(inv.Context(), templateVersion.ID, dryRun.ID, 0)
|
||||
},
|
||||
// Don't show log output for the dry-run unless there's an error.
|
||||
Silent: true,
|
||||
})
|
||||
if err != nil {
|
||||
// TODO (Dean): reprompt for parameter values if we deem it to
|
||||
// be a validation error
|
||||
return nil, xerrors.Errorf("dry-run workspace: %w", err)
|
||||
}
|
||||
|
||||
resources, err := client.TemplateVersionDryRunResources(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace dry-run resources: %w", err)
|
||||
}
|
||||
resources, err := client.TemplateVersionDryRunResources(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace dry-run resources: %w", err)
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceResources(inv.Stdout, resources, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
// Since agents haven't connected yet, hiding this makes more sense.
|
||||
HideAgentState: true,
|
||||
Title: "Workspace Preview",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get resources: %w", err)
|
||||
err = cliui.WorkspaceResources(inv.Stdout, resources, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
// Since agents haven't connected yet, hiding this makes more sense.
|
||||
HideAgentState: true,
|
||||
Title: "Workspace Preview",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get resources: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return buildParameters, nil
|
||||
|
||||
@@ -185,9 +185,6 @@ func TestDelete(t *testing.T) {
|
||||
|
||||
t.Run("WarnNoProvisioners", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("this test requires postgres")
|
||||
}
|
||||
|
||||
store, ps, db := dbtestutil.NewDBWithSQLDB(t)
|
||||
client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{
|
||||
@@ -228,9 +225,6 @@ func TestDelete(t *testing.T) {
|
||||
|
||||
t.Run("Prebuilt workspace delete permissions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("this test requires postgres")
|
||||
}
|
||||
|
||||
// Setup
|
||||
db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
|
||||
|
||||
+100
-383
@@ -29,7 +29,6 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
notificationsLib "github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
@@ -40,7 +39,6 @@ import (
|
||||
"github.com/coder/coder/v2/scaletest/dashboard"
|
||||
"github.com/coder/coder/v2/scaletest/harness"
|
||||
"github.com/coder/coder/v2/scaletest/loadtestutil"
|
||||
"github.com/coder/coder/v2/scaletest/notifications"
|
||||
"github.com/coder/coder/v2/scaletest/reconnectingpty"
|
||||
"github.com/coder/coder/v2/scaletest/workspacebuild"
|
||||
"github.com/coder/coder/v2/scaletest/workspacetraffic"
|
||||
@@ -66,6 +64,9 @@ func (r *RootCmd) scaletestCmd() *serpent.Command {
|
||||
r.scaletestWorkspaceTraffic(),
|
||||
r.scaletestAutostart(),
|
||||
r.scaletestNotifications(),
|
||||
r.scaletestTaskStatus(),
|
||||
r.scaletestSMTP(),
|
||||
r.scaletestPrebuilds(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -385,6 +386,88 @@ func (s *scaletestPrometheusFlags) attach(opts *serpent.OptionSet) {
|
||||
)
|
||||
}
|
||||
|
||||
// workspaceTargetFlags holds common flags for targeting specific workspaces in scale tests.
|
||||
type workspaceTargetFlags struct {
|
||||
template string
|
||||
targetWorkspaces string
|
||||
useHostLogin bool
|
||||
}
|
||||
|
||||
// attach adds the workspace target flags to the given options set.
|
||||
func (f *workspaceTargetFlags) attach(opts *serpent.OptionSet) {
|
||||
*opts = append(*opts,
|
||||
serpent.Option{
|
||||
Flag: "template",
|
||||
FlagShorthand: "t",
|
||||
Env: "CODER_SCALETEST_TEMPLATE",
|
||||
Description: "Name or ID of the template. Traffic generation will be limited to workspaces created from this template.",
|
||||
Value: serpent.StringOf(&f.template),
|
||||
},
|
||||
serpent.Option{
|
||||
Flag: "target-workspaces",
|
||||
Env: "CODER_SCALETEST_TARGET_WORKSPACES",
|
||||
Description: "Target a specific range of workspaces in the format [START]:[END] (exclusive). Example: 0:10 will target the 10 first alphabetically sorted workspaces (0-9).",
|
||||
Value: serpent.StringOf(&f.targetWorkspaces),
|
||||
},
|
||||
serpent.Option{
|
||||
Flag: "use-host-login",
|
||||
Env: "CODER_SCALETEST_USE_HOST_LOGIN",
|
||||
Default: "false",
|
||||
Description: "Connect as the currently logged in user.",
|
||||
Value: serpent.BoolOf(&f.useHostLogin),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// getTargetedWorkspaces retrieves the workspaces based on the template filter and target range. warnWriter is where to
|
||||
// write a warning message if any workspaces were skipped due to ownership mismatch.
|
||||
func (f *workspaceTargetFlags) getTargetedWorkspaces(ctx context.Context, client *codersdk.Client, organizationIDs []uuid.UUID, warnWriter io.Writer) ([]codersdk.Workspace, error) {
|
||||
// Validate template if provided
|
||||
if f.template != "" {
|
||||
_, err := parseTemplate(ctx, client, organizationIDs, f.template)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse template: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse target range
|
||||
targetStart, targetEnd, err := parseTargetRange("workspaces", f.targetWorkspaces)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse target workspaces: %w", err)
|
||||
}
|
||||
|
||||
// Determine owner based on useHostLogin
|
||||
var owner string
|
||||
if f.useHostLogin {
|
||||
owner = codersdk.Me
|
||||
}
|
||||
|
||||
// Get workspaces
|
||||
workspaces, numSkipped, err := getScaletestWorkspaces(ctx, client, owner, f.template)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if numSkipped > 0 {
|
||||
cliui.Warnf(warnWriter, "CODER_DISABLE_OWNER_WORKSPACE_ACCESS is set on the deployment.\n\t%d workspace(s) were skipped due to ownership mismatch.\n\tSet --use-host-login to only target workspaces you own.", numSkipped)
|
||||
}
|
||||
|
||||
// Adjust targetEnd if not specified
|
||||
if targetEnd == 0 {
|
||||
targetEnd = len(workspaces)
|
||||
}
|
||||
|
||||
// Validate range
|
||||
if len(workspaces) == 0 {
|
||||
return nil, xerrors.Errorf("no scaletest workspaces exist")
|
||||
}
|
||||
if targetEnd > len(workspaces) {
|
||||
return nil, xerrors.Errorf("target workspace end %d is greater than the number of workspaces %d", targetEnd, len(workspaces))
|
||||
}
|
||||
|
||||
// Return the sliced workspaces
|
||||
return workspaces[targetStart:targetEnd], nil
|
||||
}
|
||||
|
||||
func requireAdmin(ctx context.Context, client *codersdk.Client) (codersdk.User, error) {
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
if err != nil {
|
||||
@@ -1194,12 +1277,10 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
bytesPerTick int64
|
||||
ssh bool
|
||||
disableDirect bool
|
||||
useHostLogin bool
|
||||
app string
|
||||
template string
|
||||
targetWorkspaces string
|
||||
workspaceProxyURL string
|
||||
|
||||
targetFlags = &workspaceTargetFlags{}
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
strategy = &scaletestStrategyFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
@@ -1244,15 +1325,9 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
},
|
||||
}
|
||||
|
||||
if template != "" {
|
||||
_, err := parseTemplate(ctx, client, me.OrganizationIDs, template)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse template: %w", err)
|
||||
}
|
||||
}
|
||||
targetWorkspaceStart, targetWorkspaceEnd, err := parseTargetRange("workspaces", targetWorkspaces)
|
||||
workspaces, err := targetFlags.getTargetedWorkspaces(ctx, client, me.OrganizationIDs, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse target workspaces: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
appHost, err := client.AppHost(ctx)
|
||||
@@ -1260,30 +1335,6 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
return xerrors.Errorf("get app host: %w", err)
|
||||
}
|
||||
|
||||
var owner string
|
||||
if useHostLogin {
|
||||
owner = codersdk.Me
|
||||
}
|
||||
|
||||
workspaces, numSkipped, err := getScaletestWorkspaces(inv.Context(), client, owner, template)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if numSkipped > 0 {
|
||||
cliui.Warnf(inv.Stdout, "CODER_DISABLE_OWNER_WORKSPACE_ACCESS is set on the deployment.\n\t%d workspace(s) were skipped due to ownership mismatch.\n\tSet --use-host-login to only target workspaces you own.", numSkipped)
|
||||
}
|
||||
|
||||
if targetWorkspaceEnd == 0 {
|
||||
targetWorkspaceEnd = len(workspaces)
|
||||
}
|
||||
|
||||
if len(workspaces) == 0 {
|
||||
return xerrors.Errorf("no scaletest workspaces exist")
|
||||
}
|
||||
if targetWorkspaceEnd > len(workspaces) {
|
||||
return xerrors.Errorf("target workspace end %d is greater than the number of workspaces %d", targetWorkspaceEnd, len(workspaces))
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
@@ -1308,10 +1359,6 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
|
||||
th := harness.NewTestHarness(strategy.toStrategy(), cleanupStrategy.toStrategy())
|
||||
for idx, ws := range workspaces {
|
||||
if idx < targetWorkspaceStart || idx >= targetWorkspaceEnd {
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
agent codersdk.WorkspaceAgent
|
||||
name = "workspace-traffic"
|
||||
@@ -1416,19 +1463,6 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
}
|
||||
|
||||
cmd.Options = []serpent.Option{
|
||||
{
|
||||
Flag: "template",
|
||||
FlagShorthand: "t",
|
||||
Env: "CODER_SCALETEST_TEMPLATE",
|
||||
Description: "Name or ID of the template. Traffic generation will be limited to workspaces created from this template.",
|
||||
Value: serpent.StringOf(&template),
|
||||
},
|
||||
{
|
||||
Flag: "target-workspaces",
|
||||
Env: "CODER_SCALETEST_TARGET_WORKSPACES",
|
||||
Description: "Target a specific range of workspaces in the format [START]:[END] (exclusive). Example: 0:10 will target the 10 first alphabetically sorted workspaces (0-9).",
|
||||
Value: serpent.StringOf(&targetWorkspaces),
|
||||
},
|
||||
{
|
||||
Flag: "bytes-per-tick",
|
||||
Env: "CODER_SCALETEST_WORKSPACE_TRAFFIC_BYTES_PER_TICK",
|
||||
@@ -1464,13 +1498,6 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
Description: "Send WebSocket traffic to a workspace app (proxied via coderd), cannot be used with --ssh.",
|
||||
Value: serpent.StringOf(&app),
|
||||
},
|
||||
{
|
||||
Flag: "use-host-login",
|
||||
Env: "CODER_SCALETEST_USE_HOST_LOGIN",
|
||||
Default: "false",
|
||||
Description: "Connect as the currently logged in user.",
|
||||
Value: serpent.BoolOf(&useHostLogin),
|
||||
},
|
||||
{
|
||||
Flag: "workspace-proxy-url",
|
||||
Env: "CODER_SCALETEST_WORKSPACE_PROXY_URL",
|
||||
@@ -1480,6 +1507,7 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
},
|
||||
}
|
||||
|
||||
targetFlags.attach(&cmd.Options)
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
strategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
@@ -1921,259 +1949,6 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) scaletestNotifications() *serpent.Command {
|
||||
var (
|
||||
userCount int64
|
||||
ownerUserPercentage float64
|
||||
notificationTimeout time.Duration
|
||||
dialTimeout time.Duration
|
||||
noCleanup bool
|
||||
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
|
||||
// This test requires unlimited concurrency.
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "notifications",
|
||||
Short: "Simulate notification delivery by creating many users listening to notifications.",
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
|
||||
defer stop()
|
||||
ctx = notifyCtx
|
||||
|
||||
me, err := requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if userCount <= 0 {
|
||||
return xerrors.Errorf("--user-count must be greater than 0")
|
||||
}
|
||||
|
||||
if ownerUserPercentage < 0 || ownerUserPercentage > 100 {
|
||||
return xerrors.Errorf("--owner-user-percentage must be between 0 and 100")
|
||||
}
|
||||
|
||||
ownerUserCount := int64(float64(userCount) * ownerUserPercentage / 100)
|
||||
if ownerUserCount == 0 && ownerUserPercentage > 0 {
|
||||
ownerUserCount = 1
|
||||
}
|
||||
regularUserCount := userCount - ownerUserCount
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Distribution plan:\n")
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Total users: %d\n", userCount)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Owner users: %d (%.1f%%)\n", ownerUserCount, ownerUserPercentage)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Regular users: %d (%.1f%%)\n", regularUserCount, 100.0-ownerUserPercentage)
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags")
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := notifications.NewMetrics(reg)
|
||||
|
||||
logger := inv.Logger
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
defer func() {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
// Wait for prometheus metrics to be scraped
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Creating users...")
|
||||
|
||||
dialBarrier := &sync.WaitGroup{}
|
||||
ownerWatchBarrier := &sync.WaitGroup{}
|
||||
dialBarrier.Add(int(userCount))
|
||||
ownerWatchBarrier.Add(int(ownerUserCount))
|
||||
|
||||
expectedNotifications := map[uuid.UUID]chan time.Time{
|
||||
notificationsLib.TemplateUserAccountCreated: make(chan time.Time, 1),
|
||||
notificationsLib.TemplateUserAccountDeleted: make(chan time.Time, 1),
|
||||
}
|
||||
|
||||
configs := make([]notifications.Config, 0, userCount)
|
||||
for range ownerUserCount {
|
||||
config := notifications.Config{
|
||||
User: createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
},
|
||||
Roles: []string{codersdk.RoleOwner},
|
||||
NotificationTimeout: notificationTimeout,
|
||||
DialTimeout: dialTimeout,
|
||||
DialBarrier: dialBarrier,
|
||||
ReceivingWatchBarrier: ownerWatchBarrier,
|
||||
ExpectedNotifications: expectedNotifications,
|
||||
Metrics: metrics,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
configs = append(configs, config)
|
||||
}
|
||||
for range regularUserCount {
|
||||
config := notifications.Config{
|
||||
User: createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
},
|
||||
Roles: []string{},
|
||||
NotificationTimeout: notificationTimeout,
|
||||
DialTimeout: dialTimeout,
|
||||
DialBarrier: dialBarrier,
|
||||
ReceivingWatchBarrier: ownerWatchBarrier,
|
||||
Metrics: metrics,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
configs = append(configs, config)
|
||||
}
|
||||
|
||||
go triggerUserNotifications(
|
||||
ctx,
|
||||
logger,
|
||||
client,
|
||||
me.OrganizationIDs[0],
|
||||
dialBarrier,
|
||||
dialTimeout,
|
||||
expectedNotifications,
|
||||
)
|
||||
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
|
||||
for i, config := range configs {
|
||||
id := strconv.Itoa(i)
|
||||
name := fmt.Sprintf("notifications-%s", id)
|
||||
var runner harness.Runnable = notifications.NewRunner(client, config)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: name,
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
th.AddRun(name, id, runner)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Running notification delivery scaletest...")
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
err = th.Run(testCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
// If the command was interrupted, skip stats.
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !noCleanup {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up...")
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "user-count",
|
||||
FlagShorthand: "c",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_USER_COUNT",
|
||||
Description: "Required: Total number of users to create.",
|
||||
Value: serpent.Int64Of(&userCount),
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Flag: "owner-user-percentage",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_OWNER_USER_PERCENTAGE",
|
||||
Default: "20.0",
|
||||
Description: "Percentage of users to assign Owner role to (0-100).",
|
||||
Value: serpent.Float64Of(&ownerUserPercentage),
|
||||
},
|
||||
{
|
||||
Flag: "notification-timeout",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_TIMEOUT",
|
||||
Default: "5m",
|
||||
Description: "How long to wait for notifications after triggering.",
|
||||
Value: serpent.DurationOf(¬ificationTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "dial-timeout",
|
||||
Env: "CODER_SCALETEST_DIAL_TIMEOUT",
|
||||
Default: "2m",
|
||||
Description: "Timeout for dialing the notification websocket endpoint.",
|
||||
Value: serpent.DurationOf(&dialTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "no-cleanup",
|
||||
Env: "CODER_SCALETEST_NO_CLEANUP",
|
||||
Description: "Do not clean up resources after the test completes.",
|
||||
Value: serpent.BoolOf(&noCleanup),
|
||||
},
|
||||
}
|
||||
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
type runnableTraceWrapper struct {
|
||||
tracer trace.Tracer
|
||||
spanName string
|
||||
@@ -2183,8 +1958,9 @@ type runnableTraceWrapper struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ harness.Runnable = &runnableTraceWrapper{}
|
||||
_ harness.Cleanable = &runnableTraceWrapper{}
|
||||
_ harness.Runnable = &runnableTraceWrapper{}
|
||||
_ harness.Cleanable = &runnableTraceWrapper{}
|
||||
_ harness.Collectable = &runnableTraceWrapper{}
|
||||
)
|
||||
|
||||
func (r *runnableTraceWrapper) Run(ctx context.Context, id string, logs io.Writer) error {
|
||||
@@ -2226,6 +2002,14 @@ func (r *runnableTraceWrapper) Cleanup(ctx context.Context, id string, logs io.W
|
||||
return c.Cleanup(ctx, id, logs)
|
||||
}
|
||||
|
||||
func (r *runnableTraceWrapper) GetMetrics() map[string]any {
|
||||
c, ok := r.runner.(harness.Collectable)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return c.GetMetrics()
|
||||
}
|
||||
|
||||
func getScaletestWorkspaces(ctx context.Context, client *codersdk.Client, owner, template string) ([]codersdk.Workspace, int, error) {
|
||||
var (
|
||||
pageNumber = 0
|
||||
@@ -2374,73 +2158,6 @@ func parseTargetRange(name, targets string) (start, end int, err error) {
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// triggerUserNotifications waits for all test users to connect,
|
||||
// then creates and deletes a test user to trigger notification events for testing.
|
||||
func triggerUserNotifications(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
client *codersdk.Client,
|
||||
orgID uuid.UUID,
|
||||
dialBarrier *sync.WaitGroup,
|
||||
dialTimeout time.Duration,
|
||||
expectedNotifications map[uuid.UUID]chan time.Time,
|
||||
) {
|
||||
logger.Info(ctx, "waiting for all users to connect")
|
||||
|
||||
// Wait for all users to connect
|
||||
waitCtx, cancel := context.WithTimeout(ctx, dialTimeout+30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
dialBarrier.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
logger.Info(ctx, "all users connected")
|
||||
case <-waitCtx.Done():
|
||||
if waitCtx.Err() == context.DeadlineExceeded {
|
||||
logger.Error(ctx, "timeout waiting for users to connect")
|
||||
} else {
|
||||
logger.Info(ctx, "context canceled while waiting for users")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const (
|
||||
triggerUsername = "scaletest-trigger-user"
|
||||
triggerEmail = "scaletest-trigger@example.com"
|
||||
)
|
||||
|
||||
logger.Info(ctx, "creating test user to test notifications",
|
||||
slog.F("username", triggerUsername),
|
||||
slog.F("email", triggerEmail),
|
||||
slog.F("org_id", orgID))
|
||||
|
||||
testUser, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
||||
OrganizationIDs: []uuid.UUID{orgID},
|
||||
Username: triggerUsername,
|
||||
Email: triggerEmail,
|
||||
Password: "test-password-123",
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "create test user", slog.Error(err))
|
||||
return
|
||||
}
|
||||
expectedNotifications[notificationsLib.TemplateUserAccountCreated] <- time.Now()
|
||||
|
||||
err = client.DeleteUser(ctx, testUser.ID)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "delete test user", slog.Error(err))
|
||||
return
|
||||
}
|
||||
expectedNotifications[notificationsLib.TemplateUserAccountDeleted] <- time.Now()
|
||||
close(expectedNotifications[notificationsLib.TemplateUserAccountCreated])
|
||||
close(expectedNotifications[notificationsLib.TemplateUserAccountDeleted])
|
||||
}
|
||||
|
||||
func createWorkspaceAppConfig(client *codersdk.Client, appHost, app string, workspace codersdk.Workspace, agent codersdk.WorkspaceAgent) (workspacetraffic.AppConfig, error) {
|
||||
if app == "" {
|
||||
return workspacetraffic.AppConfig{}, nil
|
||||
|
||||
@@ -4,16 +4,20 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/serpent"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/scaletest/dynamicparameters"
|
||||
"github.com/coder/coder/v2/scaletest/harness"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -21,8 +25,15 @@ const (
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestDynamicParameters() *serpent.Command {
|
||||
var templateName string
|
||||
var numEvals int64
|
||||
var (
|
||||
templateName string
|
||||
provisionerTags []string
|
||||
numEvals int64
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
// This test requires unlimited concurrency
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
)
|
||||
orgContext := NewOrganizationContext()
|
||||
output := &scaletestOutputFlags{}
|
||||
|
||||
@@ -46,20 +57,63 @@ func (r *RootCmd) scaletestDynamicParameters() *serpent.Command {
|
||||
return xerrors.Errorf("template cannot be empty")
|
||||
}
|
||||
|
||||
tags, err := ParseProvisionerTags(provisionerTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
org, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := dynamicparameters.NewMetrics(reg, "concurrent_evaluations")
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelDebug)
|
||||
partitions, err := dynamicparameters.SetupPartitions(ctx, client, org.ID, templateName, numEvals, logger)
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Allow time for traces to flush even if command context is
|
||||
// canceled. This is a no-op if tracing is not enabled.
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
// Wait for prometheus metrics to be scraped
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
partitions, err := dynamicparameters.SetupPartitions(ctx, client, org.ID, templateName, tags, numEvals, logger)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("setup dynamic parameters partitions: %w", err)
|
||||
}
|
||||
|
||||
th := harness.NewTestHarness(harness.ConcurrentExecutionStrategy{}, harness.ConcurrentExecutionStrategy{})
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := dynamicparameters.NewMetrics(reg, "concurrent_evaluations")
|
||||
th := harness.NewTestHarness(
|
||||
timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}),
|
||||
// there is no cleanup since it's just a connection that we sever.
|
||||
nil)
|
||||
|
||||
for i, part := range partitions {
|
||||
for j := range part.ConcurrentEvaluations {
|
||||
@@ -68,12 +122,21 @@ func (r *RootCmd) scaletestDynamicParameters() *serpent.Command {
|
||||
Metrics: metrics,
|
||||
MetricLabelValues: []string{fmt.Sprintf("%d", part.ConcurrentEvaluations)},
|
||||
}
|
||||
runner := dynamicparameters.NewRunner(client, cfg)
|
||||
var runner harness.Runnable = dynamicparameters.NewRunner(client, cfg)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: fmt.Sprintf("%s/%d/%d", dynamicParametersTestName, i, j),
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
th.AddRun(dynamicParametersTestName, fmt.Sprintf("%d/%d", j, i), runner)
|
||||
}
|
||||
}
|
||||
|
||||
err = th.Run(ctx)
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
err = th.Run(testCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness: %w", err)
|
||||
}
|
||||
@@ -103,8 +166,16 @@ func (r *RootCmd) scaletestDynamicParameters() *serpent.Command {
|
||||
Default: "100",
|
||||
Value: serpent.Int64Of(&numEvals),
|
||||
},
|
||||
{
|
||||
Flag: "provisioner-tag",
|
||||
Description: "Specify a set of tags to target provisioner daemons.",
|
||||
Value: serpent.StringArrayOf(&provisionerTags),
|
||||
},
|
||||
}
|
||||
orgContext.AttachOptions(cmd)
|
||||
output.attach(&cmd.Options)
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
notificationsLib "github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/scaletest/createusers"
|
||||
"github.com/coder/coder/v2/scaletest/harness"
|
||||
"github.com/coder/coder/v2/scaletest/notifications"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestNotifications() *serpent.Command {
|
||||
var (
|
||||
userCount int64
|
||||
templateAdminPercentage float64
|
||||
notificationTimeout time.Duration
|
||||
smtpRequestTimeout time.Duration
|
||||
dialTimeout time.Duration
|
||||
noCleanup bool
|
||||
smtpAPIURL string
|
||||
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
|
||||
// This test requires unlimited concurrency.
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "notifications",
|
||||
Short: "Simulate notification delivery by creating many users listening to notifications.",
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
|
||||
defer stop()
|
||||
ctx = notifyCtx
|
||||
|
||||
me, err := requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if userCount <= 0 {
|
||||
return xerrors.Errorf("--user-count must be greater than 0")
|
||||
}
|
||||
|
||||
if templateAdminPercentage < 0 || templateAdminPercentage > 100 {
|
||||
return xerrors.Errorf("--template-admin-percentage must be between 0 and 100")
|
||||
}
|
||||
|
||||
if smtpAPIURL != "" && !strings.HasPrefix(smtpAPIURL, "http://") && !strings.HasPrefix(smtpAPIURL, "https://") {
|
||||
return xerrors.Errorf("--smtp-api-url must start with http:// or https://")
|
||||
}
|
||||
|
||||
templateAdminCount := int64(float64(userCount) * templateAdminPercentage / 100)
|
||||
if templateAdminCount == 0 && templateAdminPercentage > 0 {
|
||||
templateAdminCount = 1
|
||||
}
|
||||
regularUserCount := userCount - templateAdminCount
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Distribution plan:\n")
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Total users: %d\n", userCount)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Template admins: %d (%.1f%%)\n", templateAdminCount, templateAdminPercentage)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Regular users: %d (%.1f%%)\n", regularUserCount, 100.0-templateAdminPercentage)
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags")
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := notifications.NewMetrics(reg)
|
||||
|
||||
logger := inv.Logger
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
defer func() {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
// Wait for prometheus metrics to be scraped
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Creating users...")
|
||||
|
||||
dialBarrier := &sync.WaitGroup{}
|
||||
templateAdminWatchBarrier := &sync.WaitGroup{}
|
||||
dialBarrier.Add(int(userCount))
|
||||
templateAdminWatchBarrier.Add(int(templateAdminCount))
|
||||
|
||||
expectedNotificationIDs := map[uuid.UUID]struct{}{
|
||||
notificationsLib.TemplateTemplateDeleted: {},
|
||||
}
|
||||
|
||||
triggerTimes := make(map[uuid.UUID]chan time.Time, len(expectedNotificationIDs))
|
||||
for id := range expectedNotificationIDs {
|
||||
triggerTimes[id] = make(chan time.Time, 1)
|
||||
}
|
||||
|
||||
smtpHTTPTransport := &http.Transport{
|
||||
MaxConnsPerHost: 512,
|
||||
MaxIdleConnsPerHost: 512,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
}
|
||||
smtpHTTPClient := &http.Client{
|
||||
Transport: smtpHTTPTransport,
|
||||
}
|
||||
|
||||
configs := make([]notifications.Config, 0, userCount)
|
||||
for range templateAdminCount {
|
||||
config := notifications.Config{
|
||||
User: createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
},
|
||||
Roles: []string{codersdk.RoleTemplateAdmin},
|
||||
NotificationTimeout: notificationTimeout,
|
||||
DialTimeout: dialTimeout,
|
||||
DialBarrier: dialBarrier,
|
||||
ReceivingWatchBarrier: templateAdminWatchBarrier,
|
||||
ExpectedNotificationsIDs: expectedNotificationIDs,
|
||||
Metrics: metrics,
|
||||
SMTPApiURL: smtpAPIURL,
|
||||
SMTPRequestTimeout: smtpRequestTimeout,
|
||||
SMTPHttpClient: smtpHTTPClient,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
configs = append(configs, config)
|
||||
}
|
||||
for range regularUserCount {
|
||||
config := notifications.Config{
|
||||
User: createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
},
|
||||
Roles: []string{},
|
||||
NotificationTimeout: notificationTimeout,
|
||||
DialTimeout: dialTimeout,
|
||||
DialBarrier: dialBarrier,
|
||||
ReceivingWatchBarrier: templateAdminWatchBarrier,
|
||||
Metrics: metrics,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
configs = append(configs, config)
|
||||
}
|
||||
|
||||
go triggerNotifications(
|
||||
ctx,
|
||||
logger,
|
||||
client,
|
||||
me.OrganizationIDs[0],
|
||||
dialBarrier,
|
||||
dialTimeout,
|
||||
triggerTimes,
|
||||
)
|
||||
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
|
||||
for i, config := range configs {
|
||||
id := strconv.Itoa(i)
|
||||
name := fmt.Sprintf("notifications-%s", id)
|
||||
var runner harness.Runnable = notifications.NewRunner(client, config)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: name,
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
th.AddRun(name, id, runner)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Running notification delivery scaletest...")
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
err = th.Run(testCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
// If the command was interrupted, skip stats.
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
|
||||
if err := computeNotificationLatencies(ctx, logger, triggerTimes, res, metrics); err != nil {
|
||||
return xerrors.Errorf("compute notification latencies: %w", err)
|
||||
}
|
||||
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !noCleanup {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up...")
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "user-count",
|
||||
FlagShorthand: "c",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_USER_COUNT",
|
||||
Description: "Required: Total number of users to create.",
|
||||
Value: serpent.Int64Of(&userCount),
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Flag: "template-admin-percentage",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_TEMPLATE_ADMIN_PERCENTAGE",
|
||||
Default: "20.0",
|
||||
Description: "Percentage of users to assign Template Admin role to (0-100).",
|
||||
Value: serpent.Float64Of(&templateAdminPercentage),
|
||||
},
|
||||
{
|
||||
Flag: "notification-timeout",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_TIMEOUT",
|
||||
Default: "10m",
|
||||
Description: "How long to wait for notifications after triggering.",
|
||||
Value: serpent.DurationOf(¬ificationTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "smtp-request-timeout",
|
||||
Env: "CODER_SCALETEST_SMTP_REQUEST_TIMEOUT",
|
||||
Default: "5m",
|
||||
Description: "Timeout for SMTP requests.",
|
||||
Value: serpent.DurationOf(&smtpRequestTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "dial-timeout",
|
||||
Env: "CODER_SCALETEST_DIAL_TIMEOUT",
|
||||
Default: "10m",
|
||||
Description: "Timeout for dialing the notification websocket endpoint.",
|
||||
Value: serpent.DurationOf(&dialTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "no-cleanup",
|
||||
Env: "CODER_SCALETEST_NO_CLEANUP",
|
||||
Description: "Do not clean up resources after the test completes.",
|
||||
Value: serpent.BoolOf(&noCleanup),
|
||||
},
|
||||
{
|
||||
Flag: "smtp-api-url",
|
||||
Env: "CODER_SCALETEST_SMTP_API_URL",
|
||||
Description: "SMTP mock HTTP API address.",
|
||||
Value: serpent.StringOf(&smtpAPIURL),
|
||||
},
|
||||
}
|
||||
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func computeNotificationLatencies(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
expectedNotifications map[uuid.UUID]chan time.Time,
|
||||
results harness.Results,
|
||||
metrics *notifications.Metrics,
|
||||
) error {
|
||||
triggerTimes := make(map[uuid.UUID]time.Time)
|
||||
for notificationID, triggerTimeChan := range expectedNotifications {
|
||||
select {
|
||||
case triggerTime := <-triggerTimeChan:
|
||||
triggerTimes[notificationID] = triggerTime
|
||||
logger.Info(ctx, "received trigger time",
|
||||
slog.F("notification_id", notificationID),
|
||||
slog.F("trigger_time", triggerTime))
|
||||
default:
|
||||
logger.Warn(ctx, "no trigger time received for notification",
|
||||
slog.F("notification_id", notificationID))
|
||||
}
|
||||
}
|
||||
|
||||
if len(triggerTimes) == 0 {
|
||||
logger.Warn(ctx, "no trigger times available, skipping latency computation")
|
||||
return nil
|
||||
}
|
||||
|
||||
var totalLatencies int
|
||||
for runID, runResult := range results.Runs {
|
||||
if runResult.Error != nil {
|
||||
logger.Debug(ctx, "skipping failed run for latency computation",
|
||||
slog.F("run_id", runID))
|
||||
continue
|
||||
}
|
||||
|
||||
if runResult.Metrics == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Process websocket notifications.
|
||||
if wsReceiptTimes, ok := runResult.Metrics[notifications.WebsocketNotificationReceiptTimeMetric].(map[uuid.UUID]time.Time); ok {
|
||||
for notificationID, receiptTime := range wsReceiptTimes {
|
||||
if triggerTime, ok := triggerTimes[notificationID]; ok {
|
||||
latency := receiptTime.Sub(triggerTime)
|
||||
metrics.RecordLatency(latency, notificationID.String(), notifications.NotificationTypeWebsocket)
|
||||
totalLatencies++
|
||||
logger.Debug(ctx, "computed websocket latency",
|
||||
slog.F("run_id", runID),
|
||||
slog.F("notification_id", notificationID),
|
||||
slog.F("latency", latency))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process SMTP notifications
|
||||
if smtpReceiptTimes, ok := runResult.Metrics[notifications.SMTPNotificationReceiptTimeMetric].(map[uuid.UUID]time.Time); ok {
|
||||
for notificationID, receiptTime := range smtpReceiptTimes {
|
||||
if triggerTime, ok := triggerTimes[notificationID]; ok {
|
||||
latency := receiptTime.Sub(triggerTime)
|
||||
metrics.RecordLatency(latency, notificationID.String(), notifications.NotificationTypeSMTP)
|
||||
totalLatencies++
|
||||
logger.Debug(ctx, "computed SMTP latency",
|
||||
slog.F("run_id", runID),
|
||||
slog.F("notification_id", notificationID),
|
||||
slog.F("latency", latency))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info(ctx, "finished computing notification latencies",
|
||||
slog.F("total_runs", results.TotalRuns),
|
||||
slog.F("total_latencies_computed", totalLatencies))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// triggerNotifications waits for all test users to connect,
|
||||
// then creates and deletes a test template to trigger notification events for testing.
|
||||
func triggerNotifications(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
client *codersdk.Client,
|
||||
orgID uuid.UUID,
|
||||
dialBarrier *sync.WaitGroup,
|
||||
dialTimeout time.Duration,
|
||||
expectedNotifications map[uuid.UUID]chan time.Time,
|
||||
) {
|
||||
logger.Info(ctx, "waiting for all users to connect")
|
||||
|
||||
// Wait for all users to connect
|
||||
waitCtx, cancel := context.WithTimeout(ctx, dialTimeout+30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
dialBarrier.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
logger.Info(ctx, "all users connected")
|
||||
case <-waitCtx.Done():
|
||||
if waitCtx.Err() == context.DeadlineExceeded {
|
||||
logger.Error(ctx, "timeout waiting for users to connect")
|
||||
} else {
|
||||
logger.Info(ctx, "context canceled while waiting for users")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info(ctx, "creating test template to test notifications")
|
||||
|
||||
// Upload empty template file.
|
||||
file, err := client.Upload(ctx, codersdk.ContentTypeTar, bytes.NewReader([]byte{}))
|
||||
if err != nil {
|
||||
logger.Error(ctx, "upload test template", slog.Error(err))
|
||||
return
|
||||
}
|
||||
logger.Info(ctx, "test template uploaded", slog.F("file_id", file.ID))
|
||||
|
||||
// Create template version.
|
||||
version, err := client.CreateTemplateVersion(ctx, orgID, codersdk.CreateTemplateVersionRequest{
|
||||
StorageMethod: codersdk.ProvisionerStorageMethodFile,
|
||||
FileID: file.ID,
|
||||
Provisioner: codersdk.ProvisionerTypeEcho,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "create test template version", slog.Error(err))
|
||||
return
|
||||
}
|
||||
logger.Info(ctx, "test template version created", slog.F("template_version_id", version.ID))
|
||||
|
||||
// Create template.
|
||||
testTemplate, err := client.CreateTemplate(ctx, orgID, codersdk.CreateTemplateRequest{
|
||||
Name: "scaletest-test-template",
|
||||
Description: "scaletest-test-template",
|
||||
VersionID: version.ID,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "create test template", slog.Error(err))
|
||||
return
|
||||
}
|
||||
logger.Info(ctx, "test template created", slog.F("template_id", testTemplate.ID))
|
||||
|
||||
// Delete template to trigger notification.
|
||||
err = client.DeleteTemplate(ctx, testTemplate.ID)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "delete test template", slog.Error(err))
|
||||
return
|
||||
}
|
||||
logger.Info(ctx, "test template deleted", slog.F("template_id", testTemplate.ID))
|
||||
|
||||
// Record expected notification.
|
||||
expectedNotifications[notificationsLib.TemplateTemplateDeleted] <- time.Now()
|
||||
close(expectedNotifications[notificationsLib.TemplateTemplateDeleted])
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/scaletest/harness"
|
||||
"github.com/coder/coder/v2/scaletest/prebuilds"
|
||||
"github.com/coder/quartz"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestPrebuilds() *serpent.Command {
|
||||
var (
|
||||
numTemplates int64
|
||||
numPresets int64
|
||||
numPresetPrebuilds int64
|
||||
templateVersionJobTimeout time.Duration
|
||||
prebuildWorkspaceTimeout time.Duration
|
||||
noCleanup bool
|
||||
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "prebuilds",
|
||||
Short: "Creates prebuild workspaces on the Coder server.",
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
|
||||
defer stop()
|
||||
ctx = notifyCtx
|
||||
|
||||
me, err := requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if numTemplates <= 0 {
|
||||
return xerrors.Errorf("--num-templates must be greater than 0")
|
||||
}
|
||||
if numPresets <= 0 {
|
||||
return xerrors.Errorf("--num-presets must be greater than 0")
|
||||
}
|
||||
if numPresetPrebuilds <= 0 {
|
||||
return xerrors.Errorf("--num-preset-prebuilds must be greater than 0")
|
||||
}
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse output flags: %w", err)
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := prebuilds.NewMetrics(reg)
|
||||
|
||||
logger := inv.Logger
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
err = client.PutPrebuildsSettings(ctx, codersdk.PrebuildsSettings{
|
||||
ReconciliationPaused: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("pause prebuilds: %w", err)
|
||||
}
|
||||
|
||||
setupBarrier := new(sync.WaitGroup)
|
||||
setupBarrier.Add(int(numTemplates))
|
||||
creationBarrier := new(sync.WaitGroup)
|
||||
creationBarrier.Add(int(numTemplates))
|
||||
deletionSetupBarrier := new(sync.WaitGroup)
|
||||
deletionSetupBarrier.Add(1)
|
||||
deletionBarrier := new(sync.WaitGroup)
|
||||
deletionBarrier.Add(int(numTemplates))
|
||||
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
|
||||
for i := range numTemplates {
|
||||
id := strconv.Itoa(int(i))
|
||||
cfg := prebuilds.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
NumPresets: int(numPresets),
|
||||
NumPresetPrebuilds: int(numPresetPrebuilds),
|
||||
TemplateVersionJobTimeout: templateVersionJobTimeout,
|
||||
PrebuildWorkspaceTimeout: prebuildWorkspaceTimeout,
|
||||
Metrics: metrics,
|
||||
SetupBarrier: setupBarrier,
|
||||
CreationBarrier: creationBarrier,
|
||||
DeletionSetupBarrier: deletionSetupBarrier,
|
||||
DeletionBarrier: deletionBarrier,
|
||||
Clock: quartz.NewReal(),
|
||||
}
|
||||
err := cfg.Validate()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
|
||||
var runner harness.Runnable = prebuilds.NewRunner(client, cfg)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: fmt.Sprintf("prebuilds/%s", id),
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
th.AddRun("prebuilds", id, runner)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Creating %d templates with %d presets and %d prebuilds per preset...\n",
|
||||
numTemplates, numPresets, numPresetPrebuilds)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Total expected prebuilds: %d\n", numTemplates*numPresets*numPresetPrebuilds)
|
||||
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
|
||||
runErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
runErrCh <- th.Run(testCtx)
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Waiting for all templates to be created...")
|
||||
setupBarrier.Wait()
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "All templates created")
|
||||
|
||||
err = client.PutPrebuildsSettings(ctx, codersdk.PrebuildsSettings{
|
||||
ReconciliationPaused: false,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resume prebuilds: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Waiting for all prebuilds to be created...")
|
||||
creationBarrier.Wait()
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "All prebuilds created")
|
||||
|
||||
err = client.PutPrebuildsSettings(ctx, codersdk.PrebuildsSettings{
|
||||
ReconciliationPaused: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("pause prebuilds before deletion: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Prebuilds paused, signaling runners to prepare for deletion")
|
||||
deletionSetupBarrier.Done()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Waiting for all templates to be updated with 0 prebuilds...")
|
||||
deletionBarrier.Wait()
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "All templates updated")
|
||||
|
||||
err = client.PutPrebuildsSettings(ctx, codersdk.PrebuildsSettings{
|
||||
ReconciliationPaused: false,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resume prebuilds for deletion: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Waiting for all prebuilds to be deleted...")
|
||||
err = <-runErrCh
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
// If the command was interrupted, skip cleanup & stats
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !noCleanup {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nStarting cleanup (deleting templates)...")
|
||||
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
|
||||
// If the cleanup was interrupted, skip stats
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("prebuild creation test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "num-templates",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_NUM_TEMPLATES",
|
||||
Default: "1",
|
||||
Description: "Number of templates to create for the test.",
|
||||
Value: serpent.Int64Of(&numTemplates),
|
||||
},
|
||||
{
|
||||
Flag: "num-presets",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_NUM_PRESETS",
|
||||
Default: "1",
|
||||
Description: "Number of presets per template.",
|
||||
Value: serpent.Int64Of(&numPresets),
|
||||
},
|
||||
{
|
||||
Flag: "num-preset-prebuilds",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_NUM_PRESET_PREBUILDS",
|
||||
Default: "1",
|
||||
Description: "Number of prebuilds per preset.",
|
||||
Value: serpent.Int64Of(&numPresetPrebuilds),
|
||||
},
|
||||
{
|
||||
Flag: "template-version-job-timeout",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_TEMPLATE_VERSION_JOB_TIMEOUT",
|
||||
Default: "5m",
|
||||
Description: "Timeout for template version provisioning jobs.",
|
||||
Value: serpent.DurationOf(&templateVersionJobTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "prebuild-workspace-timeout",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_WORKSPACE_TIMEOUT",
|
||||
Default: "10m",
|
||||
Description: "Timeout for all prebuild workspaces to be created/deleted.",
|
||||
Value: serpent.DurationOf(&prebuildWorkspaceTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "skip-cleanup",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_SKIP_CLEANUP",
|
||||
Description: "Skip cleanup (deletion test) and leave resources intact.",
|
||||
Value: serpent.BoolOf(&noCleanup),
|
||||
},
|
||||
}
|
||||
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/v2/scaletest/smtpmock"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (*RootCmd) scaletestSMTP() *serpent.Command {
|
||||
var (
|
||||
hostAddress string
|
||||
smtpPort int64
|
||||
apiPort int64
|
||||
purgeAtCount int64
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Use: "smtp",
|
||||
Short: "Start a mock SMTP server for testing",
|
||||
Long: `Start a mock SMTP server with an HTTP API server that can be used to purge
|
||||
messages and get messages by email.`,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
|
||||
defer stop()
|
||||
ctx = notifyCtx
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelInfo)
|
||||
config := smtpmock.Config{
|
||||
HostAddress: hostAddress,
|
||||
SMTPPort: int(smtpPort),
|
||||
APIPort: int(apiPort),
|
||||
Logger: logger,
|
||||
}
|
||||
srv := new(smtpmock.Server)
|
||||
|
||||
if err := srv.Start(ctx, config); err != nil {
|
||||
return xerrors.Errorf("start mock SMTP server: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = srv.Stop()
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Mock SMTP server started on %s\n", srv.SMTPAddress())
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "HTTP API server started on %s\n", srv.APIAddress())
|
||||
if purgeAtCount > 0 {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, " Auto-purge when message count reaches %d\n", purgeAtCount)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "\nTotal messages received since last purge: %d\n", srv.MessageCount())
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
count := srv.MessageCount()
|
||||
if count > 0 {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Messages received: %d\n", count)
|
||||
}
|
||||
|
||||
if purgeAtCount > 0 && int64(count) >= purgeAtCount {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Message count (%d) reached threshold (%d). Purging...\n", count, purgeAtCount)
|
||||
srv.Purge()
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = []serpent.Option{
|
||||
{
|
||||
Flag: "host-address",
|
||||
Env: "CODER_SCALETEST_SMTP_HOST_ADDRESS",
|
||||
Default: "localhost",
|
||||
Description: "Host address to bind the mock SMTP and API servers.",
|
||||
Value: serpent.StringOf(&hostAddress),
|
||||
},
|
||||
{
|
||||
Flag: "smtp-port",
|
||||
Env: "CODER_SCALETEST_SMTP_PORT",
|
||||
Description: "Port for the mock SMTP server. Uses a random port if not specified.",
|
||||
Value: serpent.Int64Of(&smtpPort),
|
||||
},
|
||||
{
|
||||
Flag: "api-port",
|
||||
Env: "CODER_SCALETEST_SMTP_API_PORT",
|
||||
Description: "Port for the HTTP API server. Uses a random port if not specified.",
|
||||
Value: serpent.Int64Of(&apiPort),
|
||||
},
|
||||
{
|
||||
Flag: "purge-at-count",
|
||||
Env: "CODER_SCALETEST_SMTP_PURGE_AT_COUNT",
|
||||
Default: "100000",
|
||||
Description: "Maximum number of messages to keep before auto-purging. Set to 0 to disable.",
|
||||
Value: serpent.Int64Of(&purgeAtCount),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/serpent"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/scaletest/harness"
|
||||
"github.com/coder/coder/v2/scaletest/taskstatus"
|
||||
)
|
||||
|
||||
const (
|
||||
taskStatusTestName = "task-status"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestTaskStatus() *serpent.Command {
|
||||
var (
|
||||
count int64
|
||||
template string
|
||||
workspaceNamePrefix string
|
||||
appSlug string
|
||||
reportStatusPeriod time.Duration
|
||||
reportStatusDuration time.Duration
|
||||
baselineDuration time.Duration
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
)
|
||||
orgContext := NewOrganizationContext()
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "task-status",
|
||||
Short: "Generates load on the Coder server by simulating task status reporting",
|
||||
Long: `This test creates external workspaces and simulates AI agents reporting task status.
|
||||
After all runners connect, it waits for the baseline duration before triggering status reporting.`,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags: %w", err)
|
||||
}
|
||||
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
org, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Disable rate limits for this test
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Find the template
|
||||
tpl, err := parseTemplate(ctx, client, []uuid.UUID{org.ID}, template)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse template %q: %w", template, err)
|
||||
}
|
||||
templateID := tpl.ID
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := taskstatus.NewMetrics(reg)
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelDebug)
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Allow time for traces to flush even if command context is
|
||||
// canceled. This is a no-op if tracing is not enabled.
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
// Wait for prometheus metrics to be scraped
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
// Setup shared resources for coordination
|
||||
connectedWaitGroup := &sync.WaitGroup{}
|
||||
connectedWaitGroup.Add(int(count))
|
||||
startReporting := make(chan struct{})
|
||||
|
||||
// Create the test harness
|
||||
th := harness.NewTestHarness(
|
||||
timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}),
|
||||
cleanupStrategy.toStrategy(),
|
||||
)
|
||||
|
||||
// Create runners
|
||||
for i := range count {
|
||||
workspaceName := fmt.Sprintf("%s-%d", workspaceNamePrefix, i)
|
||||
cfg := taskstatus.Config{
|
||||
TemplateID: templateID,
|
||||
WorkspaceName: workspaceName,
|
||||
AppSlug: appSlug,
|
||||
ConnectedWaitGroup: connectedWaitGroup,
|
||||
StartReporting: startReporting,
|
||||
ReportStatusPeriod: reportStatusPeriod,
|
||||
ReportStatusDuration: reportStatusDuration,
|
||||
Metrics: metrics,
|
||||
MetricLabelValues: []string{},
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config for runner %d: %w", i, err)
|
||||
}
|
||||
|
||||
var runner harness.Runnable = taskstatus.NewRunner(client, cfg)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: fmt.Sprintf("%s/%d", taskStatusTestName, i),
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
th.AddRun(taskStatusTestName, workspaceName, runner)
|
||||
}
|
||||
|
||||
// Start the test in a separate goroutine so we can coordinate timing
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
testDone := make(chan error)
|
||||
go func() {
|
||||
testDone <- th.Run(testCtx)
|
||||
}()
|
||||
|
||||
// Wait for all runners to connect
|
||||
logger.Info(ctx, "waiting for all runners to connect")
|
||||
waitCtx, waitCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer waitCancel()
|
||||
|
||||
connectDone := make(chan struct{})
|
||||
go func() {
|
||||
connectedWaitGroup.Wait()
|
||||
close(connectDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
return xerrors.Errorf("timeout waiting for runners to connect")
|
||||
case <-connectDone:
|
||||
logger.Info(ctx, "all runners connected")
|
||||
}
|
||||
|
||||
// Wait for baseline duration
|
||||
logger.Info(ctx, "waiting for baseline duration", slog.F("duration", baselineDuration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(baselineDuration):
|
||||
}
|
||||
|
||||
// Trigger all runners to start reporting
|
||||
logger.Info(ctx, "triggering runners to start reporting task status")
|
||||
close(startReporting)
|
||||
|
||||
// Wait for the test to complete
|
||||
err = <-testDone
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness: %w", err)
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "count",
|
||||
Description: "Number of concurrent runners to create.",
|
||||
Default: "10",
|
||||
Value: serpent.Int64Of(&count),
|
||||
},
|
||||
{
|
||||
Flag: "template",
|
||||
Description: "Name or UUID of the template to use for the scale test. The template MUST include a coder_external_agent and a coder_app.",
|
||||
Default: "scaletest-task-status",
|
||||
Value: serpent.StringOf(&template),
|
||||
},
|
||||
{
|
||||
Flag: "workspace-name-prefix",
|
||||
Description: "Prefix for workspace names (will be suffixed with index).",
|
||||
Default: "scaletest-task-status",
|
||||
Value: serpent.StringOf(&workspaceNamePrefix),
|
||||
},
|
||||
{
|
||||
Flag: "app-slug",
|
||||
Description: "Slug of the app designated as the AI Agent.",
|
||||
Default: "ai-agent",
|
||||
Value: serpent.StringOf(&appSlug),
|
||||
},
|
||||
{
|
||||
Flag: "report-status-period",
|
||||
Description: "Time between reporting task statuses.",
|
||||
Default: "10s",
|
||||
Value: serpent.DurationOf(&reportStatusPeriod),
|
||||
},
|
||||
{
|
||||
Flag: "report-status-duration",
|
||||
Description: "Total time to report task statuses after baseline.",
|
||||
Default: "15m",
|
||||
Value: serpent.DurationOf(&reportStatusDuration),
|
||||
},
|
||||
{
|
||||
Flag: "baseline-duration",
|
||||
Description: "Duration to wait after all runners connect before starting to report status.",
|
||||
Default: "10m",
|
||||
Value: serpent.DurationOf(&baselineDuration),
|
||||
},
|
||||
}
|
||||
orgContext.AttachOptions(cmd)
|
||||
output.attach(&cmd.Options)
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/pretty"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) taskDelete() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "delete <task> [<task> ...]",
|
||||
Short: "Delete experimental tasks",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Delete a single task.",
|
||||
Command: "$ coder exp task delete task1",
|
||||
},
|
||||
Example{
|
||||
Description: "Delete multiple tasks.",
|
||||
Command: "$ coder exp task delete task1 task2 task3",
|
||||
},
|
||||
Example{
|
||||
Description: "Delete a task without confirmation.",
|
||||
Command: "$ coder exp task delete task4 --yes",
|
||||
},
|
||||
),
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireRangeArgs(1, -1),
|
||||
),
|
||||
Options: serpent.OptionSet{
|
||||
cliui.SkipPromptOption(),
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
|
||||
type toDelete struct {
|
||||
ID uuid.UUID
|
||||
Owner string
|
||||
Display string
|
||||
}
|
||||
|
||||
var items []toDelete
|
||||
for _, identifier := range inv.Args {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if identifier == "" {
|
||||
return xerrors.New("task identifier cannot be empty or whitespace")
|
||||
}
|
||||
|
||||
// Check task identifier, try UUID first.
|
||||
if id, err := uuid.Parse(identifier); err == nil {
|
||||
task, err := exp.TaskByID(ctx, id)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task %q: %w", identifier, err)
|
||||
}
|
||||
display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
|
||||
items = append(items, toDelete{ID: id, Display: display, Owner: task.OwnerName})
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-UUID, treat as a workspace identifier (name or owner/name).
|
||||
ws, err := namedWorkspace(ctx, client, identifier)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task %q: %w", identifier, err)
|
||||
}
|
||||
display := ws.FullName()
|
||||
items = append(items, toDelete{ID: ws.ID, Display: display, Owner: ws.OwnerName})
|
||||
}
|
||||
|
||||
// Confirm deletion of the tasks.
|
||||
var displayList []string
|
||||
for _, it := range items {
|
||||
displayList = append(displayList, it.Display)
|
||||
}
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Delete these tasks: %s?", pretty.Sprint(cliui.DefaultStyles.Code, strings.Join(displayList, ", "))),
|
||||
IsConfirm: true,
|
||||
Default: cliui.ConfirmNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if err := exp.DeleteTask(ctx, item.Owner, item.ID); err != nil {
|
||||
return xerrors.Errorf("delete task %q: %w", item.Display, err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(
|
||||
inv.Stdout, "Deleted task "+pretty.Sprint(cliui.DefaultStyles.Keyword, item.Display)+" at "+cliui.Timestamp(time.Now()),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func Test_TaskLogs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
clock = time.Date(2025, 8, 26, 12, 34, 56, 0, time.UTC)
|
||||
|
||||
taskID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
taskName = "task-workspace"
|
||||
|
||||
taskLogs = []codersdk.TaskLogEntry{
|
||||
{
|
||||
ID: 0,
|
||||
Content: "What is 1 + 1?",
|
||||
Type: codersdk.TaskLogTypeInput,
|
||||
Time: clock,
|
||||
},
|
||||
{
|
||||
ID: 1,
|
||||
Content: "2",
|
||||
Type: codersdk.TaskLogTypeOutput,
|
||||
Time: clock.Add(1 * time.Second),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
args []string
|
||||
expectTable string
|
||||
expectLogs []codersdk.TaskLogEntry
|
||||
expectError string
|
||||
handler func(t *testing.T, ctx context.Context) http.HandlerFunc
|
||||
}{
|
||||
{
|
||||
args: []string{taskName, "--output", "json"},
|
||||
expectLogs: taskLogs,
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/v2/users/me/workspace/%s", taskName):
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: taskID,
|
||||
})
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", taskID.String()):
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.TaskLogsResponse{
|
||||
Logs: taskLogs,
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{taskID.String(), "--output", "json"},
|
||||
expectLogs: taskLogs,
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", taskID.String()):
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.TaskLogsResponse{
|
||||
Logs: taskLogs,
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{taskID.String()},
|
||||
expectTable: `
|
||||
TYPE CONTENT
|
||||
input What is 1 + 1?
|
||||
output 2`,
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", taskID.String()):
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.TaskLogsResponse{
|
||||
Logs: taskLogs,
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{"doesnotexist"},
|
||||
expectError: httpapi.ResourceNotFoundResponse.Message,
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users/me/workspace/doesnotexist":
|
||||
httpapi.ResourceNotFound(w)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{uuid.Nil.String()}, // uuid does not exist
|
||||
expectError: httpapi.ResourceNotFoundResponse.Message,
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", uuid.Nil.String()):
|
||||
httpapi.ResourceNotFound(w)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{"err-fetching-logs"},
|
||||
expectError: assert.AnError.Error(),
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users/me/workspace/err-fetching-logs":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: taskID,
|
||||
})
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", taskID.String()):
|
||||
httpapi.InternalServerError(w, assert.AnError)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(strings.Join(tt.args, ","), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
srv = httptest.NewServer(tt.handler(t, ctx))
|
||||
client = codersdk.New(testutil.MustURL(t, srv.URL))
|
||||
args = []string{"exp", "task", "logs"}
|
||||
stdout strings.Builder
|
||||
err error
|
||||
)
|
||||
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
inv, root := clitest.New(t, append(args, tt.args...)...)
|
||||
inv.Stdout = &stdout
|
||||
inv.Stderr = &stdout
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
err = inv.WithContext(ctx).Run()
|
||||
if tt.expectError == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.ErrorContains(t, err, tt.expectError)
|
||||
}
|
||||
|
||||
if tt.expectTable != "" {
|
||||
if diff := tableDiff(tt.expectTable, stdout.String()); diff != "" {
|
||||
t.Errorf("unexpected output diff (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
if tt.expectLogs != nil {
|
||||
var logs []codersdk.TaskLogEntry
|
||||
err = json.NewDecoder(strings.NewReader(stdout.String())).Decode(&logs)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expectLogs, logs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func Test_TaskSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
taskName = "task-workspace"
|
||||
taskID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
args []string
|
||||
stdin string
|
||||
expectError string
|
||||
handler func(t *testing.T, ctx context.Context) http.HandlerFunc
|
||||
}{
|
||||
{
|
||||
args: []string{taskName, "carry on with the task"},
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/v2/users/me/workspace/%s", taskName):
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: taskID,
|
||||
})
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/send", taskID.String()):
|
||||
var req codersdk.TaskSendRequest
|
||||
if !httpapi.Read(ctx, w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "carry on with the task", req.Input)
|
||||
|
||||
httpapi.Write(ctx, w, http.StatusNoContent, nil)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{taskID.String(), "carry on with the task"},
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/send", taskID.String()):
|
||||
var req codersdk.TaskSendRequest
|
||||
if !httpapi.Read(ctx, w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "carry on with the task", req.Input)
|
||||
|
||||
httpapi.Write(ctx, w, http.StatusNoContent, nil)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{taskName, "--stdin"},
|
||||
stdin: "carry on with the task",
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/v2/users/me/workspace/%s", taskName):
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: taskID,
|
||||
})
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/send", taskID.String()):
|
||||
var req codersdk.TaskSendRequest
|
||||
if !httpapi.Read(ctx, w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "carry on with the task", req.Input)
|
||||
|
||||
httpapi.Write(ctx, w, http.StatusNoContent, nil)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{"doesnotexist", "some task input"},
|
||||
expectError: httpapi.ResourceNotFoundResponse.Message,
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users/me/workspace/doesnotexist":
|
||||
httpapi.ResourceNotFound(w)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{uuid.Nil.String(), "some task input"},
|
||||
expectError: httpapi.ResourceNotFoundResponse.Message,
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/send", uuid.Nil.String()):
|
||||
httpapi.ResourceNotFound(w)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{uuid.Nil.String(), "some task input"},
|
||||
expectError: assert.AnError.Error(),
|
||||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case fmt.Sprintf("/api/experimental/tasks/me/%s/send", uuid.Nil.String()):
|
||||
httpapi.InternalServerError(w, assert.AnError)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(strings.Join(tt.args, ","), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
srv = httptest.NewServer(tt.handler(t, ctx))
|
||||
client = codersdk.New(testutil.MustURL(t, srv.URL))
|
||||
args = []string{"exp", "task", "send"}
|
||||
err error
|
||||
)
|
||||
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
inv, root := clitest.New(t, append(args, tt.args...)...)
|
||||
inv.Stdin = strings.NewReader(tt.stdin)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
err = inv.WithContext(ctx).Run()
|
||||
if tt.expectError == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.ErrorContains(t, err, tt.expectError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/cli/config"
|
||||
"github.com/coder/coder/v2/cli/sessionstore"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
// keyringTestServiceName generates a unique service name for keyring tests
|
||||
// using the test name and a nanosecond timestamp to prevent collisions.
|
||||
func keyringTestServiceName(t *testing.T) string {
|
||||
t.Helper()
|
||||
var n uint32
|
||||
err := binary.Read(rand.Reader, binary.BigEndian, &n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fmt.Sprintf("%s_%v_%d", t.Name(), time.Now().UnixNano(), n)
|
||||
}
|
||||
|
||||
type keyringTestEnv struct {
|
||||
serviceName string
|
||||
keyring sessionstore.Keyring
|
||||
inv *serpent.Invocation
|
||||
cfg config.Root
|
||||
clientURL *url.URL
|
||||
}
|
||||
|
||||
func setupKeyringTestEnv(t *testing.T, clientURL string, args ...string) keyringTestEnv {
|
||||
t.Helper()
|
||||
|
||||
var root cli.RootCmd
|
||||
|
||||
cmd, err := root.Command(root.AGPL())
|
||||
require.NoError(t, err)
|
||||
|
||||
serviceName := keyringTestServiceName(t)
|
||||
root.WithKeyringServiceName(serviceName)
|
||||
root.UseKeyringWithGlobalConfig()
|
||||
|
||||
inv, cfg := clitest.NewWithDefaultKeyringCommand(t, cmd, args...)
|
||||
|
||||
parsedURL, err := url.Parse(clientURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(serviceName)
|
||||
t.Cleanup(func() {
|
||||
_ = backend.Delete(parsedURL)
|
||||
})
|
||||
|
||||
return keyringTestEnv{serviceName, backend, inv, cfg, parsedURL}
|
||||
}
|
||||
|
||||
func TestUseKeyring(t *testing.T) {
|
||||
// Verify that the --use-keyring flag default opts into using a keyring backend
|
||||
// for storing session tokens instead of plain text files.
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Login", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "windows" && runtime.GOOS != "darwin" {
|
||||
t.Skip("keyring is not supported on this OS")
|
||||
}
|
||||
|
||||
// Create a test server
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create a pty for interactive prompts
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// Create CLI invocation which defaults to using the keyring
|
||||
env := setupKeyringTestEnv(t, client.URL.String(),
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
client.URL.String())
|
||||
inv := env.inv
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
// Run login in background
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
// Provide the token when prompted
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify that session file was NOT created (using keyring instead)
|
||||
sessionFile := path.Join(string(env.cfg), "session")
|
||||
_, err := os.Stat(sessionFile)
|
||||
require.True(t, os.IsNotExist(err), "session file should not exist when using keyring")
|
||||
|
||||
// Verify that the credential IS stored in OS keyring
|
||||
cred, err := env.keyring.Read(env.clientURL)
|
||||
require.NoError(t, err, "credential should be stored in OS keyring")
|
||||
require.Equal(t, client.SessionToken(), cred, "stored token should match login token")
|
||||
})
|
||||
|
||||
t.Run("Logout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "windows" && runtime.GOOS != "darwin" {
|
||||
t.Skip("keyring is not supported on this OS")
|
||||
}
|
||||
|
||||
// Create a test server
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create a pty for interactive prompts
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// First, login with the keyring (default)
|
||||
env := setupKeyringTestEnv(t, client.URL.String(),
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
loginInv := env.inv
|
||||
loginInv.Stdin = pty.Input()
|
||||
loginInv.Stdout = pty.Output()
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := loginInv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify credential exists in OS keyring
|
||||
cred, err := env.keyring.Read(env.clientURL)
|
||||
require.NoError(t, err, "read credential should succeed before logout")
|
||||
require.NotEmpty(t, cred, "credential should exist before logout")
|
||||
|
||||
// Now logout using the same keyring service name
|
||||
var logoutRoot cli.RootCmd
|
||||
logoutCmd, err := logoutRoot.Command(logoutRoot.AGPL())
|
||||
require.NoError(t, err)
|
||||
logoutRoot.WithKeyringServiceName(env.serviceName)
|
||||
logoutRoot.UseKeyringWithGlobalConfig()
|
||||
|
||||
logoutInv, _ := clitest.NewWithDefaultKeyringCommand(t, logoutCmd,
|
||||
"logout",
|
||||
"--yes",
|
||||
"--global-config", string(env.cfg),
|
||||
)
|
||||
|
||||
var logoutOut bytes.Buffer
|
||||
logoutInv.Stdout = &logoutOut
|
||||
|
||||
err = logoutInv.Run()
|
||||
require.NoError(t, err, "logout should succeed")
|
||||
|
||||
// Verify the credential was deleted from OS keyring
|
||||
_, err = env.keyring.Read(env.clientURL)
|
||||
require.ErrorIs(t, err, os.ErrNotExist, "credential should be deleted from keyring after logout")
|
||||
})
|
||||
|
||||
t.Run("DefaultFileStorage", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("file storage is the default for Linux")
|
||||
}
|
||||
|
||||
// Create a test server
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create a pty for interactive prompts
|
||||
pty := ptytest.New(t)
|
||||
|
||||
env := setupKeyringTestEnv(t, client.URL.String(),
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
inv := env.inv
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify that session file WAS created (not using keyring)
|
||||
sessionFile := path.Join(string(env.cfg), "session")
|
||||
_, err := os.Stat(sessionFile)
|
||||
require.NoError(t, err, "session file should exist when NOT using --use-keyring on Linux")
|
||||
|
||||
// Read and verify the token from file
|
||||
content, err := os.ReadFile(sessionFile)
|
||||
require.NoError(t, err, "should be able to read session file")
|
||||
require.Equal(t, client.SessionToken(), string(content), "file should contain the session token")
|
||||
})
|
||||
|
||||
t.Run("EnvironmentVariable", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a test server
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create a pty for interactive prompts
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// Login using CODER_USE_KEYRING environment variable set to disable keyring usage,
|
||||
// which should have the same behavior on all platforms.
|
||||
env := setupKeyringTestEnv(t, client.URL.String(),
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
inv := env.inv
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Environ.Set("CODER_USE_KEYRING", "false")
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify that session file WAS created (not using keyring)
|
||||
sessionFile := path.Join(string(env.cfg), "session")
|
||||
_, err := os.Stat(sessionFile)
|
||||
require.NoError(t, err, "session file should exist when CODER_USE_KEYRING set to false")
|
||||
|
||||
// Read and verify the token from file
|
||||
content, err := os.ReadFile(sessionFile)
|
||||
require.NoError(t, err, "should be able to read session file")
|
||||
require.Equal(t, client.SessionToken(), string(content), "file should contain the session token")
|
||||
})
|
||||
|
||||
t.Run("DisableKeyringWithFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// Login with --use-keyring=false to explicitly disable keyring usage, which
|
||||
// should have the same behavior on all platforms.
|
||||
env := setupKeyringTestEnv(t, client.URL.String(),
|
||||
"login",
|
||||
"--use-keyring=false",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
inv := env.inv
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify that session file WAS created (not using keyring)
|
||||
sessionFile := path.Join(string(env.cfg), "session")
|
||||
_, err := os.Stat(sessionFile)
|
||||
require.NoError(t, err, "session file should exist when --use-keyring=false is specified")
|
||||
|
||||
// Read and verify the token from file
|
||||
content, err := os.ReadFile(sessionFile)
|
||||
require.NoError(t, err, "should be able to read session file")
|
||||
require.Equal(t, client.SessionToken(), string(content), "file should contain the session token")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUseKeyringUnsupportedOS(t *testing.T) {
|
||||
// Verify that on unsupported operating systems, file-based storage is used
|
||||
// automatically even when --use-keyring is set to true (the default).
|
||||
t.Parallel()
|
||||
|
||||
// Only run this on an unsupported OS.
|
||||
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
||||
t.Skipf("Skipping unsupported OS test on %s where keyring is supported", runtime.GOOS)
|
||||
}
|
||||
|
||||
t.Run("LoginWithDefaultKeyring", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
pty := ptytest.New(t)
|
||||
|
||||
env := setupKeyringTestEnv(t, client.URL.String(),
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
inv := env.inv
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify that session file WAS created (automatic fallback to file storage)
|
||||
sessionFile := path.Join(string(env.cfg), "session")
|
||||
_, err := os.Stat(sessionFile)
|
||||
require.NoError(t, err, "session file should exist due to automatic fallback to file storage")
|
||||
|
||||
content, err := os.ReadFile(sessionFile)
|
||||
require.NoError(t, err, "should be able to read session file")
|
||||
require.Equal(t, client.SessionToken(), string(content), "file should contain the session token")
|
||||
})
|
||||
|
||||
t.Run("LogoutWithDefaultKeyring", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// First login to create a session (will use file storage due to automatic fallback)
|
||||
env := setupKeyringTestEnv(t, client.URL.String(),
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
loginInv := env.inv
|
||||
loginInv.Stdin = pty.Input()
|
||||
loginInv.Stdout = pty.Output()
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := loginInv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify session file exists
|
||||
sessionFile := path.Join(string(env.cfg), "session")
|
||||
_, err := os.Stat(sessionFile)
|
||||
require.NoError(t, err, "session file should exist before logout")
|
||||
|
||||
// Now logout - should succeed and delete the file
|
||||
logoutEnv := setupKeyringTestEnv(t, client.URL.String(),
|
||||
"logout",
|
||||
"--yes",
|
||||
"--global-config", string(env.cfg),
|
||||
)
|
||||
|
||||
err = logoutEnv.inv.Run()
|
||||
require.NoError(t, err, "logout should succeed with automatic file storage fallback")
|
||||
|
||||
_, err = os.Stat(sessionFile)
|
||||
require.True(t, os.IsNotExist(err), "session file should be deleted after logout")
|
||||
})
|
||||
}
|
||||
+24
-5
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/coder/pretty"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/cli/sessionstore"
|
||||
"github.com/coder/coder/v2/coderd/userpassword"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
@@ -114,9 +115,11 @@ func (r *RootCmd) loginWithPassword(
|
||||
}
|
||||
|
||||
sessionToken := resp.SessionToken
|
||||
config := r.createConfig()
|
||||
err = config.Session().Write(sessionToken)
|
||||
err = r.ensureTokenBackend().Write(client.URL, sessionToken)
|
||||
if err != nil {
|
||||
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
|
||||
return errKeyringNotSupported
|
||||
}
|
||||
return xerrors.Errorf("write session token: %w", err)
|
||||
}
|
||||
|
||||
@@ -149,11 +152,15 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
useTokenForSession bool
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Use: "login [<url>]",
|
||||
Short: "Authenticate with Coder deployment",
|
||||
Use: "login [<url>]",
|
||||
Short: "Authenticate with Coder deployment",
|
||||
Long: "By default, the session token is stored in the operating system keyring on " +
|
||||
"macOS and Windows and a plain text file on Linux. Use the --use-keyring flag " +
|
||||
"or CODER_USE_KEYRING environment variable to change the storage mechanism.",
|
||||
Middleware: serpent.RequireRangeArgs(0, 1),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
|
||||
rawURL := ""
|
||||
var urlSource string
|
||||
|
||||
@@ -198,6 +205,15 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check keyring availability before prompting the user for a token to fail fast.
|
||||
if r.useKeyring {
|
||||
backend := r.ensureTokenBackend()
|
||||
_, err := backend.Read(client.URL)
|
||||
if err != nil && xerrors.Is(err, sessionstore.ErrNotImplemented) {
|
||||
return errKeyringNotSupported
|
||||
}
|
||||
}
|
||||
|
||||
hasFirstUser, err := client.HasFirstUser(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser? Error - has initial user: %w", serverURL.String(), err)
|
||||
@@ -394,8 +410,11 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
}
|
||||
|
||||
config := r.createConfig()
|
||||
err = config.Session().Write(sessionToken)
|
||||
err = r.ensureTokenBackend().Write(client.URL, sessionToken)
|
||||
if err != nil {
|
||||
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
|
||||
return errKeyringNotSupported
|
||||
}
|
||||
return xerrors.Errorf("write session token: %w", err)
|
||||
}
|
||||
err = config.URL().Write(serverURL.String())
|
||||
|
||||
+8
-3
@@ -8,6 +8,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/cli/sessionstore"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
@@ -46,11 +47,15 @@ func (r *RootCmd) logout() *serpent.Command {
|
||||
errors = append(errors, xerrors.Errorf("remove URL file: %w", err))
|
||||
}
|
||||
|
||||
err = config.Session().Delete()
|
||||
err = r.ensureTokenBackend().Delete(client.URL)
|
||||
// Only throw error if the session configuration file is present,
|
||||
// otherwise the user is already logged out, and we proceed
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
errors = append(errors, xerrors.Errorf("remove session file: %w", err))
|
||||
if err != nil && !xerrors.Is(err, os.ErrNotExist) {
|
||||
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
|
||||
errors = append(errors, errKeyringNotSupported)
|
||||
} else {
|
||||
errors = append(errors, xerrors.Errorf("remove session token: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
err = config.Organization().Delete()
|
||||
|
||||
+80
-11
@@ -37,6 +37,7 @@ import (
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/cli/config"
|
||||
"github.com/coder/coder/v2/cli/gitauth"
|
||||
"github.com/coder/coder/v2/cli/sessionstore"
|
||||
"github.com/coder/coder/v2/cli/telemetry"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
@@ -54,6 +55,8 @@ var (
|
||||
// ErrSilent is a sentinel error that tells the command handler to just exit with a non-zero error, but not print
|
||||
// anything.
|
||||
ErrSilent = xerrors.New("silent error")
|
||||
|
||||
errKeyringNotSupported = xerrors.New("keyring storage is not supported on this operating system; omit --use-keyring to use file-based storage")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -68,12 +71,14 @@ const (
|
||||
varVerbose = "verbose"
|
||||
varDisableDirect = "disable-direct-connections"
|
||||
varDisableNetworkTelemetry = "disable-network-telemetry"
|
||||
varUseKeyring = "use-keyring"
|
||||
|
||||
notLoggedInMessage = "You are not logged in. Try logging in using '%s login <url>'."
|
||||
|
||||
envNoVersionCheck = "CODER_NO_VERSION_WARNING"
|
||||
envNoFeatureWarning = "CODER_NO_FEATURE_WARNING"
|
||||
envSessionToken = "CODER_SESSION_TOKEN"
|
||||
envUseKeyring = "CODER_USE_KEYRING"
|
||||
//nolint:gosec
|
||||
envAgentToken = "CODER_AGENT_TOKEN"
|
||||
//nolint:gosec
|
||||
@@ -99,6 +104,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
|
||||
r.resetPassword(),
|
||||
r.sharing(),
|
||||
r.state(),
|
||||
r.tasksCommand(),
|
||||
r.templates(),
|
||||
r.tokens(),
|
||||
r.users(),
|
||||
@@ -144,7 +150,7 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command {
|
||||
r.mcpCommand(),
|
||||
r.promptExample(),
|
||||
r.rptyCommand(),
|
||||
r.tasksCommand(),
|
||||
r.syncCommand(),
|
||||
r.boundary(),
|
||||
}
|
||||
}
|
||||
@@ -474,6 +480,17 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
|
||||
Value: serpent.BoolOf(&r.disableNetworkTelemetry),
|
||||
Group: globalGroup,
|
||||
},
|
||||
{
|
||||
Flag: varUseKeyring,
|
||||
Env: envUseKeyring,
|
||||
Description: "Store and retrieve session tokens using the operating system " +
|
||||
"keyring. This flag is ignored and file-based storage is used when " +
|
||||
"--global-config is set or keyring usage is not supported on the current " +
|
||||
"platform. Set to false to force file-based storage on supported platforms.",
|
||||
Default: "true",
|
||||
Value: serpent.BoolOf(&r.useKeyring),
|
||||
Group: globalGroup,
|
||||
},
|
||||
{
|
||||
Flag: "debug-http",
|
||||
Description: "Debug codersdk HTTP requests.",
|
||||
@@ -508,6 +525,7 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
|
||||
type RootCmd struct {
|
||||
clientURL *url.URL
|
||||
token string
|
||||
tokenBackend sessionstore.Backend
|
||||
globalConfig string
|
||||
header []string
|
||||
headerCommand string
|
||||
@@ -519,9 +537,12 @@ type RootCmd struct {
|
||||
disableDirect bool
|
||||
debugHTTP bool
|
||||
|
||||
disableNetworkTelemetry bool
|
||||
noVersionCheck bool
|
||||
noFeatureWarning bool
|
||||
disableNetworkTelemetry bool
|
||||
noVersionCheck bool
|
||||
noFeatureWarning bool
|
||||
useKeyring bool
|
||||
keyringServiceName string
|
||||
useKeyringWithGlobalConfig bool
|
||||
}
|
||||
|
||||
// InitClient creates and configures a new client with authentication, telemetry,
|
||||
@@ -549,14 +570,19 @@ func (r *RootCmd) InitClient(inv *serpent.Invocation) (*codersdk.Client, error)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Read the token stored on disk.
|
||||
if r.token == "" {
|
||||
r.token, err = conf.Session().Read()
|
||||
tok, err := r.ensureTokenBackend().Read(r.clientURL)
|
||||
// Even if there isn't a token, we don't care.
|
||||
// Some API routes can be unauthenticated.
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
if err != nil && !xerrors.Is(err, os.ErrNotExist) {
|
||||
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
|
||||
return nil, errKeyringNotSupported
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if tok != "" {
|
||||
r.token = tok
|
||||
}
|
||||
}
|
||||
|
||||
// Configure HTTP client with transport wrappers
|
||||
@@ -588,7 +614,6 @@ func (r *RootCmd) InitClient(inv *serpent.Invocation) (*codersdk.Client, error)
|
||||
// This allows commands to run without requiring authentication, but still use auth if available.
|
||||
func (r *RootCmd) TryInitClient(inv *serpent.Invocation) (*codersdk.Client, error) {
|
||||
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()
|
||||
@@ -605,14 +630,19 @@ func (r *RootCmd) TryInitClient(inv *serpent.Invocation) (*codersdk.Client, erro
|
||||
}
|
||||
}
|
||||
}
|
||||
// Read the token stored on disk.
|
||||
if r.token == "" {
|
||||
r.token, err = conf.Session().Read()
|
||||
tok, err := r.ensureTokenBackend().Read(r.clientURL)
|
||||
// Even if there isn't a token, we don't care.
|
||||
// Some API routes can be unauthenticated.
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
if err != nil && !xerrors.Is(err, os.ErrNotExist) {
|
||||
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
|
||||
return nil, errKeyringNotSupported
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if tok != "" {
|
||||
r.token = tok
|
||||
}
|
||||
}
|
||||
|
||||
// Only configure the client if we have a URL
|
||||
@@ -688,6 +718,45 @@ func (r *RootCmd) createUnauthenticatedClient(ctx context.Context, serverURL *ur
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// ensureTokenBackend returns the session token storage backend, creating it if necessary.
|
||||
// This must be called after flags are parsed so we can respect the value of the --use-keyring
|
||||
// flag.
|
||||
func (r *RootCmd) ensureTokenBackend() sessionstore.Backend {
|
||||
if r.tokenBackend == nil {
|
||||
// Checking for the --global-config directory being set is a bit wonky but necessary
|
||||
// to allow extensions that invoke the CLI with this flag (e.g. VS code) to continue
|
||||
// working without modification. In the future we should modify these extensions to
|
||||
// either access the credential in the keyring (like Coder Desktop) or some other
|
||||
// approach that doesn't rely on the session token being stored on disk.
|
||||
assumeExtensionInUse := r.globalConfig != config.DefaultDir() && !r.useKeyringWithGlobalConfig
|
||||
keyringSupported := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
|
||||
if r.useKeyring && !assumeExtensionInUse && keyringSupported {
|
||||
serviceName := sessionstore.DefaultServiceName
|
||||
if r.keyringServiceName != "" {
|
||||
serviceName = r.keyringServiceName
|
||||
}
|
||||
r.tokenBackend = sessionstore.NewKeyringWithService(serviceName)
|
||||
} else {
|
||||
r.tokenBackend = sessionstore.NewFile(r.createConfig)
|
||||
}
|
||||
}
|
||||
return r.tokenBackend
|
||||
}
|
||||
|
||||
// WithKeyringServiceName sets a custom keyring service name for testing purposes.
|
||||
// This allows tests to use isolated keyring storage while still exercising the
|
||||
// genuine storage backend selection logic in ensureTokenBackend().
|
||||
func (r *RootCmd) WithKeyringServiceName(serviceName string) {
|
||||
r.keyringServiceName = serviceName
|
||||
}
|
||||
|
||||
// UseKeyringWithGlobalConfig enables the use of the keyring storage backend
|
||||
// when the --global-config directory is set. This is only intended as an override
|
||||
// for tests, which require specifying the global config directory for test isolation.
|
||||
func (r *RootCmd) UseKeyringWithGlobalConfig() {
|
||||
r.useKeyringWithGlobalConfig = true
|
||||
}
|
||||
|
||||
type AgentAuth struct {
|
||||
// Agent Client config
|
||||
agentToken string
|
||||
|
||||
@@ -72,6 +72,31 @@ func TestCommandHelp(t *testing.T) {
|
||||
Name: "coder provisioner jobs list --output json",
|
||||
Cmd: []string{"provisioner", "jobs", "list", "--output", "json"},
|
||||
},
|
||||
// TODO (SasSwart): Remove these once the sync commands are promoted out of experimental.
|
||||
clitest.CommandHelpCase{
|
||||
Name: "coder exp sync --help",
|
||||
Cmd: []string{"exp", "sync", "--help"},
|
||||
},
|
||||
clitest.CommandHelpCase{
|
||||
Name: "coder exp sync ping --help",
|
||||
Cmd: []string{"exp", "sync", "ping", "--help"},
|
||||
},
|
||||
clitest.CommandHelpCase{
|
||||
Name: "coder exp sync start --help",
|
||||
Cmd: []string{"exp", "sync", "start", "--help"},
|
||||
},
|
||||
clitest.CommandHelpCase{
|
||||
Name: "coder exp sync want --help",
|
||||
Cmd: []string{"exp", "sync", "want", "--help"},
|
||||
},
|
||||
clitest.CommandHelpCase{
|
||||
Name: "coder exp sync complete --help",
|
||||
Cmd: []string{"exp", "sync", "complete", "--help"},
|
||||
},
|
||||
clitest.CommandHelpCase{
|
||||
Name: "coder exp sync status --help",
|
||||
Cmd: []string{"exp", "sync", "status", "--help"},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,22 @@ func (r *RootCmd) scheduleStart() *serpent.Command {
|
||||
}
|
||||
|
||||
schedStr = ptr.Ref(sched.String())
|
||||
|
||||
// Check if the template has autostart requirements that may conflict
|
||||
// with the user's schedule.
|
||||
template, err := client.Template(inv.Context(), workspace.TemplateID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template: %w", err)
|
||||
}
|
||||
|
||||
if len(template.AutostartRequirement.DaysOfWeek) > 0 {
|
||||
_, _ = fmt.Fprintf(
|
||||
inv.Stderr,
|
||||
"Warning: your workspace template restricts autostart to the following days: %s.\n"+
|
||||
"Your workspace may only autostart on these days.\n",
|
||||
strings.Join(template.AutostartRequirement.DaysOfWeek, ", "),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
err = client.UpdateWorkspaceAutostart(inv.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
|
||||
|
||||
@@ -373,3 +373,67 @@ func TestScheduleOverride(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // t.Setenv
|
||||
func TestScheduleStart_TemplateAutostartRequirement(t *testing.T) {
|
||||
t.Setenv("TZ", "UTC")
|
||||
loc, err := tz.TimezoneIANA()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "UTC", loc.String())
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
// Update template to have autostart requirement
|
||||
// Note: In AGPL, this will be ignored and all days will be allowed (enterprise feature).
|
||||
template, err = client.UpdateTemplateMeta(context.Background(), template.ID, codersdk.UpdateTemplateMeta{
|
||||
AutostartRequirement: &codersdk.TemplateAutostartRequirement{
|
||||
DaysOfWeek: []string{"monday", "wednesday", "friday"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the template - in AGPL, AutostartRequirement will have all days (enterprise feature)
|
||||
template, err = client.Template(context.Background(), template.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, template.AutostartRequirement.DaysOfWeek, "template should have autostart requirement days")
|
||||
|
||||
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
t.Run("ShowsWarning", func(t *testing.T) {
|
||||
// When: user sets autostart schedule
|
||||
inv, root := clitest.New(t,
|
||||
"schedule", "start", workspace.Name, "9:30AM", "Mon-Fri",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
require.NoError(t, inv.Run())
|
||||
|
||||
// Then: warning should be shown
|
||||
// In AGPL, this will show all days (enterprise feature defaults to all days allowed)
|
||||
pty.ExpectMatch("Warning")
|
||||
pty.ExpectMatch("may only autostart")
|
||||
})
|
||||
|
||||
t.Run("NoWarningWhenManual", func(t *testing.T) {
|
||||
// When: user sets manual schedule
|
||||
inv, root := clitest.New(t,
|
||||
"schedule", "start", workspace.Name, "manual",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
var stderrBuf bytes.Buffer
|
||||
inv.Stderr = &stderrBuf
|
||||
|
||||
require.NoError(t, inv.Run())
|
||||
|
||||
// Then: no warning should be shown on stderr
|
||||
stderrOutput := stderrBuf.String()
|
||||
require.NotContains(t, stderrOutput, "Warning")
|
||||
})
|
||||
}
|
||||
|
||||
+84
-40
@@ -29,6 +29,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -1028,7 +1029,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
defer shutdownConns()
|
||||
|
||||
// Ensures that old database entries are cleaned up over time!
|
||||
purger := dbpurge.New(ctx, logger.Named("dbpurge"), options.Database, quartz.NewReal())
|
||||
purger := dbpurge.New(ctx, logger.Named("dbpurge"), options.Database, options.DeploymentValues, quartz.NewReal())
|
||||
defer purger.Close()
|
||||
|
||||
// Updates workspace usage
|
||||
@@ -1377,6 +1378,7 @@ func IsLocalURL(ctx context.Context, u *url.URL) (bool, error) {
|
||||
}
|
||||
|
||||
func shutdownWithTimeout(shutdown func(context.Context) error, timeout time.Duration) error {
|
||||
// nolint:gocritic // The magic number is parameterized.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return shutdown(ctx)
|
||||
@@ -1474,6 +1476,7 @@ func newProvisionerDaemon(
|
||||
Listener: terraformServer,
|
||||
Logger: provisionerLogger,
|
||||
WorkDirectory: workDir,
|
||||
Experiments: coderAPI.Experiments,
|
||||
},
|
||||
CachePath: tfDir,
|
||||
Tracer: tracer,
|
||||
@@ -2134,50 +2137,90 @@ func startBuiltinPostgres(ctx context.Context, cfg config.Root, logger slog.Logg
|
||||
return "", nil, xerrors.New("The built-in PostgreSQL cannot run as the root user. Create a non-root user and run again!")
|
||||
}
|
||||
|
||||
// Ensure a password and port have been generated!
|
||||
connectionURL, err := embeddedPostgresURL(cfg)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
pgPassword, err := cfg.PostgresPassword().Read()
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("read postgres password: %w", err)
|
||||
}
|
||||
pgPortRaw, err := cfg.PostgresPort().Read()
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("read postgres port: %w", err)
|
||||
}
|
||||
pgPort, err := strconv.ParseUint(pgPortRaw, 10, 16)
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("parse postgres port: %w", err)
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(cfg.PostgresPath(), "cache")
|
||||
if customCacheDir != "" {
|
||||
cachePath = filepath.Join(customCacheDir, "postgres")
|
||||
}
|
||||
stdlibLogger := slog.Stdlib(ctx, logger.Named("postgres"), slog.LevelDebug)
|
||||
ep := embeddedpostgres.NewDatabase(
|
||||
embeddedpostgres.DefaultConfig().
|
||||
Version(embeddedpostgres.V13).
|
||||
BinariesPath(filepath.Join(cfg.PostgresPath(), "bin")).
|
||||
// Default BinaryRepositoryURL repo1.maven.org is flaky.
|
||||
BinaryRepositoryURL("https://repo.maven.apache.org/maven2").
|
||||
DataPath(filepath.Join(cfg.PostgresPath(), "data")).
|
||||
RuntimePath(filepath.Join(cfg.PostgresPath(), "runtime")).
|
||||
CachePath(cachePath).
|
||||
Username("coder").
|
||||
Password(pgPassword).
|
||||
Database("coder").
|
||||
Encoding("UTF8").
|
||||
Port(uint32(pgPort)).
|
||||
Logger(stdlibLogger.Writer()),
|
||||
)
|
||||
err = ep.Start()
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("Failed to start built-in PostgreSQL. Optionally, specify an external deployment with `--postgres-url`: %w", err)
|
||||
|
||||
// If the port is not defined, an available port will be found dynamically. This has
|
||||
// implications in CI because here is no way to tell Postgres to use an ephemeral
|
||||
// port, so to avoid flaky tests in CI we need to retry EmbeddedPostgres.Start in
|
||||
// case of a race condition where the port we quickly listen on and close in
|
||||
// embeddedPostgresURL() is not free by the time the embedded postgres starts up.
|
||||
// The maximum retry attempts _should_ cover most cases where port conflicts occur
|
||||
// in CI and cause flaky tests.
|
||||
maxAttempts := 1
|
||||
_, err = cfg.PostgresPort().Read()
|
||||
// Important: if retryPortDiscovery is changed to not include testing.Testing(),
|
||||
// the retry logic below also needs to be updated to ensure we don't delete an
|
||||
// existing database
|
||||
retryPortDiscovery := errors.Is(err, os.ErrNotExist) && testing.Testing()
|
||||
if retryPortDiscovery {
|
||||
maxAttempts = 3
|
||||
}
|
||||
return connectionURL, ep.Stop, nil
|
||||
|
||||
var startErr error
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
if retryPortDiscovery && attempt > 0 {
|
||||
// Clean up the data and runtime directories and the port file from the
|
||||
// previous failed attempt to ensure a clean slate for the next attempt.
|
||||
_ = os.RemoveAll(filepath.Join(cfg.PostgresPath(), "data"))
|
||||
_ = os.RemoveAll(filepath.Join(cfg.PostgresPath(), "runtime"))
|
||||
_ = cfg.PostgresPort().Delete()
|
||||
}
|
||||
|
||||
// Ensure a password and port have been generated.
|
||||
connectionURL, err := embeddedPostgresURL(cfg)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
pgPassword, err := cfg.PostgresPassword().Read()
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("read postgres password: %w", err)
|
||||
}
|
||||
pgPortRaw, err := cfg.PostgresPort().Read()
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("read postgres port: %w", err)
|
||||
}
|
||||
pgPort, err := strconv.ParseUint(pgPortRaw, 10, 16)
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("parse postgres port: %w", err)
|
||||
}
|
||||
|
||||
ep := embeddedpostgres.NewDatabase(
|
||||
embeddedpostgres.DefaultConfig().
|
||||
Version(embeddedpostgres.V13).
|
||||
BinariesPath(filepath.Join(cfg.PostgresPath(), "bin")).
|
||||
// Default BinaryRepositoryURL repo1.maven.org is flaky.
|
||||
BinaryRepositoryURL("https://repo.maven.apache.org/maven2").
|
||||
DataPath(filepath.Join(cfg.PostgresPath(), "data")).
|
||||
RuntimePath(filepath.Join(cfg.PostgresPath(), "runtime")).
|
||||
CachePath(cachePath).
|
||||
Username("coder").
|
||||
Password(pgPassword).
|
||||
Database("coder").
|
||||
Encoding("UTF8").
|
||||
Port(uint32(pgPort)).
|
||||
Logger(stdlibLogger.Writer()),
|
||||
)
|
||||
|
||||
startErr = ep.Start()
|
||||
if startErr == nil {
|
||||
return connectionURL, ep.Stop, nil
|
||||
}
|
||||
|
||||
logger.Warn(ctx, "failed to start embedded postgres",
|
||||
slog.F("attempt", attempt+1),
|
||||
slog.F("max_attempts", maxAttempts),
|
||||
slog.F("port", pgPort),
|
||||
slog.Error(startErr),
|
||||
)
|
||||
}
|
||||
|
||||
return "", nil, xerrors.Errorf("failed to start built-in PostgreSQL after %d attempts. "+
|
||||
"Optionally, specify an external deployment. See https://coder.com/docs/tutorials/external-database "+
|
||||
"for more details: %w", maxAttempts, startErr)
|
||||
}
|
||||
|
||||
func ConfigureHTTPClient(ctx context.Context, clientCertFile, clientKeyFile string, tlsClientCAFile string) (context.Context, *http.Client, error) {
|
||||
@@ -2286,7 +2329,7 @@ func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, d
|
||||
var err error
|
||||
var sqlDB *sql.DB
|
||||
dbNeedsClosing := true
|
||||
// Try to connect for 30 seconds.
|
||||
// nolint:gocritic // Try to connect for 30 seconds.
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -2382,6 +2425,7 @@ func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, d
|
||||
}
|
||||
|
||||
func pingPostgres(ctx context.Context, db *sql.DB) error {
|
||||
// nolint:gocritic // This is a reasonable magic number for a ping timeout.
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
return db.PingContext(ctx)
|
||||
|
||||
@@ -17,9 +17,6 @@ import (
|
||||
|
||||
func TestRegenerateVapidKeypair(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("this test is only supported on postgres")
|
||||
}
|
||||
|
||||
t.Run("NoExistingVAPIDKeys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -348,9 +348,6 @@ func TestServer(t *testing.T) {
|
||||
|
||||
runGitHubProviderTest := func(t *testing.T, tc testCase) {
|
||||
t.Parallel()
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("test requires postgres")
|
||||
}
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(testutil.Context(t, testutil.WaitLong))
|
||||
defer cancelFunc()
|
||||
@@ -2142,10 +2139,6 @@ func TestServerYAMLConfig(t *testing.T) {
|
||||
func TestConnectToPostgres(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("this test does not make sense without postgres")
|
||||
}
|
||||
|
||||
t.Run("Migrate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2256,10 +2249,6 @@ type runServerOpts struct {
|
||||
func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("this test requires postgres")
|
||||
}
|
||||
|
||||
telemetryServerURL, deployment, snapshot := mockTelemetryServer(t)
|
||||
dbConnURL, err := dbtestutil.Open(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// Package sessionstore provides CLI session token storage mechanisms.
|
||||
// Operating system keyring storage is intended to have compatibility with other Coder
|
||||
// applications (e.g. Coder Desktop, Coder provider for JetBrains Toolbox, etc) so that
|
||||
// applications can read/write the same credential stored in the keyring.
|
||||
//
|
||||
// Note that we aren't using an existing Go package zalando/go-keyring here for a few
|
||||
// reasons. 1) It prescribes the format of the target credential name in the OS keyrings,
|
||||
// which makes our life difficult for compatibility with other Coder applications. 2)
|
||||
// It uses init functions that make it difficult to test with. As a result, the OS
|
||||
// keyring implementations may be adapted from zalando/go-keyring source (i.e. Windows).
|
||||
package sessionstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/config"
|
||||
)
|
||||
|
||||
// Backend is a storage backend for session tokens.
|
||||
type Backend interface {
|
||||
// Read returns the session token for the given server URL or an error, if any. It
|
||||
// will return os.ErrNotExist if no token exists for the given URL.
|
||||
Read(serverURL *url.URL) (string, error)
|
||||
// Write stores the session token for the given server URL.
|
||||
Write(serverURL *url.URL, token string) error
|
||||
// Delete removes the session token for the given server URL or an error, if any.
|
||||
// It will return os.ErrNotExist error if no token exists to delete.
|
||||
Delete(serverURL *url.URL) error
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
// ErrSetDataTooBig is returned if `keyringProvider.Set` was called with too much data.
|
||||
// On macOS: The combination of service, username & password should not exceed ~3000 bytes
|
||||
// On Windows: The service is limited to 32KiB while the password is limited to 2560 bytes
|
||||
ErrSetDataTooBig = xerrors.New("data passed to Set was too big")
|
||||
|
||||
// ErrNotImplemented represents when keyring usage is not implemented on the current
|
||||
// operating system.
|
||||
ErrNotImplemented = xerrors.New("not implemented")
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultServiceName is the service name used in keyrings for storing Coder CLI session
|
||||
// tokens.
|
||||
DefaultServiceName = "coder-v2-credentials"
|
||||
)
|
||||
|
||||
// keyringProvider represents an operating system keyring. The expectation
|
||||
// is these methods operate on the user/login keyring.
|
||||
type keyringProvider interface {
|
||||
// Set stores the given credential for a service name in the operating system
|
||||
// keyring.
|
||||
Set(service, credential string) error
|
||||
// Get retrieves the credential from the keyring. It must return os.ErrNotExist
|
||||
// if the credential is not found.
|
||||
Get(service string) ([]byte, error)
|
||||
// Delete deletes the credential from the keyring. It must return os.ErrNotExist
|
||||
// if the credential is not found.
|
||||
Delete(service string) error
|
||||
}
|
||||
|
||||
// credential represents a single credential entry.
|
||||
type credential struct {
|
||||
CoderURL string `json:"coder_url"`
|
||||
APIToken string `json:"api_token"`
|
||||
}
|
||||
|
||||
// credentialsMap represents the JSON structure stored in the operating system keyring.
|
||||
// It supports storing multiple credentials for different server URLs.
|
||||
type credentialsMap map[string]credential
|
||||
|
||||
// normalizeHost returns a normalized version of the URL host for use as a map key.
|
||||
func normalizeHost(u *url.URL) (string, error) {
|
||||
if u == nil || u.Host == "" {
|
||||
return "", xerrors.New("nil server URL")
|
||||
}
|
||||
return strings.TrimSpace(strings.ToLower(u.Host)), nil
|
||||
}
|
||||
|
||||
// parseCredentialsJSON parses the JSON from the keyring into a credentialsMap.
|
||||
func parseCredentialsJSON(jsonData []byte) (credentialsMap, error) {
|
||||
if len(jsonData) == 0 {
|
||||
return make(credentialsMap), nil
|
||||
}
|
||||
|
||||
var creds credentialsMap
|
||||
if err := json.Unmarshal(jsonData, &creds); err != nil {
|
||||
return nil, xerrors.Errorf("unmarshal credentials: %w", err)
|
||||
}
|
||||
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// Keyring is a Backend that exclusively stores the session token in the operating
|
||||
// system keyring. Happy path usage of this type should start with NewKeyring.
|
||||
// It stores a JSON object in the keyring that supports multiple credentials for
|
||||
// different server URLs, providing compatibility with Coder Desktop and other Coder
|
||||
// applications.
|
||||
type Keyring struct {
|
||||
provider keyringProvider
|
||||
serviceName string
|
||||
}
|
||||
|
||||
// NewKeyringWithService creates a Keyring Backend that stores credentials under the
|
||||
// specified service name. Generally, DefaultServiceName should be provided as the service
|
||||
// name except in tests which may need parameterization to avoid conflicting keyring use.
|
||||
func NewKeyringWithService(serviceName string) Keyring {
|
||||
return Keyring{
|
||||
provider: operatingSystemKeyring{},
|
||||
serviceName: serviceName,
|
||||
}
|
||||
}
|
||||
|
||||
func (o Keyring) Read(serverURL *url.URL) (string, error) {
|
||||
host, err := normalizeHost(serverURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
credJSON, err := o.provider.Get(o.serviceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(credJSON) == 0 {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
|
||||
creds, err := parseCredentialsJSON(credJSON)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("read: parse existing credentials: %w", err)
|
||||
}
|
||||
|
||||
// Return the credential for the specified URL
|
||||
cred, ok := creds[host]
|
||||
if !ok {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
return cred.APIToken, nil
|
||||
}
|
||||
|
||||
func (o Keyring) Write(serverURL *url.URL, token string) error {
|
||||
host, err := normalizeHost(serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingJSON, err := o.provider.Get(o.serviceName)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return xerrors.Errorf("read existing credentials: %w", err)
|
||||
}
|
||||
|
||||
creds, err := parseCredentialsJSON(existingJSON)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write: parse existing credentials: %w", err)
|
||||
}
|
||||
|
||||
// Upsert the credential for this URL.
|
||||
creds[host] = credential{
|
||||
CoderURL: host,
|
||||
APIToken: token,
|
||||
}
|
||||
|
||||
credsJSON, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("marshal credentials: %w", err)
|
||||
}
|
||||
|
||||
err = o.provider.Set(o.serviceName, string(credsJSON))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write credentials to keyring: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o Keyring) Delete(serverURL *url.URL) error {
|
||||
host, err := normalizeHost(serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingJSON, err := o.provider.Get(o.serviceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
creds, err := parseCredentialsJSON(existingJSON)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to parse existing credentials: %w", err)
|
||||
}
|
||||
|
||||
if _, ok := creds[host]; !ok {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
delete(creds, host)
|
||||
|
||||
// Delete the entire keyring entry when no credentials remain.
|
||||
if len(creds) == 0 {
|
||||
return o.provider.Delete(o.serviceName)
|
||||
}
|
||||
|
||||
// Write back the updated credentials map.
|
||||
credsJSON, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to marshal credentials: %w", err)
|
||||
}
|
||||
|
||||
return o.provider.Set(o.serviceName, string(credsJSON))
|
||||
}
|
||||
|
||||
// File is a Backend that exclusively stores the session token in a file on disk.
|
||||
type File struct {
|
||||
config func() config.Root
|
||||
}
|
||||
|
||||
func NewFile(f func() config.Root) *File {
|
||||
return &File{config: f}
|
||||
}
|
||||
|
||||
func (f *File) Read(_ *url.URL) (string, error) {
|
||||
return f.config().Session().Read()
|
||||
}
|
||||
|
||||
func (f *File) Write(_ *url.URL, token string) error {
|
||||
return f.config().Session().Write(token)
|
||||
}
|
||||
|
||||
func (f *File) Delete(_ *url.URL) error {
|
||||
return f.config().Session().Delete()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//go:build darwin
|
||||
|
||||
package sessionstore
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// fixedUsername is the fixed username used for all keychain entries.
|
||||
// Since our interface only uses service names, we use a constant username.
|
||||
fixedUsername = "coder-login-credentials"
|
||||
|
||||
execPathKeychain = "/usr/bin/security"
|
||||
notFoundStr = "could not be found"
|
||||
)
|
||||
|
||||
// operatingSystemKeyring implements keyringProvider for macOS.
|
||||
// It is largely adapted from the zalando/go-keyring package.
|
||||
type operatingSystemKeyring struct{}
|
||||
|
||||
func (operatingSystemKeyring) Set(service, credential string) error {
|
||||
// if the added secret has multiple lines or some non ascii,
|
||||
// macOS will hex encode it on return. To avoid getting garbage, we
|
||||
// encode all passwords
|
||||
password := base64.StdEncoding.EncodeToString([]byte(credential))
|
||||
|
||||
cmd := exec.Command(execPathKeychain, "-i")
|
||||
stdIn, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
command := fmt.Sprintf("add-generic-password -U -s %s -a %s -w %s\n",
|
||||
shellEscape(service),
|
||||
shellEscape(fixedUsername),
|
||||
shellEscape(password))
|
||||
if len(command) > 4096 {
|
||||
return ErrSetDataTooBig
|
||||
}
|
||||
|
||||
if _, err := io.WriteString(stdIn, command); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = stdIn.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
func (operatingSystemKeyring) Get(service string) ([]byte, error) {
|
||||
out, err := exec.Command(
|
||||
execPathKeychain,
|
||||
"find-generic-password",
|
||||
"-s", service,
|
||||
"-wa", fixedUsername).CombinedOutput()
|
||||
if err != nil {
|
||||
if strings.Contains(string(out), notFoundStr) {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trimStr := strings.TrimSpace(string(out))
|
||||
return base64.StdEncoding.DecodeString(trimStr)
|
||||
}
|
||||
|
||||
func (operatingSystemKeyring) Delete(service string) error {
|
||||
out, err := exec.Command(
|
||||
execPathKeychain,
|
||||
"delete-generic-password",
|
||||
"-s", service,
|
||||
"-a", fixedUsername).CombinedOutput()
|
||||
if strings.Contains(string(out), notFoundStr) {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// shellEscape returns a shell-escaped version of the string s.
|
||||
// This is adapted from github.com/zalando/go-keyring/internal/shellescape.
|
||||
func shellEscape(s string) string {
|
||||
if len(s) == 0 {
|
||||
return "''"
|
||||
}
|
||||
|
||||
pattern := regexp.MustCompile(`[^\w@%+=:,./-]`)
|
||||
if pattern.MatchString(s) {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build darwin
|
||||
|
||||
package sessionstore_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
execPathKeychain = "/usr/bin/security"
|
||||
fixedUsername = "coder-login-credentials"
|
||||
)
|
||||
|
||||
func readRawKeychainCredential(t *testing.T, service string) []byte {
|
||||
t.Helper()
|
||||
|
||||
out, err := exec.Command(
|
||||
execPathKeychain,
|
||||
"find-generic-password",
|
||||
"-s", service,
|
||||
"-wa", fixedUsername).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst := make([]byte, base64.StdEncoding.DecodedLen(len(out)))
|
||||
n, err := base64.StdEncoding.Decode(dst, out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dst[:n]
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package sessionstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url *url.URL
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "StandardHost",
|
||||
url: &url.URL{Host: "coder.example.com"},
|
||||
want: "coder.example.com",
|
||||
},
|
||||
{
|
||||
name: "HostWithPort",
|
||||
url: &url.URL{Host: "coder.example.com:8080"},
|
||||
want: "coder.example.com:8080",
|
||||
},
|
||||
{
|
||||
name: "UppercaseHost",
|
||||
url: &url.URL{Host: "CODER.EXAMPLE.COM"},
|
||||
want: "coder.example.com",
|
||||
},
|
||||
{
|
||||
name: "HostWithWhitespace",
|
||||
url: &url.URL{Host: " coder.example.com "},
|
||||
want: "coder.example.com",
|
||||
},
|
||||
{
|
||||
name: "NilURL",
|
||||
url: nil,
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "EmptyHost",
|
||||
url: &url.URL{Host: ""},
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeHost(tt.url)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCredentialsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
creds, err := parseCredentialsJSON(nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, creds)
|
||||
require.Empty(t, creds)
|
||||
})
|
||||
|
||||
t.Run("NewFormat", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
jsonData := []byte(`{
|
||||
"coder1.example.com": {"coder_url": "coder1.example.com", "api_token": "token1"},
|
||||
"coder2.example.com": {"coder_url": "coder2.example.com", "api_token": "token2"}
|
||||
}`)
|
||||
creds, err := parseCredentialsJSON(jsonData)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, creds, 2)
|
||||
require.Equal(t, "token1", creds["coder1.example.com"].APIToken)
|
||||
require.Equal(t, "token2", creds["coder2.example.com"].APIToken)
|
||||
})
|
||||
|
||||
t.Run("InvalidJSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
jsonData := []byte(`{invalid json}`)
|
||||
_, err := parseCredentialsJSON(jsonData)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCredentialsMap_RoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
creds := credentialsMap{
|
||||
"coder1.example.com": {
|
||||
CoderURL: "coder1.example.com",
|
||||
APIToken: "token1",
|
||||
},
|
||||
"coder2.example.com:8080": {
|
||||
CoderURL: "coder2.example.com:8080",
|
||||
APIToken: "token2",
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(creds)
|
||||
require.NoError(t, err)
|
||||
|
||||
parsed, err := parseCredentialsJSON(jsonData)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, creds, parsed)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package sessionstore
|
||||
|
||||
type operatingSystemKeyring struct{}
|
||||
|
||||
func (operatingSystemKeyring) Set(_, _ string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
|
||||
func (operatingSystemKeyring) Get(_ string) ([]byte, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (operatingSystemKeyring) Delete(_ string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package sessionstore_test
|
||||
|
||||
import "testing"
|
||||
|
||||
func readRawKeychainCredential(t *testing.T, _ string) []byte {
|
||||
t.Fatal("not implemented")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package sessionstore_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/config"
|
||||
"github.com/coder/coder/v2/cli/sessionstore"
|
||||
)
|
||||
|
||||
type storedCredentials map[string]struct {
|
||||
CoderURL string `json:"coder_url"`
|
||||
APIToken string `json:"api_token"`
|
||||
}
|
||||
|
||||
// Generate a test service name for use with the OS keyring. It uses a combination
|
||||
// of the test name and a nanosecond timestamp to prevent collisions.
|
||||
func keyringTestServiceName(t *testing.T) string {
|
||||
t.Helper()
|
||||
return t.Name() + "_" + fmt.Sprintf("%v", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func TestKeyring(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "windows" && runtime.GOOS != "darwin" {
|
||||
t.Skip("linux is not supported yet")
|
||||
}
|
||||
|
||||
// This test exercises use of the operating system keyring. As a result,
|
||||
// the operating system keyring is expected to be available.
|
||||
|
||||
const (
|
||||
testURL = "http://127.0.0.1:1337"
|
||||
testURL2 = "http://127.0.0.1:1338"
|
||||
)
|
||||
|
||||
t.Run("ReadNonExistent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = backend.Delete(srvURL) })
|
||||
|
||||
_, err = backend.Read(srvURL)
|
||||
require.Error(t, err)
|
||||
require.True(t, os.IsNotExist(err), "expected os.ErrNotExist when reading non-existent token")
|
||||
})
|
||||
|
||||
t.Run("DeleteNonExistent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = backend.Delete(srvURL) })
|
||||
|
||||
err = backend.Delete(srvURL)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, os.ErrNotExist), "expected os.ErrNotExist when deleting non-existent token")
|
||||
})
|
||||
|
||||
t.Run("WriteAndRead", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = backend.Delete(srvURL) })
|
||||
|
||||
dir := t.TempDir()
|
||||
expSessionFile := path.Join(dir, "session")
|
||||
|
||||
const inputToken = "test-keyring-token-12345"
|
||||
err = backend.Write(srvURL, inputToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify no session file was created (keyring stores in OS keyring, not file)
|
||||
_, err = os.Stat(expSessionFile)
|
||||
require.True(t, errors.Is(err, os.ErrNotExist), "expected session token file to not exist when using keyring")
|
||||
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, inputToken, token)
|
||||
|
||||
// Clean up
|
||||
err = backend.Delete(srvURL)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("WriteAndDelete", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = backend.Delete(srvURL) })
|
||||
|
||||
const inputToken = "test-keyring-token-67890"
|
||||
err = backend.Write(srvURL, inputToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, inputToken, token)
|
||||
|
||||
err = backend.Delete(srvURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = backend.Read(srvURL)
|
||||
require.Error(t, err)
|
||||
require.True(t, os.IsNotExist(err), "expected os.ErrNotExist after deleting token")
|
||||
})
|
||||
|
||||
t.Run("OverwriteToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = backend.Delete(srvURL) })
|
||||
|
||||
// Write first token
|
||||
const firstToken = "first-keyring-token"
|
||||
err = backend.Write(srvURL, firstToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, firstToken, token)
|
||||
|
||||
// Overwrite with second token
|
||||
const secondToken = "second-keyring-token"
|
||||
err = backend.Write(srvURL, secondToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err = backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, secondToken, token)
|
||||
|
||||
// Clean up
|
||||
err = backend.Delete(srvURL)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("MultipleServers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
srvURL2, err := url.Parse(testURL2)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = backend.Delete(srvURL)
|
||||
_ = backend.Delete(srvURL2)
|
||||
})
|
||||
|
||||
// Write token for server 1
|
||||
const token1 = "token-for-server-1"
|
||||
err = backend.Write(srvURL, token1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write token for server 2 (should NOT overwrite server 1)
|
||||
const token2 = "token-for-server-2"
|
||||
err = backend.Write(srvURL2, token2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read server 1's credential
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, token1, token)
|
||||
|
||||
// Read server 2's credential
|
||||
token, err = backend.Read(srvURL2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, token2, token)
|
||||
|
||||
// Delete server 1's credential
|
||||
err = backend.Delete(srvURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify server 1's credential is gone
|
||||
_, err = backend.Read(srvURL)
|
||||
require.Error(t, err)
|
||||
require.True(t, os.IsNotExist(err))
|
||||
|
||||
// Verify server 2's credential still exists
|
||||
token, err = backend.Read(srvURL2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, token2, token)
|
||||
|
||||
// Clean up remaining credentials
|
||||
err = backend.Delete(srvURL2)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
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.
|
||||
|
||||
const testURL1 = "http://127.0.0.1:1337"
|
||||
srv1URL, err := url.Parse(testURL1)
|
||||
require.NoError(t, err)
|
||||
|
||||
const testURL2 = "http://127.0.0.1:1338"
|
||||
srv2URL, err := url.Parse(testURL2)
|
||||
require.NoError(t, err)
|
||||
|
||||
serviceName := keyringTestServiceName(t)
|
||||
backend := sessionstore.NewKeyringWithService(serviceName)
|
||||
t.Cleanup(func() {
|
||||
_ = backend.Delete(srv1URL)
|
||||
_ = backend.Delete(srv2URL)
|
||||
})
|
||||
|
||||
// Write token for server 1
|
||||
const token1 = "token-server-1"
|
||||
err = backend.Write(srv1URL, token1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write token for server 2 (should NOT overwrite server 1's token)
|
||||
const token2 = "token-server-2"
|
||||
err = backend.Write(srv2URL, token2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify both credentials are stored in the raw format and can
|
||||
// be extracted through the Backend API.
|
||||
rawCredential := readRawKeychainCredential(t, serviceName)
|
||||
|
||||
storedCreds := make(storedCredentials)
|
||||
err = json.Unmarshal(rawCredential, &storedCreds)
|
||||
require.NoError(t, err, "unmarshalling stored credentials")
|
||||
|
||||
// Both credentials should exist
|
||||
require.Len(t, storedCreds, 2)
|
||||
require.Equal(t, token1, storedCreds[srv1URL.Host].APIToken)
|
||||
require.Equal(t, token2, storedCreds[srv2URL.Host].APIToken)
|
||||
|
||||
// Read individual credentials
|
||||
token, err := backend.Read(srv1URL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, token1, token)
|
||||
|
||||
token, err = backend.Read(srv2URL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, token2, token)
|
||||
|
||||
// Cleanup
|
||||
err = backend.Delete(srv1URL)
|
||||
require.NoError(t, err)
|
||||
err = backend.Delete(srv2URL)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFile(t *testing.T) {
|
||||
const (
|
||||
testURL = "http://127.0.0.1:1337"
|
||||
testURL2 = "http://127.0.0.1:1338"
|
||||
)
|
||||
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ReadNonExistent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = backend.Read(srvURL)
|
||||
require.Error(t, err)
|
||||
require.True(t, os.IsNotExist(err))
|
||||
})
|
||||
|
||||
t.Run("WriteAndRead", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write a token
|
||||
const inputToken = "test-token-12345"
|
||||
err = backend.Write(srvURL, inputToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the session file was created
|
||||
sessionFile := config.Root(dir).Session()
|
||||
require.True(t, sessionFile.Exists())
|
||||
|
||||
// Read the token back
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, inputToken, token)
|
||||
})
|
||||
|
||||
t.Run("WriteAndDelete", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write a token
|
||||
const inputToken = "test-token-67890"
|
||||
err = backend.Write(srvURL, inputToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the token was written
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, inputToken, token)
|
||||
|
||||
// Delete the token
|
||||
err = backend.Delete(srvURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the token is gone
|
||||
_, err = backend.Read(srvURL)
|
||||
require.Error(t, err)
|
||||
require.True(t, os.IsNotExist(err))
|
||||
})
|
||||
|
||||
t.Run("DeleteNonExistent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt to delete a non-existent token
|
||||
err = backend.Delete(srvURL)
|
||||
require.Error(t, err)
|
||||
require.True(t, os.IsNotExist(err))
|
||||
})
|
||||
|
||||
t.Run("OverwriteToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write first token
|
||||
const firstToken = "first-token"
|
||||
err = backend.Write(srvURL, firstToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, firstToken, token)
|
||||
|
||||
// Overwrite with second token
|
||||
const secondToken = "second-token"
|
||||
err = backend.Write(srvURL, secondToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err = backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, secondToken, token)
|
||||
})
|
||||
|
||||
t.Run("WriteIgnoresURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
srvURL2, err := url.Parse(testURL2)
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gosec // Write with first URL test token
|
||||
const firstToken = "token-for-url1"
|
||||
err = backend.Write(srvURL, firstToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gosec // Write with second URL - should overwrite
|
||||
const secondToken = "token-for-url2"
|
||||
err = backend.Write(srvURL2, secondToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have the second token (File backend doesn't differentiate by URL)
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, secondToken, token)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//go:build windows
|
||||
|
||||
package sessionstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/danieljoos/wincred"
|
||||
)
|
||||
|
||||
// operatingSystemKeyring implements keyringProvider and uses Windows Credential Manager.
|
||||
// It is largely adapted from the zalando/go-keyring package.
|
||||
type operatingSystemKeyring struct{}
|
||||
|
||||
func (operatingSystemKeyring) Set(service, credential string) error {
|
||||
// password may not exceed 2560 bytes (https://github.com/jaraco/keyring/issues/540#issuecomment-968329967)
|
||||
if len(credential) > 2560 {
|
||||
return ErrSetDataTooBig
|
||||
}
|
||||
|
||||
// service may not exceed 512 bytes (might need more testing)
|
||||
if len(service) >= 512 {
|
||||
return ErrSetDataTooBig
|
||||
}
|
||||
|
||||
// service may not exceed 32k but problems occur before that
|
||||
// so we limit it to 30k
|
||||
if len(service) > 1024*30 {
|
||||
return ErrSetDataTooBig
|
||||
}
|
||||
|
||||
cred := wincred.NewGenericCredential(service)
|
||||
cred.CredentialBlob = []byte(credential)
|
||||
cred.Persist = wincred.PersistLocalMachine
|
||||
return cred.Write()
|
||||
}
|
||||
|
||||
func (operatingSystemKeyring) Get(service string) ([]byte, error) {
|
||||
cred, err := wincred.GetGenericCredential(service)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.ERROR_NOT_FOUND) {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return cred.CredentialBlob, nil
|
||||
}
|
||||
|
||||
func (operatingSystemKeyring) Delete(service string) error {
|
||||
cred, err := wincred.GetGenericCredential(service)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.ERROR_NOT_FOUND) {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
return err
|
||||
}
|
||||
return cred.Delete()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//go:build windows
|
||||
|
||||
package sessionstore_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/danieljoos/wincred"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/sessionstore"
|
||||
)
|
||||
|
||||
func readRawKeychainCredential(t *testing.T, serviceName string) []byte {
|
||||
t.Helper()
|
||||
|
||||
winCred, err := wincred.GetGenericCredential(serviceName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return winCred.CredentialBlob
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
serviceName := keyringTestServiceName(t)
|
||||
backend := sessionstore.NewKeyringWithService(serviceName)
|
||||
t.Cleanup(func() { _ = backend.Delete(srvURL) })
|
||||
|
||||
// Verify no token exists initially
|
||||
_, err = backend.Read(srvURL)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
|
||||
// Write a token
|
||||
const inputToken = "test-token-12345"
|
||||
err = backend.Write(srvURL, inputToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the credential is stored in Windows Credential Manager with correct format
|
||||
winCred, err := wincred.GetGenericCredential(serviceName)
|
||||
require.NoError(t, err, "getting windows credential")
|
||||
|
||||
storedCreds := make(storedCredentials)
|
||||
err = json.Unmarshal(winCred.CredentialBlob, &storedCreds)
|
||||
require.NoError(t, err, "unmarshalling stored credentials")
|
||||
|
||||
// Verify the stored values
|
||||
require.Len(t, storedCreds, 1)
|
||||
cred, ok := storedCreds[srvURL.Host]
|
||||
require.True(t, ok, "credential for URL should exist")
|
||||
require.Equal(t, inputToken, cred.APIToken)
|
||||
require.Equal(t, srvURL.Host, cred.CoderURL)
|
||||
|
||||
// Read the token back
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, inputToken, token)
|
||||
|
||||
// Delete the token
|
||||
err = backend.Delete(srvURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify token is deleted
|
||||
_, err = backend.Read(srvURL)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
@@ -54,6 +54,7 @@ func TestSharingShare(t *testing.T) {
|
||||
MinimalUser: codersdk.MinimalUser{
|
||||
ID: toShareWithUser.ID,
|
||||
Username: toShareWithUser.Username,
|
||||
Name: toShareWithUser.Name,
|
||||
AvatarURL: toShareWithUser.AvatarURL,
|
||||
},
|
||||
Role: codersdk.WorkspaceRole("use"),
|
||||
@@ -103,6 +104,7 @@ func TestSharingShare(t *testing.T) {
|
||||
MinimalUser: codersdk.MinimalUser{
|
||||
ID: toShareWithUser1.ID,
|
||||
Username: toShareWithUser1.Username,
|
||||
Name: toShareWithUser1.Name,
|
||||
AvatarURL: toShareWithUser1.AvatarURL,
|
||||
},
|
||||
Role: codersdk.WorkspaceRoleUse,
|
||||
@@ -111,6 +113,7 @@ func TestSharingShare(t *testing.T) {
|
||||
MinimalUser: codersdk.MinimalUser{
|
||||
ID: toShareWithUser2.ID,
|
||||
Username: toShareWithUser2.Username,
|
||||
Name: toShareWithUser2.Name,
|
||||
AvatarURL: toShareWithUser2.AvatarURL,
|
||||
},
|
||||
Role: codersdk.WorkspaceRoleUse,
|
||||
@@ -155,6 +158,7 @@ func TestSharingShare(t *testing.T) {
|
||||
MinimalUser: codersdk.MinimalUser{
|
||||
ID: toShareWithUser.ID,
|
||||
Username: toShareWithUser.Username,
|
||||
Name: toShareWithUser.Name,
|
||||
AvatarURL: toShareWithUser.AvatarURL,
|
||||
},
|
||||
Role: codersdk.WorkspaceRoleAdmin,
|
||||
|
||||
+47
@@ -109,6 +109,51 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
}
|
||||
},
|
||||
),
|
||||
CompletionHandler: func(inv *serpent.Invocation) []string {
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
res, err := client.Workspaces(inv.Context(), codersdk.WorkspaceFilter{
|
||||
Owner: codersdk.Me,
|
||||
})
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var completions []string
|
||||
var wg sync.WaitGroup
|
||||
for _, ws := range res.Workspaces {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
resources, err := client.TemplateVersionResources(inv.Context(), ws.LatestBuild.TemplateVersionID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var agents []codersdk.WorkspaceAgent
|
||||
for _, resource := range resources {
|
||||
agents = append(agents, resource.Agents...)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(agents) == 1 {
|
||||
completions = append(completions, ws.Name)
|
||||
} else {
|
||||
for _, agent := range agents {
|
||||
completions = append(completions, fmt.Sprintf("%s.%s", ws.Name, agent.Name))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
slices.Sort(completions)
|
||||
return completions
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) (retErr error) {
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
@@ -906,6 +951,8 @@ func GetWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("start workspace with active template version: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Unable to start the workspace with template version from last build. Your workspace has been updated to the current active template version.")
|
||||
default:
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("start workspace with current template version: %w", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("start workspace with current template version: %w", err)
|
||||
|
||||
@@ -2447,3 +2447,99 @@ func tempDirUnixSocket(t *testing.T) string {
|
||||
|
||||
return t.TempDir()
|
||||
}
|
||||
|
||||
func TestSSH_Completion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("SingleAgent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
inv, root := clitest.New(t, "ssh", "")
|
||||
inv.Stdout = &stdout
|
||||
inv.Environ.Set("COMPLETION_MODE", "1")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancel()
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// For single-agent workspaces, the only completion should be the
|
||||
// bare workspace name.
|
||||
output := stdout.String()
|
||||
t.Logf("Completion output: %q", output)
|
||||
require.Contains(t, output, workspace.Name)
|
||||
})
|
||||
|
||||
t.Run("MultiAgent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, store := coderdtest.NewWithDatabase(t, nil)
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
|
||||
r.Username = "multiuser"
|
||||
})
|
||||
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
Name: "multiworkspace",
|
||||
OrganizationID: first.OrganizationID,
|
||||
OwnerID: user.ID,
|
||||
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
||||
return []*proto.Agent{
|
||||
{
|
||||
Name: "agent1",
|
||||
Auth: &proto.Agent_Token{},
|
||||
},
|
||||
{
|
||||
Name: "agent2",
|
||||
Auth: &proto.Agent_Token{},
|
||||
},
|
||||
}
|
||||
}).Do()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
inv, root := clitest.New(t, "ssh", "")
|
||||
inv.Stdout = &stdout
|
||||
inv.Environ.Set("COMPLETION_MODE", "1")
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancel()
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// For multi-agent workspaces, completions should include the
|
||||
// workspace.agent format but NOT the bare workspace name.
|
||||
output := stdout.String()
|
||||
t.Logf("Completion output: %q", output)
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
require.NotContains(t, lines, r.Workspace.Name)
|
||||
require.Contains(t, output, r.Workspace.Name+".agent1")
|
||||
require.Contains(t, output, r.Workspace.Name+".agent2")
|
||||
})
|
||||
|
||||
t.Run("NetworkError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
inv, _ := clitest.New(t, "ssh", "")
|
||||
inv.Stdout = &stdout
|
||||
inv.Environ.Set("COMPLETION_MODE", "1")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
output := stdout.String()
|
||||
require.Empty(t, output)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ func buildNumberOption(n *int64) serpent.Option {
|
||||
|
||||
func (r *RootCmd) statePush() *serpent.Command {
|
||||
var buildNumber int64
|
||||
var noBuild bool
|
||||
cmd := &serpent.Command{
|
||||
Use: "push <workspace> <file>",
|
||||
Short: "Push a Terraform state file to a workspace.",
|
||||
@@ -126,6 +127,16 @@ func (r *RootCmd) statePush() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
if noBuild {
|
||||
// Update state directly without triggering a build.
|
||||
err = client.UpdateWorkspaceBuildState(inv.Context(), build.ID, state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "State updated successfully.")
|
||||
return nil
|
||||
}
|
||||
|
||||
build, err = client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
TemplateVersionID: build.TemplateVersionID,
|
||||
Transition: build.Transition,
|
||||
@@ -139,6 +150,12 @@ func (r *RootCmd) statePush() *serpent.Command {
|
||||
}
|
||||
cmd.Options = serpent.OptionSet{
|
||||
buildNumberOption(&buildNumber),
|
||||
{
|
||||
Flag: "no-build",
|
||||
FlagShorthand: "n",
|
||||
Description: "Update the state without triggering a workspace build. Useful for state-only migrations.",
|
||||
Value: serpent.BoolOf(&noBuild),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -158,4 +160,49 @@ func TestStatePush(t *testing.T) {
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("NoBuild", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, store := coderdtest.NewWithDatabase(t, nil)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
templateAdmin, taUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
initialState := []byte("initial state")
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: taUser.ID,
|
||||
}).
|
||||
Seed(database.WorkspaceBuild{ProvisionerState: initialState}).
|
||||
Do()
|
||||
wantState := []byte("updated state")
|
||||
stateFile, err := os.CreateTemp(t.TempDir(), "")
|
||||
require.NoError(t, err)
|
||||
_, err = stateFile.Write(wantState)
|
||||
require.NoError(t, err)
|
||||
err = stateFile.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
inv, root := clitest.New(t, "state", "push", "--no-build", r.Workspace.Name, stateFile.Name())
|
||||
clitest.SetupConfig(t, templateAdmin, root)
|
||||
var stdout bytes.Buffer
|
||||
inv.Stdout = &stdout
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, stdout.String(), "State updated successfully")
|
||||
|
||||
// Verify the state was updated by pulling it.
|
||||
inv, root = clitest.New(t, "state", "pull", r.Workspace.Name)
|
||||
var gotState bytes.Buffer
|
||||
inv.Stdout = &gotState
|
||||
clitest.SetupConfig(t, templateAdmin, root)
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wantState, bytes.TrimSpace(gotState.Bytes()))
|
||||
|
||||
// Verify no new build was created.
|
||||
builds, err := store.GetWorkspaceBuildsByWorkspaceID(dbauthz.AsSystemRestricted(context.Background()), database.GetWorkspaceBuildsByWorkspaceIDParams{
|
||||
WorkspaceID: r.Workspace.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, builds, 1, "expected only the initial build, no new build should be created")
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user