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:
Miek Gieben
2026-04-21 17:27:39 +10:00
committed by GitHub
parent 3a79aeffcc
commit f44ada06b9
11 changed files with 547 additions and 218 deletions
+153
View File
@@ -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))
}
}
+74
View File
@@ -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,
}
+131
View File
@@ -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
}
+1
View File
@@ -14,6 +14,7 @@ func NewRootCommand() *cobra.Command {
NewAddCommand(), NewAddCommand(),
NewInitCommand(), NewInitCommand(),
NewListCommand(), NewListCommand(),
NewLogsCommand(),
NewRenameCommand(), NewRenameCommand(),
NewRmCommand(), NewRmCommand(),
NewRTTCommand(), NewRTTCommand(),
+17 -209
View File
@@ -4,16 +4,10 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"image/color"
"os"
"slices"
"strconv"
"strings" "strings"
"time"
"charm.land/lipgloss/v2"
mapset "github.com/deckarep/golang-set/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"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
@@ -22,18 +16,8 @@ import (
"github.com/spf13/cobra" "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 { func NewLogsCommand(groupID string) *cobra.Command {
var options logsOptions var options logs.Options
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "logs [SERVICE...]", Use: "logs [SERVICE...]",
@@ -73,38 +57,19 @@ If no services are specified, streams logs from all services defined in the Comp
GroupID: groupID, 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)") "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 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). // If no services specified, try to load them from the Compose file(s).
fromCompose := false fromCompose := false
if len(serviceNames) == 0 { if len(serviceNames) == 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 {
return fmt.Errorf("load Compose file(s): %w", err) 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. // Parse tail option.
tail := -1 tail, err := logs.Tail(opts.Tail)
if opts.tail != "all" { if err != nil {
tailInt, err := strconv.Atoi(opts.tail) return err
if err != nil {
return fmt.Errorf("invalid --tail value '%s': %w", opts.tail, err)
}
tail = tailInt
} }
c, err := uncli.ConnectCluster(ctx) c, err := uncli.ConnectCluster(ctx)
@@ -135,11 +96,11 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
defer c.Close() defer c.Close()
logsOpts := api.ServiceLogsOptions{ logsOpts := api.ServiceLogsOptions{
Follow: opts.follow, Follow: opts.Follow,
Tail: tail, Tail: tail,
Since: opts.since, Since: opts.Since,
Until: opts.until, Until: opts.Until,
Machines: cli.ExpandCommaSeparatedValues(opts.machines), Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
} }
// 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,
@@ -167,7 +128,7 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
if fromCompose { if fromCompose {
if len(foundServices) == 0 { if len(foundServices) == 0 {
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 serviceNames = foundServices
@@ -195,169 +156,16 @@ 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 := newLogFormatter(machineNames, serviceNames, opts.utc) formatter := logs.NewFormatter(machineNames, serviceNames, opts.UTC)
// Print merged logs. // Print merged logs.
for entry := range stream { for entry := range stream {
if entry.Err != nil { if entry.Err != nil {
formatter.printError(entry) formatter.PrintError(entry)
continue continue
} }
formatter.printEntry(entry) formatter.PrintEntry(entry)
} }
return nil 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))
}
}
+1 -1
View File
@@ -38,6 +38,7 @@ require (
github.com/psviderski/unregistry v0.4.1 github.com/psviderski/unregistry v0.4.1
github.com/siderolabs/grpc-proxy v0.5.1 github.com/siderolabs/grpc-proxy v0.5.1
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/vishvananda/netlink v1.3.1 github.com/vishvananda/netlink v1.3.1
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba 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/scep v0.0.0-20231024192529-aee96d7ad34d // indirect
github.com/smallstep/truststore v0.13.0 // indirect github.com/smallstep/truststore v0.13.0 // indirect
github.com/spf13/cast v1.7.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/stoewer/go-strcase v1.2.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
+21 -1
View File
@@ -10,11 +10,31 @@ import (
"github.com/psviderski/uncloud/pkg/api" "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" 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) { 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 := []string{"-u", unit, "--no-hostname"}
args = append(args, "-n") args = append(args, "-n")
if opts.Tail > -1 { if opts.Tail > -1 {
+2 -7
View File
@@ -12,12 +12,7 @@ import (
// Logs streams logs from a service and returns entries via a channel. // 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) { func Logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (<-chan api.LogEntry, error) {
// Hard code unit check for now if !ValidUnit(unit) {
switch unit {
case "uncloud":
case "uncloud-corrosion":
case "docker":
default:
return nil, fmt.Errorf("journal logs: invalid unit: %s", unit) return nil, fmt.Errorf("journal logs: invalid unit: %s", unit)
} }
@@ -54,7 +49,7 @@ func entry(data []byte) api.LogEntry {
return api.LogEntry{ return api.LogEntry{
Timestamp: timestamp, 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, Stream: api.LogStreamStdout,
} }
} }
+71
View File
@@ -140,6 +140,77 @@ func (cli *Client) ContainerLogs(
return ch, nil 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. // logsStreamWithServiceMetadata wraps a container logs stream and enriches each log entry with service metadata.
func logsStreamWithServiceMetadata( func logsStreamWithServiceMetadata(
stream <-chan api.LogEntry, metadata api.ServiceLogEntryMetadata, 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](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 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 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 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 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. * [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.