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(),
|
NewAddCommand(),
|
||||||
NewInitCommand(),
|
NewInitCommand(),
|
||||||
NewListCommand(),
|
NewListCommand(),
|
||||||
|
NewLogsCommand(),
|
||||||
NewRenameCommand(),
|
NewRenameCommand(),
|
||||||
NewRmCommand(),
|
NewRmCommand(),
|
||||||
NewRTTCommand(),
|
NewRTTCommand(),
|
||||||
|
|||||||
+17
-209
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
Reference in New Issue
Block a user