feat: add link for viewing raw build logs in workspace and template build jobs (#21727)

* 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 <noreply@anthropic.com>
This commit is contained in:
Cian Johnston
2026-02-03 09:45:23 +00:00
committed by GitHub
parent f75cbab6ce
commit 353ebd9664
20 changed files with 863 additions and 120 deletions
+12 -46
View File
@@ -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()
}
+40
View File
@@ -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": {
+28
View File
@@ -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": {
+21
View File
@@ -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,
+34 -2
View File
@@ -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))
}
+2
View File
@@ -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) {
+209
View File
@@ -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()
+46 -11
View File
@@ -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,
}
}
+93
View File
@@ -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)
+1
View File
@@ -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) {
+92
View File
@@ -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})
+13
View File
@@ -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 := ""
+20
View File
@@ -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 (
+112
View File
@@ -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)
}
+14 -7
View File
@@ -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
+13 -6
View File
@@ -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
+27 -13
View File
@@ -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
@@ -582,13 +582,28 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
</button>
</div>
{selectedTab === "logs" && gotBuildLogs && (
<a
href={`/api/v2/templateversions/${templateVersion.id}/logs?format=text`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 px-3 text-xs text-content-secondary hover:text-content-primary"
>
View raw logs
<ExternalLinkIcon className="size-3" />
</a>
)}
{selectedTab && (
<IconButton
onClick={() => {
setSelectedTab(undefined);
}}
css={{
marginLeft: "auto",
marginLeft:
selectedTab !== "logs" || !gotBuildLogs
? "auto"
: undefined,
width: 36,
height: 36,
borderRadius: 0,
@@ -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<TemplateVersionPageViewProps> = ({
)}
{currentVersion && currentFiles && (
<>
<Stats>
<StatsItem
label="Template"
value={
<RouterLink to={templateLink}>{templateName}</RouterLink>
}
/>
<StatsItem
label="Created by"
value={currentVersion.created_by.username}
/>
<StatsItem
label="Created"
value={createDayString(currentVersion.created_at)}
/>
<Stats className="justify-between">
<div className="flex flex-wrap items-center">
<StatsItem
label="Template"
value={
<RouterLink to={templateLink}>{templateName}</RouterLink>
}
/>
<StatsItem
label="Created by"
value={currentVersion.created_by.username}
/>
<StatsItem
label="Created"
value={createDayString(currentVersion.created_at)}
/>
</div>
<a
href={`/api/v2/templateversions/${currentVersion.id}/logs?format=text`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 p-2 text-xs text-content-secondary underline hover:text-content-primary md:py-3.5 md:px-4"
>
View raw logs
<ExternalLinkIcon className="size-3" />
</a>
</Stats>
<TemplateFiles
@@ -16,6 +16,7 @@ import { Stack } from "components/Stack/Stack";
import { Stats, StatsItem } from "components/Stats/Stats";
import { TAB_PADDING_X, TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { ExternalLinkIcon } from "lucide-react";
import { BuildAvatar } from "modules/builds/BuildAvatar/BuildAvatar";
import { DashboardFullPage } from "modules/dashboard/DashboardLayout";
import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs";
@@ -150,28 +151,52 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
</Sidebar>
<ScrollArea>
<Tabs active={tabState.value}>
<TabsList className="gap-0">
<TabLink
to={`?${LOGS_TAB_KEY}=build`}
value="build"
className="px-6 pb-2"
>
Build
</TabLink>
{agents.map((a) => (
<div className="flex items-center justify-between border-b border-solid border-border">
<Tabs active={tabState.value}>
<TabsList className="gap-0">
<TabLink
to={`?${LOGS_TAB_KEY}=build`}
value="build"
className="px-6 pb-2"
to={`?${LOGS_TAB_KEY}=${a.id}`}
value={a.id}
key={a.id}
>
coder_agent.{a.name}
Build
</TabLink>
))}
</TabsList>
</Tabs>
{agents.map((a) => (
<TabLink
className="px-6 pb-2"
to={`?${LOGS_TAB_KEY}=${a.id}`}
value={a.id}
key={a.id}
>
coder_agent.{a.name}
</TabLink>
))}
</TabsList>
</Tabs>
{tabState.value === "build" && (
<a
href={`/api/v2/workspacebuilds/${build.id}/logs?format=text`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 px-4 pb-2 text-xs text-content-secondary hover:text-content-primary"
>
View raw logs
<ExternalLinkIcon className="size-3" />
</a>
)}
{tabState.value !== "build" && selectedAgent && (
<a
href={`/api/v2/workspaceagents/${selectedAgent.id}/logs?format=text`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 px-4 pb-2 text-xs text-content-secondary hover:text-content-primary"
>
View raw logs
<ExternalLinkIcon className="size-3" />
</a>
)}
</div>
{build.transition === "delete" && build.job.status === "failed" && (
<Alert
severity="error"