mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Steven Masley
parent
49b34a716a
commit
0f446f99dd
+270
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -117,6 +117,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
|
||||
r.deleteWorkspace(),
|
||||
r.favorite(),
|
||||
r.list(),
|
||||
r.logs(),
|
||||
r.open(),
|
||||
r.ping(),
|
||||
r.rename(),
|
||||
|
||||
Vendored
+1
@@ -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
@@ -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.
|
||||
Reference in New Issue
Block a user