diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d441b185b13..6e2f6c63eb7 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9613,6 +9613,31 @@ const docTemplate = `{ } } }, + "/workspaceagents/me": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Get authenticated workspace agent", + "operationId": "get-authenticated-workspace-agent", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgent" + } + } + } + } + }, "/workspaceagents/me/app-status": { "patch": { "security": [ @@ -23148,19 +23173,19 @@ const docTemplate = `{ "type": "object", "properties": { "forceQuery": { - "description": "append a query ('?') even if RawQuery is empty", + "description": "ForceQuery indicates whether the original URL contained a query ('?') character.\nWhen set, the String method will include a trailing '?', even when RawQuery is empty.", "type": "boolean" }, "fragment": { - "description": "fragment for references, without '#'", + "description": "fragment for references (without '#')", "type": "string" }, "host": { - "description": "host or host:port (see Hostname and Port methods)", + "description": "\"host\" or \"host:port\" (see Hostname and Port methods)", "type": "string" }, "omitHost": { - "description": "do not emit empty host (authority)", + "description": "OmitHost indicates the URL has an empty host (authority).\nWhen set, the String method will not include the host when it is empty.", "type": "boolean" }, "opaque": { @@ -23172,15 +23197,15 @@ const docTemplate = `{ "type": "string" }, "rawFragment": { - "description": "encoded fragment hint (see EscapedFragment method)", + "description": "RawFragment is an optional field containing an encoded fragment hint.\nSee the EscapedFragment method for more details.\n\nIn general, code should call EscapedFragment instead of reading RawFragment.", "type": "string" }, "rawPath": { - "description": "encoded path hint (see EscapedPath method)", + "description": "RawPath is an optional field containing an encoded path hint.\nSee the EscapedPath method for more details.\n\nIn general, code should call EscapedPath instead of reading RawPath.", "type": "string" }, "rawQuery": { - "description": "encoded query values, without '?'", + "description": "RawQuery contains the encoded query values, without the initial '?'.\nUse URL.Query to decode the query.", "type": "string" }, "scheme": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5bc51ca3370..fe1fe79b073 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8508,6 +8508,27 @@ } } }, + "/workspaceagents/me": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Get authenticated workspace agent", + "operationId": "get-authenticated-workspace-agent", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgent" + } + } + } + } + }, "/workspaceagents/me/app-status": { "patch": { "security": [ @@ -21297,19 +21318,19 @@ "type": "object", "properties": { "forceQuery": { - "description": "append a query ('?') even if RawQuery is empty", + "description": "ForceQuery indicates whether the original URL contained a query ('?') character.\nWhen set, the String method will include a trailing '?', even when RawQuery is empty.", "type": "boolean" }, "fragment": { - "description": "fragment for references, without '#'", + "description": "fragment for references (without '#')", "type": "string" }, "host": { - "description": "host or host:port (see Hostname and Port methods)", + "description": "\"host\" or \"host:port\" (see Hostname and Port methods)", "type": "string" }, "omitHost": { - "description": "do not emit empty host (authority)", + "description": "OmitHost indicates the URL has an empty host (authority).\nWhen set, the String method will not include the host when it is empty.", "type": "boolean" }, "opaque": { @@ -21321,15 +21342,15 @@ "type": "string" }, "rawFragment": { - "description": "encoded fragment hint (see EscapedFragment method)", + "description": "RawFragment is an optional field containing an encoded fragment hint.\nSee the EscapedFragment method for more details.\n\nIn general, code should call EscapedFragment instead of reading RawFragment.", "type": "string" }, "rawPath": { - "description": "encoded path hint (see EscapedPath method)", + "description": "RawPath is an optional field containing an encoded path hint.\nSee the EscapedPath method for more details.\n\nIn general, code should call EscapedPath instead of reading RawPath.", "type": "string" }, "rawQuery": { - "description": "encoded query values, without '?'", + "description": "RawQuery contains the encoded query values, without the initial '?'.\nUse URL.Query to decode the query.", "type": "string" }, "scheme": { diff --git a/coderd/coderd.go b/coderd/coderd.go index f1e8622a4b0..582f663b984 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1575,6 +1575,7 @@ func New(options *Options) *API { ).Get("/connection", api.workspaceAgentConnectionGeneric) r.Route("/me", func(r chi.Router) { r.Use(workspaceAgentInfo) + r.Get("/", api.workspaceAgentMe) r.Group(func(r chi.Router) { r.Use( // Override the request_type for agent rpc traffic. diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 27719cfdea2..db00e0dae85 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -60,9 +60,43 @@ import ( // @Success 200 {object} codersdk.WorkspaceAgent // @Router /workspaceagents/{workspaceagent} [get] func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) { + waws := httpmw.WorkspaceAgentAndWorkspaceParam(r) + api.writeWorkspaceAgentResponse(rw, r, waws) +} + +// @Summary Get authenticated workspace agent +// @ID get-authenticated-workspace-agent +// @Security CoderSessionToken +// @Produce json +// @Tags Agents +// @Success 200 {object} codersdk.WorkspaceAgent +// @Router /workspaceagents/me [get] +func (api *API) workspaceAgentMe(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + agent := httpmw.WorkspaceAgent(r) + + // The /me middleware only provides the agent and build, but the + // response also needs the workspace and owner username. Fetch them + // using the authenticated agent's ID. + //nolint:gocritic // The agent RBAC scope may not cover this cross-table query. + waws, err := api.Database.GetWorkspaceAgentAndWorkspaceByID(dbauthz.AsSystemRestricted(ctx), agent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace agent.", + Detail: err.Error(), + }) + return + } + + api.writeWorkspaceAgentResponse(rw, r, waws) +} + +// writeWorkspaceAgentResponse fetches apps, scripts, log sources, and +// statuses for the given agent and writes a codersdk.WorkspaceAgent +// JSON response. +func (api *API) writeWorkspaceAgentResponse(rw http.ResponseWriter, r *http.Request, waws database.GetWorkspaceAgentAndWorkspaceByIDRow) { var ( ctx = r.Context() - waws = httpmw.WorkspaceAgentAndWorkspaceParam(r) dbApps []database.WorkspaceApp scripts []database.WorkspaceAgentScript logSources []database.WorkspaceAgentLogSource diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 48b00b405b9..b7ce6c78213 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -92,6 +92,29 @@ func TestWorkspaceAgent(t *testing.T) { require.NoError(t, err) require.True(t, workspace.LatestBuild.Resources[0].Agents[0].Health.Healthy) }) + t.Run("Me", func(t *testing.T) { + t.Parallel() + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + tmpDir := t.TempDir() + + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + agents[0].Directory = tmpDir + return agents + }).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(r.AgentToken)) + me, err := agentClient.Me(ctx) + require.NoError(t, err) + require.Equal(t, r.Agents[0].ID, me.ID) + require.Equal(t, tmpDir, me.Directory) + }) t.Run("HasFallbackTroubleshootingURL", func(t *testing.T) { t.Parallel() client, db := coderdtest.NewWithDatabase(t, nil) diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 4255e41d49a..ec701523841 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -66,6 +66,23 @@ type Client struct { SDK *codersdk.Client } +// Me returns the workspace agent metadata for the agent authenticated +// by the session token. +func (c *Client) Me(ctx context.Context) (codersdk.WorkspaceAgent, error) { + res, err := c.SDK.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me", nil) + if err != nil { + return codersdk.WorkspaceAgent{}, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return codersdk.WorkspaceAgent{}, codersdk.ReadBodyAsError(res) + } + + var agent codersdk.WorkspaceAgent + return agent, json.NewDecoder(res.Body).Decode(&agent) +} + type GitSSHKey struct { PublicKey string `json:"public_key"` PrivateKey string `json:"private_key"` diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 8252582093f..88a48d145db 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -180,6 +180,149 @@ curl -X POST http://coder-server:8080/api/v2/workspaceagents/google-instance-ide To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get authenticated workspace agent + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaceagents/me \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaceagents/me` + +### Example responses + +> 200 Response + +```json +{ + "api_version": "string", + "apps": [ + { + "command": "string", + "display_name": "string", + "external": true, + "group": "string", + "health": "disabled", + "healthcheck": { + "interval": 0, + "threshold": 0, + "url": "string" + }, + "hidden": true, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "open_in": "slim-window", + "sharing_level": "owner", + "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], + "subdomain": true, + "subdomain_name": "string", + "tooltip": "string", + "url": "string" + } + ], + "architecture": "string", + "connection_timeout_seconds": 0, + "created_at": "2019-08-24T14:15:22Z", + "directory": "string", + "disconnected_at": "2019-08-24T14:15:22Z", + "display_apps": [ + "vscode" + ], + "environment_variables": { + "property1": "string", + "property2": "string" + }, + "expanded_directory": "string", + "first_connected_at": "2019-08-24T14:15:22Z", + "health": { + "healthy": false, + "reason": "agent has lost connection" + }, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "instance_id": "string", + "last_connected_at": "2019-08-24T14:15:22Z", + "latency": { + "property1": { + "latency_ms": 0, + "preferred": true + }, + "property2": { + "latency_ms": 0, + "preferred": true + } + }, + "lifecycle_state": "created", + "log_sources": [ + { + "created_at": "2019-08-24T14:15:22Z", + "display_name": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "workspace_agent_id": "7ad2e618-fea7-4c1a-b70a-f501566a72f1" + } + ], + "logs_length": 0, + "logs_overflowed": true, + "name": "string", + "operating_system": "string", + "parent_id": { + "uuid": "string", + "valid": true + }, + "ready_at": "2019-08-24T14:15:22Z", + "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", + "scripts": [ + { + "cron": "string", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "log_path": "string", + "log_source_id": "4197ab25-95cf-4b91-9c78-f7f2af5d353a", + "run_on_start": true, + "run_on_stop": true, + "script": "string", + "start_blocks_login": true, + "timeout": 0 + } + ], + "started_at": "2019-08-24T14:15:22Z", + "startup_script_behavior": "blocking", + "status": "connecting", + "subsystems": [ + "envbox" + ], + "troubleshooting_url": "string", + "updated_at": "2019-08-24T14:15:22Z", + "version": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceAgent](schemas.md#codersdkworkspaceagent) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Patch workspace agent app status ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 3a197078bfa..d61eb5538ba 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -14315,19 +14315,21 @@ None ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------|------------------------------|----------|--------------|----------------------------------------------------| -| `forceQuery` | boolean | false | | append a query ('?') even if RawQuery is empty | -| `fragment` | string | false | | fragment for references, without '#' | -| `host` | string | false | | host or host:port (see Hostname and Port methods) | -| `omitHost` | boolean | false | | do not emit empty host (authority) | -| `opaque` | string | false | | encoded opaque data | -| `path` | string | false | | path (relative paths may omit leading slash) | -| `rawFragment` | string | false | | encoded fragment hint (see EscapedFragment method) | -| `rawPath` | string | false | | encoded path hint (see EscapedPath method) | -| `rawQuery` | string | false | | encoded query values, without '?' | -| `scheme` | string | false | | | -| `user` | [url.Userinfo](#urluserinfo) | false | | username and password information | +| Name | Type | Required | Restrictions | Description | +|--------------|---------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `forceQuery` | boolean | false | | Forcequery indicates whether the original URL contained a query ('?') character. When set, the String method will include a trailing '?', even when RawQuery is empty. | +| `fragment` | string | false | | fragment for references (without '#') | +| `host` | string | false | | "host" or "host:port" (see Hostname and Port methods) | +| `omitHost` | boolean | false | | Omithost indicates the URL has an empty host (authority). When set, the String method will not include the host when it is empty. | +| `opaque` | string | false | | encoded opaque data | +| `path` | string | false | | path (relative paths may omit leading slash) | +|`rawFragment`|string|false||Rawfragment is an optional field containing an encoded fragment hint. See the EscapedFragment method for more details. +In general, code should call EscapedFragment instead of reading RawFragment.| +|`rawPath`|string|false||Rawpath is an optional field containing an encoded path hint. See the EscapedPath method for more details. +In general, code should call EscapedPath instead of reading RawPath.| +|`rawQuery`|string|false||Rawquery contains the encoded query values, without the initial '?'. Use URL.Query to decode the query.| +|`scheme`|string|false||| +|`user`|[url.Userinfo](#urluserinfo)|false||username and password information| ## serpent.ValueSource