Compare commits

..

50 Commits

Author SHA1 Message Date
Charlie Voiselle eee13c42a4 docs(cli): reference --oidc-group-mapping flag name instead of 'legacy'
Issue: Used internal nomenclature instead of user-facing flag name

The previous fix referenced 'legacy group name mapping' but users don't
know what that means - it's an internal implementation detail. Users
configure this via the --oidc-group-mapping flag.

Changed to: 'This filter is applied after the oidc-group-mapping.'

This directly references the flag name users would actually use, making
the relationship clear and actionable. Users can now understand:
- The regex filter applies to group names
- Group names may have been transformed by --oidc-group-mapping first
- They need to write regex patterns that match the mapped names

Example: If --oidc-group-mapping transforms 'developers' to 'dev-team',
the regex in --oidc-group-regex-filter will match against 'dev-team'.
2026-02-09 16:14:00 -05:00
Charlie Voiselle 65b48c0f84 docs(cli): fix help text for --oidc-group-regex-filter (clarify mapping order)
Issue: Removed ordering information when it was actually helpful

The previous correction removed the sentence about filter order to avoid
confusion, but this actually made the description LESS clear. Users need
to understand that the regex filter operates on group names AFTER any
legacy name mapping has been applied.

Example: If IdP sends 'developers' and LegacyNameMapping renames it to
'dev-team', the regex filter will match against 'dev-team', not 'developers'.

Changed to: 'This filter is applied after legacy group name mapping.'

This clarifies:
1. It's the LEGACY mapping (name→name) not the new Mapping (name→IDs)
2. The regex operates on potentially-renamed group names
3. The filter happens before the final ID mapping

Code reference: coderd/idpsync/group.go lines 379-398
- Line 380: LegacyNameMapping (name → name)
- Line 386: RegexFilter (on the potentially renamed name)
- Line 392: Mapping (name → []uuid.UUID)
2026-02-09 16:13:59 -05:00
Charlie Voiselle 30cdf29e52 docs(cli): fix help text for --oidc-group-regex-filter (final correction)
Issue: Previous description incorrectly stated filter order

The correction commit stated 'This filter is applied after the group mapping'
but the actual code order in coderd/idpsync/group.go lines 379-398 shows:
1. Legacy group mappings
2. Regex filter
3. (New) group mapping

Since the filter order is complex and the description was causing confusion,
removed the last sentence entirely. The first two sentences clearly explain
what the flag does without introducing incorrect ordering claims.

This follows the verification report's recommendation to remove the
confusing last sentence.
2026-02-09 16:13:59 -05:00
Charlie Voiselle b1d2bb6d71 docs(cli): fix help text for --external-auth-providers
Issue: Clarity - vague description

Changed 'External Authentication providers.' to 'Configure external authentication providers for Git and other services.' to explain what these providers are actually used for.
2026-02-09 16:13:59 -05:00
Charlie Voiselle 94bad2a956 docs(cli): fix help text for --workspace-prebuilds-reconciliation-backoff-lookback-period
Issue: Clarity - unclear purpose

Changed 'Interval to look back to determine number of failed prebuilds, which influences backoff' to 'Time period to look back when counting failed prebuilds to calculate the backoff delay' to clarify this determines the time window for counting failures.
2026-02-09 16:13:59 -05:00
Charlie Voiselle 111714c7ed docs(cli): fix help text for --workspace-prebuilds-reconciliation-backoff-interval
Issue: Clarity - confusing wording about backoff behavior

Changed 'Interval to increase reconciliation backoff by when prebuilds fail, after which a retry attempt is made' to 'Amount of time to add to the reconciliation backoff delay after each prebuild failure, before the next retry attempt is made' to clarify this is an incremental addition to the backoff delay.
2026-02-09 16:13:58 -05:00
Charlie Voiselle 1f9c516c5c docs(cli): fix help text for --workspace-prebuilds-failure-hard-limit
Issue: Clarity - unclear what 'hits the hard limit' means

Changed 'before a preset hits the hard limit' to 'before a preset is considered hard-limited and stops automatic prebuild creation' to explain what actually happens when the limit is reached.
2026-02-09 16:13:58 -05:00
Charlie Voiselle 3645c65bb2 docs(cli): fix help text for --workspace-hostname-suffix
Issue: Clarity - incomplete example hostname

Changed 'in SSH config and Coder Connect on Coder Desktop' to 'for SSH connections and Coder Connect' for conciseness. Updated the example from 'myworkspace.coder' to the full format 'agent.workspace.owner.coder' to show the complete hostname structure.
2026-02-09 16:13:58 -05:00
Charlie Voiselle d3d2d2fb1e docs(cli): fix help text for --workspace-agent-logs-retention
Issue: Clarity - ambiguous scope

Changed 'Logs from the latest build are always retained' to 'Logs from the latest build for each workspace are always retained' to clarify that this applies per-workspace, not just one latest build globally.
2026-02-09 16:13:58 -05:00
Charlie Voiselle 086fb1f5d5 docs(cli): fix help text for --block-direct-connections
Issue: Clarity - imprecise wording about STUN behavior

Clarified that 'Workspace agents' (not 'Workspaces') reach out to STUN servers, changed 'get their address' to 'discover their address', and simplified 'until they are restarted after this change has been made' to just 'until they are restarted'.
2026-02-09 16:13:58 -05:00
Charlie Voiselle a73a535a5b docs(cli): fix help text for --proxy-health-interval
Issue: Clarity - awkward phrasing

Changed 'in which coderd should be checking' to 'at which coderd checks' for more concise, natural phrasing.
2026-02-09 16:13:57 -05:00
Charlie Voiselle 96e01c3018 docs(cli): fix help text for --email-tls-cert-key-file
Issue: Clarity - vague description

Changed 'Certificate key file to use' to 'Private key file for the client certificate' to clarify this is the private key that pairs with --email-tls-cert-file.
2026-02-09 16:13:57 -05:00
Charlie Voiselle 6b10a0359b docs(cli): fix help text for --email-tls-cert-file
Issue: Clarity - vague description

Changed 'Certificate file to use' to 'Client certificate file for mutual TLS authentication' to clarify what this certificate is for and when it's needed.
2026-02-09 16:13:57 -05:00
Charlie Voiselle b62583ad4b docs(cli): fix help text for --oidc-user-role-default
Issue: Clarity - ambiguous relationship between defaults and synced roles

Added 'in addition to synced roles' to clarify that these defaults don't replace synced roles. Also clarified that 'member' is always assigned 'regardless of this setting' to avoid confusion about whether this setting affects the member role.
2026-02-09 16:13:57 -05:00
Charlie Voiselle 3d6727a2cb docs(cli): fix help text for --oidc-group-field
Issue: Clarity - unclear structure

Reordered to put the primary purpose first: 'OIDC claim field to use as the user's groups' before the conditional requirement. This makes the description more scannable and understandable.
2026-02-09 16:13:56 -05:00
Charlie Voiselle b163962a14 docs(cli): fix help text for --aibridge-circuit-breaker-interval
Issue: Clarity - confusing technical jargon

Changed 'Cyclic period of the closed state for clearing internal failure counts' to 'Time window for counting failures before resetting the failure count in the closed state' to explain what the interval actually does in clearer terms.
2026-02-09 16:13:56 -05:00
Charlie Voiselle 9aca4ea27c docs(cli): fix help text for --aibridge-circuit-breaker-enabled
Issue: Clarity - ambiguous error code description

Changed '(429, 503, 529 overloaded)' to '(HTTP 429, 503, 529)' and added 'and overload errors' to clarify that these are HTTP status codes and what they represent.
2026-02-09 16:13:56 -05:00
Charlie Voiselle b0c10131ea docs(cli): fix help text for --aibridge-retention
Issue: Clarity - wordy phrasing

Simplified 'Length of time to retain data such as interceptions and all related records (token, prompt, tool use)' to 'How long to retain AI Bridge data including interceptions, tokens, prompts, and tool usage records' for more natural, clearer phrasing.
2026-02-09 16:13:56 -05:00
Charlie Voiselle c8c7e13e96 docs(cli): fix help text for --aibridge-inject-coder-mcp-tools
Issue: Clarity - awkward phrasing and formatting

Changed 'Whether to inject' to 'Enable injection of' for consistency with other boolean flags. Simplified the requirements clause and changed double quotes to single quotes for consistency.
2026-02-09 16:13:55 -05:00
Charlie Voiselle 249b7ea38e docs(cli): fix help text for --aibridge-enabled
Issue: Clarity - unclear technical jargon

Changed 'Whether to start an in-memory aibridged instance' to 'Enable the embedded AI Bridge service to intercept and record AI provider requests' to explain what the feature actually does in user-friendly terms.
2026-02-09 16:13:55 -05:00
Charlie Voiselle 1333096e25 docs(cli): fix help text for --oidc-group-regex-filter (correction)
Issue: Previous fix introduced confusing circular wording

The previous commit incorrectly changed the ending to 'after the group mapping and regex filter' which is nonsensical since this flag configures THE regex filter itself. Reverted to the correct wording: 'after the group mapping'.

The only valid changes from the original are:
- Added comma after 'If provided'
- Simplified 'allows for filtering' to 'allows filtering'
2026-02-09 16:13:55 -05:00
Charlie Voiselle 54bc9324dd docs(cli): fix help text for --samesite-auth-cookie
Issue: Grammar - missing word

Added missing 'if' to read 'Controls if the SameSite property is set' instead of 'Controls the SameSite property is set'.
2026-02-09 16:13:55 -05:00
Charlie Voiselle 109e5f2b19 docs(cli): fix help text for --enable-authz-recordings
Issue: Grammar - acronym capitalization

Capitalized 'API' (Application Programming Interface) - should always be uppercase.
2026-02-09 16:13:55 -05:00
Charlie Voiselle ee176b4207 docs(cli): fix help text for --ssh-config-options
Issue: Grammar - missing space after period

Added missing space after period between sentences: 'commas.' + 'Using' → 'commas. ' + 'Using'.
2026-02-09 16:13:54 -05:00
Charlie Voiselle 7e1e16be33 docs(cli): fix help text for --prometheus-address
Issue: Grammar - proper noun capitalization

Capitalized 'Prometheus' as it's a proper noun.
2026-02-09 16:13:54 -05:00
Charlie Voiselle 5cfe8082ce docs(cli): fix help text for --prometheus-enable
Issue: Grammar - proper noun capitalization

Capitalized 'Prometheus' as it's a proper noun (the name of the monitoring system).
2026-02-09 16:13:54 -05:00
Charlie Voiselle 6b7f672834 docs(cli): fix help text for --allow-custom-quiet-hours
Issue: Grammar - awkward phrasing

Changed 'for workspaces to stop in' to 'for when workspaces are stopped' for more natural phrasing.
2026-02-09 16:13:53 -05:00
Charlie Voiselle c55f6252a1 docs(cli): fix help text for --tls-client-ca-file
Issue: Grammar - missing article

Added missing article 'the' before 'client' to read 'authenticity of the client'.
2026-02-09 16:13:53 -05:00
Charlie Voiselle 842553b677 docs(cli): fix help text for --tls-ciphers
Issue: Grammar - missing verb

Fixed missing 'are' in 'that allowed to be used' → 'that are allowed to be used'.
2026-02-09 16:13:53 -05:00
Charlie Voiselle 05a771ba77 docs(cli): fix help text for --derp-server-stun-addresses
Issue: Grammar - incorrect possessive

Fixed "it's" (contraction of "it is") → "its" (possessive). Should be 'Each STUN server will get its own DERP region'.
2026-02-09 16:13:53 -05:00
Charlie Voiselle 70a0d42e65 docs(cli): fix help text for --derp-server-region-name
Issue: Grammar - malformed sentence

Fixed malformed sentence 'Region name that for' → 'Region name to use for'. The original was missing a verb.
2026-02-09 16:13:52 -05:00
Charlie Voiselle 6b1d73b466 docs(cli): fix help text for --notifications-store-sync-buffer-size
Issue: Grammar - typo

Fixed typo: 'change' → 'chance'. Same typo as in --notifications-store-sync-interval.
2026-02-09 16:13:52 -05:00
Charlie Voiselle d7b9596145 docs(cli): fix help text for --notifications-store-sync-interval
Issue: Grammar - typo

Fixed typo: 'change' → 'chance'. The sentence should read 'the lower the chance of state inconsistency'.
2026-02-09 16:13:52 -05:00
Charlie Voiselle 7a0aa1a40a docs(cli): fix help text for --oidc-signups-disabled-text
Issue: Grammar - awkward phrasing

Changed 'The custom text to show on the error page informing about disabled OIDC signups' to 'Custom text to show on the error page when OIDC signups are disabled' for clearer, more direct phrasing. Removed unnecessary 'The' article.
2026-02-09 16:13:52 -05:00
Charlie Voiselle 4d8ea43e11 docs(cli): fix help text for --oidc-icon-url
Issue: Grammar - redundant phrasing

Changed 'URL pointing to the icon' to 'URL of the icon'. The phrase 'pointing to' is redundant since a URL inherently points to a resource.
2026-02-09 16:13:52 -05:00
Charlie Voiselle 6fddae98f6 docs(cli): fix help text for --oidc-group-regex-filter
Issue: Grammar - missing comma + simplification + filter order clarification

Added missing comma after 'If provided'. Simplified 'allows for filtering' to 'allows filtering'. Clarified filter order to match the actual implementation.
2026-02-09 16:13:51 -05:00
Charlie Voiselle e33fbb6087 docs(cli): fix help text for --oidc-group-mapping
Issue: Grammar - subject-verb agreement + awkward phrasing

Changed 'the group in Coder it should map to' to 'the groups in Coder they should map to' for proper plural agreement. Also simplified 'for when' to 'when'.
2026-02-09 16:13:51 -05:00
Charlie Voiselle 2337393e13 docs(cli): fix help text for --oidc-client-cert-file
Issue: Grammar - incorrect acronym capitalization

Changed 'Pem' to 'PEM', 'oauth2' to 'OAuth2', and 'x509' to 'X.509'. These are standard capitalizations for these acronyms and standards.
2026-02-09 16:13:51 -05:00
Charlie Voiselle d7357a1b0a docs(cli): fix help text for --oidc-client-key-file
Issue: Grammar - incorrect acronym capitalization

Changed 'Pem' to 'PEM' (Privacy Enhanced Mail), 'oauth2' to 'OAuth2', and 'IDP' to 'IdP' (Identity Provider). These are standard capitalizations for these acronyms.
2026-02-09 16:13:51 -05:00
Charlie Voiselle afbf1af29c docs(cli): fix help text for --oauth2-github-allow-everyone
Issue: Grammar - unclear and run-on sentence

Changed 'Allow all logins, setting this option means...' to 'Allow all GitHub users to authenticate. When enabled, allowed orgs and teams must be empty.' This separates the run-on sentence and clarifies what 'all logins' means (all GitHub users).
2026-02-09 16:13:50 -05:00
Charlie Voiselle 1d834c747c docs(cli): fix help text for --aibridge-circuit-breaker-failure-threshold
Issue: Grammar - subject-verb agreement

Changed 'triggers' to 'trigger' for correct subject-verb agreement. 'Number' is the subject, which takes the singular form, but 'failures' is the head of the relative clause 'that trigger...', making 'trigger' (plural) correct.
2026-02-09 16:13:50 -05:00
Charlie Voiselle a80edec752 docs(cli): fix help text for --aibridge-bedrock-access-key-secret
Issue: Grammar - wordy and redundant phrasing

Simplified from 'The access key secret to use with the access key to authenticate against' to 'AWS secret access key for authenticating with'. Uses standard AWS terminology and eliminates redundancy.
2026-02-09 16:13:50 -05:00
Charlie Voiselle 2a6473e8c6 docs(cli): fix help text for --aibridge-bedrock-access-key
Issue: Grammar - awkward phrasing

Changed 'The access key to authenticate against' to 'AWS access key for authenticating with' for consistency and clarity. Uses standard AWS terminology.
2026-02-09 16:13:50 -05:00
Charlie Voiselle 1f9c0b9b7f docs(cli): fix help text for --aibridge-anthropic-key
Issue: Grammar - awkward phrasing

Changed 'The key to authenticate against' to 'API key for authenticating with' for consistency with --aibridge-openai-key and more natural phrasing.
2026-02-09 16:13:49 -05:00
Charlie Voiselle 5494afabd8 docs(cli): fix help text for --aibridge-openai-key
Issue: Grammar - awkward phrasing

Changed 'The key to authenticate against' to 'API key for authenticating with' for more natural, concise phrasing. This matches standard API documentation conventions.
2026-02-09 16:13:49 -05:00
Charlie Voiselle 07c6e86a50 docs(cli): fix help text for --notifications-email-hello
Issue: Factually incorrect description of SMTP HELO/EHLO (deprecated alias)

Same issue as --email-hello. This is a deprecated alias but still needs the correct description. The HELO/EHLO command identifies the client to the server, not the server itself.

Fix: Clarified this identifies 'this client to the SMTP server'.
2026-02-09 16:13:49 -05:00
Charlie Voiselle b543821a1c docs(cli): fix help text for --email-hello
Issue: Factually incorrect description of SMTP HELO/EHLO

The description incorrectly stated this identifies 'the SMTP server' when it actually identifies the CLIENT to the server. The HELO/EHLO command is how the client introduces itself to the SMTP server during connection.

Fix: Clarified this identifies 'this client to the SMTP server' which accurately reflects the SMTP protocol.
2026-02-09 16:13:49 -05:00
Charlie Voiselle e8b7045a9b docs(cli): fix help text for --pprof-enable
Issue: Factually incorrect terminology

The description incorrectly stated pprof serves 'metrics' when it actually serves profiling data (CPU profiles, memory profiles, goroutines, etc.). Metrics are Prometheus's domain, not pprof's.

Fix: Changed 'metrics' to 'profiling endpoints' to accurately describe what pprof provides.
2026-02-09 16:13:48 -05:00
Charlie Voiselle 2571089528 docs(cli): fix help text for --oidc-user-role-mapping
Issue: Factually incorrect (confuses roles with groups) + grammar error

The description incorrectly stated this maps to 'groups in Coder' when it actually maps to site ROLES (member, admin, etc.). Also had a grammar error: 'will ignored' should be 'will be ignored'.

Fix: Corrected to clarify this maps OIDC role names to Coder role names, and fixed the grammar error.
2026-02-09 16:13:48 -05:00
Charlie Voiselle 1fb733fe1e docs(cli): fix help text for --oidc-allowed-groups
Issue: Factually incorrect filter order

The description incorrectly stated that the check is applied 'after the group mapping and before the regex filter'. This is wrong.

