mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
Generated
+40
@@ -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": {
|
||||
|
||||
Generated
+28
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user