From f44ada06b91f0fbe897b1234f9c91466dc4652d9 Mon Sep 17 00:00:00 2001 From: Miek Gieben Date: Tue, 21 Apr 2026 09:27:39 +0200 Subject: [PATCH] feat(logs): 'uc machine logs' to view logs from systemd services on machines (#283) * logging: client side Create internal/logs and put abstracted way service and machine logging in there This adds the bits to do machine logging of the services. See #273 and discussion in #158 Signed-off-by: Miek Gieben * go mod tidy Signed-off-by: Miek Gieben * fix typo Signed-off-by: Miek Gieben * Hook it up further Signed-off-by: Miek Gieben * Check unit validity server side Signed-off-by: Miek Gieben * Make cli-docs Signed-off-by: Miek Gieben * Add newline to logs Signed-off-by: Miek Gieben * docs Signed-off-by: Miek Gieben * Polish a bit Signed-off-by: Miek Gieben * comment fix Signed-off-by: Miek Gieben * There is no groupid Signed-off-by: Miek Gieben --------- Signed-off-by: Miek Gieben --- cmd/uncloud/internal/logs/formatter.go | 153 ++++++++++++ cmd/uncloud/internal/logs/logs.go | 74 ++++++ cmd/uncloud/machine/logs.go | 131 ++++++++++ cmd/uncloud/machine/root.go | 1 + cmd/uncloud/service/logs.go | 226 ++---------------- go.mod | 2 +- internal/journal/journal.go | 22 +- internal/journal/logs.go | 9 +- pkg/client/logs.go | 71 ++++++ website/docs/9-cli-reference/uc_machine.md | 1 + .../docs/9-cli-reference/uc_machine_logs.md | 75 ++++++ 11 files changed, 547 insertions(+), 218 deletions(-) create mode 100644 cmd/uncloud/internal/logs/formatter.go create mode 100644 cmd/uncloud/internal/logs/logs.go create mode 100644 cmd/uncloud/machine/logs.go create mode 100644 website/docs/9-cli-reference/uc_machine_logs.md diff --git a/cmd/uncloud/internal/logs/formatter.go b/cmd/uncloud/internal/logs/formatter.go new file mode 100644 index 00000000..656657ce --- /dev/null +++ b/cmd/uncloud/internal/logs/formatter.go @@ -0,0 +1,153 @@ +package logs + +import ( + "errors" + "fmt" + "os" + "slices" + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/docker/docker/pkg/stringid" + "github.com/psviderski/uncloud/pkg/api" +) + +// Formatter handles formatting and printing of log entries with dynamic column alignment. +type Formatter struct { + machineNames []string + serviceNames []string + + maxMachineWidth int + maxServiceWidth int + + utc bool +} + +func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter { + slices.Sort(machineNames) + slices.Sort(serviceNames) + + maxMachineWidth := 0 + for _, name := range machineNames { + if len(name) > maxMachineWidth { + maxMachineWidth = len(name) + } + } + + maxServiceWidth := 0 + for _, name := range serviceNames { + if len(name) > maxServiceWidth { + maxServiceWidth = len(name) + } + } + + return &Formatter{ + machineNames: machineNames, + serviceNames: serviceNames, + maxMachineWidth: maxMachineWidth, + maxServiceWidth: maxServiceWidth, + utc: utc, + } +} + +// formatTimestamp formats timestamp using local timezone or UTC if configured. +func (f *Formatter) formatTimestamp(t time.Time) string { + if f.utc { + t = t.UTC() + } else { + t = t.In(time.Local) + } + dimStyle := lipgloss.NewStyle().Faint(true) + + return dimStyle.Render(t.Format(time.StampMilli)) +} + +func (f *Formatter) formatMachine(name string) string { + style := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxMachineWidth - len(name)) + + if len(f.serviceNames) == 1 { + // Machine name is coloured for single-service logs. + i := slices.Index(f.machineNames, name) + if i == -1 { + f.machineNames = append(f.machineNames, name) + i = len(f.machineNames) - 1 + } + + style = style.Foreground(Palette[i%len(Palette)]) + } + + return style.Render(name) +} + +func (f *Formatter) formatServiceContainer(serviceName, containerID string) string { + styleService := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxServiceWidth - len(serviceName)) + styleContainer := lipgloss.NewStyle().Faint(true) + + if len(f.serviceNames) > 1 { + // Service name is coloured for multi-service logs. + i := slices.Index(f.serviceNames, serviceName) + if i == -1 { + f.serviceNames = append(f.serviceNames, serviceName) + i = len(f.serviceNames) - 1 + } + + styleService = styleService.Foreground(Palette[i%len(Palette)]) + } + + return styleService.Render(serviceName) + styleContainer.Render("["+containerID[:5]+"]") +} + +// printEntry prints a single log entry with proper formatting. +func (f *Formatter) PrintEntry(entry api.ServiceLogEntry) { + if entry.Stream != api.LogStreamStdout && entry.Stream != api.LogStreamStderr { + return + } + + var output strings.Builder + + // Timestamp + output.WriteString(f.formatTimestamp(entry.Timestamp)) + output.WriteString(" ") + + // Machine name + output.WriteString(f.formatMachine(entry.Metadata.MachineName)) + output.WriteString(" ") + + // Service[container_id] + output.WriteString(f.formatServiceContainer(entry.Metadata.ServiceName, entry.Metadata.ContainerID)) + output.WriteString(" ") + + // Message + output.Write(entry.Message) + + // Print to appropriate stream. + if entry.Stream == api.LogStreamStderr { + fmt.Fprint(os.Stderr, output.String()) + } else { + fmt.Print(output.String()) + } +} + +// PrintError prints an error entry (e.g., stalled stream warning). +func (f *Formatter) PrintError(entry api.ServiceLogEntry) { + if entry.Metadata.ContainerID != "" { + msg := fmt.Sprintf("WARNING: log stream from %s[%s] on machine '%s'", + entry.Metadata.ServiceName, + stringid.TruncateID(entry.Metadata.ContainerID), + entry.Metadata.MachineName) + + if errors.Is(entry.Err, api.ErrLogStreamStalled) { + msg += " stopped responding" + } else { + msg += fmt.Sprintf(": %v", entry.Err) + } + + style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) // Bold bright yellow + fmt.Fprintln(os.Stderr, style.Render(msg)) + } else { + msg := fmt.Sprintf("ERROR: %v", entry.Err) + style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) // Bold bright red + fmt.Fprintln(os.Stderr, style.Render(msg)) + } +} diff --git a/cmd/uncloud/internal/logs/logs.go b/cmd/uncloud/internal/logs/logs.go new file mode 100644 index 00000000..5ca0d75c --- /dev/null +++ b/cmd/uncloud/internal/logs/logs.go @@ -0,0 +1,74 @@ +package logs + +import ( + "fmt" + "image/color" + "strconv" + + "charm.land/lipgloss/v2" + "github.com/spf13/pflag" +) + +// Options describes how and what logs we are requesting. +type Options struct { + Files []string + Follow bool + Tail string + Since string + Until string + UTC bool + Machines []string +} + +func Flags(options *Options) *pflag.FlagSet { + set := &pflag.FlagSet{} + + set.BoolVarP(&options.Follow, "follow", "f", false, + "Continually stream new logs.") + set.StringSliceVarP(&options.Machines, "machine", "m", nil, + "Filter logs by machine name or ID. Can be specified multiple times or as a comma-separated list.") + set.StringVar(&options.Since, "since", "", + "Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+ + "Examples:\n"+ + " --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+ + " --since 1h Relative duration (1 hour ago)\n"+ + " --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+ + " --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+ + " --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+ + " --since 1763953966 Unix timestamp (seconds since January 1, 1970)") + set.StringVarP(&options.Tail, "tail", "n", "100", + "Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.") + set.StringVar(&options.Until, "until", "", + "Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+ + "See --since for examples.") + set.BoolVar(&options.UTC, "utc", false, + "Print timestamps in UTC instead of local timezone.") + + return set +} + +func Tail(tail string) (int, error) { + if tail == "all" { + return -1, nil + } + + tailInt, err := strconv.Atoi(tail) + if err != nil { + return 0, fmt.Errorf("invalid --tail value '%s': %w", tail, err) + } + return tailInt, nil +} + +// 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, +} diff --git a/cmd/uncloud/machine/logs.go b/cmd/uncloud/machine/logs.go new file mode 100644 index 00000000..666fea3f --- /dev/null +++ b/cmd/uncloud/machine/logs.go @@ -0,0 +1,131 @@ +package machine + +import ( + "context" + "fmt" + + "github.com/psviderski/uncloud/cmd/uncloud/internal/logs" + "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/journal" + "github.com/psviderski/uncloud/pkg/api" + "github.com/psviderski/uncloud/pkg/client" + "github.com/spf13/cobra" +) + +func NewLogsCommand() *cobra.Command { + var options logs.Options + + cmd := &cobra.Command{ + Use: "logs [UNIT...]", + Aliases: []string{"log"}, + Short: "View systemd service logs.", + Long: `View logs from all replicas of the specified units(s) (uncloud, docker or uncloud-corrosion) across all machines in the cluster. + +If no units are specified, streams logs from the uncloud unit.`, + Example: ` # View recent logs for a system service. + uc logs uncloud + + # Stream logs in real-time (follow mode). + uc logs -f uncloud + + # View logs from multiple services. + uc logs web uncloud docker + + # View logs from uncloud + uc logs + + # Show last 20 lines per replica (default is 100). + uc logs -n 20 docker + + # Show all logs without line limit. + uc logs -n all docker + + # View logs from a specific time range. + uc logs --since 3h --until 1h30m docker + + # View logs only from replicas running on specific machines. + uc logs -m machine1,machine2 docker corrosion`, + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + return runLogs(cmd.Context(), uncli, args, options) + }, + } + + cmd.Flags().AddFlagSet(logs.Flags(&options)) + return cmd +} + +func runLogs(ctx context.Context, uncli *cli.CLI, units []string, opts logs.Options) error { + if len(units) == 0 { + units = []string{journal.UnitUncloud} + } + + for _, unit := range units { + if !journal.ValidUnit(unit) { + return fmt.Errorf("invalid unit '%s'", unit) + } + } + + 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() + + logsOpts := api.ServiceLogsOptions{ + Follow: opts.Follow, + Tail: tail, + Since: opts.Since, + Until: opts.Until, + Machines: cli.ExpandCommaSeparatedValues(opts.Machines), + } + + // Fetch machine names for all machines we want the unit logs from. + machines, err := c.ListMachines(ctx, &api.MachineFilter{NamesOrIDs: opts.Machines}) + 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) + } + + // Collect log streams from the units on the machine machines. + unitStreams := make([]<-chan api.ServiceLogEntry, 0, len(units)+len(machines)) + + for _, machine := range machineNames { + for _, unit := range units { + ch, err := c.MachineLogs(ctx, machine, unit, logsOpts) + if err != nil { + return fmt.Errorf("stream logs for systemd service '%s': %w", unit, err) + } + unitStreams = append(unitStreams, ch) + } + } + + var stream <-chan api.ServiceLogEntry + if len(units) == 1 { + stream = unitStreams[0] + } else { + merger := client.NewLogMerger(unitStreams, client.LogMergerOptions{}) + stream = merger.Stream() + } + + formatter := logs.NewFormatter(machineNames, units, opts.UTC) + + // Print merged logs. + for entry := range stream { + if entry.Err != nil { + formatter.PrintError(entry) + continue + } + formatter.PrintEntry(entry) + } + + return nil +} diff --git a/cmd/uncloud/machine/root.go b/cmd/uncloud/machine/root.go index 1cf8156b..3bb70443 100644 --- a/cmd/uncloud/machine/root.go +++ b/cmd/uncloud/machine/root.go @@ -14,6 +14,7 @@ func NewRootCommand() *cobra.Command { NewAddCommand(), NewInitCommand(), NewListCommand(), + NewLogsCommand(), NewRenameCommand(), NewRmCommand(), NewRTTCommand(), diff --git a/cmd/uncloud/service/logs.go b/cmd/uncloud/service/logs.go index e205fe3d..912ec9be 100644 --- a/cmd/uncloud/service/logs.go +++ b/cmd/uncloud/service/logs.go @@ -4,16 +4,10 @@ import ( "context" "errors" "fmt" - "image/color" - "os" - "slices" - "strconv" "strings" - "time" - "charm.land/lipgloss/v2" mapset "github.com/deckarep/golang-set/v2" - "github.com/docker/docker/pkg/stringid" + "github.com/psviderski/uncloud/cmd/uncloud/internal/logs" "github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/pkg/api" @@ -22,18 +16,8 @@ import ( "github.com/spf13/cobra" ) -type logsOptions struct { - files []string - follow bool - tail string - since string - until string - utc bool - machines []string -} - func NewLogsCommand(groupID string) *cobra.Command { - var options logsOptions + var options logs.Options cmd := &cobra.Command{ Use: "logs [SERVICE...]", @@ -73,38 +57,19 @@ If no services are specified, streams logs from all services defined in the Comp GroupID: groupID, } - cmd.Flags().StringSliceVar(&options.files, "file", nil, + 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().BoolVarP(&options.follow, "follow", "f", false, - "Continually stream new logs.") - cmd.Flags().StringSliceVarP(&options.machines, "machine", "m", nil, - "Filter logs by machine name or ID. Can be specified multiple times or as a comma-separated list.") - cmd.Flags().StringVar(&options.since, "since", "", - "Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+ - "Examples:\n"+ - " --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+ - " --since 1h Relative duration (1 hour ago)\n"+ - " --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+ - " --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+ - " --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+ - " --since 1763953966 Unix timestamp (seconds since January 1, 1970)") - cmd.Flags().StringVarP(&options.tail, "tail", "n", "100", - "Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.") - cmd.Flags().StringVar(&options.until, "until", "", - "Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+ - "See --since for examples.") - cmd.Flags().BoolVar(&options.utc, "utc", false, - "Print timestamps in UTC instead of local timezone.") + cmd.Flags().AddFlagSet(logs.Flags(&options)) return cmd } -func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts logsOptions) error { +func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts logs.Options) error { // If no services specified, try to load them from the Compose file(s). fromCompose := false if len(serviceNames) == 0 { fromCompose = true - project, err := compose.LoadProject(ctx, opts.files) + project, err := compose.LoadProject(ctx, opts.Files) if err != nil { return fmt.Errorf("load Compose file(s): %w", err) } @@ -119,13 +84,9 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo } // Parse tail option. - tail := -1 - if opts.tail != "all" { - tailInt, err := strconv.Atoi(opts.tail) - if err != nil { - return fmt.Errorf("invalid --tail value '%s': %w", opts.tail, err) - } - tail = tailInt + tail, err := logs.Tail(opts.Tail) + if err != nil { + return err } c, err := uncli.ConnectCluster(ctx) @@ -135,11 +96,11 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo defer c.Close() logsOpts := api.ServiceLogsOptions{ - Follow: opts.follow, + Follow: opts.Follow, Tail: tail, - Since: opts.since, - Until: opts.until, - Machines: cli.ExpandCommaSeparatedValues(opts.machines), + 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, @@ -167,7 +128,7 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo 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, ", ")) + strings.Join(opts.Files, ", ")) } serviceNames = foundServices @@ -195,169 +156,16 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo machineNames = append(machineNames, m.Machine.Name) } - formatter := newLogFormatter(machineNames, serviceNames, opts.utc) + formatter := logs.NewFormatter(machineNames, serviceNames, opts.UTC) // Print merged logs. for entry := range stream { if entry.Err != nil { - formatter.printError(entry) + formatter.PrintError(entry) continue } - formatter.printEntry(entry) + formatter.PrintEntry(entry) } return nil } - -// Available colors for machine/service differentiation. -var colorPalette = []color.Color{ - lipgloss.BrightGreen, - lipgloss.BrightYellow, - lipgloss.BrightBlue, - lipgloss.BrightMagenta, - lipgloss.BrightCyan, - lipgloss.Green, - lipgloss.Yellow, - lipgloss.Blue, - lipgloss.Magenta, - lipgloss.Cyan, -} - -// logFormatter handles formatting and printing of log entries with dynamic column alignment. -type logFormatter struct { - machineNames []string - serviceNames []string - - maxMachineWidth int - maxServiceWidth int - - utc bool -} - -func newLogFormatter(machineNames, serviceNames []string, utc bool) *logFormatter { - slices.Sort(machineNames) - slices.Sort(serviceNames) - - maxMachineWidth := 0 - for _, name := range machineNames { - if len(name) > maxMachineWidth { - maxMachineWidth = len(name) - } - } - - maxServiceWidth := 0 - for _, name := range serviceNames { - if len(name) > maxServiceWidth { - maxServiceWidth = len(name) - } - } - - return &logFormatter{ - machineNames: machineNames, - serviceNames: serviceNames, - maxMachineWidth: maxMachineWidth, - maxServiceWidth: maxServiceWidth, - utc: utc, - } -} - -// formatTimestamp formats timestamp using local timezone or UTC if configured. -func (f *logFormatter) formatTimestamp(t time.Time) string { - if f.utc { - t = t.UTC() - } else { - t = t.In(time.Local) - } - dimStyle := lipgloss.NewStyle().Faint(true) - - return dimStyle.Render(t.Format(time.StampMilli)) -} - -func (f *logFormatter) formatMachine(name string) string { - style := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxMachineWidth - len(name)) - - if len(f.serviceNames) == 1 { - // Machine name is coloured for single-service logs. - i := slices.Index(f.machineNames, name) - if i == -1 { - f.machineNames = append(f.machineNames, name) - i = len(f.machineNames) - 1 - } - - style = style.Foreground(colorPalette[i%len(colorPalette)]) - } - - return style.Render(name) -} - -func (f *logFormatter) formatServiceContainer(serviceName, containerID string) string { - styleService := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxServiceWidth - len(serviceName)) - styleContainer := lipgloss.NewStyle().Faint(true) - - if len(f.serviceNames) > 1 { - // Service name is coloured for multi-service logs. - i := slices.Index(f.serviceNames, serviceName) - if i == -1 { - f.serviceNames = append(f.serviceNames, serviceName) - i = len(f.serviceNames) - 1 - } - - styleService = styleService.Foreground(colorPalette[i%len(colorPalette)]) - } - - return styleService.Render(serviceName) + styleContainer.Render("["+containerID[:5]+"]") -} - -// printEntry prints a single log entry with proper formatting. -func (f *logFormatter) printEntry(entry api.ServiceLogEntry) { - if entry.Stream != api.LogStreamStdout && entry.Stream != api.LogStreamStderr { - return - } - - var output strings.Builder - - // Timestamp - output.WriteString(f.formatTimestamp(entry.Timestamp)) - output.WriteString(" ") - - // Machine name - output.WriteString(f.formatMachine(entry.Metadata.MachineName)) - output.WriteString(" ") - - // Service[container_id] - output.WriteString(f.formatServiceContainer(entry.Metadata.ServiceName, entry.Metadata.ContainerID)) - output.WriteString(" ") - - // Message - output.Write(entry.Message) - - // Print to appropriate stream. - if entry.Stream == api.LogStreamStderr { - fmt.Fprint(os.Stderr, output.String()) - } else { - fmt.Print(output.String()) - } -} - -// printError prints an error entry (e.g., stalled stream warning). -func (f *logFormatter) printError(entry api.ServiceLogEntry) { - if entry.Metadata.ContainerID != "" { - msg := fmt.Sprintf("WARNING: log stream from %s[%s] on machine '%s'", - entry.Metadata.ServiceName, - stringid.TruncateID(entry.Metadata.ContainerID), - entry.Metadata.MachineName) - - if errors.Is(entry.Err, api.ErrLogStreamStalled) { - msg += " stopped responding" - } else { - msg += fmt.Sprintf(": %v", entry.Err) - } - - style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) // Bold bright yellow - fmt.Fprintln(os.Stderr, style.Render(msg)) - } else { - msg := fmt.Sprintf("ERROR: %v", entry.Err) - style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) // Bold bright red - fmt.Fprintln(os.Stderr, style.Render(msg)) - } -} diff --git a/go.mod b/go.mod index eb1c4ffb..a3936afc 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/psviderski/unregistry v0.4.1 github.com/siderolabs/grpc-proxy v0.5.1 github.com/spf13/cobra v1.10.1 + github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/vishvananda/netlink v1.3.1 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba @@ -273,7 +274,6 @@ require ( github.com/smallstep/scep v0.0.0-20231024192529-aee96d7ad34d // indirect github.com/smallstep/truststore v0.13.0 // indirect github.com/spf13/cast v1.7.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect diff --git a/internal/journal/journal.go b/internal/journal/journal.go index d5233178..b9b9f917 100644 --- a/internal/journal/journal.go +++ b/internal/journal/journal.go @@ -10,11 +10,31 @@ import ( "github.com/psviderski/uncloud/pkg/api" ) +const ( + UnitUncloud = "uncloud" + UnitDocker = "docker" + UnitCorrosion = "uncloud-corrosion" +) + +func ValidUnit(unit string) bool { + switch unit { + case UnitUncloud: + case UnitDocker: + case UnitCorrosion: + default: + return false + } + return true +} + const journalctl = "journalctl" -var commandContext = exec.CommandContext // overidable for the test +var commandContext = exec.CommandContext // allow override for test func logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (io.ReadCloser, error) { + if !ValidUnit(unit) { + return nil, fmt.Errorf("journal logs: invalid unit: %s", unit) + } args := []string{"-u", unit, "--no-hostname"} args = append(args, "-n") if opts.Tail > -1 { diff --git a/internal/journal/logs.go b/internal/journal/logs.go index 4a742259..dfdf8618 100644 --- a/internal/journal/logs.go +++ b/internal/journal/logs.go @@ -12,12 +12,7 @@ import ( // Logs streams logs from a service and returns entries via a channel. func Logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (<-chan api.LogEntry, error) { - // Hard code unit check for now - switch unit { - case "uncloud": - case "uncloud-corrosion": - case "docker": - default: + if !ValidUnit(unit) { return nil, fmt.Errorf("journal logs: invalid unit: %s", unit) } @@ -54,7 +49,7 @@ func entry(data []byte) api.LogEntry { return api.LogEntry{ Timestamp: timestamp, - Message: slices.Clone(message), // scanner controls the buffer + Message: append(slices.Clone(message), '\n'), // scanner controls the buffer so Clone and re-add newline Stream: api.LogStreamStdout, } } diff --git a/pkg/client/logs.go b/pkg/client/logs.go index 75c5a9e6..7fe17431 100644 --- a/pkg/client/logs.go +++ b/pkg/client/logs.go @@ -140,6 +140,77 @@ func (cli *Client) ContainerLogs( return ch, nil } +// MachineLogs streams journal logs from the unit on a specified machine. +func (cli *Client) MachineLogs( + ctx context.Context, machineNameOrID string, unit string, opts api.ServiceLogsOptions, +) (<-chan api.ServiceLogEntry, error) { + proxyCtx, _, err := cli.ProxyMachinesContext(ctx, []string{machineNameOrID}) + if err != nil { + return nil, fmt.Errorf("create request context to proxy to machine '%s': %w", machineNameOrID, err) + } + + req := &pb.LogsRequest{ + Id: unit, + Follow: opts.Follow, + Tail: int32(opts.Tail), + Since: opts.Since, + Until: opts.Until, + } + if !opts.Follow && opts.Tail == 0 { + // If not following and tail is 0, set tail to -1 to return all logs. + // Otherwise, no logs will be returned at all. + req.Tail = -1 + } + + stream, err := cli.MachineClient.MachineLogs(proxyCtx, req) + if err != nil { + return nil, err + } + + // Enrich log entries from the machine with metadata. + metadata := api.ServiceLogEntryMetadata{ + ServiceID: "", + ServiceName: "", + ContainerID: unit, + MachineID: machineNameOrID, + MachineName: machineNameOrID, + } + + ch := make(chan api.LogEntry) + + go func() { + defer close(ch) + + for { + pbEntry, err := stream.Recv() + if err == io.EOF { + return + } + if err != nil { + ch <- api.LogEntry{ + Err: err, + } + return + } + + entry := api.LogEntry{ + Stream: api.LogStreamTypeFromProto(pbEntry.Stream), + Message: pbEntry.Message, + Timestamp: pbEntry.Timestamp.AsTime(), + } + + select { + case ch <- entry: + case <-ctx.Done(): + return + } + } + }() + + enrichedCh := logsStreamWithServiceMetadata(ch, metadata) + return enrichedCh, nil +} + // logsStreamWithServiceMetadata wraps a container logs stream and enriches each log entry with service metadata. func logsStreamWithServiceMetadata( stream <-chan api.LogEntry, metadata api.ServiceLogEntryMetadata, diff --git a/website/docs/9-cli-reference/uc_machine.md b/website/docs/9-cli-reference/uc_machine.md index 0a777ed3..98d936b4 100644 --- a/website/docs/9-cli-reference/uc_machine.md +++ b/website/docs/9-cli-reference/uc_machine.md @@ -22,6 +22,7 @@ Manage machines in the cluster. * [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes. * [uc machine add](uc_machine_add.md) - Add a remote machine to a cluster. * [uc machine init](uc_machine_init.md) - Initialise a new cluster with a remote machine as the first member. +* [uc machine logs](uc_machine_logs.md) - View systemd service logs. * [uc machine ls](uc_machine_ls.md) - List machines in a cluster. * [uc machine rename](uc_machine_rename.md) - Rename a machine in the cluster. * [uc machine rm](uc_machine_rm.md) - Remove a machine from a cluster and reset it. diff --git a/website/docs/9-cli-reference/uc_machine_logs.md b/website/docs/9-cli-reference/uc_machine_logs.md new file mode 100644 index 00000000..a1f37328 --- /dev/null +++ b/website/docs/9-cli-reference/uc_machine_logs.md @@ -0,0 +1,75 @@ +# uc machine logs + +View systemd service logs. + +## Synopsis + +View logs from all replicas of the specified units(s) (uncloud, docker or uncloud-corrosion) across all machines in the cluster. + +If no units are specified, streams logs from the uncloud unit. + +``` +uc machine logs [UNIT...] [flags] +``` + +## Examples + +``` + # View recent logs for a system service. + uc logs uncloud + + # Stream logs in real-time (follow mode). + uc logs -f uncloud + + # View logs from multiple services. + uc logs web uncloud docker + + # View logs from uncloud + uc logs + + # Show last 20 lines per replica (default is 100). + uc logs -n 20 docker + + # Show all logs without line limit. + uc logs -n all docker + + # View logs from a specific time range. + uc logs --since 3h --until 1h30m docker + + # View logs only from replicas running on specific machines. + uc logs -m machine1,machine2 docker corrosion +``` + +## Options + +``` + -f, --follow Continually stream new logs. + -h, --help help for logs + -m, --machine strings Filter logs by machine name or ID. Can be specified multiple times or as a comma-separated list. + --since string Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp. + Examples: + --since 2m30s Relative duration (2 minutes 30 seconds ago) + --since 1h Relative duration (1 hour ago) + --since 2025-11-24 RFC 3339 date only (midnight using local timezone) + --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone + --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC + --since 1763953966 Unix timestamp (seconds since January 1, 1970) + -n, --tail string Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs. (default "100") + --until string Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp. + See --since for examples. + --utc Print timestamps in UTC instead of local timezone. +``` + +## Options inherited from parent commands + +``` + --connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT] + Format: [ssh://]user@host[:port], ssh+go://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock + -c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT] + --uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml") +``` + +## See also + +* [uc machine](uc_machine.md) - Manage machines in the cluster. +