Fix: Updated to reflect actual behavior where the check is applied BEFORE any group mapping or filtering. Also clarified the positive case (users WITH at least one matching group are allowed) instead of the confusing double-negative phrasing.
2026-02-09 16:13:48 -05:00
357 changed files with 6144 additions and 10942 deletions
-4
View File
@@ -1,4 +0,0 @@
# All artifacts of the build processed are dumped here.
# Ignore it for docker context, as all Dockerfiles should build their own
# binaries.
build
+1 -1
View File
@@ -4,7 +4,7 @@ description: |
inputs:
version:
description: "The Go version to use."
default: "1.25.7"
default: "1.25.6"
use-preinstalled-go:
description: "Whether to use preinstalled Go."
default: "false"
+1 -1
View File
@@ -7,5 +7,5 @@ runs:
- name: Install Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
with:
terraform_version: 1.14.5
terraform_version: 1.14.1
terraform_wrapper: false
+17 -25
View File
@@ -35,7 +35,7 @@ jobs:
tailnet-integration: ${{ steps.filter.outputs.tailnet-integration }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
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@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -247,7 +247,7 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -272,7 +272,7 @@ jobs:
if: ${{ !cancelled() }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -329,7 +329,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -381,7 +381,7 @@ jobs:
- windows-2022
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -489,14 +489,6 @@ jobs:
# macOS will output "The default interactive shell is now zsh" intermittently in CI.
touch ~/.bash_profile && echo "export BASH_SILENCE_DEPRECATION_WARNING=1" >> ~/.bash_profile
- name: Increase PTY limit (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
# Increase PTY limit to avoid exhaustion during tests.
# Default is 511; 999 is the maximum value on CI runner.
sudo sysctl -w kern.tty.ptmx_max=999
- name: Test with PostgreSQL Database (Linux)
if: runner.os == 'Linux'
uses: ./.github/actions/test-go-pg
@@ -586,7 +578,7 @@ jobs:
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -648,7 +640,7 @@ jobs:
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -720,7 +712,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -747,7 +739,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -780,7 +772,7 @@ jobs:
name: ${{ matrix.variant.name }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -860,7 +852,7 @@ jobs:
if: needs.changes.outputs.site == 'true' || needs.changes.outputs.ci == 'true'
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -941,7 +933,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -1013,7 +1005,7 @@ jobs:
if: always()
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -1128,7 +1120,7 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -1183,7 +1175,7 @@ jobs:
IMAGE: ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -1580,7 +1572,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@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+3 -3
View File
@@ -36,7 +36,7 @@ jobs:
verdict: ${{ steps.check.outputs.verdict }} # DEPLOY or NOOP
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
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@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -146,7 +146,7 @@ jobs:
needs: deploy
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+3 -3
View File
@@ -38,7 +38,7 @@ jobs:
if: github.repository_owner == 'coder'
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -58,11 +58,11 @@ jobs:
run: mkdir base-build-context
- name: Install depot.dev CLI
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# This uses OIDC authentication, so no auth variables are required.
- name: Build base Docker image via depot.dev
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
uses: depot/build-push-action@9785b135c3c76c33db102e45be96a25ab55cd507 # v1.16.2
with:
project: wl5hnrrkns
context: base-build-context
+4 -4
View File
@@ -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@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -75,7 +75,7 @@ jobs:
BRANCH_NAME: ${{ steps.branch-name.outputs.current_branch }}
- name: Set up Depot CLI
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
@@ -88,7 +88,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Build and push Non-Nix image
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
uses: depot/build-push-action@9785b135c3c76c33db102e45be96a25ab55cd507 # v1.16.2
with:
project: b4q6ltmpzh
token: ${{ secrets.DEPOT_TOKEN }}
@@ -125,7 +125,7 @@ jobs:
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- windows-2022
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+5 -5
View File
@@ -39,7 +39,7 @@ jobs:
PR_OPEN: ${{ steps.check_pr.outputs.pr_open }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -76,7 +76,7 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -184,7 +184,7 @@ jobs:
pull-requests: write # needed for commenting on PRs
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -228,7 +228,7 @@ jobs:
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -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@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+6 -6
View File
@@ -158,7 +158,7 @@ jobs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -386,12 +386,12 @@ jobs:
- name: Install depot.dev CLI
if: steps.image-base-tag.outputs.tag != ''
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# This uses OIDC authentication, so no auth variables are required.
- name: Build base Docker image via depot.dev
if: steps.image-base-tag.outputs.tag != ''
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
uses: depot/build-push-action@9785b135c3c76c33db102e45be96a25ab55cd507 # v1.16.2
with:
project: wl5hnrrkns
context: base-build-context
@@ -796,7 +796,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@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -872,7 +872,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -965,7 +965,7 @@ jobs:
if: ${{ !inputs.dry_run }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+2 -2
View File
@@ -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@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -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@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -96,7 +96,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -120,7 +120,7 @@ jobs:
actions: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+1 -1
View File
@@ -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@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+2 -6
View File
@@ -866,8 +866,7 @@ site/src/theme/icons.json: site/node_modules/.installed $(wildcard scripts/gensi
(cd site/ && pnpm exec biome format --write src/theme/icons.json)
touch "$@"
examples/examples.gen.json: scripts/examplegen/main.go examples/examples.go scripts/fetch-registry-templates.sh
bash scripts/fetch-registry-templates.sh
examples/examples.gen.json: scripts/examplegen/main.go examples/examples.go $(shell find ./examples/templates)
go run ./scripts/examplegen/main.go > examples/examples.gen.json
touch "$@"
@@ -910,10 +909,7 @@ site/src/api/countriesGenerated.ts: site/node_modules/.installed scripts/typegen
(cd site/ && pnpm exec biome format --write src/api/countriesGenerated.ts)
touch "$@"
scripts/metricsdocgen/generated_metrics: $(GO_SRC_FILES)
go run ./scripts/metricsdocgen/scanner > $@
docs/admin/integrations/prometheus.md: node_modules/.installed scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics scripts/metricsdocgen/generated_metrics
docs/admin/integrations/prometheus.md: node_modules/.installed scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics
go run scripts/metricsdocgen/main.go
pnpm exec markdownlint-cli2 --fix ./docs/admin/integrations/prometheus.md
pnpm exec markdown-table-formatter ./docs/admin/integrations/prometheus.md
+2 -10
View File
@@ -111,12 +111,6 @@ type Client interface {
ConnectRPC28(ctx context.Context) (
proto.DRPCAgentClient28, tailnetproto.DRPCTailnetClient28, error,
)
// ConnectRPC28WithRole is like ConnectRPC28 but sends an explicit
// role query parameter to the server. The workspace agent should
// use role "agent" to enable connection monitoring.
ConnectRPC28WithRole(ctx context.Context, role string) (
proto.DRPCAgentClient28, tailnetproto.DRPCTailnetClient28, error,
)
tailnet.DERPMapRewriter
agentsdk.RefreshableSessionTokenProvider
}
@@ -1003,10 +997,8 @@ func (a *agent) run() (retErr error) {
return xerrors.Errorf("refresh token: %w", err)
}
// ConnectRPC returns the dRPC connection we use for the Agent and Tailnet v2+ APIs.
// We pass role "agent" to enable connection monitoring on the server, which tracks
// the agent's connectivity state (first_connected_at, last_connected_at, disconnected_at).
aAPI, tAPI, err := a.client.ConnectRPC28WithRole(a.hardCtx, "agent")
// ConnectRPC returns the dRPC connection we use for the Agent and Tailnet v2+ APIs
aAPI, tAPI, err := a.client.ConnectRPC28(a.hardCtx)
if err != nil {
return err
}
-6
View File
@@ -124,12 +124,6 @@ func (c *Client) Close() {
c.derpMapOnce.Do(func() { close(c.derpMapUpdates) })
}
func (c *Client) ConnectRPC28WithRole(ctx context.Context, _ string) (
agentproto.DRPCAgentClient28, proto.DRPCTailnetClient28, error,
) {
return c.ConnectRPC28(ctx)
}
func (c *Client) ConnectRPC28(ctx context.Context) (
agentproto.DRPCAgentClient28, proto.DRPCTailnetClient28, error,
) {
+21 -25
View File
@@ -3,11 +3,11 @@
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true,
"defaultBranch": "main",
"defaultBranch": "main"
},
"files": {
"includes": ["**", "!**/pnpm-lock.yaml"],
"ignoreUnknown": true,
"ignoreUnknown": true
},
"linter": {
"rules": {
@@ -15,18 +15,18 @@
"noSvgWithoutTitle": "off",
"useButtonType": "off",
"useSemanticElements": "off",
"noStaticElementInteractions": "off",
"noStaticElementInteractions": "off"
},
"correctness": {
"noUnusedImports": "warn",
"correctness": {
"noUnusedImports": "warn",
"useUniqueElementIds": "off", // TODO: This is new but we want to fix it
"noNestedComponentDefinitions": "off", // TODO: Investigate, since it is used by shadcn components
"noUnusedVariables": {
"level": "warn",
"noUnusedVariables": {
"level": "warn",
"options": {
"ignoreRestSiblings": true,
},
},
"ignoreRestSiblings": true
}
}
},
"style": {
"noNonNullAssertion": "off",
@@ -45,10 +45,6 @@
"level": "error",
"options": {
"paths": {
"react": {
"message": "React 19 no longer requires forwardRef. Use ref as a prop instead.",
"importNames": ["forwardRef"],
},
// "@mui/material/Alert": "Use components/Alert/Alert instead.",
// "@mui/material/AlertTitle": "Use components/Alert/Alert instead.",
// "@mui/material/Autocomplete": "Use shadcn/ui Combobox instead.",
@@ -115,10 +111,10 @@
"@emotion/styled": "Use Tailwind CSS instead.",
// "@emotion/cache": "Use Tailwind CSS instead.",
// "components/Stack/Stack": "Use Tailwind flex utilities instead (e.g., <div className='flex flex-col gap-4'>).",
"lodash": "Use lodash/<name> instead.",
},
},
},
"lodash": "Use lodash/<name> instead."
}
}
}
},
"suspicious": {
"noArrayIndexKey": "off",
@@ -129,14 +125,14 @@
"noConsole": {
"level": "error",
"options": {
"allow": ["error", "info", "warn"],
},
},
"allow": ["error", "info", "warn"]
}
}
},
"complexity": {
"noImportantStyles": "off", // TODO: check and fix !important styles
},
},
"noImportantStyles": "off" // TODO: check and fix !important styles
}
}
},
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json"
}
+4 -15
View File
@@ -884,27 +884,16 @@ func (o *OrganizationContext) Selected(inv *serpent.Invocation, client *codersdk
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
return org.Name == o.FlagSelect || org.ID.String() == o.FlagSelect
})
if index >= 0 {
return orgs[index], nil
}
// Not in membership list - try direct fetch.
// This allows site-wide admins (e.g., Owners) to use orgs they aren't
// members of.
org, err := client.OrganizationByName(inv.Context(), o.FlagSelect)
if err != nil {
if index < 0 {
var names []string
for _, org := range orgs {
names = append(names, org.Name)
}
var sdkErr *codersdk.Error
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
return codersdk.Organization{}, xerrors.Errorf("organization %q not found, are you sure you are a member of this organization? "+
"Valid options for '--org=' are [%s].", o.FlagSelect, strings.Join(names, ", "))
}
return codersdk.Organization{}, xerrors.Errorf("get organization %q: %w", o.FlagSelect, err)
return codersdk.Organization{}, xerrors.Errorf("organization %q not found, are you sure you are a member of this organization? "+
"Valid options for '--org=' are [%s].", o.FlagSelect, strings.Join(names, ", "))
}
return org, nil
return orgs[index], nil
}
if len(orgs) == 1 {
+1 -8
View File
@@ -95,7 +95,6 @@ import (
"github.com/coder/coder/v2/coderd/webpush"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/coderd/workspacestats"
"github.com/coder/coder/v2/coderd/wsbuilder"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/drpcsdk"
"github.com/coder/coder/v2/cryptorand"
@@ -936,12 +935,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
options.StatsBatcher = batcher
defer closeBatcher()
wsBuilderMetrics, err := wsbuilder.NewMetrics(options.PrometheusRegistry)
if err != nil {
return xerrors.Errorf("failed to register workspace builder metrics: %w", err)
}
options.WorkspaceBuilderMetrics = wsBuilderMetrics
// Manage notifications.
var (
notificationsCfg = options.DeploymentValues.Notifications
@@ -1125,7 +1118,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
autobuildTicker := time.NewTicker(vals.AutobuildPollInterval.Value())
defer autobuildTicker.Stop()
autobuildExecutor := autobuild.NewExecutor(
ctx, options.Database, options.Pubsub, coderAPI.FileCache, options.PrometheusRegistry, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, coderAPI.BuildUsageChecker, logger, autobuildTicker.C, options.NotificationsEnqueuer, coderAPI.Experiments, coderAPI.WorkspaceBuilderMetrics)
ctx, options.Database, options.Pubsub, coderAPI.FileCache, options.PrometheusRegistry, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, coderAPI.BuildUsageChecker, logger, autobuildTicker.C, options.NotificationsEnqueuer, coderAPI.Experiments)
autobuildExecutor.Run()
jobReaperTicker := time.NewTicker(vals.JobReaperDetectorInterval.Value())
-2
View File
@@ -17,8 +17,6 @@ func (r *RootCmd) tasksCommand() *serpent.Command {
r.taskDelete(),
r.taskList(),
r.taskLogs(),
r.taskPause(),
r.taskResume(),
r.taskSend(),
r.taskStatus(),
},
+10 -5
View File
@@ -41,7 +41,8 @@ func Test_TaskLogs_Golden(t *testing.T) {
t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
userClient := client // user already has access to their own workspace
inv, root := clitest.New(t, "task", "logs", task.Name, "--output", "json")
output := clitest.Capture(inv)
@@ -64,7 +65,8 @@ func Test_TaskLogs_Golden(t *testing.T) {
t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
userClient := client
inv, root := clitest.New(t, "task", "logs", task.ID.String(), "--output", "json")
output := clitest.Capture(inv)
@@ -87,7 +89,8 @@ func Test_TaskLogs_Golden(t *testing.T) {
t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
userClient := client
inv, root := clitest.New(t, "task", "logs", task.ID.String())
output := clitest.Capture(inv)
@@ -141,7 +144,8 @@ func Test_TaskLogs_Golden(t *testing.T) {
t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsErr(assert.AnError))
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsErr(assert.AnError))
userClient := client
inv, root := clitest.New(t, "task", "logs", task.ID.String())
clitest.SetupConfig(t, userClient, root)
@@ -197,7 +201,8 @@ func Test_TaskLogs_Golden(t *testing.T) {
t.Run("SnapshotWithoutLogs_NoSnapshotCaptured", func(t *testing.T) {
t.Parallel()
userClient, task := setupCLITaskTestWithoutSnapshot(t, codersdk.TaskStatusPaused)
client, task := setupCLITaskTestWithoutSnapshot(t, codersdk.TaskStatusPaused)
userClient := client
inv, root := clitest.New(t, "task", "logs", task.Name)
output := clitest.Capture(inv)
-90
View File
@@ -1,90 +0,0 @@
package cli
import (
"fmt"
"time"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
func (r *RootCmd) taskPause() *serpent.Command {
cmd := &serpent.Command{
Use: "pause <task>",
Short: "Pause a task",
Long: FormatExamples(
Example{
Description: "Pause a task by name",
Command: "coder task pause my-task",
},
Example{
Description: "Pause another user's task",
Command: "coder task pause alice/my-task",
},
Example{
Description: "Pause a task without confirmation",
Command: "coder task pause my-task --yes",
},
),
Middleware: serpent.Chain(
serpent.RequireNArgs(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
}
task, err := client.TaskByIdentifier(ctx, inv.Args[0])
if err != nil {
return xerrors.Errorf("resolve task %q: %w", inv.Args[0], err)
}
display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
if task.Status == codersdk.TaskStatusPaused {
return xerrors.Errorf("task %q is already paused", display)
}
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: fmt.Sprintf("Pause task %s?", pretty.Sprint(cliui.DefaultStyles.Code, display)),
IsConfirm: true,
Default: cliui.ConfirmNo,
})
if err != nil {
return err
}
resp, err := client.PauseTask(ctx, task.OwnerName, task.ID)
if err != nil {
return xerrors.Errorf("pause task %q: %w", display, err)
}
if resp.WorkspaceBuild == nil {
return xerrors.Errorf("pause task %q: no workspace build returned", display)
}
err = cliui.WorkspaceBuild(ctx, inv.Stdout, client, resp.WorkspaceBuild.ID)
if err != nil {
return xerrors.Errorf("watch pause build for task %q: %w", display, err)
}
_, _ = fmt.Fprintf(
inv.Stdout,
"\nThe %s task has been paused at %s!\n",
cliui.Keyword(task.Name),
cliui.Timestamp(time.Now()),
)
return nil
},
}
return cmd
}
-144
View File
@@ -1,144 +0,0 @@
package cli_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
func TestExpTaskPause(t *testing.T) {
t.Parallel()
t.Run("WithYesFlag", func(t *testing.T) {
t.Parallel()
// Given: A running task
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
// When: We attempt to pause the task
inv, root := clitest.New(t, "task", "pause", task.Name, "--yes")
output := clitest.Capture(inv)
clitest.SetupConfig(t, userClient, root)
// Then: Expect the task to be paused
ctx := testutil.Context(t, testutil.WaitMedium)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
require.Contains(t, output.Stdout(), "has been paused")
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
require.NoError(t, err)
require.Equal(t, codersdk.TaskStatusPaused, updated.Status)
})
// OtherUserTask verifies that an admin can pause a task owned by
// another user using the "owner/name" identifier format.
t.Run("OtherUserTask", func(t *testing.T) {
t.Parallel()
// Given: A different user's running task
setupCtx := testutil.Context(t, testutil.WaitLong)
adminClient, _, task := setupCLITaskTest(setupCtx, t, nil)
// When: We attempt to pause their task
identifier := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
inv, root := clitest.New(t, "task", "pause", identifier, "--yes")
output := clitest.Capture(inv)
clitest.SetupConfig(t, adminClient, root)
// Then: We expect the task to be paused
ctx := testutil.Context(t, testutil.WaitMedium)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
require.Contains(t, output.Stdout(), "has been paused")
updated, err := adminClient.TaskByIdentifier(ctx, identifier)
require.NoError(t, err)
require.Equal(t, codersdk.TaskStatusPaused, updated.Status)
})
t.Run("PromptConfirm", func(t *testing.T) {
t.Parallel()
// Given: A running task
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
// When: We attempt to pause the task
inv, root := clitest.New(t, "task", "pause", task.Name)
clitest.SetupConfig(t, userClient, root)
// And: We confirm we want to pause the task
ctx := testutil.Context(t, testutil.WaitMedium)
inv = inv.WithContext(ctx)
pty := ptytest.New(t).Attach(inv)
w := clitest.StartWithWaiter(t, inv)
pty.ExpectMatchContext(ctx, "Pause task")
pty.WriteLine("yes")
// Then: We expect the task to be paused
pty.ExpectMatchContext(ctx, "has been paused")
require.NoError(t, w.Wait())
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
require.NoError(t, err)
require.Equal(t, codersdk.TaskStatusPaused, updated.Status)
})
t.Run("PromptDecline", func(t *testing.T) {
t.Parallel()
// Given: A running task
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
// When: We attempt to pause the task
inv, root := clitest.New(t, "task", "pause", task.Name)
clitest.SetupConfig(t, userClient, root)
// But: We say no at the confirmation screen
ctx := testutil.Context(t, testutil.WaitMedium)
inv = inv.WithContext(ctx)
pty := ptytest.New(t).Attach(inv)
w := clitest.StartWithWaiter(t, inv)
pty.ExpectMatchContext(ctx, "Pause task")
pty.WriteLine("no")
require.Error(t, w.Wait())
// Then: We expect the task to not be paused
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
require.NoError(t, err)
require.NotEqual(t, codersdk.TaskStatusPaused, updated.Status)
})
t.Run("TaskAlreadyPaused", func(t *testing.T) {
t.Parallel()
// Given: A running task
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
// And: We paused the running task
ctx := testutil.Context(t, testutil.WaitMedium)
resp, err := userClient.PauseTask(ctx, task.OwnerName, task.ID)
require.NoError(t, err)
require.NotNil(t, resp.WorkspaceBuild)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, resp.WorkspaceBuild.ID)
// When: We attempt to pause the task again
inv, root := clitest.New(t, "task", "pause", task.Name, "--yes")
clitest.SetupConfig(t, userClient, root)
// Then: We expect to get an error that the task is already paused
err = inv.WithContext(ctx).Run()
require.ErrorContains(t, err, "is already paused")
})
}
-95
View File
@@ -1,95 +0,0 @@
package cli
import (
"fmt"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
func (r *RootCmd) taskResume() *serpent.Command {
var noWait bool
cmd := &serpent.Command{
Use: "resume <task>",
Short: "Resume a task",
Long: FormatExamples(
Example{
Description: "Resume a task by name",
Command: "coder task resume my-task",
},
Example{
Description: "Resume another user's task",
Command: "coder task resume alice/my-task",
},
Example{
Description: "Resume a task without confirmation",
Command: "coder task resume my-task --yes",
},
),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
),
Options: serpent.OptionSet{
{
Flag: "no-wait",
Description: "Return immediately after resuming the task.",
Value: serpent.BoolOf(&noWait),
},
cliui.SkipPromptOption(),
},
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
client, err := r.InitClient(inv)
if err != nil {
return err
}
task, err := client.TaskByIdentifier(ctx, inv.Args[0])
if err != nil {
return xerrors.Errorf("resolve task %q: %w", inv.Args[0], err)
}
display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
if task.Status == codersdk.TaskStatusError || task.Status == codersdk.TaskStatusUnknown {
return xerrors.Errorf("task %q is in %s state and cannot be resumed; check the workspace build logs and agent status for details", display, task.Status)
} else if task.Status != codersdk.TaskStatusPaused {
return xerrors.Errorf("task %q cannot be resumed (current status: %s)", display, task.Status)
}
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: fmt.Sprintf("Resume task %s?", pretty.Sprint(cliui.DefaultStyles.Code, display)),
IsConfirm: true,
Default: cliui.ConfirmNo,
})
if err != nil {
return err
}
resp, err := client.ResumeTask(ctx, task.OwnerName, task.ID)
if err != nil {
return xerrors.Errorf("resume task %q: %w", display, err)
} else if resp.WorkspaceBuild == nil {
return xerrors.Errorf("resume task %q: no workspace build returned", display)
}
if noWait {
_, _ = fmt.Fprintf(inv.Stdout, "Resuming task %q in the background.\n", cliui.Keyword(display))
return nil
}
if err = cliui.WorkspaceBuild(ctx, inv.Stdout, client, resp.WorkspaceBuild.ID); err != nil {
return xerrors.Errorf("watch resume build for task %q: %w", display, err)
}
_, _ = fmt.Fprintf(inv.Stdout, "\nThe %s task has been resumed.\n", cliui.Keyword(display))
return nil
},
}
return cmd
}
-183
View File
@@ -1,183 +0,0 @@
package cli_test
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
func TestExpTaskResume(t *testing.T) {
t.Parallel()
// pauseTask is a helper that pauses a task and waits for the stop
// build to complete.
pauseTask := func(ctx context.Context, t *testing.T, client *codersdk.Client, task codersdk.Task) {
t.Helper()
pauseResp, err := client.PauseTask(ctx, task.OwnerName, task.ID)
require.NoError(t, err)
require.NotNil(t, pauseResp.WorkspaceBuild)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID)
}
t.Run("WithYesFlag", func(t *testing.T) {
t.Parallel()
// Given: A paused task
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
pauseTask(setupCtx, t, userClient, task)
// When: We attempt to resume the task
inv, root := clitest.New(t, "task", "resume", task.Name, "--yes")
output := clitest.Capture(inv)
clitest.SetupConfig(t, userClient, root)
// Then: We expect the task to be resumed
ctx := testutil.Context(t, testutil.WaitMedium)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
require.Contains(t, output.Stdout(), "has been resumed")
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
require.NoError(t, err)
require.Equal(t, codersdk.TaskStatusInitializing, updated.Status)
})
// OtherUserTask verifies that an admin can resume a task owned by
// another user using the "owner/name" identifier format.
t.Run("OtherUserTask", func(t *testing.T) {
t.Parallel()
// Given: A different user's paused task
setupCtx := testutil.Context(t, testutil.WaitLong)
adminClient, userClient, task := setupCLITaskTest(setupCtx, t, nil)
pauseTask(setupCtx, t, userClient, task)
// When: We attempt to resume their task
identifier := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
inv, root := clitest.New(t, "task", "resume", identifier, "--yes")
output := clitest.Capture(inv)
clitest.SetupConfig(t, adminClient, root)
// Then: We expect the task to be resumed
ctx := testutil.Context(t, testutil.WaitMedium)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
require.Contains(t, output.Stdout(), "has been resumed")
updated, err := adminClient.TaskByIdentifier(ctx, identifier)
require.NoError(t, err)
require.Equal(t, codersdk.TaskStatusInitializing, updated.Status)
})
t.Run("NoWait", func(t *testing.T) {
t.Parallel()
// Given: A paused task
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
pauseTask(setupCtx, t, userClient, task)
// When: We attempt to resume the task (and specify no wait)
inv, root := clitest.New(t, "task", "resume", task.Name, "--yes", "--no-wait")
output := clitest.Capture(inv)
clitest.SetupConfig(t, userClient, root)
// Then: We expect the task to be resumed in the background
ctx := testutil.Context(t, testutil.WaitMedium)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
require.Contains(t, output.Stdout(), "in the background")
// And: The task to eventually be resumed
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
ws := coderdtest.MustWorkspace(t, userClient, task.WorkspaceID.UUID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, ws.LatestBuild.ID)
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
require.NoError(t, err)
require.Equal(t, codersdk.TaskStatusInitializing, updated.Status)
})
t.Run("PromptConfirm", func(t *testing.T) {
t.Parallel()
// Given: A paused task
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
pauseTask(setupCtx, t, userClient, task)
// When: We attempt to resume the task
inv, root := clitest.New(t, "task", "resume", task.Name)
clitest.SetupConfig(t, userClient, root)
// And: We confirm we want to resume the task
ctx := testutil.Context(t, testutil.WaitMedium)
inv = inv.WithContext(ctx)
pty := ptytest.New(t).Attach(inv)
w := clitest.StartWithWaiter(t, inv)
pty.ExpectMatchContext(ctx, "Resume task")
pty.WriteLine("yes")
// Then: We expect the task to be resumed
pty.ExpectMatchContext(ctx, "has been resumed")
require.NoError(t, w.Wait())
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
require.NoError(t, err)
require.Equal(t, codersdk.TaskStatusInitializing, updated.Status)
})
t.Run("PromptDecline", func(t *testing.T) {
t.Parallel()
// Given: A paused task
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
pauseTask(setupCtx, t, userClient, task)
// When: We attempt to resume the task
inv, root := clitest.New(t, "task", "resume", task.Name)
clitest.SetupConfig(t, userClient, root)
// But: Say no at the confirmation screen
ctx := testutil.Context(t, testutil.WaitMedium)
inv = inv.WithContext(ctx)
pty := ptytest.New(t).Attach(inv)
w := clitest.StartWithWaiter(t, inv)
pty.ExpectMatchContext(ctx, "Resume task")
pty.WriteLine("no")
require.Error(t, w.Wait())
// Then: We expect the task to still be paused
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
require.NoError(t, err)
require.Equal(t, codersdk.TaskStatusPaused, updated.Status)
})
t.Run("TaskNotPaused", func(t *testing.T) {
t.Parallel()
// Given: A running task
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
// When: We attempt to resume the task that is not paused
inv, root := clitest.New(t, "task", "resume", task.Name, "--yes")
clitest.SetupConfig(t, userClient, root)
// Then: We expect to get an error that the task is not paused
ctx := testutil.Context(t, testutil.WaitMedium)
err := inv.WithContext(ctx).Run()
require.ErrorContains(t, err, "cannot be resumed")
})
}
+7 -4
View File
@@ -25,7 +25,8 @@ func Test_TaskSend(t *testing.T) {
t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
userClient := client
var stdout strings.Builder
inv, root := clitest.New(t, "task", "send", task.Name, "carry on with the task")
@@ -41,7 +42,8 @@ func Test_TaskSend(t *testing.T) {
t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
userClient := client
var stdout strings.Builder
inv, root := clitest.New(t, "task", "send", task.ID.String(), "carry on with the task")
@@ -57,7 +59,8 @@ func Test_TaskSend(t *testing.T) {
t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
userClient := client
var stdout strings.Builder
inv, root := clitest.New(t, "task", "send", task.Name, "--stdin")
@@ -110,7 +113,7 @@ func Test_TaskSend(t *testing.T) {
t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong)
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendErr(t, assert.AnError))
userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendErr(t, assert.AnError))
var stdout strings.Builder
inv, root := clitest.New(t, "task", "send", task.Name, "some task input")
+10 -44
View File
@@ -120,40 +120,6 @@ func Test_Tasks(t *testing.T) {
require.Equal(t, logs[2].Type, codersdk.TaskLogTypeOutput, "third message should be an output")
},
},
{
name: "pause task",
cmdArgs: []string{"task", "pause", taskName, "--yes"},
assertFn: func(stdout string, userClient *codersdk.Client) {
require.Contains(t, stdout, "has been paused", "pause output should confirm task was paused")
},
},
{
name: "get task status after pause",
cmdArgs: []string{"task", "status", taskName, "--output", "json"},
assertFn: func(stdout string, userClient *codersdk.Client) {
var task codersdk.Task
require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&task), "should unmarshal task status")
require.Equal(t, taskName, task.Name, "task name should match")
require.Equal(t, codersdk.TaskStatusPaused, task.Status, "task should be paused")
},
},
{
name: "resume task",
cmdArgs: []string{"task", "resume", taskName, "--yes"},
assertFn: func(stdout string, userClient *codersdk.Client) {
require.Contains(t, stdout, "has been resumed", "resume output should confirm task was resumed")
},
},
{
name: "get task status after resume",
cmdArgs: []string{"task", "status", taskName, "--output", "json"},
assertFn: func(stdout string, userClient *codersdk.Client) {
var task codersdk.Task
require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&task), "should unmarshal task status")
require.Equal(t, taskName, task.Name, "task name should match")
require.Equal(t, codersdk.TaskStatusInitializing, task.Status, "task should be initializing after resume")
},
},
{
name: "delete task",
cmdArgs: []string{"task", "delete", taskName, "--yes"},
@@ -272,17 +238,17 @@ func fakeAgentAPIEcho(ctx context.Context, t testing.TB, initMsg agentapisdk.Mes
// setupCLITaskTest creates a test workspace with an AI task template and agent,
// with a fake agent API configured with the provided set of handlers.
// Returns the user client and workspace.
func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (ownerClient *codersdk.Client, memberClient *codersdk.Client, task codersdk.Task) {
func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (*codersdk.Client, codersdk.Task) {
t.Helper()
ownerClient = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, ownerClient)
userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
fakeAPI := startFakeAgentAPI(t, agentAPIHandlers)
authToken := uuid.NewString()
template := createAITaskTemplate(t, ownerClient, owner.OrganizationID, withSidebarURL(fakeAPI.URL()), withAgentToken(authToken))
template := createAITaskTemplate(t, client, owner.OrganizationID, withSidebarURL(fakeAPI.URL()), withAgentToken(authToken))
wantPrompt := "test prompt"
task, err := userClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
@@ -296,17 +262,17 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
workspace, err := userClient.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
agentClient := agentsdk.New(userClient.URL, agentsdk.WithFixedToken(authToken))
_ = agenttest.New(t, userClient.URL, authToken, func(o *agent.Options) {
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken))
_ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) {
o.Client = agentClient
})
coderdtest.NewWorkspaceAgentWaiter(t, userClient, workspace.ID).
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).
WaitFor(coderdtest.AgentsReady)
return ownerClient, userClient, task
return userClient, task
}
// setupCLITaskTestWithSnapshot creates a task in the specified status with a log snapshot.
-2
View File
@@ -141,7 +141,6 @@ type templateVersionRow struct {
TemplateVersion codersdk.TemplateVersion `table:"-"`
// For table format:
ID string `json:"-" table:"id"`
Name string `json:"-" table:"name,default_sort"`
CreatedAt time.Time `json:"-" table:"created at"`
CreatedBy string `json:"-" table:"created by"`
@@ -167,7 +166,6 @@ func templateVersionsToRows(activeVersionID uuid.UUID, templateVersions ...coder
rows[i] = templateVersionRow{
TemplateVersion: templateVersion,
ID: templateVersion.ID.String(),
Name: templateVersion.Name,
CreatedAt: templateVersion.CreatedAt,
CreatedBy: templateVersion.CreatedBy.Username,
-2
View File
@@ -12,8 +12,6 @@ SUBCOMMANDS:
delete Delete tasks
list List tasks
logs Show a task's logs
pause Pause a task
resume Resume a task
send Send input to a task
status Show the status of a task.
-25
View File
@@ -1,25 +0,0 @@
coder v0.0.0-devel
USAGE:
coder task pause [flags] <task>
Pause a task
- Pause a task by name:
$ coder task pause my-task
- Pause another user's task:
$ coder task pause alice/my-task
- Pause a task without confirmation:
$ coder task pause my-task --yes
OPTIONS:
-y, --yes bool
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
-28
View File
@@ -1,28 +0,0 @@
coder v0.0.0-devel
USAGE:
coder task resume [flags] <task>
Resume a task
- Resume a task by name:
$ coder task resume my-task
- Resume another user's task:
$ coder task resume alice/my-task
- Resume a task without confirmation:
$ coder task resume my-task --yes
OPTIONS:
--no-wait bool
Return immediately after resuming the task.
-y, --yes bool
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -9,7 +9,7 @@ OPTIONS:
-O, --org string, $CODER_ORGANIZATION
Select which organization (uuid or name) to use.
-c, --column [id|name|created at|created by|status|active|archived] (default: name,created at,created by,status,active)
-c, --column [name|created at|created by|status|active|archived] (default: name,created at,created by,status,active)
Columns to display in table output.
--include-archived bool
+1 -1
View File
@@ -27,7 +27,7 @@ USAGE:
SUBCOMMANDS:
create Create a token
list List tokens
remove Expire or delete a token
remove Delete a token
view Display detailed information about a token
———
-4
View File
@@ -15,10 +15,6 @@ OPTIONS:
-c, --column [id|name|scopes|allow list|last used|expires at|created at|owner] (default: id,name,scopes,allow list,last used,expires at,created at)
Columns to display in table output.
--include-expired bool
Include expired tokens in the output. By default, expired tokens are
hidden.
-o, --output table|json (default: table)
Output format.
+2 -10
View File
@@ -1,19 +1,11 @@
coder v0.0.0-devel
USAGE:
coder tokens remove [flags] <name|id|token>
coder tokens remove <name|id|token>
Expire or delete a token
Delete a token
Aliases: delete, rm
Remove a token by expiring it. Use --delete to permanently hard-delete the
token instead.
OPTIONS:
--delete bool
Permanently delete the token instead of expiring it. This removes the
audit trail.
———
Run `coder --help` for a list of global options.
+13 -49
View File
@@ -218,10 +218,9 @@ func (r *RootCmd) listTokens() *serpent.Command {
}
var (
all bool
includeExpired bool
displayTokens []tokenListRow
formatter = cliui.NewOutputFormatter(
all bool
displayTokens []tokenListRow
formatter = cliui.NewOutputFormatter(
cliui.TableFormat([]tokenListRow{}, defaultCols),
cliui.JSONFormat(),
)
@@ -247,20 +246,6 @@ func (r *RootCmd) listTokens() *serpent.Command {
return xerrors.Errorf("list tokens: %w", err)
}
// Filter out expired tokens unless --include-expired is set
// TODO(Cian): This _could_ get too big for client-side filtering.
// If it causes issues, we can filter server-side.
if !includeExpired {
now := time.Now()
filtered := make([]codersdk.APIKeyWithOwner, 0, len(tokens))
for _, token := range tokens {
if token.ExpiresAt.After(now) {
filtered = append(filtered, token)
}
}
tokens = filtered
}
displayTokens = make([]tokenListRow, len(tokens))
for i, token := range tokens {
@@ -289,12 +274,6 @@ func (r *RootCmd) listTokens() *serpent.Command {
Description: "Specifies whether all users' tokens will be listed or not (must have Owner role to see all tokens).",
Value: serpent.BoolOf(&all),
},
{
Name: "include-expired",
Flag: "include-expired",
Description: "Include expired tokens in the output. By default, expired tokens are hidden.",
Value: serpent.BoolOf(&includeExpired),
},
}
formatter.AttachOptions(&cmd.Options)
@@ -344,13 +323,10 @@ func (r *RootCmd) viewToken() *serpent.Command {
}
func (r *RootCmd) removeToken() *serpent.Command {
var deleteToken bool
cmd := &serpent.Command{
Use: "remove <name|id|token>",
Aliases: []string{"delete"},
Short: "Expire or delete a token",
Long: "Remove a token by expiring it. Use --delete to permanently hard-" +
"delete the token instead.",
Short: "Delete a token",
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
),
@@ -362,7 +338,7 @@ func (r *RootCmd) removeToken() *serpent.Command {
token, err := client.APIKeyByName(inv.Context(), codersdk.Me, inv.Args[0])
if err != nil {
// If it's a token, we need to extract the ID.
// If it's a token, we need to extract the ID
maybeID := strings.Split(inv.Args[0], "-")[0]
token, err = client.APIKeyByID(inv.Context(), codersdk.Me, maybeID)
if err != nil {
@@ -370,29 +346,17 @@ func (r *RootCmd) removeToken() *serpent.Command {
}
}
if deleteToken {
err = client.DeleteAPIKey(inv.Context(), codersdk.Me, token.ID)
if err != nil {
return xerrors.Errorf("delete api key: %w", err)
}
cliui.Infof(inv.Stdout, "Token has been deleted.")
return nil
}
err = client.ExpireAPIKey(inv.Context(), codersdk.Me, token.ID)
err = client.DeleteAPIKey(inv.Context(), codersdk.Me, token.ID)
if err != nil {
return xerrors.Errorf("expire api key: %w", err)
return xerrors.Errorf("delete api key: %w", err)
}
cliui.Infof(inv.Stdout, "Token has been expired.")
return nil
},
}
cmd.Options = serpent.OptionSet{
{
Flag: "delete",
Description: "Permanently delete the token instead of expiring it. This removes the audit trail.",
Value: serpent.BoolOf(&deleteToken),
cliui.Infof(
inv.Stdout,
"Token has been deleted.",
)
return nil
},
}
+17 -153
View File
@@ -6,16 +6,12 @@ import (
"encoding/json"
"fmt"
"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/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
@@ -26,7 +22,7 @@ func TestTokens(t *testing.T) {
adminUser := coderdtest.CreateFirstUser(t, client)
secondUserClient, secondUser := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID)
thirdUserClient, thirdUser := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID)
_, thirdUser := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID)
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancelFunc()
@@ -159,7 +155,7 @@ func TestTokens(t *testing.T) {
require.Len(t, scopedToken.AllowList, 1)
require.Equal(t, allowSpec, scopedToken.AllowList[0].String())
// Delete by name (default behavior is now expire)
// Delete by name
inv, root = clitest.New(t, "tokens", "rm", "token-one")
clitest.SetupConfig(t, client, root)
buf = new(bytes.Buffer)
@@ -168,42 +164,21 @@ func TestTokens(t *testing.T) {
require.NoError(t, err)
res = buf.String()
require.NotEmpty(t, res)
require.Contains(t, res, "expired")
// Regular users cannot expire other users' tokens (expire is default now).
inv, root = clitest.New(t, "tokens", "rm", secondTokenID)
clitest.SetupConfig(t, thirdUserClient, root)
buf = new(bytes.Buffer)
inv.Stdout = buf
err = inv.WithContext(ctx).Run()
require.Error(t, err)
require.Contains(t, err.Error(), "not found")
// Only admin users can expire other users' tokens (expire is default now).
inv, root = clitest.New(t, "tokens", "rm", secondTokenID)
clitest.SetupConfig(t, client, root)
buf = new(bytes.Buffer)
inv.Stdout = buf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
// Validate that token was expired
if token, err := client.APIKeyByName(ctx, secondUser.ID.String(), "token-two"); assert.NoError(t, err) {
require.True(t, token.ExpiresAt.Before(time.Now()))
}
// Delete by ID (explicit delete flag)
inv, root = clitest.New(t, "tokens", "rm", "--delete", secondTokenID)
clitest.SetupConfig(t, client, root)
buf = new(bytes.Buffer)
inv.Stdout = buf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
res = buf.String()
require.NotEmpty(t, res)
require.Contains(t, res, "deleted")
// Delete scoped token by ID (explicit delete flag)
inv, root = clitest.New(t, "tokens", "rm", "--delete", scopedTokenID)
// Delete by ID
inv, root = clitest.New(t, "tokens", "rm", secondTokenID)
clitest.SetupConfig(t, client, root)
buf = new(bytes.Buffer)
inv.Stdout = buf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
res = buf.String()
require.NotEmpty(t, res)
require.Contains(t, res, "deleted")
// Delete scoped token by ID
inv, root = clitest.New(t, "tokens", "rm", scopedTokenID)
clitest.SetupConfig(t, client, root)
buf = new(bytes.Buffer)
inv.Stdout = buf
@@ -224,8 +199,8 @@ func TestTokens(t *testing.T) {
require.NotEmpty(t, res)
fourthToken := res
// Delete by token (explicit delete flag)
inv, root = clitest.New(t, "tokens", "rm", "--delete", fourthToken)
// Delete by token
inv, root = clitest.New(t, "tokens", "rm", fourthToken)
clitest.SetupConfig(t, client, root)
buf = new(bytes.Buffer)
inv.Stdout = buf
@@ -235,114 +210,3 @@ func TestTokens(t *testing.T) {
require.NotEmpty(t, res)
require.Contains(t, res, "deleted")
}
func TestTokensListExpiredFiltering(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
// Create a valid (non-expired) token
validToken, _ := dbgen.APIKey(t, api.Database, database.APIKey{
UserID: owner.UserID,
ExpiresAt: time.Now().Add(24 * time.Hour),
LoginType: database.LoginTypeToken,
TokenName: "valid-token",
})
// Create an expired token
expiredToken, _ := dbgen.APIKey(t, api.Database, database.APIKey{
UserID: owner.UserID,
ExpiresAt: time.Now().Add(-24 * time.Hour),
LoginType: database.LoginTypeToken,
TokenName: "expired-token",
})
t.Run("HidesExpiredByDefault", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
inv, root := clitest.New(t, "tokens", "ls")
clitest.SetupConfig(t, client, root)
buf := new(bytes.Buffer)
inv.Stdout = buf
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
res := buf.String()
require.Contains(t, res, validToken.ID)
require.Contains(t, res, "valid-token")
require.NotContains(t, res, expiredToken.ID)
require.NotContains(t, res, "expired-token")
})
t.Run("ShowsExpiredWithFlag", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
inv, root := clitest.New(t, "tokens", "ls", "--include-expired")
clitest.SetupConfig(t, client, root)
buf := new(bytes.Buffer)
inv.Stdout = buf
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
res := buf.String()
require.Contains(t, res, validToken.ID)
require.Contains(t, res, "valid-token")
require.Contains(t, res, expiredToken.ID)
require.Contains(t, res, "expired-token")
})
t.Run("JSONOutputRespectsFilter", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Default (no expired)
inv, root := clitest.New(t, "tokens", "ls", "--output=json")
clitest.SetupConfig(t, client, root)
buf := new(bytes.Buffer)
inv.Stdout = buf
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
res := buf.String()
require.Contains(t, res, "valid-token")
require.NotContains(t, res, "expired-token")
// With --include-expired
inv, root = clitest.New(t, "tokens", "ls", "--output=json", "--include-expired")
clitest.SetupConfig(t, client, root)
buf = new(bytes.Buffer)
inv.Stdout = buf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
res = buf.String()
require.Contains(t, res, "valid-token")
require.Contains(t, res, "expired-token")
})
t.Run("AllUsersWithIncludeExpired", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
inv, root := clitest.New(t, "tokens", "ls", "--all", "--include-expired")
clitest.SetupConfig(t, client, root)
buf := new(bytes.Buffer)
inv.Stdout = buf
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
res := buf.String()
// Should show both valid and expired tokens
require.Contains(t, res, validToken.ID)
require.Contains(t, res, "valid-token")
require.Contains(t, res, expiredToken.ID)
require.Contains(t, res, "expired-token")
})
}
+24
View File
@@ -0,0 +1,24 @@
//go:build !windows && !darwin
package cli
import (
"golang.org/x/xerrors"
"github.com/coder/serpent"
)
func (*RootCmd) vpnDaemonRun() *serpent.Command {
cmd := &serpent.Command{
Use: "run",
Short: "Run the VPN daemon on Windows.",
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
),
Handler: func(_ *serpent.Invocation) error {
return xerrors.New("vpn-daemon subcommand is not supported on this platform")
},
}
return cmd
}
@@ -1,4 +1,4 @@
//go:build windows || linux
//go:build windows
package cli
@@ -11,7 +11,7 @@ import (
"github.com/coder/serpent"
)
func (*RootCmd) vpnDaemonRun() *serpent.Command {
func (r *RootCmd) vpnDaemonRun() *serpent.Command {
var (
rpcReadHandleInt int64
rpcWriteHandleInt int64
@@ -19,7 +19,7 @@ func (*RootCmd) vpnDaemonRun() *serpent.Command {
cmd := &serpent.Command{
Use: "run",
Short: "Run the VPN daemon on Windows and Linux.",
Short: "Run the VPN daemon on Windows.",
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
),
@@ -53,8 +53,8 @@ func (*RootCmd) vpnDaemonRun() *serpent.Command {
return xerrors.Errorf("rpc-read-handle (%v) and rpc-write-handle (%v) must be different", rpcReadHandleInt, rpcWriteHandleInt)
}
// The manager passes the read and write descriptors directly to the
// daemon, so we can open the RPC pipe from the raw values.
// We don't need to worry about duplicating the handles on Windows,
// which is different from Unix.
logger.Info(ctx, "opening bidirectional RPC pipe", slog.F("rpc_read_handle", rpcReadHandleInt), slog.F("rpc_write_handle", rpcWriteHandleInt))
pipe, err := vpn.NewBidirectionalPipe(uintptr(rpcReadHandleInt), uintptr(rpcWriteHandleInt))
if err != nil {
@@ -62,7 +62,7 @@ func (*RootCmd) vpnDaemonRun() *serpent.Command {
}
defer pipe.Close()
logger.Info(ctx, "starting VPN tunnel")
logger.Info(ctx, "starting tunnel")
tunnel, err := vpn.NewTunnel(ctx, logger, pipe, vpn.NewClient(), vpn.UseOSNetworkingStack())
if err != nil {
return xerrors.Errorf("create new tunnel for client: %w", err)
@@ -1,19 +0,0 @@
//go:build linux
package cli_test
import (
"os"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)
func dupHandle(t *testing.T, f *os.File) uintptr {
t.Helper()
dupFD, err := unix.Dup(int(f.Fd()))
require.NoError(t, err)
return uintptr(dupFD)
}
@@ -1,33 +0,0 @@
//go:build windows
package cli_test
import (
"os"
"syscall"
"testing"
"github.com/stretchr/testify/require"
)
func dupHandle(t *testing.T, f *os.File) uintptr {
t.Helper()
src := syscall.Handle(f.Fd())
var dup syscall.Handle
proc, err := syscall.GetCurrentProcess()
require.NoError(t, err)
err = syscall.DuplicateHandle(
proc,
src,
proc,
&dup,
0,
false,
syscall.DUPLICATE_SAME_ACCESS,
)
require.NoError(t, err)
return uintptr(dup)
}
@@ -1,4 +1,4 @@
//go:build windows || linux
//go:build windows
package cli_test
@@ -67,35 +67,22 @@ func TestVPNDaemonRun(t *testing.T) {
r1, w1, err := os.Pipe()
require.NoError(t, err)
defer r1.Close()
defer w1.Close()
r2, w2, err := os.Pipe()
require.NoError(t, err)
defer r2.Close()
// The daemon closes the handles passed via NewBidirectionalPipe. Since our
// CLI tests run in-process, pass duplicated handles so we can close the
// originals without risking a double-close on FD reuse.
rpcReadHandle := dupHandle(t, r1)
rpcWriteHandle := dupHandle(t, w2)
require.NoError(t, r1.Close())
require.NoError(t, w2.Close())
defer w2.Close()
ctx := testutil.Context(t, testutil.WaitLong)
inv, _ := clitest.New(t,
"vpn-daemon",
"run",
"--rpc-read-handle",
fmt.Sprint(rpcReadHandle),
"--rpc-write-handle",
fmt.Sprint(rpcWriteHandle),
)
inv, _ := clitest.New(t, "vpn-daemon", "run", "--rpc-read-handle", fmt.Sprint(r1.Fd()), "--rpc-write-handle", fmt.Sprint(w2.Fd()))
waiter := clitest.StartWithWaiter(t, inv.WithContext(ctx))
// Send an invalid header, including a newline delimiter, so the handshake
// fails without requiring context cancellation.
_, err = w1.Write([]byte("garbage\n"))
// Send garbage which should cause the handshake to fail and the daemon
// to exit.
_, err = w1.Write([]byte("garbage"))
require.NoError(t, err)
waiter.Cancel()
err = waiter.Wait()
require.ErrorContains(t, err, "handshake failed")
})
-87
View File
@@ -1304,90 +1304,3 @@ func (api *API) pauseTask(rw http.ResponseWriter, r *http.Request) {
WorkspaceBuild: &build,
})
}
// @Summary Resume task
// @ID resume-task
// @Security CoderSessionToken
// @Accept json
// @Tags Tasks
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
// @Param task path string true "Task ID" format(uuid)
// @Success 202 {object} codersdk.ResumeTaskResponse
// @Router /tasks/{user}/{task}/resume [post]
func (api *API) resumeTask(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
task = httpmw.TaskParam(r)
)
if !task.WorkspaceID.Valid {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Task does not have a workspace.",
})
return
}
workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching task workspace.",
Detail: err.Error(),
})
return
}
latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching task workspace build.",
Detail: err.Error(),
})
return
}
job, err := api.Database.GetProvisionerJobByID(ctx, latestBuild.JobID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching task workspace build job.",
Detail: err.Error(),
})
return
}
workspaceStatus := codersdk.ConvertWorkspaceStatus(
codersdk.ProvisionerJobStatus(job.JobStatus),
codersdk.WorkspaceTransition(latestBuild.Transition),
)
if workspaceStatus == codersdk.WorkspaceStatusRunning {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Task workspace is already running.",
Detail: fmt.Sprintf("Workspace status is %q.", workspaceStatus),
})
return
}
buildReq := codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStart,
Reason: codersdk.CreateWorkspaceBuildReasonTaskResume,
}
build, err := api.postWorkspaceBuildsInternal(
ctx,
apiKey,
workspace,
buildReq,
func(action policy.Action, object rbac.Objecter) bool {
return api.Authorize(r, action, object)
},
audit.WorkspaceBuildBaggageFromRequest(r),
)
if err != nil {
httperror.WriteWorkspaceBuildError(ctx, rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusAccepted, codersdk.ResumeTaskResponse{
WorkspaceBuild: &build,
})
}
+1 -337
View File
@@ -2512,20 +2512,13 @@ func TestPauseTask(t *testing.T) {
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
resp, err := client.PauseTask(ctx, codersdk.Me, task.ID)
// Verify that the request was accepted correctly:
require.NoError(t, err)
build := *resp.WorkspaceBuild
require.NotNil(t, build)
require.Equal(t, codersdk.WorkspaceTransitionStop, build.Transition)
require.Equal(t, task.WorkspaceID.UUID, build.WorkspaceID)
require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber)
require.Equal(t, string(codersdk.CreateWorkspaceBuildReasonTaskManualPause), string(build.Reason))
// Verify that the accepted request was processed correctly:
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
workspace, err = client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
require.Equal(t, codersdk.WorkspaceStatusStopped, workspace.LatestBuild.Status)
})
t.Run("Non-owner role access", func(t *testing.T) {
@@ -2788,332 +2781,3 @@ func TestPauseTask(t *testing.T) {
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
})
}
func TestResumeTask(t *testing.T) {
t.Parallel()
setupClient := func(t *testing.T, db database.Store, ps pubsub.Pubsub, authorizer rbac.Authorizer) *codersdk.Client {
t.Helper()
client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
Authorizer: authorizer,
IncludeProvisionerDaemon: true,
})
return client
}
setupWorkspaceTask := func(t *testing.T, db database.Store, user codersdk.CreateFirstUserResponse) (database.Task, uuid.UUID) {
t.Helper()
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
}).WithTask(database.TaskTable{
Prompt: "resume me",
}, nil).Do()
return workspaceBuild.Task, workspaceBuild.Workspace.ID
}
t.Run("OK", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: echo.ApplyComplete,
ProvisionGraph: []*proto.Response{
{Type: &proto.Response_Graph{Graph: &proto.GraphComplete{
HasAiTasks: true,
}}},
},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
task, err := client.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "resume me",
})
require.NoError(t, err)
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
pauseResp, err := client.PauseTask(ctx, codersdk.Me, task.ID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID)
resumeResp, err := client.ResumeTask(ctx, codersdk.Me, task.ID)
require.NoError(t, err)
build := *resumeResp.WorkspaceBuild
require.Equal(t, codersdk.WorkspaceTransitionStart, build.Transition)
require.Equal(t, task.WorkspaceID.UUID, build.WorkspaceID)
require.Equal(t, workspace.LatestBuild.BuildNumber+2, build.BuildNumber)
require.Equal(t, string(codersdk.CreateWorkspaceBuildReasonTaskResume), string(build.Reason))
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
workspace, err = client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
require.Equal(t, codersdk.WorkspaceStatusRunning, workspace.LatestBuild.Status)
})
t.Run("Resume a task that is not paused", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
db, ps := dbtestutil.NewDB(t)
client := setupClient(t, db, ps, nil)
user := coderdtest.CreateFirstUser(t, client)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
}).
WithTask(database.TaskTable{
Prompt: "pause me",
}, nil).
Succeeded().
Do()
_, err := client.ResumeTask(ctx, codersdk.Me, workspaceBuild.Task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
})
t.Run("Task not found", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
_ = coderdtest.CreateFirstUser(t, client)
_, err := client.ResumeTask(ctx, codersdk.Me, uuid.New())
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("Task lookup forbidden", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
auth := &coderdtest.FakeAuthorizer{
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
if action == policy.ActionRead && object.Type == rbac.ResourceTask.Type {
return rbac.UnauthorizedError{}
}
return nil
},
}
client := setupClient(t, db, ps, auth)
user := coderdtest.CreateFirstUser(t, client)
task, _ := setupWorkspaceTask(t, db, user)
_, err := client.ResumeTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("Workspace lookup forbidden", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
auth := &coderdtest.FakeAuthorizer{
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
if action == policy.ActionRead && object.Type == rbac.ResourceWorkspace.Type {
return rbac.UnauthorizedError{}
}
return nil
},
}
client := setupClient(t, db, ps, auth)
user := coderdtest.CreateFirstUser(t, client)
task, _ := setupWorkspaceTask(t, db, user)
_, err := client.ResumeTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("No Workspace for Task", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
client := setupClient(t, db, ps, nil)
user := coderdtest.CreateFirstUser(t, client)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
}).Do()
task := dbgen.Task(t, db, database.TaskTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
TemplateVersionID: workspaceBuild.Build.TemplateVersionID,
Prompt: "no workspace",
})
_, err := client.ResumeTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
require.Equal(t, "Task does not have a workspace.", apiErr.Message)
})
t.Run("Workspace not found", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
var workspaceID uuid.UUID
wrapped := aiTaskStoreWrapper{
Store: db,
getWorkspaceByID: func(ctx context.Context, id uuid.UUID) (database.Workspace, error) {
if id == workspaceID && id != uuid.Nil {
return database.Workspace{}, sql.ErrNoRows
}
return db.GetWorkspaceByID(ctx, id)
},
}
client := setupClient(t, wrapped, ps, nil)
user := coderdtest.CreateFirstUser(t, client)
task, workspaceIDValue := setupWorkspaceTask(t, db, user)
workspaceID = workspaceIDValue
_, err := client.ResumeTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("Workspace lookup internal error", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
var workspaceID uuid.UUID
wrapped := aiTaskStoreWrapper{
Store: db,
getWorkspaceByID: func(ctx context.Context, id uuid.UUID) (database.Workspace, error) {
if id == workspaceID && id != uuid.Nil {
return database.Workspace{}, xerrors.New("boom")
}
return db.GetWorkspaceByID(ctx, id)
},
}
client := setupClient(t, wrapped, ps, nil)
user := coderdtest.CreateFirstUser(t, client)
task, workspaceIDValue := setupWorkspaceTask(t, db, user)
workspaceID = workspaceIDValue
_, err := client.ResumeTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
require.Equal(t, "Internal error fetching task workspace.", apiErr.Message)
})
t.Run("Build Forbidden", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
auth := &coderdtest.FakeAuthorizer{
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
if action == policy.ActionWorkspaceStart && object.Type == rbac.ResourceWorkspace.Type {
return rbac.UnauthorizedError{}
}
return nil
},
}
client := setupClient(t, db, ps, auth)
user := coderdtest.CreateFirstUser(t, client)
task, _ := setupWorkspaceTask(t, db, user)
pauseResp, err := client.PauseTask(ctx, codersdk.Me, task.ID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID)
_, err = client.ResumeTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
})
t.Run("Job already in progress", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
client := setupClient(t, db, ps, nil)
user := coderdtest.CreateFirstUser(t, client)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
}).
WithTask(database.TaskTable{
Prompt: "resume me",
}, nil).
Starting().
Do()
_, err := client.ResumeTask(ctx, codersdk.Me, workspaceBuild.Task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
})
t.Run("Build Internal Error", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
wrapped := aiTaskStoreWrapper{
Store: db,
}
client := setupClient(t, &wrapped, ps, nil)
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: echo.ApplyComplete,
ProvisionGraph: []*proto.Response{
{Type: &proto.Response_Graph{Graph: &proto.GraphComplete{
HasAiTasks: true,
}}},
},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
task, err := client.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "resume me",
})
require.NoError(t, err)
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
pauseResp, err := client.PauseTask(ctx, codersdk.Me, task.ID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID)
// Induce a transient failure in the database after the task has been paused.
wrapped.insertWorkspaceBuild = func(ctx context.Context, arg database.InsertWorkspaceBuildParams) error {
return xerrors.New("insert failed")
}
_, err = client.ResumeTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
})
}
+4 -116
View File
@@ -5866,48 +5866,6 @@ const docTemplate = `{
}
}
},
"/tasks/{user}/{task}/resume": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"tags": [
"Tasks"
],
"summary": "Resume task",
"operationId": "resume-task",
"parameters": [
{
"type": "string",
"description": "Username, user ID, or 'me' for the authenticated user",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
"description": "Task ID",
"name": "task",
"in": "path",
"required": true
}
],
"responses": {
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/codersdk.ResumeTaskResponse"
}
}
}
}
},
"/tasks/{user}/{task}/send": {
"post": {
"security": [
@@ -8386,54 +8344,6 @@ const docTemplate = `{
}
}
},
"/users/{user}/keys/{keyid}/expire": {
"put": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": [
"Users"
],
"summary": "Expire API key",
"operationId": "expire-api-key",
"parameters": [
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"format": "string",
"description": "Key ID",
"name": "keyid",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/codersdk.Response"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/codersdk.Response"
}
}
}
}
},
"/users/{user}/login-type": {
"get": {
"security": [
@@ -12351,9 +12261,6 @@ const docTemplate = `{
"api_key_id": {
"type": "string"
},
"client": {
"type": "string"
},
"ended_at": {
"type": "string",
"format": "date-time"
@@ -13553,10 +13460,7 @@ const docTemplate = `{
"cli",
"ssh_connection",
"vscode_connection",
"jetbrains_connection",
"task_auto_pause",
"task_manual_pause",
"task_resume"
"jetbrains_connection"
],
"x-enum-varnames": [
"BuildReasonInitiator",
@@ -13567,10 +13471,7 @@ const docTemplate = `{
"BuildReasonCLI",
"BuildReasonSSHConnection",
"BuildReasonVSCodeConnection",
"BuildReasonJetbrainsConnection",
"BuildReasonTaskAutoPause",
"BuildReasonTaskManualPause",
"BuildReasonTaskResume"
"BuildReasonJetbrainsConnection"
]
},
"codersdk.CORSBehavior": {
@@ -14244,8 +14145,7 @@ const docTemplate = `{
"ssh_connection",
"vscode_connection",
"jetbrains_connection",
"task_manual_pause",
"task_resume"
"task_manual_pause"
],
"x-enum-varnames": [
"CreateWorkspaceBuildReasonDashboard",
@@ -14253,8 +14153,7 @@ const docTemplate = `{
"CreateWorkspaceBuildReasonSSHConnection",
"CreateWorkspaceBuildReasonVSCodeConnection",
"CreateWorkspaceBuildReasonJetbrainsConnection",
"CreateWorkspaceBuildReasonTaskManualPause",
"CreateWorkspaceBuildReasonTaskResume"
"CreateWorkspaceBuildReasonTaskManualPause"
]
},
"codersdk.CreateWorkspaceBuildRequest": {
@@ -18336,14 +18235,6 @@ const docTemplate = `{
}
}
},
"codersdk.ResumeTaskResponse": {
"type": "object",
"properties": {
"workspace_build": {
"$ref": "#/definitions/codersdk.WorkspaceBuild"
}
}
},
"codersdk.RetentionConfig": {
"type": "object",
"properties": {
@@ -18976,9 +18867,6 @@ const docTemplate = `{
"default_ttl_ms": {
"type": "integer"
},
"deleted": {
"type": "boolean"
},
"deprecated": {
"type": "boolean"
},
+4 -110
View File
@@ -5185,44 +5185,6 @@
}
}
},
"/tasks/{user}/{task}/resume": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"tags": ["Tasks"],
"summary": "Resume task",
"operationId": "resume-task",
"parameters": [
{
"type": "string",
"description": "Username, user ID, or 'me' for the authenticated user",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
"description": "Task ID",
"name": "task",
"in": "path",
"required": true
}
],
"responses": {
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/codersdk.ResumeTaskResponse"
}
}
}
}
},
"/tasks/{user}/{task}/send": {
"post": {
"security": [
@@ -7417,52 +7379,6 @@
}
}
},
"/users/{user}/keys/{keyid}/expire": {
"put": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": ["Users"],
"summary": "Expire API key",
"operationId": "expire-api-key",
"parameters": [
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"format": "string",
"description": "Key ID",
"name": "keyid",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/codersdk.Response"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/codersdk.Response"
}
}
}
}
},
"/users/{user}/login-type": {
"get": {
"security": [
@@ -10971,9 +10887,6 @@
"api_key_id": {
"type": "string"
},
"client": {
"type": "string"
},
"ended_at": {
"type": "string",
"format": "date-time"
@@ -12148,10 +12061,7 @@
"cli",
"ssh_connection",
"vscode_connection",
"jetbrains_connection",
"task_auto_pause",
"task_manual_pause",
"task_resume"
"jetbrains_connection"
],
"x-enum-varnames": [
"BuildReasonInitiator",
@@ -12162,10 +12072,7 @@
"BuildReasonCLI",
"BuildReasonSSHConnection",
"BuildReasonVSCodeConnection",
"BuildReasonJetbrainsConnection",
"BuildReasonTaskAutoPause",
"BuildReasonTaskManualPause",
"BuildReasonTaskResume"
"BuildReasonJetbrainsConnection"
]
},
"codersdk.CORSBehavior": {
@@ -12794,8 +12701,7 @@
"ssh_connection",
"vscode_connection",
"jetbrains_connection",
"task_manual_pause",
"task_resume"
"task_manual_pause"
],
"x-enum-varnames": [
"CreateWorkspaceBuildReasonDashboard",
@@ -12803,8 +12709,7 @@
"CreateWorkspaceBuildReasonSSHConnection",
"CreateWorkspaceBuildReasonVSCodeConnection",
"CreateWorkspaceBuildReasonJetbrainsConnection",
"CreateWorkspaceBuildReasonTaskManualPause",
"CreateWorkspaceBuildReasonTaskResume"
"CreateWorkspaceBuildReasonTaskManualPause"
]
},
"codersdk.CreateWorkspaceBuildRequest": {
@@ -16742,14 +16647,6 @@
}
}
},
"codersdk.ResumeTaskResponse": {
"type": "object",
"properties": {
"workspace_build": {
"$ref": "#/definitions/codersdk.WorkspaceBuild"
}
}
},
"codersdk.RetentionConfig": {
"type": "object",
"properties": {
@@ -17361,9 +17258,6 @@
"default_ttl_ms": {
"type": "integer"
},
"deleted": {
"type": "boolean"
},
"deprecated": {
"type": "boolean"
},
-63
View File
@@ -421,69 +421,6 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Expire API key
// @ID expire-api-key
// @Security CoderSessionToken
// @Tags Users
// @Param user path string true "User ID, name, or me"
// @Param keyid path string true "Key ID" format(string)
// @Success 204
// @Failure 404 {object} codersdk.Response
// @Failure 500 {object} codersdk.Response
// @Router /users/{user}/keys/{keyid}/expire [put]
func (api *API) expireAPIKey(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
keyID = chi.URLParam(r, "keyid")
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
)
defer commitAudit()
if err := api.Database.InTx(func(db database.Store) error {
key, err := db.GetAPIKeyByID(ctx, keyID)
if err != nil {
return xerrors.Errorf("fetch API key: %w", err)
}
if !key.ExpiresAt.After(api.Clock.Now()) {
return nil // Already expired
}
aReq.Old = key
if err := db.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{
ID: key.ID,
LastUsed: key.LastUsed,
ExpiresAt: dbtime.Now(),
IPAddress: key.IPAddress,
}); err != nil {
return xerrors.Errorf("expire API key: %w", err)
}
// Fetch the updated key for audit log.
newKey, err := db.GetAPIKeyByID(ctx, keyID)
if err != nil {
api.Logger.Warn(ctx, "failed to fetch updated API key for audit log", slog.Error(err))
} else {
aReq.New = newKey
}
return nil
}, nil); httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
} else if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error expiring API key.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get token config
// @ID get-token-config
// @Security CoderSessionToken
+4 -159
View File
@@ -400,7 +400,7 @@ func TestAPIKey_Deleted(t *testing.T) {
require.Error(t, err)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
}
func TestAPIKey_SetDefault(t *testing.T) {
@@ -439,7 +439,7 @@ func TestAPIKey_PrebuildsNotAllowed(t *testing.T) {
DeploymentValues: dc,
})
setupCtx := testutil.Context(t, testutil.WaitLong)
ctx := testutil.Context(t, testutil.WaitLong)
// Given: an existing api token for the prebuilds user
_, prebuildsToken := dbgen.APIKey(t, db, database.APIKey{
@@ -448,167 +448,12 @@ func TestAPIKey_PrebuildsNotAllowed(t *testing.T) {
client.SetSessionToken(prebuildsToken)
// When: the prebuilds user tries to create an API key
_, err := client.CreateAPIKey(setupCtx, database.PrebuildsSystemUserID.String())
_, err := client.CreateAPIKey(ctx, database.PrebuildsSystemUserID.String())
// Then: denied.
require.ErrorContains(t, err, httpapi.ResourceForbiddenResponse.Message)
// When: the prebuilds user tries to create a token
_, err = client.CreateToken(setupCtx, database.PrebuildsSystemUserID.String(), codersdk.CreateTokenRequest{})
_, err = client.CreateToken(ctx, database.PrebuildsSystemUserID.String(), codersdk.CreateTokenRequest{})
// Then: also denied.
require.ErrorContains(t, err, httpapi.ResourceForbiddenResponse.Message)
}
//nolint:tparallel,paralleltest // Subtests share the same coderdtest instance and auditor.
func TestExpireAPIKey(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
adminClient := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
admin := coderdtest.CreateFirstUser(t, adminClient)
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, admin.OrganizationID)
t.Run("OwnerCanExpireOwnToken", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
// Create a token.
res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Lifetime: time.Hour * 24 * 7,
})
require.NoError(t, err)
keyID := strings.Split(res.Key, "-")[0]
// Verify the token is not expired.
key, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
require.NoError(t, err)
require.True(t, key.ExpiresAt.After(time.Now()))
auditor.ResetLogs()
// Expire the token.
err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID)
require.NoError(t, err)
// Verify the token is expired.
key, err = adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
require.NoError(t, err)
require.True(t, key.ExpiresAt.Before(time.Now()))
// Verify audit log.
als := auditor.AuditLogs()
require.Len(t, als, 1)
require.Equal(t, database.AuditActionWrite, als[0].Action)
require.Equal(t, database.ResourceTypeApiKey, als[0].ResourceType)
require.Equal(t, admin.UserID.String(), als[0].UserID.String())
})
t.Run("AdminCanExpireOtherUsersToken", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
// Create a token for the member.
res, err := memberClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Lifetime: time.Hour * 24 * 7,
})
require.NoError(t, err)
keyID := strings.Split(res.Key, "-")[0]
// Admin expires the member's token.
err = adminClient.ExpireAPIKey(ctx, member.ID.String(), keyID)
require.NoError(t, err)
// Verify the token is expired.
key, err := memberClient.APIKeyByID(ctx, codersdk.Me, keyID)
require.NoError(t, err)
require.True(t, key.ExpiresAt.Before(time.Now()))
})
t.Run("MemberCannotExpireOtherUsersToken", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
// Create a token for the admin.
res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Lifetime: time.Hour * 24 * 7,
})
require.NoError(t, err)
keyID := strings.Split(res.Key, "-")[0]
// Member attempts to expire admin's token.
err = memberClient.ExpireAPIKey(ctx, admin.UserID.String(), keyID)
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
// Members cannot read other users, so they get a 404 Not Found
// from the authorization layer.
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
t.Run("NotFound", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
// Try to expire a non-existent token.
err := adminClient.ExpireAPIKey(ctx, codersdk.Me, "nonexistent")
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
t.Run("ExpiringAlreadyExpiredTokenSucceeds", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
// Create and expire a token.
res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Lifetime: time.Hour * 24 * 7,
})
require.NoError(t, err)
keyID := strings.Split(res.Key, "-")[0]
// Expire it once.
err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID)
require.NoError(t, err)
// Invariant: make sure it's actually expired
key, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
require.NoError(t, err)
require.LessOrEqual(t, key.ExpiresAt, time.Now(), "key should be expired")
// Expire it again - should succeed (idempotent).
err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID)
require.NoError(t, err)
// Token should still be just as expired as before. No more, no less.
keyAgain, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
require.NoError(t, err)
require.Equal(t, key.ExpiresAt, keyAgain.ExpiresAt, "expiration should be idempotent")
})
t.Run("DeletingExpiredTokenSucceeds", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
// Create a token.
res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Lifetime: time.Hour * 24 * 7,
})
require.NoError(t, err)
keyID := strings.Split(res.Key, "-")[0]
// Expire it first.
err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID)
require.NoError(t, err)
// Verify it's expired.
key, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
require.NoError(t, err)
require.True(t, key.ExpiresAt.Before(time.Now()))
// Delete the expired token - should succeed.
err = adminClient.DeleteAPIKey(ctx, codersdk.Me, keyID)
require.NoError(t, err)
// Verify it's gone.
_, err = adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
}
+18 -29
View File
@@ -48,10 +48,9 @@ type Executor struct {
tick <-chan time.Time
statsCh chan<- Stats
// NotificationsEnqueuer handles enqueueing notifications for delivery by SMTP, webhook, etc.
notificationsEnqueuer notifications.Enqueuer
reg prometheus.Registerer
experiments codersdk.Experiments
workspaceBuilderMetrics *wsbuilder.Metrics
notificationsEnqueuer notifications.Enqueuer
reg prometheus.Registerer
experiments codersdk.Experiments
metrics executorMetrics
}
@@ -68,24 +67,23 @@ type Stats struct {
}
// New returns a new wsactions executor.
func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, fc *files.Cache, reg prometheus.Registerer, tss *atomic.Pointer[schedule.TemplateScheduleStore], auditor *atomic.Pointer[audit.Auditor], acs *atomic.Pointer[dbauthz.AccessControlStore], buildUsageChecker *atomic.Pointer[wsbuilder.UsageChecker], log slog.Logger, tick <-chan time.Time, enqueuer notifications.Enqueuer, exp codersdk.Experiments, workspaceBuilderMetrics *wsbuilder.Metrics) *Executor {
func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, fc *files.Cache, reg prometheus.Registerer, tss *atomic.Pointer[schedule.TemplateScheduleStore], auditor *atomic.Pointer[audit.Auditor], acs *atomic.Pointer[dbauthz.AccessControlStore], buildUsageChecker *atomic.Pointer[wsbuilder.UsageChecker], log slog.Logger, tick <-chan time.Time, enqueuer notifications.Enqueuer, exp codersdk.Experiments) *Executor {
factory := promauto.With(reg)
le := &Executor{
//nolint:gocritic // Autostart has a limited set of permissions.
ctx: dbauthz.AsAutostart(ctx),
db: db,
ps: ps,
fileCache: fc,
templateScheduleStore: tss,
tick: tick,
log: log.Named("autobuild"),
auditor: auditor,
accessControlStore: acs,
buildUsageChecker: buildUsageChecker,
notificationsEnqueuer: enqueuer,
reg: reg,
experiments: exp,
workspaceBuilderMetrics: workspaceBuilderMetrics,
ctx: dbauthz.AsAutostart(ctx),
db: db,
ps: ps,
fileCache: fc,
templateScheduleStore: tss,
tick: tick,
log: log.Named("autobuild"),
auditor: auditor,
accessControlStore: acs,
buildUsageChecker: buildUsageChecker,
notificationsEnqueuer: enqueuer,
reg: reg,
experiments: exp,
metrics: executorMetrics{
autobuildExecutionDuration: factory.NewHistogram(prometheus.HistogramOpts{
Namespace: "coderd",
@@ -337,8 +335,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
SetLastWorkspaceBuildInTx(&latestBuild).
SetLastWorkspaceBuildJobInTx(&latestJob).
Experiments(e.experiments).
Reason(reason).
BuildMetrics(e.workspaceBuilderMetrics)
Reason(reason)
log.Debug(e.ctx, "auto building workspace", slog.F("transition", nextTransition))
if nextTransition == database.WorkspaceTransitionStart &&
useActiveVersion(accessControl, ws) {
@@ -525,18 +522,10 @@ func getNextTransition(
) {
switch {
case isEligibleForAutostop(user, ws, latestBuild, latestJob, currentTick):
// Use task-specific reason for AI task workspaces.
if ws.TaskID.Valid {
return database.WorkspaceTransitionStop, database.BuildReasonTaskAutoPause, nil
}
return database.WorkspaceTransitionStop, database.BuildReasonAutostop, nil
case isEligibleForAutostart(user, ws, latestBuild, latestJob, templateSchedule, currentTick):
return database.WorkspaceTransitionStart, database.BuildReasonAutostart, nil
case isEligibleForFailedStop(latestBuild, latestJob, templateSchedule, currentTick):
// Use task-specific reason for AI task workspaces.
if ws.TaskID.Valid {
return database.WorkspaceTransitionStop, database.BuildReasonTaskAutoPause, nil
}
return database.WorkspaceTransitionStop, database.BuildReasonAutostop, nil
case isEligibleForDormantStop(ws, templateSchedule, currentTick):
// Only stop started workspaces.
@@ -5,113 +5,12 @@ import (
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/schedule"
)
func Test_getNextTransition_TaskAutoPause(t *testing.T) {
t.Parallel()
// Set up a workspace that is eligible for autostop (past deadline).
now := time.Now()
pastDeadline := now.Add(-time.Hour)
okUser := database.User{Status: database.UserStatusActive}
okBuild := database.WorkspaceBuild{
Transition: database.WorkspaceTransitionStart,
Deadline: pastDeadline,
}
okJob := database.ProvisionerJob{
JobStatus: database.ProvisionerJobStatusSucceeded,
}
okTemplateSchedule := schedule.TemplateScheduleOptions{}
// Failed build setup for failedstop tests.
failedBuild := database.WorkspaceBuild{
Transition: database.WorkspaceTransitionStart,
}
failedJob := database.ProvisionerJob{
JobStatus: database.ProvisionerJobStatusFailed,
CompletedAt: sql.NullTime{Time: now.Add(-time.Hour), Valid: true},
}
failedTemplateSchedule := schedule.TemplateScheduleOptions{
FailureTTL: time.Minute, // TTL already elapsed since job completed an hour ago.
}
testCases := []struct {
Name string
Workspace database.Workspace
Build database.WorkspaceBuild
Job database.ProvisionerJob
TemplateSchedule schedule.TemplateScheduleOptions
ExpectedReason database.BuildReason
}{
{
Name: "RegularWorkspace_Autostop",
Workspace: database.Workspace{
DormantAt: sql.NullTime{Valid: false},
},
Build: okBuild,
Job: okJob,
TemplateSchedule: okTemplateSchedule,
ExpectedReason: database.BuildReasonAutostop,
},
{
Name: "TaskWorkspace_Autostop_UsesTaskAutoPause",
Workspace: database.Workspace{
DormantAt: sql.NullTime{Valid: false},
TaskID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
},
Build: okBuild,
Job: okJob,
TemplateSchedule: okTemplateSchedule,
ExpectedReason: database.BuildReasonTaskAutoPause,
},
{
Name: "RegularWorkspace_FailedStop",
Workspace: database.Workspace{
DormantAt: sql.NullTime{Valid: false},
},
Build: failedBuild,
Job: failedJob,
TemplateSchedule: failedTemplateSchedule,
ExpectedReason: database.BuildReasonAutostop,
},
{
Name: "TaskWorkspace_FailedStop_UsesTaskAutoPause",
Workspace: database.Workspace{
DormantAt: sql.NullTime{Valid: false},
TaskID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
},
Build: failedBuild,
Job: failedJob,
TemplateSchedule: failedTemplateSchedule,
ExpectedReason: database.BuildReasonTaskAutoPause,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
transition, reason, err := getNextTransition(
okUser,
tc.Workspace,
tc.Build,
tc.Job,
tc.TemplateSchedule,
now,
)
require.NoError(t, err)
require.Equal(t, database.WorkspaceTransitionStop, transition)
require.Equal(t, tc.ExpectedReason, reason)
})
}
}
func Test_isEligibleForAutostart(t *testing.T) {
t.Parallel()
@@ -2019,11 +2019,5 @@ func TestExecutorTaskWorkspace(t *testing.T) {
assert.Contains(t, stats.Transitions, workspace.ID, "task workspace should be in transitions")
assert.Equal(t, database.WorkspaceTransitionStop, stats.Transitions[workspace.ID], "should autostop the workspace")
require.Empty(t, stats.Errors, "should have no errors when managing task workspaces")
// Then: The build reason should be TaskAutoPause (not regular Autostop)
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
assert.Equal(t, codersdk.BuildReasonTaskAutoPause, workspace.LatestBuild.Reason, "task workspace should use TaskAutoPause build reason")
})
}
-3
View File
@@ -245,7 +245,6 @@ type Options struct {
MetadataBatcherOptions []metadatabatcher.Option
ProvisionerdServerMetrics *provisionerdserver.Metrics
WorkspaceBuilderMetrics *wsbuilder.Metrics
// WorkspaceAppAuditSessionTimeout allows changing the timeout for audit
// sessions. Raising or lowering this value will directly affect the write
@@ -1080,7 +1079,6 @@ func New(options *Options) *API {
r.Post("/send", api.taskSend)
r.Get("/logs", api.taskLogs)
r.Post("/pause", api.pauseTask)
r.Post("/resume", api.resumeTask)
})
})
})
@@ -1399,7 +1397,6 @@ func New(options *Options) *API {
r.Route("/{keyid}", func(r chi.Router) {
r.Get("/", api.apiKeyByID)
r.Delete("/", api.deleteAPIKey)
r.Put("/expire", api.expireAPIKey)
})
})
-3
View File
@@ -191,7 +191,6 @@ type Options struct {
TelemetryReporter telemetry.Reporter
ProvisionerdServerMetrics *provisionerdserver.Metrics
WorkspaceBuilderMetrics *wsbuilder.Metrics
UsageInserter usage.Inserter
}
@@ -400,7 +399,6 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
options.AutobuildTicker,
options.NotificationsEnqueuer,
experiments,
options.WorkspaceBuilderMetrics,
).WithStatsChannel(options.AutobuildStats)
lifecycleExecutor.Run()
@@ -622,7 +620,6 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
AppEncryptionKeyCache: options.APIKeyEncryptionCache,
OIDCConvertKeyCache: options.OIDCConvertKeyCache,
ProvisionerdServerMetrics: options.ProvisionerdServerMetrics,
WorkspaceBuilderMetrics: options.WorkspaceBuilderMetrics,
}
}
-2
View File
@@ -17,6 +17,4 @@ const (
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
CheckGroupAclIsObject CheckConstraint = "group_acl_is_object" // workspaces
CheckUserAclIsObject CheckConstraint = "user_acl_is_object" // workspaces
)
+1
View File
@@ -93,6 +93,7 @@ type TxOptions struct {
// IncrementExecutionCount is a helper function for external packages
// to increment the unexported count.
// Mainly for `dbmem`.
func IncrementExecutionCount(opts *TxOptions) {
opts.executionCount++
}
-3
View File
@@ -981,9 +981,6 @@ func AIBridgeInterception(interception database.AIBridgeInterception, initiator
if interception.EndedAt.Valid {
intc.EndedAt = &interception.EndedAt.Time
}
if interception.Client.Valid {
intc.Client = &interception.Client.String
}
return intc
}
-229
View File
@@ -9,7 +9,6 @@ import (
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
@@ -207,231 +206,3 @@ func TestTemplateVersionParameter_BadDescription(t *testing.T) {
req.NoError(err)
req.NotEmpty(sdk.DescriptionPlaintext, "broke the markdown parser with %v", desc)
}
func TestAIBridgeInterception(t *testing.T) {
t.Parallel()
now := dbtime.Now()
interceptionID := uuid.New()
initiatorID := uuid.New()
cases := []struct {
name string
interception database.AIBridgeInterception
initiator database.VisibleUser
tokenUsages []database.AIBridgeTokenUsage
userPrompts []database.AIBridgeUserPrompt
toolUsages []database.AIBridgeToolUsage
expected codersdk.AIBridgeInterception
}{
{
name: "all_optional_values_set",
interception: database.AIBridgeInterception{
ID: interceptionID,
InitiatorID: initiatorID,
Provider: "anthropic",
Model: "claude-3-opus",
StartedAt: now,
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"key":"value"}`),
Valid: true,
},
EndedAt: sql.NullTime{
Time: now.Add(time.Minute),
Valid: true,
},
APIKeyID: sql.NullString{
String: "api-key-123",
Valid: true,
},
Client: sql.NullString{
String: "claude-code/1.0.0",
Valid: true,
},
},
initiator: database.VisibleUser{
ID: initiatorID,
Username: "testuser",
Name: "Test User",
AvatarURL: "https://example.com/avatar.png",
},
tokenUsages: []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
InterceptionID: interceptionID,
ProviderResponseID: "resp-123",
InputTokens: 100,
OutputTokens: 200,
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"cache":"hit"}`),
Valid: true,
},
CreatedAt: now.Add(10 * time.Second),
},
},
userPrompts: []database.AIBridgeUserPrompt{
{
ID: uuid.New(),
InterceptionID: interceptionID,
ProviderResponseID: "resp-123",
Prompt: "Hello, world!",
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"role":"user"}`),
Valid: true,
},
CreatedAt: now.Add(5 * time.Second),
},
},
toolUsages: []database.AIBridgeToolUsage{
{
ID: uuid.New(),
InterceptionID: interceptionID,
ProviderResponseID: "resp-123",
ServerUrl: sql.NullString{
String: "https://mcp.example.com",
Valid: true,
},
Tool: "read_file",
Input: `{"path":"/tmp/test.txt"}`,
Injected: true,
InvocationError: sql.NullString{
String: "file not found",
Valid: true,
},
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"duration_ms":50}`),
Valid: true,
},
CreatedAt: now.Add(15 * time.Second),
},
},
expected: codersdk.AIBridgeInterception{
ID: interceptionID,
Initiator: codersdk.MinimalUser{
ID: initiatorID,
Username: "testuser",
Name: "Test User",
AvatarURL: "https://example.com/avatar.png",
},
Provider: "anthropic",
Model: "claude-3-opus",
Metadata: map[string]any{"key": "value"},
StartedAt: now,
},
},
{
name: "no_optional_values_set",
interception: database.AIBridgeInterception{
ID: interceptionID,
InitiatorID: initiatorID,
Provider: "openai",
Model: "gpt-4",
StartedAt: now,
Metadata: pqtype.NullRawMessage{Valid: false},
EndedAt: sql.NullTime{Valid: false},
APIKeyID: sql.NullString{Valid: false},
Client: sql.NullString{Valid: false},
},
initiator: database.VisibleUser{
ID: initiatorID,
Username: "minimaluser",
Name: "",
AvatarURL: "",
},
tokenUsages: nil,
userPrompts: nil,
toolUsages: nil,
expected: codersdk.AIBridgeInterception{
ID: interceptionID,
Initiator: codersdk.MinimalUser{
ID: initiatorID,
Username: "minimaluser",
Name: "",
AvatarURL: "",
},
Provider: "openai",
Model: "gpt-4",
Metadata: nil,
StartedAt: now,
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := db2sdk.AIBridgeInterception(
tc.interception,
tc.initiator,
tc.tokenUsages,
tc.userPrompts,
tc.toolUsages,
)
// Check basic fields.
require.Equal(t, tc.expected.ID, result.ID)
require.Equal(t, tc.expected.Initiator, result.Initiator)
require.Equal(t, tc.expected.Provider, result.Provider)
require.Equal(t, tc.expected.Model, result.Model)
require.Equal(t, tc.expected.StartedAt.UTC(), result.StartedAt.UTC())
require.Equal(t, tc.expected.Metadata, result.Metadata)
// Check optional pointer fields.
if tc.interception.APIKeyID.Valid {
require.NotNil(t, result.APIKeyID)
require.Equal(t, tc.interception.APIKeyID.String, *result.APIKeyID)
} else {
require.Nil(t, result.APIKeyID)
}
if tc.interception.EndedAt.Valid {
require.NotNil(t, result.EndedAt)
require.Equal(t, tc.interception.EndedAt.Time.UTC(), result.EndedAt.UTC())
} else {
require.Nil(t, result.EndedAt)
}
if tc.interception.Client.Valid {
require.NotNil(t, result.Client)
require.Equal(t, tc.interception.Client.String, *result.Client)
} else {
require.Nil(t, result.Client)
}
// Check slices.
require.Len(t, result.TokenUsages, len(tc.tokenUsages))
require.Len(t, result.UserPrompts, len(tc.userPrompts))
require.Len(t, result.ToolUsages, len(tc.toolUsages))
// Verify token usages are converted correctly.
for i, tu := range tc.tokenUsages {
require.Equal(t, tu.ID, result.TokenUsages[i].ID)
require.Equal(t, tu.InterceptionID, result.TokenUsages[i].InterceptionID)
require.Equal(t, tu.ProviderResponseID, result.TokenUsages[i].ProviderResponseID)
require.Equal(t, tu.InputTokens, result.TokenUsages[i].InputTokens)
require.Equal(t, tu.OutputTokens, result.TokenUsages[i].OutputTokens)
}
// Verify user prompts are converted correctly.
for i, up := range tc.userPrompts {
require.Equal(t, up.ID, result.UserPrompts[i].ID)
require.Equal(t, up.InterceptionID, result.UserPrompts[i].InterceptionID)
require.Equal(t, up.ProviderResponseID, result.UserPrompts[i].ProviderResponseID)
require.Equal(t, up.Prompt, result.UserPrompts[i].Prompt)
}
// Verify tool usages are converted correctly.
for i, toolUsage := range tc.toolUsages {
require.Equal(t, toolUsage.ID, result.ToolUsages[i].ID)
require.Equal(t, toolUsage.InterceptionID, result.ToolUsages[i].InterceptionID)
require.Equal(t, toolUsage.ProviderResponseID, result.ToolUsages[i].ProviderResponseID)
require.Equal(t, toolUsage.ServerUrl.String, result.ToolUsages[i].ServerURL)
require.Equal(t, toolUsage.Tool, result.ToolUsages[i].Tool)
require.Equal(t, toolUsage.Input, result.ToolUsages[i].Input)
require.Equal(t, toolUsage.Injected, result.ToolUsages[i].Injected)
require.Equal(t, toolUsage.InvocationError.String, result.ToolUsages[i].InvocationError)
}
})
}
}
+5 -3
View File
@@ -19,6 +19,7 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
@@ -29,6 +30,7 @@ import (
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/rbac/rolestore"
"github.com/coder/coder/v2/coderd/taskname"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/provisionerd/proto"
@@ -1590,7 +1592,6 @@ func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertA
Model: takeFirst(seed.Model, "model"),
Metadata: takeFirstSlice(seed.Metadata, json.RawMessage("{}")),
StartedAt: takeFirst(seed.StartedAt, dbtime.Now()),
Client: seed.Client,
})
if endedAt != nil {
interception, err = db.UpdateAIBridgeInterceptionEnded(genCtx, database.UpdateAIBridgeInterceptionEndedParams{
@@ -1663,12 +1664,13 @@ func Task(t testing.TB, db database.Store, orig database.TaskTable) database.Tas
parameters = json.RawMessage([]byte("{}"))
}
taskName := taskname.Generate(genCtx, slog.Make(), orig.Prompt)
task, err := db.InsertTask(genCtx, database.InsertTaskParams{
ID: takeFirst(orig.ID, uuid.New()),
OrganizationID: orig.OrganizationID,
OwnerID: orig.OwnerID,
Name: takeFirst(orig.Name, testutil.GetRandomNameHyphenated(t)),
DisplayName: takeFirst(orig.DisplayName, testutil.GetRandomNameHyphenated(t)),
Name: takeFirst(orig.Name, taskName.Name),
DisplayName: takeFirst(orig.DisplayName, taskName.DisplayName),
WorkspaceID: orig.WorkspaceID,
TemplateVersionID: orig.TemplateVersionID,
TemplateParameters: parameters,
+2 -7
View File
@@ -1023,8 +1023,7 @@ CREATE TABLE aibridge_interceptions (
started_at timestamp with time zone NOT NULL,
metadata jsonb,
ended_at timestamp with time zone,
api_key_id text,
client character varying(64) DEFAULT 'Unknown'::character varying
api_key_id text
);
COMMENT ON TABLE aibridge_interceptions IS 'Audit log of requests intercepted by AI Bridge';
@@ -2737,9 +2736,7 @@ CREATE TABLE workspaces (
favorite boolean DEFAULT false NOT NULL,
next_start_at timestamp with time zone,
group_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT group_acl_is_object CHECK ((jsonb_typeof(group_acl) = 'object'::text)),
CONSTRAINT user_acl_is_object CHECK ((jsonb_typeof(user_acl) = 'object'::text))
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL
);
COMMENT ON COLUMN workspaces.favorite IS 'Favorite is true if the workspace owner has favorited the workspace.';
@@ -3275,8 +3272,6 @@ CREATE INDEX idx_agent_stats_created_at ON workspace_agent_stats USING btree (cr
CREATE INDEX idx_agent_stats_user_id ON workspace_agent_stats USING btree (user_id);
CREATE INDEX idx_aibridge_interceptions_client ON aibridge_interceptions USING btree (client);
CREATE INDEX idx_aibridge_interceptions_initiator_id ON aibridge_interceptions USING btree (initiator_id);
CREATE INDEX idx_aibridge_interceptions_model ON aibridge_interceptions USING btree (model);
@@ -1,3 +0,0 @@
ALTER TABLE workspaces
DROP CONSTRAINT IF EXISTS group_acl_is_object,
DROP CONSTRAINT IF EXISTS user_acl_is_object;
@@ -1,9 +0,0 @@
-- Add constraints that reject 'null'::jsonb for group and user ACLs
-- because they would break the new workspace_expanded view.
UPDATE workspaces SET group_acl = '{}'::jsonb WHERE group_acl = 'null'::jsonb;
UPDATE workspaces SET user_acl = '{}'::jsonb WHERE user_acl = 'null'::jsonb;
ALTER TABLE workspaces
ADD CONSTRAINT group_acl_is_object CHECK (jsonb_typeof(group_acl) = 'object'),
ADD CONSTRAINT user_acl_is_object CHECK (jsonb_typeof(user_acl) = 'object');
@@ -1,2 +0,0 @@
ALTER TABLE aibridge_interceptions
DROP COLUMN client;
@@ -1,5 +0,0 @@
ALTER TABLE aibridge_interceptions
ADD COLUMN client VARCHAR(64)
DEFAULT 'Unknown';
CREATE INDEX idx_aibridge_interceptions_client ON aibridge_interceptions (client);
@@ -1,35 +0,0 @@
-- Fixture for migration 000417_workspace_acl_object_constraint.
-- Inserts a workspace with 'null'::json ACLs to ensure the migration
-- correctly normalizes such values.
INSERT INTO workspaces (
id,
created_at,
updated_at,
owner_id,
organization_id,
template_id,
deleted,
name,
last_used_at,
automatic_updates,
favorite,
group_acl,
user_acl
)
VALUES (
'6f6fdbee-4c18-4a5c-8a8d-9b811c9f0a28',
'2024-02-10 00:00:00+00',
'2024-02-10 00:00:00+00',
'30095c71-380b-457a-8995-97b8ee6e5307',
'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1',
'4cc1f466-f326-477e-8762-9d0c6781fc56',
false,
'acl-null-workspace',
'0001-01-01 00:00:00+00',
'never',
false,
'null'::jsonb,
'null'::jsonb
)
ON CONFLICT DO NOTHING;
-3
View File
@@ -790,7 +790,6 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, ar
arg.InitiatorID,
arg.Provider,
arg.Model,
arg.Client,
arg.AfterID,
arg.Offset,
arg.Limit,
@@ -811,7 +810,6 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, ar
&i.AIBridgeInterception.Metadata,
&i.AIBridgeInterception.EndedAt,
&i.AIBridgeInterception.APIKeyID,
&i.AIBridgeInterception.Client,
&i.VisibleUser.ID,
&i.VisibleUser.Username,
&i.VisibleUser.Name,
@@ -849,7 +847,6 @@ func (q *sqlQuerier) CountAuthorizedAIBridgeInterceptions(ctx context.Context, a
arg.InitiatorID,
arg.Provider,
arg.Model,
arg.Client,
)
if err != nil {
return 0, err
-1
View File
@@ -3642,7 +3642,6 @@ type AIBridgeInterception struct {
Metadata pqtype.NullRawMessage `db:"metadata" json:"metadata"`
EndedAt sql.NullTime `db:"ended_at" json:"ended_at"`
APIKeyID sql.NullString `db:"api_key_id" json:"api_key_id"`
Client sql.NullString `db:"client" json:"client"`
}
// Audit log of tokens used by intercepted requests in AI Bridge
-62
View File
@@ -6765,65 +6765,6 @@ func TestWorkspaceBuildDeadlineConstraint(t *testing.T) {
}
}
func TestWorkspaceACLObjectConstraint(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
template := dbgen.Template(t, db, database.Template{
CreatedBy: user.ID,
OrganizationID: org.ID,
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
TemplateID: template.ID,
Deleted: false,
})
t.Run("GroupACLNull", func(t *testing.T) {
t.Parallel()
var nilACL database.WorkspaceACL
ctx := testutil.Context(t, testutil.WaitLong)
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
ID: workspace.ID,
GroupACL: nilACL,
UserACL: database.WorkspaceACL{},
})
require.Error(t, err)
require.True(t, database.IsCheckViolation(err, database.CheckGroupAclIsObject))
})
t.Run("UserACLNull", func(t *testing.T) {
t.Parallel()
var nilACL database.WorkspaceACL
ctx := testutil.Context(t, testutil.WaitLong)
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
ID: workspace.ID,
GroupACL: database.WorkspaceACL{},
UserACL: nilACL,
})
require.Error(t, err)
require.True(t, database.IsCheckViolation(err, database.CheckUserAclIsObject))
})
t.Run("ValidEmptyObjects", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
ID: workspace.ID,
GroupACL: database.WorkspaceACL{},
UserACL: database.WorkspaceACL{},
})
require.NoError(t, err)
})
}
// TestGetLatestWorkspaceBuildsByWorkspaceIDs populates the database with
// workspaces and builds. It then tests that
// GetLatestWorkspaceBuildsByWorkspaceIDs returns the latest build for some
@@ -8070,15 +8011,12 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) {
ID: uid,
InitiatorID: user.ID,
Metadata: json.RawMessage("{}"),
Client: sql.NullString{String: "client", Valid: true},
}
intc, err := db.InsertAIBridgeInterception(ctx, insertParams)
require.NoError(t, err)
require.Equal(t, uid, intc.ID)
require.False(t, intc.EndedAt.Valid)
require.True(t, intc.Client.Valid)
require.Equal(t, "client", intc.Client.String)
interceptions = append(interceptions, intc)
}
+16 -35
View File
@@ -123,7 +123,8 @@ WITH interceptions_in_range AS (
WHERE
provider = $1::text
AND model = $2::text
AND COALESCE(client, 'Unknown') = $3::text
-- TODO: use the client value once we have it (see https://github.com/coder/aibridge/issues/31)
AND 'unknown' = $3::text
AND ended_at IS NOT NULL -- incomplete interceptions are not included in summaries
AND ended_at >= $4::timestamptz
AND ended_at < $5::timestamptz
@@ -300,11 +301,6 @@ WHERE
WHEN $5::text != '' THEN aibridge_interceptions.model = $5::text
ELSE true
END
-- Filter client
AND CASE
WHEN $6::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = $6::text
ELSE true
END
-- Authorize Filter clause will be injected below in ListAuthorizedAIBridgeInterceptions
-- @authorize_filter
`
@@ -315,7 +311,6 @@ type CountAIBridgeInterceptionsParams struct {
InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"`
Provider string `db:"provider" json:"provider"`
Model string `db:"model" json:"model"`
Client string `db:"client" json:"client"`
}
func (q *sqlQuerier) CountAIBridgeInterceptions(ctx context.Context, arg CountAIBridgeInterceptionsParams) (int64, error) {
@@ -325,7 +320,6 @@ func (q *sqlQuerier) CountAIBridgeInterceptions(ctx context.Context, arg CountAI
arg.InitiatorID,
arg.Provider,
arg.Model,
arg.Client,
)
var count int64
err := row.Scan(&count)
@@ -378,7 +372,7 @@ func (q *sqlQuerier) DeleteOldAIBridgeRecords(ctx context.Context, beforeTime ti
const getAIBridgeInterceptionByID = `-- name: GetAIBridgeInterceptionByID :one
SELECT
id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client
id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id
FROM
aibridge_interceptions
WHERE
@@ -397,14 +391,13 @@ func (q *sqlQuerier) GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UU
&i.Metadata,
&i.EndedAt,
&i.APIKeyID,
&i.Client,
)
return i, err
}
const getAIBridgeInterceptions = `-- name: GetAIBridgeInterceptions :many
SELECT
id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client
id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id
FROM
aibridge_interceptions
`
@@ -427,7 +420,6 @@ func (q *sqlQuerier) GetAIBridgeInterceptions(ctx context.Context) ([]AIBridgeIn
&i.Metadata,
&i.EndedAt,
&i.APIKeyID,
&i.Client,
); err != nil {
return nil, err
}
@@ -573,11 +565,11 @@ func (q *sqlQuerier) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context,
const insertAIBridgeInterception = `-- name: InsertAIBridgeInterception :one
INSERT INTO aibridge_interceptions (
id, api_key_id, initiator_id, provider, model, metadata, started_at, client
id, api_key_id, initiator_id, provider, model, metadata, started_at
) VALUES (
$1, $2, $3, $4, $5, COALESCE($6::jsonb, '{}'::jsonb), $7, $8
$1, $2, $3, $4, $5, COALESCE($6::jsonb, '{}'::jsonb), $7
)
RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client
RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id
`
type InsertAIBridgeInterceptionParams struct {
@@ -588,7 +580,6 @@ type InsertAIBridgeInterceptionParams struct {
Model string `db:"model" json:"model"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
StartedAt time.Time `db:"started_at" json:"started_at"`
Client sql.NullString `db:"client" json:"client"`
}
func (q *sqlQuerier) InsertAIBridgeInterception(ctx context.Context, arg InsertAIBridgeInterceptionParams) (AIBridgeInterception, error) {
@@ -600,7 +591,6 @@ func (q *sqlQuerier) InsertAIBridgeInterception(ctx context.Context, arg InsertA
arg.Model,
arg.Metadata,
arg.StartedAt,
arg.Client,
)
var i AIBridgeInterception
err := row.Scan(
@@ -612,7 +602,6 @@ func (q *sqlQuerier) InsertAIBridgeInterception(ctx context.Context, arg InsertA
&i.Metadata,
&i.EndedAt,
&i.APIKeyID,
&i.Client,
)
return i, err
}
@@ -751,7 +740,7 @@ func (q *sqlQuerier) InsertAIBridgeUserPrompt(ctx context.Context, arg InsertAIB
const listAIBridgeInterceptions = `-- name: ListAIBridgeInterceptions :many
SELECT
aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata, aibridge_interceptions.ended_at, aibridge_interceptions.api_key_id, aibridge_interceptions.client,
aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata, aibridge_interceptions.ended_at, aibridge_interceptions.api_key_id,
visible_users.id, visible_users.username, visible_users.name, visible_users.avatar_url
FROM
aibridge_interceptions
@@ -784,14 +773,9 @@ WHERE
WHEN $5::text != '' THEN aibridge_interceptions.model = $5::text
ELSE true
END
-- Filter client
AND CASE
WHEN $6::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = $6::text
ELSE true
END
-- Cursor pagination
AND CASE
WHEN $7::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
WHEN $6::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
-- The pagination cursor is the last ID of the previous page.
-- The query is ordered by the started_at field, so select all
-- rows before the cursor and before the after_id UUID.
@@ -799,8 +783,8 @@ WHERE
-- "after_id" terminology comes from our pagination parser in
-- coderd.
(aibridge_interceptions.started_at, aibridge_interceptions.id) < (
(SELECT started_at FROM aibridge_interceptions WHERE id = $7),
$7::uuid
(SELECT started_at FROM aibridge_interceptions WHERE id = $6),
$6::uuid
)
)
ELSE true
@@ -810,8 +794,8 @@ WHERE
ORDER BY
aibridge_interceptions.started_at DESC,
aibridge_interceptions.id DESC
LIMIT COALESCE(NULLIF($9::integer, 0), 100)
OFFSET $8
LIMIT COALESCE(NULLIF($8::integer, 0), 100)
OFFSET $7
`
type ListAIBridgeInterceptionsParams struct {
@@ -820,7 +804,6 @@ type ListAIBridgeInterceptionsParams struct {
InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"`
Provider string `db:"provider" json:"provider"`
Model string `db:"model" json:"model"`
Client string `db:"client" json:"client"`
AfterID uuid.UUID `db:"after_id" json:"after_id"`
Offset int32 `db:"offset_" json:"offset_"`
Limit int32 `db:"limit_" json:"limit_"`
@@ -838,7 +821,6 @@ func (q *sqlQuerier) ListAIBridgeInterceptions(ctx context.Context, arg ListAIBr
arg.InitiatorID,
arg.Provider,
arg.Model,
arg.Client,
arg.AfterID,
arg.Offset,
arg.Limit,
@@ -859,7 +841,6 @@ func (q *sqlQuerier) ListAIBridgeInterceptions(ctx context.Context, arg ListAIBr
&i.AIBridgeInterception.Metadata,
&i.AIBridgeInterception.EndedAt,
&i.AIBridgeInterception.APIKeyID,
&i.AIBridgeInterception.Client,
&i.VisibleUser.ID,
&i.VisibleUser.Username,
&i.VisibleUser.Name,
@@ -883,7 +864,8 @@ SELECT
DISTINCT ON (provider, model, client)
provider,
model,
COALESCE(client, 'Unknown') AS client
-- TODO: use the client value once we have it (see https://github.com/coder/aibridge/issues/31)
'unknown' AS client
FROM
aibridge_interceptions
WHERE
@@ -1065,7 +1047,7 @@ UPDATE aibridge_interceptions
WHERE
id = $2::uuid
AND ended_at IS NULL
RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client
RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id
`
type UpdateAIBridgeInterceptionEndedParams struct {
@@ -1085,7 +1067,6 @@ func (q *sqlQuerier) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg Up
&i.Metadata,
&i.EndedAt,
&i.APIKeyID,
&i.Client,
)
return i, err
}
+6 -14
View File
@@ -1,8 +1,8 @@
-- name: InsertAIBridgeInterception :one
INSERT INTO aibridge_interceptions (
id, api_key_id, initiator_id, provider, model, metadata, started_at, client
id, api_key_id, initiator_id, provider, model, metadata, started_at
) VALUES (
@id, @api_key_id, @initiator_id, @provider, @model, COALESCE(@metadata::jsonb, '{}'::jsonb), @started_at, @client
@id, @api_key_id, @initiator_id, @provider, @model, COALESCE(@metadata::jsonb, '{}'::jsonb), @started_at
)
RETURNING *;
@@ -115,11 +115,6 @@ WHERE
WHEN @model::text != '' THEN aibridge_interceptions.model = @model::text
ELSE true
END
-- Filter client
AND CASE
WHEN @client::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = @client::text
ELSE true
END
-- Authorize Filter clause will be injected below in ListAuthorizedAIBridgeInterceptions
-- @authorize_filter
;
@@ -159,11 +154,6 @@ WHERE
WHEN @model::text != '' THEN aibridge_interceptions.model = @model::text
ELSE true
END
-- Filter client
AND CASE
WHEN @client::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = @client::text
ELSE true
END
-- Cursor pagination
AND CASE
WHEN @after_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
@@ -229,7 +219,8 @@ SELECT
DISTINCT ON (provider, model, client)
provider,
model,
COALESCE(client, 'Unknown') AS client
-- TODO: use the client value once we have it (see https://github.com/coder/aibridge/issues/31)
'unknown' AS client
FROM
aibridge_interceptions
WHERE
@@ -251,7 +242,8 @@ WITH interceptions_in_range AS (
WHERE
provider = @provider::text
AND model = @model::text
AND COALESCE(client, 'Unknown') = @client::text
-- TODO: use the client value once we have it (see https://github.com/coder/aibridge/issues/31)
AND 'unknown' = @client::text
AND ended_at IS NOT NULL -- incomplete interceptions are not included in summaries
AND ended_at >= @ended_at_after::timestamptz
AND ended_at < @ended_at_before::timestamptz
-8
View File
@@ -106,10 +106,6 @@ func ExtractUserContext(ctx context.Context, db database.Store, rw http.Response
if userID, err := uuid.Parse(userQuery); err == nil {
user, err = db.GetUserByID(ctx, userID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return database.User{}, false
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: userErrorMessage,
Detail: fmt.Sprintf("queried user=%q", userQuery),
@@ -124,10 +120,6 @@ func ExtractUserContext(ctx context.Context, db database.Store, rw http.Response
Username: userQuery,
})
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return database.User{}, false
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: userErrorMessage,
Detail: fmt.Sprintf("queried user=%q", userQuery),
+1 -47
View File
@@ -71,53 +71,7 @@ func TestUserParam(t *testing.T) {
})).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
// User "ben" doesn't exist, so expect 404.
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("NotFoundByUsername", func(t *testing.T) {
t.Parallel()
db, rw, r := setup(t)
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(http.HandlerFunc(func(rw http.ResponseWriter, returnedRequest *http.Request) {
r = returnedRequest
})).ServeHTTP(rw, r)
routeContext := chi.NewRouteContext()
routeContext.URLParams.Add("user", "nonexistent-user")
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeContext))
httpmw.ExtractUserParam(db)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
})).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("NotFoundByUUID", func(t *testing.T) {
t.Parallel()
db, rw, r := setup(t)
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(http.HandlerFunc(func(rw http.ResponseWriter, returnedRequest *http.Request) {
r = returnedRequest
})).ServeHTTP(rw, r)
routeContext := chi.NewRouteContext()
// Use a valid UUID that doesn't exist in the database.
routeContext.URLParams.Add("user", "88888888-4444-4444-4444-121212121212")
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeContext))
httpmw.ExtractUserParam(db)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
})).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
require.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("me", func(t *testing.T) {
@@ -262,6 +262,8 @@ func TestWebhookDispatch(t *testing.T) {
// This is not strictly necessary for this test, but it's testing some side logic which is too small for its own test.
require.Equal(t, payload.Payload.UserName, name)
require.Equal(t, payload.Payload.UserUsername, username)
// Right now we don't have a way to query notification templates by ID in dbmem, and it's not necessary to add this
// just to satisfy this test. We can safely assume that as long as this value is not empty that the given value was delivered.
require.NotEmpty(t, payload.Payload.NotificationName)
}
+1 -1
View File
@@ -150,7 +150,7 @@ func TestNotificationPreferences(t *testing.T) {
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
// NOTE: ExtractUserParam gets in the way here, and returns a 400 Bad Request instead of a 403 Forbidden.
// This is not ideal, and we should probably change this behavior.
require.Equal(t, http.StatusNotFound, sdkError.StatusCode())
require.Equal(t, http.StatusBadRequest, sdkError.StatusCode())
})
t.Run("Admin may read any users' preferences", func(t *testing.T) {
+1 -41
View File
@@ -13,7 +13,6 @@ type Metrics struct {
logger slog.Logger
workspaceCreationTimings *prometheus.HistogramVec
workspaceClaimTimings *prometheus.HistogramVec
jobQueueWait *prometheus.HistogramVec
}
type WorkspaceTimingType int
@@ -30,12 +29,6 @@ const (
workspaceTypePrebuild = "prebuild"
)
// BuildReasonPrebuild is the build_reason metric label value for prebuild
// operations. This is distinct from database.BuildReason values since prebuilds
// use BuildReasonInitiator in the database but we want to track them separately
// in metrics. This is also used as a label value by the metrics in wsbuilder.
const BuildReasonPrebuild = workspaceTypePrebuild
type WorkspaceTimingFlags struct {
IsPrebuild bool
IsClaim bool
@@ -97,30 +90,6 @@ func NewMetrics(logger slog.Logger) *Metrics {
NativeHistogramZeroThreshold: 0,
NativeHistogramMaxZeroThreshold: 0,
}, []string{"organization_name", "template_name", "preset_name"}),
jobQueueWait: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "coderd",
Name: "provisioner_job_queue_wait_seconds",
Help: "Time from job creation to acquisition by a provisioner daemon.",
Buckets: []float64{
0.1, // 100ms
0.5, // 500ms
1, // 1s
5, // 5s
10, // 10s
30, // 30s
60, // 1m
120, // 2m
300, // 5m
600, // 10m
900, // 15m
1800, // 30m
},
NativeHistogramBucketFactor: 1.1,
NativeHistogramMaxBucketNumber: 100,
NativeHistogramMinResetDuration: time.Hour,
NativeHistogramZeroThreshold: 0,
NativeHistogramMaxZeroThreshold: 0,
}, []string{"provisioner_type", "job_type", "transition", "build_reason"}),
}
}
@@ -128,10 +97,7 @@ func (m *Metrics) Register(reg prometheus.Registerer) error {
if err := reg.Register(m.workspaceCreationTimings); err != nil {
return err
}
if err := reg.Register(m.workspaceClaimTimings); err != nil {
return err
}
return reg.Register(m.jobQueueWait)
return reg.Register(m.workspaceClaimTimings)
}
// IsTrackable returns true if the workspace build should be tracked in metrics.
@@ -196,9 +162,3 @@ func (m *Metrics) UpdateWorkspaceTimingsMetrics(
// Not a trackable build type (e.g. restart, stop, subsequent builds)
}
}
// ObserveJobQueueWait records the time a provisioner job spent waiting in the queue.
// For non-workspace-build jobs, transition and buildReason should be empty strings.
func (m *Metrics) ObserveJobQueueWait(provisionerType, jobType, transition, buildReason string, waitSeconds float64) {
m.jobQueueWait.WithLabelValues(provisionerType, jobType, transition, buildReason).Observe(waitSeconds)
}
@@ -478,10 +478,6 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
TraceMetadata: jobTraceMetadata,
}
// jobTransition and jobBuildReason are used for metrics; only set for workspace builds.
var jobTransition string
var jobBuildReason string
switch job.Type {
case database.ProvisionerJobTypeWorkspaceBuild:
var input WorkspaceProvisionJob
@@ -588,15 +584,6 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
if err != nil {
return nil, failJob(fmt.Sprintf("convert workspace transition: %s", err))
}
jobTransition = string(workspaceBuild.Transition)
// Prebuilds use BuildReasonInitiator in the database but we want to
// track them separately in metrics. Check the initiator ID to detect
// prebuild jobs.
if job.InitiatorID == database.PrebuildsSystemUserID {
jobBuildReason = BuildReasonPrebuild
} else {
jobBuildReason = string(workspaceBuild.Reason)
}
// A previous workspace build exists
var lastWorkspaceBuildParameters []database.WorkspaceBuildParameter
@@ -838,12 +825,6 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
return nil, failJob(fmt.Sprintf("payload was too big: %d > %d", protobuf.Size(protoJob), drpcsdk.MaxMessageSize))
}
// Record the time the job spent waiting in the queue.
if s.metrics != nil && job.StartedAt.Valid && job.Provisioner.Valid() {
queueWaitSeconds := job.StartedAt.Time.Sub(job.CreatedAt).Seconds()
s.metrics.ObserveJobQueueWait(string(job.Provisioner), string(job.Type), jobTransition, jobBuildReason, queueWaitSeconds)
}
return protoJob, err
}
-1
View File
@@ -385,7 +385,6 @@ func AIBridgeInterceptions(ctx context.Context, db database.Store, query string,
filter.InitiatorID = parseUser(ctx, db, parser, values, "initiator", actorID)
filter.Provider = parser.String(values, "", "provider")
filter.Model = parser.String(values, "", "model")
filter.Client = parser.String(values, "", "client")
// Time must be between started_after and started_before.
filter.StartedAfter = parser.Time3339Nano(values, time.Time{}, "started_after")
+2 -2
View File
@@ -376,7 +376,7 @@ func TestTelemetry(t *testing.T) {
require.Equal(t, snapshot1.Provider, aiBridgeInterception1.Provider)
require.Equal(t, snapshot1.Model, aiBridgeInterception1.Model)
require.Equal(t, snapshot1.Client, "Unknown") // no client info yet
require.Equal(t, snapshot1.Client, "unknown") // no client info yet
require.EqualValues(t, snapshot1.InterceptionCount, 2)
require.EqualValues(t, snapshot1.InterceptionsByRoute, map[string]int64{}) // no route info yet
require.EqualValues(t, snapshot1.InterceptionDurationMillis.P50, 90_000)
@@ -396,7 +396,7 @@ func TestTelemetry(t *testing.T) {
require.Equal(t, snapshot2.Provider, aiBridgeInterception3.Provider)
require.Equal(t, snapshot2.Model, aiBridgeInterception3.Model)
require.Equal(t, snapshot2.Client, "Unknown") // no client info yet
require.Equal(t, snapshot2.Client, "unknown") // no client info yet
require.EqualValues(t, snapshot2.InterceptionCount, 1)
require.EqualValues(t, snapshot2.InterceptionsByRoute, map[string]int64{}) // no route info yet
require.EqualValues(t, snapshot2.InterceptionDurationMillis.P50, 180_000)
-1
View File
@@ -1131,7 +1131,6 @@ func (api *API) convertTemplate(
RequireActiveVersion: templateAccessControl.RequireActiveVersion,
Deprecated: templateAccessControl.IsDeprecated(),
DeprecationMessage: templateAccessControl.Deprecated,
Deleted: template.Deleted,
MaxPortShareLevel: maxPortShareLevel,
UseClassicParameterFlow: template.UseClassicParameterFlow,
CORSBehavior: codersdk.CORSBehavior(template.CorsBehavior),
-43
View File
@@ -1801,49 +1801,6 @@ func TestDeleteTemplate(t *testing.T) {
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
})
t.Run("DeletedIsSet", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
ctx := testutil.Context(t, testutil.WaitLong)
// Verify the deleted field is exposed in the SDK and set to false for active templates
got, err := client.Template(ctx, template.ID)
require.NoError(t, err)
require.False(t, got.Deleted)
})
t.Run("DeletedIsTrue", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
ctx := testutil.Context(t, testutil.WaitLong)
err := client.DeleteTemplate(ctx, template.ID)
require.NoError(t, err)
// Verify the deleted field is set to true by listing templates with
// deleted:true filter.
templates, err := client.Templates(ctx, codersdk.TemplateFilter{
OrganizationID: user.OrganizationID,
SearchQuery: "deleted:true",
})
require.NoError(t, err)
require.Len(t, templates, 1)
require.Equal(t, template.ID, templates[0].ID)
require.True(t, templates[0].Deleted)
})
}
func TestTemplateMetrics(t *testing.T) {
+3 -3
View File
@@ -349,7 +349,7 @@ func TestDeleteUser(t *testing.T) {
err := client.DeleteUser(context.Background(), firstUser.UserID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
})
t.Run("HasWorkspaces", func(t *testing.T) {
t.Parallel()
@@ -1010,7 +1010,7 @@ func TestUpdateUserProfile(t *testing.T) {
require.ErrorAs(t, err, &apiErr)
// Right now, we are raising a BAD request error because we don't support a
// user accessing other users info
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
})
t.Run("ConflictingUsername", func(t *testing.T) {
@@ -2602,7 +2602,7 @@ func TestUserAutofillParameters(t *testing.T) {
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
// u1 should be able to read u2's parameters as u1 is site admin.
_, err = client1.UserAutofillParameters(
+4 -20
View File
@@ -59,17 +59,6 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) {
return
}
// The role parameter distinguishes the real workspace agent from
// other clients using the same agent token (e.g. coder-logstream-kube).
// Only connections with the "agent" role trigger connection monitoring
// that updates first_connected_at/last_connected_at/disconnected_at.
// For backward compatibility, we default to monitoring when the role
// is omitted, since older agents don't send this parameter. In a
// future release, once all agents include role=agent, we can change
// this default to skip monitoring for unspecified roles.
role := r.URL.Query().Get("role")
monitorConnection := role == "" || role == "agent"
api.WebsocketWaitMutex.Lock()
api.WebsocketWaitGroup.Add(1)
api.WebsocketWaitMutex.Unlock()
@@ -132,15 +121,10 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) {
slog.F("agent_api_version", workspaceAgent.APIVersion),
slog.F("agent_resource_id", workspaceAgent.ResourceID))
if monitorConnection {
closeCtx, closeCtxCancel := context.WithCancel(ctx)
defer closeCtxCancel()
monitor := api.startAgentYamuxMonitor(closeCtx, workspace, workspaceAgent, build, mux)
defer monitor.close()
} else {
logger.Debug(ctx, "skipping agent connection monitoring",
slog.F("role", role))
}
closeCtx, closeCtxCancel := context.WithCancel(ctx)
defer closeCtxCancel()
monitor := api.startAgentYamuxMonitor(closeCtx, workspace, workspaceAgent, build, mux)
defer monitor.close()
agentAPI := agentapi.New(agentapi.Options{
AgentID: workspaceAgent.ID,
-83
View File
@@ -11,7 +11,6 @@ import (
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/coderdtest"
"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/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
@@ -169,85 +168,3 @@ func TestAgentAPI_LargeManifest(t *testing.T) {
})
}
}
func TestWorkspaceAgentRPCRole(t *testing.T) {
t.Parallel()
t.Run("AgentRoleMonitorsConnection", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
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()
// Connect with role=agent using ConnectRPCWithRole. This is
// how the real workspace agent connects.
ac := agentsdk.New(client.URL, agentsdk.WithFixedToken(r.AgentToken))
conn, err := ac.ConnectRPCWithRole(ctx, "agent")
require.NoError(t, err)
defer func() {
_ = conn.Close()
}()
// The connection monitor updates the database asynchronously,
// so we need to wait for first_connected_at to be set.
var agent database.WorkspaceAgent
require.Eventually(t, func() bool {
agent, err = db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), r.Agents[0].ID)
if err != nil {
return false
}
return agent.FirstConnectedAt.Valid
}, testutil.WaitShort, testutil.IntervalFast)
assert.True(t, agent.LastConnectedAt.Valid,
"last_connected_at should be set for agent role")
})
t.Run("NonAgentRoleSkipsMonitoring", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
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()
// Connect with a non-agent role using ConnectRPCWithRole.
// This is how coder-logstream-kube should connect.
ac := agentsdk.New(client.URL, agentsdk.WithFixedToken(r.AgentToken))
conn, err := ac.ConnectRPCWithRole(ctx, "logstream-kube")
require.NoError(t, err)
// Send a log to confirm the RPC connection is functional.
agentAPI := agentproto.NewDRPCAgentClient(conn)
_, err = agentAPI.BatchCreateLogs(ctx, &agentproto.BatchCreateLogsRequest{
LogSourceId: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
})
// We don't care about the log source error, just that the
// RPC is functional.
_ = err
// Close the connection and give the server time to process.
_ = conn.Close()
time.Sleep(100 * time.Millisecond)
// Verify that connectivity timestamps were never set.
agent, err := db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), r.Agents[0].ID)
require.NoError(t, err)
assert.False(t, agent.FirstConnectedAt.Valid,
"first_connected_at should NOT be set for non-agent role")
assert.False(t, agent.LastConnectedAt.Valid,
"last_connected_at should NOT be set for non-agent role")
assert.False(t, agent.DisconnectedAt.Valid,
"disconnected_at should NOT be set for non-agent role")
})
// NOTE: Backward compatibility (empty role) is implicitly tested by
// existing tests like TestWorkspaceAgentReportStats which use
// ConnectRPC() (no role). The server defaults to monitoring when
// the role query parameter is omitted.
}
+14 -17
View File
@@ -68,30 +68,27 @@ func SubdomainAppSessionTokenCookie(hostname string) string {
// the wrong value.
//
// We use different cookie names for:
// - path apps: coder_path_app_session_token
// - path apps on primary access URL: coder_session_token
// - path apps on proxies: coder_path_app_session_token
// - subdomain apps: coder_subdomain_app_session_token_{unique_hash}
//
// We prefer the access-method-specific cookie first, then fall back to standard
// Coder token extraction (query parameters, Coder-Session-Token header, etc.).
// First we try the default function to get a token from request, which supports
// query parameters, the Coder-Session-Token header and the coder_session_token
// cookie.
//
// Then we try the specific cookie name for the access method.
func (c AppCookies) TokenFromRequest(r *http.Request, accessMethod AccessMethod) string {
// Prefer the access-method-specific cookie first.
//
// Workspace app requests commonly include an `Authorization` header intended
// for the upstream app (e.g. API calls). `httpmw.APITokenFromRequest` supports
// RFC 6750 bearer tokens, so if we consult it first we'd incorrectly treat
// that upstream header as a Coder session token and ignore the app session
// cookie, breaking token renewal for subdomain apps.
cookie, err := r.Cookie(c.CookieNameForAccessMethod(accessMethod))
if err == nil && cookie.Value != "" {
return cookie.Value
}
// Fall back to standard Coder token extraction (session cookie, query param,
// Coder-Session-Token header, and then Authorization: Bearer).
// Try the default function first.
token := httpmw.APITokenFromRequest(r)
if token != "" {
return token
}
// Then try the specific cookie name for the access method.
cookie, err := r.Cookie(c.CookieNameForAccessMethod(accessMethod))
if err == nil && cookie.Value != "" {
return cookie.Value
}
return ""
}
-18
View File
@@ -1,8 +1,6 @@
package workspaceapps_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
@@ -34,19 +32,3 @@ func TestAppCookies(t *testing.T) {
newCookies := workspaceapps.NewAppCookies("different.com")
require.NotEqual(t, cookies.SubdomainAppSessionToken, newCookies.SubdomainAppSessionToken)
}
func TestAppCookies_TokenFromRequest_PrefersAppCookieOverAuthorizationBearer(t *testing.T) {
t.Parallel()
cookies := workspaceapps.NewAppCookies("apps.example.com")
req := httptest.NewRequest("GET", "https://8081--agent--workspace--user.apps.example.com/", nil)
req.Header.Set("Authorization", "Bearer whatever")
req.AddCookie(&http.Cookie{
Name: cookies.CookieNameForAccessMethod(workspaceapps.AccessMethodSubdomain),
Value: "subdomain-session-token",
})
got := cookies.TokenFromRequest(req, workspaceapps.AccessMethodSubdomain)
require.Equal(t, "subdomain-session-token", got)
}
+1 -2
View File
@@ -382,8 +382,7 @@ func (api *API) postWorkspaceBuildsInternal(
LogLevel(string(createBuild.LogLevel)).
DeploymentValues(api.Options.DeploymentValues).
Experiments(api.Experiments).
TemplateVersionPresetID(createBuild.TemplateVersionPresetID).
BuildMetrics(api.WorkspaceBuilderMetrics)
TemplateVersionPresetID(createBuild.TemplateVersionPresetID)
if (transition == database.WorkspaceTransitionStart || transition == database.WorkspaceTransitionStop) && createBuild.Reason != "" {
builder = builder.Reason(database.BuildReason(createBuild.Reason))
+1 -2
View File
@@ -787,8 +787,7 @@ func createWorkspace(
ActiveVersion().
Experiments(api.Experiments).
DeploymentValues(api.DeploymentValues).
RichParameterValues(req.RichParameterValues).
BuildMetrics(api.WorkspaceBuilderMetrics)
RichParameterValues(req.RichParameterValues)
if req.TemplateVersionID != uuid.Nil {
builder = builder.VersionID(req.TemplateVersionID)
}
-137
View File
@@ -14,7 +14,6 @@ import (
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -22,9 +21,7 @@ import (
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/autobuild"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/coderdtest/promhelp"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
@@ -33,7 +30,6 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/coderd/provisionerdserver"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/render"
@@ -41,7 +37,6 @@ import (
"github.com/coder/coder/v2/coderd/schedule/cron"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/coderd/wsbuilder"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/provisioner/echo"
@@ -5906,135 +5901,3 @@ func TestWorkspaceCreateWithImplicitPreset(t *testing.T) {
require.Equal(t, preset2ID, *ws2.LatestBuild.TemplateVersionPresetID)
})
}
func TestProvisionerJobQueueWaitMetric(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
reg := prometheus.NewRegistry()
metrics := provisionerdserver.NewMetrics(logger)
err := metrics.Register(reg)
require.NoError(t, err)
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
ProvisionerdServerMetrics: metrics,
})
user := coderdtest.CreateFirstUser(t, client)
// Create a template version - this triggers a template_version_import job.
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
// Check that the queue wait metric was recorded for the template_version_import job.
importMetric := promhelp.MetricValue(t, reg, "coderd_provisioner_job_queue_wait_seconds", prometheus.Labels{
"provisioner_type": string(database.ProvisionerTypeEcho),
"job_type": string(database.ProvisionerJobTypeTemplateVersionImport),
"transition": "",
"build_reason": "",
})
require.NotNil(t, importMetric, "import job metric should be recorded")
importHistogram := importMetric.GetHistogram()
require.NotNil(t, importHistogram)
require.Equal(t, uint64(1), importHistogram.GetSampleCount(), "import job should have 1 sample")
require.Greater(t, importHistogram.GetSampleSum(), 0.0, "import job queue wait should be non-zero")
// Create a template and workspace - this triggers a workspace_build job.
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Check that the queue wait metric was recorded for the workspace_build job.
buildMetric := promhelp.MetricValue(t, reg, "coderd_provisioner_job_queue_wait_seconds", prometheus.Labels{
"provisioner_type": string(database.ProvisionerTypeEcho),
"job_type": string(database.ProvisionerJobTypeWorkspaceBuild),
"transition": string(database.WorkspaceTransitionStart),
"build_reason": string(database.BuildReasonInitiator),
})
require.NotNil(t, buildMetric, "workspace build job metric should be recorded")
buildHistogram := buildMetric.GetHistogram()
require.NotNil(t, buildHistogram)
require.Equal(t, uint64(1), buildHistogram.GetSampleCount(), "workspace build job should have 1 sample")
require.Greater(t, buildHistogram.GetSampleSum(), 0.0, "workspace build job queue wait should be non-zero")
}
func TestWorkspaceBuildsEnqueuedMetric(t *testing.T) {
t.Parallel()
var (
logger = testutil.Logger(t)
reg = prometheus.NewRegistry()
metrics = provisionerdserver.NewMetrics(logger)
sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *")
tickCh = make(chan time.Time)
statsCh = make(chan autobuild.Stats)
)
err := metrics.Register(reg)
require.NoError(t, err)
wsBuilderMetrics, err := wsbuilder.NewMetrics(reg)
require.NoError(t, err)
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
ProvisionerdServerMetrics: metrics,
WorkspaceBuilderMetrics: wsBuilderMetrics,
AutobuildTicker: tickCh,
AutobuildStats: statsCh,
})
user := coderdtest.CreateFirstUser(t, client)
// Create a template and workspace with autostart schedule.
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.AutostartSchedule = ptr.Ref(sched.String())
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Stop the workspace to prepare for autostart.
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// Trigger an autostart build via the autobuild ticker. This verifies that
// autostart builds are recorded with build_reason="autostart".
p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, map[string]string{})
require.NoError(t, err)
go func() {
tickTime := sched.Next(workspace.LatestBuild.CreatedAt)
coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime)
tickCh <- tickTime
close(tickCh)
}()
// Wait for the autostart to complete.
stats := <-statsCh
require.Len(t, stats.Errors, 0)
require.Len(t, stats.Transitions, 1)
require.Contains(t, stats.Transitions, workspace.ID)
require.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[workspace.ID])
// Verify the workspace was autostarted.
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
require.Equal(t, codersdk.BuildReasonAutostart, workspace.LatestBuild.Reason)
// Now check the autostart metric was recorded.
autostartCount := promhelp.CounterValue(t, reg, "coderd_workspace_builds_enqueued_total", prometheus.Labels{
"provisioner_type": string(database.ProvisionerTypeEcho),
"build_reason": string(database.BuildReasonAutostart),
"transition": string(database.WorkspaceTransitionStart),
"status": wsbuilder.BuildStatusSuccess,
})
require.Equal(t, 1, autostartCount, "autostart should record 1 enqueue with build_reason=autostart")
}
func mustSchedule(t *testing.T, s string) *cron.Schedule {
t.Helper()
sched, err := cron.Weekly(s)
require.NoError(t, err)
return sched
}
-42
View File
@@ -1,42 +0,0 @@
package wsbuilder
import "github.com/prometheus/client_golang/prometheus"
// Metrics holds metrics related to workspace build creation.
type Metrics struct {
workspaceBuildsEnqueued *prometheus.CounterVec
}
// Metric label values for build status.
const (
BuildStatusSuccess = "success"
BuildStatusFailed = "failed"
)
func NewMetrics(reg prometheus.Registerer) (*Metrics, error) {
m := &Metrics{
workspaceBuildsEnqueued: prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "coderd",
Name: "workspace_builds_enqueued_total",
Help: "Total number of workspace build enqueue attempts.",
}, []string{"provisioner_type", "build_reason", "transition", "status"}),
}
if reg != nil {
if err := reg.Register(m.workspaceBuildsEnqueued); err != nil {
return nil, err
}
}
return m, nil
}
// RecordBuildEnqueued records a workspace build enqueue attempt. It determines
// the status based on whether an error occurred and increments the counter.
func (m *Metrics) RecordBuildEnqueued(provisionerType, buildReason, transition string, err error) {
status := BuildStatusSuccess
if err != nil {
status = BuildStatusFailed
}
m.workspaceBuildsEnqueued.WithLabelValues(provisionerType, buildReason, transition, status).Inc()
}
-31
View File
@@ -90,8 +90,6 @@ type Builder struct {
prebuiltWorkspaceBuildStage sdkproto.PrebuiltWorkspaceBuildStage
verifyNoLegacyParametersOnce bool
buildMetrics *Metrics
}
type UsageChecker interface {
@@ -255,12 +253,6 @@ func (b Builder) TemplateVersionPresetID(id uuid.UUID) Builder {
return b
}
func (b Builder) BuildMetrics(m *Metrics) Builder {
// nolint: revive
b.buildMetrics = m
return b
}
type BuildError struct {
// Status is a suitable HTTP status code
Status int
@@ -321,34 +313,11 @@ func (b *Builder) Build(
return err
})
if err != nil {
b.recordBuildMetrics(provisionerJob, err)
return nil, nil, nil, xerrors.Errorf("build tx: %w", err)
}
b.recordBuildMetrics(provisionerJob, nil)
return workspaceBuild, provisionerJob, provisionerDaemons, nil
}
// recordBuildMetrics records the workspace build enqueue metric if metrics are
// configured. It determines the appropriate build reason label, using "prebuild"
// for prebuild operations instead of the database reason.
func (b *Builder) recordBuildMetrics(job *database.ProvisionerJob, err error) {
if b.buildMetrics == nil {
return
}
if job == nil || !job.Provisioner.Valid() {
return
}
// Determine the build reason for metrics. Prebuilds use BuildReasonInitiator
// in the database but we want to track them separately in metrics.
buildReason := string(b.reason)
if b.prebuiltWorkspaceBuildStage == sdkproto.PrebuiltWorkspaceBuildStage_CREATE {
buildReason = provisionerdserver.BuildReasonPrebuild
}
b.buildMetrics.RecordBuildEnqueued(string(job.Provisioner), buildReason, string(b.trans), err)
}
// buildTx contains the business logic of computing a new build. Attributes of the new database objects are computed
// in a functional style, rather than imperative, to emphasize the logic of how they are defined. A simple cache
// of database-fetched objects is stored on the struct to ensure we only fetch things once, even if they are used in
+14 -42
View File
@@ -152,7 +152,7 @@ func (c *Client) RewriteDERPMap(derpMap *tailcfg.DERPMap) {
// Release Versions from 2.9+
// Deprecated: use ConnectRPC20WithTailnet
func (c *Client) ConnectRPC20(ctx context.Context) (proto.DRPCAgentClient20, error) {
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 0), "")
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 0))
if err != nil {
return nil, err
}
@@ -165,7 +165,7 @@ func (c *Client) ConnectRPC20(ctx context.Context) (proto.DRPCAgentClient20, err
func (c *Client) ConnectRPC20WithTailnet(ctx context.Context) (
proto.DRPCAgentClient20, tailnetproto.DRPCTailnetClient20, error,
) {
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 0), "")
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 0))
if err != nil {
return nil, nil, err
}
@@ -176,7 +176,7 @@ func (c *Client) ConnectRPC20WithTailnet(ctx context.Context) (
// maximally compatible with Coderd Release Versions from 2.12+
// Deprecated: use ConnectRPC21WithTailnet
func (c *Client) ConnectRPC21(ctx context.Context) (proto.DRPCAgentClient21, error) {
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 1), "")
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 1))
if err != nil {
return nil, err
}
@@ -188,7 +188,7 @@ func (c *Client) ConnectRPC21(ctx context.Context) (proto.DRPCAgentClient21, err
func (c *Client) ConnectRPC21WithTailnet(ctx context.Context) (
proto.DRPCAgentClient21, tailnetproto.DRPCTailnetClient21, error,
) {
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 1), "")
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 1))
if err != nil {
return nil, nil, err
}
@@ -200,7 +200,7 @@ func (c *Client) ConnectRPC21WithTailnet(ctx context.Context) (
func (c *Client) ConnectRPC22(ctx context.Context) (
proto.DRPCAgentClient22, tailnetproto.DRPCTailnetClient22, error,
) {
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 2), "")
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 2))
if err != nil {
return nil, nil, err
}
@@ -212,7 +212,7 @@ func (c *Client) ConnectRPC22(ctx context.Context) (
func (c *Client) ConnectRPC23(ctx context.Context) (
proto.DRPCAgentClient23, tailnetproto.DRPCTailnetClient23, error,
) {
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 3), "")
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 3))
if err != nil {
return nil, nil, err
}
@@ -224,7 +224,7 @@ func (c *Client) ConnectRPC23(ctx context.Context) (
func (c *Client) ConnectRPC24(ctx context.Context) (
proto.DRPCAgentClient24, tailnetproto.DRPCTailnetClient24, error,
) {
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 4), "")
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 4))
if err != nil {
return nil, nil, err
}
@@ -236,7 +236,7 @@ func (c *Client) ConnectRPC24(ctx context.Context) (
func (c *Client) ConnectRPC25(ctx context.Context) (
proto.DRPCAgentClient25, tailnetproto.DRPCTailnetClient25, error,
) {
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 5), "")
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 5))
if err != nil {
return nil, nil, err
}
@@ -248,7 +248,7 @@ func (c *Client) ConnectRPC25(ctx context.Context) (
func (c *Client) ConnectRPC26(ctx context.Context) (
proto.DRPCAgentClient26, tailnetproto.DRPCTailnetClient26, error,
) {
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 6), "")
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 6))
if err != nil {
return nil, nil, err
}
@@ -260,7 +260,7 @@ func (c *Client) ConnectRPC26(ctx context.Context) (
func (c *Client) ConnectRPC27(ctx context.Context) (
proto.DRPCAgentClient27, tailnetproto.DRPCTailnetClient27, error,
) {
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 7), "")
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 7))
if err != nil {
return nil, nil, err
}
@@ -272,53 +272,25 @@ func (c *Client) ConnectRPC27(ctx context.Context) (
func (c *Client) ConnectRPC28(ctx context.Context) (
proto.DRPCAgentClient28, tailnetproto.DRPCTailnetClient28, error,
) {
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 8), "")
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 8))
if err != nil {
return nil, nil, err
}
return proto.NewDRPCAgentClient(conn), tailnetproto.NewDRPCTailnetClient(conn), nil
}
// ConnectRPC28WithRole is like ConnectRPC28 but sends an explicit role
// query parameter to the server. Use "agent" for workspace agents to
// enable connection monitoring.
func (c *Client) ConnectRPC28WithRole(ctx context.Context, role string) (
proto.DRPCAgentClient28, tailnetproto.DRPCTailnetClient28, error,
) {
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 8), role)
if err != nil {
return nil, nil, err
}
return proto.NewDRPCAgentClient(conn), tailnetproto.NewDRPCTailnetClient(conn), nil
}
// ConnectRPC connects to the workspace agent API and tailnet API.
// It does not send a role query parameter, so the server will apply
// its default behavior (currently: enable connection monitoring for
// backward compatibility). Use ConnectRPCWithRole to explicitly
// identify the caller's role.
// ConnectRPC connects to the workspace agent API and tailnet API
func (c *Client) ConnectRPC(ctx context.Context) (drpc.Conn, error) {
return c.connectRPCVersion(ctx, proto.CurrentVersion, "")
return c.connectRPCVersion(ctx, proto.CurrentVersion)
}
// ConnectRPCWithRole connects to the workspace agent RPC API with an
// explicit role. The role parameter is sent to the server to identify
// the type of client. Use "agent" for workspace agents to enable
// connection monitoring.
func (c *Client) ConnectRPCWithRole(ctx context.Context, role string) (drpc.Conn, error) {
return c.connectRPCVersion(ctx, proto.CurrentVersion, role)
}
func (c *Client) connectRPCVersion(ctx context.Context, version *apiversion.APIVersion, role string) (drpc.Conn, error) {
func (c *Client) connectRPCVersion(ctx context.Context, version *apiversion.APIVersion) (drpc.Conn, error) {
rpcURL, err := c.SDK.URL.Parse("/api/v2/workspaceagents/me/rpc")
if err != nil {
return nil, xerrors.Errorf("parse url: %w", err)
}
q := rpcURL.Query()
q.Add("version", version.String())
if role != "" {
q.Add("role", role)
}
rpcURL.RawQuery = q.Encode()
jar, err := cookiejar.New(nil)
-5
View File
@@ -17,7 +17,6 @@ type AIBridgeInterception struct {
Initiator MinimalUser `json:"initiator"`
Provider string `json:"provider"`
Model string `json:"model"`
Client *string `json:"client"`
Metadata map[string]any `json:"metadata"`
StartedAt time.Time `json:"started_at" format:"date-time"`
EndedAt *time.Time `json:"ended_at" format:"date-time"`
@@ -76,7 +75,6 @@ type AIBridgeListInterceptionsFilter struct {
StartedAfter time.Time `json:"started_after,omitempty" format:"date-time"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Client string `json:"client,omitempty"`
FilterQuery string `json:"q,omitempty"`
}
@@ -103,9 +101,6 @@ func (f AIBridgeListInterceptionsFilter) asRequestOption() RequestOption {
if f.Model != "" {
params = append(params, fmt.Sprintf("model:%q", f.Model))
}
if f.Client != "" {
params = append(params, fmt.Sprintf("client:%q", f.Client))
}
if f.FilterQuery != "" {
// If custom stuff is added, just add it on here.
params = append(params, f.FilterQuery)
-23
View File
@@ -354,29 +354,6 @@ func (c *Client) PauseTask(ctx context.Context, user string, id uuid.UUID) (Paus
return resp, nil
}
// ResumeTaskResponse represents the response from resuming a task.
type ResumeTaskResponse struct {
WorkspaceBuild *WorkspaceBuild `json:"workspace_build"`
}
func (c *Client) ResumeTask(ctx context.Context, user string, id uuid.UUID) (ResumeTaskResponse, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s/%s/resume", user, id.String()), nil)
if err != nil {
return ResumeTaskResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusAccepted {
return ResumeTaskResponse{}, ReadBodyAsError(res)
}
var resp ResumeTaskResponse
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return ResumeTaskResponse{}, err
}
return resp, nil
}
// TaskLogType indicates the source of a task log entry.
type TaskLogType string
-14
View File
@@ -171,20 +171,6 @@ func (c *Client) DeleteAPIKey(ctx context.Context, userID string, id string) err
return nil
}
// ExpireAPIKey expires an API key by id, setting its expiry to now.
// This preserves the API key record for audit purposes rather than deleting it.
func (c *Client) ExpireAPIKey(ctx context.Context, userID string, id string) error {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/keys/%s/expire", userID, id), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode > http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// GetTokenConfig returns deployment options related to token management
func (c *Client) GetTokenConfig(ctx context.Context, userID string) (TokenConfig, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/tokens/tokenconfig", userID), nil)
+48 -48
View File
@@ -1431,7 +1431,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
}
emailHello := serpent.Option{
Name: "Email: Hello",
Description: "The hostname identifying the SMTP server.",
Description: "The hostname identifying this client to the SMTP server.",
Flag: "email-hello",
Env: "CODER_EMAIL_HELLO",
Default: "localhost",
@@ -1523,7 +1523,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
}
emailTLSCertFile := serpent.Option{
Name: "Email TLS: Certificate File",
Description: "Certificate file to use.",
Description: "Client certificate file for mutual TLS authentication.",
Flag: "email-tls-cert-file",
Env: "CODER_EMAIL_TLS_CERTFILE",
Value: &c.Notifications.SMTP.TLS.CertFile,
@@ -1532,7 +1532,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
}
emailTLSCertKeyFile := serpent.Option{
Name: "Email TLS: Certificate Key File",
Description: "Certificate key file to use.",
Description: "Private key file for the client certificate.",
Flag: "email-tls-cert-key-file",
Env: "CODER_EMAIL_TLS_CERTKEYFILE",
Value: &c.Notifications.SMTP.TLS.KeyFile,
@@ -1551,7 +1551,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
}
workspaceHostnameSuffix := serpent.Option{
Name: "Workspace Hostname Suffix",
Description: "Workspace hostnames use this suffix in SSH config and Coder Connect on Coder Desktop. By default it is coder, resulting in names like myworkspace.coder.",
Description: "Workspace hostnames use this suffix for SSH connections and Coder Connect. By default it is coder, resulting in hostnames like agent.workspace.owner.coder.",
Flag: "workspace-hostname-suffix",
Env: "CODER_WORKSPACE_HOSTNAME_SUFFIX",
YAML: "workspaceHostnameSuffix",
@@ -1680,7 +1680,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "TLS Client CA Files",
Description: "PEM-encoded Certificate Authority file used for checking the authenticity of client.",
Description: "PEM-encoded Certificate Authority file used for checking the authenticity of the client.",
Flag: "tls-client-ca-file",
Env: "CODER_TLS_CLIENT_CA_FILE",
Value: &c.TLS.ClientCAFile,
@@ -1742,7 +1742,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "TLS Ciphers",
Description: "Specify specific TLS ciphers that allowed to be used. See https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L53-L75.",
Description: "Specify specific TLS ciphers that are allowed to be used. See https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L53-L75.",
Flag: "tls-ciphers",
Env: "CODER_TLS_CIPHERS",
Default: "",
@@ -1800,7 +1800,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "DERP Server Region Name",
Description: "Region name that for the embedded DERP server.",
Description: "Region name to use for the embedded DERP server.",
Flag: "derp-server-region-name",
Env: "CODER_DERP_SERVER_REGION_NAME",
Default: "Coder Embedded Relay",
@@ -1811,7 +1811,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "DERP Server STUN Addresses",
Description: "Addresses for STUN servers to establish P2P connections. It's recommended to have at least two STUN servers to give users the best chance of connecting P2P to workspaces. Each STUN server will get it's own DERP region, with region IDs starting at `--derp-server-region-id + 1`. Use special value 'disable' to turn off STUN completely.",
Description: "Addresses for STUN servers to establish P2P connections. It's recommended to have at least two STUN servers to give users the best chance of connecting P2P to workspaces. Each STUN server will get its own DERP region, with region IDs starting at `--derp-server-region-id + 1`. Use special value 'disable' to turn off STUN completely.",
Flag: "derp-server-stun-addresses",
Env: "CODER_DERP_SERVER_STUN_ADDRESSES",
Default: "stun.l.google.com:19302,stun1.l.google.com:19302,stun2.l.google.com:19302,stun3.l.google.com:19302,stun4.l.google.com:19302",
@@ -1833,7 +1833,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "Block Direct Connections",
Description: "Block peer-to-peer (aka. direct) workspace connections. All workspace connections from the CLI will be proxied through Coder (or custom configured DERP servers) and will never be peer-to-peer when enabled. Workspaces may still reach out to STUN servers to get their address until they are restarted after this change has been made, but new connections will still be proxied regardless.",
Description: "Block peer-to-peer (aka. direct) workspace connections. All workspace connections from the CLI will be proxied through Coder (or custom configured DERP servers) and will never be peer-to-peer when enabled. Workspace agents may still reach out to STUN servers to discover their address until they are restarted, but all new connections will be proxied regardless.",
// This cannot be called `disable-direct-connections` because that's
// already a global CLI flag for CLI connections. This is a
// deployment-wide flag.
@@ -1884,7 +1884,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
// Prometheus settings
{
Name: "Prometheus Enable",
Description: "Serve prometheus metrics on the address defined by prometheus address.",
Description: "Serve Prometheus metrics on the address defined by prometheus address.",
Flag: "prometheus-enable",
Env: "CODER_PROMETHEUS_ENABLE",
Value: &c.Prometheus.Enable,
@@ -1894,7 +1894,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "Prometheus Address",
Description: "The bind address to serve prometheus metrics.",
Description: "The bind address to serve Prometheus metrics.",
Flag: "prometheus-address",
Env: "CODER_PROMETHEUS_ADDRESS",
Default: "127.0.0.1:2112",
@@ -1945,7 +1945,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
// Pprof settings
{
Name: "pprof Enable",
Description: "Serve pprof metrics on the address defined by pprof address.",
Description: "Serve pprof profiling endpoints on the address defined by pprof address.",
Flag: "pprof-enable",
Env: "CODER_PPROF_ENABLE",
Value: &c.Pprof.Enable,
@@ -2032,7 +2032,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OAuth2 GitHub Allow Everyone",
Description: "Allow all logins, setting this option means allowed orgs and teams must be empty.",
Description: "Allow all GitHub users to authenticate. When enabled, allowed orgs and teams must be empty.",
Flag: "oauth2-github-allow-everyone",
Env: "CODER_OAUTH2_GITHUB_ALLOW_EVERYONE",
Value: &c.OAuth2.Github.AllowEveryone,
@@ -2079,8 +2079,8 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC Client Key File",
Description: "Pem encoded RSA private key to use for oauth2 PKI/JWT authorization. " +
"This can be used instead of oidc-client-secret if your IDP supports it.",
Description: "PEM encoded RSA private key to use for OAuth2 PKI/JWT authorization. " +
"This can be used instead of oidc-client-secret if your IdP supports it.",
Flag: "oidc-client-key-file",
Env: "CODER_OIDC_CLIENT_KEY_FILE",
YAML: "oidcClientKeyFile",
@@ -2089,8 +2089,8 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC Client Cert File",
Description: "Pem encoded certificate file to use for oauth2 PKI/JWT authorization. " +
"The public certificate that accompanies oidc-client-key-file. A standard x509 certificate is expected.",
Description: "PEM encoded certificate file to use for OAuth2 PKI/JWT authorization. " +
"The public certificate that accompanies oidc-client-key-file. A standard X.509 certificate is expected.",
Flag: "oidc-client-cert-file",
Env: "CODER_OIDC_CLIENT_CERT_FILE",
YAML: "oidcClientCertFile",
@@ -2242,7 +2242,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC Group Field",
Description: "This field must be set if using the group sync feature and the scope name is not 'groups'. Set to the claim to be used for groups.",
Description: "OIDC claim field to use as the user's groups. This field must be set if using the group sync feature and the scope name is not 'groups'.",
Flag: "oidc-group-field",
Env: "CODER_OIDC_GROUP_FIELD",
// This value is intentionally blank. If this is empty, then OIDC group
@@ -2257,7 +2257,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC Group Mapping",
Description: "A map of OIDC group IDs and the group in Coder it should map to. This is useful for when OIDC providers only return group IDs.",
Description: "A map of OIDC group IDs and the groups in Coder they should map to. This is useful when OIDC providers only return group IDs.",
Flag: "oidc-group-mapping",
Env: "CODER_OIDC_GROUP_MAPPING",
Default: "{}",
@@ -2277,7 +2277,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC Regex Group Filter",
Description: "If provided any group name not matching the regex is ignored. This allows for filtering out groups that are not needed. This filter is applied after the group mapping.",
Description: "If provided, any group name not matching the regex is ignored. This allows filtering out groups that are not needed. This filter is applied after the OIDC Group Mapping step.",
Flag: "oidc-group-regex-filter",
Env: "CODER_OIDC_GROUP_REGEX_FILTER",
Default: ".*",
@@ -2287,7 +2287,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC Allowed Groups",
Description: "If provided any group name not in the list will not be allowed to authenticate. This allows for restricting access to a specific set of groups. This filter is applied after the group mapping and before the regex filter.",
Description: "If provided, only users with at least one group in this list will be allowed to authenticate. This restricts access to a specific set of groups. This check is applied before any group mapping or filtering.",
Flag: "oidc-allowed-groups",
Env: "CODER_OIDC_ALLOWED_GROUPS",
Default: "",
@@ -2309,7 +2309,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC User Role Mapping",
Description: "A map of the OIDC passed in user roles and the groups in Coder it should map to. This is useful if the group names do not match. If mapped to the empty string, the role will ignored.",
Description: "A map of OIDC user role names to Coder role names. This is useful if the role names do not match between systems. If mapped to the empty string, the role will be ignored.",
Flag: "oidc-user-role-mapping",
Env: "CODER_OIDC_USER_ROLE_MAPPING",
Default: "{}",
@@ -2319,7 +2319,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC User Role Default",
Description: "If user role sync is enabled, these roles are always included for all authenticated users. The 'member' role is always assigned.",
Description: "If user role sync is enabled, these roles are always included for all authenticated users in addition to synced roles. The 'member' role is always assigned regardless of this setting.",
Flag: "oidc-user-role-default",
Env: "CODER_OIDC_USER_ROLE_DEFAULT",
Default: "",
@@ -2339,7 +2339,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OpenID connect icon URL",
Description: "URL pointing to the icon to use on the OpenID Connect login button.",
Description: "URL of the icon to use on the OpenID Connect login button.",
Flag: "oidc-icon-url",
Env: "CODER_OIDC_ICON_URL",
Value: &c.OIDC.IconURL,
@@ -2348,7 +2348,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "Signups disabled text",
Description: "The custom text to show on the error page informing about disabled OIDC signups. Markdown format is supported.",
Description: "Custom text to show on the error page when OIDC signups are disabled. Markdown format is supported.",
Flag: "oidc-signups-disabled-text",
Env: "CODER_OIDC_SIGNUPS_DISABLED_TEXT",
Value: &c.OIDC.SignupsDisabledText,
@@ -2807,7 +2807,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "SameSite Auth Cookie",
Description: "Controls the 'SameSite' property is set on browser session cookies.",
Description: "Controls if the 'SameSite' property is set on browser session cookies.",
Flag: "samesite-auth-cookie",
Env: "CODER_SAMESITE_AUTH_COOKIE",
// Do not allow "strict" same-site cookies. That would potentially break workspace apps.
@@ -3000,7 +3000,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
{
Name: "SSH Config Options",
Description: "These SSH config options will override the default SSH config options. " +
"Provide options in \"key=value\" or \"key value\" format separated by commas." +
"Provide options in \"key=value\" or \"key value\" format separated by commas. " +
"Using this incorrectly can break SSH to your deployment, use cautiously.",
Flag: "ssh-config-options",
Env: "CODER_SSH_CONFIG_OPTIONS",
@@ -3041,7 +3041,7 @@ Write out the current server config as YAML to stdout.`,
{
// Env handling is done in cli.ReadGitAuthFromEnvironment
Name: "External Auth Providers",
Description: "External Authentication providers.",
Description: "Configure external authentication providers for Git and other services.",
YAML: "externalAuthProviders",
Flag: "external-auth-providers",
Value: &c.ExternalAuthConfigs,
@@ -3059,7 +3059,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Proxy Health Check Interval",
Description: "The interval in which coderd should be checking the status of workspace proxies.",
Description: "The interval at which coderd checks the status of workspace proxies.",
Flag: "proxy-health-interval",
Env: "CODER_PROXY_HEALTH_INTERVAL",
Default: (time.Minute).String(),
@@ -3080,7 +3080,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Allow Custom Quiet Hours",
Description: "Allow users to set their own quiet hours schedule for workspaces to stop in (depending on template autostop requirement settings). If false, users can't change their quiet hours schedule and the site default is always used.",
Description: "Allow users to set their own quiet hours schedule for when workspaces are stopped (depending on template autostop requirement settings). If false, users can't change their quiet hours schedule and the site default is always used.",
Flag: "allow-custom-quiet-hours",
Env: "CODER_ALLOW_CUSTOM_QUIET_HOURS",
Default: "true",
@@ -3192,7 +3192,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Notifications: Email: Hello",
Description: "The hostname identifying the SMTP server.",
Description: "The hostname identifying this client to the SMTP server.",
Flag: "notifications-email-hello",
Env: "CODER_NOTIFICATIONS_EMAIL_HELLO",
Value: &c.Notifications.SMTP.Hello,
@@ -3355,7 +3355,7 @@ Write out the current server config as YAML to stdout.`,
Name: "Notifications: Store Sync Interval",
Description: "The notifications system buffers message updates in memory to ease pressure on the database. " +
"This option controls how often it synchronizes its state with the database. The shorter this value the " +
"lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the " +
"lower the chance of state inconsistency in a non-graceful shutdown - but it also increases load on the " +
"database. It is recommended to keep this option at its default value.",
Flag: "notifications-store-sync-interval",
Env: "CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL",
@@ -3370,7 +3370,7 @@ Write out the current server config as YAML to stdout.`,
Name: "Notifications: Store Sync Buffer Size",
Description: "The notifications system buffers message updates in memory to ease pressure on the database. " +
"This option controls how many updates are kept in memory. The lower this value the " +
"lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the " +
"lower the chance of state inconsistency in a non-graceful shutdown - but it also increases load on the " +
"database. It is recommended to keep this option at its default value.",
Flag: "notifications-store-sync-buffer-size",
Env: "CODER_NOTIFICATIONS_STORE_SYNC_BUFFER_SIZE",
@@ -3434,7 +3434,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Reconciliation Backoff Interval",
Description: "Interval to increase reconciliation backoff by when prebuilds fail, after which a retry attempt is made.",
Description: "Amount of time to add to the reconciliation backoff delay after each prebuild failure, before the next retry attempt is made.",
Flag: "workspace-prebuilds-reconciliation-backoff-interval",
Env: "CODER_WORKSPACE_PREBUILDS_RECONCILIATION_BACKOFF_INTERVAL",
Value: &c.Prebuilds.ReconciliationBackoffInterval,
@@ -3446,7 +3446,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Reconciliation Backoff Lookback Period",
Description: "Interval to look back to determine number of failed prebuilds, which influences backoff.",
Description: "Time period to look back when counting failed prebuilds to calculate the backoff delay.",
Flag: "workspace-prebuilds-reconciliation-backoff-lookback-period",
Env: "CODER_WORKSPACE_PREBUILDS_RECONCILIATION_BACKOFF_LOOKBACK_PERIOD",
Value: &c.Prebuilds.ReconciliationBackoffLookback,
@@ -3458,7 +3458,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Failure Hard Limit",
Description: "Maximum number of consecutive failed prebuilds before a preset hits the hard limit; disabled when set to zero.",
Description: "Maximum number of consecutive failed prebuilds before a preset is considered hard-limited and stops automatic prebuild creation. Disabled when set to zero.",
Flag: "workspace-prebuilds-failure-hard-limit",
Env: "CODER_WORKSPACE_PREBUILDS_FAILURE_HARD_LIMIT",
Value: &c.Prebuilds.FailureHardLimit,
@@ -3481,7 +3481,7 @@ Write out the current server config as YAML to stdout.`,
// AI Bridge Options
{
Name: "AI Bridge Enabled",
Description: "Whether to start an in-memory aibridged instance.",
Description: "Enable the embedded AI Bridge service to intercept and record AI provider requests.",
Flag: "aibridge-enabled",
Env: "CODER_AIBRIDGE_ENABLED",
Value: &c.AI.BridgeConfig.Enabled,
@@ -3501,7 +3501,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge OpenAI Key",
Description: "The key to authenticate against the OpenAI API.",
Description: "API key for authenticating with the OpenAI API.",
Flag: "aibridge-openai-key",
Env: "CODER_AIBRIDGE_OPENAI_KEY",
Value: &c.AI.BridgeConfig.OpenAI.Key,
@@ -3521,7 +3521,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Anthropic Key",
Description: "The key to authenticate against the Anthropic API.",
Description: "API key for authenticating with the Anthropic API.",
Flag: "aibridge-anthropic-key",
Env: "CODER_AIBRIDGE_ANTHROPIC_KEY",
Value: &c.AI.BridgeConfig.Anthropic.Key,
@@ -3553,7 +3553,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Bedrock Access Key",
Description: "The access key to authenticate against the AWS Bedrock API.",
Description: "AWS access key for authenticating with the AWS Bedrock API.",
Flag: "aibridge-bedrock-access-key",
Env: "CODER_AIBRIDGE_BEDROCK_ACCESS_KEY",
Value: &c.AI.BridgeConfig.Bedrock.AccessKey,
@@ -3563,7 +3563,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Bedrock Access Key Secret",
Description: "The access key secret to use with the access key to authenticate against the AWS Bedrock API.",
Description: "AWS secret access key for authenticating with the AWS Bedrock API.",
Flag: "aibridge-bedrock-access-key-secret",
Env: "CODER_AIBRIDGE_BEDROCK_ACCESS_KEY_SECRET",
Value: &c.AI.BridgeConfig.Bedrock.AccessKeySecret,
@@ -3593,7 +3593,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Inject Coder MCP tools",
Description: "Whether to inject Coder's MCP tools into intercepted AI Bridge requests (requires the \"oauth2\" and \"mcp-server-http\" experiments to be enabled).",
Description: "Enable injection of Coder's MCP tools into intercepted AI Bridge requests. Requires the 'oauth2' and 'mcp-server-http' experiments.",
Flag: "aibridge-inject-coder-mcp-tools",
Env: "CODER_AIBRIDGE_INJECT_CODER_MCP_TOOLS",
Value: &c.AI.BridgeConfig.InjectCoderMCPTools,
@@ -3603,7 +3603,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Data Retention Duration",
Description: "Length of time to retain data such as interceptions and all related records (token, prompt, tool use).",
Description: "How long to retain AI Bridge data including interceptions, tokens, prompts, and tool usage records.",
Flag: "aibridge-retention",
Env: "CODER_AIBRIDGE_RETENTION",
Value: &c.AI.BridgeConfig.Retention,
@@ -3656,7 +3656,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Circuit Breaker Enabled",
Description: "Enable the circuit breaker to protect against cascading failures from upstream AI provider rate limits (429, 503, 529 overloaded).",
Description: "Enable the circuit breaker to protect against cascading failures from upstream AI provider rate limits and overload errors (HTTP 429, 503, 529).",
Flag: "aibridge-circuit-breaker-enabled",
Env: "CODER_AIBRIDGE_CIRCUIT_BREAKER_ENABLED",
Value: &c.AI.BridgeConfig.CircuitBreakerEnabled,
@@ -3666,7 +3666,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Circuit Breaker Failure Threshold",
Description: "Number of consecutive failures that triggers the circuit breaker to open.",
Description: "Number of consecutive failures that trigger the circuit breaker to open.",
Flag: "aibridge-circuit-breaker-failure-threshold",
Env: "CODER_AIBRIDGE_CIRCUIT_BREAKER_FAILURE_THRESHOLD",
Value: serpent.Validate(&c.AI.BridgeConfig.CircuitBreakerFailureThreshold, func(value *serpent.Int64) error {
@@ -3682,7 +3682,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Circuit Breaker Interval",
Description: "Cyclic period of the closed state for clearing internal failure counts.",
Description: "Time window for counting failures before resetting the failure count in the closed state.",
Flag: "aibridge-circuit-breaker-interval",
Env: "CODER_AIBRIDGE_CIRCUIT_BREAKER_INTERVAL",
Value: &c.AI.BridgeConfig.CircuitBreakerInterval,
@@ -3830,7 +3830,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Workspace Agent Logs Retention",
Description: "How long workspace agent logs are retained. Logs from non-latest builds are deleted if the agent hasn't connected within this period. Logs from the latest build are always retained. Set to 0 to disable automatic deletion.",
Description: "How long workspace agent logs are retained. Logs from non-latest builds are deleted if the agent hasn't connected within this period. Logs from the latest build for each workspace are always retained. Set to 0 to disable automatic deletion.",
Flag: "workspace-agent-logs-retention",
Env: "CODER_WORKSPACE_AGENT_LOGS_RETENTION",
Value: &c.Retention.WorkspaceAgentLogs,
@@ -3841,7 +3841,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Enable Authorization Recordings",
Description: "All api requests will have a header including all authorization calls made during the request. " +
Description: "All API requests will have a header including all authorization calls made during the request. " +
"This is used for debugging purposes and only available for dev builds.",
Required: false,
Flag: "enable-authz-recordings",
-1
View File
@@ -32,7 +32,6 @@ type Template struct {
Description string `json:"description"`
Deprecated bool `json:"deprecated"`
DeprecationMessage string `json:"deprecation_message"`
Deleted bool `json:"deleted"`
Icon string `json:"icon"`
DefaultTTLMillis int64 `json:"default_ttl_ms"`
ActivityBumpMillis int64 `json:"activity_bump_ms"`
-9
View File
@@ -59,15 +59,6 @@ const (
BuildReasonVSCodeConnection BuildReason = "vscode_connection"
// BuildReasonJetbrainsConnection "jetbrains_connection" is used when a build to start a workspace is triggered by a JetBrains connection.
BuildReasonJetbrainsConnection BuildReason = "jetbrains_connection"
// BuildReasonTaskAutoPause "task_auto_pause" is used when a build to stop
// a task workspace is triggered by the lifecycle executor.
BuildReasonTaskAutoPause BuildReason = "task_auto_pause"
// BuildReasonTaskManualPause "task_manual_pause" is used when a build to
// stop a task workspace is triggered by a user.
BuildReasonTaskManualPause BuildReason = "task_manual_pause"
// BuildReasonTaskResume "task_resume" is used when a build to
// start a task workspace is triggered by a user.
BuildReasonTaskResume BuildReason = "task_resume"
)
// WorkspaceBuild is an at-point representation of a workspace state.

Some files were not shown because too many files have changed in this diff Show More