mirror of
https://github.com/psviderski/uncloud.git
synced 2026-10-07 05:39:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce1c6bd05a | ||
|
|
11e1200fe8 | ||
|
|
0cdf721a6a | ||
|
|
5e26cfbead | ||
|
|
409dc3bfe3 | ||
|
|
5fa236871f | ||
|
|
e3479409b1 | ||
|
|
a1b6b31e9c | ||
|
|
d04772dacd | ||
|
|
87691ec029 | ||
|
|
7ecf63169b |
No files matched your search
+1
-1
@@ -305,7 +305,7 @@ const defaultFailedContainerLogsTail = 10
|
|||||||
// UNCLOUD_FAILED_CONTAINER_LOGS_TAIL environment variable override when set and valid.
|
// UNCLOUD_FAILED_CONTAINER_LOGS_TAIL environment variable override when set and valid.
|
||||||
func failedContainerLogsTail() int {
|
func failedContainerLogsTail() int {
|
||||||
if v := os.Getenv("UNCLOUD_FAILED_CONTAINER_LOGS_TAIL"); v != "" {
|
if v := os.Getenv("UNCLOUD_FAILED_CONTAINER_LOGS_TAIL"); v != "" {
|
||||||
if tail, err := logs.Tail(v); err == nil && (tail == -1 || tail > 0) {
|
if tail, err := logs.ParseTail(v); err == nil && (tail == -1 || tail > 0) {
|
||||||
return tail
|
return tail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-7
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||||
@@ -64,6 +65,15 @@ If no services are specified, streams logs from the uncloud service.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.Options) error {
|
func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.Options) error {
|
||||||
|
since, until, err := logs.TimeRange(opts.Since, opts.Until, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tail, err := opts.TailLines()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if len(services) == 0 {
|
if len(services) == 0 {
|
||||||
services = []string{api.SystemServiceUncloud}
|
services = []string{api.SystemServiceUncloud}
|
||||||
}
|
}
|
||||||
@@ -74,11 +84,6 @@ func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.O
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tail, err := logs.Tail(opts.Tail)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := uncli.ConnectCluster(ctx)
|
c, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
@@ -88,8 +93,8 @@ func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.O
|
|||||||
logsOpts := api.ServiceLogsOptions{
|
logsOpts := api.ServiceLogsOptions{
|
||||||
Follow: opts.Follow,
|
Follow: opts.Follow,
|
||||||
Tail: tail,
|
Tail: tail,
|
||||||
Since: opts.Since,
|
Since: since,
|
||||||
Until: opts.Until,
|
Until: until,
|
||||||
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
|
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/logs"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunLogsInvalidTimeFilters(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Invalid filters must fail before connecting to the cluster.
|
||||||
|
for _, flag := range []string{"since", "until"} {
|
||||||
|
opts := logs.Options{}
|
||||||
|
if flag == "since" {
|
||||||
|
opts.Since = "invalid"
|
||||||
|
} else {
|
||||||
|
opts.Until = "invalid"
|
||||||
|
}
|
||||||
|
err := runLogs(context.Background(), nil, nil, opts)
|
||||||
|
require.ErrorContains(t, err, "invalid --"+flag+" value")
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-8
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
mapset "github.com/deckarep/golang-set/v2"
|
mapset "github.com/deckarep/golang-set/v2"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
@@ -78,6 +79,15 @@ If no services are specified, streams logs from all services defined in the Comp
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Options) error {
|
func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Options) error {
|
||||||
|
since, until, err := logs.TimeRange(opts.Since, opts.Until, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tail, err := opts.TailLines()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
serviceArgs, err := logs.ParseServiceArgs(args)
|
serviceArgs, err := logs.ParseServiceArgs(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -106,12 +116,6 @@ func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Optio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse tail option.
|
|
||||||
tail, err := logs.Tail(opts.Tail)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := uncli.ConnectCluster(ctx)
|
c, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
@@ -121,8 +125,8 @@ func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Optio
|
|||||||
baseOpts := api.ServiceLogsOptions{
|
baseOpts := api.ServiceLogsOptions{
|
||||||
Follow: opts.Follow,
|
Follow: opts.Follow,
|
||||||
Tail: tail,
|
Tail: tail,
|
||||||
Since: opts.Since,
|
Since: since,
|
||||||
Until: opts.Until,
|
Until: until,
|
||||||
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
|
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/logs"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunLogsInvalidTimeFilters(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Invalid filters must fail before loading Compose files or connecting to the cluster.
|
||||||
|
for _, flag := range []string{"since", "until"} {
|
||||||
|
opts := logs.Options{}
|
||||||
|
if flag == "since" {
|
||||||
|
opts.Since = "invalid"
|
||||||
|
} else {
|
||||||
|
opts.Until = "invalid"
|
||||||
|
}
|
||||||
|
err := RunLogs(context.Background(), nil, nil, opts)
|
||||||
|
require.ErrorContains(t, err, "invalid --"+flag+" value")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Duration is an extended standard time.Duration that also supports days as a unit.
|
||||||
|
type Duration = time.Duration
|
||||||
|
|
||||||
|
// ParseDuration parses a Go duration with the additional unit d, meaning exactly 24 hours.
|
||||||
|
// Days can be fractional or combined with other units, such as "1.5d" or "2d3h".
|
||||||
|
func ParseDuration(value string) (Duration, error) {
|
||||||
|
if !strings.Contains(value, "d") {
|
||||||
|
return time.ParseDuration(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := value
|
||||||
|
var normalised strings.Builder
|
||||||
|
if len(rest) > 0 && (rest[0] == '-' || rest[0] == '+') {
|
||||||
|
normalised.WriteByte(rest[0])
|
||||||
|
rest = rest[1:]
|
||||||
|
}
|
||||||
|
for len(rest) > 0 {
|
||||||
|
// Each component is a decimal number followed by a unit. Only the leading sign is allowed.
|
||||||
|
numberEnd := 0
|
||||||
|
for numberEnd < len(rest) && (isDigit(rest[numberEnd]) || rest[numberEnd] == '.') {
|
||||||
|
numberEnd++
|
||||||
|
}
|
||||||
|
unitEnd := numberEnd
|
||||||
|
for unitEnd < len(rest) && !isDigit(rest[unitEnd]) && rest[unitEnd] != '.' {
|
||||||
|
unitEnd++
|
||||||
|
}
|
||||||
|
if numberEnd == 0 || unitEnd == numberEnd {
|
||||||
|
return 0, fmt.Errorf("time: invalid duration '%s'", value)
|
||||||
|
}
|
||||||
|
number, unit := rest[:numberEnd], rest[numberEnd:unitEnd]
|
||||||
|
if unit == "d" {
|
||||||
|
// Use exact decimal arithmetic to avoid float rounding and retain nanosecond precision.
|
||||||
|
days, ok := new(big.Rat).SetString(number)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("time: invalid duration '%s'", value)
|
||||||
|
}
|
||||||
|
days.Mul(days, new(big.Rat).SetInt64(int64(24*time.Hour)))
|
||||||
|
nanoseconds := new(big.Int).Quo(days.Num(), days.Denom())
|
||||||
|
normalised.WriteString(nanoseconds.String())
|
||||||
|
normalised.WriteString("ns")
|
||||||
|
} else {
|
||||||
|
normalised.WriteString(rest[:unitEnd])
|
||||||
|
}
|
||||||
|
rest = rest[unitEnd:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The standard parser validates the remaining units and checks the total for overflow.
|
||||||
|
duration, err := time.ParseDuration(normalised.String())
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("time: invalid duration '%s': %w", value, err)
|
||||||
|
}
|
||||||
|
return duration, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDigit(c byte) bool {
|
||||||
|
return c >= '0' && c <= '9'
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseDuration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
input string
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{"0", 0},
|
||||||
|
{"2m30s", 150 * time.Second},
|
||||||
|
{"1µs", time.Microsecond},
|
||||||
|
{"1μs", time.Microsecond},
|
||||||
|
{"2d", 48 * time.Hour},
|
||||||
|
{"010d", 240 * time.Hour},
|
||||||
|
{"08d", 192 * time.Hour},
|
||||||
|
{"2d3h", 51 * time.Hour},
|
||||||
|
{"3h2d", 51 * time.Hour},
|
||||||
|
{"1d1d", 48 * time.Hour},
|
||||||
|
{"1.5d", 36 * time.Hour},
|
||||||
|
{".5d", 12 * time.Hour},
|
||||||
|
{"1.d", 24 * time.Hour},
|
||||||
|
{"-2d3h", -51 * time.Hour},
|
||||||
|
{"+2d", 48 * time.Hour},
|
||||||
|
{"0d", 0},
|
||||||
|
{"0d1ns", time.Nanosecond},
|
||||||
|
{"0.000000000001d", 86 * time.Nanosecond},
|
||||||
|
{"-0.000000000001d", -86 * time.Nanosecond},
|
||||||
|
{"106751d23h47m16.854775807s", time.Duration(math.MaxInt64)},
|
||||||
|
{"-106751d23h47m16.854775808s", time.Duration(math.MinInt64)},
|
||||||
|
} {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
actual, err := ParseDuration(tt.input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDuration_Invalid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, input := range []string{
|
||||||
|
"", "d", "2d3", "1..2d", ".d", "1d-2h", "1d+2h", "1d 2h", "1day", "2D", "1w",
|
||||||
|
"1e2d", "1d2w", "106752d", "-106752d", "106751d23h47m16.854775808s",
|
||||||
|
"-106751d23h47m16.854775809s", "999999999999999999999999999d",
|
||||||
|
} {
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
_, err := ParseDuration(input)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/charmbracelet/colorprofile"
|
||||||
"github.com/docker/docker/pkg/stringid"
|
"github.com/docker/docker/pkg/stringid"
|
||||||
"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"
|
||||||
@@ -24,6 +25,10 @@ type Formatter struct {
|
|||||||
maxServiceWidth int
|
maxServiceWidth int
|
||||||
|
|
||||||
utc bool
|
utc bool
|
||||||
|
|
||||||
|
// Cache each stream's terminal profile to avoid detection on every log entry.
|
||||||
|
stdout *colorprofile.Writer
|
||||||
|
stderr *colorprofile.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter {
|
func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter {
|
||||||
@@ -50,6 +55,8 @@ func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter {
|
|||||||
maxMachineWidth: maxMachineWidth,
|
maxMachineWidth: maxMachineWidth,
|
||||||
maxServiceWidth: maxServiceWidth,
|
maxServiceWidth: maxServiceWidth,
|
||||||
utc: utc,
|
utc: utc,
|
||||||
|
stdout: colorprofile.NewWriter(os.Stdout, os.Environ()),
|
||||||
|
stderr: colorprofile.NewWriter(os.Stderr, os.Environ()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +145,9 @@ func (f *Formatter) PrintEntry(entry api.ServiceLogEntry) {
|
|||||||
|
|
||||||
// Print to appropriate stream.
|
// Print to appropriate stream.
|
||||||
if entry.Stream == api.LogStreamStderr {
|
if entry.Stream == api.LogStreamStderr {
|
||||||
fmt.Fprint(os.Stderr, output.String())
|
fmt.Fprint(f.stderr, output.String())
|
||||||
} else {
|
} else {
|
||||||
fmt.Print(output.String())
|
fmt.Fprint(f.stdout, output.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,8 +155,7 @@ func (f *Formatter) PrintEntry(entry api.ServiceLogEntry) {
|
|||||||
func (f *Formatter) printError(entry api.ServiceLogEntry) {
|
func (f *Formatter) printError(entry api.ServiceLogEntry) {
|
||||||
if entry.Metadata.ServiceName == "" {
|
if entry.Metadata.ServiceName == "" {
|
||||||
msg := fmt.Sprintf("ERROR: %v", entry.Err)
|
msg := fmt.Sprintf("ERROR: %v", entry.Err)
|
||||||
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.BrightRed)
|
fmt.Fprintln(f.stderr, tui.BoldRed.Render(msg))
|
||||||
fmt.Fprintln(os.Stderr, style.Render(msg))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,8 +177,7 @@ func (f *Formatter) printError(entry api.ServiceLogEntry) {
|
|||||||
msg += fmt.Sprintf(": %v", entry.Err)
|
msg += fmt.Sprintf(": %v", entry.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.BrightYellow)
|
fmt.Fprintln(f.stderr, tui.BoldYellow.Render(msg))
|
||||||
fmt.Fprintln(os.Stderr, style.Render(msg))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// palette is available colors for machine/service differentiation.
|
// palette is available colors for machine/service differentiation.
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ type Options struct {
|
|||||||
Machines []string
|
Machines []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TailLines resolves the default tail limit after parsing flags. An empty opts.Tail means the user
|
||||||
|
// did not specify a limit, so --since can select all matching logs without overriding an explicit --tail.
|
||||||
|
func (opts Options) TailLines() (int, error) {
|
||||||
|
if opts.Tail == "" {
|
||||||
|
if opts.Since != "" {
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
return 100, nil
|
||||||
|
}
|
||||||
|
return ParseTail(opts.Tail)
|
||||||
|
}
|
||||||
|
|
||||||
func Flags(options *Options) *pflag.FlagSet {
|
func Flags(options *Options) *pflag.FlagSet {
|
||||||
set := &pflag.FlagSet{}
|
set := &pflag.FlagSet{}
|
||||||
|
|
||||||
@@ -29,14 +41,17 @@ func Flags(options *Options) *pflag.FlagSet {
|
|||||||
set.StringVar(&options.Since, "since", "",
|
set.StringVar(&options.Since, "since", "",
|
||||||
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||||
"Examples:\n"+
|
"Examples:\n"+
|
||||||
" --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+
|
" --since 1h45m Relative duration (1 hour 45 minutes ago)\n"+
|
||||||
" --since 1h Relative duration (1 hour ago)\n"+
|
" Supported units: d (day = 24h), h (hour), m (minute),\n"+
|
||||||
" --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+
|
" s (second), ms (millisecond),\n"+
|
||||||
" --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+
|
" us/µs (microsecond), ns (nanosecond)\n"+
|
||||||
|
" --since 2025-11-24 RFC 3339 date only (midnight using client local timezone)\n"+
|
||||||
|
" --since 2024-05-14T22:50:00 RFC 3339 date/time using client local timezone\n"+
|
||||||
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
|
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
|
||||||
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
|
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
|
||||||
set.StringVarP(&options.Tail, "tail", "n", "100",
|
set.StringVarP(&options.Tail, "tail", "n", "",
|
||||||
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.")
|
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.\n"+
|
||||||
|
"Defaults to 100, or 'all' when --since is set.")
|
||||||
set.StringVar(&options.Until, "until", "",
|
set.StringVar(&options.Until, "until", "",
|
||||||
"Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
"Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||||
"See --since for examples.")
|
"See --since for examples.")
|
||||||
@@ -46,7 +61,7 @@ func Flags(options *Options) *pflag.FlagSet {
|
|||||||
return set
|
return set
|
||||||
}
|
}
|
||||||
|
|
||||||
func Tail(tail string) (int, error) {
|
func ParseTail(tail string) (int, error) {
|
||||||
if tail == "all" {
|
if tail == "all" {
|
||||||
return -1, nil
|
return -1, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,42 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestOptionsTailLines(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"default", nil, 100},
|
||||||
|
{"follow", []string{"-f"}, 100},
|
||||||
|
{"since", []string{"--since", "1h"}, -1},
|
||||||
|
{"since and follow", []string{"--since", "1h", "-f"}, -1},
|
||||||
|
{"until only", []string{"--until", "1h"}, 100},
|
||||||
|
{"time range", []string{"--since", "3h", "--until", "1h"}, -1},
|
||||||
|
{"explicit default with since", []string{"--since", "1h", "--tail", "100"}, 100},
|
||||||
|
{"explicit limit with since", []string{"--since", "1h", "-n", "20"}, 20},
|
||||||
|
{"explicit limit before since", []string{"-n", "20", "--since", "1h"}, 20},
|
||||||
|
{"explicit all", []string{"--tail", "all"}, -1},
|
||||||
|
{"explicit zero with since", []string{"--since", "1h", "-n", "0", "-f"}, 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var options Options
|
||||||
|
require.NoError(t, Flags(&options).Parse(tt.args))
|
||||||
|
tail, err := options.TailLines()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, tail)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var options Options
|
||||||
|
require.NoError(t, Flags(&options).Parse([]string{"--since", "1h", "--tail", "invalid"}))
|
||||||
|
_, err := options.TailLines()
|
||||||
|
require.ErrorContains(t, err, "invalid --tail value")
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseServiceArgs(t *testing.T) {
|
func TestParseServiceArgs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TimeRange resolves log filters that could be RFC 3339, timestamp, or relative duration to UTC timestamps.
|
||||||
|
// Dates without a timezone use now's location. Relative durations are computed from now.
|
||||||
|
func TimeRange(since, until string, now time.Time) (string, string, error) {
|
||||||
|
var err error
|
||||||
|
since, err = timestamp(since, now)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid --since value: %w", err)
|
||||||
|
}
|
||||||
|
until, err = timestamp(until, now)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid --until value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return since, until, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func timestamp(value string, now time.Time) (string, error) {
|
||||||
|
if value == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
// A bare zero is the Unix epoch, matching Docker's log filters.
|
||||||
|
if duration, err := ParseDuration(value); value != "0" && err == nil {
|
||||||
|
return now.Add(-duration).UTC().Format(time.RFC3339Nano), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep Docker's supported date layouts, but use the location's offset at the requested date.
|
||||||
|
for _, layout := range []string{
|
||||||
|
time.RFC3339Nano,
|
||||||
|
"2006-01-02T15:04Z07:00",
|
||||||
|
"2006-01-02T15Z07:00",
|
||||||
|
"2006-01-02Z07:00",
|
||||||
|
"2006-01-02T15:04:05.999999999",
|
||||||
|
"2006-01-02T15:04",
|
||||||
|
"2006-01-02T15",
|
||||||
|
"2006-01-02",
|
||||||
|
} {
|
||||||
|
if t, err := time.ParseInLocation(layout, value, now.Location()); err == nil {
|
||||||
|
return t.UTC().Format(time.RFC3339Nano), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds, fraction, hasFraction := strings.Cut(value, ".")
|
||||||
|
sec, err := strconv.ParseInt(seconds, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse '%s' as a time or duration", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
var nsec int64
|
||||||
|
if hasFraction {
|
||||||
|
if len(fraction) == 0 || len(fraction) > 9 || strings.ContainsAny(fraction, "+-") {
|
||||||
|
return "", fmt.Errorf("invalid Unix timestamp fraction in '%s'", value)
|
||||||
|
}
|
||||||
|
nsec, err = strconv.ParseInt(fraction+strings.Repeat("0", 9-len(fraction)), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid Unix timestamp fraction in '%s': %w", value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t := time.Unix(sec, nsec).UTC()
|
||||||
|
if t.Year() < 0 || t.Year() > 9999 {
|
||||||
|
return "", fmt.Errorf("Unix timestamp '%s' is outside the RFC 3339 date range", value)
|
||||||
|
}
|
||||||
|
return t.Format(time.RFC3339Nano), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
timetypes "github.com/docker/docker/api/types/time"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTimeRange(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("Australia/Sydney")
|
||||||
|
require.NoError(t, err)
|
||||||
|
// October is daylight-saving time, but July timestamps must use the winter offset.
|
||||||
|
// Daylight saving starts on 4 October. Day durations must still mean 24 elapsed hours.
|
||||||
|
now := time.Date(2026, 10, 5, 12, 0, 0, 123456789, location)
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", ""},
|
||||||
|
{"2026-07-01", "2026-06-30T14:00:00Z"},
|
||||||
|
{"2026-07-01T10", "2026-07-01T00:00:00Z"},
|
||||||
|
{"2026-07-01T10:30", "2026-07-01T00:30:00Z"},
|
||||||
|
{"2026-07-01T10:30:45.123456789", "2026-07-01T00:30:45.123456789Z"},
|
||||||
|
{"2026-01-01T10:00:00", "2025-12-31T23:00:00Z"},
|
||||||
|
{"2026-07-01T10:30:45Z", "2026-07-01T10:30:45Z"},
|
||||||
|
{"2026-07-01T10:30:45+02:00", "2026-07-01T08:30:45Z"},
|
||||||
|
{"2026-07-01T10:30:45-04:00", "2026-07-01T14:30:45Z"},
|
||||||
|
{"2026-07-01T10+02:00", "2026-07-01T08:00:00Z"},
|
||||||
|
{"2026-07-01T10:30+02:00", "2026-07-01T08:30:00Z"},
|
||||||
|
{"2026-07-01+02:00", "2026-06-30T22:00:00Z"},
|
||||||
|
{"1763953966", "2025-11-24T03:12:46Z"},
|
||||||
|
{"1763953966.000000001", "2025-11-24T03:12:46.000000001Z"},
|
||||||
|
{"0", "1970-01-01T00:00:00Z"},
|
||||||
|
{"2m30s", "2026-10-05T00:57:30.123456789Z"},
|
||||||
|
{"1d", "2026-10-04T01:00:00.123456789Z"},
|
||||||
|
{"2d", "2026-10-03T01:00:00.123456789Z"},
|
||||||
|
{"2d3h", "2026-10-02T22:00:00.123456789Z"},
|
||||||
|
{"1.5d", "2026-10-03T13:00:00.123456789Z"},
|
||||||
|
{"-2d", "2026-10-07T01:00:00.123456789Z"},
|
||||||
|
{"-1h", "2026-10-05T02:00:00.123456789Z"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
since, until, err := TimeRange(tt.input, tt.input, now)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, since)
|
||||||
|
assert.Equal(t, tt.want, until)
|
||||||
|
|
||||||
|
if since != "" {
|
||||||
|
// The server-side Docker SDK must preserve the cutoff even in another timezone.
|
||||||
|
actual, err := timetypes.GetTimestamp(since, now.In(time.UTC))
|
||||||
|
require.NoError(t, err)
|
||||||
|
expected, err := time.Parse(time.RFC3339Nano, tt.want)
|
||||||
|
require.NoError(t, err)
|
||||||
|
sec, nsec, err := timetypes.ParseTimestamps(actual, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, expected.Equal(time.Unix(sec, nsec)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
since, until, err := TimeRange("3h", "1h30m", now)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "2026-10-04T22:00:00.123456789Z", since)
|
||||||
|
assert.Equal(t, "2026-10-04T23:30:00.123456789Z", until)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeRange_Invalid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, input := range []string{
|
||||||
|
"invalid", "2026-02-30", "2026-01-01T25:00:00", "1763953966.xyz",
|
||||||
|
"1763953966.1234567890", "253402300800",
|
||||||
|
} {
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
_, _, err := TimeRange(input, "", time.Now())
|
||||||
|
require.ErrorContains(t, err, "invalid --since value")
|
||||||
|
_, _, err = TimeRange("", input, time.Now())
|
||||||
|
require.ErrorContains(t, err, "invalid --until value")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func PrintWarning(msg string) {
|
func PrintWarning(msg string) {
|
||||||
styledMsg := BoldYellow.Render(fmt.Sprintf("WARNING: %s", msg))
|
styledMsg := BoldYellow.Render(fmt.Sprintf("WARNING: %s", msg))
|
||||||
fmt.Fprintln(os.Stderr, styledMsg)
|
lipgloss.Fprintln(os.Stderr, styledMsg)
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
@@ -31,11 +32,11 @@ func logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (io.Rea
|
|||||||
|
|
||||||
if opts.Since != "" {
|
if opts.Since != "" {
|
||||||
args = append(args, "-S")
|
args = append(args, "-S")
|
||||||
args = append(args, opts.Since)
|
args = append(args, journalTimestamp(opts.Since))
|
||||||
}
|
}
|
||||||
if opts.Until != "" {
|
if opts.Until != "" {
|
||||||
args = append(args, "-U")
|
args = append(args, "-U")
|
||||||
args = append(args, opts.Until)
|
args = append(args, journalTimestamp(opts.Until))
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := commandContext(ctx, journalctl, args...)
|
cmd := commandContext(ctx, journalctl, args...)
|
||||||
@@ -51,6 +52,18 @@ func logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (io.Rea
|
|||||||
return p, cmd.Wait, nil
|
return p, cmd.Wait, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// journalTimestamp formats normalised client timestamps for journalctl versions that do not
|
||||||
|
// accept RFC 3339 timezone suffixes. Support for timestamps containing T and Z was added in
|
||||||
|
// systemd 255 (6 December 2023). Debian 12 ships systemd 252, so it still needs this conversion.
|
||||||
|
// Journald timestamps have microsecond precision.
|
||||||
|
func journalTimestamp(value string) string {
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||||
|
return t.UTC().Format("2006-01-02 15:04:05.999999") + " UTC"
|
||||||
|
}
|
||||||
|
// Preserve raw filters from older clients and SDK callers.
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// follow synchronously follows the io.Reader, writing each new journal entry to channel.
|
// follow synchronously follows the io.Reader, writing each new journal entry to channel.
|
||||||
// It stops when the reader is exhausted or the context is cancelled.
|
// It stops when the reader is exhausted or the context is cancelled.
|
||||||
func follow(ctx context.Context, reader io.Reader, outCh chan api.LogEntry) {
|
func follow(ctx context.Context, reader io.Reader, outCh chan api.LogEntry) {
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ func TestEntry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLogs(t *testing.T) {
|
func TestLogs(t *testing.T) {
|
||||||
|
originalCommandContext := commandContext
|
||||||
|
t.Cleanup(func() { commandContext = originalCommandContext })
|
||||||
|
|
||||||
commandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
|
commandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
|
||||||
return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs")
|
return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs")
|
||||||
}
|
}
|
||||||
@@ -150,3 +153,38 @@ func TestLogs(t *testing.T) {
|
|||||||
// Still six because heartbeats are not written here and Tail is ignored as the command is overridden.
|
// Still six because heartbeats are not written here and Tail is ignored as the command is overridden.
|
||||||
assert.Equal(t, 6, i)
|
assert.Equal(t, 6, i)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLogs_TimeFilters(t *testing.T) {
|
||||||
|
originalCommandContext := commandContext
|
||||||
|
t.Cleanup(func() { commandContext = originalCommandContext })
|
||||||
|
|
||||||
|
var args []string
|
||||||
|
commandContext = func(ctx context.Context, command string, commandArgs ...string) *exec.Cmd {
|
||||||
|
assert.Equal(t, "journalctl", command)
|
||||||
|
args = commandArgs
|
||||||
|
return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
ch, err := Logs(context.Background(), "uncloud", api.ServiceLogsOptions{
|
||||||
|
Since: "2026-07-01T10:30:45.123456789+10:00",
|
||||||
|
Until: "2026-07-01T01:30:45Z",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
for range ch {
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"-u", "uncloud", "--no-hostname", "-n", "0", "-o", "short-unix",
|
||||||
|
"-S", "2026-07-01 00:30:45.123456 UTC",
|
||||||
|
"-U", "2026-07-01 01:30:45 UTC",
|
||||||
|
}, args)
|
||||||
|
|
||||||
|
// Older clients and SDK callers can still pass journalctl's native filters.
|
||||||
|
ch, err = Logs(context.Background(), "uncloud", api.ServiceLogsOptions{Since: "1h ago", Until: "today"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
for range ch {
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"-u", "uncloud", "--no-hostname", "-n", "0", "-o", "short-unix",
|
||||||
|
"-S", "1h ago", "-U", "today",
|
||||||
|
}, args)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/psviderski/uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
@@ -87,6 +88,11 @@ func (c *Cluster) AddMachine(ctx context.Context, req *pb.AddMachineRequest) (*p
|
|||||||
func (c *Cluster) AddMachineWithoutReadyCheck(
|
func (c *Cluster) AddMachineWithoutReadyCheck(
|
||||||
ctx context.Context, req *pb.AddMachineRequest,
|
ctx context.Context, req *pb.AddMachineRequest,
|
||||||
) (*pb.AddMachineResponse, error) {
|
) (*pb.AddMachineResponse, error) {
|
||||||
|
if req.Name != "" {
|
||||||
|
if err := api.ValidateMachineName(req.Name); err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
if req.Network == nil {
|
if req.Network == nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, "network not set")
|
return nil, status.Error(codes.InvalidArgument, "network not set")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAddMachine_InvalidName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ready := make(chan struct{})
|
||||||
|
close(ready)
|
||||||
|
// Invalid names must be rejected before any store access.
|
||||||
|
c := NewCluster(nil, nil, nil, ready)
|
||||||
|
for _, name := range []string{"VPS1", "worker.example", "rr", "nearest", "c337f00600de51ef4375c9a9a267dba5"} {
|
||||||
|
t.Run("add", func(t *testing.T) {
|
||||||
|
resp, err := c.AddMachine(context.Background(), &pb.AddMachineRequest{Name: name})
|
||||||
|
require.Nil(t, resp)
|
||||||
|
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||||
|
require.ErrorContains(t, err, "invalid machine name")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewMachineID generates a new unique machine ID.
|
// NewMachineID generates a new unique machine ID.
|
||||||
@@ -27,7 +28,7 @@ func NewRandomMachineName() (string, error) {
|
|||||||
// a numeric suffix ("-1", "-2", etc.) if needed.
|
// a numeric suffix ("-1", "-2", etc.) if needed.
|
||||||
func DefaultMachineName(hostname string, existing []string) (string, error) {
|
func DefaultMachineName(hostname string, existing []string) (string, error) {
|
||||||
name := machineNameFromHostname(hostname)
|
name := machineNameFromHostname(hostname)
|
||||||
if name == "" {
|
if api.ValidateMachineName(name) != nil {
|
||||||
var err error
|
var err error
|
||||||
if name, err = NewRandomMachineName(); err != nil {
|
if name, err = NewRandomMachineName(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -38,7 +39,9 @@ func DefaultMachineName(hostname string, existing []string) (string, error) {
|
|||||||
return name, nil
|
return name, nil
|
||||||
}
|
}
|
||||||
for i := 1; ; i++ {
|
for i := 1; ; i++ {
|
||||||
candidate := fmt.Sprintf("%s-%d", name, i)
|
suffix := fmt.Sprintf("-%d", i)
|
||||||
|
base := strings.TrimRight(name[:min(len(name), 63-len(suffix))], "-")
|
||||||
|
candidate := base + suffix
|
||||||
if !slices.Contains(existing, candidate) {
|
if !slices.Contains(existing, candidate) {
|
||||||
return candidate, nil
|
return candidate, nil
|
||||||
}
|
}
|
||||||
@@ -55,12 +58,13 @@ func machineNameFromHostname(hostname string) string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, r := range label {
|
for _, r := range label {
|
||||||
switch {
|
switch {
|
||||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
|
||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
default:
|
default:
|
||||||
b.WriteRune('-')
|
b.WriteRune('-')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Trim leading and trailing hyphens that may result from the sanitisation above.
|
// Trim leading and trailing hyphens that may result from the sanitisation above.
|
||||||
return strings.Trim(b.String(), "-")
|
name := strings.Trim(b.String(), "-")
|
||||||
|
return strings.TrimRight(name[:min(len(name), 63)], "-")
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package cluster
|
package cluster
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -18,9 +20,11 @@ func TestMachineNameFromHostname(t *testing.T) {
|
|||||||
{"simple", "web", "web"},
|
{"simple", "web", "web"},
|
||||||
{"fqdn uses first label", "web-1.example.com", "web-1"},
|
{"fqdn uses first label", "web-1.example.com", "web-1"},
|
||||||
{"uppercase lowercased", "Web-Server", "web-server"},
|
{"uppercase lowercased", "Web-Server", "web-server"},
|
||||||
{"invalid chars replaced", "host_name@1", "host_name-1"},
|
{"invalid chars replaced", "host_name@1", "host-name-1"},
|
||||||
{"trim surrounding hyphens", "-_host_-", "_host_"},
|
{"trim surrounding hyphens", "-_host_-", "host"},
|
||||||
{"whitespace trimmed", " myhost ", "myhost"},
|
{"whitespace trimmed", " myhost ", "myhost"},
|
||||||
|
{"long hostname truncated", strings.Repeat("a", 64), strings.Repeat("a", 63)},
|
||||||
|
{"truncation trims trailing hyphen", strings.Repeat("a", 62) + "-b", strings.Repeat("a", 62)},
|
||||||
{"empty", "", ""},
|
{"empty", "", ""},
|
||||||
{"only invalid chars", "@#", ""},
|
{"only invalid chars", "@#", ""},
|
||||||
{"dot only", ".example.com", ""},
|
{"dot only", ".example.com", ""},
|
||||||
@@ -37,10 +41,12 @@ func TestDefaultMachineName(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
t.Run("falls back to random", func(t *testing.T) {
|
t.Run("falls back to random", func(t *testing.T) {
|
||||||
// An empty or fully invalid hostname falls back to a random "machine-xxxx" name.
|
for _, hostname := range []string{"***", "rr", "nearest", strings.Repeat("a", 32)} {
|
||||||
got, err := DefaultMachineName("***", nil)
|
got, err := DefaultMachineName(hostname, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Regexp(t, `^machine-[a-zA-Z0-9]{4}$`, got)
|
assert.Regexp(t, `^machine-[a-z0-9]{4}$`, got)
|
||||||
|
require.NoError(t, api.ValidateMachineName(got))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -49,10 +55,45 @@ func TestDefaultMachineName(t *testing.T) {
|
|||||||
existing []string
|
existing []string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"from hostname", "web-1.example.com", nil, "web-1"},
|
{
|
||||||
{"dedup against existing", "web", []string{"web"}, "web-1"},
|
name: "from hostname",
|
||||||
{"dedup multiple", "web", []string{"web", "web-1"}, "web-2"},
|
hostname: "web-1.example.com",
|
||||||
{"sanitized", "My_Host", nil, "my_host"},
|
want: "web-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dedup against existing",
|
||||||
|
hostname: "web",
|
||||||
|
existing: []string{"web"},
|
||||||
|
want: "web-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dedup multiple",
|
||||||
|
hostname: "web",
|
||||||
|
existing: []string{"web", "web-1"},
|
||||||
|
want: "web-2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sanitized",
|
||||||
|
hostname: "My_Host",
|
||||||
|
want: "my-host",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "long hostname",
|
||||||
|
hostname: strings.Repeat("a", 64),
|
||||||
|
want: strings.Repeat("a", 63),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dedup maximum length",
|
||||||
|
hostname: strings.Repeat("a", 63),
|
||||||
|
existing: []string{strings.Repeat("a", 63)},
|
||||||
|
want: strings.Repeat("a", 61) + "-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dedup trims trailing hyphen",
|
||||||
|
hostname: strings.Repeat("a", 60) + "-bb",
|
||||||
|
existing: []string{strings.Repeat("a", 60) + "-bb"},
|
||||||
|
want: strings.Repeat("a", 60) + "-1",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -60,6 +101,7 @@ func TestDefaultMachineName(t *testing.T) {
|
|||||||
got, err := DefaultMachineName(tt.hostname, tt.existing)
|
got, err := DefaultMachineName(tt.hostname, tt.existing)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tt.want, got)
|
assert.Equal(t, tt.want, got)
|
||||||
|
require.NoError(t, api.ValidateMachineName(got))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/psviderski/uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,7 +21,9 @@ type ClusterResolver struct {
|
|||||||
store *store.Store
|
store *store.Store
|
||||||
// serviceIPs maps service names to container IPs.
|
// serviceIPs maps service names to container IPs.
|
||||||
serviceIPs map[string][]netip.Addr
|
serviceIPs map[string][]netip.Addr
|
||||||
// mu protects the serviceIPs map.
|
// machineIPs maps machine IDs and names to their IP.
|
||||||
|
machineIPs map[string]netip.Addr
|
||||||
|
// mu protects the serviceIPs and machineIPs map.
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
// lastUpdate tracks when records were last updated.
|
// lastUpdate tracks when records were last updated.
|
||||||
lastUpdate time.Time
|
lastUpdate time.Time
|
||||||
@@ -46,6 +50,13 @@ func (r *ClusterResolver) Run(ctx context.Context) error {
|
|||||||
// TODO: implement machine membership check using Corrossion Admin client to filter available containers.
|
// TODO: implement machine membership check using Corrossion Admin client to filter available containers.
|
||||||
r.updateServiceIPs(containers)
|
r.updateServiceIPs(containers)
|
||||||
|
|
||||||
|
machines, mchanges, err := r.store.SubscribeMachines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("subscribe to machine changes: %w", err)
|
||||||
|
}
|
||||||
|
r.log.Info("Subscribed to machine changes in the clsuter to keep machine DNS records updated.")
|
||||||
|
r.updateMachineIPs(machines)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case _, ok := <-changes:
|
case _, ok := <-changes:
|
||||||
@@ -59,9 +70,22 @@ func (r *ClusterResolver) Run(ctx context.Context) error {
|
|||||||
r.log.Error("Failed to list containers.", "err", err)
|
r.log.Error("Failed to list containers.", "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: implement machine membership check using Corrossion Admin client to filter available containers.
|
// TODO: implement machine membership check using Corrossion Admin client to filter available containers.
|
||||||
r.updateServiceIPs(containers)
|
r.updateServiceIPs(containers)
|
||||||
|
|
||||||
|
case _, ok := <-mchanges:
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("machine subscription failed")
|
||||||
|
}
|
||||||
|
r.log.Debug("Cluster machines changed, updating DNS records.")
|
||||||
|
|
||||||
|
machines, err := r.store.ListMachines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
r.log.Error("Failed to list machines.", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.updateMachineIPs(machines)
|
||||||
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -121,19 +145,49 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
|
|||||||
r.log.Info("DNS records updated.", "services", len(newServiceIPs)/3, "containers", containersCount)
|
r.log.Info("DNS records updated.", "services", len(newServiceIPs)/3, "containers", containersCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve returns IP addresses of the service containers.
|
func (r *ClusterResolver) updateMachineIPs(machines []*pb.MachineInfo) {
|
||||||
func (r *ClusterResolver) Resolve(serviceName string) []netip.Addr {
|
newMachineIPs := make(map[string]netip.Addr, len(machines))
|
||||||
|
|
||||||
|
for _, machine := range machines {
|
||||||
|
subnet, err := machine.Network.Subnet.ToPrefix()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr := network.MachineIP(subnet)
|
||||||
|
newMachineIPs[machine.Name+".m"] = addr
|
||||||
|
newMachineIPs[machine.Id+".m"] = addr
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.machineIPs = newMachineIPs
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
r.log.Info("DNS records updated.", "machines", len(machines))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns IP addresses of the service containers or machines.
|
||||||
|
func (r *ClusterResolver) Resolve(name string) []netip.Addr {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
ips, ok := r.serviceIPs[serviceName]
|
// Return a copy of the IPs slice to prevent modification of the original.
|
||||||
if !ok || len(ips) == 0 {
|
ips, ok := r.serviceIPs[name]
|
||||||
return nil
|
if ok && len(ips) > 0 {
|
||||||
|
return slices.Clone(ips)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return a copy of the IPs slice to prevent modification of the original.
|
ip, ok := r.machineIPs[name]
|
||||||
ipsCopy := make([]netip.Addr, len(ips))
|
if ok {
|
||||||
copy(ipsCopy, ips)
|
return slices.Clone([]netip.Addr{ip})
|
||||||
|
}
|
||||||
|
|
||||||
return ipsCopy
|
if name == "m" { // all machines subdomain
|
||||||
|
// collect all unique IP address
|
||||||
|
uniq := map[string]netip.Addr{}
|
||||||
|
for _, addr := range r.machineIPs {
|
||||||
|
uniq[addr.String()] = addr
|
||||||
|
}
|
||||||
|
return slices.Clone(slices.Collect(maps.Values(uniq)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
package dns
|
package dns
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/netip"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -75,3 +77,32 @@ func newRecord(serviceID, serviceName, ip, machineID string) store.ContainerReco
|
|||||||
MachineID: machineID,
|
MachineID: machineID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClusterResolver_UpdateMachineIPs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
machines := []*pb.MachineInfo{
|
||||||
|
newMachineRecord("x0y0z0", "mach-1", "10.210.0.0/24"),
|
||||||
|
newMachineRecord("x1y1z1", "mach-2", "10.210.1.0/24"),
|
||||||
|
newMachineRecord("x2y2z2", "mach-3", "10.210.2.0/24"),
|
||||||
|
}
|
||||||
|
|
||||||
|
r := NewClusterResolver(nil)
|
||||||
|
r.updateMachineIPs(machines)
|
||||||
|
|
||||||
|
assert.NotEmpty(t, r.Resolve("mach-1.m"))
|
||||||
|
assert.NotEmpty(t, r.Resolve("mach-3.m"))
|
||||||
|
assert.NotEmpty(t, r.Resolve("x0y0z0.m"))
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(r.Resolve("m")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMachineRecord(machineID, machineName, prefix string) *pb.MachineInfo {
|
||||||
|
return &pb.MachineInfo{
|
||||||
|
Id: machineID,
|
||||||
|
Name: machineName,
|
||||||
|
Network: &pb.NetworkConfig{
|
||||||
|
Subnet: pb.NewIPPrefix(netip.MustParsePrefix(prefix)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -293,13 +293,13 @@ func (s *Server) forwardRequest(req *dns.Msg, proto string) (*dns.Msg, error) {
|
|||||||
// handleAQuery processes an A query for the internal domain and returns A records for the requested name.
|
// handleAQuery processes an A query for the internal domain and returns A records for the requested name.
|
||||||
// The internal domain suffix is already stripped from the name. An empty list is returned if no records are found.
|
// The internal domain suffix is already stripped from the name. An empty list is returned if no records are found.
|
||||||
func (s *Server) handleAQuery(name string) []dns.RR {
|
func (s *Server) handleAQuery(name string) []dns.RR {
|
||||||
serviceName, mode := extractModeFromDomain(trimInternalDomain(name))
|
unname, mode := extractModeFromDomain(trimInternalDomain(name))
|
||||||
ips := s.resolver.Resolve(serviceName)
|
ips := s.resolver.Resolve(unname)
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
s.log.Debug("Failed to resolve service name.", "service", serviceName)
|
s.log.Debug("Failed to resolve internal name.", "name", name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s.log.Debug("Resolved service name.", "service", serviceName, "ips", ips)
|
s.log.Debug("Resolved service internal name.", "name", name, "ips", ips)
|
||||||
|
|
||||||
if len(ips) > 1 {
|
if len(ips) > 1 {
|
||||||
// Shuffle the IPs to approximate round-robin.
|
// Shuffle the IPs to approximate round-robin.
|
||||||
|
|||||||
@@ -836,6 +836,11 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
|
|||||||
if m.Initialised() {
|
if m.Initialised() {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "machine is already configured as a cluster member")
|
return nil, status.Error(codes.FailedPrecondition, "machine is already configured as a cluster member")
|
||||||
}
|
}
|
||||||
|
if req.MachineName != "" {
|
||||||
|
if err := api.ValidateMachineName(req.MachineName); err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clusterNetwork, err := req.Network.ToPrefix()
|
clusterNetwork, err := req.Network.ToPrefix()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1220,8 +1225,8 @@ func (m *Machine) applyMachineUpdate(ctx context.Context, req *pb.UpdateMachineR
|
|||||||
defer m.state.mu.Unlock()
|
defer m.state.mu.Unlock()
|
||||||
|
|
||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
if *req.Name == "" {
|
if err := api.ValidateMachineName(*req.Name); err != nil {
|
||||||
return status.Error(codes.InvalidArgument, "machine name cannot be empty")
|
return status.Error(codes.InvalidArgument, err.Error())
|
||||||
}
|
}
|
||||||
// Check for duplicate names across the cluster, excluding this machine.
|
// Check for duplicate names across the cluster, excluding this machine.
|
||||||
if *req.Name != m.state.Name {
|
if *req.Name != m.state.Name {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInitCluster_InvalidMachineName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, name := range []string{"VPS1", "rr", "nearest", "c337f00600de51ef4375c9a9a267dba5"} {
|
||||||
|
// Invalid names must be rejected before initializing cluster state.
|
||||||
|
m := &Machine{state: &State{}}
|
||||||
|
|
||||||
|
resp, err := m.InitCluster(context.Background(), &pb.InitClusterRequest{MachineName: name})
|
||||||
|
require.Nil(t, resp)
|
||||||
|
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||||
|
require.ErrorContains(t, err, "invalid machine name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateMachine_InvalidName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, name := range []string{"", "VPS1", "worker.example", "rr", "nearest", "c337f00600de51ef4375c9a9a267dba5"} {
|
||||||
|
m := &Machine{state: &State{ID: "machine-id", Name: "worker"}}
|
||||||
|
resp, err := m.UpdateMachine(context.Background(), &pb.UpdateMachineRequest{Name: &name})
|
||||||
|
require.Nil(t, resp)
|
||||||
|
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||||
|
require.ErrorContains(t, err, "invalid machine name")
|
||||||
|
require.Equal(t, "worker", m.state.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ const (
|
|||||||
// SyncStatusOutdated indicates that a container record may be outdated, for example, due to being unable
|
// SyncStatusOutdated indicates that a container record may be outdated, for example, due to being unable
|
||||||
// to retrieve the container's state from the Docker daemon or when the machine is being stopped or restarted.
|
// to retrieve the container's state from the Docker daemon or when the machine is being stopped or restarted.
|
||||||
SyncStatusOutdated = "outdated"
|
SyncStatusOutdated = "outdated"
|
||||||
|
|
||||||
|
// containerChangesDebounceInterval defines how long to wait before notifying subscribers about container changes.
|
||||||
|
// Multiple changes within this window are grouped into a single notification to prevent system overload.
|
||||||
|
containerChangesDebounceInterval = 250 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContainerRecord struct {
|
type ContainerRecord struct {
|
||||||
@@ -267,6 +271,13 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-c
|
|||||||
changes := make(chan struct{})
|
changes := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
defer close(changes)
|
defer close(changes)
|
||||||
|
// Coalesce bursts of rapid-fire row-level change events (e.g. from frequent health-check updates
|
||||||
|
// across many services) into a single signal per debounce window, instead of forwarding one signal
|
||||||
|
// per event. Every subscriber of this channel (e.g. the Caddy and DNS reconcilers) otherwise reruns
|
||||||
|
// its full reconciliation on every single event, which can burn significant CPU across the cluster
|
||||||
|
// when there's a lot of container churn.
|
||||||
|
var debouncer *time.Timer
|
||||||
|
var debouncerCh <-chan time.Time
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -279,8 +290,21 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-c
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Just signal that there is a change in the containers list.
|
if debouncerCh == nil {
|
||||||
changes <- struct{}{}
|
if debouncer == nil {
|
||||||
|
debouncer = time.NewTimer(containerChangesDebounceInterval)
|
||||||
|
} else {
|
||||||
|
debouncer.Reset(containerChangesDebounceInterval)
|
||||||
|
}
|
||||||
|
debouncerCh = debouncer.C
|
||||||
|
}
|
||||||
|
case <-debouncerCh:
|
||||||
|
select {
|
||||||
|
case changes <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debouncerCh = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -1,12 +1,33 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/api/pb"
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ValidateMachineName checks that a machine name is a lowercase DNS label and doesn't conflict with
|
||||||
|
// internal DNS query modes or machine IDs.
|
||||||
|
func ValidateMachineName(name string) error {
|
||||||
|
if !DNSLabelRegex.MatchString(name) {
|
||||||
|
return fmt.Errorf("invalid machine name %q: must be 1-63 characters, lowercase letters, numbers, "+
|
||||||
|
"and hyphens only, starting and ending with a letter or number", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "rr", "nearest":
|
||||||
|
return fmt.Errorf("invalid machine name %q: reserved for internal DNS query modes", name)
|
||||||
|
}
|
||||||
|
if IDRegex.MatchString(name) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"invalid machine name %q: must not match the machine ID format (32 hexadecimal characters)", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MachineFilter defines criteria to filter machines in ListMachines.
|
// MachineFilter defines criteria to filter machines in ListMachines.
|
||||||
type MachineFilter struct {
|
type MachineFilter struct {
|
||||||
// Available filters machines that are not DOWN.
|
// Available filters machines that are not DOWN.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/api/pb"
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
@@ -10,6 +11,52 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestValidateMachineName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "single letter", input: "a"},
|
||||||
|
{name: "single digit", input: "1"},
|
||||||
|
{name: "two characters", input: "a1"},
|
||||||
|
{name: "generated name", input: "machine-ab12"},
|
||||||
|
{name: "hyphens and digits", input: "1-worker-2"},
|
||||||
|
{name: "maximum length", input: strings.Repeat("a", 63)},
|
||||||
|
{name: "maximum length with hyphens", input: "a" + strings.Repeat("-", 61) + "1"},
|
||||||
|
{name: "machine namespace label", input: "m"},
|
||||||
|
{name: "mode prefix", input: "nearest-worker"},
|
||||||
|
{name: "short hexadecimal name", input: strings.Repeat("a", 31)},
|
||||||
|
{name: "long hexadecimal name", input: strings.Repeat("a", 33)},
|
||||||
|
{name: "non-hexadecimal 32 characters", input: strings.Repeat("g", 32)},
|
||||||
|
{name: "empty", wantErr: "must be 1-63 characters"},
|
||||||
|
{name: "too long", input: strings.Repeat("a", 64), wantErr: "must be 1-63 characters"},
|
||||||
|
{name: "uppercase", input: "VPS1", wantErr: "lowercase letters"},
|
||||||
|
{name: "leading hyphen", input: "-worker", wantErr: "starting and ending"},
|
||||||
|
{name: "trailing hyphen", input: "worker-", wantErr: "starting and ending"},
|
||||||
|
{name: "underscore", input: "worker_1", wantErr: "hyphens only"},
|
||||||
|
{name: "dot", input: "worker.example", wantErr: "hyphens only"},
|
||||||
|
{name: "space", input: "worker 1", wantErr: "hyphens only"},
|
||||||
|
{name: "leading whitespace", input: " worker", wantErr: "hyphens only"},
|
||||||
|
{name: "trailing whitespace", input: "worker\t", wantErr: "hyphens only"},
|
||||||
|
{name: "non-ASCII", input: "wörker", wantErr: "lowercase letters"},
|
||||||
|
{name: "round-robin mode", input: "rr", wantErr: "reserved for internal DNS query modes"},
|
||||||
|
{name: "nearest mode", input: "nearest", wantErr: "reserved for internal DNS query modes"},
|
||||||
|
{name: "machine ID", input: "c337f00600de51ef4375c9a9a267dba5", wantErr: "machine ID format"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
err := ValidateMachineName(tt.input)
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
require.NoError(t, err)
|
||||||
|
} else {
|
||||||
|
require.ErrorContains(t, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMachineMembersList_Info(t *testing.T) {
|
func TestMachineMembersList_Info(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+23
-9
@@ -41,12 +41,30 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
|
IDRegex = regexp.MustCompile("^[0-9a-f]{32}$")
|
||||||
dnsLabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
|
DNSLabelRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func ValidateServiceID(id string) bool {
|
func ValidateServiceID(id string) bool {
|
||||||
return serviceIDRegexp.MatchString(id)
|
return IDRegex.MatchString(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateServiceName checks that a service name is a lowercase DNS label and doesn't conflict with
|
||||||
|
// the machine DNS namespace or service IDs.
|
||||||
|
func ValidateServiceName(name string) error {
|
||||||
|
if !DNSLabelRegex.MatchString(name) {
|
||||||
|
return fmt.Errorf("invalid service name %q: must be 1-63 characters, lowercase letters, numbers, "+
|
||||||
|
"and hyphens only, starting and ending with a letter or number", name)
|
||||||
|
}
|
||||||
|
if name == "m" {
|
||||||
|
return fmt.Errorf("invalid service name %q: reserved for the machine DNS namespace", name)
|
||||||
|
}
|
||||||
|
if IDRegex.MatchString(name) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"invalid service name %q: must not match the service ID format (32 hexadecimal characters)", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceSpec defines the desired state of a service.
|
// ServiceSpec defines the desired state of a service.
|
||||||
@@ -147,12 +165,8 @@ func (s *ServiceSpec) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.Name != "" {
|
if s.Name != "" {
|
||||||
if len(s.Name) > 63 {
|
if err := ValidateServiceName(s.Name); err != nil {
|
||||||
return fmt.Errorf("service name too long (max 63 characters): %q", s.Name)
|
return err
|
||||||
}
|
|
||||||
if !dnsLabelRegexp.MatchString(s.Name) {
|
|
||||||
return fmt.Errorf("invalid service name: %q. must be 1-63 characters, lowercase letters, numbers, "+
|
|
||||||
"and dashes only; must start and end with a letter or number", s.Name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
@@ -9,6 +10,59 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestServiceSpec_Validate_Name(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "empty allows generated name"},
|
||||||
|
{name: "single character", input: "a"},
|
||||||
|
{name: "single digit", input: "1"},
|
||||||
|
{name: "two characters", input: "a1"},
|
||||||
|
{name: "hyphens and digits", input: "1-web-2"},
|
||||||
|
{name: "consecutive hyphens", input: "web--1"},
|
||||||
|
{name: "maximum length", input: strings.Repeat("a", 63)},
|
||||||
|
{name: "maximum length with hyphens", input: "a" + strings.Repeat("-", 61) + "1"},
|
||||||
|
{name: "round-robin mode is a valid service name", input: "rr"},
|
||||||
|
{name: "nearest mode is a valid service name", input: "nearest"},
|
||||||
|
{name: "machine namespace prefix", input: "m-service"},
|
||||||
|
{name: "short hexadecimal name", input: strings.Repeat("a", 31)},
|
||||||
|
{name: "long hexadecimal name", input: strings.Repeat("a", 33)},
|
||||||
|
{name: "non-hexadecimal 32 characters", input: strings.Repeat("g", 32)},
|
||||||
|
{name: "too long", input: strings.Repeat("a", 64), wantErr: "must be 1-63 characters"},
|
||||||
|
{name: "uppercase", input: "WEB1", wantErr: "lowercase letters"},
|
||||||
|
{name: "leading hyphen", input: "-web", wantErr: "starting and ending"},
|
||||||
|
{name: "trailing hyphen", input: "web-", wantErr: "starting and ending"},
|
||||||
|
{name: "hyphen only", input: "-", wantErr: "starting and ending"},
|
||||||
|
{name: "underscore", input: "web_1", wantErr: "hyphens only"},
|
||||||
|
{name: "dot", input: "web.example", wantErr: "hyphens only"},
|
||||||
|
{name: "slash", input: "web/api", wantErr: "hyphens only"},
|
||||||
|
{name: "space", input: "web 1", wantErr: "hyphens only"},
|
||||||
|
{name: "leading whitespace", input: " web", wantErr: "hyphens only"},
|
||||||
|
{name: "trailing whitespace", input: "web ", wantErr: "hyphens only"},
|
||||||
|
{name: "tab", input: "web\t1", wantErr: "hyphens only"},
|
||||||
|
{name: "newline", input: "web\n", wantErr: "hyphens only"},
|
||||||
|
{name: "non-ASCII", input: "wéb", wantErr: "lowercase letters"},
|
||||||
|
{name: "machine DNS namespace", input: "m", wantErr: "reserved for the machine DNS namespace"},
|
||||||
|
{name: "service ID", input: "c337f00600de51ef4375c9a9a267dba5", wantErr: "service ID format"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
spec := ServiceSpec{Name: tt.input, Container: ContainerSpec{Image: "nginx:latest"}}
|
||||||
|
err := spec.Validate()
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.ErrorContains(t, err, tt.wantErr)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) {
|
func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ includes it and configure Caddy to use it.
|
|||||||
|
|
||||||
## Enabling cluster storage
|
## Enabling cluster storage
|
||||||
|
|
||||||
|
:::warning Certificate migration
|
||||||
|
|
||||||
|
Caddy doesn't migrate existing certificates automatically when you change its storage backend. **All certificates will
|
||||||
|
need to be reissued unless you migrate your existing storage first.** Use the experimental
|
||||||
|
[`caddy storage export` and `caddy storage import`](https://caddyserver.com/docs/command-line#caddy-storage)
|
||||||
|
commands with the old and new configs to transfer the storage contents.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
:::info Requirements
|
:::info Requirements
|
||||||
|
|
||||||
Cluster storage requires Uncloud **v0.21.0 or newer** for both the `uc` CLI and the daemon on every cluster machine.
|
Cluster storage requires Uncloud **v0.21.0 or newer** for both the `uc` CLI and the daemon on every cluster machine.
|
||||||
@@ -37,7 +46,7 @@ Create a global Caddyfile, or add `storage uncloud` to the global options in you
|
|||||||
Deploy Caddy with the pre-built module image and your global config:
|
Deploy Caddy with the pre-built module image and your global config:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
uc caddy deploy --image ghcr.io/unlabs-dev/caddy-uncloud:0.1.1 --caddyfile global.Caddyfile
|
uc caddy deploy --image ghcr.io/unlabs-dev/caddy-uncloud:0.1.3 --caddyfile global.Caddyfile
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [module README](https://github.com/unlabs-dev/caddy-uncloud#usage) for custom image builds, Compose deployment,
|
See the [module README](https://github.com/unlabs-dev/caddy-uncloud#usage) for custom image builds, Compose deployment,
|
||||||
@@ -55,4 +64,10 @@ List issued certificates in cluster storage with [`uc caddy cert ls`](../../9-cl
|
|||||||
|
|
||||||
```shell
|
```shell
|
||||||
uc caddy cert ls
|
uc caddy cert ls
|
||||||
|
|
||||||
|
ID NAME ISSUER EXPIRES
|
||||||
|
c111e23615f7 dns.uncloud.run Let's Encrypt 2026-12-31 (2 months)
|
||||||
|
b5dcd2a03e1b nginx.2t5ex2.uncld.dev Let's Encrypt 2026-12-31 (2 months)
|
||||||
|
70707fd2fea2 test-staging.2t5ex2.uncld.dev Let's Encrypt (staging) 2026-12-31 (2 months)
|
||||||
|
c5f9301e1c06 uncloud.run Let's Encrypt 2026-12-31 (2 months)
|
||||||
```
|
```
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
Services can be addressed on the internal WireGuard network by service name, service ID, or a machine-scoped service name:
|
Services can be addressed on the internal WireGuard network by service name, service ID, or a machine-scoped service name:
|
||||||
|
|
||||||
## Service name
|
## Service name
|
||||||
|
|
||||||
```
|
```
|
||||||
$ nslookup nats.internal
|
$ nslookup nats.internal
|
||||||
Server: 127.0.0.11
|
Server: 127.0.0.11
|
||||||
@@ -30,6 +31,7 @@ Address: 10.210.1.4
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Service ID
|
## Service ID
|
||||||
|
|
||||||
```
|
```
|
||||||
$ nslookup 3ecb3a8bbec5fd3f46efb056a934714a.internal
|
$ nslookup 3ecb3a8bbec5fd3f46efb056a934714a.internal
|
||||||
Server: 127.0.0.11
|
Server: 127.0.0.11
|
||||||
@@ -40,6 +42,7 @@ Address: 10.210.0.4
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Machine ID scoped service name
|
## Machine ID scoped service name
|
||||||
|
|
||||||
```
|
```
|
||||||
$ nslookup 0903f0ee483aa97d559eeeaac5e22283.m.nats.internal
|
$ nslookup 0903f0ee483aa97d559eeeaac5e22283.m.nats.internal
|
||||||
Server: 127.0.0.11
|
Server: 127.0.0.11
|
||||||
@@ -64,7 +67,8 @@ Address: 10.210.1.4
|
|||||||
|
|
||||||
Additionally, the IP ordering preference can be specified with a `rr` (round-robin) or `nearest` subdomain prefix.
|
Additionally, the IP ordering preference can be specified with a `rr` (round-robin) or `nearest` subdomain prefix.
|
||||||
|
|
||||||
### `rr` (round-robin) *current default*
|
### `rr` (round-robin) _current default_
|
||||||
|
|
||||||
Randomly shuffled order on each lookup.
|
Randomly shuffled order on each lookup.
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -96,9 +100,11 @@ Address: 10.210.0.3
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Nearest scope
|
## Nearest scope
|
||||||
|
|
||||||
Returns machine-local instances first.
|
Returns machine-local instances first.
|
||||||
|
|
||||||
`machine-a`:
|
`machine-a`:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ nslookup nearest.worker.internal
|
$ nslookup nearest.worker.internal
|
||||||
Server: 127.0.0.11
|
Server: 127.0.0.11
|
||||||
@@ -115,6 +121,7 @@ Address: 10.210.1.4
|
|||||||
```
|
```
|
||||||
|
|
||||||
`machine-b`:
|
`machine-b`:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ nslookup nearest.worker.internal
|
$ nslookup nearest.worker.internal
|
||||||
Server: 127.0.0.11
|
Server: 127.0.0.11
|
||||||
@@ -131,3 +138,28 @@ Address: 10.210.0.4
|
|||||||
```
|
```
|
||||||
|
|
||||||
The prefixes can be used with service ID and machine-scoped service names, as well (e.g. `nearest.3ecb3a8bbec5fd3f46efb056a934714a.internal` or `rr.0903f0ee483aa97d559eeeaac5e22283.m.worker.internal`).
|
The prefixes can be used with service ID and machine-scoped service names, as well (e.g. `nearest.3ecb3a8bbec5fd3f46efb056a934714a.internal` or `rr.0903f0ee483aa97d559eeeaac5e22283.m.worker.internal`).
|
||||||
|
|
||||||
|
## Machine name
|
||||||
|
|
||||||
|
Machines can be addressed too on the internal WireGuard network, either by name of by ID, these names are
|
||||||
|
available from the `m.internal` zone.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ nslookup machine-1.m.internal
|
||||||
|
Server: 127.0.0.11
|
||||||
|
Address: 127.0.0.11#53
|
||||||
|
|
||||||
|
Name: machine-1.m.internal
|
||||||
|
Address: 10.210.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Machine ID
|
||||||
|
|
||||||
|
```
|
||||||
|
$ nslookup c337f00600de51ef4375c9a9a267dba5.m.internal
|
||||||
|
Server: 127.0.0.11
|
||||||
|
Address: 127.0.0.11#53
|
||||||
|
|
||||||
|
Name: c337f00600de51ef4375c9a9a267dba5.m.internal
|
||||||
|
Address: 10.210.0.1
|
||||||
|
```
|
||||||
@@ -21,13 +21,16 @@ uc caddy logs [flags]
|
|||||||
-m, --machine strings Filter logs by machine name or ID. Can be specified multiple times or as a comma-separated list.
|
-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.
|
--since string Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.
|
||||||
Examples:
|
Examples:
|
||||||
--since 2m30s Relative duration (2 minutes 30 seconds ago)
|
--since 1h45m Relative duration (1 hour 45 minutes ago)
|
||||||
--since 1h Relative duration (1 hour ago)
|
Supported units: d (day = 24h), h (hour), m (minute),
|
||||||
--since 2025-11-24 RFC 3339 date only (midnight using local timezone)
|
s (second), ms (millisecond),
|
||||||
--since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone
|
us/µs (microsecond), ns (nanosecond)
|
||||||
|
--since 2025-11-24 RFC 3339 date only (midnight using client local timezone)
|
||||||
|
--since 2024-05-14T22:50:00 RFC 3339 date/time using client local timezone
|
||||||
--since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC
|
--since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC
|
||||||
--since 1763953966 Unix timestamp (seconds since January 1, 1970)
|
--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")
|
-n, --tail string Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.
|
||||||
|
Defaults to 100, or 'all' when --since is set.
|
||||||
--until string Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.
|
--until string Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.
|
||||||
See --since for examples.
|
See --since for examples.
|
||||||
--utc Print timestamps in UTC instead of local timezone.
|
--utc Print timestamps in UTC instead of local timezone.
|
||||||
|
|||||||
@@ -56,13 +56,16 @@ uc logs [SERVICE[/CONTAINER]...] [flags]
|
|||||||
-m, --machine strings Filter logs by machine name or ID. Can be specified multiple times or as a comma-separated list.
|
-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.
|
--since string Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.
|
||||||
Examples:
|
Examples:
|
||||||
--since 2m30s Relative duration (2 minutes 30 seconds ago)
|
--since 1h45m Relative duration (1 hour 45 minutes ago)
|
||||||
--since 1h Relative duration (1 hour ago)
|
Supported units: d (day = 24h), h (hour), m (minute),
|
||||||
--since 2025-11-24 RFC 3339 date only (midnight using local timezone)
|
s (second), ms (millisecond),
|
||||||
--since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone
|
us/µs (microsecond), ns (nanosecond)
|
||||||
|
--since 2025-11-24 RFC 3339 date only (midnight using client local timezone)
|
||||||
|
--since 2024-05-14T22:50:00 RFC 3339 date/time using client local timezone
|
||||||
--since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC
|
--since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC
|
||||||
--since 1763953966 Unix timestamp (seconds since January 1, 1970)
|
--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")
|
-n, --tail string Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.
|
||||||
|
Defaults to 100, or 'all' when --since is set.
|
||||||
--until string Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.
|
--until string Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.
|
||||||
See --since for examples.
|
See --since for examples.
|
||||||
--utc Print timestamps in UTC instead of local timezone.
|
--utc Print timestamps in UTC instead of local timezone.
|
||||||
|
|||||||
@@ -52,13 +52,16 @@ uc machine logs [SERVICE...] [flags]
|
|||||||
-m, --machine strings Filter logs by machine name or ID. Can be specified multiple times or as a comma-separated list.
|
-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.
|
--since string Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.
|
||||||
Examples:
|
Examples:
|
||||||
--since 2m30s Relative duration (2 minutes 30 seconds ago)
|
--since 1h45m Relative duration (1 hour 45 minutes ago)
|
||||||
--since 1h Relative duration (1 hour ago)
|
Supported units: d (day = 24h), h (hour), m (minute),
|
||||||
--since 2025-11-24 RFC 3339 date only (midnight using local timezone)
|
s (second), ms (millisecond),
|
||||||
--since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone
|
us/µs (microsecond), ns (nanosecond)
|
||||||
|
--since 2025-11-24 RFC 3339 date only (midnight using client local timezone)
|
||||||
|
--since 2024-05-14T22:50:00 RFC 3339 date/time using client local timezone
|
||||||
--since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC
|
--since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC
|
||||||
--since 1763953966 Unix timestamp (seconds since January 1, 1970)
|
--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")
|
-n, --tail string Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.
|
||||||
|
Defaults to 100, or 'all' when --since is set.
|
||||||
--until string Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.
|
--until string Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.
|
||||||
See --since for examples.
|
See --since for examples.
|
||||||
--utc Print timestamps in UTC instead of local timezone.
|
--utc Print timestamps in UTC instead of local timezone.
|
||||||
|
|||||||
@@ -56,13 +56,16 @@ uc service logs [SERVICE[/CONTAINER]...] [flags]
|
|||||||
-m, --machine strings Filter logs by machine name or ID. Can be specified multiple times or as a comma-separated list.
|
-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.
|
--since string Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.
|
||||||
Examples:
|
Examples:
|
||||||
--since 2m30s Relative duration (2 minutes 30 seconds ago)
|
--since 1h45m Relative duration (1 hour 45 minutes ago)
|
||||||
--since 1h Relative duration (1 hour ago)
|
Supported units: d (day = 24h), h (hour), m (minute),
|
||||||
--since 2025-11-24 RFC 3339 date only (midnight using local timezone)
|
s (second), ms (millisecond),
|
||||||
--since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone
|
us/µs (microsecond), ns (nanosecond)
|
||||||
|
--since 2025-11-24 RFC 3339 date only (midnight using client local timezone)
|
||||||
|
--since 2024-05-14T22:50:00 RFC 3339 date/time using client local timezone
|
||||||
--since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC
|
--since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC
|
||||||
--since 1763953966 Unix timestamp (seconds since January 1, 1970)
|
--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")
|
-n, --tail string Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.
|
||||||
|
Defaults to 100, or 'all' when --since is set.
|
||||||
--until string Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.
|
--until string Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.
|
||||||
See --since for examples.
|
See --since for examples.
|
||||||
--utc Print timestamps in UTC instead of local timezone.
|
--utc Print timestamps in UTC instead of local timezone.
|
||||||
|
|||||||
Reference in new issue
Block a user