mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
d5a5be116d
`namedWorkspace` in `cli/root.go` parsed workspace identifiers with `uuid.Parse` first and returned immediately on success, even when no workspace had that UUID as its actual ID. This caused 404 errors for any workspace whose name was a valid 32-char hex string (dashless UUID). - Add `codersdk.ResolveWorkspace`: tries UUID lookup first, falls back to name lookup on 404. `NameValid` guard skips the fallback for standard dashed UUIDs (36 chars > 32-char name limit). - Export `codersdk.SplitWorkspaceIdentifier`, replacing the duplicate `splitNamedWorkspace` in `cli/root.go` (uses `strings.Cut`). - Delete `namedWorkspace` from `cli/root.go`; all 28 call sites now use `client.ResolveWorkspace` directly. - Delete `namedWorkspace` and `splitNameAndOwner` from `codersdk/toolsdk/bash.go`; inline `client.ResolveWorkspace`. - Simplify `GetWorkspace` tool handler to a single `ResolveWorkspace` call. - Unit tests via httptest mock cover UUID, name, owner/name, UUID-like fallback, not-found, server error, transport error, and invalid identifier paths. - Integration tests in `cli/show_test.go` and `codersdk/toolsdk` for workspaces with UUID-like names. > Generated with Coder Agents
237 lines
6.0 KiB
Go
237 lines
6.0 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/sync/errgroup"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/cli/cliui"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
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 := client.ResolveWorkspace(inv.Context(), 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.text)
|
|
}
|
|
if followArg {
|
|
_, _ = fmt.Fprintln(inv.Stdout, "--- Streaming logs ---")
|
|
for log := range logsCh {
|
|
_, _ = fmt.Fprintln(inv.Stdout, log.text)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
type logLine struct {
|
|
ts time.Time // for sorting
|
|
text 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,
|
|
text: log.Text(),
|
|
}
|
|
}
|
|
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,
|
|
text: log.Text(),
|
|
}
|
|
}
|
|
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,
|
|
text: log.Text(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,
|
|
text: log.Text(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
|
|
}
|