feat(cli): add logs cmd (#21430)

This PR adds a command to view the provisioner and agent logs for a
given workspace.
Note: I did investigate using the existing `cliui` methods to tail the
logs but they are tailored to a very specific use-case.

Other changes:
- Adds `Agents` to `dbfake.WorkspaceResponse`
- Adds methods to generate provisioner and agent logs in `dbgen`

---------

Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
This commit is contained in:
Cian Johnston
2026-01-08 09:58:10 +00:00
committed by GitHub
parent 49b34a716a
commit 0f446f99dd
10 changed files with 480 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
package cli
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"time"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
)
func (r *RootCmd) logs() *serpent.Command {
var (
buildNumberArg int64
followArg bool
)
cmd := &serpent.Command{
Use: "logs <workspace>",
Short: "View logs for a workspace",
Long: "View logs for a workspace",
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
),
Options: serpent.OptionSet{
{
Name: "Build Number",
Flag: "build-number",
FlagShorthand: "n",
Description: "Only show logs for a specific build number. Defaults to 0, which maps to the most recent build (build numbers start at 1). Negative values are treated as offsets—for example, -1 refers to the previous build.",
Value: serpent.Int64Of(&buildNumberArg),
Default: "0",
},
{
Name: "Follow",
Flag: "follow",
FlagShorthand: "f",
Description: "Follow logs as they are emitted.",
Value: serpent.BoolOf(&followArg),
Default: "false",
},
},
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
client, err := r.InitClient(inv)
if err != nil {
return err
}
ws, err := namedWorkspace(inv.Context(), client, inv.Args[0])
if err != nil {
return xerrors.Errorf("failed to get workspace: %w", err)
}
bld := ws.LatestBuild
buildNumber := buildNumberArg
// User supplied a negative build number, treat it as an offset from the latest build
if buildNumber < 0 {
buildNumber = int64(ws.LatestBuild.BuildNumber) + buildNumberArg
if buildNumber < 1 {
return xerrors.Errorf("invalid build number offset: %d latest build number: %d", buildNumberArg, ws.LatestBuild.BuildNumber)
}
}
// Fetch specific build if requested
if buildNumber > 0 {
wb, err := client.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(ctx, ws.OwnerName, ws.Name, strconv.FormatInt(buildNumber, 10))
if err != nil {
return xerrors.Errorf("failed to get build %d: %w", buildNumberArg, err)
}
bld = wb
}
cliui.Infof(inv.Stdout, "--- Logs for workspace build #%d (ID: %s Template Version: %s) ---", bld.BuildNumber, bld.ID, bld.TemplateVersionName)
logs, logsCh, err := workspaceLogs(ctx, client, bld, followArg)
if err != nil {
return err
}
for _, log := range logs {
_, _ = fmt.Fprintln(inv.Stdout, log.String())
}
if followArg {
_, _ = fmt.Fprintln(inv.Stdout, "--- Streaming logs ---")
for log := range logsCh {
_, _ = fmt.Fprintln(inv.Stdout, log.String())
}
}
return nil
},
}
return cmd
}
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()
}
// workspaceLogs fetches logs for the given workspace build. If follow is true,
// the returned channel will stream new logs as they are emitted. Otherwise,
// the channel will be closed immediately.
// nolint: revive // control flag is appropriate here
func workspaceLogs(ctx context.Context, client *codersdk.Client, wb codersdk.WorkspaceBuild, follow bool) ([]logLine, <-chan logLine, error) {
logs := make([]logLine, 0)
logsCh := make(chan logLine)
followCh := make(chan logLine)
var fetchGroup, followGroup errgroup.Group
buildLogsAfterCh := make(chan int64)
fetchGroup.Go(func() error {
var afterID int64
defer func() {
if !follow {
return
}
buildLogsAfterCh <- afterID
}()
buildLogsC, closer, err := client.WorkspaceBuildLogsAfter(ctx, wb.ID, 0)
if err != nil {
return xerrors.Errorf("failed to get build logs: %w", err)
}
defer closer.Close()
for log := range buildLogsC {
afterID = log.ID
logsCh <- logLine{
ts: log.CreatedAt,
Content: buildLogToString(log),
}
}
return nil
})
if follow {
followGroup.Go(func() error {
afterID := <-buildLogsAfterCh
buildLogsC, closer, err := client.WorkspaceBuildLogsAfter(ctx, wb.ID, afterID)
if err != nil {
return xerrors.Errorf("failed to follow build logs: %w", err)
}
defer closer.Close()
for log := range buildLogsC {
followCh <- logLine{
ts: log.CreatedAt,
Content: buildLogToString(log),
}
}
return nil
})
}
for _, res := range wb.Resources {
for _, agt := range res.Agents {
logSrcNames := make(map[uuid.UUID]string)
for _, src := range agt.LogSources {
logSrcNames[src.ID] = src.DisplayName
}
agentLogsAfterCh := make(chan int64)
var afterID int64
fetchGroup.Go(func() error {
defer func() {
if !follow {
return
}
agentLogsAfterCh <- afterID
}()
agentLogsCh, closer, err := client.WorkspaceAgentLogsAfter(ctx, agt.ID, 0, false)
if err != nil {
return xerrors.Errorf("failed to get agent logs: %w", err)
}
defer closer.Close()
for logChunk := range agentLogsCh {
for _, log := range logChunk {
afterID = log.ID
logsCh <- logLine{
ts: log.CreatedAt,
Content: workspaceAgentLogToString(log, agt.Name, logSrcNames[log.SourceID]),
}
}
}
return nil
})
if follow {
followGroup.Go(func() error {
afterID := <-agentLogsAfterCh
agentLogsCh, closer, err := client.WorkspaceAgentLogsAfter(ctx, agt.ID, afterID, true)
if err != nil {
return xerrors.Errorf("failed to follow agent logs: %w", err)
}
defer closer.Close()
for logChunk := range agentLogsCh {
for _, log := range logChunk {
followCh <- logLine{
ts: log.CreatedAt,
Content: workspaceAgentLogToString(log, agt.Name, logSrcNames[log.SourceID]),
}
}
}
return nil
})
}
}
}
logsDone := make(chan struct{})
go func() {
defer close(logsDone)
for log := range logsCh {
logs = append(logs, log)
}
}()
err := fetchGroup.Wait()
close(logsCh)
<-logsDone
slices.SortFunc(logs, func(a, b logLine) int {
return a.ts.Compare(b.ts)
})
if follow {
go func() {
_ = followGroup.Wait()
close(followCh)
}()
} else {
close(followCh)
}
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()
}
+115
View File
@@ -0,0 +1,115 @@
package cli_test
import (
"fmt"
"strings"
"testing"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestLogsCmd(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{})
owner := coderdtest.CreateFirstUser(t, client)
memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
testWorkspace := func(t testing.TB, db database.Store, ownerID, orgID uuid.UUID) dbfake.WorkspaceResponse {
wb := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OwnerID: memberUser.ID,
OrganizationID: owner.OrganizationID,
}).WithAgent().Do()
_ = dbgen.ProvisionerJobLog(t, db, database.ProvisionerJobLog{
JobID: wb.Build.JobID,
Output: "test provisioner log for build " + wb.Build.ID.String(),
})
for _, agt := range wb.Agents {
_ = dbgen.WorkspaceAgentLog(t, db, database.WorkspaceAgentLog{
AgentID: agt.ID,
Output: "test agent log for agent " + agt.ID.String(),
})
}
return wb
}
assertLogOutput := func(t testing.TB, wb dbfake.WorkspaceResponse, output string) {
t.Helper()
require.Contains(t, output, "test provisioner log for build "+wb.Build.ID.String())
for _, agt := range wb.Agents {
require.Contains(t, output, "test agent log for agent "+agt.ID.String())
}
}
assertAntagonist := func(t testing.TB, wb dbfake.WorkspaceResponse, output string) {
t.Helper()
require.NotContains(t, output, "test provisioner log for build "+wb.Build.ID.String())
for _, agt := range wb.Agents {
require.NotContains(t, output, "test agent log for agent "+agt.ID.String())
}
}
wb1 := testWorkspace(t, db, memberUser.ID, owner.OrganizationID)
wb2 := testWorkspace(t, db, owner.UserID, owner.OrganizationID)
t.Run("workspace not found", func(t *testing.T) {
t.Parallel()
inv, root := clitest.New(t, "logs", "doesnotexist")
clitest.SetupConfig(t, memberClient, root)
ctx := testutil.Context(t, testutil.WaitShort)
var stdout strings.Builder
inv.Stdout = &stdout
err := inv.WithContext(ctx).Run()
require.ErrorContains(t, err, "Resource not found or you do not have access to this resource")
})
// Note: not testing with --follow as it is inherently racy.
t.Run("current build", func(t *testing.T) {
t.Parallel()
inv, root := clitest.New(t, "logs", wb1.Workspace.Name)
clitest.SetupConfig(t, memberClient, root)
ctx := testutil.Context(t, testutil.WaitShort)
var stdout strings.Builder
inv.Stdout = &stdout
err := inv.WithContext(ctx).Run()
require.NoError(t, err, "failed to fetch logs for current build")
assertLogOutput(t, wb1, stdout.String())
assertAntagonist(t, wb2, stdout.String())
})
t.Run("specific build", func(t *testing.T) {
t.Parallel()
inv, root := clitest.New(t, "logs", wb1.Workspace.Name, "-n", fmt.Sprintf("%d", wb1.Build.BuildNumber))
clitest.SetupConfig(t, memberClient, root)
ctx := testutil.Context(t, testutil.WaitShort)
var stdout strings.Builder
inv.Stdout = &stdout
err := inv.WithContext(ctx).Run()
require.NoError(t, err, "failed to fetch logs for specific build")
assertLogOutput(t, wb1, stdout.String())
assertAntagonist(t, wb2, stdout.String())
})
t.Run("build out of range", func(t *testing.T) {
t.Parallel()
inv, root := clitest.New(t, "logs", wb1.Workspace.Name, "-n", "-9999")
clitest.SetupConfig(t, memberClient, root)
ctx := testutil.Context(t, testutil.WaitShort)
var stdout strings.Builder
inv.Stdout = &stdout
err := inv.WithContext(ctx).Run()
require.ErrorContains(t, err, "invalid build number offset")
})
}
+1
View File
@@ -117,6 +117,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
r.deleteWorkspace(),
r.favorite(),
r.list(),
r.logs(),
r.open(),
r.ping(),
r.rename(),
+1
View File
@@ -28,6 +28,7 @@ SUBCOMMANDS:
list List workspaces
login Authenticate with Coder deployment
logout Unauthenticate your local session
logs View logs for a workspace
netcheck Print network debug information for DERP and STUN
notifications Manage Coder notifications
open Open a workspace
+20
View File
@@ -0,0 +1,20 @@
coder v0.0.0-devel
USAGE:
coder logs [flags] <workspace>
View logs for a workspace
View logs for a workspace
OPTIONS:
-n, --build-number int (default: 0)
Only show logs for a specific build number. Defaults to 0, which maps
to the most recent build (build numbers start at 1). Negative values
are treated as offsets—for example, -1 refers to the previous build.
-f, --follow bool (default: false)
Follow logs as they are emitted.
———
Run `coder --help` for a list of global options.
+3
View File
@@ -38,6 +38,7 @@ var ownerCtx = dbauthz.As(context.Background(), rbac.Subject{
type WorkspaceResponse struct {
Workspace database.WorkspaceTable
Build database.WorkspaceBuild
Agents []database.WorkspaceAgent
AgentToken string
TemplateVersionResponse
Task database.Task
@@ -189,6 +190,7 @@ func (b WorkspaceBuildBuilder) doInTX() WorkspaceResponse {
resp := WorkspaceResponse{
AgentToken: b.agentToken,
Agents: make([]database.WorkspaceAgent, 0),
}
if b.ws.TemplateID == uuid.Nil {
b.logger.Debug(context.Background(), "creating template and version")
@@ -422,6 +424,7 @@ func (b WorkspaceBuildBuilder) doInTX() WorkspaceResponse {
// Insert deleted subagent test antagonists for the workspace build.
// See also `dbgen.WorkspaceAgent()`.
for _, agent := range agents {
resp.Agents = append(resp.Agents, agent)
subAgent := dbgen.WorkspaceSubAgent(b.t, b.db, agent, database.WorkspaceAgent{
TroubleshootingURL: "I AM A TEST ANTAGONIST AND I AM HERE TO MESS UP YOUR TESTS. IF YOU SEE ME, SOMETHING IS WRONG AND SUB AGENT DELETION MAY NOT BE HANDLED CORRECTLY IN A QUERY.",
})
+28
View File
@@ -472,6 +472,20 @@ func WorkspaceAgentLogSource(t testing.TB, db database.Store, orig database.Work
return sources[0]
}
func WorkspaceAgentLog(t testing.TB, db database.Store, orig database.WorkspaceAgentLog) database.WorkspaceAgentLog {
log, err := db.InsertWorkspaceAgentLogs(genCtx, database.InsertWorkspaceAgentLogsParams{
AgentID: takeFirst(orig.AgentID, uuid.New()),
CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()),
LogSourceID: takeFirst(orig.LogSourceID, uuid.New()),
OutputLength: int32(len(orig.Output)), // nolint: gosec // integer overflow is not a concern here
Level: []database.LogLevel{takeFirst(orig.Level, database.LogLevelInfo)},
Output: []string{takeFirst(orig.Output, "Test agent log")},
})
require.NoError(t, err, "insert workspace agent log")
require.Len(t, log, 1, "incorrect number of agent logs returned")
return log[0]
}
func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuild) database.WorkspaceBuild {
t.Helper()
@@ -863,6 +877,20 @@ func ProvisionerJob(t testing.TB, db database.Store, ps pubsub.Pubsub, orig data
return job
}
func ProvisionerJobLog(t testing.TB, db database.Store, orig database.ProvisionerJobLog) database.ProvisionerJobLog {
logs, err := db.InsertProvisionerJobLogs(genCtx, database.InsertProvisionerJobLogsParams{
JobID: takeFirst(orig.JobID, uuid.New()),
CreatedAt: []time.Time{takeFirst(orig.CreatedAt, dbtime.Now())},
Source: []database.LogSource{takeFirst(orig.Source, database.LogSourceProvisioner)},
Level: []database.LogLevel{takeFirst(orig.Level, database.LogLevelInfo)},
Stage: []string{takeFirst(orig.Stage, "Test")},
Output: []string{takeFirst(orig.Output, "Provisioner job log")},
})
require.NoError(t, err, "insert provisioner job log")
require.Len(t, logs, 1, "insert provisioner job log returned incorrect number of logs")
return logs[0]
}
func ProvisionerKey(t testing.TB, db database.Store, orig database.ProvisionerKey) database.ProvisionerKey {
key, err := db.InsertProvisionerKey(genCtx, database.InsertProvisionerKeyParams{
ID: takeFirst(orig.ID, uuid.New()),
+5
View File
@@ -1436,6 +1436,11 @@
"description": "Unauthenticate your local session",
"path": "reference/cli/logout.md"
},
{
"title": "logs",
"description": "View logs for a workspace",
"path": "reference/cli/logs.md"
},
{
"title": "netcheck",
"description": "Print network debug information for DERP and STUN",
+1
View File
@@ -47,6 +47,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr
| [<code>delete</code>](./delete.md) | Delete a workspace |
| [<code>favorite</code>](./favorite.md) | Add a workspace to your favorites |
| [<code>list</code>](./list.md) | List workspaces |
| [<code>logs</code>](./logs.md) | View logs for a workspace |
| [<code>open</code>](./open.md) | Open a workspace |
| [<code>ping</code>](./ping.md) | Ping a workspace |
| [<code>rename</code>](./rename.md) | Rename a workspace |
+36
View File
@@ -0,0 +1,36 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# logs
View logs for a workspace
## Usage
```console
coder logs [flags] <workspace>
```
## Description
```console
View logs for a workspace
```
## Options
### -n, --build-number
| | |
|---------|------------------|
| Type | <code>int</code> |
| Default | <code>0</code> |
Only show logs for a specific build number. Defaults to 0, which maps to the most recent build (build numbers start at 1). Negative values are treated as offsets—for example, -1 refers to the previous build.
### -f, --follow
| | |
|---------|--------------------|
| Type | <code>bool</code> |
| Default | <code>false</code> |
Follow logs as they are emitted.