Files
coder/coderd/provisionerjobs.go
T
Nick Vigilante 61e52532c0 docs: wrap placeholder syntax in backticks in CLI help and swagger annotations (#27194)
## Problem

Generated reference docs (`docs/reference/cli/*`,
`docs/reference/api/*`) contained raw placeholder and JSON syntax that
came straight from Go CLI help strings and swagger annotations. HTML
renderers treat the angle-bracket tokens (`<team-slug>`, `<uuid>`,
`<KEY>`, etc.) as unknown tags and drop them, so readers see
broken/half-missing text today. The same strings also break MDX parsing.

## Fix

Wrap the placeholder/JSON syntax in backticks **at the source** (Go help
strings and swagger annotation comments), then `make gen`. Rendered docs
now show the placeholders as inline code instead of dropping them.

### Source changes

| File | Placeholder wrapped | Surfaces in |
|------|--------------------|-------------|
| `codersdk/deployment.go` | `` `<organization-name>/<team-slug>` `` |
`cli/server.md`, `coder --help`, settings UI |
| `codersdk/deployment.go` | `` `CODER_AI_GATEWAY_PROVIDER_<N>_*` ``, ``
`CODER_AI_GATEWAY_PROVIDER_<N>_<KEY>` `` | `api/schemas.md` |
| `cli/tokens.go` | `` `<type>:<uuid>` `` | `cli/tokens_create.md`,
`coder --help` |
| `coderd/aitasks.go` | `` `owner:<…>` ``, `` `organization:<…>` ``, ``
`status:<status>` `` | `api/tasks.md` |
| `coderd/exp_chats.go` | `` `pr_status:<…>` `` and sibling filter
tokens | `api/chats.md` |
| `coderd/provisionerdaemons.go`, `coderd/provisionerjobs.go` | ``
`{'tag1':'value1','tag2':'value2'}` `` | `api/organizations.md`,
`api/provisioning.md` |

Everything else in the diff (`coderd/apidoc/*`, `docs/reference/**`,
`*.golden`, `site/src/api/typesGenerated.ts`) is `make gen` output.

## Reviewer notes (the "considered pass" from the ticket)

- **Product-visible:** this changes `coder server --help` and `coder
tokens create --help` output, and the `server-config.yaml` reference
comment. Backticks in terminal help are literal but read fine as
placeholder markers.
- **Settings UI:** the `deployment.go` `Description` also renders in the
deployment settings page. If that field is not Markdown-rendered,
literal backticks will show there. Happy to drop the `deployment.go`
change if you'd rather keep the UI text clean and fix `server.md`
another way.
- **Out of scope here:** `docs/reference/cli/agent-firewall.md`
(`<host>`/`<glob>`) is generated from the external
`github.com/coder/boundary` module, not this repo. It needs an upstream
fix + module bump; not included in this PR.

<details>
<summary>Implementation notes / decision log</summary>

- Scope taken from DOCS-551: source-level backtick pass for generated
reference docs only. Hand-written Markdown fixes are tracked separately
(companion ticket).
- Swagger `@Param` descriptions are Go comments, so the existing `\|`
pipe-escaping in the chats `q` filter is preserved inside the new
backticks (still required for the Markdown table cell to render `|`).
- Verified after `make gen`: generated docs render placeholders as code
spans, table pipes intact; `gofmt` clean; changed Go packages build; no
emdash/endash introduced.
- Deliberately left the `AIProviderConfig` type-level doc comment
untouched because it does not surface in any generated doc (kept the
diff to doc-feeding comments).

</details>

Linear: DOCS-551

---

_Opened by Coder Agents on behalf of @nickvigilante._



---

## Evidence: placeholders dropped on the live docs site

Verified **2026-07-14** against the live site (`coder.com/docs`, i.e.
`main`, pre-merge) by loading each affected page in headless Chrome and
reading the post-hydration DOM (confirmed identical in the raw page
payload). Each simple `<token>` placeholder is parsed as an **empty
custom HTML element**, so the browser renders nothing for it and the
placeholder text disappears from the page.

### What readers see today (before this PR)

| Page (live) | Source Markdown | Rendered on the live site |
|-------------|-----------------|---------------------------|
| [`cli/server`](https://coder.com/docs/reference/cli/server) — OAuth2
GitHub Allowed Teams | `Structured as: <organization-name>/<team-slug>.`
| `Structured as: /.` |
|
[`cli/tokens_create`](https://coder.com/docs/reference/cli/tokens_create)
— `--allow` | `Repeatable allow-list entry (<type>:<uuid>, e.g.
workspace:1234-...).` | `Repeatable allow-list entry (:, e.g.
workspace:1234-...).` |
| [`api/tasks`](https://coder.com/docs/reference/api/tasks) — `q` | `...
status:<status>` | `... status:` (nothing after the colon) |
| [`api/schemas`](https://coder.com/docs/reference/api/schemas) —
AIBridgeConfig (`anthropic`/`bedrock`/`openai`) |
`CODER_AI_GATEWAY_PROVIDER_<N>_*` | `CODER_AI_GATEWAY_PROVIDER__*` |
| [`api/schemas`](https://coder.com/docs/reference/api/schemas) —
AIBridgeConfig (`providers`) | `CODER_AI_GATEWAY_PROVIDER_<N>_<KEY>` |
`CODER_AI_GATEWAY_PROVIDER__` |

[`api/chats`](https://coder.com/docs/reference/api/chats) (`q`) drops
five tokens the same way — `title:<substring>`, `diff_url:<url>`,
`pr:<number>`, `pr_title:<text>`, and the trailing `title:<value>`. The
live parameter description reads (note the dangling `title:`,
`diff_url:`, `pr:`, `pr_title:`):

```text
Search query. Supports title: (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:<draft|open|merged|closed> as repeated or comma-separated values, source:<created_by_me|shared_with_me>, diff_url: (quote values containing colons), pr: (exact PR number match), repo:<owner/repo> (case-insensitive substring match against git remote origin or URL), pr_title: (case-insensitive PR title substring). Bare terms are not supported; use title: for title filtering.
```

<details>
<summary>Raw rendered DOM from the live site (headless Chrome,
post-hydration)</summary>

```html
<!-- reference/cli/server -->
Structured as: <organization-name>/<team-slug>.</team-slug></organization-name>

<!-- reference/cli/tokens_create -->
Repeatable allow-list entry (<type>:<uuid>, e.g. workspace:1234-...).</uuid></type>

<!-- reference/api/tasks : only status:<status> drops; the /-containing tokens are escaped and survive -->
Search query for filtering tasks. Supports: owner:&lt;username/uuid/me&gt;, organization:&lt;org-name/uuid&gt;, status:<status></status>

<!-- reference/api/schemas : anthropic / bedrock / openai rows -->
Deprecated: Use Providers with indexed CODER_AI_GATEWAY_PROVIDER_<n>_* env vars instead.</n>

<!-- reference/api/schemas : providers row -->
Providers holds provider instances populated from CODER_AI_GATEWAY_PROVIDER_<n>_<key> env vars and/or the deprecated LegacyOpenAI/LegacyAnthropic/LegacyBedrock fields above.</key></n>
```

The parser auto-inserts closing tags
(`</team-slug></organization-name>`) and lowercases the tag name (`<N>`
becomes `<n>`), leaving `__` where `<N>_` used to be. Every wrapped
placeholder renders correctly as inline code on the [docs preview for
this
branch](https://coder.com/docs/@vigilante%2Fdocs-551-backtick-placeholder-syntax-in-generated-reference-docs-cli/reference/cli/server).

</details>

### Accuracy note — cases that do *not* drop on live

These render fine today, so they are **not** evidence of dropping (the
PR still wraps them for consistency / MDX-safety):

-
[`api/organizations`](https://coder.com/docs/reference/api/organizations)
and
[`api/provisioning`](https://coder.com/docs/reference/api/provisioning):
`{'tag1':'value1','tag2':'value2'}` renders verbatim — curly braces are
not an HTML tag.
- Tokens containing `/` or `|` are escaped by the renderer and stay
visible (as literal `<...>`): `<username/uuid/me>`, `<org-name/uuid>`,
`<owner/repo>`, `<draft|open|merged|closed>`,
`<created_by_me|shared_with_me>`. Backticks still improve their
readability, but they were never dropped.
2026-07-14 13:39:58 -04:00

702 lines
23 KiB
Go

package coderd
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"sort"
"strconv"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/httpmw/loggermw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/coder/v2/provisionersdk"
"github.com/coder/websocket"
)
// @Summary Get provisioner job
// @ID get-provisioner-job
// @Security CoderSessionToken
// @Produce json
// @Tags Organizations
// @Param organization path string true "Organization ID" format(uuid)
// @Param job path string true "Job ID" format(uuid)
// @Success 200 {object} codersdk.ProvisionerJob
// @Router /api/v2/organizations/{organization}/provisionerjobs/{job} [get]
func (api *API) provisionerJob(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
jobID, ok := httpmw.ParseUUIDParam(rw, r, "job")
if !ok {
return
}
jobs, ok := api.handleAuthAndFetchProvisionerJobs(rw, r, []uuid.UUID{jobID})
if !ok {
return
}
if len(jobs) == 0 {
httpapi.ResourceNotFound(rw)
return
}
if len(jobs) > 1 || jobs[0].ProvisionerJob.ID != jobID {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner job.",
Detail: "Database returned an unexpected job.",
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertProvisionerJobWithQueuePosition(jobs[0]))
}
// @Summary Get provisioner jobs
// @ID get-provisioner-jobs
// @Security CoderSessionToken
// @Produce json
// @Tags Organizations
// @Param organization path string true "Organization ID" format(uuid)
// @Param limit query int false "Page limit"
// @Param ids query []string false "Filter results by job IDs" format(uuid)
// @Param status query codersdk.ProvisionerJobStatus false "Filter results by status" enums(pending,running,succeeded,canceling,canceled,failed)
// @Param tags query object false "Provisioner tags to filter by (JSON of the form `{'tag1':'value1','tag2':'value2'}`)"
// @Param initiator query string false "Filter results by initiator" format(uuid)
// @Success 200 {array} codersdk.ProvisionerJob
// @Router /api/v2/organizations/{organization}/provisionerjobs [get]
func (api *API) provisionerJobs(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
jobs, ok := api.handleAuthAndFetchProvisionerJobs(rw, r, nil)
if !ok {
return
}
httpapi.Write(ctx, rw, http.StatusOK, slice.List(jobs, convertProvisionerJobWithQueuePosition))
}
// handleAuthAndFetchProvisionerJobs is an internal method shared by
// provisionerJob and provisionerJobs. If ok is false the caller should
// return immediately because the response has already been written.
func (api *API) handleAuthAndFetchProvisionerJobs(rw http.ResponseWriter, r *http.Request, ids []uuid.UUID) (_ []database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow, ok bool) {
ctx := r.Context()
org := httpmw.OrganizationParam(r)
// For now, only owners and template admins can access provisioner jobs.
if !api.Authorize(r, policy.ActionRead, rbac.ResourceProvisionerJobs.InOrg(org.ID)) {
httpapi.ResourceNotFound(rw)
return nil, false
}
qp := r.URL.Query()
p := httpapi.NewQueryParamParser()
limit := p.PositiveInt32(qp, 50, "limit")
status := p.Strings(qp, nil, "status")
if ids == nil {
ids = p.UUIDs(qp, nil, "ids")
}
tags := p.JSONStringMap(qp, database.StringMap{}, "tags")
initiatorID := p.UUID(qp, uuid.Nil, "initiator")
p.ErrorExcessParams(qp)
if len(p.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid query parameters.",
Validations: p.Errors,
})
return nil, false
}
jobs, err := api.Database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(ctx, database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams{
OrganizationID: org.ID,
Status: slice.StringEnums[database.ProvisionerJobStatus](status),
Limit: sql.NullInt32{Int32: limit, Valid: limit > 0},
IDs: ids,
Tags: tags,
InitiatorID: initiatorID,
})
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return nil, false
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner jobs.",
Detail: err.Error(),
})
return nil, false
}
return jobs, true
}
// Returns provisioner logs based on query parameters.
// The intended usage for a client to stream all logs (with JS API):
// GET /logs
// GET /logs?after=<id>&follow
// The combination of these responses should provide all current logs
// to the consumer, and future logs are streamed in the follow request.
func (api *API) provisionerJobLogs(rw http.ResponseWriter, r *http.Request, job database.ProvisionerJob) {
var (
ctx = r.Context()
logger = api.Logger.With(slog.F("job_id", job.ID))
follow = r.URL.Query().Has("follow")
afterRaw = r.URL.Query().Get("after")
format = r.URL.Query().Get("format")
)
// Validate format parameter.
if format == "" {
format = "json"
}
if format != "json" && format != "text" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid format parameter.",
Detail: "Allowed values are \"json\" and \"text\".",
})
return
}
// Text format is not supported with streaming.
if format == "text" && follow {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Text format is not supported with follow mode.",
Detail: "Use format=json or omit the follow parameter.",
})
return
}
var after int64
// Only fetch logs created after the time provided.
if afterRaw != "" {
var err error
after, err = strconv.ParseInt(afterRaw, 10, 64)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query param \"after\" must be an integer.",
Validations: []codersdk.ValidationError{
{Field: "after", Detail: "Must be an integer"},
},
})
return
}
}
if !follow {
fetchAndWriteLogs(ctx, api.Database, job.ID, after, rw, format)
return
}
follower := newLogFollower(ctx, logger, api.Database, api.Pubsub, api.wsWatcher, rw, r, job, after)
api.WebsocketWaitMutex.Lock()
api.WebsocketWaitGroup.Add(1)
api.WebsocketWaitMutex.Unlock()
defer api.WebsocketWaitGroup.Done()
follower.follow()
}
func (api *API) provisionerJobResources(rw http.ResponseWriter, r *http.Request, job database.ProvisionerJob) {
ctx := r.Context()
if !job.CompletedAt.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Job hasn't completed!",
})
return
}
// nolint:gocritic // GetWorkspaceResourcesByJobID is a system function.
resources, err := api.Database.GetWorkspaceResourcesByJobID(dbauthz.AsSystemRestricted(ctx), job.ID)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching job resources.",
Detail: err.Error(),
})
return
}
resourceIDs := make([]uuid.UUID, 0)
for _, resource := range resources {
resourceIDs = append(resourceIDs, resource.ID)
}
// nolint:gocritic // GetWorkspaceAgentsByResourceIDs is a system function.
resourceAgents, err := api.Database.GetWorkspaceAgentsByResourceIDs(dbauthz.AsSystemRestricted(ctx), resourceIDs)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace agent.",
Detail: err.Error(),
})
return
}
resourceAgentIDs := make([]uuid.UUID, 0)
for _, agent := range resourceAgents {
resourceAgentIDs = append(resourceAgentIDs, agent.ID)
}
// nolint:gocritic // GetWorkspaceAppsByAgentIDs is a system function.
apps, err := api.Database.GetWorkspaceAppsByAgentIDs(dbauthz.AsSystemRestricted(ctx), resourceAgentIDs)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace applications.",
Detail: err.Error(),
})
return
}
// nolint:gocritic // GetWorkspaceAgentScriptsByAgentIDs is a system function.
scripts, err := api.Database.GetWorkspaceAgentScriptsByAgentIDs(dbauthz.AsSystemRestricted(ctx), resourceAgentIDs)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace agent scripts.",
Detail: err.Error(),
})
return
}
// nolint:gocritic // GetWorkspaceAgentLogSourcesByAgentIDs is a system function.
logSources, err := api.Database.GetWorkspaceAgentLogSourcesByAgentIDs(dbauthz.AsSystemRestricted(ctx), resourceAgentIDs)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace agent log sources.",
Detail: err.Error(),
})
return
}
// nolint:gocritic // GetWorkspaceResourceMetadataByResourceIDs is a system function.
resourceMetadata, err := api.Database.GetWorkspaceResourceMetadataByResourceIDs(dbauthz.AsSystemRestricted(ctx), resourceIDs)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace metadata.",
Detail: err.Error(),
})
return
}
apiResources := make([]codersdk.WorkspaceResource, 0)
for _, resource := range resources {
agents := make([]codersdk.WorkspaceAgent, 0)
for _, agent := range resourceAgents {
if agent.ResourceID != resource.ID {
continue
}
dbApps := make([]database.WorkspaceApp, 0)
for _, app := range apps {
if app.AgentID == agent.ID {
dbApps = append(dbApps, app)
}
}
dbScripts := make([]database.GetWorkspaceAgentScriptsByAgentIDsRow, 0)
for _, script := range scripts {
if script.WorkspaceAgentID == agent.ID {
dbScripts = append(dbScripts, script)
}
}
dbLogSources := make([]database.WorkspaceAgentLogSource, 0)
for _, logSource := range logSources {
if logSource.WorkspaceAgentID == agent.ID {
dbLogSources = append(dbLogSources, logSource)
}
}
apiAgent, err := db2sdk.WorkspaceAgent(
api.DERPMap(), *api.TailnetCoordinator.Load(), agent, convertProvisionedApps(dbApps), convertScripts(dbScripts), convertLogSources(dbLogSources), api.AgentInactiveDisconnectTimeout,
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error reading job agent.",
Detail: err.Error(),
})
return
}
agents = append(agents, apiAgent)
}
metadata := make([]database.WorkspaceResourceMetadatum, 0)
for _, field := range resourceMetadata {
if field.WorkspaceResourceID == resource.ID {
metadata = append(metadata, field)
}
}
apiResources = append(apiResources, convertWorkspaceResource(resource, agents, metadata))
}
sort.Slice(apiResources, func(i, j int) bool {
return apiResources[i].Name < apiResources[j].Name
})
httpapi.Write(ctx, rw, http.StatusOK, apiResources)
}
func convertProvisionerJobLogs(provisionerJobLogs []database.ProvisionerJobLog) []codersdk.ProvisionerJobLog {
sdk := make([]codersdk.ProvisionerJobLog, 0, len(provisionerJobLogs))
for _, log := range provisionerJobLogs {
sdk = append(sdk, convertProvisionerJobLog(log))
}
return sdk
}
func convertProvisionerJobLog(provisionerJobLog database.ProvisionerJobLog) codersdk.ProvisionerJobLog {
return codersdk.ProvisionerJobLog{
ID: provisionerJobLog.ID,
CreatedAt: provisionerJobLog.CreatedAt,
Source: codersdk.LogSource(provisionerJobLog.Source),
Level: codersdk.LogLevel(provisionerJobLog.Level),
Stage: provisionerJobLog.Stage,
Output: provisionerJobLog.Output,
}
}
func convertProvisionerJob(pj database.GetProvisionerJobsByIDsWithQueuePositionRow) codersdk.ProvisionerJob {
provisionerJob := pj.ProvisionerJob
job := codersdk.ProvisionerJob{
ID: provisionerJob.ID,
OrganizationID: provisionerJob.OrganizationID,
InitiatorID: provisionerJob.InitiatorID,
CreatedAt: provisionerJob.CreatedAt,
Type: codersdk.ProvisionerJobType(provisionerJob.Type),
Error: provisionerJob.Error.String,
ErrorCode: codersdk.JobErrorCode(provisionerJob.ErrorCode.String),
FileID: provisionerJob.FileID,
Tags: provisionerJob.Tags,
QueuePosition: int(pj.QueuePosition),
QueueSize: int(pj.QueueSize),
LogsOverflowed: provisionerJob.LogsOverflowed,
}
// Applying values optional to the struct.
if provisionerJob.StartedAt.Valid {
job.StartedAt = &provisionerJob.StartedAt.Time
}
if provisionerJob.CompletedAt.Valid {
job.CompletedAt = &provisionerJob.CompletedAt.Time
}
if provisionerJob.CanceledAt.Valid {
job.CanceledAt = &provisionerJob.CanceledAt.Time
}
if provisionerJob.WorkerID.Valid {
job.WorkerID = &provisionerJob.WorkerID.UUID
}
job.Status = codersdk.ProvisionerJobStatus(pj.ProvisionerJob.JobStatus)
// Only unmarshal input if it exists, this should only be zero in testing.
if len(provisionerJob.Input) > 0 {
if err := json.Unmarshal(provisionerJob.Input, &job.Input); err != nil {
job.Input.Error = xerrors.Errorf("decode input %s: %w", provisionerJob.Input, err).Error()
}
}
return job
}
func convertProvisionerJobWithQueuePosition(pj database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow) codersdk.ProvisionerJob {
job := convertProvisionerJob(database.GetProvisionerJobsByIDsWithQueuePositionRow{
ProvisionerJob: pj.ProvisionerJob,
QueuePosition: pj.QueuePosition,
QueueSize: pj.QueueSize,
})
job.WorkerName = pj.WorkerName
job.AvailableWorkers = pj.AvailableWorkers
job.Metadata = codersdk.ProvisionerJobMetadata{
TemplateVersionName: pj.TemplateVersionName,
TemplateID: pj.TemplateID.UUID,
TemplateName: pj.TemplateName,
TemplateDisplayName: pj.TemplateDisplayName,
TemplateIcon: pj.TemplateIcon,
WorkspaceName: pj.WorkspaceName,
}
if pj.WorkspaceID.Valid {
job.Metadata.WorkspaceID = &pj.WorkspaceID.UUID
}
if pj.WorkspaceBuildTransition.Valid {
job.Metadata.WorkspaceBuildTransition = codersdk.WorkspaceTransition(pj.WorkspaceBuildTransition.WorkspaceTransition)
}
return job
}
func fetchAndWriteLogs(ctx context.Context, db database.Store, jobID uuid.UUID, after int64, rw http.ResponseWriter, format string) {
logs, err := db.GetProvisionerLogsAfterID(ctx, database.GetProvisionerLogsAfterIDParams{
JobID: jobID,
CreatedAfter: after,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner logs.",
Detail: err.Error(),
})
return
}
if logs == nil {
logs = []database.ProvisionerJobLog{}
}
if format == "text" {
rw.Header().Set("Content-Type", "text/plain; charset=utf-8")
rw.WriteHeader(http.StatusOK)
for _, log := range logs {
_, _ = rw.Write([]byte(db2sdk.ProvisionerJobLog(log).Text()))
_, _ = rw.Write([]byte("\n"))
}
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertProvisionerJobLogs(logs))
}
func jobIsComplete(logger slog.Logger, job database.ProvisionerJob) bool {
status := codersdk.ProvisionerJobStatus(job.JobStatus)
switch status {
case codersdk.ProvisionerJobCanceled:
return true
case codersdk.ProvisionerJobFailed:
return true
case codersdk.ProvisionerJobSucceeded:
return true
case codersdk.ProvisionerJobPending:
return false
case codersdk.ProvisionerJobCanceling:
return false
case codersdk.ProvisionerJobRunning:
return false
default:
logger.Error(context.Background(),
"can't convert the provisioner job status",
slog.F("job_id", job.ID), slog.F("status", status))
return false
}
}
type logFollower struct {
ctx context.Context
logger slog.Logger
db database.Store
pubsub pubsub.Pubsub
wsWatcher *httpapi.WSWatcher
r *http.Request
rw http.ResponseWriter
conn *websocket.Conn
enc *wsjson.Encoder[codersdk.ProvisionerJobLog]
jobID uuid.UUID
after int64
complete bool
notifications chan provisionersdk.ProvisionerJobLogsNotifyMessage
errors chan error
}
func newLogFollower(
ctx context.Context, logger slog.Logger, db database.Store, ps pubsub.Pubsub,
wsWatcher *httpapi.WSWatcher, rw http.ResponseWriter, r *http.Request,
job database.ProvisionerJob, after int64,
) *logFollower {
return &logFollower{
ctx: ctx,
logger: logger,
db: db,
pubsub: ps,
wsWatcher: wsWatcher,
r: r,
rw: rw,
jobID: job.ID,
after: after,
complete: jobIsComplete(logger, job),
notifications: make(chan provisionersdk.ProvisionerJobLogsNotifyMessage),
errors: make(chan error),
}
}
func (f *logFollower) follow() {
var cancel context.CancelFunc
f.ctx, cancel = context.WithCancel(f.ctx)
defer cancel()
// note that we only need to subscribe to updates if the job is not yet
// complete.
if !f.complete {
subCancel, err := f.pubsub.SubscribeWithErr(
provisionersdk.ProvisionerJobLogsNotifyChannel(f.jobID),
f.listener,
)
if err != nil {
httpapi.Write(f.ctx, f.rw, http.StatusInternalServerError, codersdk.Response{
Message: "failed to subscribe to job updates",
Detail: err.Error(),
})
return
}
defer subCancel()
// Move cancel up the stack so it happens before unsubscribing,
// otherwise we can end up in a deadlock due to how the
// in-memory pubsub does mutex locking on send/unsubscribe.
defer cancel()
// we were provided `complete` prior to starting this subscription, so
// we also need to check whether the job is now complete, in case the
// job completed between the last time we queried the job and the start
// of the subscription. If the job completes after this, we will get
// a notification on the subscription.
job, err := f.db.GetProvisionerJobByID(f.ctx, f.jobID)
if err != nil {
httpapi.Write(f.ctx, f.rw, http.StatusInternalServerError, codersdk.Response{
Message: "failed to query job",
Detail: err.Error(),
})
return
}
f.complete = jobIsComplete(f.logger, job)
f.logger.Debug(f.ctx, "queried job after subscribe", slog.F("complete", f.complete))
}
var err error
f.conn, err = websocket.Accept(f.rw, f.r, nil)
if err != nil {
httpapi.Write(f.ctx, f.rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to accept websocket.",
Detail: err.Error(),
})
return
}
defer f.conn.Close(websocket.StatusNormalClosure, "done")
// Do not reassign f.ctx here; the listener method reads
// f.ctx on the pubsub goroutine concurrently. Use a local
// variable instead. The watched context is a child of f.ctx,
// so canceling f.ctx still cascades.
watchCtx := f.wsWatcher.Watch(f.ctx, f.logger, f.conn)
f.enc = wsjson.NewEncoder[codersdk.ProvisionerJobLog](f.conn, websocket.MessageText)
// query for logs once right away, so we can get historical data from before
// subscription
if err := f.query(watchCtx); err != nil {
if watchCtx.Err() == nil && !xerrors.Is(err, io.EOF) {
// neither context expiry, nor EOF, close and log
f.logger.Error(watchCtx, "failed to query logs", slog.Error(err))
err = f.conn.Close(websocket.StatusInternalError, err.Error())
if err != nil {
f.logger.Warn(watchCtx, "failed to close websocket", slog.Error(err))
}
}
return
}
// Log the request immediately instead of after it completes.
if rl := loggermw.RequestLoggerFromContext(watchCtx); rl != nil {
rl.WriteLog(watchCtx, http.StatusAccepted)
}
// no need to wait if the job is done
if f.complete {
return
}
for {
select {
case err := <-f.errors:
// we've dropped at least one notification. This can happen if we
// lose database connectivity. We don't know whether the job is
// now complete since we could have missed the end of logs message.
// We could soldier on and retry, but loss of database connectivity
// is fairly serious, so instead just 500 and bail out. Client
// can retry and hopefully find a healthier node.
f.logger.Error(watchCtx, "dropped or corrupted notification", slog.Error(err))
err = f.conn.Close(websocket.StatusInternalError, err.Error())
if err != nil {
f.logger.Warn(watchCtx, "failed to close websocket", slog.Error(err))
}
return
case <-watchCtx.Done():
// client disconnect or probe failure
return
case n := <-f.notifications:
if n.EndOfLogs {
// safe to return here because we started the subscription,
// and then queried at least once, so we will have already
// gotten all logs prior to the start of our subscription.
return
}
err = f.query(watchCtx)
if err != nil {
if watchCtx.Err() == nil && !xerrors.Is(err, io.EOF) {
// neither context expiry, nor EOF, close and log
f.logger.Error(watchCtx, "failed to query logs", slog.Error(err))
err = f.conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("%s", err.Error()))
if err != nil {
f.logger.Warn(watchCtx, "failed to close websocket", slog.Error(err))
}
}
return
}
}
}
}
func (f *logFollower) listener(_ context.Context, message []byte, err error) {
// in this function we always pair writes to channels with a select on the context
// otherwise we could block a goroutine if the follow() method exits.
if err != nil {
select {
case <-f.ctx.Done():
case f.errors <- err:
}
return
}
var n provisionersdk.ProvisionerJobLogsNotifyMessage
err = json.Unmarshal(message, &n)
if err != nil {
select {
case <-f.ctx.Done():
case f.errors <- err:
}
return
}
select {
case <-f.ctx.Done():
case f.notifications <- n:
}
}
// query fetches the latest job logs from the database and writes them to the
// connection.
func (f *logFollower) query(watchCtx context.Context) error {
f.logger.Debug(watchCtx, "querying logs", slog.F("after", f.after))
logs, err := f.db.GetProvisionerLogsAfterID(watchCtx, database.GetProvisionerLogsAfterIDParams{
JobID: f.jobID,
CreatedAfter: f.after,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("error fetching logs: %w", err)
}
for _, log := range logs {
err := f.enc.Encode(convertProvisionerJobLog(log))
if err != nil {
return xerrors.Errorf("error writing to websocket: %w", err)
}
f.after = log.ID
f.logger.Debug(watchCtx, "wrote log to websocket", slog.F("id", log.ID))
}
return nil
}