mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
feat(logs): update 'uc logs' command to support filtering by service/container
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user