mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
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 <miek@miek.nl> * go mod tidy Signed-off-by: Miek Gieben <miek@miek.nl> * fix typo Signed-off-by: Miek Gieben <miek@miek.nl> * Hook it up further Signed-off-by: Miek Gieben <miek@miek.nl> * Check unit validity server side Signed-off-by: Miek Gieben <miek@miek.nl> * Make cli-docs Signed-off-by: Miek Gieben <miek@miek.nl> * Add newline to logs Signed-off-by: Miek Gieben <miek@miek.nl> * docs Signed-off-by: Miek Gieben <miek@miek.nl> * Polish a bit Signed-off-by: Miek Gieben <miek@miek.nl> * comment fix Signed-off-by: Miek Gieben <miek@miek.nl> * There is no groupid Signed-off-by: Miek Gieben <miek@miek.nl> --------- Signed-off-by: Miek Gieben <miek@miek.nl>
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -14,6 +14,7 @@ func NewRootCommand() *cobra.Command {
|
||||
NewAddCommand(),
|
||||
NewInitCommand(),
|
||||
NewListCommand(),
|
||||
NewLogsCommand(),
|
||||
NewRenameCommand(),
|
||||
NewRmCommand(),
|
||||
NewRTTCommand(),
|
||||
|
||||
+17
-209
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user