Compare commits

..

57 Commits

Author SHA1 Message Date
Rowan Smith 031d99288a chore: switch agent gone response from 502 to 404 (backport #23090) (#23634)
Backport of #23090 to `release/2.29`.

When a user creates a workspace, opens the web terminal, then the
workspace stops but the web terminal remains open, the web terminal will
retry the connection. Coder would issue a HTTP 502 Bad Gateway response
when this occurred because coderd could not connect to the workspace
agent, however this is problematic as any load balancer sitting in front
of Coder sees a 502 and thinks Coder is unhealthy.

This PR changes the response to a HTTP 404 after internal discussion.

Cherry-picked from merge commit
c33812a430. The conflict in
`coderd/workspaceapps/errors.go` was resolved by applying the status
code change (502 → 404) while keeping the existing
`RetryEnabled`/`DashboardURL` fields (the `Actions` refactor is not on
this branch).
2026-03-25 16:49:51 -04:00
Rowan Smith afb2fc6faf fix: prevent ui error when last org member is removed (#23017)
Backport of #22975 to release/2.29.
2026-03-25 15:47:37 -04:00
Steven Masley dc7be5f43a chore: update to Go 1.25.6 and coder/preview to 1.08 (cherry 2.29) (#23228)
- Update Go version from 1.24.11 to 1.25.6
- Remove dependency on `moby` for `namesgenerator`
- Disable any use of trivy in zizmor GH action linting
(https://github.com/coder/coder/pull/23228/commits/17532ef2a8e40784499c36d3e7b871a2109d9bf2)

---------


(cherry picked from commit 3ee4f6d0ec)
(cherry picked from commit
https://github.com/coder/coder/commit/091d31224d2fe00d83695adcc53a225842dbb8d3)
(cherry picked from commit
https://github.com/coder/coder/commit/b44a421412a12ef7222322c68109426fb1f65286)

---------

Co-authored-by: Danny Kopping <danny@coder.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Zach <3724288+zedkipp@users.noreply.github.com>
2026-03-25 15:46:57 -04:00
Charlie Voiselle 4ee29d078d fix: open coder_app links in new tab when open_in is tab (cherry-pick #23000) (#23623)
Cherry-pick of #23000 onto release/2.29.
2026-03-25 15:31:12 -04:00
Rowan Smith 50c4832f41 fix: avoid derp-related panic during wsproxy registration (backport release/2.29) (#22342)
Backport of #22322.

- Cherry-picked 7f03bd7.

Co-authored-by: Dean Sheather <dean@deansheather.com>
2026-03-03 13:25:55 -05:00
Lukasz 5c99fed1f1 chore: update Go from 1.24.10 to 1.24.13 (#22473)
Update Go from 1.24.10 to 1.24.13
This update resolves 9 vulnerabilities across three minor releases
(1.24.11, 1.24.12, and 1.24.13)

---------

Co-authored-by: Jakub Domeracki <jakub@coder.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 12:24:44 +01:00
Cian Johnston 72d05f322b fix(stringutil): operate on runes instead of bytes in Truncate (#22388) (#22468)
Fixes https://github.com/coder/coder/issues/22375

Updates `stringutil.Truncate` to properly handle multi-byte UTF-8
characters.
Adds tests for multi-byte truncation with word boundary.

Created by Mux using Opus 4.6

(cherry picked from commit 0cfa03718e)
2026-03-02 11:19:33 +00:00
Jakub Domeracki 8a097ee635 feat(site)!: add consent prompt for auto-creation with prefilled parameters (#22256)
Cherry-pick of 60e3ab7632 from main.

Workspace created via mode=auto links now require explicit user
confirmation before provisioning. A warning dialog shows all prefilled
param.* values from the URL and blocks creation until the user clicks
`Confirm and Create`. Clicking `Cancel` falls back to the standard form
view.

### Breaking behavior change

Links using `mode=auto` (e.g., "Open in Coder" buttons) will no longer
silently create workspaces. Users will now see a consent dialog and must
explicitly confirm before the workspace is provisioned.

Original PR: #22011

Co-authored-by: Kacper Sawicki <kacper@coder.com>
Co-authored-by: Jake Howell <jacob@coder.com>
2026-02-23 17:37:57 -05:00
Danielle Maywood 2ca88b0f07 fix: avoid re-using AuthInstanceID for sub agents (#22196) (#22212)
Parent agents were re-using AuthInstanceID when spawning child agents.
This caused GetWorkspaceAgentByInstanceID to return the most recently
created sub agent instead of the parent when the parent tried to refetch
its own manifest.

Fix by not reusing AuthInstanceID for sub agents, and updating
GetWorkspaceAgentByInstanceID to filter them out entirely.

---

Cherry picked from 911d734df9
2026-02-23 17:37:41 -05:00
Jake Howell 79a0ff8249 feat: convert soft_limit to limit (cherry-pick/v2.29) (#22207)
Related [`internal#1281`](https://github.com/coder/internal/issues/1281)

Cherry picks two pull-requests in `release/2.29`.

* https://github.com/coder/coder/pull/22048
* https://github.com/coder/coder/pull/21998
* https://github.com/coder/coder/pull/22210
2026-02-23 17:37:15 -05:00
Lukasz 7819c471f7 chore: bump bundled terraform to 1.14.5 for 2.29 (#22193)
Description:
This PR updates the bundled Terraform binary and related version pins
from 1.13.4 to 1.14.5 (base image, installer fallback, and CI/test
fixtures). Terraform is statically built with an embedded Go runtime.
Moving to 1.14.5 updates the embedded toolchain and is intended to
address Go stdlib CVEs reported by security scanning.

Notes:

- Change is version-only; no functional Coder logic changes.

- Backport-friendly: intended to be cherry-picked to release branches
after merge.

---------

Co-authored-by: Jakub Domeracki <jakub@coder.com>
Co-authored-by: Dean Sheather <dean@deansheather.com>
2026-02-23 15:32:41 +01:00
Lukasz 3aa8212aac chore: bump versions of gh actions for 2.29 (#22218)
Update gh actions:

- aquasecurity/trivy-action v0.34.0
- harden-runner v2.14.2
2026-02-20 12:49:36 +01:00
Jon Ayers 8b2f472f71 chore: use old slog (#21959) 2026-02-05 16:35:41 -06:00
Jon Ayers 13337a193c chore: fix go.mod (#21958) 2026-02-05 16:23:04 -06:00
Jon Ayers b275be2e7a chore: backport fixes (#21957) 2026-02-05 16:09:41 -06:00
Lukasz 72afd3677c chore: bump alpine to 3.23.3 in release/2.29 (#21879)
(cherry picked from commit 3d97f677e5)

Co-authored-by: Jon Ayers <jon@coder.com>
2026-02-03 09:12:15 -06:00
Dean Sheather 7dfaa606ee fix: fix various AI task usage accounting bugs (#21723)
<!--

If you have used AI to produce some or all of this PR, please ensure you
have read our [AI Contribution
guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING)
before submitting.

-->

---------

Co-authored-by: Cian Johnston <cian@coder.com>
Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
2026-01-29 10:06:45 -06:00
Cian Johnston 0c3144fc32 fix(coderd): ensure inbox WebSocket is closed when client disconnects… (#21684)
… (#21652)

Relates to https://github.com/coder/coder/issues/19715

This is similar to https://github.com/coder/coder/pull/19711

This endpoint works by doing the following:
- Subscribing to the database's with pubsub
- Accepts a WebSocket upgrade
- Starts a `httpapi.Heartbeat`
- Creates a json encoder
- **Infinitely loops waiting for notification until request context
cancelled**

The critical issue here is that `httpapi.Heartbeat` silently fails when
the client has disconnected. This means we never cancel the request
context, leaving the WebSocket alive until we receive a notification
from the database and fail to write that down the pipe.

By replacing usage of `httpapi.Heartbeat` with `httpapi.HeartbeatClose`,
we cancel the context _when the heartbeat fails to write_ due to the
client disconnecting. This allows us to cleanup without waiting for a
notification to come through the pubsub channel.

(cherry picked from commit 409360c62d)

<!--

If you have used AI to produce some or all of this PR, please ensure you
have read our [AI Contribution
guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING)
before submitting.

-->

Co-authored-by: Danielle Maywood <danielle@themaywoods.com>
2026-01-26 09:28:04 -06:00
Cian Johnston b5360a9180 fix: backport migration fixes (#21611)
* https://github.com/coder/coder/pull/21493
* https://github.com/coder/coder/pull/21496
* https://github.com/coder/coder/pull/21530

NB these commits were originally authored by Blink on behalf of
@dannykopping, so amended to reflect actual authorship.


**Repro/Verification Steps:**

* Created a Coder deployment with a non-public schema via Docker compose
on v2.28.6:
  
* Created a DB init script under `db-init/01-create-schema.sql` with the
following:
    ```sql
    CREATE SCHEMA IF NOT EXISTS coder AUTHORIZATION coder;
    GRANT ALL PRIVILEGES ON SCHEMA coder TO coder;
    ALTER ROLE coder SET search_path TO coder;
    ```
  * Mounted above inside the `postgres` container:
    ```diff
         volumes:
           - coder_data:/var/lib/postgresql/data
    +      - ./db-init:/docker-entrypoint-initdb.d:ro
    ```
  * Edited `CODER_PG_CONNECTION_URL` to update the search path:
    ```diff
    environment:
- CODER_PG_CONNECTION_URL:
"postgresql://${POSTGRES_USER:-username}:${POSTGRES_PASSWORD:-password}@database/${POSTGRES_DB:-coder}?sslmode=disable"
+ CODER_PG_CONNECTION_URL:
"postgresql://${POSTGRES_USER:-username}:${POSTGRES_PASSWORD:-password}@database/${POSTGRES_DB:-coder}?sslmode=disable&search_path=coder"
    ```
  * Brought up the deployment:
    ```shell
CODER_VERSION=v2.28.6 CODER_ACCESS_URL=http://localhost:7080
POSTGRES_USER=coder POSTGRES_PASSWORD=coder docker compose up`
    ```
  * Created user / template / workspace

* Updated to `v2.29.1`:
  * ```shell
CODER_VERSION=v2.29.1 CODER_ACCESS_URL=http://localhost:7080
POSTGRES_USER=coder POSTGRES_PASSWORD=coder docker compose up`
    ```

  * Observed following error:
    ```
database-1 | 2026-01-21 15:07:17.629 UTC [102] ERROR: relation
"public.workspace_agents" does not exist
coder-1 | Encountered an error running "coder server", see "coder server
--help" for more information
database-1 | 2026-01-21 15:07:17.629 UTC [102] STATEMENT: CREATE INDEX
IF NOT EXISTS workspace_agents_auth_instance_id_deleted_idx ON
public.workspace_agents (auth_instance_id, deleted);
coder-1 | error: connect to postgres: connect to postgres: migrate up:
up: 2 errors occurred:
coder-1 | * run statement: migration failed: relation
"public.workspace_agents" does not exist in line 0: CREATE INDEX IF NOT
EXISTS workspace_agents_auth_instance_id_deleted_idx ON
public.workspace_agents (auth_instance_id, deleted);
coder-1 | (details: pq: relation "public.workspace_agents" does not
exist)
coder-1 | * commit tx on unlock: pq: Could not complete operation in a
failed transaction
    coder-1 exited with code 1
    ```

  * Built image locally:
    ```console
    $ make build/coder_$(./scripts/version.sh)_linux_amd64.tag
    ...
    ghcr.io/coder/coder:v2.29.1-devel-e8c482a98a67-amd64
    ```

  * Started with new image:
    ```shell
CODER_VERSION=v2.29.1-devel-e8c482a98a67-amd64
CODER_ACCESS_URL=http://localhost:7080 POSTGRES_USER=coder
POSTGRES_PASSWORD=coder docker compose up
    ```

  * Observed migrations ran successfully and Coder came up successfully

---------

Signed-off-by: Danny Kopping <danny@coder.com>
Co-authored-by: Danny Kopping <danny@coder.com>
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2026-01-21 15:45:58 +00:00
Kacper Sawicki 2e2d0dde44 feat(cli): backport #21374 to 2.29 (#21561)
backport #21374 to 2.29

feat(cli): add --no-build flag to state push for state-only updates
#21374
2026-01-20 15:46:46 -06:00
Kacper Sawicki 2314e4a94e fix: backport update boundary version to 2.29 (#21290) (#21575)
fix: update boundary version https://github.com/coder/coder/pull/21290

required by https://github.com/coder/coder/pull/21561

Co-authored-by: Yevhenii Shcherbina <evgeniy.shcherbina.es@gmail.com>
2026-01-20 11:19:53 +01:00
blinkagent[bot] bd76c602e4 chore: add antigravity to allowed protocols list (#20873) (#21122)
Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: Atif Ali <atif@coder.com>
2025-12-29 13:29:28 +05:00
Jakub Domeracki 59cdd7e21f chore: update react to apply patch for CVE-2025-55182 (#21084) (#21168)
Reference:

https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components

> Please note that coder deployments aren't vulnerable since [React
Server Components](https://react.dev/reference/rsc/server-components)
aren't in use

---------

Co-authored-by: blinkagent[bot] <237617714+blinkagent[bot]@users.noreply.github.com>
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2025-12-09 09:34:16 -06:00
blinkagent[bot] ba71b321bc fix: remove a sensitive field from an agent log line (#20968) (#21063)
This PR removes a log field that could expose sensitive information in
agent logs for workspaces that pass such information to the agent via
its manifest.

(cherry picked from commit 1d726c81bb)

Co-authored-by: Sas Swart <sas.swart.cdk@gmail.com>
2025-12-02 11:33:50 -06:00
Callum Styan c94c470aae fix: pass context with authorization to agentapi (#21045)
cherry pick 20959 into release branch

Signed-off-by: Callum Styan <callumstyan@gmail.com>
2025-12-01 14:16:30 -06:00
Zach 8430dd648a fix(cli): remove defaulting to keyring when --global-config set (cherry-pick) (#20953)
(cherry picked from commit bbf7b137da)
2025-12-01 14:09:54 -06:00
Susana Ferreira 0bd0990e14 feat: add notification warning alert to Tasks page (#20900) (#20981)
Related to PR: https://github.com/coder/coder/pull/20900

(cherry picked from commit f8d9a8046f)
2025-12-01 14:08:50 -06:00
Susana Ferreira 10e70f8c51 fix: show task display name in task topbar (#20957) (#20980)
Related to PR: https://github.com/coder/coder/pull/20957

(cherry picked from commit 21efebeadc)
2025-12-01 14:08:01 -06:00
Sas Swart abe66a38eb feat: implement agent socket api, client and cli (#20758) (#20976) 2025-12-01 14:07:40 -06:00
Jake Howell cd9d3ef46f fix: ensure we check if the user can actually see ai bridge (#20964) 2025-12-01 14:07:18 -06:00
Mathias Fredriksson c0a2522bd6 fix(site): only show active tasks in waiting for input tab (#20933) (#20955) 2025-12-01 14:06:44 -06:00
Danny Kopping dfa25d5f00 chore: document bedrock setup process for aibridge (#20956) (#20966)
Cherry-pick of https://github.com/coder/coder/pull/20956

Signed-off-by: Danny Kopping <danny@coder.com>
2025-11-28 13:09:10 +05:00
Mykyta Protsenko c87c33f7dd perf: add index to improve the GetWorkspaceAgentByInstanceID query performance (#20936)
## Context

GetWorkspaceAgentByInstanceID has a suboptimal plan. Even though it is
designed to fetch a small subset of records, there are no corresponding
indexes and that query results in full table scan:

Query:

```
SELECT id, auth_instance_id FROM workspace_agents
where auth_instance_id='i-013c2b96b6441648a' and deleted=FALSE;
```

Plan:

```
------------------------------------------------------------------------------------------------------------------
 Seq Scan on workspace_agents  (cost=0.00..222325.48 rows=2 width=36) (actual time=0.012..234.152 rows=4 loops=1)
   Filter: ((NOT deleted) AND ((auth_instance_id)::text = 'i-013c2b96b6441648a'::text))
   Rows Removed by Filter: 302276
 Planning Time: 0.173 ms
 Execution Time: 234.169 ms
```

After adding the index, the plan improves drastically.

Updated plan:

```
 Bitmap Heap Scan on workspace_agents  (cost=4.44..12.32 rows=2 width=36) (actual time=0.019..0.019 rows=0 loops=1)
   Recheck Cond: (((auth_instance_id)::text = 'i-013c2b96b6441648a'::text) AND (NOT deleted))
   ->  Bitmap Index Scan on workspace_agents_auth_instance_id_deleted_idx  (cost=0.00..4.44 rows=2 width=0) (actual time=0.013..0.014 rows=0 loops=1)
         Index Cond: (((auth_instance_id)::text = 'i-013c2b96b6441648a'::text) AND (deleted = false))
 Planning Time: 0.388 ms
 Execution Time: 0.044 ms
```

## Changes

* add an index to optimize this query

## Testing

* ran the queries manually against prod and test DBs
* ran `./scripts/develop.sh`, connected to the local PostgreSQL
instance, inspected the indexes to make sure new index is there:

```
Indexes:
    "workspace_agents_pkey" PRIMARY KEY, btree (id)
    // NEW INDEX CREATED SUCCESSFULLY  [comment is mine]
    "workspace_agents_auth_instance_id_deleted_idx" btree (auth_instance_id, deleted)
    "workspace_agents_auth_token_idx" btree (auth_token)
    "workspace_agents_resource_id_idx" btree (resource_id)
```

---------

Signed-off-by: Danny Kopping <danny@coder.com>
Co-authored-by: Danny Kopping <danny@coder.com>
2025-11-26 05:57:25 +02:00
George K a9261577bc perf: optimize migration 371 to run faster on large deployments (#20906)
closes https://github.com/coder/coder/issues/20899

This is in response to a migration in v2.27 that takes very long on
deployments with large `api_keys` tables.

NOTE: The optimization causes the _up_ migration to delete old data
(keys that expired more than 7 days ago). The _down_ migration won't
resurrect the deleted data.
2025-11-25 21:44:59 -06:00
Benjamin Cohen 9c2f94b574 fix(site): remove erroneous "install Cursor" notification for Cursor Desktop (#20875)
### Summary

This change removes the erroneous “Install Cursor” notification in the
desktop environment when Cursor is already installed. The issue is the
timeout of 500ms was simply too short for Cursor Desktop to respond in
time, so I increased it to 1500ms. This seems to consistently work, but
it could easily be increased to 2000ms to be safe.

### Issue

Fixes #20289

### Testing

- Verified that when Cursor is installed, the notification no longer
appears

### QA

#### Before


https://github.com/user-attachments/assets/facd2e74-6eb7-47ac-935d-7b11974648a0

#### After


https://github.com/user-attachments/assets/ced817d8-ca0c-428c-8436-5f30ffc6134b
2025-11-26 12:59:06 +11:00
Zach 6238a99275 feat(cli)!: enable keyring usage by default (#20851)
Make keyring usage for session token storage on by default for supported
platforms (Windows and macOS), with the ability to opt-out via
--use-keyring=false.

This change will be a breaking change for any users depending on the
session token being stored on disk, though users can restore file usage
via the flag above.

This change will also require CLI users to authenticate after updating.
2025-11-25 18:13:00 -07:00
Asher c266bb830c chore: add debug logging and recovery to agent api requests (#20785)
This is to debug context timeouts on API requests to the agent.

Because rbac and database cannot be imported in slim, split the logger
middleware into slim and non-slim versions and break out the recovery
middleware.
2025-11-25 14:59:20 -09:00
Callum Styan b0e8384b82 perf: reduce DB calls to GetWorkspaceByAgentID via caching workspace info (#20662)
---------

Signed-off-by: Callum Styan <callumstyan@gmail.com>
2025-11-25 14:45:05 -08:00
ケイラ 956cbe7751 chore: remove classic parameters frontend code (#20710) 2025-11-25 15:07:21 -07:00
Andrew Aquino 4863812d8c refactor: replace MUI Tooltip component with Tooltip (simple usage) (#20849)
for #19974 

Redo of #20027, this time splitting it into multiple PRs + using our
existing `Tooltip` component instead of creating a new component (see
below). This PR covers the most basic usage of the MUI Tooltip, i.e.,
the tooltip content is a string literal.

~~Adds a global `TooltipProvider` to `AppProviders` and our Storybook
decorators, so that we don't have to render a `TooltipProvider` for
every tooltip instance. Removing redundant `TooltipProvider`s will be
another separate PR~~ <- this was done by #20869
2025-11-25 13:40:26 -08:00
Danny Kopping e340560164 chore: actually store translated token metadata (#20929)
Signed-off-by: Danny Kopping <danny@coder.com>
2025-11-25 16:50:19 +00:00
Mathias Fredriksson e189dc1f81 fix: complete Tasks GA promotion (docs, site) (#20927)
## Summary

Completes the Coder Tasks GA promotion by updating swagger tags and
regenerating API documentation and updating the frontend API structure.

## Related

Follows #20923 and #20921 which promoted Tasks from Beta/Experimental to
GA.

---

🤖 This change was written by Claude Sonnet 4.5 Thinking using
[mux](https://github.com/coder/mux) and reviewed by a human 🏂
2025-11-25 16:46:13 +00:00
Susana Ferreira 2f399eafae feat(site): use display name field for tasks (#20918)
## Description

This PR updates the frontend to use the new `display_name` field for
tasks. In the tasks list view, `display_name` replaces `initial_prompt`,
and in the tasks sidebar, `display_name` replaces `name`. This is a
follow-up to https://github.com/coder/coder/pull/20856, which introduced
the `display_name` field in the backend.

## Changes

- Display `task.display_name` instead of `task.initial_prompt` in the
tasks table
- Display `task.display_name` instead of `task.name` in the task sidebar
view
- Updated mock data to include `display_name` for all test tasks
- Added Storybook story to showcase display name rendering with
different lengths (short, max length with ellipsis, and edge cases)

Follow-up: https://github.com/coder/coder/pull/20856
Closes: https://github.com/coder/coder/issues/20801
2025-11-25 16:29:54 +00:00
Mathias Fredriksson 02bac71421 feat: promote Tasks to GA (#20923)
## Summary

This change promotes Coder Tasks from Beta to GA by removing Beta labels
from:

- TasksPage UI component
- Documentation files

Tasks feature is now ready for general availability!

---

🤖 This change was written by Claude Sonnet 4.5 Thinking using
[mux](https://github.com/coder/mux) and reviewed by a human 🏂
2025-11-25 17:43:31 +02:00
Danielle Maywood b255827a52 chore: promote tasks to stable from experimental (#20921)
- Promote tasks from `/api/experimental` to `/api/v2`.
- Move sdk from `ExperimentalClient` to `Client`.
- Update swagger
2025-11-25 15:24:25 +00:00
Mathias Fredriksson 37fc6646ad perf(coderd/database): limit GetLatestWorkspaceAppStatusByAppID to 1 row (#20917)
## Description

This PR fixes an issue where `GetLatestWorkspaceAppStatusesByAppID`
returned an unbounded number of rows for a given app ID, which could
cause performance issues for noisy or long-running AI tasks.

## Impact

This change reduces database query overhead for workspace app status
updates, particularly for busy AI tasks that update their status
frequently. Previously, fetching the latest status would return all
historical statuses, now it returns only the most recent one.

Fixes #20862

---

🤖 This change was written by Claude Sonnet 4.5 Thinking using [mux](https://github.com/coder/mux) and reviewed by a human 🏄🏻‍♂️
2025-11-25 16:56:42 +02:00
Yevhenii Shcherbina 5213023fe5 chore: update boundary to v0.2.1 (#20920)
Update boundary to v0.2.1 which uses sys-admin permissions to work with
newer kernel versions.
2025-11-25 09:17:20 -05:00
Danny Kopping ae2c94b322 chore: add @jdomeracki-coder as CODEOWNER of .github dir (#20919)
Supply-chain attacks are on the rise; Jakub is going to keep an eye on
things.

Signed-off-by: Danny Kopping <danny@coder.com>
2025-11-25 13:58:22 +00:00
Mathias Fredriksson ad8ba4aac6 feat(cli): promote tasks commands from experimental to GA (#20916)
## Overview

This change promotes the tasks CLI commands from `coder exp task` to
`coder task`, marking them as generally available (GA).

## Migration

Users will need to update their scripts from:

```shell
coder exp task create "my task"
```

To:
```shell
coder task create "my task"
```

---

🤖 This change was written by Claude Sonnet 4.5 Thinking using [mux](https://github.com/coder/mux) and reviewed by a human 🏄🏻‍♂️
2025-11-25 13:50:22 +00:00
Susana Ferreira 3011207519 feat: add display name field for tasks (#20856)
## Problem

Tasks currently only expose a machine-friendly name field (e.g.
`task-python-debug-a1b2`), but this value is primarily an identifier
rather than a clean, descriptive label. We need a separate
display-friendly name for use in the UI.

This PR introduces a new `display_name` field and updates the task-name
generation flow. The Claude system prompt was updated to return valid
JSON with both `name` and `display_name`. The name generation logic
follows a fallback chain (Anthropic > prompt sanitization > random
fallback). To make task names more closely resemble their display names,
the legacy `task-` prefix has been removed. For context, PR
https://github.com/coder/coder/pull/20834 introduced a small Task icon
to the workspace list to help identify workspaces associated to tasks.

## Changes

- Database migration: Added `display_name` column to tasks table
- Updated system prompt to generate both task name and display name as
valid JSON
- Task name generation now follows a fallback chain: Anthropic > prompt
sanitization > random fallback
- Removed `task-` prefix from task names to allow more descriptive names
- Note: PR https://github.com/coder/coder/pull/20834 adds a Task icon to
workspaces in the workspace list to distinguish task-created workspaces

**Note:** UI changes will be addressed in a follow-up PR

Related to: https://github.com/coder/coder/issues/20801
2025-11-25 13:00:59 +00:00
Atif Ali e8bf074022 chore: update AI client compatibility table in AI Bridge documentation (#20915) 2025-11-25 12:29:47 +00:00
Danielle Maywood 7fd9a450c1 docs: update dev containers documentation to reflect GA status (#20847)
Updates the dev containers documentation to accurately reflect that the
feature is generally available and document all configuration options.

Closes https://github.com/coder/internal/issues/1138

---

🤖 PR was written by Claude Sonnet 4.5 Thinking using [Coder
Mux](https://github.com/coder/cmux) and reviewed by a human 👩
2025-11-25 11:29:11 +00:00
Danielle Maywood 82f525baf3 feat(coderd): add task prompt modification endpoint (#20811)
This PR adds the backend implementation for modifying task prompts. Part
of https://github.com/coder/internal/issues/1084

## Changes

- New `UpdateTaskPrompt` database query to update task prompts
- New PATCH `/api/v2/tasks/{task}/prompt` endpoint

## Notes

This is part 1 of a 2-part PR stack. The frontend UI will be added in a
follow-up PR based on this branch
(https://github.com/coder/coder/pull/20812).

---

🤖 PR was written by Claude Sonnet 4.5 Thinking using [Coder
Mux](https://github.com/coder/cmux) and reviewed by a human 👩
2025-11-25 11:13:32 +00:00
Spike Curtis afd40436f0 fix: mock Agent querying OS for listening ports in tests (#20842)
fixes https://github.com/coder/internal/issues/1123

We want to tests that ports are not included after they are no longer used, but this isn't safe on the real OS networking stack because there is no way to guarantee a port _won't_ be used. Instead, we introduce an interface and fake implementation for testing.

On order to leave the filtering logic in the test path, this PR also does some refactoring.

Caching logic is left in the real OS querying implementation and a new test case is added for it in this PR.
2025-11-25 14:25:24 +04:00
Danny Kopping 823009d9ea chore: document key scopes for OpenAI and Anthropic for aibridge (#20903)
Closes https://github.com/coder/internal/issues/1135

---------

Signed-off-by: Danny Kopping <danny@coder.com>
2025-11-25 11:33:07 +02:00
Danielle Maywood c12303f0b2 fix: allow agents to be created on dormant workspaces (#20909)
Closes https://github.com/coder/coder/issues/20711

We now allow agents to be created on dormant workspaces.

I've ran the test with and without the change. I've confirmed that -
without the fix - it triggers the "rbac: unauthorized" error.
2025-11-25 06:24:33 +00:00
Callum Styan 658e8c34a9 perf: improve performance of metricsAggregator path by reducing memory allocations (#20724)
Signed-off-by: Callum Styan <callumstyan@gmail.com>
2025-11-24 15:45:08 -08:00
368 changed files with 14156 additions and 10419 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ ignorePatterns:
- pattern: "splunk.com"
- pattern: "stackoverflow.com/questions"
- pattern: "developer.hashicorp.com/terraform/language"
- pattern: "platform.openai.com/docs/api-reference"
- pattern: "platform.openai.com"
- pattern: "api.openai.com"
aliveStatusCodes:
- 200
+1 -1
View File
@@ -11,4 +11,4 @@ runs:
go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34
go install golang.org/x/tools/cmd/goimports@v0.31.0
go install github.com/mikefarah/yq/v4@v4.44.3
go install go.uber.org/mock/mockgen@v0.5.0
go install go.uber.org/mock/mockgen@v0.6.0
+1 -1
View File
@@ -4,7 +4,7 @@ description: |
inputs:
version:
description: "The Go version to use."
default: "1.24.10"
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.13.4
terraform_version: 1.14.5
terraform_wrapper: false
+16 -16
View File
@@ -35,7 +35,7 @@ jobs:
tailnet-integration: ${{ steps.filter.outputs.tailnet-integration }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -235,7 +235,7 @@ jobs:
if: ${{ !cancelled() }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -292,7 +292,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -343,7 +343,7 @@ jobs:
- windows-2022
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -532,7 +532,7 @@ jobs:
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -581,7 +581,7 @@ jobs:
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -641,7 +641,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -668,7 +668,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -701,7 +701,7 @@ jobs:
name: ${{ matrix.variant.name }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -781,7 +781,7 @@ jobs:
if: needs.changes.outputs.site == 'true' || needs.changes.outputs.ci == 'true'
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -862,7 +862,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -933,7 +933,7 @@ jobs:
if: always()
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -1053,7 +1053,7 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -1108,7 +1108,7 @@ jobs:
IMAGE: ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -1505,7 +1505,7 @@ jobs:
if: needs.changes.outputs.db == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -146,7 +146,7 @@ jobs:
needs: deploy
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
if: github.repository_owner == 'coder'
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
+2 -2
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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -125,7 +125,7 @@ jobs:
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- windows-2022
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -76,7 +76,7 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
+4 -4
View File
@@ -164,7 +164,7 @@ jobs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -802,7 +802,7 @@ jobs:
# TODO: skip this if it's not a new release (i.e. a backport). This is
# fine right now because it just makes a PR that we can close.
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -878,7 +878,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -971,7 +971,7 @@ jobs:
if: ${{ !inputs.dry_run }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
+4 -4
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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -97,7 +97,7 @@ jobs:
- name: Install yq
run: go run github.com/mikefarah/yq/v4@v4.44.3
- name: Install mockgen
run: go install go.uber.org/mock/mockgen@v0.5.0
run: go install go.uber.org/mock/mockgen@v0.6.0
- name: Install protoc-gen-go
run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30
- name: Install protoc-gen-go-drpc
@@ -146,7 +146,7 @@ jobs:
echo "image=$(cat "$image_job")" >> "$GITHUB_OUTPUT"
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8
uses: aquasecurity/trivy-action@c1824fd6edce30d7ab345a9989de00bbd46ef284 # v0.34.0
with:
image-ref: ${{ steps.build.outputs.image }}
format: sarif
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -96,7 +96,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
@@ -120,7 +120,7 @@ jobs:
actions: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.16.0
with:
egress-policy: audit
+2
View File
@@ -27,3 +27,5 @@ coderd/schedule/autostop.go @deansheather @DanielleMaywood
# well as guidance from revenue.
coderd/usage/ @deansheather @spikecurtis
enterprise/coderd/usage/ @deansheather @spikecurtis
.github/ @jdomeracki-coder
+13 -2
View File
@@ -69,6 +69,9 @@ MOST_GO_SRC_FILES := $(shell \
# All the shell files in the repo, excluding ignored files.
SHELL_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.sh')
MIGRATION_FILES := $(shell find ./coderd/database/migrations/ -maxdepth 1 $(FIND_EXCLUSIONS) -type f -name '*.sql')
FIXTURE_FILES := $(shell find ./coderd/database/migrations/testdata/fixtures/ $(FIND_EXCLUSIONS) -type f -name '*.sql')
# Ensure we don't use the user's git configs which might cause side-effects
GIT_FLAGS = GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
@@ -561,7 +564,7 @@ endif
# Note: we don't run zizmor in the lint target because it takes a while. CI
# runs it explicitly.
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/actions/actionlint lint/check-scopes
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/actions/actionlint lint/check-scopes lint/migrations
.PHONY: lint
lint/site-icons:
@@ -608,7 +611,9 @@ lint/actions/actionlint:
.PHONY: lint/actions/actionlint
lint/actions/zizmor:
./scripts/zizmor.sh \
# Using a token will use trivy, which is no longer supported.
# So disable any use of a token for this target.
GH_TOKEN="" ./scripts/zizmor.sh \
--strict-collection \
--persona=regular \
.
@@ -619,6 +624,12 @@ lint/check-scopes: coderd/database/dump.sql
go run ./scripts/check-scopes
.PHONY: lint/check-scopes
# Verify migrations do not hardcode the public schema.
lint/migrations:
./scripts/check_pg_schema.sh "Migrations" $(MIGRATION_FILES)
./scripts/check_pg_schema.sh "Fixtures" $(FIXTURE_FILES)
.PHONY: lint/migrations
# All files generated by the database should be added here, and this can be used
# as a target for jobs that need to run after the database is generated.
DB_GEN_FILES := \
+96 -49
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"hash/fnv"
"io"
"maps"
"net"
"net/http"
"net/netip"
@@ -40,6 +41,7 @@ import (
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentscripts"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/agent/proto/resourcesmonitor"
@@ -70,16 +72,21 @@ const (
)
type Options struct {
Filesystem afero.Fs
LogDir string
TempDir string
ScriptDataDir string
Client Client
ReconnectingPTYTimeout time.Duration
EnvironmentVariables map[string]string
Logger slog.Logger
IgnorePorts map[int]string
PortCacheDuration time.Duration
Filesystem afero.Fs
LogDir string
TempDir string
ScriptDataDir string
Client Client
ReconnectingPTYTimeout time.Duration
EnvironmentVariables map[string]string
Logger slog.Logger
// IgnorePorts tells the api handler which ports to ignore when
// listing all listening ports. This is helpful to hide ports that
// are used by the agent, that the user does not care about.
IgnorePorts map[int]string
// ListeningPortsGetter is used to get the list of listening ports. Only
// tests should set this. If unset, a default that queries the OS will be used.
ListeningPortsGetter ListeningPortsGetter
SSHMaxTimeout time.Duration
TailnetListenPort uint16
Subsystems []codersdk.AgentSubsystem
@@ -91,6 +98,8 @@ type Options struct {
Devcontainers bool
DevcontainerAPIOptions []agentcontainers.Option // Enable Devcontainers for these to be effective.
Clock quartz.Clock
SocketServerEnabled bool
SocketPath string // Path for the agent socket server socket
}
type Client interface {
@@ -137,9 +146,7 @@ func New(options Options) Agent {
if options.ServiceBannerRefreshInterval == 0 {
options.ServiceBannerRefreshInterval = 2 * time.Minute
}
if options.PortCacheDuration == 0 {
options.PortCacheDuration = 1 * time.Second
}
if options.Clock == nil {
options.Clock = quartz.NewReal()
}
@@ -153,30 +160,38 @@ func New(options Options) Agent {
options.Execer = agentexec.DefaultExecer
}
if options.ListeningPortsGetter == nil {
options.ListeningPortsGetter = &osListeningPortsGetter{
cacheDuration: 1 * time.Second,
}
}
hardCtx, hardCancel := context.WithCancel(context.Background())
gracefulCtx, gracefulCancel := context.WithCancel(hardCtx)
a := &agent{
clock: options.Clock,
tailnetListenPort: options.TailnetListenPort,
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
logger: options.Logger,
gracefulCtx: gracefulCtx,
gracefulCancel: gracefulCancel,
hardCtx: hardCtx,
hardCancel: hardCancel,
coordDisconnected: make(chan struct{}),
environmentVariables: options.EnvironmentVariables,
client: options.Client,
filesystem: options.Filesystem,
logDir: options.LogDir,
tempDir: options.TempDir,
scriptDataDir: options.ScriptDataDir,
lifecycleUpdate: make(chan struct{}, 1),
lifecycleReported: make(chan codersdk.WorkspaceAgentLifecycle, 1),
lifecycleStates: []agentsdk.PostLifecycleRequest{{State: codersdk.WorkspaceAgentLifecycleCreated}},
reportConnectionsUpdate: make(chan struct{}, 1),
ignorePorts: options.IgnorePorts,
portCacheDuration: options.PortCacheDuration,
clock: options.Clock,
tailnetListenPort: options.TailnetListenPort,
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
logger: options.Logger,
gracefulCtx: gracefulCtx,
gracefulCancel: gracefulCancel,
hardCtx: hardCtx,
hardCancel: hardCancel,
coordDisconnected: make(chan struct{}),
environmentVariables: options.EnvironmentVariables,
client: options.Client,
filesystem: options.Filesystem,
logDir: options.LogDir,
tempDir: options.TempDir,
scriptDataDir: options.ScriptDataDir,
lifecycleUpdate: make(chan struct{}, 1),
lifecycleReported: make(chan codersdk.WorkspaceAgentLifecycle, 1),
lifecycleStates: []agentsdk.PostLifecycleRequest{{State: codersdk.WorkspaceAgentLifecycleCreated}},
reportConnectionsUpdate: make(chan struct{}, 1),
listeningPortsHandler: listeningPortsHandler{
getter: options.ListeningPortsGetter,
ignorePorts: maps.Clone(options.IgnorePorts),
},
reportMetadataInterval: options.ReportMetadataInterval,
announcementBannersRefreshInterval: options.ServiceBannerRefreshInterval,
sshMaxTimeout: options.SSHMaxTimeout,
@@ -190,6 +205,8 @@ func New(options Options) Agent {
devcontainers: options.Devcontainers,
containerAPIOptions: options.DevcontainerAPIOptions,
socketPath: options.SocketPath,
socketServerEnabled: options.SocketServerEnabled,
}
// Initially, we have a closed channel, reflecting the fact that we are not initially connected.
// Each time we connect we replace the channel (while holding the closeMutex) with a new one
@@ -202,20 +219,16 @@ func New(options Options) Agent {
}
type agent struct {
clock quartz.Clock
logger slog.Logger
client Client
tailnetListenPort uint16
filesystem afero.Fs
logDir string
tempDir string
scriptDataDir string
// ignorePorts tells the api handler which ports to ignore when
// listing all listening ports. This is helpful to hide ports that
// are used by the agent, that the user does not care about.
ignorePorts map[int]string
portCacheDuration time.Duration
subsystems []codersdk.AgentSubsystem
clock quartz.Clock
logger slog.Logger
client Client
tailnetListenPort uint16
filesystem afero.Fs
logDir string
tempDir string
scriptDataDir string
listeningPortsHandler listeningPortsHandler
subsystems []codersdk.AgentSubsystem
reconnectingPTYTimeout time.Duration
reconnectingPTYServer *reconnectingpty.Server
@@ -271,6 +284,10 @@ type agent struct {
devcontainers bool
containerAPIOptions []agentcontainers.Option
containerAPI *agentcontainers.API
socketServerEnabled bool
socketPath string
socketServer *agentsocket.Server
}
func (a *agent) TailnetConn() *tailnet.Conn {
@@ -350,9 +367,32 @@ func (a *agent) init() {
s.ExperimentalContainers = a.devcontainers
},
)
a.initSocketServer()
go a.runLoop()
}
// initSocketServer initializes server that allows direct communication with a workspace agent using IPC.
func (a *agent) initSocketServer() {
if !a.socketServerEnabled {
a.logger.Info(a.hardCtx, "socket server is disabled")
return
}
server, err := agentsocket.NewServer(
a.logger.Named("socket"),
agentsocket.WithPath(a.socketPath),
)
if err != nil {
a.logger.Warn(a.hardCtx, "failed to create socket server", slog.Error(err), slog.F("path", a.socketPath))
return
}
a.socketServer = server
a.logger.Debug(a.hardCtx, "socket server started", slog.F("path", a.socketPath))
}
// runLoop attempts to start the agent in a retry loop.
// Coder may be offline temporarily, a connection issue
// may be happening, but regardless after the intermittent
@@ -1087,7 +1127,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
if err != nil {
return xerrors.Errorf("fetch metadata: %w", err)
}
a.logger.Info(ctx, "fetched manifest", slog.F("manifest", mp))
a.logger.Info(ctx, "fetched manifest")
manifest, err := agentsdk.ManifestFromProto(mp)
if err != nil {
a.logger.Critical(ctx, "failed to convert manifest", slog.F("manifest", mp), slog.Error(err))
@@ -1920,6 +1960,7 @@ func (a *agent) Close() error {
lifecycleState = codersdk.WorkspaceAgentLifecycleShutdownError
}
}
a.setLifecycle(lifecycleState)
err = a.scriptRunner.Close()
@@ -1927,6 +1968,12 @@ func (a *agent) Close() error {
a.logger.Error(a.hardCtx, "script runner close", slog.Error(err))
}
if a.socketServer != nil {
if err := a.socketServer.Close(); err != nil {
a.logger.Error(a.hardCtx, "socket server close", slog.Error(err))
}
}
if err := a.containerAPI.Close(); err != nil {
a.logger.Error(a.hardCtx, "container API close", slog.Error(err))
}
+149
View File
@@ -0,0 +1,149 @@
package agentsocket
import (
"context"
"golang.org/x/xerrors"
"storj.io/drpc"
"storj.io/drpc/drpcconn"
"github.com/coder/coder/v2/agent/agentsocket/proto"
"github.com/coder/coder/v2/agent/unit"
)
// Option represents a configuration option for NewClient.
type Option func(*options)
type options struct {
path string
}
// WithPath sets the socket path. If not provided or empty, the client will
// auto-discover the default socket path.
func WithPath(path string) Option {
return func(opts *options) {
if path == "" {
return
}
opts.path = path
}
}
// Client provides a client for communicating with the workspace agentsocket API.
type Client struct {
client proto.DRPCAgentSocketClient
conn drpc.Conn
}
// NewClient creates a new socket client and opens a connection to the socket.
// If path is not provided via WithPath or is empty, it will auto-discover the
// default socket path.
func NewClient(ctx context.Context, opts ...Option) (*Client, error) {
options := &options{}
for _, opt := range opts {
opt(options)
}
conn, err := dialSocket(ctx, options.path)
if err != nil {
return nil, xerrors.Errorf("connect to socket: %w", err)
}
drpcConn := drpcconn.New(conn)
client := proto.NewDRPCAgentSocketClient(drpcConn)
return &Client{
client: client,
conn: drpcConn,
}, nil
}
// Close closes the socket connection.
func (c *Client) Close() error {
return c.conn.Close()
}
// Ping sends a ping request to the agent.
func (c *Client) Ping(ctx context.Context) error {
_, err := c.client.Ping(ctx, &proto.PingRequest{})
return err
}
// SyncStart starts a unit in the dependency graph.
func (c *Client) SyncStart(ctx context.Context, unitName unit.ID) error {
_, err := c.client.SyncStart(ctx, &proto.SyncStartRequest{
Unit: string(unitName),
})
return err
}
// SyncWant declares a dependency between units.
func (c *Client) SyncWant(ctx context.Context, unitName, dependsOn unit.ID) error {
_, err := c.client.SyncWant(ctx, &proto.SyncWantRequest{
Unit: string(unitName),
DependsOn: string(dependsOn),
})
return err
}
// SyncComplete marks a unit as complete in the dependency graph.
func (c *Client) SyncComplete(ctx context.Context, unitName unit.ID) error {
_, err := c.client.SyncComplete(ctx, &proto.SyncCompleteRequest{
Unit: string(unitName),
})
return err
}
// SyncReady requests whether a unit is ready to be started. That is, all dependencies are satisfied.
func (c *Client) SyncReady(ctx context.Context, unitName unit.ID) (bool, error) {
resp, err := c.client.SyncReady(ctx, &proto.SyncReadyRequest{
Unit: string(unitName),
})
if err != nil {
return false, xerrors.Errorf("sync ready: %w", err)
}
return resp.Ready, nil
}
// SyncStatus gets the status of a unit and its dependencies.
func (c *Client) SyncStatus(ctx context.Context, unitName unit.ID) (SyncStatusResponse, error) {
resp, err := c.client.SyncStatus(ctx, &proto.SyncStatusRequest{
Unit: string(unitName),
})
if err != nil {
return SyncStatusResponse{}, err
}
var dependencies []DependencyInfo
for _, dep := range resp.Dependencies {
dependencies = append(dependencies, DependencyInfo{
DependsOn: unit.ID(dep.DependsOn),
RequiredStatus: unit.Status(dep.RequiredStatus),
CurrentStatus: unit.Status(dep.CurrentStatus),
IsSatisfied: dep.IsSatisfied,
})
}
return SyncStatusResponse{
UnitName: unitName,
Status: unit.Status(resp.Status),
IsReady: resp.IsReady,
Dependencies: dependencies,
}, nil
}
// SyncStatusResponse contains the status information for a unit.
type SyncStatusResponse struct {
UnitName unit.ID `table:"unit,default_sort" json:"unit_name"`
Status unit.Status `table:"status" json:"status"`
IsReady bool `table:"ready" json:"is_ready"`
Dependencies []DependencyInfo `table:"dependencies" json:"dependencies"`
}
// DependencyInfo contains information about a unit dependency.
type DependencyInfo struct {
DependsOn unit.ID `table:"depends on,default_sort" json:"depends_on"`
RequiredStatus unit.Status `table:"required status" json:"required_status"`
CurrentStatus unit.Status `table:"current status" json:"current_status"`
IsSatisfied bool `table:"satisfied" json:"is_satisfied"`
}
+12 -59
View File
@@ -7,8 +7,6 @@ import (
"sync"
"golang.org/x/xerrors"
"github.com/hashicorp/yamux"
"storj.io/drpc/drpcmux"
"storj.io/drpc/drpcserver"
@@ -33,11 +31,17 @@ type Server struct {
wg sync.WaitGroup
}
func NewServer(path string, logger slog.Logger) (*Server, error) {
// NewServer creates a new agent socket server.
func NewServer(logger slog.Logger, opts ...Option) (*Server, error) {
options := &options{}
for _, opt := range opts {
opt(options)
}
logger = logger.Named("agentsocket-server")
server := &Server{
logger: logger,
path: path,
path: options.path,
service: &DRPCAgentSocketService{
logger: logger,
unitManager: unit.NewManager(),
@@ -61,14 +65,6 @@ func NewServer(path string, logger slog.Logger) (*Server, error) {
},
})
if server.path == "" {
var err error
server.path, err = getDefaultSocketPath()
if err != nil {
return nil, xerrors.Errorf("get default socket path: %w", err)
}
}
listener, err := createSocket(server.path)
if err != nil {
return nil, xerrors.Errorf("create socket: %w", err)
@@ -91,6 +87,7 @@ func NewServer(path string, logger slog.Logger) (*Server, error) {
return server, nil
}
// Close stops the server and cleans up resources.
func (s *Server) Close() error {
s.mu.Lock()
@@ -134,52 +131,8 @@ func (s *Server) acceptConnections() {
return
}
for {
select {
case <-s.ctx.Done():
return
default:
}
conn, err := listener.Accept()
if err != nil {
s.logger.Warn(s.ctx, "error accepting connection", slog.Error(err))
continue
}
s.mu.Lock()
if s.listener == nil {
s.mu.Unlock()
_ = conn.Close()
return
}
s.wg.Add(1)
s.mu.Unlock()
go func() {
defer s.wg.Done()
s.handleConnection(conn)
}()
}
}
func (s *Server) handleConnection(conn net.Conn) {
defer conn.Close()
s.logger.Debug(s.ctx, "new connection accepted", slog.F("remote_addr", conn.RemoteAddr()))
config := yamux.DefaultConfig()
config.LogOutput = nil
config.Logger = slog.Stdlib(s.ctx, s.logger.Named("agentsocket-yamux"), slog.LevelInfo)
session, err := yamux.Server(conn, config)
if err != nil {
s.logger.Warn(s.ctx, "failed to create yamux session", slog.Error(err))
return
}
defer session.Close()
err = s.drpcServer.Serve(s.ctx, session)
if err != nil {
s.logger.Debug(s.ctx, "drpc server finished", slog.Error(err))
err := s.drpcServer.Serve(s.ctx, listener)
if err != nil {
s.logger.Warn(s.ctx, "error serving drpc server", slog.Error(err))
}
}
+90 -4
View File
@@ -1,14 +1,24 @@
package agentsocket_test
import (
"context"
"path/filepath"
"runtime"
"testing"
"github.com/google/uuid"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/agent/agenttest"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/tailnet/tailnettest"
"github.com/coder/coder/v2/testutil"
)
func TestServer(t *testing.T) {
@@ -23,7 +33,7 @@ func TestServer(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "test.sock")
logger := slog.Make().Leveled(slog.LevelDebug)
server, err := agentsocket.NewServer(socketPath, logger)
server, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
require.NoError(t, err)
require.NoError(t, server.Close())
})
@@ -33,10 +43,10 @@ func TestServer(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "test.sock")
logger := slog.Make().Leveled(slog.LevelDebug)
server1, err := agentsocket.NewServer(socketPath, logger)
server1, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
require.NoError(t, err)
defer server1.Close()
_, err = agentsocket.NewServer(socketPath, logger)
_, err = agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
require.ErrorContains(t, err, "create socket")
})
@@ -45,8 +55,84 @@ func TestServer(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "test.sock")
logger := slog.Make().Leveled(slog.LevelDebug)
server, err := agentsocket.NewServer(socketPath, logger)
server, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
require.NoError(t, err)
require.NoError(t, server.Close())
})
}
func TestServerWindowsNotSupported(t *testing.T) {
t.Parallel()
if runtime.GOOS != "windows" {
t.Skip("this test only runs on Windows")
}
t.Run("NewServer", func(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(t.TempDir(), "test.sock")
logger := slog.Make().Leveled(slog.LevelDebug)
_, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
require.ErrorContains(t, err, "agentsocket is not supported on Windows")
})
t.Run("NewClient", func(t *testing.T) {
t.Parallel()
_, err := agentsocket.NewClient(context.Background(), agentsocket.WithPath("test.sock"))
require.ErrorContains(t, err, "agentsocket is not supported on Windows")
})
}
func TestAgentInitializesOnWindowsWithoutSocketServer(t *testing.T) {
t.Parallel()
if runtime.GOOS != "windows" {
t.Skip("this test only runs on Windows")
}
ctx := testutil.Context(t, testutil.WaitShort)
logger := testutil.Logger(t).Named("agent")
derpMap, _ := tailnettest.RunDERPAndSTUN(t)
coordinator := tailnet.NewCoordinator(logger)
t.Cleanup(func() {
_ = coordinator.Close()
})
statsCh := make(chan *agentproto.Stats, 50)
agentID := uuid.New()
manifest := agentsdk.Manifest{
AgentID: agentID,
AgentName: "test-agent",
WorkspaceName: "test-workspace",
OwnerName: "test-user",
WorkspaceID: uuid.New(),
DERPMap: derpMap,
}
client := agenttest.NewClient(t, logger.Named("agenttest"), agentID, manifest, statsCh, coordinator)
t.Cleanup(client.Close)
options := agent.Options{
Client: client,
Filesystem: afero.NewMemMapFs(),
Logger: logger.Named("agent"),
ReconnectingPTYTimeout: testutil.WaitShort,
EnvironmentVariables: map[string]string{},
SocketPath: "",
}
agnt := agent.New(options)
t.Cleanup(func() {
_ = agnt.Close()
})
startup := testutil.TryReceive(ctx, t, client.GetStartup())
require.NotNil(t, startup, "agent should send startup message")
err := agnt.Close()
require.NoError(t, err, "agent should close cleanly")
}
+12 -2
View File
@@ -15,15 +15,18 @@ var _ proto.DRPCAgentSocketServer = (*DRPCAgentSocketService)(nil)
var ErrUnitManagerNotAvailable = xerrors.New("unit manager not available")
// DRPCAgentSocketService implements the DRPC agent socket service.
type DRPCAgentSocketService struct {
unitManager *unit.Manager
logger slog.Logger
}
// Ping responds to a ping request to check if the service is alive.
func (*DRPCAgentSocketService) Ping(_ context.Context, _ *proto.PingRequest) (*proto.PingResponse, error) {
return &proto.PingResponse{}, nil
}
// SyncStart starts a unit in the dependency graph.
func (s *DRPCAgentSocketService) SyncStart(_ context.Context, req *proto.SyncStartRequest) (*proto.SyncStartResponse, error) {
if s.unitManager == nil {
return nil, xerrors.Errorf("SyncStart: %w", ErrUnitManagerNotAvailable)
@@ -53,6 +56,7 @@ func (s *DRPCAgentSocketService) SyncStart(_ context.Context, req *proto.SyncSta
return &proto.SyncStartResponse{}, nil
}
// SyncWant declares a dependency between units.
func (s *DRPCAgentSocketService) SyncWant(_ context.Context, req *proto.SyncWantRequest) (*proto.SyncWantResponse, error) {
if s.unitManager == nil {
return nil, xerrors.Errorf("cannot add dependency: %w", ErrUnitManagerNotAvailable)
@@ -72,6 +76,7 @@ func (s *DRPCAgentSocketService) SyncWant(_ context.Context, req *proto.SyncWant
return &proto.SyncWantResponse{}, nil
}
// SyncComplete marks a unit as complete in the dependency graph.
func (s *DRPCAgentSocketService) SyncComplete(_ context.Context, req *proto.SyncCompleteRequest) (*proto.SyncCompleteResponse, error) {
if s.unitManager == nil {
return nil, xerrors.Errorf("cannot complete unit: %w", ErrUnitManagerNotAvailable)
@@ -86,6 +91,7 @@ func (s *DRPCAgentSocketService) SyncComplete(_ context.Context, req *proto.Sync
return &proto.SyncCompleteResponse{}, nil
}
// SyncReady checks whether a unit is ready to be started. That is, all dependencies are satisfied.
func (s *DRPCAgentSocketService) SyncReady(_ context.Context, req *proto.SyncReadyRequest) (*proto.SyncReadyResponse, error) {
if s.unitManager == nil {
return nil, xerrors.Errorf("cannot check readiness: %w", ErrUnitManagerNotAvailable)
@@ -102,6 +108,7 @@ func (s *DRPCAgentSocketService) SyncReady(_ context.Context, req *proto.SyncRea
}, nil
}
// SyncStatus gets the status of a unit and lists its dependencies.
func (s *DRPCAgentSocketService) SyncStatus(_ context.Context, req *proto.SyncStatusRequest) (*proto.SyncStatusResponse, error) {
if s.unitManager == nil {
return nil, xerrors.Errorf("cannot get status for unit %q: %w", req.Unit, ErrUnitManagerNotAvailable)
@@ -115,8 +122,11 @@ func (s *DRPCAgentSocketService) SyncStatus(_ context.Context, req *proto.SyncSt
}
dependencies, err := s.unitManager.GetAllDependencies(unitID)
if err != nil {
return nil, xerrors.Errorf("failed to get dependencies: %w", err)
switch {
case errors.Is(err, unit.ErrUnitNotFound):
dependencies = []unit.Dependency{}
case err != nil:
return nil, xerrors.Errorf("cannot get dependencies: %w", err)
}
var depInfos []*proto.DependencyInfo
+92 -173
View File
@@ -5,21 +5,18 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/hashicorp/yamux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/agent/agentsocket/proto"
"github.com/coder/coder/v2/agent/unit"
"github.com/coder/coder/v2/codersdk/drpcsdk"
"github.com/coder/coder/v2/testutil"
)
// tempDirUnixSocket returns a temporary directory that can safely hold unix
@@ -47,23 +44,15 @@ func tempDirUnixSocket(t *testing.T) string {
}
// newSocketClient creates a DRPC client connected to the Unix socket at the given path.
func newSocketClient(t *testing.T, socketPath string) proto.DRPCAgentSocketClient {
func newSocketClient(ctx context.Context, t *testing.T, socketPath string) *agentsocket.Client {
t.Helper()
conn, err := net.Dial("unix", socketPath)
require.NoError(t, err)
config := yamux.DefaultConfig()
config.Logger = nil
session, err := yamux.Client(conn, config)
require.NoError(t, err)
client := proto.NewDRPCAgentSocketClient(drpcsdk.MultiplexedConn(session))
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(socketPath))
t.Cleanup(func() {
_ = session.Close()
_ = conn.Close()
_ = client.Close()
})
require.NoError(t, err)
return client
}
@@ -78,17 +67,17 @@ func TestDRPCAgentSocketService(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
socketPath,
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(t, socketPath)
client := newSocketClient(ctx, t, socketPath)
_, err = client.Ping(context.Background(), &proto.PingRequest{})
err = client.Ping(ctx)
require.NoError(t, err)
})
@@ -98,147 +87,116 @@ func TestDRPCAgentSocketService(t *testing.T) {
t.Run("NewUnit", func(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
socketPath,
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(t, socketPath)
client := newSocketClient(ctx, t, socketPath)
_, err = client.SyncStart(context.Background(), &proto.SyncStartRequest{
Unit: "test-unit",
})
err = client.SyncStart(ctx, "test-unit")
require.NoError(t, err)
status, err := client.SyncStatus(context.Background(), &proto.SyncStatusRequest{
Unit: "test-unit",
})
status, err := client.SyncStatus(ctx, "test-unit")
require.NoError(t, err)
require.Equal(t, "started", status.Status)
require.Equal(t, unit.StatusStarted, status.Status)
})
t.Run("UnitAlreadyStarted", func(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
socketPath,
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(t, socketPath)
client := newSocketClient(ctx, t, socketPath)
// First Start
_, err = client.SyncStart(context.Background(), &proto.SyncStartRequest{
Unit: "test-unit",
})
err = client.SyncStart(ctx, "test-unit")
require.NoError(t, err)
status, err := client.SyncStatus(context.Background(), &proto.SyncStatusRequest{
Unit: "test-unit",
})
status, err := client.SyncStatus(ctx, "test-unit")
require.NoError(t, err)
require.Equal(t, "started", status.Status)
require.Equal(t, unit.StatusStarted, status.Status)
// Second Start
_, err = client.SyncStart(context.Background(), &proto.SyncStartRequest{
Unit: "test-unit",
})
err = client.SyncStart(ctx, "test-unit")
require.ErrorContains(t, err, unit.ErrSameStatusAlreadySet.Error())
status, err = client.SyncStatus(context.Background(), &proto.SyncStatusRequest{
Unit: "test-unit",
})
status, err = client.SyncStatus(ctx, "test-unit")
require.NoError(t, err)
require.Equal(t, "started", status.Status)
require.Equal(t, unit.StatusStarted, status.Status)
})
t.Run("UnitAlreadyCompleted", func(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
socketPath,
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(t, socketPath)
client := newSocketClient(ctx, t, socketPath)
// First start
_, err = client.SyncStart(context.Background(), &proto.SyncStartRequest{
Unit: "test-unit",
})
err = client.SyncStart(ctx, "test-unit")
require.NoError(t, err)
status, err := client.SyncStatus(context.Background(), &proto.SyncStatusRequest{
Unit: "test-unit",
})
status, err := client.SyncStatus(ctx, "test-unit")
require.NoError(t, err)
require.Equal(t, "started", status.Status)
require.Equal(t, unit.StatusStarted, status.Status)
// Complete the unit
_, err = client.SyncComplete(context.Background(), &proto.SyncCompleteRequest{
Unit: "test-unit",
})
err = client.SyncComplete(ctx, "test-unit")
require.NoError(t, err)
status, err = client.SyncStatus(context.Background(), &proto.SyncStatusRequest{
Unit: "test-unit",
})
status, err = client.SyncStatus(ctx, "test-unit")
require.NoError(t, err)
require.Equal(t, "completed", status.Status)
require.Equal(t, unit.StatusComplete, status.Status)
// Second start
_, err = client.SyncStart(context.Background(), &proto.SyncStartRequest{
Unit: "test-unit",
})
err = client.SyncStart(ctx, "test-unit")
require.NoError(t, err)
status, err = client.SyncStatus(context.Background(), &proto.SyncStatusRequest{
Unit: "test-unit",
})
status, err = client.SyncStatus(ctx, "test-unit")
require.NoError(t, err)
require.Equal(t, "started", status.Status)
require.Equal(t, unit.StatusStarted, status.Status)
})
t.Run("UnitNotReady", func(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
socketPath,
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(t, socketPath)
client := newSocketClient(ctx, t, socketPath)
_, err = client.SyncWant(context.Background(), &proto.SyncWantRequest{
Unit: "test-unit",
DependsOn: "dependency-unit",
})
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
require.NoError(t, err)
_, err = client.SyncStart(context.Background(), &proto.SyncStartRequest{
Unit: "test-unit",
})
err = client.SyncStart(ctx, "test-unit")
require.ErrorContains(t, err, "unit not ready")
status, err := client.SyncStatus(context.Background(), &proto.SyncStatusRequest{
Unit: "test-unit",
})
status, err := client.SyncStatus(ctx, "test-unit")
require.NoError(t, err)
require.Equal(t, string(unit.StatusPending), status.Status)
require.Equal(t, unit.StatusPending, status.Status)
require.False(t, status.IsReady)
})
})
@@ -250,107 +208,86 @@ func TestDRPCAgentSocketService(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
socketPath,
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(t, socketPath)
client := newSocketClient(ctx, t, socketPath)
// If dependency units are not registered, they are registered automatically
_, err = client.SyncWant(context.Background(), &proto.SyncWantRequest{
Unit: "test-unit",
DependsOn: "dependency-unit",
})
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
require.NoError(t, err)
status, err := client.SyncStatus(context.Background(), &proto.SyncStatusRequest{
Unit: "test-unit",
})
status, err := client.SyncStatus(ctx, "test-unit")
require.NoError(t, err)
require.Len(t, status.Dependencies, 1)
require.Equal(t, "dependency-unit", status.Dependencies[0].DependsOn)
require.Equal(t, "completed", status.Dependencies[0].RequiredStatus)
require.Equal(t, unit.ID("dependency-unit"), status.Dependencies[0].DependsOn)
require.Equal(t, unit.StatusComplete, status.Dependencies[0].RequiredStatus)
})
t.Run("DependencyAlreadyRegistered", func(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
socketPath,
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(t, socketPath)
client := newSocketClient(ctx, t, socketPath)
// Start the dependency unit
_, err = client.SyncStart(context.Background(), &proto.SyncStartRequest{
Unit: "dependency-unit",
})
err = client.SyncStart(ctx, "dependency-unit")
require.NoError(t, err)
status, err := client.SyncStatus(context.Background(), &proto.SyncStatusRequest{
Unit: "dependency-unit",
})
status, err := client.SyncStatus(ctx, "dependency-unit")
require.NoError(t, err)
require.Equal(t, "started", status.Status)
require.Equal(t, unit.StatusStarted, status.Status)
// Add the dependency after the dependency unit has already started
_, err = client.SyncWant(context.Background(), &proto.SyncWantRequest{
Unit: "test-unit",
DependsOn: "dependency-unit",
})
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
// Dependencies can be added even if the dependency unit has already started
require.NoError(t, err)
// The dependency is now reflected in the test unit's status
status, err = client.SyncStatus(context.Background(), &proto.SyncStatusRequest{
Unit: "test-unit",
})
status, err = client.SyncStatus(ctx, "test-unit")
require.NoError(t, err)
require.Equal(t, "dependency-unit", status.Dependencies[0].DependsOn)
require.Equal(t, "completed", status.Dependencies[0].RequiredStatus)
require.Equal(t, unit.ID("dependency-unit"), status.Dependencies[0].DependsOn)
require.Equal(t, unit.StatusComplete, status.Dependencies[0].RequiredStatus)
})
t.Run("DependencyAddedAfterDependentStarted", func(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
socketPath,
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(t, socketPath)
client := newSocketClient(ctx, t, socketPath)
// Start the dependent unit
_, err = client.SyncStart(context.Background(), &proto.SyncStartRequest{
Unit: "test-unit",
})
err = client.SyncStart(ctx, "test-unit")
require.NoError(t, err)
status, err := client.SyncStatus(context.Background(), &proto.SyncStatusRequest{
Unit: "test-unit",
})
status, err := client.SyncStatus(ctx, "test-unit")
require.NoError(t, err)
require.Equal(t, "started", status.Status)
require.Equal(t, unit.StatusStarted, status.Status)
// Add the dependency after the dependency unit has already started
_, err = client.SyncWant(context.Background(), &proto.SyncWantRequest{
Unit: "test-unit",
DependsOn: "dependency-unit",
})
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
// Dependencies can be added even if the dependent unit has already started.
// The dependency applies the next time a unit is started. The current status is not updated.
@@ -359,12 +296,10 @@ func TestDRPCAgentSocketService(t *testing.T) {
require.NoError(t, err)
// The dependency is now reflected in the test unit's status
status, err = client.SyncStatus(context.Background(), &proto.SyncStatusRequest{
Unit: "test-unit",
})
status, err = client.SyncStatus(ctx, "test-unit")
require.NoError(t, err)
require.Equal(t, "dependency-unit", status.Dependencies[0].DependsOn)
require.Equal(t, "completed", status.Dependencies[0].RequiredStatus)
require.Equal(t, unit.ID("dependency-unit"), status.Dependencies[0].DependsOn)
require.Equal(t, unit.StatusComplete, status.Dependencies[0].RequiredStatus)
})
})
@@ -375,96 +310,80 @@ func TestDRPCAgentSocketService(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
socketPath,
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(t, socketPath)
client := newSocketClient(ctx, t, socketPath)
response, err := client.SyncReady(context.Background(), &proto.SyncReadyRequest{
Unit: "unregistered-unit",
})
ready, err := client.SyncReady(ctx, "unregistered-unit")
require.NoError(t, err)
require.False(t, response.Ready)
require.True(t, ready)
})
t.Run("UnitNotReady", func(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
socketPath,
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(t, socketPath)
client := newSocketClient(ctx, t, socketPath)
// Register a unit with an unsatisfied dependency
_, err = client.SyncWant(context.Background(), &proto.SyncWantRequest{
Unit: "test-unit",
DependsOn: "dependency-unit",
})
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
require.NoError(t, err)
// Check readiness - should be false because dependency is not satisfied
response, err := client.SyncReady(context.Background(), &proto.SyncReadyRequest{
Unit: "test-unit",
})
ready, err := client.SyncReady(ctx, "test-unit")
require.NoError(t, err)
require.False(t, response.Ready)
require.False(t, ready)
})
t.Run("UnitReady", func(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
socketPath,
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(t, socketPath)
client := newSocketClient(ctx, t, socketPath)
// Register a unit with no dependencies - should be ready immediately
_, err = client.SyncStart(context.Background(), &proto.SyncStartRequest{
Unit: "test-unit",
})
err = client.SyncStart(ctx, "test-unit")
require.NoError(t, err)
// Check readiness - should be true
_, err = client.SyncReady(context.Background(), &proto.SyncReadyRequest{
Unit: "test-unit",
})
ready, err := client.SyncReady(ctx, "test-unit")
require.NoError(t, err)
require.True(t, ready)
// Also test a unit with satisfied dependencies
_, err = client.SyncWant(context.Background(), &proto.SyncWantRequest{
Unit: "dependent-unit",
DependsOn: "test-unit",
})
err = client.SyncWant(ctx, "dependent-unit", "test-unit")
require.NoError(t, err)
// Complete the dependency
_, err = client.SyncComplete(context.Background(), &proto.SyncCompleteRequest{
Unit: "test-unit",
})
err = client.SyncComplete(ctx, "test-unit")
require.NoError(t, err)
// Now dependent-unit should be ready
_, err = client.SyncReady(context.Background(), &proto.SyncReadyRequest{
Unit: "dependent-unit",
})
ready, err = client.SyncReady(ctx, "dependent-unit")
require.NoError(t, err)
require.True(t, ready)
})
})
}
+17 -27
View File
@@ -3,8 +3,7 @@
package agentsocket
import (
"crypto/rand"
"encoding/hex"
"context"
"net"
"os"
"path/filepath"
@@ -13,8 +12,13 @@ import (
"golang.org/x/xerrors"
)
// createSocket creates a Unix domain socket listener
const defaultSocketPath = "/tmp/coder-agent.sock"
func createSocket(path string) (net.Listener, error) {
if path == "" {
path = defaultSocketPath
}
if !isSocketAvailable(path) {
return nil, xerrors.Errorf("socket path %s is not available", path)
}
@@ -23,7 +27,6 @@ func createSocket(path string) (net.Listener, error) {
return nil, xerrors.Errorf("remove existing socket: %w", err)
}
// Create parent directory if it doesn't exist
parentDir := filepath.Dir(path)
if err := os.MkdirAll(parentDir, 0o700); err != nil {
return nil, xerrors.Errorf("create socket directory: %w", err)
@@ -41,43 +44,30 @@ func createSocket(path string) (net.Listener, error) {
return listener, nil
}
// getDefaultSocketPath returns the default socket path for Unix-like systems
func getDefaultSocketPath() (string, error) {
randomBytes := make([]byte, 4)
if _, err := rand.Read(randomBytes); err != nil {
return "", xerrors.Errorf("generate random socket name: %w", err)
}
randomSuffix := hex.EncodeToString(randomBytes)
// Try XDG_RUNTIME_DIR first
if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
return filepath.Join(runtimeDir, "coder-agent-"+randomSuffix+".sock"), nil
}
return filepath.Join("/tmp", "coder-agent-"+randomSuffix+".sock"), nil
}
// CleanupSocket removes the socket file
func cleanupSocket(path string) error {
return os.Remove(path)
}
// isSocketAvailable checks if a socket path is available for use
func isSocketAvailable(path string) bool {
// Check if file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
return true
}
// Try to connect to see if it's actually listening
// Try to connect to see if it's actually listening.
dialer := net.Dialer{Timeout: 10 * time.Second}
conn, err := dialer.Dial("unix", path)
if err != nil {
// If we can't connect, the socket is not in use
// Socket is available for use
return true
}
_ = conn.Close()
// Socket is in use
return false
}
func dialSocket(ctx context.Context, path string) (net.Conn, error) {
if path == "" {
path = defaultSocketPath
}
dialer := net.Dialer{}
return dialer.DialContext(ctx, "unix", path)
}
+5 -10
View File
@@ -3,25 +3,20 @@
package agentsocket
import (
"context"
"net"
"golang.org/x/xerrors"
)
// createSocket returns an error indicating that agentsocket is not supported on Windows.
// This feature is unix-only in its current experimental state.
func createSocket(_ string) (net.Listener, error) {
return nil, xerrors.New("agentsocket is not supported on Windows")
}
// getDefaultSocketPath returns an error indicating that agentsocket is not supported on Windows.
// This feature is unix-only in its current experimental state.
func getDefaultSocketPath() (string, error) {
return "", xerrors.New("agentsocket is not supported on Windows")
}
// cleanupSocket is a no-op on Windows since agentsocket is not supported.
func cleanupSocket(_ string) error {
// No-op since agentsocket is not supported on Windows
return nil
}
func dialSocket(_ context.Context, _ string) (net.Conn, error) {
return nil, xerrors.New("agentsocket is not supported on Windows")
}
+33 -31
View File
@@ -2,41 +2,31 @@ package agent
import (
"net/http"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw/loggermw"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/httpmw"
)
func (a *agent) apiHandler() http.Handler {
r := chi.NewRouter()
r.Use(
httpmw.Recover(a.logger),
tracing.StatusWriterMiddleware,
loggermw.Logger(a.logger),
)
r.Get("/", func(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
Message: "Hello from the agent!",
})
})
// Make a copy to ensure the map is not modified after the handler is
// created.
cpy := make(map[int]string)
for k, b := range a.ignorePorts {
cpy[k] = b
}
cacheDuration := 1 * time.Second
if a.portCacheDuration > 0 {
cacheDuration = a.portCacheDuration
}
lp := &listeningPortsHandler{
ignorePorts: cpy,
cacheDuration: cacheDuration,
}
if a.devcontainers {
r.Mount("/api/v0/containers", a.containerAPI.Routes())
} else if manifest := a.manifest.Load(); manifest != nil && manifest.ParentID != uuid.Nil {
@@ -57,7 +47,7 @@ func (a *agent) apiHandler() http.Handler {
promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger)
r.Get("/api/v0/listening-ports", lp.handler)
r.Get("/api/v0/listening-ports", a.listeningPortsHandler.handler)
r.Get("/api/v0/netcheck", a.HandleNetcheck)
r.Post("/api/v0/list-directory", a.HandleLS)
r.Get("/api/v0/read-file", a.HandleReadFile)
@@ -72,22 +62,21 @@ func (a *agent) apiHandler() http.Handler {
return r
}
type listeningPortsHandler struct {
ignorePorts map[int]string
cacheDuration time.Duration
type ListeningPortsGetter interface {
GetListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error)
}
//nolint: unused // used on some but not all platforms
mut sync.Mutex
//nolint: unused // used on some but not all platforms
ports []codersdk.WorkspaceAgentListeningPort
//nolint: unused // used on some but not all platforms
mtime time.Time
type listeningPortsHandler struct {
// In production code, this is set to an osListeningPortsGetter, but it can be overridden for
// testing.
getter ListeningPortsGetter
ignorePorts map[int]string
}
// handler returns a list of listening ports. This is tested by coderd's
// TestWorkspaceAgentListeningPorts test.
func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request) {
ports, err := lp.getListeningPorts()
ports, err := lp.getter.GetListeningPorts()
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Could not scan for listening ports.",
@@ -96,7 +85,20 @@ func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request
return
}
filteredPorts := make([]codersdk.WorkspaceAgentListeningPort, 0, len(ports))
for _, port := range ports {
if port.Port < workspacesdk.AgentMinimumListeningPort {
continue
}
// Ignore ports that we've been told to ignore.
if _, ok := lp.ignorePorts[int(port.Port)]; ok {
continue
}
filteredPorts = append(filteredPorts, port)
}
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.WorkspaceAgentListeningPortsResponse{
Ports: ports,
Ports: filteredPorts,
})
}
+10 -8
View File
@@ -3,16 +3,23 @@
package agent
import (
"sync"
"time"
"github.com/cakturk/go-netstat/netstat"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
type osListeningPortsGetter struct {
cacheDuration time.Duration
mut sync.Mutex
ports []codersdk.WorkspaceAgentListeningPort
mtime time.Time
}
func (lp *osListeningPortsGetter) GetListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
lp.mut.Lock()
defer lp.mut.Unlock()
@@ -33,12 +40,7 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentL
seen := make(map[uint16]struct{}, len(tabs))
ports := []codersdk.WorkspaceAgentListeningPort{}
for _, tab := range tabs {
if tab.LocalAddr == nil || tab.LocalAddr.Port < workspacesdk.AgentMinimumListeningPort {
continue
}
// Ignore ports that we've been told to ignore.
if _, ok := lp.ignorePorts[int(tab.LocalAddr.Port)]; ok {
if tab.LocalAddr == nil {
continue
}
+45
View File
@@ -0,0 +1,45 @@
//go:build linux || (windows && amd64)
package agent
import (
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestOSListeningPortsGetter(t *testing.T) {
t.Parallel()
uut := &osListeningPortsGetter{
cacheDuration: 1 * time.Hour,
}
l, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
defer l.Close()
ports, err := uut.GetListeningPorts()
require.NoError(t, err)
found := false
for _, port := range ports {
// #nosec G115 - Safe conversion as TCP port numbers are within uint16 range (0-65535)
if port.Port == uint16(l.Addr().(*net.TCPAddr).Port) {
found = true
break
}
}
require.True(t, found)
// check that we cache the ports
err = l.Close()
require.NoError(t, err)
portsNew, err := uut.GetListeningPorts()
require.NoError(t, err)
require.Equal(t, ports, portsNew)
// note that it's unsafe to try to assert that a port does not exist in the response
// because the OS may reallocate the port very quickly.
}
+10 -2
View File
@@ -2,9 +2,17 @@
package agent
import "github.com/coder/coder/v2/codersdk"
import (
"time"
func (*listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
"github.com/coder/coder/v2/codersdk"
)
type osListeningPortsGetter struct {
cacheDuration time.Duration
}
func (*osListeningPortsGetter) GetListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
// Can't scan for ports on non-linux or non-windows_amd64 systems at the
// moment. The UI will not show any "no ports found" message to the user, so
// the user won't suspect a thing.
+9
View File
@@ -4,6 +4,8 @@ import (
"os"
"github.com/hashicorp/go-reap"
"cdr.dev/slog"
)
type Option func(o *options)
@@ -34,8 +36,15 @@ func WithCatchSignals(sigs ...os.Signal) Option {
}
}
func WithLogger(logger slog.Logger) Option {
return func(o *options) {
o.Logger = logger
}
}
type options struct {
ExecArgs []string
PIDs reap.PidCh
CatchSignals []os.Signal
Logger slog.Logger
}
+2 -2
View File
@@ -7,6 +7,6 @@ func IsInitProcess() bool {
return false
}
func ForkReap(_ ...Option) error {
return nil
func ForkReap(_ ...Option) (int, error) {
return 0, nil
}
+37 -2
View File
@@ -32,12 +32,13 @@ func TestReap(t *testing.T) {
}
pids := make(reap.PidCh, 1)
err := reaper.ForkReap(
exitCode, err := reaper.ForkReap(
reaper.WithPIDCallback(pids),
// Provide some argument that immediately exits.
reaper.WithExecArgs("/bin/sh", "-c", "exit 0"),
)
require.NoError(t, err)
require.Equal(t, 0, exitCode)
cmd := exec.Command("tail", "-f", "/dev/null")
err = cmd.Start()
@@ -65,6 +66,36 @@ func TestReap(t *testing.T) {
}
}
//nolint:paralleltest
func TestForkReapExitCodes(t *testing.T) {
if testutil.InCI() {
t.Skip("Detected CI, skipping reaper tests")
}
tests := []struct {
name string
command string
expectedCode int
}{
{"exit 0", "exit 0", 0},
{"exit 1", "exit 1", 1},
{"exit 42", "exit 42", 42},
{"exit 255", "exit 255", 255},
{"SIGKILL", "kill -9 $$", 128 + 9},
{"SIGTERM", "kill -15 $$", 128 + 15},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
exitCode, err := reaper.ForkReap(
reaper.WithExecArgs("/bin/sh", "-c", tt.command),
)
require.NoError(t, err)
require.Equal(t, tt.expectedCode, exitCode, "exit code mismatch for %q", tt.command)
})
}
}
//nolint:paralleltest // Signal handling.
func TestReapInterrupt(t *testing.T) {
// Don't run the reaper test in CI. It does weird
@@ -84,13 +115,17 @@ func TestReapInterrupt(t *testing.T) {
defer signal.Stop(usrSig)
go func() {
errC <- reaper.ForkReap(
exitCode, err := reaper.ForkReap(
reaper.WithPIDCallback(pids),
reaper.WithCatchSignals(os.Interrupt),
// Signal propagation does not extend to children of children, so
// we create a little bash script to ensure sleep is interrupted.
reaper.WithExecArgs("/bin/sh", "-c", fmt.Sprintf("pid=0; trap 'kill -USR2 %d; kill -TERM $pid' INT; sleep 10 &\npid=$!; kill -USR1 %d; wait", os.Getpid(), os.Getpid())),
)
// The child exits with 128 + SIGTERM (15) = 143, but the trap catches
// SIGINT and sends SIGTERM to the sleep process, so exit code varies.
_ = exitCode
errC <- err
}()
require.Equal(t, <-usrSig, syscall.SIGUSR1)
+34 -6
View File
@@ -3,12 +3,15 @@
package reaper
import (
"context"
"os"
"os/signal"
"syscall"
"github.com/hashicorp/go-reap"
"golang.org/x/xerrors"
"cdr.dev/slog"
)
// IsInitProcess returns true if the current process's PID is 1.
@@ -16,7 +19,7 @@ func IsInitProcess() bool {
return os.Getpid() == 1
}
func catchSignals(pid int, sigs []os.Signal) {
func catchSignals(logger slog.Logger, pid int, sigs []os.Signal) {
if len(sigs) == 0 {
return
}
@@ -25,10 +28,19 @@ func catchSignals(pid int, sigs []os.Signal) {
signal.Notify(sc, sigs...)
defer signal.Stop(sc)
logger.Info(context.Background(), "reaper catching signals",
slog.F("signals", sigs),
slog.F("child_pid", pid),
)
for {
s := <-sc
sig, ok := s.(syscall.Signal)
if ok {
logger.Info(context.Background(), "reaper caught signal, killing child process",
slog.F("signal", sig.String()),
slog.F("child_pid", pid),
)
_ = syscall.Kill(pid, sig)
}
}
@@ -40,7 +52,10 @@ func catchSignals(pid int, sigs []os.Signal) {
// the reaper and an exec.Command waiting for its process to complete.
// The provided 'pids' channel may be nil if the caller does not care about the
// reaped children PIDs.
func ForkReap(opt ...Option) error {
//
// Returns the child's exit code (using 128+signal for signal termination)
// and any error from Wait4.
func ForkReap(opt ...Option) (int, error) {
opts := &options{
ExecArgs: os.Args,
}
@@ -53,7 +68,7 @@ func ForkReap(opt ...Option) error {
pwd, err := os.Getwd()
if err != nil {
return xerrors.Errorf("get wd: %w", err)
return 1, xerrors.Errorf("get wd: %w", err)
}
pattrs := &syscall.ProcAttr{
@@ -72,15 +87,28 @@ func ForkReap(opt ...Option) error {
//#nosec G204
pid, err := syscall.ForkExec(opts.ExecArgs[0], opts.ExecArgs, pattrs)
if err != nil {
return xerrors.Errorf("fork exec: %w", err)
return 1, xerrors.Errorf("fork exec: %w", err)
}
go catchSignals(pid, opts.CatchSignals)
go catchSignals(opts.Logger, pid, opts.CatchSignals)
var wstatus syscall.WaitStatus
_, err = syscall.Wait4(pid, &wstatus, 0, nil)
for xerrors.Is(err, syscall.EINTR) {
_, err = syscall.Wait4(pid, &wstatus, 0, nil)
}
return err
// Convert wait status to exit code using standard Unix conventions:
// - Normal exit: use the exit code
// - Signal termination: use 128 + signal number
var exitCode int
switch {
case wstatus.Exited():
exitCode = wstatus.ExitStatus()
case wstatus.Signaled():
exitCode = 128 + int(wstatus.Signal())
default:
exitCode = 1
}
return exitCode, err
}
+11 -1
View File
@@ -2,6 +2,7 @@ package unit
import (
"errors"
"fmt"
"sync"
"golang.org/x/xerrors"
@@ -23,6 +24,15 @@ var (
// Status represents the status of a unit.
type Status string
var _ fmt.Stringer = Status("")
func (s Status) String() string {
if s == StatusNotRegistered {
return "not registered"
}
return string(s)
}
// Status constants for dependency tracking.
const (
StatusNotRegistered Status = ""
@@ -137,7 +147,7 @@ func (m *Manager) IsReady(id ID) (bool, error) {
defer m.mu.RUnlock()
if !m.registered(id) {
return false, nil
return true, nil
}
return m.units[id].ready, nil
+1 -1
View File
@@ -684,7 +684,7 @@ func TestManager_IsReady(t *testing.T) {
// Then: the unit is not ready
isReady, err := manager.IsReady(unitA)
require.NoError(t, err)
assert.False(t, isReady)
assert.True(t, isReady)
})
}
+63 -18
View File
@@ -9,6 +9,7 @@ import (
"net/http/pprof"
"net/url"
"os"
"os/signal"
"path/filepath"
"runtime"
"slices"
@@ -57,6 +58,8 @@ func workspaceAgent() *serpent.Command {
devcontainers bool
devcontainerProjectDiscovery bool
devcontainerDiscoveryAutostart bool
socketServerEnabled bool
socketPath string
)
agentAuth := &AgentAuth{}
cmd := &serpent.Command{
@@ -128,40 +131,29 @@ func workspaceAgent() *serpent.Command {
sinks = append(sinks, sloghuman.Sink(logWriter))
logger := inv.Logger.AppendSinks(sinks...).Leveled(slog.LevelDebug)
logger = logger.Named("reaper")
logger.Info(ctx, "spawning reaper process")
// Do not start a reaper on the child process. It's important
// to do this else we fork bomb ourselves.
//nolint:gocritic
args := append(os.Args, "--no-reap")
err := reaper.ForkReap(
exitCode, err := reaper.ForkReap(
reaper.WithExecArgs(args...),
reaper.WithCatchSignals(StopSignals...),
reaper.WithLogger(logger),
)
if err != nil {
logger.Error(ctx, "agent process reaper unable to fork", slog.Error(err))
return xerrors.Errorf("fork reap: %w", err)
}
logger.Info(ctx, "reaper process exiting")
return nil
logger.Info(ctx, "child process exited, propagating exit code",
slog.F("exit_code", exitCode),
)
return ExitError(exitCode, nil)
}
// Handle interrupt signals to allow for graceful shutdown,
// note that calling stopNotify disables the signal handler
// and the next interrupt will terminate the program (you
// probably want cancel instead).
//
// Note that we don't want to handle these signals in the
// process that runs as PID 1, that's why we do this after
// the reaper forked.
ctx, stopNotify := inv.SignalNotifyContext(ctx, StopSignals...)
defer stopNotify()
// DumpHandler does signal handling, so we call it after the
// reaper.
go DumpHandler(ctx, "agent")
logWriter := &clilog.LumberjackWriteCloseFixer{Writer: &lumberjack.Logger{
Filename: filepath.Join(logDir, "coder-agent.log"),
MaxSize: 5, // MB
@@ -174,6 +166,21 @@ func workspaceAgent() *serpent.Command {
sinks = append(sinks, sloghuman.Sink(logWriter))
logger := inv.Logger.AppendSinks(sinks...).Leveled(slog.LevelDebug)
// Handle interrupt signals to allow for graceful shutdown,
// note that calling stopNotify disables the signal handler
// and the next interrupt will terminate the program (you
// probably want cancel instead).
//
// Note that we also handle these signals in the
// process that runs as PID 1, mainly to forward it to the agent child
// so that it can shutdown gracefully.
ctx, stopNotify := logSignalNotifyContext(ctx, logger, StopSignals...)
defer stopNotify()
// DumpHandler does signal handling, so we call it after the
// reaper.
go DumpHandler(ctx, "agent")
version := buildinfo.Version()
logger.Info(ctx, "agent is starting now",
slog.F("url", agentAuth.agentURL),
@@ -317,6 +324,8 @@ func workspaceAgent() *serpent.Command {
agentcontainers.WithProjectDiscovery(devcontainerProjectDiscovery),
agentcontainers.WithDiscoveryAutostart(devcontainerDiscoveryAutostart),
},
SocketPath: socketPath,
SocketServerEnabled: socketServerEnabled,
})
if debugAddress != "" {
@@ -477,6 +486,19 @@ func workspaceAgent() *serpent.Command {
Description: "Allow the agent to autostart devcontainer projects it discovers based on their configuration.",
Value: serpent.BoolOf(&devcontainerDiscoveryAutostart),
},
{
Flag: "socket-server-enabled",
Default: "false",
Env: "CODER_AGENT_SOCKET_SERVER_ENABLED",
Description: "Enable the agent socket server.",
Value: serpent.BoolOf(&socketServerEnabled),
},
{
Flag: "socket-path",
Env: "CODER_AGENT_SOCKET_PATH",
Description: "Specify the path for the agent socket.",
Value: serpent.StringOf(&socketPath),
},
}
agentAuth.AttachOptions(cmd, false)
return cmd
@@ -540,3 +562,26 @@ func urlPort(u string) (int, error) {
}
return -1, xerrors.Errorf("invalid port: %s", u)
}
// logSignalNotifyContext is like signal.NotifyContext but logs the received
// signal before canceling the context.
func logSignalNotifyContext(parent context.Context, logger slog.Logger, signals ...os.Signal) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancelCause(parent)
c := make(chan os.Signal, 1)
signal.Notify(c, signals...)
go func() {
select {
case sig := <-c:
logger.Info(ctx, "agent received signal", slog.F("signal", sig.String()))
cancel(xerrors.Errorf("signal: %s", sig.String()))
case <-ctx.Done():
logger.Info(ctx, "ctx canceled, stopping signal handler")
}
}()
return ctx, func() {
cancel(context.Canceled)
signal.Stop(c)
}
}
+20 -4
View File
@@ -28,7 +28,9 @@ import (
)
// New creates a CLI instance with a configuration pointed to a
// temporary testing directory.
// temporary testing directory. The invocation is set up to use a
// global config directory for the given testing.TB, and keyring
// usage disabled.
func New(t testing.TB, args ...string) (*serpent.Invocation, config.Root) {
var root cli.RootCmd
@@ -59,6 +61,15 @@ func NewWithCommand(
t testing.TB, cmd *serpent.Command, args ...string,
) (*serpent.Invocation, config.Root) {
configDir := config.Root(t.TempDir())
// Keyring usage is disabled here when --global-config is set because many existing
// tests expect the session token to be stored on disk and is not properly instrumented
// for parallel testing against the actual operating system keyring.
invArgs := append([]string{"--global-config", string(configDir)}, args...)
return setupInvocation(t, cmd, invArgs...), configDir
}
func setupInvocation(t testing.TB, cmd *serpent.Command, args ...string,
) *serpent.Invocation {
// I really would like to fail test on error logs, but realistically, turning on by default
// in all our CLI tests is going to create a lot of flaky noise.
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
@@ -66,16 +77,21 @@ func NewWithCommand(
Named("cli")
i := &serpent.Invocation{
Command: cmd,
Args: append([]string{"--global-config", string(configDir)}, args...),
Args: args,
Stdin: io.LimitReader(nil, 0),
Stdout: (&logWriter{prefix: "stdout", log: logger}),
Stderr: (&logWriter{prefix: "stderr", log: logger}),
Logger: logger,
}
t.Logf("invoking command: %s %s", cmd.Name(), strings.Join(i.Args, " "))
return i
}
// These can be overridden by the test.
return i, configDir
func NewWithDefaultKeyringCommand(t testing.TB, cmd *serpent.Command, args ...string,
) (*serpent.Invocation, config.Root) {
configDir := config.Root(t.TempDir())
invArgs := append([]string{"--global-config", string(configDir)}, args...)
return setupInvocation(t, cmd, invArgs...), configDir
}
// SetupConfig applies the URL and SessionToken of the client to the config.
+204 -133
View File
@@ -2,62 +2,85 @@ package cli_test
import (
"bytes"
"crypto/rand"
"encoding/binary"
"fmt"
"net/url"
"os"
"path"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/cli/sessionstore"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/serpent"
)
// mockKeyring is a mock sessionstore.Backend implementation.
type mockKeyring struct {
credentials map[string]string // service name -> credential
}
const mockServiceName = "mock-service-name"
func newMockKeyring() *mockKeyring {
return &mockKeyring{credentials: make(map[string]string)}
}
func (m *mockKeyring) Read(_ *url.URL) (string, error) {
cred, ok := m.credentials[mockServiceName]
if !ok {
return "", os.ErrNotExist
// keyringTestServiceName generates a unique service name for keyring tests
// using the test name and a nanosecond timestamp to prevent collisions.
func keyringTestServiceName(t *testing.T) string {
t.Helper()
var n uint32
err := binary.Read(rand.Reader, binary.BigEndian, &n)
if err != nil {
t.Fatal(err)
}
return cred, nil
return fmt.Sprintf("%s_%v_%d", t.Name(), time.Now().UnixNano(), n)
}
func (m *mockKeyring) Write(_ *url.URL, token string) error {
m.credentials[mockServiceName] = token
return nil
type keyringTestEnv struct {
serviceName string
keyring sessionstore.Keyring
inv *serpent.Invocation
cfg config.Root
clientURL *url.URL
}
func (m *mockKeyring) Delete(_ *url.URL) error {
_, ok := m.credentials[mockServiceName]
if !ok {
return os.ErrNotExist
}
delete(m.credentials, mockServiceName)
return nil
func setupKeyringTestEnv(t *testing.T, clientURL string, args ...string) keyringTestEnv {
t.Helper()
var root cli.RootCmd
cmd, err := root.Command(root.AGPL())
require.NoError(t, err)
serviceName := keyringTestServiceName(t)
root.WithKeyringServiceName(serviceName)
root.UseKeyringWithGlobalConfig()
inv, cfg := clitest.NewWithDefaultKeyringCommand(t, cmd, args...)
parsedURL, err := url.Parse(clientURL)
require.NoError(t, err)
backend := sessionstore.NewKeyringWithService(serviceName)
t.Cleanup(func() {
_ = backend.Delete(parsedURL)
})
return keyringTestEnv{serviceName, backend, inv, cfg, parsedURL}
}
func TestUseKeyring(t *testing.T) {
// Verify that the --use-keyring flag opts into using a keyring backend for
// storing session tokens instead of plain text files.
// Verify that the --use-keyring flag default opts into using a keyring backend
// for storing session tokens instead of plain text files.
t.Parallel()
t.Run("Login", func(t *testing.T) {
t.Parallel()
if runtime.GOOS != "windows" && runtime.GOOS != "darwin" {
t.Skip("keyring is not supported on this OS")
}
// Create a test server
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
@@ -65,25 +88,16 @@ func TestUseKeyring(t *testing.T) {
// Create a pty for interactive prompts
pty := ptytest.New(t)
// Create CLI invocation with --use-keyring flag
inv, cfg := clitest.New(t,
// Create CLI invocation which defaults to using the keyring
env := setupKeyringTestEnv(t, client.URL.String(),
"login",
"--force-tty",
"--use-keyring",
"--no-open",
client.URL.String(),
)
client.URL.String())
inv := env.inv
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
// Inject the mock backend before running the command
var root cli.RootCmd
cmd, err := root.Command(root.AGPL())
require.NoError(t, err)
mockBackend := newMockKeyring()
root.WithSessionStorageBackend(mockBackend)
inv.Command = cmd
// Run login in background
doneChan := make(chan struct{})
go func() {
@@ -99,19 +113,23 @@ func TestUseKeyring(t *testing.T) {
<-doneChan
// Verify that session file was NOT created (using keyring instead)
sessionFile := path.Join(string(cfg), "session")
_, err = os.Stat(sessionFile)
sessionFile := path.Join(string(env.cfg), "session")
_, err := os.Stat(sessionFile)
require.True(t, os.IsNotExist(err), "session file should not exist when using keyring")
// Verify that the credential IS stored in mock keyring
cred, err := mockBackend.Read(nil)
require.NoError(t, err, "credential should be stored in mock keyring")
// Verify that the credential IS stored in OS keyring
cred, err := env.keyring.Read(env.clientURL)
require.NoError(t, err, "credential should be stored in OS keyring")
require.Equal(t, client.SessionToken(), cred, "stored token should match login token")
})
t.Run("Logout", func(t *testing.T) {
t.Parallel()
if runtime.GOOS != "windows" && runtime.GOOS != "darwin" {
t.Skip("keyring is not supported on this OS")
}
// Create a test server
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
@@ -119,25 +137,17 @@ func TestUseKeyring(t *testing.T) {
// Create a pty for interactive prompts
pty := ptytest.New(t)
// First, login with --use-keyring
loginInv, cfg := clitest.New(t,
// First, login with the keyring (default)
env := setupKeyringTestEnv(t, client.URL.String(),
"login",
"--force-tty",
"--use-keyring",
"--no-open",
client.URL.String(),
)
loginInv := env.inv
loginInv.Stdin = pty.Input()
loginInv.Stdout = pty.Output()
// Inject the mock backend
var loginRoot cli.RootCmd
loginCmd, err := loginRoot.Command(loginRoot.AGPL())
require.NoError(t, err)
mockBackend := newMockKeyring()
loginRoot.WithSessionStorageBackend(mockBackend)
loginInv.Command = loginCmd
doneChan := make(chan struct{})
go func() {
defer close(doneChan)
@@ -150,25 +160,23 @@ func TestUseKeyring(t *testing.T) {
pty.ExpectMatch("Welcome to Coder")
<-doneChan
// Verify credential exists in mock keyring
cred, err := mockBackend.Read(nil)
// Verify credential exists in OS keyring
cred, err := env.keyring.Read(env.clientURL)
require.NoError(t, err, "read credential should succeed before logout")
require.NotEmpty(t, cred, "credential should exist after logout")
require.NotEmpty(t, cred, "credential should exist before logout")
// Now run logout with --use-keyring
logoutInv, _ := clitest.New(t,
"logout",
"--use-keyring",
"--yes",
"--global-config", string(cfg),
)
// Inject the same mock backend
// Now logout using the same keyring service name
var logoutRoot cli.RootCmd
logoutCmd, err := logoutRoot.Command(logoutRoot.AGPL())
require.NoError(t, err)
logoutRoot.WithSessionStorageBackend(mockBackend)
logoutInv.Command = logoutCmd
logoutRoot.WithKeyringServiceName(env.serviceName)
logoutRoot.UseKeyringWithGlobalConfig()
logoutInv, _ := clitest.NewWithDefaultKeyringCommand(t, logoutCmd,
"logout",
"--yes",
"--global-config", string(env.cfg),
)
var logoutOut bytes.Buffer
logoutInv.Stdout = &logoutOut
@@ -176,14 +184,18 @@ func TestUseKeyring(t *testing.T) {
err = logoutInv.Run()
require.NoError(t, err, "logout should succeed")
// Verify the credential was deleted from mock keyring
_, err = mockBackend.Read(nil)
// Verify the credential was deleted from OS keyring
_, err = env.keyring.Read(env.clientURL)
require.ErrorIs(t, err, os.ErrNotExist, "credential should be deleted from keyring after logout")
})
t.Run("OmitFlag", func(t *testing.T) {
t.Run("DefaultFileStorage", func(t *testing.T) {
t.Parallel()
if runtime.GOOS != "linux" {
t.Skip("file storage is the default for Linux")
}
// Create a test server
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
@@ -191,13 +203,13 @@ func TestUseKeyring(t *testing.T) {
// Create a pty for interactive prompts
pty := ptytest.New(t)
// --use-keyring flag omitted (should use file-based storage)
inv, cfg := clitest.New(t,
env := setupKeyringTestEnv(t, client.URL.String(),
"login",
"--force-tty",
"--no-open",
client.URL.String(),
)
inv := env.inv
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
@@ -214,9 +226,9 @@ func TestUseKeyring(t *testing.T) {
<-doneChan
// Verify that session file WAS created (not using keyring)
sessionFile := path.Join(string(cfg), "session")
sessionFile := path.Join(string(env.cfg), "session")
_, err := os.Stat(sessionFile)
require.NoError(t, err, "session file should exist when NOT using --use-keyring")
require.NoError(t, err, "session file should exist when NOT using --use-keyring on Linux")
// Read and verify the token from file
content, err := os.ReadFile(sessionFile)
@@ -234,24 +246,18 @@ func TestUseKeyring(t *testing.T) {
// Create a pty for interactive prompts
pty := ptytest.New(t)
// Login using CODER_USE_KEYRING environment variable instead of flag
inv, cfg := clitest.New(t,
// Login using CODER_USE_KEYRING environment variable set to disable keyring usage,
// which should have the same behavior on all platforms.
env := setupKeyringTestEnv(t, client.URL.String(),
"login",
"--force-tty",
"--no-open",
client.URL.String(),
)
inv := env.inv
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
inv.Environ.Set("CODER_USE_KEYRING", "true")
// Inject the mock backend
var root cli.RootCmd
cmd, err := root.Command(root.AGPL())
require.NoError(t, err)
mockBackend := newMockKeyring()
root.WithSessionStorageBackend(mockBackend)
inv.Command = cmd
inv.Environ.Set("CODER_USE_KEYRING", "false")
doneChan := make(chan struct{})
go func() {
@@ -265,21 +271,64 @@ func TestUseKeyring(t *testing.T) {
pty.ExpectMatch("Welcome to Coder")
<-doneChan
// Verify that session file was NOT created (using keyring via env var)
sessionFile := path.Join(string(cfg), "session")
_, err = os.Stat(sessionFile)
require.True(t, os.IsNotExist(err), "session file should not exist when using keyring via env var")
// Verify that session file WAS created (not using keyring)
sessionFile := path.Join(string(env.cfg), "session")
_, err := os.Stat(sessionFile)
require.NoError(t, err, "session file should exist when CODER_USE_KEYRING set to false")
// Verify credential is in mock keyring
cred, err := mockBackend.Read(nil)
require.NoError(t, err, "credential should be stored in keyring when CODER_USE_KEYRING=true")
require.NotEmpty(t, cred)
// Read and verify the token from file
content, err := os.ReadFile(sessionFile)
require.NoError(t, err, "should be able to read session file")
require.Equal(t, client.SessionToken(), string(content), "file should contain the session token")
})
t.Run("DisableKeyringWithFlag", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
pty := ptytest.New(t)
// Login with --use-keyring=false to explicitly disable keyring usage, which
// should have the same behavior on all platforms.
env := setupKeyringTestEnv(t, client.URL.String(),
"login",
"--use-keyring=false",
"--force-tty",
"--no-open",
client.URL.String(),
)
inv := env.inv
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
doneChan := make(chan struct{})
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
pty.ExpectMatch("Paste your token here:")
pty.WriteLine(client.SessionToken())
pty.ExpectMatch("Welcome to Coder")
<-doneChan
// Verify that session file WAS created (not using keyring)
sessionFile := path.Join(string(env.cfg), "session")
_, err := os.Stat(sessionFile)
require.NoError(t, err, "session file should exist when --use-keyring=false is specified")
// Read and verify the token from file
content, err := os.ReadFile(sessionFile)
require.NoError(t, err, "should be able to read session file")
require.Equal(t, client.SessionToken(), string(content), "file should contain the session token")
})
}
func TestUseKeyringUnsupportedOS(t *testing.T) {
// Verify that trying to use --use-keyring on an unsupported operating system produces
// a helpful error message.
// Verify that on unsupported operating systems, file-based storage is used
// automatically even when --use-keyring is set to true (the default).
t.Parallel()
// Only run this on an unsupported OS.
@@ -287,43 +336,60 @@ func TestUseKeyringUnsupportedOS(t *testing.T) {
t.Skipf("Skipping unsupported OS test on %s where keyring is supported", runtime.GOOS)
}
const expMessage = "keyring storage is not supported on this operating system; remove the --use-keyring flag"
t.Run("LoginWithUnsupportedKeyring", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
// Try to login with --use-keyring on an unsupported OS
inv, _ := clitest.New(t,
"login",
"--use-keyring",
client.URL.String(),
)
// The error should occur immediately, before any prompts
loginErr := inv.Run()
// Verify we got an error about unsupported OS
require.Error(t, loginErr)
require.Contains(t, loginErr.Error(), expMessage)
})
t.Run("LogoutWithUnsupportedKeyring", func(t *testing.T) {
t.Run("LoginWithDefaultKeyring", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
pty := ptytest.New(t)
// First login without keyring to create a session
loginInv, cfg := clitest.New(t,
env := setupKeyringTestEnv(t, client.URL.String(),
"login",
"--force-tty",
"--no-open",
client.URL.String(),
)
inv := env.inv
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
doneChan := make(chan struct{})
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
pty.ExpectMatch("Paste your token here:")
pty.WriteLine(client.SessionToken())
pty.ExpectMatch("Welcome to Coder")
<-doneChan
// Verify that session file WAS created (automatic fallback to file storage)
sessionFile := path.Join(string(env.cfg), "session")
_, err := os.Stat(sessionFile)
require.NoError(t, err, "session file should exist due to automatic fallback to file storage")
content, err := os.ReadFile(sessionFile)
require.NoError(t, err, "should be able to read session file")
require.Equal(t, client.SessionToken(), string(content), "file should contain the session token")
})
t.Run("LogoutWithDefaultKeyring", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
pty := ptytest.New(t)
// First login to create a session (will use file storage due to automatic fallback)
env := setupKeyringTestEnv(t, client.URL.String(),
"login",
"--force-tty",
"--no-open",
client.URL.String(),
)
loginInv := env.inv
loginInv.Stdin = pty.Input()
loginInv.Stdout = pty.Output()
@@ -339,17 +405,22 @@ func TestUseKeyringUnsupportedOS(t *testing.T) {
pty.ExpectMatch("Welcome to Coder")
<-doneChan
// Now try to logout with --use-keyring on an unsupported OS
logoutInv, _ := clitest.New(t,
// Verify session file exists
sessionFile := path.Join(string(env.cfg), "session")
_, err := os.Stat(sessionFile)
require.NoError(t, err, "session file should exist before logout")
// Now logout - should succeed and delete the file
logoutEnv := setupKeyringTestEnv(t, client.URL.String(),
"logout",
"--use-keyring",
"--yes",
"--global-config", string(cfg),
"--global-config", string(env.cfg),
)
err := logoutInv.Run()
// Verify we got an error about unsupported OS
require.Error(t, err)
require.Contains(t, err.Error(), expMessage)
err = logoutEnv.inv.Run()
require.NoError(t, err, "logout should succeed with automatic file storage fallback")
_, err = os.Stat(sessionFile)
require.True(t, os.IsNotExist(err), "session file should be deleted after logout")
})
}
+3 -3
View File
@@ -154,9 +154,9 @@ func (r *RootCmd) login() *serpent.Command {
cmd := &serpent.Command{
Use: "login [<url>]",
Short: "Authenticate with Coder deployment",
Long: "By default, the session token is stored in a plain text file. Use the " +
"--use-keyring flag or set CODER_USE_KEYRING=true to store the token in " +
"the operating system keyring instead.",
Long: "By default, the session token is stored in the operating system keyring on " +
"macOS and Windows and a plain text file on Linux. Use the --use-keyring flag " +
"or CODER_USE_KEYRING environment variable to change the storage mechanism.",
Middleware: serpent.RequireRangeArgs(0, 1),
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
+40 -14
View File
@@ -56,7 +56,7 @@ var (
// anything.
ErrSilent = xerrors.New("silent error")
errKeyringNotSupported = xerrors.New("keyring storage is not supported on this operating system; remove the --use-keyring flag to use file-based storage")
errKeyringNotSupported = xerrors.New("keyring storage is not supported on this operating system; omit --use-keyring to use file-based storage")
)
const (
@@ -104,6 +104,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
r.resetPassword(),
r.sharing(),
r.state(),
r.tasksCommand(),
r.templates(),
r.tokens(),
r.users(),
@@ -149,7 +150,7 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command {
r.mcpCommand(),
r.promptExample(),
r.rptyCommand(),
r.tasksCommand(),
r.syncCommand(),
r.boundary(),
}
}
@@ -483,10 +484,12 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
Flag: varUseKeyring,
Env: envUseKeyring,
Description: "Store and retrieve session tokens using the operating system " +
"keyring. Currently only supported on Windows. By default, tokens are " +
"stored in plain text files.",
Value: serpent.BoolOf(&r.useKeyring),
Group: globalGroup,
"keyring. This flag is ignored and file-based storage is used when " +
"--global-config is set or keyring usage is not supported on the current " +
"platform. Set to false to force file-based storage on supported platforms.",
Default: "true",
Value: serpent.BoolOf(&r.useKeyring),
Group: globalGroup,
},
{
Flag: "debug-http",
@@ -534,10 +537,12 @@ type RootCmd struct {
disableDirect bool
debugHTTP bool
disableNetworkTelemetry bool
noVersionCheck bool
noFeatureWarning bool
useKeyring bool
disableNetworkTelemetry bool
noVersionCheck bool
noFeatureWarning bool
useKeyring bool
keyringServiceName string
useKeyringWithGlobalConfig bool
}
// InitClient creates and configures a new client with authentication, telemetry,
@@ -718,8 +723,19 @@ func (r *RootCmd) createUnauthenticatedClient(ctx context.Context, serverURL *ur
// flag.
func (r *RootCmd) ensureTokenBackend() sessionstore.Backend {
if r.tokenBackend == nil {
if r.useKeyring {
r.tokenBackend = sessionstore.NewKeyring()
// Checking for the --global-config directory being set is a bit wonky but necessary
// to allow extensions that invoke the CLI with this flag (e.g. VS code) to continue
// working without modification. In the future we should modify these extensions to
// either access the credential in the keyring (like Coder Desktop) or some other
// approach that doesn't rely on the session token being stored on disk.
assumeExtensionInUse := r.globalConfig != config.DefaultDir() && !r.useKeyringWithGlobalConfig
keyringSupported := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
if r.useKeyring && !assumeExtensionInUse && keyringSupported {
serviceName := sessionstore.DefaultServiceName
if r.keyringServiceName != "" {
serviceName = r.keyringServiceName
}
r.tokenBackend = sessionstore.NewKeyringWithService(serviceName)
} else {
r.tokenBackend = sessionstore.NewFile(r.createConfig)
}
@@ -727,8 +743,18 @@ func (r *RootCmd) ensureTokenBackend() sessionstore.Backend {
return r.tokenBackend
}
func (r *RootCmd) WithSessionStorageBackend(backend sessionstore.Backend) {
r.tokenBackend = backend
// WithKeyringServiceName sets a custom keyring service name for testing purposes.
// This allows tests to use isolated keyring storage while still exercising the
// genuine storage backend selection logic in ensureTokenBackend().
func (r *RootCmd) WithKeyringServiceName(serviceName string) {
r.keyringServiceName = serviceName
}
// UseKeyringWithGlobalConfig enables the use of the keyring storage backend
// when the --global-config directory is set. This is only intended as an override
// for tests, which require specifying the global config directory for test isolation.
func (r *RootCmd) UseKeyringWithGlobalConfig() {
r.useKeyringWithGlobalConfig = true
}
type AgentAuth struct {
+25
View File
@@ -72,6 +72,31 @@ func TestCommandHelp(t *testing.T) {
Name: "coder provisioner jobs list --output json",
Cmd: []string{"provisioner", "jobs", "list", "--output", "json"},
},
// TODO (SasSwart): Remove these once the sync commands are promoted out of experimental.
clitest.CommandHelpCase{
Name: "coder exp sync --help",
Cmd: []string{"exp", "sync", "--help"},
},
clitest.CommandHelpCase{
Name: "coder exp sync ping --help",
Cmd: []string{"exp", "sync", "ping", "--help"},
},
clitest.CommandHelpCase{
Name: "coder exp sync start --help",
Cmd: []string{"exp", "sync", "start", "--help"},
},
clitest.CommandHelpCase{
Name: "coder exp sync want --help",
Cmd: []string{"exp", "sync", "want", "--help"},
},
clitest.CommandHelpCase{
Name: "coder exp sync complete --help",
Cmd: []string{"exp", "sync", "complete", "--help"},
},
clitest.CommandHelpCase{
Name: "coder exp sync status --help",
Cmd: []string{"exp", "sync", "status", "--help"},
},
))
}
+4 -12
View File
@@ -47,9 +47,9 @@ var (
)
const (
// defaultServiceName is the service name used in keyrings for storing Coder CLI session
// DefaultServiceName is the service name used in keyrings for storing Coder CLI session
// tokens.
defaultServiceName = "coder-v2-credentials"
DefaultServiceName = "coder-v2-credentials"
)
// keyringProvider represents an operating system keyring. The expectation
@@ -108,17 +108,9 @@ type Keyring struct {
serviceName string
}
// NewKeyring creates a Keyring with the default service name for production use.
func NewKeyring() Keyring {
return Keyring{
provider: operatingSystemKeyring{},
serviceName: defaultServiceName,
}
}
// NewKeyringWithService creates a Keyring Backend that stores credentials under the
// specified service name. This is primarily intended for testing to avoid conflicts
// with production credentials and collisions between tests.
// specified service name. Generally, DefaultServiceName should be provided as the service
// name except in tests which may need parameterization to avoid conflicting keyring use.
func NewKeyringWithService(serviceName string) Keyring {
return Keyring{
provider: operatingSystemKeyring{},
+17
View File
@@ -87,6 +87,7 @@ func buildNumberOption(n *int64) serpent.Option {
func (r *RootCmd) statePush() *serpent.Command {
var buildNumber int64
var noBuild bool
cmd := &serpent.Command{
Use: "push <workspace> <file>",
Short: "Push a Terraform state file to a workspace.",
@@ -126,6 +127,16 @@ func (r *RootCmd) statePush() *serpent.Command {
return err
}
if noBuild {
// Update state directly without triggering a build.
err = client.UpdateWorkspaceBuildState(inv.Context(), build.ID, state)
if err != nil {
return err
}
_, _ = fmt.Fprintln(inv.Stdout, "State updated successfully.")
return nil
}
build, err = client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
TemplateVersionID: build.TemplateVersionID,
Transition: build.Transition,
@@ -139,6 +150,12 @@ func (r *RootCmd) statePush() *serpent.Command {
}
cmd.Options = serpent.OptionSet{
buildNumberOption(&buildNumber),
{
Flag: "no-build",
FlagShorthand: "n",
Description: "Update the state without triggering a workspace build. Useful for state-only migrations.",
Value: serpent.BoolOf(&noBuild),
},
}
return cmd
}
+47
View File
@@ -2,6 +2,7 @@ package cli_test
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
@@ -10,6 +11,7 @@ import (
"testing"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/stretchr/testify/require"
@@ -158,4 +160,49 @@ func TestStatePush(t *testing.T) {
err := inv.Run()
require.NoError(t, err)
})
t.Run("NoBuild", func(t *testing.T) {
t.Parallel()
client, store := coderdtest.NewWithDatabase(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
templateAdmin, taUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
initialState := []byte("initial state")
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: owner.OrganizationID,
OwnerID: taUser.ID,
}).
Seed(database.WorkspaceBuild{ProvisionerState: initialState}).
Do()
wantState := []byte("updated state")
stateFile, err := os.CreateTemp(t.TempDir(), "")
require.NoError(t, err)
_, err = stateFile.Write(wantState)
require.NoError(t, err)
err = stateFile.Close()
require.NoError(t, err)
inv, root := clitest.New(t, "state", "push", "--no-build", r.Workspace.Name, stateFile.Name())
clitest.SetupConfig(t, templateAdmin, root)
var stdout bytes.Buffer
inv.Stdout = &stdout
err = inv.Run()
require.NoError(t, err)
require.Contains(t, stdout.String(), "State updated successfully")
// Verify the state was updated by pulling it.
inv, root = clitest.New(t, "state", "pull", r.Workspace.Name)
var gotState bytes.Buffer
inv.Stdout = &gotState
clitest.SetupConfig(t, templateAdmin, root)
err = inv.Run()
require.NoError(t, err)
require.Equal(t, wantState, bytes.TrimSpace(gotState.Bytes()))
// Verify no new build was created.
builds, err := store.GetWorkspaceBuildsByWorkspaceID(dbauthz.AsSystemRestricted(context.Background()), database.GetWorkspaceBuildsByWorkspaceIDParams{
WorkspaceID: r.Workspace.ID,
})
require.NoError(t, err)
require.Len(t, builds, 1, "expected only the initial build, no new build should be created")
})
}
+35
View File
@@ -0,0 +1,35 @@
package cli
import (
"github.com/coder/serpent"
)
func (r *RootCmd) syncCommand() *serpent.Command {
var socketPath string
cmd := &serpent.Command{
Use: "sync",
Short: "Manage unit dependencies for coordinated startup",
Long: "Commands for orchestrating unit startup order in workspaces. Units are most commonly coder scripts. Use these commands to declare dependencies between units, coordinate their startup sequence, and ensure units start only after their dependencies are ready. This helps prevent race conditions and startup failures.",
Handler: func(i *serpent.Invocation) error {
return i.Command.HelpHandler(i)
},
Children: []*serpent.Command{
r.syncPing(&socketPath),
r.syncStart(&socketPath),
r.syncWant(&socketPath),
r.syncComplete(&socketPath),
r.syncStatus(&socketPath),
},
Options: serpent.OptionSet{
{
Flag: "socket-path",
Env: "CODER_AGENT_SOCKET_PATH",
Description: "Specify the path for the agent socket.",
Value: serpent.StringOf(&socketPath),
},
},
}
return cmd
}
+47
View File
@@ -0,0 +1,47 @@
package cli
import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/agent/unit"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/serpent"
)
func (*RootCmd) syncComplete(socketPath *string) *serpent.Command {
cmd := &serpent.Command{
Use: "complete <unit>",
Short: "Mark a unit as complete",
Long: "Mark a unit as complete. Indicating to other units that it has completed its work. This allows units that depend on it to proceed with their startup.",
Handler: func(i *serpent.Invocation) error {
ctx := i.Context()
if len(i.Args) != 1 {
return xerrors.New("exactly one unit name is required")
}
unit := unit.ID(i.Args[0])
opts := []agentsocket.Option{}
if *socketPath != "" {
opts = append(opts, agentsocket.WithPath(*socketPath))
}
client, err := agentsocket.NewClient(ctx, opts...)
if err != nil {
return xerrors.Errorf("connect to agent socket: %w", err)
}
defer client.Close()
if err := client.SyncComplete(ctx, unit); err != nil {
return xerrors.Errorf("complete unit failed: %w", err)
}
cliui.Info(i.Stdout, "Success")
return nil
},
}
return cmd
}
+42
View File
@@ -0,0 +1,42 @@
package cli
import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/serpent"
)
func (*RootCmd) syncPing(socketPath *string) *serpent.Command {
cmd := &serpent.Command{
Use: "ping",
Short: "Test agent socket connectivity and health",
Long: "Test connectivity to the local Coder agent socket to verify the agent is running and responsive. Useful for troubleshooting startup issues or verifying the agent is accessible before running other sync commands.",
Handler: func(i *serpent.Invocation) error {
ctx := i.Context()
opts := []agentsocket.Option{}
if *socketPath != "" {
opts = append(opts, agentsocket.WithPath(*socketPath))
}
client, err := agentsocket.NewClient(ctx, opts...)
if err != nil {
return xerrors.Errorf("connect to agent socket: %w", err)
}
defer client.Close()
err = client.Ping(ctx)
if err != nil {
return xerrors.Errorf("ping failed: %w", err)
}
cliui.Info(i.Stdout, "Success")
return nil
},
}
return cmd
}
+101
View File
@@ -0,0 +1,101 @@
package cli
import (
"context"
"time"
"golang.org/x/xerrors"
"github.com/coder/serpent"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/agent/unit"
"github.com/coder/coder/v2/cli/cliui"
)
const (
syncPollInterval = 1 * time.Second
)
func (*RootCmd) syncStart(socketPath *string) *serpent.Command {
var timeout time.Duration
cmd := &serpent.Command{
Use: "start <unit>",
Short: "Wait until all unit dependencies are satisfied",
Long: "Wait until all dependencies are satisfied, consider the unit to have started, then allow it to proceed. This command polls until dependencies are ready, then marks the unit as started.",
Handler: func(i *serpent.Invocation) error {
ctx := i.Context()
if len(i.Args) != 1 {
return xerrors.New("exactly one unit name is required")
}
unitName := unit.ID(i.Args[0])
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
opts := []agentsocket.Option{}
if *socketPath != "" {
opts = append(opts, agentsocket.WithPath(*socketPath))
}
client, err := agentsocket.NewClient(ctx, opts...)
if err != nil {
return xerrors.Errorf("connect to agent socket: %w", err)
}
defer client.Close()
ready, err := client.SyncReady(ctx, unitName)
if err != nil {
return xerrors.Errorf("error checking dependencies: %w", err)
}
if !ready {
cliui.Infof(i.Stdout, "Waiting for dependencies of unit '%s' to be satisfied...", unitName)
ticker := time.NewTicker(syncPollInterval)
defer ticker.Stop()
pollLoop:
for {
select {
case <-ctx.Done():
if ctx.Err() == context.DeadlineExceeded {
return xerrors.Errorf("timeout waiting for dependencies of unit '%s'", unitName)
}
return ctx.Err()
case <-ticker.C:
ready, err := client.SyncReady(ctx, unitName)
if err != nil {
return xerrors.Errorf("error checking dependencies: %w", err)
}
if ready {
break pollLoop
}
}
}
}
if err := client.SyncStart(ctx, unitName); err != nil {
return xerrors.Errorf("start unit failed: %w", err)
}
cliui.Info(i.Stdout, "Success")
return nil
},
}
cmd.Options = append(cmd.Options, serpent.Option{
Flag: "timeout",
Description: "Maximum time to wait for dependencies (e.g., 30s, 5m). 5m by default.",
Value: serpent.DurationOf(&timeout),
Default: "5m",
})
return cmd
}
+88
View File
@@ -0,0 +1,88 @@
package cli
import (
"fmt"
"golang.org/x/xerrors"
"github.com/coder/serpent"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/agent/unit"
"github.com/coder/coder/v2/cli/cliui"
)
func (*RootCmd) syncStatus(socketPath *string) *serpent.Command {
formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData(
cliui.TableFormat(
[]agentsocket.DependencyInfo{},
[]string{
"depends on",
"required status",
"current status",
"satisfied",
},
),
func(data any) (any, error) {
resp, ok := data.(agentsocket.SyncStatusResponse)
if !ok {
return nil, xerrors.Errorf("expected agentsocket.SyncStatusResponse, got %T", data)
}
return resp.Dependencies, nil
}),
cliui.JSONFormat(),
)
cmd := &serpent.Command{
Use: "status <unit>",
Short: "Show unit status and dependency state",
Long: "Show the current status of a unit, whether it is ready to start, and lists its dependencies. Shows which dependencies are satisfied and which are still pending. Supports multiple output formats.",
Handler: func(i *serpent.Invocation) error {
ctx := i.Context()
if len(i.Args) != 1 {
return xerrors.New("exactly one unit name is required")
}
unit := unit.ID(i.Args[0])
opts := []agentsocket.Option{}
if *socketPath != "" {
opts = append(opts, agentsocket.WithPath(*socketPath))
}
client, err := agentsocket.NewClient(ctx, opts...)
if err != nil {
return xerrors.Errorf("connect to agent socket: %w", err)
}
defer client.Close()
statusResp, err := client.SyncStatus(ctx, unit)
if err != nil {
return xerrors.Errorf("get status failed: %w", err)
}
var out string
header := fmt.Sprintf("Unit: %s\nStatus: %s\nReady: %t\n\nDependencies:\n", unit, statusResp.Status, statusResp.IsReady)
if formatter.FormatID() == "table" && len(statusResp.Dependencies) == 0 {
out = header + "No dependencies found"
} else {
out, err = formatter.Format(ctx, statusResp)
if err != nil {
return xerrors.Errorf("format status: %w", err)
}
if formatter.FormatID() == "table" {
out = header + out
}
}
_, _ = fmt.Fprintln(i.Stdout, out)
return nil
},
}
formatter.AttachOptions(&cmd.Options)
return cmd
}
+330
View File
@@ -0,0 +1,330 @@
//go:build !windows
package cli_test
import (
"bytes"
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/testutil"
)
// setupSocketServer creates an agentsocket server at a temporary path for testing.
// Returns the socket path and a cleanup function. The path should be passed to
// sync commands via the --socket-path flag.
func setupSocketServer(t *testing.T) (path string, cleanup func()) {
t.Helper()
// Use a temporary socket path for each test
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
// Create parent directory if needed
parentDir := filepath.Dir(socketPath)
err := os.MkdirAll(parentDir, 0o700)
require.NoError(t, err, "create socket directory")
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err, "create socket server")
// Return cleanup function
return socketPath, func() {
err := server.Close()
require.NoError(t, err, "close socket server")
_ = os.Remove(socketPath)
}
}
func TestSyncCommands_Golden(t *testing.T) {
t.Parallel()
t.Run("ping", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "ping", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/ping_success", outBuf.Bytes(), nil)
})
t.Run("start_no_dependencies", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "start", "test-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/start_no_dependencies", outBuf.Bytes(), nil)
})
t.Run("start_with_dependencies", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// Set up dependency: test-unit depends on dep-unit
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
// Declare dependency
err = client.SyncWant(ctx, "test-unit", "dep-unit")
require.NoError(t, err)
client.Close()
// Start a goroutine to complete the dependency after a short delay
// This simulates the dependency being satisfied while start is waiting
// The delay ensures the "Waiting..." message appears in the output
done := make(chan error, 1)
go func() {
// Wait a moment to let the start command begin waiting and print the message
time.Sleep(100 * time.Millisecond)
compCtx := context.Background()
compClient, err := agentsocket.NewClient(compCtx, agentsocket.WithPath(path))
if err != nil {
done <- err
return
}
defer compClient.Close()
// Start and complete the dependency unit
err = compClient.SyncStart(compCtx, "dep-unit")
if err != nil {
done <- err
return
}
err = compClient.SyncComplete(compCtx, "dep-unit")
done <- err
}()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "start", "test-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
// Run the start command - it should wait for the dependency
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
// Ensure the completion goroutine finished
select {
case err := <-done:
require.NoError(t, err, "complete dependency")
case <-time.After(time.Second):
// Goroutine should have finished by now
}
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/start_with_dependencies", outBuf.Bytes(), nil)
})
t.Run("want", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "want", "test-unit", "dep-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/want_success", outBuf.Bytes(), nil)
})
t.Run("complete", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// First start the unit
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
err = client.SyncStart(ctx, "test-unit")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "complete", "test-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/complete_success", outBuf.Bytes(), nil)
})
t.Run("status_pending", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// Set up a unit with unsatisfied dependency
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
err = client.SyncWant(ctx, "test-unit", "dep-unit")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_pending", outBuf.Bytes(), nil)
})
t.Run("status_started", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// Start a unit
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
err = client.SyncStart(ctx, "test-unit")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_started", outBuf.Bytes(), nil)
})
t.Run("status_completed", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// Start and complete a unit
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
err = client.SyncStart(ctx, "test-unit")
require.NoError(t, err)
err = client.SyncComplete(ctx, "test-unit")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_completed", outBuf.Bytes(), nil)
})
t.Run("status_with_dependencies", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// Set up a unit with dependencies, some satisfied, some not
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
err = client.SyncWant(ctx, "test-unit", "dep-1")
require.NoError(t, err)
err = client.SyncWant(ctx, "test-unit", "dep-2")
require.NoError(t, err)
// Complete dep-1, leave dep-2 incomplete
err = client.SyncStart(ctx, "dep-1")
require.NoError(t, err)
err = client.SyncComplete(ctx, "dep-1")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_with_dependencies", outBuf.Bytes(), nil)
})
t.Run("status_json_format", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
// Set up a unit with dependencies
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
err = client.SyncWant(ctx, "test-unit", "dep-unit")
require.NoError(t, err)
err = client.SyncStart(ctx, "dep-unit")
require.NoError(t, err)
err = client.SyncComplete(ctx, "dep-unit")
require.NoError(t, err)
client.Close()
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--output", "json", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_json_format", outBuf.Bytes(), nil)
})
}
+49
View File
@@ -0,0 +1,49 @@
package cli
import (
"golang.org/x/xerrors"
"github.com/coder/serpent"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/agent/unit"
"github.com/coder/coder/v2/cli/cliui"
)
func (*RootCmd) syncWant(socketPath *string) *serpent.Command {
cmd := &serpent.Command{
Use: "want <unit> <depends-on>",
Short: "Declare that a unit depends on another unit completing before it can start",
Long: "Declare that a unit depends on another unit completing before it can start. The unit specified first will not start until the second has signaled that it has completed.",
Handler: func(i *serpent.Invocation) error {
ctx := i.Context()
if len(i.Args) != 2 {
return xerrors.New("exactly two arguments are required: unit and depends-on")
}
dependentUnit := unit.ID(i.Args[0])
dependsOn := unit.ID(i.Args[1])
opts := []agentsocket.Option{}
if *socketPath != "" {
opts = append(opts, agentsocket.WithPath(*socketPath))
}
client, err := agentsocket.NewClient(ctx, opts...)
if err != nil {
return xerrors.Errorf("connect to agent socket: %w", err)
}
defer client.Close()
if err := client.SyncWant(ctx, dependentUnit, dependsOn); err != nil {
return xerrors.Errorf("declare dependency failed: %w", err)
}
cliui.Info(i.Stdout, "Success")
return nil
},
}
return cmd
}
+1 -1
View File
@@ -8,7 +8,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command {
cmd := &serpent.Command{
Use: "task",
Aliases: []string{"tasks"},
Short: "Experimental task commands.",
Short: "Manage tasks",
Handler: func(i *serpent.Invocation) error {
return i.Command.HelpHandler(i)
},
@@ -28,27 +28,27 @@ func (r *RootCmd) taskCreate() *serpent.Command {
cmd := &serpent.Command{
Use: "create [input]",
Short: "Create an experimental task",
Short: "Create a task",
Long: FormatExamples(
Example{
Description: "Create a task with direct input",
Command: "coder exp task create \"Add authentication to the user service\"",
Command: "coder task create \"Add authentication to the user service\"",
},
Example{
Description: "Create a task with stdin input",
Command: "echo \"Add authentication to the user service\" | coder exp task create",
Command: "echo \"Add authentication to the user service\" | coder task create",
},
Example{
Description: "Create a task with a specific name",
Command: "coder exp task create --name task1 \"Add authentication to the user service\"",
Command: "coder task create --name task1 \"Add authentication to the user service\"",
},
Example{
Description: "Create a task from a specific template / preset",
Command: "coder exp task create --template backend-dev --preset \"My Preset\" \"Add authentication to the user service\"",
Command: "coder task create --template backend-dev --preset \"My Preset\" \"Add authentication to the user service\"",
},
Example{
Description: "Create a task for another user (requires appropriate permissions)",
Command: "coder exp task create --owner user@example.com \"Add authentication to the user service\"",
Command: "coder task create --owner user@example.com \"Add authentication to the user service\"",
},
),
Middleware: serpent.Chain(
@@ -111,8 +111,7 @@ func (r *RootCmd) taskCreate() *serpent.Command {
}
var (
ctx = inv.Context()
expClient = codersdk.NewExperimentalClient(client)
ctx = inv.Context()
taskInput string
templateVersionID uuid.UUID
@@ -208,7 +207,7 @@ func (r *RootCmd) taskCreate() *serpent.Command {
templateVersionPresetID = preset.ID
}
task, err := expClient.CreateTask(ctx, ownerArg, codersdk.CreateTaskRequest{
task, err := client.CreateTask(ctx, ownerArg, codersdk.CreateTaskRequest{
Name: taskName,
TemplateVersionID: templateVersionID,
TemplateVersionPresetID: templateVersionPresetID,
@@ -69,7 +69,7 @@ func TestTaskCreate(t *testing.T) {
ActiveVersionID: templateVersionID,
},
})
case fmt.Sprintf("/api/experimental/tasks/%s", username):
case fmt.Sprintf("/api/v2/tasks/%s", username):
var req codersdk.CreateTaskRequest
if !httpapi.Read(ctx, w, r, &req) {
return
@@ -329,7 +329,7 @@ func TestTaskCreate(t *testing.T) {
ctx = testutil.Context(t, testutil.WaitShort)
srv = httptest.NewServer(tt.handler(t, ctx))
client = codersdk.New(testutil.MustURL(t, srv.URL))
args = []string{"exp", "task", "create"}
args = []string{"task", "create"}
sb strings.Builder
err error
)
@@ -17,19 +17,19 @@ import (
func (r *RootCmd) taskDelete() *serpent.Command {
cmd := &serpent.Command{
Use: "delete <task> [<task> ...]",
Short: "Delete experimental tasks",
Short: "Delete tasks",
Long: FormatExamples(
Example{
Description: "Delete a single task.",
Command: "$ coder exp task delete task1",
Command: "$ coder task delete task1",
},
Example{
Description: "Delete multiple tasks.",
Command: "$ coder exp task delete task1 task2 task3",
Command: "$ coder task delete task1 task2 task3",
},
Example{
Description: "Delete a task without confirmation.",
Command: "$ coder exp task delete task4 --yes",
Command: "$ coder task delete task4 --yes",
},
),
Middleware: serpent.Chain(
@@ -44,11 +44,10 @@ func (r *RootCmd) taskDelete() *serpent.Command {
if err != nil {
return err
}
exp := codersdk.NewExperimentalClient(client)
var tasks []codersdk.Task
for _, identifier := range inv.Args {
task, err := exp.TaskByIdentifier(ctx, identifier)
task, err := client.TaskByIdentifier(ctx, identifier)
if err != nil {
return xerrors.Errorf("resolve task %q: %w", identifier, err)
}
@@ -71,7 +70,7 @@ func (r *RootCmd) taskDelete() *serpent.Command {
for i, task := range tasks {
display := displayList[i]
if err := exp.DeleteTask(ctx, task.OwnerName, task.ID); err != nil {
if err := client.DeleteTask(ctx, task.OwnerName, task.ID); err != nil {
return xerrors.Errorf("delete task %q: %w", display, err)
}
_, _ = fmt.Fprintln(
@@ -56,7 +56,7 @@ func TestExpTaskDelete(t *testing.T) {
taskID := uuid.MustParse(id1)
return func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/exists":
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/exists":
c.nameResolves.Add(1)
httpapi.Write(r.Context(), w, http.StatusOK,
codersdk.Task{
@@ -64,7 +64,7 @@ func TestExpTaskDelete(t *testing.T) {
Name: "exists",
OwnerName: "me",
})
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id1:
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/"+id1:
c.deleteCalls.Add(1)
w.WriteHeader(http.StatusAccepted)
default:
@@ -82,13 +82,13 @@ func TestExpTaskDelete(t *testing.T) {
buildHandler: func(c *testCounters) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id2:
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/"+id2:
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
ID: uuid.MustParse(id2),
OwnerName: "me",
Name: "uuid-task",
})
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id2:
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/"+id2:
c.deleteCalls.Add(1)
w.WriteHeader(http.StatusAccepted)
default:
@@ -104,24 +104,24 @@ func TestExpTaskDelete(t *testing.T) {
buildHandler: func(c *testCounters) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/first":
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/first":
c.nameResolves.Add(1)
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
ID: uuid.MustParse(id3),
Name: "first",
OwnerName: "me",
})
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id4:
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/"+id4:
c.nameResolves.Add(1)
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
ID: uuid.MustParse(id4),
OwnerName: "me",
Name: "uuid-task-4",
})
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id3:
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/"+id3:
c.deleteCalls.Add(1)
w.WriteHeader(http.StatusAccepted)
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id4:
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/"+id4:
c.deleteCalls.Add(1)
w.WriteHeader(http.StatusAccepted)
default:
@@ -140,7 +140,7 @@ func TestExpTaskDelete(t *testing.T) {
buildHandler: func(_ *testCounters) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
httpapi.Write(r.Context(), w, http.StatusOK, struct {
Tasks []codersdk.Task `json:"tasks"`
Count int `json:"count"`
@@ -163,14 +163,14 @@ func TestExpTaskDelete(t *testing.T) {
taskID := uuid.MustParse(id5)
return func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/bad":
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/bad":
c.nameResolves.Add(1)
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
ID: taskID,
Name: "bad",
OwnerName: "me",
})
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/bad":
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/bad":
httpapi.InternalServerError(w, xerrors.New("boom"))
default:
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
@@ -193,7 +193,7 @@ func TestExpTaskDelete(t *testing.T) {
client := codersdk.New(testutil.MustURL(t, srv.URL))
args := append([]string{"exp", "task", "delete"}, tc.args...)
args := append([]string{"task", "delete"}, tc.args...)
inv, root := clitest.New(t, args...)
inv = inv.WithContext(ctx)
clitest.SetupConfig(t, client, root)
+7 -8
View File
@@ -69,27 +69,27 @@ func (r *RootCmd) taskList() *serpent.Command {
cmd := &serpent.Command{
Use: "list",
Short: "List experimental tasks",
Short: "List tasks",
Long: FormatExamples(
Example{
Description: "List tasks for the current user.",
Command: "coder exp task list",
Command: "coder task list",
},
Example{
Description: "List tasks for a specific user.",
Command: "coder exp task list --user someone-else",
Command: "coder task list --user someone-else",
},
Example{
Description: "List all tasks you can view.",
Command: "coder exp task list --all",
Command: "coder task list --all",
},
Example{
Description: "List all your running tasks.",
Command: "coder exp task list --status running",
Command: "coder task list --status running",
},
Example{
Description: "As above, but only show IDs.",
Command: "coder exp task list --status running --quiet",
Command: "coder task list --status running --quiet",
},
),
Aliases: []string{"ls"},
@@ -135,14 +135,13 @@ func (r *RootCmd) taskList() *serpent.Command {
}
ctx := inv.Context()
exp := codersdk.NewExperimentalClient(client)
targetUser := strings.TrimSpace(user)
if targetUser == "" && !all {
targetUser = codersdk.Me
}
tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{
tasks, err := client.Tasks(ctx, &codersdk.TasksFilter{
Owner: targetUser,
Status: codersdk.TaskStatus(statusFilter),
})
@@ -69,7 +69,7 @@ func TestExpTaskList(t *testing.T) {
owner := coderdtest.CreateFirstUser(t, client)
memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
inv, root := clitest.New(t, "exp", "task", "list")
inv, root := clitest.New(t, "task", "list")
clitest.SetupConfig(t, memberClient, root)
pty := ptytest.New(t).Attach(inv)
@@ -93,7 +93,7 @@ func TestExpTaskList(t *testing.T) {
wantPrompt := "build me a web app"
task := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, wantPrompt)
inv, root := clitest.New(t, "exp", "task", "list", "--column", "id,name,status,initial prompt")
inv, root := clitest.New(t, "task", "list", "--column", "id,name,status,initial prompt")
clitest.SetupConfig(t, memberClient, root)
pty := ptytest.New(t).Attach(inv)
@@ -122,7 +122,7 @@ func TestExpTaskList(t *testing.T) {
pausedTask := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please")
// Use JSON output to reliably validate filtering.
inv, root := clitest.New(t, "exp", "task", "list", "--status=paused", "--output=json")
inv, root := clitest.New(t, "task", "list", "--status=paused", "--output=json")
clitest.SetupConfig(t, memberClient, root)
ctx := testutil.Context(t, testutil.WaitShort)
@@ -153,7 +153,7 @@ func TestExpTaskList(t *testing.T) {
_ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "other-task")
task := makeAITask(t, db, owner.OrganizationID, owner.UserID, owner.UserID, database.WorkspaceTransitionStart, "me-task")
inv, root := clitest.New(t, "exp", "task", "list", "--user", "me")
inv, root := clitest.New(t, "task", "list", "--user", "me")
//nolint:gocritic // Owner client is intended here smoke test the member task not showing up.
clitest.SetupConfig(t, client, root)
@@ -180,7 +180,7 @@ func TestExpTaskList(t *testing.T) {
task2 := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please")
// Given: We add the `--quiet` flag
inv, root := clitest.New(t, "exp", "task", "list", "--quiet")
inv, root := clitest.New(t, "task", "list", "--quiet")
clitest.SetupConfig(t, memberClient, root)
ctx := testutil.Context(t, testutil.WaitShort)
@@ -224,7 +224,7 @@ func TestExpTaskList_OwnerCanListOthers(t *testing.T) {
t.Parallel()
// As the owner, list only member A tasks.
inv, root := clitest.New(t, "exp", "task", "list", "--user", memberAUser.Username, "--output=json")
inv, root := clitest.New(t, "task", "list", "--user", memberAUser.Username, "--output=json")
//nolint:gocritic // Owner client is intended here to allow member tasks to be listed.
clitest.SetupConfig(t, ownerClient, root)
@@ -252,7 +252,7 @@ func TestExpTaskList_OwnerCanListOthers(t *testing.T) {
// As the owner, list all tasks to verify both member tasks are present.
// Use JSON output to reliably validate filtering.
inv, root := clitest.New(t, "exp", "task", "list", "--all", "--output=json")
inv, root := clitest.New(t, "task", "list", "--all", "--output=json")
//nolint:gocritic // Owner client is intended here to allow all tasks to be listed.
clitest.SetupConfig(t, ownerClient, root)
+3 -4
View File
@@ -28,7 +28,7 @@ func (r *RootCmd) taskLogs() *serpent.Command {
Long: FormatExamples(
Example{
Description: "Show logs for a given task.",
Command: "coder exp task logs task1",
Command: "coder task logs task1",
}),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
@@ -41,16 +41,15 @@ func (r *RootCmd) taskLogs() *serpent.Command {
var (
ctx = inv.Context()
exp = codersdk.NewExperimentalClient(client)
identifier = inv.Args[0]
)
task, err := exp.TaskByIdentifier(ctx, identifier)
task, err := client.TaskByIdentifier(ctx, identifier)
if err != nil {
return xerrors.Errorf("resolve task %q: %w", identifier, err)
}
logs, err := exp.TaskLogs(ctx, codersdk.Me, task.ID)
logs, err := client.TaskLogs(ctx, codersdk.Me, task.ID)
if err != nil {
return xerrors.Errorf("get task logs: %w", err)
}
@@ -46,7 +46,7 @@ func Test_TaskLogs(t *testing.T) {
userClient := client // user already has access to their own workspace
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "logs", task.Name, "--output", "json")
inv, root := clitest.New(t, "task", "logs", task.Name, "--output", "json")
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -72,7 +72,7 @@ func Test_TaskLogs(t *testing.T) {
userClient := client
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String(), "--output", "json")
inv, root := clitest.New(t, "task", "logs", task.ID.String(), "--output", "json")
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -98,7 +98,7 @@ func Test_TaskLogs(t *testing.T) {
userClient := client
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String())
inv, root := clitest.New(t, "task", "logs", task.ID.String())
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -121,7 +121,7 @@ func Test_TaskLogs(t *testing.T) {
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "logs", "doesnotexist")
inv, root := clitest.New(t, "task", "logs", "doesnotexist")
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -139,7 +139,7 @@ func Test_TaskLogs(t *testing.T) {
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "logs", uuid.Nil.String())
inv, root := clitest.New(t, "task", "logs", uuid.Nil.String())
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -155,7 +155,7 @@ func Test_TaskLogs(t *testing.T) {
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsErr(assert.AnError))
userClient := client
inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String())
inv, root := clitest.New(t, "task", "logs", task.ID.String())
clitest.SetupConfig(t, userClient, root)
err := inv.WithContext(ctx).Run()
+4 -5
View File
@@ -17,10 +17,10 @@ func (r *RootCmd) taskSend() *serpent.Command {
Short: "Send input to a task",
Long: FormatExamples(Example{
Description: "Send direct input to a task.",
Command: "coder exp task send task1 \"Please also add unit tests\"",
Command: "coder task send task1 \"Please also add unit tests\"",
}, Example{
Description: "Send input from stdin to a task.",
Command: "echo \"Please also add unit tests\" | coder exp task send task1 --stdin",
Command: "echo \"Please also add unit tests\" | coder task send task1 --stdin",
}),
Middleware: serpent.RequireRangeArgs(1, 2),
Options: serpent.OptionSet{
@@ -39,7 +39,6 @@ func (r *RootCmd) taskSend() *serpent.Command {
var (
ctx = inv.Context()
exp = codersdk.NewExperimentalClient(client)
identifier = inv.Args[0]
taskInput string
@@ -60,12 +59,12 @@ func (r *RootCmd) taskSend() *serpent.Command {
taskInput = inv.Args[1]
}
task, err := exp.TaskByIdentifier(ctx, identifier)
task, err := client.TaskByIdentifier(ctx, identifier)
if err != nil {
return xerrors.Errorf("resolve task: %w", err)
}
if err = exp.TaskSend(ctx, codersdk.Me, task.ID, codersdk.TaskSendRequest{Input: taskInput}); err != nil {
if err = client.TaskSend(ctx, codersdk.Me, task.ID, codersdk.TaskSendRequest{Input: taskInput}); err != nil {
return xerrors.Errorf("send input to task: %w", err)
}
@@ -30,7 +30,7 @@ func Test_TaskSend(t *testing.T) {
userClient := client
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "send", task.Name, "carry on with the task")
inv, root := clitest.New(t, "task", "send", task.Name, "carry on with the task")
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -46,7 +46,7 @@ func Test_TaskSend(t *testing.T) {
userClient := client
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "send", task.ID.String(), "carry on with the task")
inv, root := clitest.New(t, "task", "send", task.ID.String(), "carry on with the task")
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -62,7 +62,7 @@ func Test_TaskSend(t *testing.T) {
userClient := client
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "send", task.Name, "--stdin")
inv, root := clitest.New(t, "task", "send", task.Name, "--stdin")
inv.Stdout = &stdout
inv.Stdin = strings.NewReader("carry on with the task")
clitest.SetupConfig(t, userClient, root)
@@ -80,7 +80,7 @@ func Test_TaskSend(t *testing.T) {
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "send", "doesnotexist", "some task input")
inv, root := clitest.New(t, "task", "send", "doesnotexist", "some task input")
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -98,7 +98,7 @@ func Test_TaskSend(t *testing.T) {
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "send", uuid.Nil.String(), "some task input")
inv, root := clitest.New(t, "task", "send", uuid.Nil.String(), "some task input")
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -114,7 +114,7 @@ func Test_TaskSend(t *testing.T) {
userClient, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendErr(t, assert.AnError))
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "send", task.Name, "some task input")
inv, root := clitest.New(t, "task", "send", task.Name, "some task input")
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -47,11 +47,11 @@ func (r *RootCmd) taskStatus() *serpent.Command {
Long: FormatExamples(
Example{
Description: "Show the status of a given task.",
Command: "coder exp task status task1",
Command: "coder task status task1",
},
Example{
Description: "Watch the status of a given task until it completes (idle or stopped).",
Command: "coder exp task status task1 --watch",
Command: "coder task status task1 --watch",
},
),
Use: "status",
@@ -83,10 +83,9 @@ func (r *RootCmd) taskStatus() *serpent.Command {
}
ctx := i.Context()
exp := codersdk.NewExperimentalClient(client)
identifier := i.Args[0]
task, err := exp.TaskByIdentifier(ctx, identifier)
task, err := client.TaskByIdentifier(ctx, identifier)
if err != nil {
return err
}
@@ -107,7 +106,7 @@ func (r *RootCmd) taskStatus() *serpent.Command {
// TODO: implement streaming updates instead of polling
lastStatusRow := tsr
for range t.C {
task, err := exp.TaskByID(ctx, task.ID)
task, err := client.TaskByID(ctx, task.ID)
if err != nil {
return err
}
@@ -36,7 +36,7 @@ func Test_TaskStatus(t *testing.T) {
hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/experimental/tasks/me/doesnotexist":
case "/api/v2/tasks/me/doesnotexist":
httpapi.ResourceNotFound(w)
return
default:
@@ -52,7 +52,7 @@ func Test_TaskStatus(t *testing.T) {
hf: func(ctx context.Context, now time.Time) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/experimental/tasks/me/exists":
case "/api/v2/tasks/me/exists":
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
@@ -88,7 +88,7 @@ func Test_TaskStatus(t *testing.T) {
var calls atomic.Int64
return func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/experimental/tasks/me/exists":
case "/api/v2/tasks/me/exists":
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
Name: "exists",
@@ -103,7 +103,7 @@ func Test_TaskStatus(t *testing.T) {
Status: codersdk.TaskStatusPending,
})
return
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
case "/api/v2/tasks/me/11111111-1111-1111-1111-111111111111":
defer calls.Add(1)
switch calls.Load() {
case 0:
@@ -189,6 +189,7 @@ func Test_TaskStatus(t *testing.T) {
"owner_id": "00000000-0000-0000-0000-000000000000",
"owner_name": "me",
"name": "exists",
"display_name": "Task exists",
"template_id": "00000000-0000-0000-0000-000000000000",
"template_version_id": "00000000-0000-0000-0000-000000000000",
"template_name": "",
@@ -218,11 +219,12 @@ func Test_TaskStatus(t *testing.T) {
ts := time.Date(2025, 8, 26, 12, 34, 56, 0, time.UTC)
return func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/experimental/tasks/me/exists":
case "/api/v2/tasks/me/exists":
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
Name: "exists",
OwnerName: "me",
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
Name: "exists",
DisplayName: "Task exists",
OwnerName: "me",
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
Healthy: true,
},
@@ -254,7 +256,7 @@ func Test_TaskStatus(t *testing.T) {
srv = httptest.NewServer(http.HandlerFunc(tc.hf(ctx, now)))
client = codersdk.New(testutil.MustURL(t, srv.URL))
sb = strings.Builder{}
args = []string{"exp", "task", "status", "--watch-interval", testutil.IntervalFast.String()}
args = []string{"task", "status", "--watch-interval", testutil.IntervalFast.String()}
)
t.Cleanup(srv.Close)
+8 -10
View File
@@ -60,14 +60,14 @@ func Test_Tasks(t *testing.T) {
}{
{
name: "create task",
cmdArgs: []string{"exp", "task", "create", "test task input for " + t.Name(), "--name", taskName, "--template", taskTpl.Name},
cmdArgs: []string{"task", "create", "test task input for " + t.Name(), "--name", taskName, "--template", taskTpl.Name},
assertFn: func(stdout string, userClient *codersdk.Client) {
require.Contains(t, stdout, taskName, "task name should be in output")
},
},
{
name: "list tasks after create",
cmdArgs: []string{"exp", "task", "list", "--output", "json"},
cmdArgs: []string{"task", "list", "--output", "json"},
assertFn: func(stdout string, userClient *codersdk.Client) {
var tasks []codersdk.Task
err := json.NewDecoder(strings.NewReader(stdout)).Decode(&tasks)
@@ -88,7 +88,7 @@ func Test_Tasks(t *testing.T) {
},
{
name: "get task status after create",
cmdArgs: []string{"exp", "task", "status", taskName, "--output", "json"},
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")
@@ -98,12 +98,12 @@ func Test_Tasks(t *testing.T) {
},
{
name: "send task message",
cmdArgs: []string{"exp", "task", "send", taskName, "hello"},
cmdArgs: []string{"task", "send", taskName, "hello"},
// Assertions for this happen in the fake agent API handler.
},
{
name: "read task logs",
cmdArgs: []string{"exp", "task", "logs", taskName, "--output", "json"},
cmdArgs: []string{"task", "logs", taskName, "--output", "json"},
assertFn: func(stdout string, userClient *codersdk.Client) {
var logs []codersdk.TaskLogEntry
require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&logs), "should unmarshal task logs")
@@ -118,12 +118,11 @@ func Test_Tasks(t *testing.T) {
},
{
name: "delete task",
cmdArgs: []string{"exp", "task", "delete", taskName, "--yes"},
cmdArgs: []string{"task", "delete", taskName, "--yes"},
assertFn: func(stdout string, userClient *codersdk.Client) {
// The task should eventually no longer show up in the list of tasks
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
expClient := codersdk.NewExperimentalClient(userClient)
tasks, err := expClient.Tasks(ctx, &codersdk.TasksFilter{})
tasks, err := userClient.Tasks(ctx, &codersdk.TasksFilter{})
if !assert.NoError(t, err) {
return false
}
@@ -248,8 +247,7 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st
template := createAITaskTemplate(t, client, owner.OrganizationID, withSidebarURL(fakeAPI.URL()), withAgentToken(authToken))
wantPrompt := "test prompt"
exp := codersdk.NewExperimentalClient(userClient)
task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
task, err := userClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: wantPrompt,
Name: "test-task",
@@ -0,0 +1 @@
Success
@@ -0,0 +1 @@
Success
@@ -0,0 +1 @@
Success
@@ -0,0 +1,2 @@
Waiting for dependencies of unit 'test-unit' to be satisfied...
Success
@@ -0,0 +1,6 @@
Unit: test-unit
Status: completed
Ready: true
Dependencies:
No dependencies found
@@ -0,0 +1,13 @@
{
"unit_name": "test-unit",
"status": "pending",
"is_ready": true,
"dependencies": [
{
"depends_on": "dep-unit",
"required_status": "completed",
"current_status": "completed",
"is_satisfied": true
}
]
}
@@ -0,0 +1,7 @@
Unit: test-unit
Status: pending
Ready: false
Dependencies:
DEPENDS ON REQUIRED STATUS CURRENT STATUS SATISFIED
dep-unit completed not registered false
@@ -0,0 +1,6 @@
Unit: test-unit
Status: started
Ready: true
Dependencies:
No dependencies found
@@ -0,0 +1,8 @@
Unit: test-unit
Status: pending
Ready: false
Dependencies:
DEPENDS ON REQUIRED STATUS CURRENT STATUS SATISFIED
dep-1 completed completed true
dep-2 completed not registered false
@@ -0,0 +1 @@
Success
+6 -3
View File
@@ -53,6 +53,7 @@ SUBCOMMANDS:
stop Stop a workspace
support Commands for troubleshooting issues with a Coder
deployment.
task Manage tasks
templates Manage templates
tokens Manage personal access tokens
unfavorite Remove a workspace from your favorites
@@ -108,10 +109,12 @@ variables or flags.
--url url, $CODER_URL
URL to a deployment.
--use-keyring bool, $CODER_USE_KEYRING
--use-keyring bool, $CODER_USE_KEYRING (default: true)
Store and retrieve session tokens using the operating system keyring.
Currently only supported on Windows. By default, tokens are stored in
plain text files.
This flag is ignored and file-based storage is used when
--global-config is set or keyring usage is not supported on the
current platform. Set to false to force file-based storage on
supported platforms.
-v, --verbose bool, $CODER_VERBOSE
Enable verbose output.
+6
View File
@@ -67,6 +67,12 @@ OPTIONS:
--script-data-dir string, $CODER_AGENT_SCRIPT_DATA_DIR (default: /tmp)
Specify the location for storing script data.
--socket-path string, $CODER_AGENT_SOCKET_PATH
Specify the path for the agent socket.
--socket-server-enabled bool, $CODER_AGENT_SOCKET_SERVER_ENABLED (default: false)
Enable the agent socket server.
--ssh-max-timeout duration, $CODER_AGENT_SSH_MAX_TIMEOUT (default: 72h)
Specify the max timeout for a SSH connection, it is advisable to set
it to a minimum of 60s, but no more than 72h.
+27
View File
@@ -0,0 +1,27 @@
coder v0.0.0-devel
USAGE:
coder exp sync [flags]
Manage unit dependencies for coordinated startup
Commands for orchestrating unit startup order in workspaces. Units are most
commonly coder scripts. Use these commands to declare dependencies between
units, coordinate their startup sequence, and ensure units start only after
their dependencies are ready. This helps prevent race conditions and startup
failures.
SUBCOMMANDS:
complete Mark a unit as complete
ping Test agent socket connectivity and health
start Wait until all unit dependencies are satisfied
status Show unit status and dependency state
want Declare that a unit depends on another unit completing before it
can start
OPTIONS:
--socket-path string, $CODER_AGENT_SOCKET_PATH
Specify the path for the agent socket.
———
Run `coder --help` for a list of global options.
+12
View File
@@ -0,0 +1,12 @@
coder v0.0.0-devel
USAGE:
coder exp sync complete <unit>
Mark a unit as complete
Mark a unit as complete. Indicating to other units that it has completed its
work. This allows units that depend on it to proceed with their startup.
———
Run `coder --help` for a list of global options.
+13
View File
@@ -0,0 +1,13 @@
coder v0.0.0-devel
USAGE:
coder exp sync ping
Test agent socket connectivity and health
Test connectivity to the local Coder agent socket to verify the agent is
running and responsive. Useful for troubleshooting startup issues or verifying
the agent is accessible before running other sync commands.
———
Run `coder --help` for a list of global options.
+17
View File
@@ -0,0 +1,17 @@
coder v0.0.0-devel
USAGE:
coder exp sync start [flags] <unit>
Wait until all unit dependencies are satisfied
Wait until all dependencies are satisfied, consider the unit to have started,
then allow it to proceed. This command polls until dependencies are ready,
then marks the unit as started.
OPTIONS:
--timeout duration (default: 5m)
Maximum time to wait for dependencies (e.g., 30s, 5m). 5m by default.
———
Run `coder --help` for a list of global options.
+20
View File
@@ -0,0 +1,20 @@
coder v0.0.0-devel
USAGE:
coder exp sync status [flags] <unit>
Show unit status and dependency state
Show the current status of a unit, whether it is ready to start, and lists its
dependencies. Shows which dependencies are satisfied and which are still
pending. Supports multiple output formats.
OPTIONS:
-c, --column [depends on|required status|current status|satisfied] (default: depends on,required status,current status,satisfied)
Columns to display in table output.
-o, --output table|json (default: table)
Output format.
———
Run `coder --help` for a list of global options.
+13
View File
@@ -0,0 +1,13 @@
coder v0.0.0-devel
USAGE:
coder exp sync want <unit> <depends-on>
Declare that a unit depends on another unit completing before it can start
Declare that a unit depends on another unit completing before it can start.
The unit specified first will not start until the second has signaled that it
has completed.
———
Run `coder --help` for a list of global options.
+3 -3
View File
@@ -5,9 +5,9 @@ USAGE:
Authenticate with Coder deployment
By default, the session token is stored in a plain text file. Use the
--use-keyring flag or set CODER_USE_KEYRING=true to store the token in the
operating system keyring instead.
By default, the session token is stored in the operating system keyring on
macOS and Windows and a plain text file on Linux. Use the --use-keyring flag
or CODER_USE_KEYRING environment variable to change the storage mechanism.
OPTIONS:
--first-user-email string, $CODER_FIRST_USER_EMAIL
+4
View File
@@ -9,5 +9,9 @@ OPTIONS:
-b, --build int
Specify a workspace build to target by name. Defaults to latest.
-n, --no-build bool
Update the state without triggering a workspace build. Useful for
state-only migrations.
———
Run `coder --help` for a list of global options.
+19
View File
@@ -0,0 +1,19 @@
coder v0.0.0-devel
USAGE:
coder task
Manage tasks
Aliases: tasks
SUBCOMMANDS:
create Create a task
delete Delete tasks
list List tasks
logs Show a task's logs
send Send input to a task
status Show the status of a task.
———
Run `coder --help` for a list of global options.
+51
View File
@@ -0,0 +1,51 @@
coder v0.0.0-devel
USAGE:
coder task create [flags] [input]
Create a task
- Create a task with direct input:
$ coder task create "Add authentication to the user service"
- Create a task with stdin input:
$ echo "Add authentication to the user service" | coder task create
- Create a task with a specific name:
$ coder task create --name task1 "Add authentication to the user service"
- Create a task from a specific template / preset:
$ coder task create --template backend-dev --preset "My Preset" "Add
authentication to the user service"
- Create a task for another user (requires appropriate permissions):
$ coder task create --owner user@example.com "Add authentication to the
user service"
OPTIONS:
-O, --org string, $CODER_ORGANIZATION
Select which organization (uuid or name) to use.
--name string
Specify the name of the task. If you do not specify one, a name will
be generated for you.
--owner string (default: me)
Specify the owner of the task. Defaults to the current user.
--preset string, $CODER_TASK_PRESET_NAME (default: none)
-q, --quiet bool
Only display the created task's ID.
--stdin bool
Reads from stdin for the task input.
--template string, $CODER_TASK_TEMPLATE_NAME
--template-version string, $CODER_TASK_TEMPLATE_VERSION
———
Run `coder --help` for a list of global options.
+27
View File
@@ -0,0 +1,27 @@
coder v0.0.0-devel
USAGE:
coder task delete [flags] <task> [<task> ...]
Delete tasks
Aliases: rm
- Delete a single task.:
$ $ coder task delete task1
- Delete multiple tasks.:
$ $ coder task delete task1 task2 task3
- Delete a task without confirmation.:
$ $ coder task delete task4 --yes
OPTIONS:
-y, --yes bool
Bypass prompts.
———
Run `coder --help` for a list of global options.
+50
View File
@@ -0,0 +1,50 @@
coder v0.0.0-devel
USAGE:
coder task list [flags]
List tasks
Aliases: ls
- List tasks for the current user.:
$ coder task list
- List tasks for a specific user.:
$ coder task list --user someone-else
- List all tasks you can view.:
$ coder task list --all
- List all your running tasks.:
$ coder task list --status running
- As above, but only show IDs.:
$ coder task list --status running --quiet
OPTIONS:
-a, --all bool (default: false)
List tasks for all users you can view.
-c, --column [id|organization id|owner id|owner name|owner avatar url|name|display name|template id|template version id|template name|template display name|template icon|workspace id|workspace name|workspace status|workspace build number|workspace agent id|workspace agent lifecycle|workspace agent health|workspace app id|initial prompt|status|state|message|created at|updated at|state changed] (default: name,status,state,state changed,message)
Columns to display in table output.
-o, --output table|json (default: table)
Output format.
-q, --quiet bool (default: false)
Only display task IDs.
--status pending|initializing|active|paused|error|unknown
Filter by task status.
--user string
List tasks for the specified user (username, "me").
———
Run `coder --help` for a list of global options.
+20
View File
@@ -0,0 +1,20 @@
coder v0.0.0-devel
USAGE:
coder task logs [flags] <task>
Show a task's logs
- Show logs for a given task.:
$ coder task logs task1
OPTIONS:
-c, --column [id|content|type|time] (default: type,content)
Columns to display in table output.
-o, --output table|json (default: table)
Output format.
———
Run `coder --help` for a list of global options.
+21
View File
@@ -0,0 +1,21 @@
coder v0.0.0-devel
USAGE:
coder task send [flags] <task> [<input> | --stdin]
Send input to a task
- Send direct input to a task.:
$ coder task send task1 "Please also add unit tests"
- Send input from stdin to a task.:
$ echo "Please also add unit tests" | coder task send task1 --stdin
OPTIONS:
--stdin bool
Reads the input from stdin.
———
Run `coder --help` for a list of global options.
+30
View File
@@ -0,0 +1,30 @@
coder v0.0.0-devel
USAGE:
coder task status [flags]
Show the status of a task.
Aliases: stat
- Show the status of a given task.:
$ coder task status task1
- Watch the status of a given task until it completes (idle or stopped).:
$ coder task status task1 --watch
OPTIONS:
-c, --column [id|organization id|owner id|owner name|owner avatar url|name|display name|template id|template version id|template name|template display name|template icon|workspace id|workspace name|workspace status|workspace build number|workspace agent id|workspace agent lifecycle|workspace agent health|workspace app id|initial prompt|status|state|message|created at|updated at|state changed|healthy] (default: state changed,status,healthy,state,message)
Columns to display in table output.
-o, --output table|json (default: table)
Output format.
--watch bool (default: false)
Watch the task status output. This will stream updates to the terminal
until the underlying workspace is stopped.
———
Run `coder --help` for a list of global options.
+73 -6
View File
@@ -36,6 +36,8 @@ import (
"github.com/coder/quartz"
)
const workspaceCacheRefreshInterval = 5 * time.Minute
// API implements the DRPC agent API interface from agent/proto. This struct is
// instantiated once per agent connection and kept alive for the duration of the
// session.
@@ -54,6 +56,8 @@ type API struct {
*SubAgentAPI
*tailnet.DRPCService
cachedWorkspaceFields *CachedWorkspaceFields
mu sync.Mutex
}
@@ -65,7 +69,7 @@ type Options struct {
WorkspaceID uuid.UUID
OrganizationID uuid.UUID
Ctx context.Context
AuthenticatedCtx context.Context
Log slog.Logger
Clock quartz.Clock
Database database.Store
@@ -92,7 +96,7 @@ type Options struct {
UpdateAgentMetricsFn func(ctx context.Context, labels prometheusmetrics.AgentMetricLabels, metrics []*agentproto.Stats_Metric)
}
func New(opts Options) *API {
func New(opts Options, workspace database.Workspace) *API {
if opts.Clock == nil {
opts.Clock = quartz.NewReal()
}
@@ -114,6 +118,13 @@ func New(opts Options) *API {
WorkspaceID: opts.WorkspaceID,
}
// Don't cache details for prebuilds, though the cached fields will eventually be updated
// by the refresh routine once the prebuild workspace is claimed.
api.cachedWorkspaceFields = &CachedWorkspaceFields{}
if !workspace.IsPrebuild() {
api.cachedWorkspaceFields.UpdateValues(workspace)
}
api.AnnouncementBannerAPI = &AnnouncementBannerAPI{
appearanceFetcher: opts.AppearanceFetcher,
}
@@ -139,6 +150,7 @@ func New(opts Options) *API {
api.StatsAPI = &StatsAPI{
AgentFn: api.agent,
Workspace: api.cachedWorkspaceFields,
Database: opts.Database,
Log: opts.Log,
StatsReporter: opts.StatsReporter,
@@ -162,10 +174,11 @@ func New(opts Options) *API {
}
api.MetadataAPI = &MetadataAPI{
AgentFn: api.agent,
Database: opts.Database,
Pubsub: opts.Pubsub,
Log: opts.Log,
AgentFn: api.agent,
Workspace: api.cachedWorkspaceFields,
Database: opts.Database,
Pubsub: opts.Pubsub,
Log: opts.Log,
}
api.LogsAPI = &LogsAPI{
@@ -205,6 +218,10 @@ func New(opts Options) *API {
Database: opts.Database,
}
// Start background cache refresh loop to handle workspace changes
// like prebuild claims where owner_id and other fields may be modified in the DB.
go api.startCacheRefreshLoop(opts.AuthenticatedCtx)
return api
}
@@ -254,6 +271,56 @@ func (a *API) agent(ctx context.Context) (database.WorkspaceAgent, error) {
return agent, nil
}
// refreshCachedWorkspace periodically updates the cached workspace fields.
// This ensures that changes like prebuild claims (which modify owner_id, name, etc.)
// are eventually reflected in the cache without requiring agent reconnection.
func (a *API) refreshCachedWorkspace(ctx context.Context) {
ws, err := a.opts.Database.GetWorkspaceByID(ctx, a.opts.WorkspaceID)
if err != nil {
a.opts.Log.Warn(ctx, "failed to refresh cached workspace fields", slog.Error(err))
a.cachedWorkspaceFields.Clear()
return
}
if ws.IsPrebuild() {
return
}
// If we still have the same values, skip the update and logging calls.
if a.cachedWorkspaceFields.identity.Equal(database.WorkspaceIdentityFromWorkspace(ws)) {
return
}
// Update fields that can change during workspace lifecycle (e.g., AutostartSchedule)
a.cachedWorkspaceFields.UpdateValues(ws)
a.opts.Log.Debug(ctx, "refreshed cached workspace fields",
slog.F("workspace_id", ws.ID),
slog.F("owner_id", ws.OwnerID),
slog.F("name", ws.Name))
}
// startCacheRefreshLoop runs a background goroutine that periodically refreshes
// the cached workspace fields. This is primarily needed to handle prebuild claims
// where the owner_id and other fields change while the agent connection persists.
func (a *API) startCacheRefreshLoop(ctx context.Context) {
// Refresh every 5 minutes. This provides a reasonable balance between:
// - Keeping cache fresh for prebuild claims and other workspace updates
// - Minimizing unnecessary database queries
ticker := a.opts.Clock.TickerFunc(ctx, workspaceCacheRefreshInterval, func() error {
a.refreshCachedWorkspace(ctx)
return nil
}, "cache_refresh")
// We need to wait on the ticker exiting.
_ = ticker.Wait()
a.opts.Log.Debug(ctx, "cache refresh loop exited, invalidating the workspace cache on agent API",
slog.F("workspace_id", a.cachedWorkspaceFields.identity.ID),
slog.F("owner_id", a.cachedWorkspaceFields.identity.OwnerUsername),
slog.F("name", a.cachedWorkspaceFields.identity.Name))
a.cachedWorkspaceFields.Clear()
}
func (a *API) publishWorkspaceUpdate(ctx context.Context, agent *database.WorkspaceAgent, kind wspubsub.WorkspaceEventKind) error {
a.opts.PublishWorkspaceUpdateFn(ctx, a.opts.OwnerID, wspubsub.WorkspaceEvent{
Kind: kind,
+52
View File
@@ -0,0 +1,52 @@
package agentapi
import (
"sync"
"github.com/coder/coder/v2/coderd/database"
)
// CachedWorkspaceFields contains workspace data that is safe to cache for the
// duration of an agent connection. These fields are used to reduce database calls
// in high-frequency operations like stats reporting and metadata updates.
// Prebuild workspaces should not be cached using this struct within the API struct,
// however some of these fields for a workspace can be updated live so there is a
// routine in the API for refreshing the workspace on a timed interval.
//
// IMPORTANT: ACL fields (GroupACL, UserACL) are NOT cached because they can be
// modified in the database and we must use fresh data for authorization checks.
type CachedWorkspaceFields struct {
lock sync.RWMutex
identity database.WorkspaceIdentity
}
func (cws *CachedWorkspaceFields) Clear() {
cws.lock.Lock()
defer cws.lock.Unlock()
cws.identity = database.WorkspaceIdentity{}
}
func (cws *CachedWorkspaceFields) UpdateValues(ws database.Workspace) {
cws.lock.Lock()
defer cws.lock.Unlock()
cws.identity.ID = ws.ID
cws.identity.OwnerID = ws.OwnerID
cws.identity.OrganizationID = ws.OrganizationID
cws.identity.TemplateID = ws.TemplateID
cws.identity.Name = ws.Name
cws.identity.OwnerUsername = ws.OwnerUsername
cws.identity.TemplateName = ws.TemplateName
cws.identity.AutostartSchedule = ws.AutostartSchedule
}
// Returns the Workspace, true, unless the workspace has not been cached (nuked or was a prebuild).
func (cws *CachedWorkspaceFields) AsWorkspaceIdentity() (database.WorkspaceIdentity, bool) {
cws.lock.RLock()
defer cws.lock.RUnlock()
// Should we be more explicit about all fields being set to be valid?
if cws.identity.Equal(database.WorkspaceIdentity{}) {
return database.WorkspaceIdentity{}, false
}
return cws.identity, true
}
+97
View File
@@ -0,0 +1,97 @@
package agentapi_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/agentapi"
"github.com/coder/coder/v2/coderd/database"
)
func TestCacheClear(t *testing.T) {
t.Parallel()
var (
user = database.User{
ID: uuid.New(),
Username: "bill",
}
template = database.Template{
ID: uuid.New(),
Name: "tpl",
}
workspace = database.Workspace{
ID: uuid.New(),
OwnerID: user.ID,
OwnerUsername: user.Username,
TemplateID: template.ID,
Name: "xyz",
TemplateName: template.Name,
}
workspaceAsCacheFields = agentapi.CachedWorkspaceFields{}
)
workspaceAsCacheFields.UpdateValues(database.Workspace{
ID: workspace.ID,
OwnerID: workspace.OwnerID,
OwnerUsername: workspace.OwnerUsername,
TemplateID: workspace.TemplateID,
Name: workspace.Name,
TemplateName: workspace.TemplateName,
AutostartSchedule: workspace.AutostartSchedule,
},
)
emptyCws := agentapi.CachedWorkspaceFields{}
workspaceAsCacheFields.Clear()
wsi, ok := workspaceAsCacheFields.AsWorkspaceIdentity()
require.False(t, ok)
ecwsi, ok := emptyCws.AsWorkspaceIdentity()
require.False(t, ok)
require.True(t, ecwsi.Equal(wsi))
}
func TestCacheUpdate(t *testing.T) {
t.Parallel()
var (
user = database.User{
ID: uuid.New(),
Username: "bill",
}
template = database.Template{
ID: uuid.New(),
Name: "tpl",
}
workspace = database.Workspace{
ID: uuid.New(),
OwnerID: user.ID,
OwnerUsername: user.Username,
TemplateID: template.ID,
Name: "xyz",
TemplateName: template.Name,
}
workspaceAsCacheFields = agentapi.CachedWorkspaceFields{}
)
workspaceAsCacheFields.UpdateValues(database.Workspace{
ID: workspace.ID,
OwnerID: workspace.OwnerID,
OwnerUsername: workspace.OwnerUsername,
TemplateID: workspace.TemplateID,
Name: workspace.Name,
TemplateName: workspace.TemplateName,
AutostartSchedule: workspace.AutostartSchedule,
},
)
cws := agentapi.CachedWorkspaceFields{}
cws.UpdateValues(workspace)
wsi, ok := workspaceAsCacheFields.AsWorkspaceIdentity()
require.True(t, ok)
cwsi, ok := cws.AsWorkspaceIdentity()
require.True(t, ok)
require.True(t, wsi.Equal(cwsi))
}
+19 -5
View File
@@ -12,15 +12,17 @@ import (
"cdr.dev/slog"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/pubsub"
)
type MetadataAPI struct {
AgentFn func(context.Context) (database.WorkspaceAgent, error)
Database database.Store
Pubsub pubsub.Pubsub
Log slog.Logger
AgentFn func(context.Context) (database.WorkspaceAgent, error)
Workspace *CachedWorkspaceFields
Database database.Store
Pubsub pubsub.Pubsub
Log slog.Logger
TimeNowFn func() time.Time // defaults to dbtime.Now()
}
@@ -107,7 +109,19 @@ func (a *MetadataAPI) BatchUpdateMetadata(ctx context.Context, req *agentproto.B
)
}
err = a.Database.UpdateWorkspaceAgentMetadata(ctx, dbUpdate)
// Inject RBAC object into context for dbauthz fast path, avoid having to
// call GetWorkspaceByAgentID on every metadata update.
rbacCtx := ctx
if dbws, ok := a.Workspace.AsWorkspaceIdentity(); ok {
rbacCtx, err = dbauthz.WithWorkspaceRBAC(ctx, dbws.RBACObject())
if err != nil {
// Don't error level log here, will exit the function. We want to fall back to GetWorkspaceByAgentID.
//nolint:gocritic
a.Log.Debug(ctx, "Cached workspace was present but RBAC object was invalid", slog.F("err", err))
}
}
err = a.Database.UpdateWorkspaceAgentMetadata(rbacCtx, dbUpdate)
if err != nil {
return nil, xerrors.Errorf("update workspace agent metadata in database: %w", err)
}
+435 -9
View File
@@ -2,12 +2,14 @@ package agentapi_test
import (
"context"
"database/sql"
"encoding/json"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -15,10 +17,14 @@ import (
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/agentapi"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
type fakePublisher struct {
@@ -84,9 +90,10 @@ func TestBatchUpdateMetadata(t *testing.T) {
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
return agent, nil
},
Database: dbM,
Pubsub: pub,
Log: testutil.Logger(t),
Workspace: &agentapi.CachedWorkspaceFields{},
Database: dbM,
Pubsub: pub,
Log: testutil.Logger(t),
TimeNowFn: func() time.Time {
return now
},
@@ -169,9 +176,10 @@ func TestBatchUpdateMetadata(t *testing.T) {
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
return agent, nil
},
Database: dbM,
Pubsub: pub,
Log: testutil.Logger(t),
Workspace: &agentapi.CachedWorkspaceFields{},
Database: dbM,
Pubsub: pub,
Log: testutil.Logger(t),
TimeNowFn: func() time.Time {
return now
},
@@ -238,9 +246,10 @@ func TestBatchUpdateMetadata(t *testing.T) {
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
return agent, nil
},
Database: dbM,
Pubsub: pub,
Log: testutil.Logger(t),
Workspace: &agentapi.CachedWorkspaceFields{},
Database: dbM,
Pubsub: pub,
Log: testutil.Logger(t),
TimeNowFn: func() time.Time {
return now
},
@@ -272,4 +281,421 @@ func TestBatchUpdateMetadata(t *testing.T) {
Keys: []string{req.Metadata[0].Key, req.Metadata[1].Key, req.Metadata[2].Key},
}, gotEvent)
})
// Test RBAC fast path with valid RBAC object - should NOT call GetWorkspaceByAgentID
// This test verifies that when a valid RBAC object is present in context, the dbauthz layer
// uses the fast path and skips the GetWorkspaceByAgentID database call.
t.Run("WorkspaceCached_SkipsDBCall", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
dbM = dbmock.NewMockStore(ctrl)
pub = &fakePublisher{}
now = dbtime.Now()
// Set up consistent IDs that represent a valid workspace->agent relationship
workspaceID = uuid.MustParse("12345678-1234-1234-1234-123456789012")
ownerID = uuid.MustParse("87654321-4321-4321-4321-210987654321")
orgID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
agentID = uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
)
agent := database.WorkspaceAgent{
ID: agentID,
// In a real scenario, this agent would belong to a resource in the workspace above
}
req := &agentproto.BatchUpdateMetadataRequest{
Metadata: []*agentproto.Metadata{
{
Key: "test_key",
Result: &agentproto.WorkspaceAgentMetadata_Result{
CollectedAt: timestamppb.New(now.Add(-time.Second)),
Age: 1,
Value: "test_value",
},
},
},
}
// Expect UpdateWorkspaceAgentMetadata to be called
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), database.UpdateWorkspaceAgentMetadataParams{
WorkspaceAgentID: agent.ID,
Key: []string{"test_key"},
Value: []string{"test_value"},
Error: []string{""},
CollectedAt: []time.Time{now},
}).Return(nil)
// DO NOT expect GetWorkspaceByAgentID - the fast path should skip this call
// If GetWorkspaceByAgentID is called, the test will fail with "unexpected call"
// dbauthz will call Wrappers() to check for wrapped databases
dbM.EXPECT().Wrappers().Return([]string{}).AnyTimes()
// Set up dbauthz to test the actual authorization layer
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
accessControlStore.Store(&acs)
api := &agentapi.MetadataAPI{
AgentFn: func(_ context.Context) (database.WorkspaceAgent, error) {
return agent, nil
},
Workspace: &agentapi.CachedWorkspaceFields{},
Database: dbauthz.New(dbM, auth, testutil.Logger(t), accessControlStore),
Pubsub: pub,
Log: testutil.Logger(t),
TimeNowFn: func() time.Time {
return now
},
}
api.Workspace.UpdateValues(database.Workspace{
ID: workspaceID,
OwnerID: ownerID,
OrganizationID: orgID,
})
// Create context with system actor so authorization passes
ctx := dbauthz.AsSystemRestricted(context.Background())
resp, err := api.BatchUpdateMetadata(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
})
// Test RBAC slow path - invalid RBAC object should fall back to GetWorkspaceByAgentID
// This test verifies that when the RBAC object has invalid IDs (nil UUIDs), the dbauthz layer
// falls back to the slow path and calls GetWorkspaceByAgentID.
t.Run("InvalidWorkspaceCached_RequiresDBCall", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
dbM = dbmock.NewMockStore(ctrl)
pub = &fakePublisher{}
now = dbtime.Now()
workspaceID = uuid.MustParse("12345678-1234-1234-1234-123456789012")
ownerID = uuid.MustParse("87654321-4321-4321-4321-210987654321")
orgID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
agentID = uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
)
agent := database.WorkspaceAgent{
ID: agentID,
}
req := &agentproto.BatchUpdateMetadataRequest{
Metadata: []*agentproto.Metadata{
{
Key: "test_key",
Result: &agentproto.WorkspaceAgentMetadata_Result{
CollectedAt: timestamppb.New(now.Add(-time.Second)),
Age: 1,
Value: "test_value",
},
},
},
}
// EXPECT GetWorkspaceByAgentID to be called because the RBAC fast path validation fails
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agentID).Return(database.Workspace{
ID: workspaceID,
OwnerID: ownerID,
OrganizationID: orgID,
}, nil)
// Expect UpdateWorkspaceAgentMetadata to be called after authorization
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), database.UpdateWorkspaceAgentMetadataParams{
WorkspaceAgentID: agent.ID,
Key: []string{"test_key"},
Value: []string{"test_value"},
Error: []string{""},
CollectedAt: []time.Time{now},
}).Return(nil)
// dbauthz will call Wrappers() to check for wrapped databases
dbM.EXPECT().Wrappers().Return([]string{}).AnyTimes()
// Set up dbauthz to test the actual authorization layer
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
accessControlStore.Store(&acs)
api := &agentapi.MetadataAPI{
AgentFn: func(_ context.Context) (database.WorkspaceAgent, error) {
return agent, nil
},
Workspace: &agentapi.CachedWorkspaceFields{},
Database: dbauthz.New(dbM, auth, testutil.Logger(t), accessControlStore),
Pubsub: pub,
Log: testutil.Logger(t),
TimeNowFn: func() time.Time {
return now
},
}
// Create an invalid RBAC object with nil UUIDs for owner/org
// This will fail dbauthz fast path validation and trigger GetWorkspaceByAgentID
api.Workspace.UpdateValues(database.Workspace{
ID: uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
OwnerID: uuid.Nil, // Invalid: fails dbauthz fast path validation
OrganizationID: uuid.Nil, // Invalid: fails dbauthz fast path validation
})
// Create context with system actor so authorization passes
ctx := dbauthz.AsSystemRestricted(context.Background())
resp, err := api.BatchUpdateMetadata(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
})
// Test RBAC slow path - no RBAC object in context
// This test verifies that when no RBAC object is present in context, the dbauthz layer
// falls back to the slow path and calls GetWorkspaceByAgentID.
t.Run("WorkspaceNotCached_RequiresDBCall", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
dbM = dbmock.NewMockStore(ctrl)
pub = &fakePublisher{}
now = dbtime.Now()
workspaceID = uuid.MustParse("12345678-1234-1234-1234-123456789012")
ownerID = uuid.MustParse("87654321-4321-4321-4321-210987654321")
orgID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
agentID = uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd")
)
agent := database.WorkspaceAgent{
ID: agentID,
}
req := &agentproto.BatchUpdateMetadataRequest{
Metadata: []*agentproto.Metadata{
{
Key: "test_key",
Result: &agentproto.WorkspaceAgentMetadata_Result{
CollectedAt: timestamppb.New(now.Add(-time.Second)),
Age: 1,
Value: "test_value",
},
},
},
}
// EXPECT GetWorkspaceByAgentID to be called because no RBAC object is in context
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agentID).Return(database.Workspace{
ID: workspaceID,
OwnerID: ownerID,
OrganizationID: orgID,
}, nil)
// Expect UpdateWorkspaceAgentMetadata to be called after authorization
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), database.UpdateWorkspaceAgentMetadataParams{
WorkspaceAgentID: agent.ID,
Key: []string{"test_key"},
Value: []string{"test_value"},
Error: []string{""},
CollectedAt: []time.Time{now},
}).Return(nil)
// dbauthz will call Wrappers() to check for wrapped databases
dbM.EXPECT().Wrappers().Return([]string{}).AnyTimes()
// Set up dbauthz to test the actual authorization layer
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
accessControlStore.Store(&acs)
api := &agentapi.MetadataAPI{
AgentFn: func(_ context.Context) (database.WorkspaceAgent, error) {
return agent, nil
},
Workspace: &agentapi.CachedWorkspaceFields{},
Database: dbauthz.New(dbM, auth, testutil.Logger(t), accessControlStore),
Pubsub: pub,
Log: testutil.Logger(t),
TimeNowFn: func() time.Time {
return now
},
}
// Create context with system actor so authorization passes
ctx := dbauthz.AsSystemRestricted(context.Background())
resp, err := api.BatchUpdateMetadata(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
})
// Test cache refresh - AutostartSchedule updated
// This test verifies that the cache refresh mechanism actually calls GetWorkspaceByID
// and updates the cached workspace fields when the workspace is modified (e.g., autostart schedule changes).
t.Run("CacheRefreshed_AutostartScheduleUpdated", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
dbM = dbmock.NewMockStore(ctrl)
pub = &fakePublisher{}
now = dbtime.Now()
mClock = quartz.NewMock(t)
tickerTrap = mClock.Trap().TickerFunc("cache_refresh")
workspaceID = uuid.MustParse("12345678-1234-1234-1234-123456789012")
ownerID = uuid.MustParse("87654321-4321-4321-4321-210987654321")
orgID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
templateID = uuid.MustParse("aaaabbbb-cccc-dddd-eeee-ffffffff0000")
agentID = uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff")
)
agent := database.WorkspaceAgent{
ID: agentID,
}
// Initial workspace - has Monday-Friday 9am autostart
initialWorkspace := database.Workspace{
ID: workspaceID,
OwnerID: ownerID,
OrganizationID: orgID,
TemplateID: templateID,
Name: "my-workspace",
OwnerUsername: "testuser",
TemplateName: "test-template",
AutostartSchedule: sql.NullString{Valid: true, String: "CRON_TZ=UTC 0 9 * * 1-5"},
}
// Updated workspace - user changed autostart to 5pm and renamed workspace
updatedWorkspace := database.Workspace{
ID: workspaceID,
OwnerID: ownerID,
OrganizationID: orgID,
TemplateID: templateID,
Name: "my-workspace-renamed", // Changed!
OwnerUsername: "testuser",
TemplateName: "test-template",
AutostartSchedule: sql.NullString{Valid: true, String: "CRON_TZ=UTC 0 17 * * 1-5"}, // Changed!
DormantAt: sql.NullTime{},
}
req := &agentproto.BatchUpdateMetadataRequest{
Metadata: []*agentproto.Metadata{
{
Key: "test_key",
Result: &agentproto.WorkspaceAgentMetadata_Result{
CollectedAt: timestamppb.New(now.Add(-time.Second)),
Age: 1,
Value: "test_value",
},
},
},
}
// EXPECT GetWorkspaceByID to be called during cache refresh
// This is the key assertion - proves the refresh mechanism is working
dbM.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(updatedWorkspace, nil)
// API needs to fetch the agent when calling metadata update
dbM.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agentID).Return(agent, nil)
// After refresh, metadata update should work with updated cache
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, params database.UpdateWorkspaceAgentMetadataParams) error {
require.Equal(t, agent.ID, params.WorkspaceAgentID)
require.Equal(t, []string{"test_key"}, params.Key)
require.Equal(t, []string{"test_value"}, params.Value)
require.Equal(t, []string{""}, params.Error)
require.Len(t, params.CollectedAt, 1)
return nil
},
).AnyTimes()
// May call GetWorkspaceByAgentID if slow path is used before refresh
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agentID).Return(updatedWorkspace, nil).AnyTimes()
// dbauthz will call Wrappers()
dbM.EXPECT().Wrappers().Return([]string{}).AnyTimes()
// Set up dbauthz
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
accessControlStore.Store(&acs)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create roles with workspace permissions
userRoles := rbac.Roles([]rbac.Role{
{
Identifier: rbac.RoleMember(),
User: []rbac.Permission{
{
Negate: false,
ResourceType: rbac.ResourceWorkspace.Type,
Action: policy.WildcardSymbol,
},
},
ByOrgID: map[string]rbac.OrgPermissions{
orgID.String(): {
Member: []rbac.Permission{
{
Negate: false,
ResourceType: rbac.ResourceWorkspace.Type,
Action: policy.WildcardSymbol,
},
},
},
},
},
})
agentScope := rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{
WorkspaceID: workspaceID,
OwnerID: ownerID,
TemplateID: templateID,
VersionID: uuid.New(),
})
ctxWithActor := dbauthz.As(ctx, rbac.Subject{
Type: rbac.SubjectTypeUser,
FriendlyName: "testuser",
Email: "testuser@example.com",
ID: ownerID.String(),
Roles: userRoles,
Groups: []string{orgID.String()},
Scope: agentScope,
}.WithCachedASTValue())
// Create full API with cached workspace fields (initial state)
api := agentapi.New(agentapi.Options{
AuthenticatedCtx: ctxWithActor,
AgentID: agentID,
WorkspaceID: workspaceID,
OwnerID: ownerID,
OrganizationID: orgID,
Database: dbauthz.New(dbM, auth, testutil.Logger(t), accessControlStore),
Log: testutil.Logger(t),
Clock: mClock,
Pubsub: pub,
}, initialWorkspace) // Cache is initialized with 9am schedule and "my-workspace" name
// Wait for ticker to be set up and release it so it can fire
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// Advance clock to trigger cache refresh and wait for it to complete
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
// At this point, GetWorkspaceByID should have been called and cache updated
// The cache now has the 5pm schedule and "my-workspace-renamed" name
// Now call metadata update to verify the refreshed cache works
resp, err := api.MetadataAPI.BatchUpdateMetadata(ctxWithActor, req)
require.NoError(t, err)
require.NotNil(t, resp)
})
}

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