Skip to content

Commit 2045819

Browse files
committed
feat(logs): update 'uc logs' command to support filtering by service/container
1 parent cb72f67 commit 2045819

8 files changed

Lines changed: 203 additions & 56 deletions

File tree

‎cmd/uncloud/internal/logs/formatter.go‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package logs
33
import (
44
"errors"
55
"fmt"
6+
"image/color"
67
"os"
78
"slices"
89
"strings"
@@ -75,7 +76,7 @@ func (f *Formatter) formatMachine(name string) string {
7576
i = len(f.machineNames) - 1
7677
}
7778

78-
style = style.Foreground(Palette[i%len(Palette)])
79+
style = style.Foreground(palette[i%len(palette)])
7980
}
8081

8182
return style.Render(name)
@@ -93,7 +94,7 @@ func (f *Formatter) formatService(serviceName, containerID string) string {
9394
i = len(f.serviceNames) - 1
9495
}
9596

96-
styleService = styleService.Foreground(Palette[i%len(Palette)])
97+
styleService = styleService.Foreground(palette[i%len(palette)])
9798
}
9899

99100
// Journal logs are unit-scoped and have no container ID.
@@ -165,3 +166,17 @@ func (f *Formatter) PrintError(entry api.ServiceLogEntry) {
165166
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) // Bold bright yellow.
166167
fmt.Fprintln(os.Stderr, style.Render(msg))
167168
}
169+
170+
// palette is available colors for machine/service differentiation.
171+
var palette = []color.Color{
172+
lipgloss.BrightGreen,
173+
lipgloss.BrightYellow,
174+
lipgloss.BrightBlue,
175+
lipgloss.BrightMagenta,
176+
lipgloss.BrightCyan,
177+
lipgloss.Green,
178+
lipgloss.Yellow,
179+
lipgloss.Blue,
180+
lipgloss.Magenta,
181+
lipgloss.Cyan,
182+
}

‎cmd/uncloud/internal/logs/logs.go‎

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ package logs
22

33
import (
44
"fmt"
5-
"image/color"
65
"strconv"
6+
"strings"
77

8-
"charm.land/lipgloss/v2"
98
"github.com/spf13/pflag"
109
)
1110

@@ -59,16 +58,59 @@ func Tail(tail string) (int, error) {
5958
return tailInt, nil
6059
}
6160

