feat(logs): update 'uc logs' command to support filtering by service/container

This commit is contained in:
Pasha Sviderski
2026-04-22 11:23:21 +10:00
parent cb72f67560
commit 2045819735
8 changed files with 203 additions and 56 deletions
+17 -2
View File
@@ -3,6 +3,7 @@ package logs
import ( import (
"errors" "errors"
"fmt" "fmt"
"image/color"
"os" "os"
"slices" "slices"
"strings" "strings"
@@ -75,7 +76,7 @@ func (f *Formatter) formatMachine(name string) string {
i = len(f.machineNames) - 1 i = len(f.machineNames) - 1
} }
style = style.Foreground(Palette[i%len(Palette)]) style = style.Foreground(palette[i%len(palette)])
} }
return style.Render(name) return style.Render(name)
@@ -93,7 +94,7 @@ func (f *Formatter) formatService(serviceName, containerID string) string {
i = len(f.serviceNames) - 1 i = len(f.serviceNames) - 1
} }
styleService = styleService.Foreground(Palette[i%len(Palette)]) styleService = styleService.Foreground(palette[i%len(palette)])
} }
// Journal logs are unit-scoped and have no container ID. // Journal logs are unit-scoped and have no container ID.
@@ -165,3 +166,17 @@ func (f *Formatter) PrintError(entry api.ServiceLogEntry) {
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) // Bold bright yellow. style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) // Bold bright yellow.
fmt.Fprintln(os.Stderr, style.Render(msg)) fmt.Fprintln(os.Stderr, style.Render(msg))
} }
// palette is available colors for machine/service differentiation.
var palette = []color.Color{
lipgloss.BrightGreen,
lipgloss.BrightYellow,
lipgloss.BrightBlue,
lipgloss.BrightMagenta,
lipgloss.BrightCyan,
lipgloss.Green,
lipgloss.Yellow,
lipgloss.Blue,
lipgloss.Magenta,
lipgloss.Cyan,
}
+56 -14
View File
@@ -2,10 +2,9 @@ package logs
import ( import (
"fmt" "fmt"
"image/color"
"strconv" "strconv"
"strings"
"charm.land/lipgloss/v2"
"github.com/spf13/pflag" "github.com/spf13/pflag"
) )
@@ -59,16 +58,59 @@ func Tail(tail string) (int, error) {
return tailInt, nil return tailInt, nil
} }
// Available colors for machine/service differentiation. // ServiceArg pairs a service name with the list of container filters parsed from `uc service logs` arguments.
var Palette = []color.Color{ // An empty Containers slice means "stream logs from all containers of this service".
lipgloss.BrightGreen, type ServiceArg struct {
lipgloss.BrightYellow, Service string
lipgloss.BrightBlue, Containers []string
lipgloss.BrightMagenta, }
lipgloss.BrightCyan,
lipgloss.Green, // ParseServiceArgs groups raw `uc service logs` positional arguments into per-service entries,
lipgloss.Yellow, // preserving first-seen order. Each argument is either a service name (e.g. "web") or a
lipgloss.Blue, // service/container reference (e.g. "web/61d57fd3428f").
lipgloss.Magenta, // Arguments for the same service are merged: if any argument for a service lacks a container suffix, all containers
lipgloss.Cyan, // of that service are streamed regardless of any other service/container arguments for it.
func ParseServiceArgs(args []string) ([]ServiceArg, error) {
indexByService := make(map[string]int, len(args))
allContainers := make(map[string]bool, len(args))
result := make([]ServiceArg, 0, len(args))
for _, arg := range args {
arg = strings.TrimSpace(arg)
if arg == "" {
return nil, fmt.Errorf("empty service argument")
}
service, container, hasSlash := strings.Cut(arg, "/")
if service == "" {
return nil, fmt.Errorf("invalid service argument '%s': service name is empty", arg)
}
if hasSlash && container == "" {
return nil, fmt.Errorf("invalid service argument '%s': container name or ID is empty", arg)
}
idx, seen := indexByService[service]
if !seen {
entry := ServiceArg{Service: service}
if hasSlash {
entry.Containers = []string{container}
} else {
allContainers[service] = true
}
result = append(result, entry)
indexByService[service] = len(result) - 1
continue
}
if !hasSlash {
result[idx].Containers = nil
allContainers[service] = true
continue
}
if !allContainers[service] {
result[idx].Containers = append(result[idx].Containers, container)
}
}
return result, nil
} }
+35 -15
View File
@@ -20,11 +20,14 @@ func NewLogsCommand(groupID string) *cobra.Command {
var options logs.Options var options logs.Options
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "logs [SERVICE...]", Use: "logs [SERVICE[/CONTAINER]...]",
Aliases: []string{"log"}, Aliases: []string{"log"},
Short: "View service logs.", Short: "View service logs.",
Long: `View logs from all replicas of the specified service(s) across all machines in the cluster. Long: `View logs from all replicas of the specified service(s) across all machines in the cluster.
To view logs from specific replicas (containers) within a service, use the SERVICE/CONTAINER form,
where CONTAINER is a container name, full ID, or unique ID prefix.
If no services are specified, streams logs from all services defined in the Compose file If no services are specified, streams logs from all services defined in the Compose file
(compose.yaml by default or the file(s) specified with --file).`, (compose.yaml by default or the file(s) specified with --file).`,
Example: ` # View recent logs for a service. Example: ` # View recent logs for a service.
@@ -48,6 +51,9 @@ If no services are specified, streams logs from all services defined in the Comp
# View logs from a specific time range. # View logs from a specific time range.
uc logs --since 3h --until 1h30m web uc logs --since 3h --until 1h30m web
# View logs only from specific replicas (containers).
uc logs web/61d57fd3428f api/2f60
# View logs only from replicas running on specific machines. # View logs only from replicas running on specific machines.
uc logs -m machine1,machine2 web api`, uc logs -m machine1,machine2 web api`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -64,10 +70,15 @@ If no services are specified, streams logs from all services defined in the Comp
return cmd return cmd
} }
func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts logs.Options) error { func runLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Options) error {
serviceArgs, err := logs.ParseServiceArgs(args)
if err != nil {
return err
}
// If no services specified, try to load them from the Compose file(s). // If no services specified, try to load them from the Compose file(s).
fromCompose := false fromCompose := false
if len(serviceNames) == 0 { if len(serviceArgs) == 0 {
fromCompose = true fromCompose = true
project, err := compose.LoadProject(ctx, opts.Files) project, err := compose.LoadProject(ctx, opts.Files)
if err != nil { if err != nil {
@@ -77,10 +88,15 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
uncli.SetClusterContextIfUnset(compose.ClusterContext(project)) uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
// View logs for all services, including disabled by inactive profiles. // View logs for all services, including disabled by inactive profiles.
serviceNames = append(project.ServiceNames(), project.DisabledServiceNames()...) composeServices := append(project.ServiceNames(), project.DisabledServiceNames()...)
if len(serviceNames) == 0 { if len(composeServices) == 0 {
return errors.New("no services found in Compose file(s)") return errors.New("no services found in Compose file(s)")
} }
serviceArgs = make([]logs.ServiceArg, len(composeServices))
for i, name := range composeServices {
serviceArgs[i] = logs.ServiceArg{Service: name}
}
} }
// Parse tail option. // Parse tail option.
@@ -95,7 +111,7 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
} }
defer c.Close() defer c.Close()
logsOpts := api.ServiceLogsOptions{ baseOpts := api.ServiceLogsOptions{
Follow: opts.Follow, Follow: opts.Follow,
Tail: tail, Tail: tail,
Since: opts.Since, Since: opts.Since,
@@ -106,20 +122,23 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
// Collect log streams from all services. When service names come from a Compose file, // Collect log streams from all services. When service names come from a Compose file,
// skip the ones that are not found in the cluster (they may have been removed or not deployed yet). // skip the ones that are not found in the cluster (they may have been removed or not deployed yet).
machineIDsSet := mapset.NewSet[string]() machineIDsSet := mapset.NewSet[string]()
svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceNames)) svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceArgs))
var foundServices, notFoundServices []string var foundServices, notFoundServices []string
for _, serviceName := range serviceNames { for _, sa := range serviceArgs {
svc, ch, err := c.ServiceLogs(ctx, serviceName, logsOpts) svcOpts := baseOpts
svcOpts.Containers = sa.Containers
svc, ch, err := c.ServiceLogs(ctx, sa.Service, svcOpts)
if err != nil { if err != nil {
if errors.Is(err, api.ErrNotFound) && fromCompose { if errors.Is(err, api.ErrNotFound) && fromCompose {
notFoundServices = append(notFoundServices, serviceName) notFoundServices = append(notFoundServices, sa.Service)
continue continue
} }
return fmt.Errorf("stream logs for service '%s': %w", serviceName, err) return fmt.Errorf("stream logs for service '%s': %w", sa.Service, err)
} }
svcStreams = append(svcStreams, ch) svcStreams = append(svcStreams, ch)
foundServices = append(foundServices, serviceName) foundServices = append(foundServices, sa.Service)
machineIDs := svc.MachineIDs() machineIDs := svc.MachineIDs()
machineIDsSet.Append(machineIDs...) machineIDsSet.Append(machineIDs...)
@@ -130,7 +149,6 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
return fmt.Errorf("stream logs for services defined in %s: no services found in the cluster", return fmt.Errorf("stream logs for services defined in %s: no services found in the cluster",
strings.Join(opts.Files, ", ")) strings.Join(opts.Files, ", "))
} }
serviceNames = foundServices
for _, name := range notFoundServices { for _, name := range notFoundServices {
tui.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name)) tui.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name))
@@ -138,7 +156,7 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
} }
var stream <-chan api.ServiceLogEntry var stream <-chan api.ServiceLogEntry
if len(serviceNames) == 1 { if len(svcStreams) == 1 {
stream = svcStreams[0] stream = svcStreams[0]
} else { } else {
// Merge all service streams into a single sorted stream without stall detection as its handled per-service. // Merge all service streams into a single sorted stream without stall detection as its handled per-service.
@@ -147,6 +165,8 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
} }
// Fetch machine names for all machines (machineIDsSet) service containers are running on. // Fetch machine names for all machines (machineIDsSet) service containers are running on.
// Note: this is the full set per service, not narrowed by --machine or per-container filters,
// so the formatter may pad columns wider than strictly needed when filters are active.
machines, err := c.ListMachines(ctx, &api.MachineFilter{NamesOrIDs: machineIDsSet.ToSlice()}) machines, err := c.ListMachines(ctx, &api.MachineFilter{NamesOrIDs: machineIDsSet.ToSlice()})
if err != nil { if err != nil {
return fmt.Errorf("list machines: %w", err) return fmt.Errorf("list machines: %w", err)
@@ -156,7 +176,7 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
machineNames = append(machineNames, m.Machine.Name) machineNames = append(machineNames, m.Machine.Name)
} }
formatter := logs.NewFormatter(machineNames, serviceNames, opts.UTC) formatter := logs.NewFormatter(machineNames, foundServices, opts.UTC)
// Print merged logs. // Print merged logs.
for entry := range stream { for entry := range stream {
+5 -1
View File
@@ -46,12 +46,16 @@ func LogStreamTypeToProto(s LogStreamType) pb.LogEntry_StreamType {
} }
} }
// ServiceLogsOptions specifies parameters for ServiceLogs. // ServiceLogsOptions specifies parameters for ServiceLogs and MachineLogs.
type ServiceLogsOptions struct { type ServiceLogsOptions struct {
Follow bool Follow bool
Tail int Tail int
Since string Since string
Until string Until string
// Containers filters logs to only include the specified service containers (names, full IDs,
// or unique ID prefixes). If empty, logs from all containers in the service are included.
// Ignored by MachineLogs.
Containers []string
// Machines filters logs to only include containers running on the specified machines (names or IDs). // Machines filters logs to only include containers running on the specified machines (names or IDs).
// If empty, logs from all machines are included. // If empty, logs from all machines are included.
Machines []string Machines []string
+23
View File
@@ -519,6 +519,29 @@ type MachineServiceContainer struct {
Container ServiceContainer Container ServiceContainer
} }
// FindContainer returns the service container by exact name, ID, or unique ID prefix.
// Returns ErrNotFound if no container matches, or an error if an ID prefix matches more than one container.
func (s *Service) FindContainer(nameOrID string) (MachineServiceContainer, error) {
var prefixMatches []MachineServiceContainer
for _, c := range append(s.Containers, s.HookContainers...) {
if c.Container.ID == nameOrID || c.Container.Name == nameOrID {
return c, nil
}
if strings.HasPrefix(c.Container.ID, nameOrID) {
prefixMatches = append(prefixMatches, c)
}
}
if len(prefixMatches) == 1 {
return prefixMatches[0], nil
}
if len(prefixMatches) > 1 {
return MachineServiceContainer{}, fmt.Errorf("multiple containers found with ID prefix '%s'", nameOrID)
}
return MachineServiceContainer{}, ErrNotFound
}
// MachineIDs returns a list of unique machine IDs where the service containers are running. // MachineIDs returns a list of unique machine IDs where the service containers are running.
func (s *Service) MachineIDs() []string { func (s *Service) MachineIDs() []string {
ids := mapset.NewSet[string]() ids := mapset.NewSet[string]()
+1 -20
View File
@@ -256,26 +256,7 @@ func (cli *Client) InspectContainer(
return api.MachineServiceContainer{}, fmt.Errorf("inspect service: %w", err) return api.MachineServiceContainer{}, fmt.Errorf("inspect service: %w", err)
} }
prefixMatchCandidates := []api.MachineServiceContainer{} return svc.FindContainer(containerNameOrID)
for _, c := range append(svc.Containers, svc.HookContainers...) {
if c.Container.ID == containerNameOrID ||
c.Container.Name == containerNameOrID {
return c, nil
}
if strings.HasPrefix(c.Container.ID, containerNameOrID) {
prefixMatchCandidates = append(prefixMatchCandidates, c)
}
}
if len(prefixMatchCandidates) == 1 {
return prefixMatchCandidates[0], nil
} else if len(prefixMatchCandidates) > 1 {
return api.MachineServiceContainer{}, fmt.Errorf(
"multiple containers found with ID prefix '%s'", containerNameOrID)
}
return api.MachineServiceContainer{}, api.ErrNotFound
} }
// StartContainer starts the specified container within the service. // StartContainer starts the specified container within the service.
+20 -4
View File
@@ -5,6 +5,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"slices"
"github.com/docker/docker/pkg/stringid" "github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/machine/api/pb"
@@ -24,11 +26,25 @@ func (cli *Client) ServiceLogs(
return svc, nil, fmt.Errorf("inspect service: %w", err) return svc, nil, fmt.Errorf("inspect service: %w", err)
} }
allContainers := append(svc.Containers, svc.HookContainers...) containers := append(svc.Containers, svc.HookContainers...)
if len(allContainers) == 0 { if len(containers) == 0 {
return svc, nil, fmt.Errorf("no containers found for service: %s", serviceNameOrID) return svc, nil, fmt.Errorf("no containers found for service: %s", serviceNameOrID)
} }
if len(opts.Containers) > 0 {
selected := make(map[string]api.MachineServiceContainer, len(opts.Containers))
for _, nameOrID := range opts.Containers {
ctr, err := svc.FindContainer(nameOrID)
if err != nil {
return svc, nil, fmt.Errorf("find container '%s' in service '%s': %w",
nameOrID, serviceNameOrID, err)
}
selected[ctr.Container.ID] = ctr
}
containers = slices.Collect(maps.Values(selected))
}
machines, err := cli.ListMachines(ctx, &api.MachineFilter{ machines, err := cli.ListMachines(ctx, &api.MachineFilter{
NamesOrIDs: opts.Machines, NamesOrIDs: opts.Machines,
}) })
@@ -36,8 +52,8 @@ func (cli *Client) ServiceLogs(
return svc, nil, fmt.Errorf("list machines: %w", err) return svc, nil, fmt.Errorf("list machines: %w", err)
} }
ctrStreams := make([]<-chan api.ServiceLogEntry, 0, len(allContainers)) ctrStreams := make([]<-chan api.ServiceLogEntry, 0, len(containers))
for _, ctr := range allContainers { for _, ctr := range containers {
// Skip containers not running on the specified machines. // Skip containers not running on the specified machines.
m := machines.FindByNameOrID(ctr.MachineID) m := machines.FindByNameOrID(ctr.MachineID)
if len(opts.Machines) > 0 && m == nil { if len(opts.Machines) > 0 && m == nil {
+46
View File
@@ -2026,6 +2026,52 @@ func TestServiceLifecycle(t *testing.T) {
}) })
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "machines not found") assert.Contains(t, err.Error(), "machines not found")
// Test filter logs by container.
targetCtr := svc.Containers[0].Container
expectedStdout := fmt.Sprintf("Hello from %s\n", targetCtr.Name)
expectedStderr := fmt.Sprintf("Hello stderr from %s\n", targetCtr.Name)
// Filter by full container ID.
_, ctrStream, err := cli.ServiceLogs(ctx, name, api.ServiceLogsOptions{
Containers: []string{targetCtr.ID},
})
require.NoError(t, err)
assert.Equal(t, []string{expectedStdout, expectedStderr}, collectLogs(ctrStream))
// Filter by container name.
_, ctrStream, err = cli.ServiceLogs(ctx, name, api.ServiceLogsOptions{
Containers: []string{targetCtr.Name},
})
require.NoError(t, err)
assert.Equal(t, []string{expectedStdout, expectedStderr}, collectLogs(ctrStream))
// Filter by short ID prefix (12 chars is Docker's standard short ID length).
_, ctrStream, err = cli.ServiceLogs(ctx, name, api.ServiceLogsOptions{
Containers: []string{targetCtr.ID[:12]},
})
require.NoError(t, err)
assert.Equal(t, []string{expectedStdout, expectedStderr}, collectLogs(ctrStream))
// Filter by multiple containers in the same service.
secondCtr := svc.Containers[1].Container
_, ctrStream, err = cli.ServiceLogs(ctx, name, api.ServiceLogsOptions{
Containers: []string{targetCtr.ID, secondCtr.ID},
})
require.NoError(t, err)
multiLogs := collectLogs(ctrStream)
require.Len(t, multiLogs, 4, "should have 4 log entries from 2 containers")
assert.Contains(t, multiLogs, expectedStdout)
assert.Contains(t, multiLogs, expectedStderr)
assert.Contains(t, multiLogs, fmt.Sprintf("Hello from %s\n", secondCtr.Name))
assert.Contains(t, multiLogs, fmt.Sprintf("Hello stderr from %s\n", secondCtr.Name))
// Test non-existent container filter returns error.
_, _, err = cli.ServiceLogs(ctx, name, api.ServiceLogsOptions{
Containers: []string{"non-existent-container"},
})
require.Error(t, err)
assert.ErrorContains(t, err, "container 'non-existent-container' in service 'test-service-logs': not found")
}) })
t.Run("internal DNS", func(t *testing.T) { t.Run("internal DNS", func(t *testing.T) {