From 0f446f99ddc8e8cdca4533444777de152f28e1f8 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 8 Jan 2026 09:58:10 +0000 Subject: [PATCH] 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 --- cli/logs.go | 270 ++++++++++++++++++++++++++ cli/logs_test.go | 115 +++++++++++ cli/root.go | 1 + cli/testdata/coder_--help.golden | 1 + cli/testdata/coder_logs_--help.golden | 20 ++ coderd/database/dbfake/dbfake.go | 3 + coderd/database/dbgen/dbgen.go | 28 +++ docs/manifest.json | 5 + docs/reference/cli/index.md | 1 + docs/reference/cli/logs.md | 36 ++++ 10 files changed, 480 insertions(+) create mode 100644 cli/logs.go create mode 100644 cli/logs_test.go create mode 100644 cli/testdata/coder_logs_--help.golden create mode 100644 docs/reference/cli/logs.md diff --git a/cli/logs.go b/cli/logs.go new file mode 100644 index 0000000000..8f6dbb7b0d --- /dev/null +++ b/cli/logs.go @@ -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 ", + 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() +} diff --git a/cli/logs_test.go b/cli/logs_test.go new file mode 100644 index 0000000000..3fcb0e8997 --- /dev/null +++ b/cli/logs_test.go @@ -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") + }) +} diff --git a/cli/root.go b/cli/root.go index 1aa45ae42d..161106d77c 100644 --- a/cli/root.go +++ b/cli/root.go @@ -117,6 +117,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { r.deleteWorkspace(), r.favorite(), r.list(), + r.logs(), r.open(), r.ping(), r.rename(), diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index ab13e2af71..ea4ecdc8c6 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -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 diff --git a/cli/testdata/coder_logs_--help.golden b/cli/testdata/coder_logs_--help.golden new file mode 100644 index 0000000000..ae74999f1a --- /dev/null +++ b/cli/testdata/coder_logs_--help.golden @@ -0,0 +1,20 @@ +coder v0.0.0-devel + +USAGE: + coder logs [flags] + + 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. diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 38d999f112..ce72d74d79 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -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.", }) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 277f84ba2a..6700054808 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -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()), diff --git a/docs/manifest.json b/docs/manifest.json index e28fcab781..87931ab6a4 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -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", diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index b26ec94a7f..35c9bc1636 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -47,6 +47,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr | [delete](./delete.md) | Delete a workspace | | [favorite](./favorite.md) | Add a workspace to your favorites | | [list](./list.md) | List workspaces | +| [logs](./logs.md) | View logs for a workspace | | [open](./open.md) | Open a workspace | | [ping](./ping.md) | Ping a workspace | | [rename](./rename.md) | Rename a workspace | diff --git a/docs/reference/cli/logs.md b/docs/reference/cli/logs.md new file mode 100644 index 0000000000..347378270f --- /dev/null +++ b/docs/reference/cli/logs.md @@ -0,0 +1,36 @@ + +# logs + +View logs for a workspace + +## Usage + +```console +coder logs [flags] +``` + +## Description + +```console +View logs for a workspace +``` + +## Options + +### -n, --build-number + +| | | +|---------|------------------| +| Type | 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 + +| | | +|---------|--------------------| +| Type | bool | +| Default | false | + +Follow logs as they are emitted.