From 353ebd9664784a236bc56f9474aaca5cb78f8fd4 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 3 Feb 2026 09:45:23 +0000 Subject: [PATCH] feat: add link for viewing raw build logs in workspace and template build jobs (#21727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adds support for parameter `format=text` in the following API routes: * `/api/v2/workspaceagents/:id/logs` * `/api/v2/workspacebuilds/:id/logs` * `/api/v2/templateversions/:id/logs` * `/api/v2/templateversions/:id/dry-run/:id/logs` * Adds links to view raw logs on the following pages: * Workspace build page * Template editor page * Template version page * Refactors existing log formatting in `cli/logs.go` to live in `codersdk`. πŸ€– Generated with Claude Opus 4.5, reviewed by me. --------- Co-authored-by: Claude --- cli/logs.go | 58 +---- coderd/apidoc/docs.go | 40 ++++ coderd/apidoc/swagger.json | 28 +++ coderd/database/db2sdk/db2sdk.go | 21 ++ coderd/provisionerjobs.go | 36 ++- coderd/templateversions.go | 2 + coderd/templateversions_test.go | 209 ++++++++++++++++++ coderd/workspaceagents.go | 57 ++++- coderd/workspaceagents_test.go | 93 ++++++++ coderd/workspacebuilds.go | 1 + coderd/workspacebuilds_test.go | 92 ++++++++ codersdk/provisionerdaemons.go | 13 ++ codersdk/workspaceagents.go | 20 ++ codersdk/workspaceagents_test.go | 112 ++++++++++ docs/reference/api/agents.md | 21 +- docs/reference/api/builds.md | 19 +- docs/reference/api/templates.md | 40 ++-- .../TemplateVersionEditor.tsx | 17 +- .../TemplateVersionPageView.tsx | 43 ++-- .../WorkspaceBuildPageView.tsx | 61 +++-- 20 files changed, 863 insertions(+), 120 deletions(-) create mode 100644 codersdk/workspaceagents_test.go diff --git a/cli/logs.go b/cli/logs.go index 928550bf07..11ddd7ba6e 100644 --- a/cli/logs.go +++ b/cli/logs.go @@ -5,7 +5,6 @@ import ( "fmt" "slices" "strconv" - "strings" "time" "github.com/google/uuid" @@ -82,12 +81,12 @@ func (r *RootCmd) logs() *serpent.Command { return err } for _, log := range logs { - _, _ = fmt.Fprintln(inv.Stdout, log.String()) + _, _ = fmt.Fprintln(inv.Stdout, log.text) } if followArg { _, _ = fmt.Fprintln(inv.Stdout, "--- Streaming logs ---") for log := range logsCh { - _, _ = fmt.Fprintln(inv.Stdout, log.String()) + _, _ = fmt.Fprintln(inv.Stdout, log.text) } } return nil @@ -97,15 +96,8 @@ func (r *RootCmd) logs() *serpent.Command { } type logLine struct { - ts time.Time - Content string -} - -func (l *logLine) String() string { - var sb strings.Builder - _, _ = sb.WriteString(l.ts.Format(time.RFC3339)) - _, _ = sb.WriteString(l.Content) - return sb.String() + ts time.Time // for sorting + text string } // workspaceLogs fetches logs for the given workspace build. If follow is true, @@ -136,8 +128,8 @@ func workspaceLogs(ctx context.Context, client *codersdk.Client, wb codersdk.Wor for log := range buildLogsC { afterID = log.ID logsCh <- logLine{ - ts: log.CreatedAt, - Content: buildLogToString(log), + ts: log.CreatedAt, + text: log.Text(), } } return nil @@ -153,8 +145,8 @@ func workspaceLogs(ctx context.Context, client *codersdk.Client, wb codersdk.Wor defer closer.Close() for log := range buildLogsC { followCh <- logLine{ - ts: log.CreatedAt, - Content: buildLogToString(log), + ts: log.CreatedAt, + text: log.Text(), } } return nil @@ -185,8 +177,8 @@ func workspaceLogs(ctx context.Context, client *codersdk.Client, wb codersdk.Wor for _, log := range logChunk { afterID = log.ID logsCh <- logLine{ - ts: log.CreatedAt, - Content: workspaceAgentLogToString(log, agt.Name, logSrcNames[log.SourceID]), + ts: log.CreatedAt, + text: log.Text(agt.Name, logSrcNames[log.SourceID]), } } } @@ -204,8 +196,8 @@ func workspaceLogs(ctx context.Context, client *codersdk.Client, wb codersdk.Wor for logChunk := range agentLogsCh { for _, log := range logChunk { followCh <- logLine{ - ts: log.CreatedAt, - Content: workspaceAgentLogToString(log, agt.Name, logSrcNames[log.SourceID]), + ts: log.CreatedAt, + text: log.Text(agt.Name, logSrcNames[log.SourceID]), } } } @@ -242,29 +234,3 @@ func workspaceLogs(ctx context.Context, client *codersdk.Client, wb codersdk.Wor return logs, followCh, err } - -func buildLogToString(log codersdk.ProvisionerJobLog) string { - var sb strings.Builder - _, _ = sb.WriteString(" [") - _, _ = sb.WriteString(string(log.Level)) - _, _ = sb.WriteString("] [") - _, _ = sb.WriteString("provisioner|") - _, _ = sb.WriteString(log.Stage) - _, _ = sb.WriteString("] ") - _, _ = sb.WriteString(log.Output) - return sb.String() -} - -func workspaceAgentLogToString(log codersdk.WorkspaceAgentLog, agtName, srcName string) string { - var sb strings.Builder - _, _ = sb.WriteString(" [") - _, _ = sb.WriteString(string(log.Level)) - _, _ = sb.WriteString("] [") - _, _ = sb.WriteString("agent.") - _, _ = sb.WriteString(agtName) - _, _ = sb.WriteString("|") - _, _ = sb.WriteString(srcName) - _, _ = sb.WriteString("] ") - _, _ = sb.WriteString(log.Output) - return sb.String() -} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c44f36be86..fd36565afa 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6722,6 +6722,16 @@ const docTemplate = `{ "description": "Follow log stream", "name": "follow", "in": "query" + }, + { + "enum": [ + "json", + "text" + ], + "type": "string", + "description": "Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true.", + "name": "format", + "in": "query" } ], "responses": { @@ -6981,6 +6991,16 @@ const docTemplate = `{ "description": "Follow log stream", "name": "follow", "in": "query" + }, + { + "enum": [ + "json", + "text" + ], + "type": "string", + "description": "Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true.", + "name": "format", + "in": "query" } ], "responses": { @@ -9944,6 +9964,16 @@ const docTemplate = `{ "description": "Disable compression for WebSocket connection", "name": "no_compression", "in": "query" + }, + { + "enum": [ + "json", + "text" + ], + "type": "string", + "description": "Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true.", + "name": "format", + "in": "query" } ], "responses": { @@ -10239,6 +10269,16 @@ const docTemplate = `{ "description": "Follow log stream", "name": "follow", "in": "query" + }, + { + "enum": [ + "json", + "text" + ], + "type": "string", + "description": "Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true.", + "name": "format", + "in": "query" } ], "responses": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 6e9039cea4..0e190021a2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5945,6 +5945,13 @@ "description": "Follow log stream", "name": "follow", "in": "query" + }, + { + "enum": ["json", "text"], + "type": "string", + "description": "Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true.", + "name": "format", + "in": "query" } ], "responses": { @@ -6180,6 +6187,13 @@ "description": "Follow log stream", "name": "follow", "in": "query" + }, + { + "enum": ["json", "text"], + "type": "string", + "description": "Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true.", + "name": "format", + "in": "query" } ], "responses": { @@ -8799,6 +8813,13 @@ "description": "Disable compression for WebSocket connection", "name": "no_compression", "in": "query" + }, + { + "enum": ["json", "text"], + "type": "string", + "description": "Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true.", + "name": "format", + "in": "query" } ], "responses": { @@ -9067,6 +9088,13 @@ "description": "Follow log stream", "name": "follow", "in": "query" + }, + { + "enum": ["json", "text"], + "type": "string", + "description": "Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true.", + "name": "format", + "in": "query" } ], "responses": { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 662bef36f8..0696dfb51c 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -623,6 +623,27 @@ func WorkspaceAppStatus(status database.WorkspaceAppStatus) codersdk.WorkspaceAp } } +func ProvisionerJobLog(log database.ProvisionerJobLog) codersdk.ProvisionerJobLog { + return codersdk.ProvisionerJobLog{ + ID: log.ID, + CreatedAt: log.CreatedAt, + Source: codersdk.LogSource(log.Source), + Level: codersdk.LogLevel(log.Level), + Stage: log.Stage, + Output: log.Output, + } +} + +func WorkspaceAgentLog(log database.WorkspaceAgentLog) codersdk.WorkspaceAgentLog { + return codersdk.WorkspaceAgentLog{ + ID: log.ID, + CreatedAt: log.CreatedAt, + Output: log.Output, + Level: codersdk.LogLevel(log.Level), + SourceID: log.LogSourceID, + } +} + func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.ProvisionerDaemon { result := codersdk.ProvisionerDaemon{ ID: dbDaemon.ID, diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 15b96d40df..1814f932d5 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -157,8 +157,30 @@ func (api *API) provisionerJobLogs(rw http.ResponseWriter, r *http.Request, job 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 != "" { @@ -176,7 +198,7 @@ func (api *API) provisionerJobLogs(rw http.ResponseWriter, r *http.Request, job } if !follow { - fetchAndWriteLogs(ctx, api.Database, job.ID, after, rw) + fetchAndWriteLogs(ctx, api.Database, job.ID, after, rw, format) return } @@ -416,7 +438,7 @@ func convertProvisionerJobWithQueuePosition(pj database.GetProvisionerJobsByOrga return job } -func fetchAndWriteLogs(ctx context.Context, db database.Store, jobID uuid.UUID, after int64, rw http.ResponseWriter) { +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, @@ -431,6 +453,16 @@ func fetchAndWriteLogs(ctx context.Context, db database.Store, jobID uuid.UUID, 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)) } diff --git a/coderd/templateversions.go b/coderd/templateversions.go index f5fadeb605..509274dc25 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -656,6 +656,7 @@ func (api *API) templateVersionDryRunResources(rw http.ResponseWriter, r *http.R // @Param before query int false "Before Unix timestamp" // @Param after query int false "After Unix timestamp" // @Param follow query bool false "Follow log stream" +// @Param format query string false "Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true." Enums(json,text) // @Success 200 {array} codersdk.ProvisionerJobLog // @Router /templateversions/{templateversion}/dry-run/{jobID}/logs [get] func (api *API) templateVersionDryRunLogs(rw http.ResponseWriter, r *http.Request) { @@ -1928,6 +1929,7 @@ func (api *API) templateVersionResources(rw http.ResponseWriter, r *http.Request // @Param before query int false "Before log id" // @Param after query int false "After log id" // @Param follow query bool false "Follow log stream" +// @Param format query string false "Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true." Enums(json,text) // @Success 200 {array} codersdk.ProvisionerJobLog // @Router /templateversions/{templateversion}/logs [get] func (api *API) templateVersionLogs(rw http.ResponseWriter, r *http.Request) { diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index d377787a20..488b094564 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -3,6 +3,9 @@ package coderd_test import ( "bytes" "context" + "encoding/json" + "fmt" + "io" "net/http" "regexp" "strings" @@ -16,9 +19,13 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" "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/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/externalauth" + "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" @@ -996,6 +1003,103 @@ func TestTemplateVersionLogs(t *testing.T) { } } +func TestTemplateVersionLogsFormat(t *testing.T) { + t.Parallel() + + // Setup: Create template version with logs using dbfake. + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + tv := dbfake.TemplateVersion(t, db). + Seed(database.TemplateVersion{ + OrganizationID: user.OrganizationID, + CreatedBy: user.UserID, + }). + Do() + + // Insert test log directly into database. + jl := dbgen.ProvisionerJobLog(t, db, database.ProvisionerJobLog{ + JobID: tv.TemplateVersion.JobID, + Stage: "Planning", + Source: database.LogSourceProvisioner, + Level: database.LogLevelInfo, + Output: "test log output", + }) + + tests := []struct { + name string + queryParams string + expectedStatus int + expectedContentType string + checkBody func(t *testing.T, body string) + }{ + { + name: "JSON", + queryParams: "", + expectedStatus: http.StatusOK, + expectedContentType: "application/json", + checkBody: func(t *testing.T, body string) { + assert.NotEmpty(t, body) // This is checked more thoroughly in TestTemplateVersionLogs above. + }, + }, + { + name: "Text", + queryParams: "?format=text", + expectedStatus: http.StatusOK, + expectedContentType: "text/plain", + checkBody: func(t *testing.T, body string) { + expected := db2sdk.ProvisionerJobLog(jl).Text() + assert.Contains(t, body, expected) + }, + }, + { + name: "InvalidFormat", + queryParams: "?format=invalid", + expectedStatus: http.StatusBadRequest, + checkBody: func(t *testing.T, body string) { + t.Log(body) + var sdkErr codersdk.Error + assert.NoError(t, json.NewDecoder(strings.NewReader(body)).Decode(&sdkErr)) + assert.Equal(t, "Invalid format parameter.", sdkErr.Message) + }, + }, + { + name: "TextWithFollowFails", + queryParams: "?format=text&follow", + expectedStatus: http.StatusBadRequest, + checkBody: func(t *testing.T, body string) { + t.Log(body) + var sdkErr codersdk.Error + assert.NoError(t, json.NewDecoder(strings.NewReader(body)).Decode(&sdkErr)) + assert.Equal(t, "Text format is not supported with follow mode.", sdkErr.Message) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + urlPath := fmt.Sprintf("/api/v2/templateversions/%s/logs%s", tv.TemplateVersion.ID, tt.queryParams) + + res, err := client.Request(ctx, http.MethodGet, urlPath, nil) + require.NoError(t, err) + defer res.Body.Close() + + require.Equal(t, tt.expectedStatus, res.StatusCode) + if tt.expectedContentType != "" { + require.Contains(t, res.Header.Get("Content-Type"), tt.expectedContentType) + } + if assert.NotNil(t, tt.checkBody) { + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + tt.checkBody(t, string(body)) + } + }) + } +} + func TestTemplateVersionsByTemplate(t *testing.T) { t.Parallel() t.Run("Get", func(t *testing.T) { @@ -1461,6 +1565,111 @@ func TestTemplateVersionDryRun(t *testing.T) { }) } +func TestTemplateVersionDryRunLogsFormat(t *testing.T) { + t.Parallel() + + // Setup: Create template version and dry-run job with logs using dbfake. + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + tv := dbfake.TemplateVersion(t, db). + Seed(database.TemplateVersion{ + OrganizationID: user.OrganizationID, + CreatedBy: user.UserID, + }). + Do() + + // Create a dry-run provisioner job. + dryRunInput, err := json.Marshal(provisionerdserver.TemplateVersionDryRunJob{ + TemplateVersionID: tv.TemplateVersion.ID, + }) + require.NoError(t, err) + + dryRunJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + OrganizationID: user.OrganizationID, + InitiatorID: user.UserID, + Type: database.ProvisionerJobTypeTemplateVersionDryRun, + Input: dryRunInput, + }) + + // Insert test log directly into database. + jl := dbgen.ProvisionerJobLog(t, db, database.ProvisionerJobLog{ + JobID: dryRunJob.ID, + Stage: "Planning", + Source: database.LogSourceProvisioner, + Level: database.LogLevelInfo, + Output: "test dry-run log output", + }) + + tests := []struct { + name string + queryParams string + expectedStatus int + expectedContentType string + checkBody func(t *testing.T, body string) + }{ + { + name: "JSON", + queryParams: "", + expectedStatus: http.StatusOK, + expectedContentType: "application/json", + checkBody: func(t *testing.T, body string) { + assert.NotEmpty(t, body) + }, + }, + { + name: "Text", + queryParams: "?format=text", + expectedStatus: http.StatusOK, + expectedContentType: "text/plain", + checkBody: func(t *testing.T, body string) { + expected := db2sdk.ProvisionerJobLog(jl).Text() + assert.Contains(t, body, expected) + }, + }, + { + name: "InvalidFormat", + queryParams: "?format=invalid", + expectedStatus: http.StatusBadRequest, + checkBody: func(t *testing.T, body string) { + assert.Contains(t, body, "Invalid format") + }, + }, + { + name: "TextWithFollowFails", + queryParams: "?format=text&follow", + expectedStatus: http.StatusBadRequest, + checkBody: func(t *testing.T, body string) { + assert.Contains(t, body, "not supported with follow mode") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + urlPath := fmt.Sprintf("/api/v2/templateversions/%s/dry-run/%s/logs%s", tv.TemplateVersion.ID, dryRunJob.ID, tt.queryParams) + + res, err := client.Request(ctx, http.MethodGet, urlPath, nil) + require.NoError(t, err) + defer res.Body.Close() + + require.Equal(t, tt.expectedStatus, res.StatusCode) + if tt.expectedContentType != "" { + require.Contains(t, res.Header.Get("Content-Type"), tt.expectedContentType) + } + + if assert.NotNil(t, tt.checkBody) { + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + tt.checkBody(t, string(body)) + } + }) + } +} + // TestPaginatedTemplateVersions creates a list of template versions and paginate. func TestPaginatedTemplateVersions(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 38e63be3d0..747038506d 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -534,6 +534,7 @@ func (api *API) enqueueAITaskStateNotification( // @Param after query int false "After log id" // @Param follow query bool false "Follow log stream" // @Param no_compression query bool false "Disable compression for WebSocket connection" +// @Param format query string false "Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true." Enums(json,text) // @Success 200 {array} codersdk.WorkspaceAgentLog // @Router /workspaceagents/{workspaceagent}/logs [get] func (api *API) workspaceAgentLogs(rw http.ResponseWriter, r *http.Request) { @@ -545,8 +546,30 @@ func (api *API) workspaceAgentLogs(rw http.ResponseWriter, r *http.Request) { follow = r.URL.Query().Has("follow") afterRaw = r.URL.Query().Get("after") noCompression = r.URL.Query().Has("no_compression") + 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 != "" { @@ -582,6 +605,28 @@ func (api *API) workspaceAgentLogs(rw http.ResponseWriter, r *http.Request) { } if !follow { + if format == "text" { + sids, err := api.Database.GetWorkspaceAgentLogSourcesByAgentIDs(ctx, []uuid.UUID{waws.WorkspaceAgent.ID}) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace agent log sources.", + Detail: err.Error(), + }) + return + } + + lsids := make(map[uuid.UUID]string, len(sids)) + for _, sid := range sids { + lsids[sid.ID] = sid.DisplayName + } + rw.Header().Set("Content-Type", "text/plain; charset=utf-8") + rw.WriteHeader(http.StatusOK) + for _, log := range logs { + _, _ = rw.Write([]byte(db2sdk.WorkspaceAgentLog(log).Text(waws.WorkspaceAgent.Name, lsids[log.LogSourceID]))) + _, _ = rw.Write([]byte("\n")) + } + return + } httpapi.Write(ctx, rw, http.StatusOK, convertWorkspaceAgentLogs(logs)) return } @@ -2375,17 +2420,7 @@ func createExternalAuthResponse(typ, token string, extra pqtype.NullRawMessage) func convertWorkspaceAgentLogs(logs []database.WorkspaceAgentLog) []codersdk.WorkspaceAgentLog { sdk := make([]codersdk.WorkspaceAgentLog, 0, len(logs)) for _, logEntry := range logs { - sdk = append(sdk, convertWorkspaceAgentLog(logEntry)) + sdk = append(sdk, db2sdk.WorkspaceAgentLog(logEntry)) } return sdk } - -func convertWorkspaceAgentLog(logEntry database.WorkspaceAgentLog) codersdk.WorkspaceAgentLog { - return codersdk.WorkspaceAgentLog{ - ID: logEntry.ID, - CreatedAt: logEntry.CreatedAt, - Output: logEntry.Output, - Level: codersdk.LogLevel(logEntry.Level), - SourceID: logEntry.LogSourceID, - } -} diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 3373e2b32b..826615e43d 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "maps" "net/http" "os" @@ -39,6 +40,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "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/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" @@ -337,6 +339,97 @@ func TestWorkspaceAgentLogs(t *testing.T) { }) } +func TestWorkspaceAgentLogsFormat(t *testing.T) { + t.Parallel() + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent().Do() + + workspaceAgent := r.Agents[0] + logSource := dbgen.WorkspaceAgentLogSource(t, db, database.WorkspaceAgentLogSource{ + WorkspaceAgentID: workspaceAgent.ID, + DisplayName: "startup_script", + }) + agentLog := dbgen.WorkspaceAgentLog(t, db, database.WorkspaceAgentLog{ + AgentID: workspaceAgent.ID, + LogSourceID: logSource.ID, + Output: "test log output", + Level: database.LogLevelInfo, + }) + + tests := []struct { + name string + queryParams string + expectedStatus int + expectedContentType string + checkBody func(string) + }{ + { + name: "JSON", + queryParams: "", + expectedStatus: http.StatusOK, + expectedContentType: "application/json", + checkBody: func(body string) { + assert.NotEmpty(t, body) + }, + }, + { + name: "Text", + queryParams: "?format=text", + expectedStatus: http.StatusOK, + expectedContentType: "text/plain", + checkBody: func(body string) { + expected := db2sdk.WorkspaceAgentLog(agentLog).Text(workspaceAgent.Name, logSource.DisplayName) + assert.Contains(t, body, expected) + }, + }, + { + name: "InvalidFormat", + queryParams: "?format=invalid", + expectedStatus: http.StatusBadRequest, + checkBody: func(body string) { + assert.Contains(t, body, "Invalid format") + }, + }, + { + name: "TextWithFollowFails", + queryParams: "?format=text&follow", + expectedStatus: http.StatusBadRequest, + checkBody: func(body string) { + assert.Contains(t, body, "not supported with follow mode") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + urlPath := fmt.Sprintf("/api/v2/workspaceagents/%s/logs%s", workspaceAgent.ID, tt.queryParams) + + res, err := client.Request(ctx, http.MethodGet, urlPath, nil) + require.NoError(t, err) + defer res.Body.Close() + + require.Equal(t, tt.expectedStatus, res.StatusCode) + if tt.expectedContentType != "" { + require.Contains(t, res.Header.Get("Content-Type"), tt.expectedContentType) + } + + if assert.NotNil(t, tt.checkBody) { + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + tt.checkBody(string(body)) + } + }) + } +} + func TestWorkspaceAgentAppStatus(t *testing.T) { t.Parallel() client, db := coderdtest.NewWithDatabase(t, nil) diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 24f8224a36..78eaa303dd 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -826,6 +826,7 @@ func (api *API) workspaceBuildParameters(rw http.ResponseWriter, r *http.Request // @Param before query int false "Before log id" // @Param after query int false "After log id" // @Param follow query bool false "Follow log stream" +// @Param format query string false "Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true." Enums(json,text) // @Success 200 {array} codersdk.ProvisionerJobLog // @Router /workspacebuilds/{workspacebuild}/logs [get] func (api *API) workspaceBuildLogs(rw http.ResponseWriter, r *http.Request) { diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 9985980936..800076eaff 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -6,6 +6,7 @@ import ( "database/sql" "errors" "fmt" + "io" "net/http" "slices" "strconv" @@ -25,6 +26,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "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/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" @@ -1092,6 +1094,96 @@ func TestWorkspaceBuildLogs(t *testing.T) { require.Fail(t, "example message never happened") } +func TestWorkspaceBuildLogsFormat(t *testing.T) { + t.Parallel() + + // Setup: Create workspace build with logs using dbfake. + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).Do() + + // Insert test log directly into database. + jl := dbgen.ProvisionerJobLog(t, db, database.ProvisionerJobLog{ + JobID: r.Build.JobID, + Stage: "Planning", + Source: database.LogSourceProvisioner, + Level: database.LogLevelInfo, + Output: "test log output", + }) + + tests := []struct { + name string + queryParams string + expectedStatus int + expectedContentType string + checkBody func(t *testing.T, body string) + }{ + { + name: "JSON", + queryParams: "", + expectedStatus: http.StatusOK, + expectedContentType: "application/json", + checkBody: func(t *testing.T, body string) { + require.NotEmpty(t, body) + }, + }, + { + name: "Text", + queryParams: "?format=text", + expectedStatus: http.StatusOK, + expectedContentType: "text/plain", + checkBody: func(t *testing.T, body string) { + expected := db2sdk.ProvisionerJobLog(jl).Text() + require.Contains(t, body, expected) + }, + }, + { + name: "InvalidFormat", + queryParams: "?format=invalid", + expectedStatus: http.StatusBadRequest, + checkBody: func(t *testing.T, body string) { + require.Contains(t, body, "Invalid format") + }, + }, + { + name: "TextWithFollowFails", + queryParams: "?format=text&follow", + expectedStatus: http.StatusBadRequest, + checkBody: func(t *testing.T, body string) { + require.Contains(t, body, "not supported with follow mode") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + urlPath := fmt.Sprintf("/api/v2/workspacebuilds/%s/logs%s", r.Build.ID, tt.queryParams) + + res, err := client.Request(ctx, http.MethodGet, urlPath, nil) + require.NoError(t, err) + defer res.Body.Close() + + require.Equal(t, tt.expectedStatus, res.StatusCode) + if tt.expectedContentType != "" { + require.Contains(t, res.Header.Get("Content-Type"), tt.expectedContentType) + } + + if assert.NotNil(t, tt.checkBody) { + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + tt.checkBody(t, string(body)) + } + }) + } +} + func TestWorkspaceBuildState(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 19f8cae546..be51efe013 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -216,6 +216,19 @@ type ProvisionerJobLog struct { Output string `json:"output"` } +// Text formats the log entry as human-readable text. +func (l ProvisionerJobLog) Text() string { + var sb strings.Builder + _, _ = sb.WriteString(l.CreatedAt.Format(time.RFC3339)) + _, _ = sb.WriteString(" [") + _, _ = sb.WriteString(string(l.Level)) + _, _ = sb.WriteString("] [provisioner|") + _, _ = sb.WriteString(l.Stage) + _, _ = sb.WriteString("] ") + _, _ = sb.WriteString(l.Output) + return sb.String() +} + // provisionerJobLogsAfter streams logs that occurred after a specific time. func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after int64) (<-chan ProvisionerJobLog, io.Closer, error) { afterQuery := "" diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index d37629a3fe..a9026baf27 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -217,6 +217,26 @@ type WorkspaceAgentLog struct { SourceID uuid.UUID `json:"source_id" format:"uuid"` } +// Text formats the log entry as human-readable text. +func (l WorkspaceAgentLog) Text(agentName, sourceName string) string { + var sb strings.Builder + _, _ = sb.WriteString(l.CreatedAt.Format(time.RFC3339)) + _, _ = sb.WriteString(" [") + _, _ = sb.WriteString(string(l.Level)) + _, _ = sb.WriteString("] [agent") + if agentName != "" { + _, _ = sb.WriteString(".") + _, _ = sb.WriteString(agentName) + } + if sourceName != "" { + _, _ = sb.WriteString("|") + _, _ = sb.WriteString(sourceName) + } + _, _ = sb.WriteString("] ") + _, _ = sb.WriteString(l.Output) + return sb.String() +} + type AgentSubsystem string const ( diff --git a/codersdk/workspaceagents_test.go b/codersdk/workspaceagents_test.go new file mode 100644 index 0000000000..b44508f06a --- /dev/null +++ b/codersdk/workspaceagents_test.go @@ -0,0 +1,112 @@ +package codersdk_test + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" +) + +func TestProvisionerJobLogText(t *testing.T) { + t.Parallel() + + ts := time.Date(2024, 1, 28, 10, 30, 0, 0, time.UTC) + log := codersdk.ProvisionerJobLog{ + CreatedAt: ts, + Level: codersdk.LogLevelInfo, + Source: codersdk.LogSourceProvisioner, + Stage: "Planning", + Output: "Terraform init complete", + } + result := log.Text() + require.Equal(t, "2024-01-28T10:30:00Z [info] [provisioner|Planning] Terraform init complete", result) +} + +func TestProvisionerJobLogTextEmptyOutput(t *testing.T) { + t.Parallel() + + ts := time.Date(2024, 1, 28, 10, 30, 0, 0, time.UTC) + log := codersdk.ProvisionerJobLog{ + CreatedAt: ts, + Level: codersdk.LogLevelInfo, + Source: codersdk.LogSourceProvisioner, + Stage: "Planning", + Output: "", + } + result := log.Text() + require.Equal(t, "2024-01-28T10:30:00Z [info] [provisioner|Planning] ", result) +} + +func TestProvisionerJobLogTextSpecialChars(t *testing.T) { + t.Parallel() + + ts := time.Date(2024, 1, 28, 10, 30, 0, 0, time.UTC) + log := codersdk.ProvisionerJobLog{ + CreatedAt: ts, + Level: codersdk.LogLevelInfo, + Source: codersdk.LogSourceProvisioner, + Stage: "Applying", + Output: "\033[32mSuccess!\033[0m Unicode: δ½ ε₯½δΈ–η•Œ", + } + result := log.Text() + require.Equal(t, "2024-01-28T10:30:00Z [info] [provisioner|Applying] \033[32mSuccess!\033[0m Unicode: δ½ ε₯½δΈ–η•Œ", result) +} + +func TestWorkspaceAgentLogText(t *testing.T) { + t.Parallel() + + ts := time.Date(2024, 1, 28, 10, 30, 0, 0, time.UTC) + log := codersdk.WorkspaceAgentLog{ + CreatedAt: ts, + Level: codersdk.LogLevelInfo, + Output: "Agent started successfully", + SourceID: uuid.New(), + } + result := log.Text("main", "startup_script") + require.Equal(t, "2024-01-28T10:30:00Z [info] [agent.main|startup_script] Agent started successfully", result) +} + +func TestWorkspaceAgentLogTextEmptySourceAndAgent(t *testing.T) { + t.Parallel() + + ts := time.Date(2024, 1, 28, 10, 30, 0, 0, time.UTC) + log := codersdk.WorkspaceAgentLog{ + CreatedAt: ts, + Level: codersdk.LogLevelWarn, + Output: "Warning message", + SourceID: uuid.New(), + } + result := log.Text("", "") + require.Equal(t, "2024-01-28T10:30:00Z [warn] [agent] Warning message", result) +} + +func TestWorkspaceAgentLogTextMultiline(t *testing.T) { + t.Parallel() + + ts := time.Date(2024, 1, 28, 10, 30, 0, 0, time.UTC) + log := codersdk.WorkspaceAgentLog{ + CreatedAt: ts, + Level: codersdk.LogLevelInfo, + Output: "Line 1\nLine 2\nLine 3", + SourceID: uuid.New(), + } + result := log.Text("main", "startup_script") + require.Equal(t, "2024-01-28T10:30:00Z [info] [agent.main|startup_script] Line 1\nLine 2\nLine 3", result) +} + +func TestWorkspaceAgentLogTextSpecialChars(t *testing.T) { + t.Parallel() + + ts := time.Date(2024, 1, 28, 10, 30, 0, 0, time.UTC) + log := codersdk.WorkspaceAgentLog{ + CreatedAt: ts, + Level: codersdk.LogLevelDebug, + Output: "\033[31mError!\033[0m πŸš€ Unicode: ζ—₯本θͺž", + SourceID: uuid.New(), + } + result := log.Text("main", "startup_script") + require.Equal(t, "2024-01-28T10:30:00Z [debug] [agent.main|startup_script] \033[31mError!\033[0m πŸš€ Unicode: ζ—₯本θͺž", result) +} diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 75b495fcfb..1db9c4cc74 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -1116,13 +1116,20 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/log ### Parameters -| Name | In | Type | Required | Description | -|------------------|-------|--------------|----------|----------------------------------------------| -| `workspaceagent` | path | string(uuid) | true | Workspace agent ID | -| `before` | query | integer | false | Before log id | -| `after` | query | integer | false | After log id | -| `follow` | query | boolean | false | Follow log stream | -| `no_compression` | query | boolean | false | Disable compression for WebSocket connection | +| Name | In | Type | Required | Description | +|------------------|-------|--------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------| +| `workspaceagent` | path | string(uuid) | true | Workspace agent ID | +| `before` | query | integer | false | Before log id | +| `after` | query | integer | false | After log id | +| `follow` | query | boolean | false | Follow log stream | +| `no_compression` | query | boolean | false | Disable compression for WebSocket connection | +| `format` | query | string | false | Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true. | + +#### Enumerated Values + +| Parameter | Value(s) | +|-----------|----------------| +| `format` | `json`, `text` | ### Example responses diff --git a/docs/reference/api/builds.md b/docs/reference/api/builds.md index 1ad978f11d..c52366d8be 100644 --- a/docs/reference/api/builds.md +++ b/docs/reference/api/builds.md @@ -548,12 +548,19 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/log ### Parameters -| Name | In | Type | Required | Description | -|------------------|-------|---------|----------|--------------------| -| `workspacebuild` | path | string | true | Workspace build ID | -| `before` | query | integer | false | Before log id | -| `after` | query | integer | false | After log id | -| `follow` | query | boolean | false | Follow log stream | +| Name | In | Type | Required | Description | +|------------------|-------|---------|----------|---------------------------------------------------------------------------------------------------------------------------------------------| +| `workspacebuild` | path | string | true | Workspace build ID | +| `before` | query | integer | false | Before log id | +| `after` | query | integer | false | After log id | +| `follow` | query | boolean | false | Follow log stream | +| `format` | query | string | false | Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true. | + +#### Enumerated Values + +| Parameter | Value(s) | +|-----------|----------------| +| `format` | `json`, `text` | ### Example responses diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index f55e2c68c0..b59efa8c90 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -2239,13 +2239,20 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/d ### Parameters -| Name | In | Type | Required | Description | -|-------------------|-------|--------------|----------|-----------------------| -| `templateversion` | path | string(uuid) | true | Template version ID | -| `jobID` | path | string(uuid) | true | Job ID | -| `before` | query | integer | false | Before Unix timestamp | -| `after` | query | integer | false | After Unix timestamp | -| `follow` | query | boolean | false | Follow log stream | +| Name | In | Type | Required | Description | +|-------------------|-------|--------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------| +| `templateversion` | path | string(uuid) | true | Template version ID | +| `jobID` | path | string(uuid) | true | Job ID | +| `before` | query | integer | false | Before Unix timestamp | +| `after` | query | integer | false | After Unix timestamp | +| `follow` | query | boolean | false | Follow log stream | +| `format` | query | string | false | Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true. | + +#### Enumerated Values + +| Parameter | Value(s) | +|-----------|----------------| +| `format` | `json`, `text` | ### Example responses @@ -2847,12 +2854,19 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/l ### Parameters -| Name | In | Type | Required | Description | -|-------------------|-------|--------------|----------|---------------------| -| `templateversion` | path | string(uuid) | true | Template version ID | -| `before` | query | integer | false | Before log id | -| `after` | query | integer | false | After log id | -| `follow` | query | boolean | false | Follow log stream | +| Name | In | Type | Required | Description | +|-------------------|-------|--------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------| +| `templateversion` | path | string(uuid) | true | Template version ID | +| `before` | query | integer | false | Before log id | +| `after` | query | integer | false | After log id | +| `follow` | query | boolean | false | Follow log stream | +| `format` | query | string | false | Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true. | + +#### Enumerated Values + +| Parameter | Value(s) | +|-----------|----------------| +| `format` | `json`, `text` | ### Example responses diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx index bc7c9e70fb..970a556f03 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx @@ -582,13 +582,28 @@ export const TemplateVersionEditor: FC = ({ + {selectedTab === "logs" && gotBuildLogs && ( + + View raw logs + + + )} + {selectedTab && ( { setSelectedTab(undefined); }} css={{ - marginLeft: "auto", + marginLeft: + selectedTab !== "logs" || !gotBuildLogs + ? "auto" + : undefined, width: 36, height: 36, borderRadius: 0, diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx index 7c7390682b..2a2a72f598 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx @@ -10,7 +10,7 @@ import { } from "components/PageHeader/PageHeader"; import { Stack } from "components/Stack/Stack"; import { Stats, StatsItem } from "components/Stats/Stats"; -import { EditIcon, PlusIcon } from "lucide-react"; +import { EditIcon, ExternalLinkIcon, PlusIcon } from "lucide-react"; import { linkToTemplate, useLinks } from "modules/navigation"; import { TemplateFiles } from "modules/templates/TemplateFiles/TemplateFiles"; import { TemplateUpdateMessage } from "modules/templates/TemplateUpdateMessage"; @@ -80,21 +80,32 @@ export const TemplateVersionPageView: FC = ({ )} {currentVersion && currentFiles && ( <> - - {templateName} - } - /> - - + +
+ {templateName} + } + /> + + +
+ + View raw logs + +
= ({ - - - - Build - - - {agents.map((a) => ( +
+ + - coder_agent.{a.name} + Build - ))} - - + + {agents.map((a) => ( + + coder_agent.{a.name} + + ))} + + + {tabState.value === "build" && ( + + View raw logs + + + )} + {tabState.value !== "build" && selectedAgent && ( + + View raw logs + + + )} +
{build.transition === "delete" && build.job.status === "failed" && (