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
co-authored by Claude
parent f75cbab6ce
commit 353ebd9664
20 changed files with 863 additions and 120 deletions
+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()