mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
195 lines
6.0 KiB
Go
195 lines
6.0 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
mapset "github.com/deckarep/golang-set/v2"
|
|
"github.com/psviderski/uncloud/internal/cli"
|
|
"github.com/psviderski/uncloud/internal/cli/completion"
|
|
"github.com/psviderski/uncloud/internal/cli/logs"
|
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
|
"github.com/psviderski/uncloud/pkg/api"
|
|
"github.com/psviderski/uncloud/pkg/client"
|
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func NewLogsCommand(groupID string) *cobra.Command {
|
|
var options logs.Options
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "logs [SERVICE[/CONTAINER]...]",
|
|
Aliases: []string{"log"},
|
|
Short: "View service logs.",
|
|
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
|
|
(compose.yaml by default or the file(s) specified with --file).`,
|
|
Example: ` # View recent logs for a service.
|
|
uc logs web
|
|
|
|
# Stream logs in real-time (follow mode).
|
|
uc logs -f web
|
|
|
|
# View logs from multiple services.
|
|
uc logs web api db
|
|
|
|
# View logs from all services in compose.yaml.
|
|
uc logs
|
|
|
|
# Show last 20 lines per replica (default is 100).
|
|
uc logs -n 20 web
|
|
|
|
# Show all logs without line limit.
|
|
uc logs -n all web
|
|
|
|
# View logs from a specific time range.
|
|
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.
|
|
uc logs -m machine1,machine2 web api`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
|
return RunLogs(cmd.Context(), uncli, args, options)
|
|
},
|
|
GroupID: groupID,
|
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
|
return completion.Services(cmd.Context(), uncli, args, toComplete)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringSliceVar(&options.Files, "file", nil,
|
|
"One or more Compose files to load service names from when no services are specified. (default compose.yaml)")
|
|
|
|
cmd.Flags().AddFlagSet(logs.Flags(&options))
|
|
completion.MachinesFlag(cmd)
|
|
|
|
return cmd
|
|
}
|
|
|
|
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).
|
|
fromCompose := false
|
|
if len(serviceArgs) == 0 {
|
|
fromCompose = true
|
|
project, err := compose.LoadProject(ctx, opts.Files)
|
|
if err != nil {
|
|
return fmt.Errorf("load Compose file(s): %w", err)
|
|
}
|
|
|
|
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
|
|
|
|
// View logs for all services, including disabled by inactive profiles.
|
|
composeServices := append(project.ServiceNames(), project.DisabledServiceNames()...)
|
|
if len(composeServices) == 0 {
|
|
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.
|
|
tail, err := logs.Tail(opts.Tail)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
c, err := uncli.ConnectCluster(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("connect to cluster: %w", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
baseOpts := api.ServiceLogsOptions{
|
|
Follow: opts.Follow,
|
|
Tail: tail,
|
|
Since: opts.Since,
|
|
Until: opts.Until,
|
|
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
|
|
}
|
|
|
|
// 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).
|
|
machineIDsSet := mapset.NewSet[string]()
|
|
svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceArgs))
|
|
var foundServices, notFoundServices []string
|
|
|
|
for _, sa := range serviceArgs {
|
|
svcOpts := baseOpts
|
|
svcOpts.Containers = sa.Containers
|
|
|
|
svc, ch, err := c.ServiceLogs(ctx, sa.Service, svcOpts)
|
|
if err != nil {
|
|
if errors.Is(err, api.ErrNotFound) && fromCompose {
|
|
notFoundServices = append(notFoundServices, sa.Service)
|
|
continue
|
|
}
|
|
return fmt.Errorf("stream logs for service '%s': %w", sa.Service, err)
|
|
}
|
|
svcStreams = append(svcStreams, ch)
|
|
foundServices = append(foundServices, sa.Service)
|
|
|
|
machineIDs := svc.MachineIDs()
|
|
machineIDsSet.Append(machineIDs...)
|
|
}
|
|
|
|
if fromCompose {
|
|
if len(foundServices) == 0 {
|
|
return fmt.Errorf("stream logs for services defined in %s: no services found in the cluster",
|
|
strings.Join(opts.Files, ", "))
|
|
}
|
|
|
|
for _, name := range notFoundServices {
|
|
tui.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name))
|
|
}
|
|
}
|
|
|
|
var stream <-chan api.ServiceLogEntry
|
|
if len(svcStreams) == 1 {
|
|
stream = svcStreams[0]
|
|
} else {
|
|
// Merge all service streams into a single sorted stream without stall detection as its handled per-service.
|
|
merger := client.NewLogMerger(svcStreams, client.LogMergerOptions{})
|
|
stream = merger.Stream()
|
|
}
|
|
|
|
// 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()})
|
|
if err != nil {
|
|
return fmt.Errorf("list machines: %w", err)
|
|
}
|
|
machineNames := make([]string, 0, len(machines))
|
|
for _, m := range machines {
|
|
machineNames = append(machineNames, m.Machine.Name)
|
|
}
|
|
|
|
formatter := logs.NewFormatter(machineNames, foundServices, opts.UTC)
|
|
|
|
// Print merged logs.
|
|
for entry := range stream {
|
|
formatter.PrintEntry(entry)
|
|
}
|
|
|
|
return nil
|
|
}
|