62-
// Available colors for machine/service differentiation.
63-
var Palette = []color.Color{
64-
lipgloss.BrightGreen,
65-
lipgloss.BrightYellow,
66-
lipgloss.BrightBlue,
67-
lipgloss.BrightMagenta,
68-
lipgloss.BrightCyan,
69-
lipgloss.Green,
70-
lipgloss.Yellow,
71-
lipgloss.Blue,
72-
lipgloss.Magenta,
73-
lipgloss.Cyan,
61+
// ServiceArg pairs a service name with the list of container filters parsed from `uc service logs` arguments.
62+
// An empty Containers slice means "stream logs from all containers of this service".
63+
type ServiceArg struct {
64+
Service string
65+
Containers []string
66+
}
67+
68+
// ParseServiceArgs groups raw `uc service logs` positional arguments into per-service entries,
69+
// preserving first-seen order. Each argument is either a service name (e.g. "web") or a
70+
// service/container reference (e.g. "web/61d57fd3428f").
71+
// Arguments for the same service are merged: if any argument for a service lacks a container suffix, all containers
72+
// of that service are streamed regardless of any other service/container arguments for it.
73+
func ParseServiceArgs(args []string) ([]ServiceArg, error) {
74+
indexByService := make(map[string]int, len(args))
75+
allContainers := make(map[string]bool, len(args))
76+
result := make([]ServiceArg, 0, len(args))
77+
78+
for _, arg := range args {
79+
arg = strings.TrimSpace(arg)
80+
if arg == "" {
81+
return nil, fmt.Errorf("empty service argument")
82+
}
83+
84+
service, container, hasSlash := strings.Cut(arg, "/")
85+
if service == "" {
86+
return nil, fmt.Errorf("invalid service argument '%s': service name is empty", arg)
87+
}
88+
if hasSlash && container == "" {
89+
return nil, fmt.Errorf("invalid service argument '%s': container name or ID is empty", arg)
90+
}
91+
92+
idx, seen := indexByService[service]
93+
if !seen {
94+
entry := ServiceArg{Service: service}
95+
if hasSlash {
96+
entry.Containers = []string{container}
97+
} else {
98+
allContainers[service] = true
99+
}
100+
result = append(result, entry)
101+
indexByService[service] = len(result) - 1
102+
continue
103+
}
104+
105+
if !hasSlash {
106+
result[idx].Containers = nil
107+
allContainers[service] = true
108+
continue
109+
}
110+
if !allContainers[service] {
111+
result[idx].Containers = append(result[idx].Containers, container)
112+
}
113+
}
114+
115+
return result, nil
74116
}

‎cmd/uncloud/service/logs.go‎

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@ func NewLogsCommand(groupID string) *cobra.Command {
2020
var options logs.Options
2121

2222
cmd := &cobra.Command{
23-
Use: "logs [SERVICE...]",
23+
Use: "logs [SERVICE[/CONTAINER]...]",
2424
Aliases: []string{"log"},
2525
Short: "View service logs.",
2626
Long: `View logs from all replicas of the specified service(s) across all machines in the cluster.
2727
28+
To view logs from specific replicas (containers) within a service, use the SERVICE/CONTAINER form,
29+
where CONTAINER is a container name, full ID, or unique ID prefix.
30+
2831
If no services are specified, streams logs from all services defined in the Compose file
2932
(compose.yaml by default or the file(s) specified with --file).`,
3033
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
4851
# View logs from a specific time range.
4952
uc logs --since 3h --until 1h30m web
5053
54+
# View logs only from specific replicas (containers).
55+
uc logs web/61d57fd3428f api/2f60
56+
5157
# View logs only from replicas running on specific machines.
5258
uc logs -m machine1,machine2 web api`,
5359
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
6470
return cmd
6571
}
6672

67-
func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts logs.Options) error {
73+
func runLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Options) error {
74+
serviceArgs, err := logs.ParseServiceArgs(args)
75+
if err != nil {
76+
return err
77+
}
78+
6879
// If no services specified, try to load them from the Compose file(s).
6980
fromCompose := false
70-
if len(serviceNames) == 0 {
81+
if len(serviceArgs) == 0 {
7182
fromCompose = true
7283
project, err := compose.LoadProject(ctx, opts.Files)
7384
if err != nil {
@@ -77,10 +88,15 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
7788
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
7889

7990
// View logs for all services, including disabled by inactive profiles.
80-
serviceNames = append(project.ServiceNames(), project.DisabledServiceNames()...)
81-
if len(serviceNames) == 0 {
91+
composeServices := append(project.ServiceNames(), project.DisabledServiceNames()...)
92+
if len(composeServices) == 0 {
8293
return errors.New("no services found in Compose file(s)")
8394
}
95+
96+
serviceArgs = make([]logs.ServiceArg, len(composeServices))
97+
for i, name := range composeServices {
98+
serviceArgs[i] = logs.ServiceArg{Service: name}
99+
}
84100
}
85101

86102
// Parse tail option.
@@ -95,7 +111,7 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
95111
}
96112
defer c.Close()
97113

98-
logsOpts := api.ServiceLogsOptions{
114+
baseOpts := api.ServiceLogsOptions{
99115
Follow: opts.Follow,
100116
Tail: tail,
101117
Since: opts.Since,
@@ -106,20 +122,23 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
106122
// Collect log streams from all services. When service names come from a Compose file,
107123
// skip the ones that are not found in the cluster (they may have been removed or not deployed yet).
108124
machineIDsSet := mapset.NewSet[string]()
109-
svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceNames))
125+
svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceArgs))
110126
var foundServices, notFoundServices []string
111127

112-
for _, serviceName := range serviceNames {
113-
svc, ch, err := c.ServiceLogs(ctx, serviceName, logsOpts)
128+
for _, sa := range serviceArgs {
129+
svcOpts := baseOpts
130+
svcOpts.Containers = sa.Containers
131+
132+
svc, ch, err := c.ServiceLogs(ctx, sa.Service, svcOpts)
114133
if err != nil {
115134
if errors.Is(err, api.ErrNotFound) && fromCompose {
116-
notFoundServices = append(notFoundServices, serviceName)
135+
notFoundServices = append(notFoundServices, sa.Service)
117136
continue
118137
}
119-
return fmt.Errorf("stream logs for service '%s': %w", serviceName, err)
138+
return fmt.Errorf("stream logs for service '%s': %w", sa.Service, err)
120139
}
121140
svcStreams = append(svcStreams, ch)
122-
foundServices = append(foundServices, serviceName)
141+
foundServices = append(foundServices, sa.Service)
123142

124143
machineIDs := svc.MachineIDs()
125144
machineIDsSet.Append(machineIDs...)
@@ -130,15 +149,14 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
130149
return fmt.Errorf("stream logs for services defined in %s: no services found in the cluster",
131150
strings.Join(opts.Files, ", "))
132151
}
133-
serviceNames = foundServices
134152

135153
for _, name := range notFoundServices {
136154
tui.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name))
137155
}
138156
}
139157

140158
var stream <-chan api.ServiceLogEntry
141-
if len(serviceNames) == 1 {
159+
if len(svcStreams) == 1 {
142160
stream = svcStreams[0]
143161
} else {
144162
// 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
147165
}
148166

149167
// Fetch machine names for all machines (machineIDsSet) service containers are running on.
168+
// Note: this is the full set per service, not narrowed by --machine or per-container filters,
169+
// so the formatter may pad columns wider than strictly needed when filters are active.
150170
machines, err := c.ListMachines(ctx, &api.MachineFilter{NamesOrIDs: machineIDsSet.ToSlice()})
151171
if err != nil {
152172
return fmt.Errorf("list machines: %w", err)
@@ -156,7 +176,7 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
156176
machineNames = append(machineNames, m.Machine.Name)
157177
}
158178

159-
formatter := logs.NewFormatter(machineNames, serviceNames, opts.UTC)
179+
formatter := logs.NewFormatter(machineNames, foundServices, opts.UTC)
160180

161181
// Print merged logs.
162182
for entry := range stream {

‎pkg/api/logs.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,16 @@ func LogStreamTypeToProto(s LogStreamType) pb.LogEntry_StreamType {
4646
}
4747
}
4848

49-
// ServiceLogsOptions specifies parameters for ServiceLogs.
49+
// ServiceLogsOptions specifies parameters for ServiceLogs and MachineLogs.
5050
type ServiceLogsOptions struct {
5151
Follow bool
5252
Tail int
5353
Since string
5454
Until string
55+
// Containers filters logs to only include the specified service containers (names, full IDs,
56+
// or unique ID prefixes). If empty, logs from all containers in the service are included.
57+
// Ignored by MachineLogs.
58+
Containers []string
5559
// Machines filters logs to only include containers running on the specified machines (names or IDs).
5660
// If empty, logs from all machines are included.
5761
Machines []string

‎pkg/api/service.go‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,29 @@ type MachineServiceContainer struct {
519519
Container ServiceContainer
520520
}
521521

522+
// FindContainer returns the service container by exact name, ID, or unique ID prefix.
523+
// Returns ErrNotFound if no container matches, or an error if an ID prefix matches more than one container.
524+
func (s *Service) FindContainer(nameOrID string) (MachineServiceContainer, error) {
525+
var prefixMatches []MachineServiceContainer
526+
for _, c := range append(s.Containers, s.HookContainers...) {
527+
if c.Container.ID == nameOrID || c.Container.Name == nameOrID {
528+
return c, nil
529+
}
530+
if strings.HasPrefix(c.Container.ID, nameOrID) {
531+
prefixMatches = append(prefixMatches, c)
532+
}
533+
}
534+
535+
if len(prefixMatches) == 1 {
536+
return prefixMatches[0], nil
537+
}
538+
if len(prefixMatches) > 1 {
539+
return MachineServiceContainer{}, fmt.Errorf("multiple containers found with ID prefix '%s'", nameOrID)
540+
}
541+
542+
return MachineServiceContainer{}, ErrNotFound
543+
}
544+
522545
// MachineIDs returns a list of unique machine IDs where the service containers are running.
523546
func (s *Service) MachineIDs() []string {
524547
ids := mapset.NewSet[string]()

‎pkg/client/container.go‎

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -256,26 +256,7 @@ func (cli *Client) InspectContainer(
256256
return api.MachineServiceContainer{}, fmt.Errorf("inspect service: %w", err)
257257
}
258258

259-
prefixMatchCandidates := []api.MachineServiceContainer{}
260-
for _, c := range append(svc.Containers, svc.HookContainers...) {
261-
if c.Container.ID == containerNameOrID ||
262-
c.Container.Name == containerNameOrID {
263-
return c, nil
264-
}
265-
266-
if strings.HasPrefix(c.Container.ID, containerNameOrID) {
267-
prefixMatchCandidates = append(prefixMatchCandidates, c)
268-
}
269-
}
270-
271-
if len(prefixMatchCandidates) == 1 {
272-
return prefixMatchCandidates[0], nil
273-
} else if len(prefixMatchCandidates) > 1 {
274-
return api.MachineServiceContainer{}, fmt.Errorf(
275-
"multiple containers found with ID prefix '%s'", containerNameOrID)
276-
}
277-
278-
return api.MachineServiceContainer{}, api.ErrNotFound
259+
return svc.FindContainer(containerNameOrID)
279260
}
280261

281262
// StartContainer starts the specified container within the service.

‎pkg/client/logs.go‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"errors"
66
"fmt"
77
"io"
8+
"maps"
9+
"slices"
810

911
"github.com/docker/docker/pkg/stringid"
1012
"github.com/psviderski/uncloud/internal/machine/api/pb"
@@ -24,20 +26,34 @@ func (cli *Client) ServiceLogs(
2426
return svc, nil, fmt.Errorf("inspect service: %w", err)
2527
}
2628

27-
allContainers := append(svc.Containers, svc.HookContainers...)
28-
if len(allContainers) == 0 {
29+
containers := append(svc.Containers, svc.HookContainers...)
30+
if len(containers) == 0 {
2931
return svc, nil, fmt.Errorf("no containers found for service: %s", serviceNameOrID)
3032
}
3133

34+
if len(opts.Containers) > 0 {
35+
selected := make(map[string]api.MachineServiceContainer, len(opts.Containers))
36+
for _, nameOrID := range opts.Containers {
37+
ctr, err := svc.FindContainer(nameOrID)
38+
if err != nil {
39+
return svc, nil, fmt.Errorf("find container '%s' in service '%s': %w",
40+
nameOrID, serviceNameOrID, err)
41+
}
42+
selected[ctr.Container.ID] = ctr
43+
}
44+
45+
containers = slices.Collect(maps.Values(selected))
46+
}
47+
3248
machines, err := cli.ListMachines(ctx, &api.MachineFilter{
3349
NamesOrIDs: opts.Machines,
3450
})
3551
if err != nil {
3652
return svc, nil, fmt.Errorf("list machines: %w", err)
3753
}
3854

39-
ctrStreams := make([]<-chan api.ServiceLogEntry, 0, len(allContainers))
40-
for _, ctr := range allContainers {
55+
ctrStreams := make([]<-chan api.ServiceLogEntry, 0, len(containers))
56+
for _, ctr := range containers {
4157
// Skip containers not running on the specified machines.
4258
m := machines.FindByNameOrID(ctr.MachineID)
4359
if len(opts.Machines) > 0 && m == nil {

0 commit comments

Comments
 (0)