mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00: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:
+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.
|
||||
@@ -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.",
|
||||
})
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1
@@ -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 |
|
||||
|
||||
Generated
+36
@@ -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.
|
||||
Reference in New Issue
Block a user