Compare commits

...
25 Commits
Author SHA1 Message Date
Pasha Sviderski 965879b69f docs: regenerate CLI reference 2026-10-29 14:08:59 +10:00
Pasha Sviderski d66ec2bada chore: bump mise to 2026.10.22 on CI 2026-10-29 14:05:19 +10:00
Pasha Sviderski 20e2e97c36 chore(docs): update documentation generation commands and regenerate reference docs 2026-10-29 13:52:38 +10:00
Pasha SviderskiandMiek Gieben e9eab32e6d feat(images): add 'uc image rm' command to remove images from cluster machines
Co-authored-by: Miek Gieben <miek@miek.nl>
2026-10-29 13:17:20 +10:00
Pasha Sviderski 7c6d1be8a7 feat(images): implement RemoveImage gRPC API with extra protection for used images 2026-10-28 20:24:16 +10:00
Pasha Sviderski a2987d3ff2 chore: minor comments in common proto 2026-10-28 20:24:16 +10:00
Pasha Sviderski 3351111feb refactor(images): simplify image name handling when listing images 2026-10-28 20:24:16 +10:00
Pasha Sviderski bd695f65a3 refactor(grpc-proxy): use Metadata.Err() method to get upstream error for a broadcasted request 2026-10-28 20:24:16 +10:00
Pasha Sviderski 7e8736fa99 feat(images): include all tags and digest references when listing images 2026-10-28 20:24:16 +10:00
Pasha Sviderski 8160c9b9d0 move deploy.operation package to operation 2026-10-28 20:24:16 +10:00
Pasha Sviderski 80d2a323c6 fix: handle output redirect and NO_COLOR for 'uc caddy config' 2026-10-28 20:24:16 +10:00
Miek GiebenandGitHub 5d640b9a57 uc context show: take UNCLOUD_CONTEXT into consideration (#444)
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-10-14 10:32:55 +10:00
NickandGitHub ba224bb5be fix(deploy): prepare replacement before stop-first cutover (#420) 2026-10-12 16:02:54 +10:00
8ddbe3c606 fix(compose): strip and resolve pre-deploy hook secrets like service env (#424)
* fix(compose): strip and resolve pre-deploy hook secrets like service env (fixes #422)

normaliseContainerForStore only cleared Config.Env and
ServiceSpec.Container.Env before writing a container record to the
replicated Corrosion store, leaving ServiceSpec.PreDeploy.Env in place.
A secret set via x-pre_deploy.environment therefore replicated to
every machine in the cluster, unlike the same value set via
environment.

ResolveSecrets had the same asymmetry in the other direction: it only
resolved secret://name references in service.Environment, so a
reference placed in the pre-deploy hook's environment passed through
unresolved as a literal string. HasCommandSecretRefs is extended the
same way for consistency.

Add tests pinning both behaviours.

* update docs that secret references are available for pre-deploy hooks

---------

Co-authored-by: Pasha Sviderski <me@psviderski.name>
2026-10-12 15:48:47 +10:00
Pasha Sviderski ce1c6bd05a feat(machine): add validation for machine names to accept only DNS labels 2026-10-07 15:24:23 +10:00
Pasha Sviderski 11e1200fe8 docs: regenerate CLI reference 2026-10-07 15:00:40 +10:00
Miek GiebenandGitHub 0cdf721a6a feat(dns): implement DNS names for machines in m.internal (#359)
* feat(dns): implement extra dns names for machine in m.internal

This implements <machinename>.m.internal and <machineid>.m.internal as
names that can be queried in the cluster.

Listing all machines/services is not implemented, this may come later.

Fixes: #340

Signed-off-by: Miek Gieben <miek@miek.nl>

* Implement getting all machine address

A query for m.internal returns all ip address

There is no overflow check

Signed-off-by: Miek Gieben <miek@miek.nl>

---------

Signed-off-by: Miek Gieben <miek@miek.nl>
2026-10-07 14:54:28 +10:00
Pasha Sviderski 5e26cfbead feat(logs): add support for days unit (d) in --since/until filters for logs 2026-10-07 13:30:25 +10:00
Pasha Sviderski 409dc3bfe3 fix(logs): show all matched logs when --since filter specified 2026-10-07 13:30:25 +10:00
Pasha Sviderski 5fa236871f fix(logs): resolve time ranges using client local time zone for logs commands 2026-10-07 13:30:25 +10:00
Pasha Sviderski e3479409b1 docs(caddy): bump caddy-uncloud image to 0.1.3 2026-10-07 13:30:25 +10:00
Pasha Sviderski a1b6b31e9c fix(logs): implement color profiling for log output streams, e.g. disable color on redirect 2026-10-07 13:30:25 +10:00
Pasha Sviderski d04772dacd docs(caddy): add warning about certificate migration when changing storage 2026-10-07 13:30:25 +10:00
Pasha Sviderski 87691ec029 docs: bump caddy-uncloud image to 0.1.2 2026-10-07 13:30:25 +10:00
7ecf63169b feat(daemon): debounce container change notifications to avoid reconcile storms (#438)
* fix(store): debounce container change notifications to avoid reconcile storms

SubscribeContainers forwarded every raw row-level change event from
Corrosion as its own signal, with no coalescing. Each signal triggers
a full reconcile (ListContainers + regenerate) in every independent
subscriber (caddy-controller, DNS resolver) on every machine in the
cluster. With enough container/health-check churn across services,
this produces a sustained stream of events (observed ~1.6-1.7/sec
cluster-wide on a 45-service, 7-machine cluster) and burns meaningful
CPU on every machine continuously, regardless of whether that machine
runs Caddy locally. On a resource-constrained node this escalated into
kernel RCU stall warnings and a full freeze.

Coalesce bursts of events into a single signal per debounce window
instead of forwarding one per event.

* bound debounced changes by the window

---------

Co-authored-by: Pasha Sviderski <me@psviderski.name>
2026-10-07 13:30:09 +10:00
94 changed files with 12214 additions and 627 deletions
+5 -4
View File
@@ -14,12 +14,13 @@ on:
- "**.proto"
- "go.*"
- "Makefile"
- "mise.toml"
- "scripts/**"
- "test/**"
permissions:
contents: read
env:
MISE_VERSION: "2026.3.7"
MISE_VERSION: "2026.10.22"
jobs:
test:
runs-on: ubuntu-latest
@@ -42,9 +43,9 @@ jobs:
- name: Generate docs
run: |
make cli-docs
mise run docs:cli
git diff --exit-code ||
(echo "Documentation is out of date. Please run 'make cli-docs' and commit the changes." && exit 1)
(echo "Documentation is out of date. Please run 'mise run docs:cli' and commit the changes." && exit 1)
- name: Run tests
run: |
@@ -70,7 +71,7 @@ jobs:
version: ${{ env.MISE_VERSION }}
# TODO: figure out how to properly lock all platforms in the lockfile and remove this hack.
# `mise lock` (mise run mise:lock) doesn't generate checksums for some of the tools, e.g. protoc*
# `mise lock` doesn't generate checksums for some of the tools, e.g. protoc*
- name: Reset changes in mise.lock after fresh mise install
run: git checkout mise.lock
+1 -1
View File
@@ -14,7 +14,7 @@ on:
permissions:
contents: read
env:
MISE_VERSION: "2026.3.7"
MISE_VERSION: "2026.10.22"
jobs:
lint:
runs-on: ubuntu-latest
+1 -1
View File
@@ -8,7 +8,7 @@ permissions:
contents: write
env:
MISE_VERSION: "2026.3.7"
MISE_VERSION: "2026.10.22"
jobs:
nightly:
+6
View File
@@ -1,4 +1,10 @@
packages:
github.com/docker/docker/client:
config:
dir: internal/machine/docker
pkgname: docker
interfaces:
APIClient:
github.com/psviderski/uncloud/internal/machine/caddyconfig:
interfaces:
CaddyfileValidator:
+26 -3
View File
@@ -35,10 +35,11 @@ Build the CLI:
go build -o uc ./cmd/uc
```
Or build and run the CLI with a single command:
Or build and run the CLI with mise. It forwards arguments, including help flags, directly to the CLI:
```shell
go run ./cmd/uc --help
mise uc --help
mise uc image rm -h
```
The Uncloud daemon (`uncloudd`) only supports Linux, so you need to cross-compile it if you're developing on macOS or
@@ -116,9 +117,31 @@ mise run proto
Regenerate mocks:
```shell
make mocks
mise run mocks
```
## Documentation
Regenerate the CLI reference docs after changing commands or flags:
```shell
mise run docs:cli
```
To preview the documentation website, install its dependencies from the project root:
```shell
npm ci --prefix website
```
Then start the local development server:
```shell
mise run docs:dev
```
The server opens the site (http://localhost:3000/) in your browser and reloads it as you edit files in `website/`.
## Uncloud in Docker (UCinD)
The [`ucind`](./cmd/ucind) CLI lets you run Uncloud clusters locally using Docker containers instead of real machines.
-8
View File
@@ -21,10 +21,6 @@ corrosion-multiarch-image-push:
ucind-multiarch-image-push:
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
.PHONY: mocks
mocks:
@mockery
.PHONY: test
test:
ifeq ($(TEST_NAME),)
@@ -69,7 +65,3 @@ _lint:
# Explicitly set OS to Linux to not skip *_linux.go files when running on macOS.
# Uncloud daemon won't likely support OS other than Linux anytime soon, so for now we can rely on that.
GOOS=linux golangci-lint run $(ARGS)
.PHONY: cli-docs
cli-docs:
go run ./cmd/uc docs
+8
View File
@@ -4,8 +4,16 @@ import (
"bytes"
"fmt"
"net/netip"
"google.golang.org/grpc/status"
)
// Err returns the upstream error, preserving its gRPC status code.
// It returns nil for nil metadata or an absent or successful status.
func (m *Metadata) Err() error {
return status.ErrorProto(m.GetStatus())
}
func NewIP(addr netip.Addr) *IP {
// MarshalBinary always returns a nil error.
ip, _ := addr.MarshalBinary()
+2 -1
View File
@@ -87,7 +87,8 @@ type Metadata struct {
MachineName string `protobuf:"bytes,5,opt,name=machine_name,json=machineName,proto3" json:"machine_name,omitempty"`
// Address of the machine the response came from.
MachineAddr string `protobuf:"bytes,1,opt,name=machine_addr,json=machineAddr,proto3" json:"machine_addr,omitempty"`
// error is set if the request to upstream failed. The rest of the response is undefined.
// error is set if the request to upstream failed. The rest of the response apart from this metadata message
// is undefined.
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
// error as a gRPC Status message.
Status *status.Status `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
+2 -1
View File
@@ -18,7 +18,8 @@ message Metadata {
// Address of the machine the response came from.
string machine_addr = 1;
// error is set if the request to upstream failed. The rest of the response is undefined.
// error is set if the request to upstream failed. The rest of the response apart from this metadata message
// is undefined.
string error = 2;
// error as a gRPC Status message.
google.rpc.Status status = 3;
+719 -296
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -24,6 +24,7 @@ service Docker {
// Docker auth credentials if necessary.
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse);
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
@@ -179,6 +180,40 @@ message ListImagesRequest {
bytes options = 1;
}
message RemoveImageRequest {
string id = 1;
bool force = 2;
bool prune_children = 3;
repeated ImagePlatform platforms = 4;
}
// ImagePlatform describes an OCI image platform.
message ImagePlatform {
string architecture = 1;
string os = 2;
string os_version = 3;
repeated string os_features = 4;
string variant = 5;
}
message RemoveImageResponse {
// Must contain only one repeated messages field to allow broadcasting RemoveImage requests to multiple machines.
repeated MachineImageRemoval messages = 1;
}
message MachineImageRemoval {
Metadata metadata = 1;
repeated ImageRemoval results = 2;
}
// ImageRemoval reports an image deletion or an untagged image reference.
message ImageRemoval {
// The image ID of an image that was deleted.
string deleted = 1;
// The image reference of an image that was untagged.
string untagged = 2;
}
message ListImagesResponse {
// Must contain only one repeated messages field to allow broadcasting ListImages requests to multiple machines.
repeated MachineImages messages = 1;
+38
View File
@@ -32,6 +32,7 @@ const (
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
Docker_ListImages_FullMethodName = "/api.Docker/ListImages"
Docker_RemoveImage_FullMethodName = "/api.Docker/RemoveImage"
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
@@ -59,6 +60,7 @@ type DockerClient interface {
// Docker auth credentials if necessary.
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error)
RemoveImage(ctx context.Context, in *RemoveImageRequest, opts ...grpc.CallOption) (*RemoveImageResponse, error)
CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error)
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
@@ -217,6 +219,16 @@ func (c *dockerClient) ListImages(ctx context.Context, in *ListImagesRequest, op
return out, nil
}
func (c *dockerClient) RemoveImage(ctx context.Context, in *RemoveImageRequest, opts ...grpc.CallOption) (*RemoveImageResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RemoveImageResponse)
err := c.cc.Invoke(ctx, Docker_RemoveImage_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateVolumeResponse)
@@ -305,6 +317,7 @@ type DockerServer interface {
// Docker auth credentials if necessary.
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error)
RemoveImage(context.Context, *RemoveImageRequest) (*RemoveImageResponse, error)
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error)
@@ -358,6 +371,9 @@ func (UnimplementedDockerServer) InspectRemoteImage(context.Context, *InspectRem
func (UnimplementedDockerServer) ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListImages not implemented")
}
func (UnimplementedDockerServer) RemoveImage(context.Context, *RemoveImageRequest) (*RemoveImageResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RemoveImage not implemented")
}
func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented")
}
@@ -591,6 +607,24 @@ func _Docker_ListImages_Handler(srv interface{}, ctx context.Context, dec func(i
return interceptor(ctx, in, info, handler)
}
func _Docker_RemoveImage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveImageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DockerServer).RemoveImage(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Docker_RemoveImage_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DockerServer).RemoveImage(ctx, req.(*RemoveImageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateVolumeRequest)
if err := dec(in); err != nil {
@@ -760,6 +794,10 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListImages",
Handler: _Docker_ListImages_Handler,
},
{
MethodName: "RemoveImage",
Handler: _Docker_RemoveImage_Handler,
},
{
MethodName: "CreateVolume",
Handler: _Docker_CreateVolume_Handler,
+4 -11
View File
@@ -3,8 +3,8 @@ package caddy
import (
"context"
"fmt"
"os"
"charm.land/lipgloss/v2"
"github.com/alecthomas/chroma/v2/quick"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
@@ -15,7 +15,6 @@ import (
type configOptions struct {
machine string
noColor bool
}
func NewConfigCommand() *cobra.Command {
@@ -33,8 +32,6 @@ func NewConfigCommand() *cobra.Command {
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
"Name or ID of the machine to get the configuration from. (default is connected machine)")
cmd.Flags().BoolVar(&opts.noColor, "no-color", false,
"Disable syntax highlighting for the output.")
completion.MachinesFlag(cmd)
@@ -54,13 +51,9 @@ func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
}
// Print the Caddyfile with syntax highlighting.
if opts.noColor {
fmt.Print(config.Caddyfile)
} else {
if err = quick.Highlight(os.Stdout, config.Caddyfile, "caddy", "terminal256", "monokai"); err != nil {
// If highlighting fails, fall back to plain output.
fmt.Print(config.Caddyfile)
}
if err = quick.Highlight(lipgloss.Writer, config.Caddyfile, "caddy", "terminal256", "monokai"); err != nil {
// If highlighting fails, fall back to plain output.
lipgloss.Print(config.Caddyfile)
}
if config.LastReconciliationError != "" {
+1 -10
View File
@@ -22,15 +22,6 @@ func NewShowCommand() *cobra.Command {
}
func show(uncli *cli.CLI) error {
// discard errors, only show the current context, otherwise nothing
if uncli.Config == nil {
return nil
}
if len(uncli.Config.Contexts) == 0 {
return nil
}
fmt.Println(uncli.Config.CurrentContext)
fmt.Println(uncli.ContextOverrideOrCurrent())
return nil
}
+2 -2
View File
@@ -18,7 +18,7 @@ import (
"github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/operation"
"github.com/spf13/cobra"
)
@@ -305,7 +305,7 @@ const defaultFailedContainerLogsTail = 10
// UNCLOUD_FAILED_CONTAINER_LOGS_TAIL environment variable override when set and valid.
func failedContainerLogsTail() int {
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
}
}
+54 -48
View File
@@ -13,6 +13,7 @@ import (
"github.com/charmbracelet/colorprofile"
"github.com/containerd/platforms"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli"
@@ -90,9 +91,14 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
machines := cli.ExpandCommaSeparatedValues(opts.machines)
clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{
Machines: machines,
Name: opts.nameFilter,
var clusterImages []api.MachineImages
err = tui.RunSpinner(ctx, "Listing images...", func(ctx context.Context) error {
var err error
clusterImages, err = clusterClient.ListImages(ctx, api.ImageFilter{
Machines: machines,
Name: opts.nameFilter,
})
return err
})
if err != nil {
return fmt.Errorf("list images: %w", err)
@@ -102,7 +108,7 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
var rows []imageRow
for _, machineImages := range clusterImages {
if err := machineImages.Error(); err != nil {
if err := machineImages.Metadata.Err(); err != nil {
tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s",
machineImages.Metadata.MachineName, err))
continue
@@ -116,49 +122,7 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
store = "containerd"
}
// Process each image for this machine.
for _, img := range machineImages.Images {
// Show the first 12 chars without 'sha256:' as the image ID like Docker does.
id := strings.TrimPrefix(img.ID, "sha256:")[:12]
name := "<none>"
if len(img.RepoTags) > 0 && img.RepoTags[0] != "<none>:<none>" {
name = img.RepoTags[0]
}
imgPlatforms, _ := imagePlatforms(img)
formattedPlatforms := formatPlatforms(imgPlatforms)
created := ""
createdAt := time.Unix(img.Created, 0)
if !createdAt.IsZero() {
created = units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
}
size := units.HumanSizeWithPrecision(float64(img.Size), 3)
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
inUse := "-"
if img.Containers != -1 { // -1 means the info is not available.
if img.Containers > 0 {
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
} else {
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
}
}
rows = append(rows, imageRow{
id: id,
name: name,
platforms: formattedPlatforms,
createdHuman: created,
createdUnix: img.Created,
size: size,
inUse: inUse,
store: store,
machine: machineName,
})
}
rows = append(rows, imageRows(machineImages.Images, machineName, store)...)
}
if len(rows) == 0 {
@@ -184,13 +148,55 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
return nil
}
// imageRows keeps every stored name visible and retains digest-only and unnamed images.
func imageRows(images []image.Summary, machineName, store string) []imageRow {
var rows []imageRow
for _, img := range images {
// RepoTags includes stored digest names on containerd. RepoDigests can include derived names.
imageNames := img.RepoTags
if len(imageNames) == 0 {
imageNames = []string{"<untagged>"}
}
imgPlatforms, _ := imagePlatforms(img)
created := ""
if img.Created > 0 {
created = units.HumanDuration(time.Since(time.Unix(img.Created, 0))) + " ago"
}
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
inUse := "-"
if img.Containers >= 0 { // -1 means the info is not available.
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
if img.Containers > 0 {
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
}
}
for _, name := range imageNames {
rows = append(rows, imageRow{
id: stringid.TruncateID(img.ID),
name: name,
platforms: formatPlatforms(imgPlatforms),
createdHuman: created,
createdUnix: img.Created,
size: units.HumanSizeWithPrecision(float64(img.Size), 3),
inUse: inUse,
store: store,
machine: machineName,
})
}
}
return rows
}
// imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform.
func imagePlatforms(img image.Summary) ([]string, bool) {
var formattedPlatforms []string
multiPlatform := false
for _, m := range img.Manifests {
if m.Kind != image.ManifestKindImage || !m.Available {
if m.Kind != image.ManifestKindImage || !m.Available || m.ImageData == nil {
continue
}
+137
View File
@@ -0,0 +1,137 @@
package image
import (
"context"
"errors"
"fmt"
"os"
"charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/spf13/cobra"
)
type removeOptions struct {
machines []string
yes bool
}
func NewRemoveCommand() *cobra.Command {
opts := removeOptions{}
cmd := &cobra.Command{
Use: "rm IMAGE [IMAGE...]",
Aliases: []string{"remove", "delete"},
Short: "Remove images from machines in the cluster.",
Long: `Remove images from all or selected machines in the cluster.
An image name without a tag selects all its tags. Specify a tag, such as myapp:latest, to remove
only that tag.
Wildcard patterns are supported for image names and tags and should be quoted to prevent your
shell from expanding them. Removing by image ID attempts to remove each name for that image
independently.
Images used by running or stopped containers cannot be removed. Unused tags can be removed even
when the same image is used under another tag. Failed removals do not prevent other removals.`,
Example: ` # Remove a tag from all machines after confirming the plan.
uc image rm myapp:old
# Remove all tags for an image name from all machines.
uc image rm myapp
# Remove matching tags from two machines without confirmation.
uc image rm 'myapp:1.*' 'otherapp:*' -m machine1,machine2 --yes
# Remove all names for an image by ID from all machines.
uc image rm ee8fed0e9618`,
Args: cobra.MinimumNArgs(1),
PreRun: func(cmd *cobra.Command, args []string) {
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
},
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return remove(cmd.Context(), uncli, args, opts)
},
}
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
"Machine names or IDs to remove images from. Can be specified multiple times or as a comma-separated list.\n"+
"If not specified, the matching images will be removed from all machines.")
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Auto-confirm the removal plan. Should be explicitly set when running non-interactively,\n"+
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
completion.MachinesFlag(cmd)
return cmd
}
func remove(ctx context.Context, uncli *cli.CLI, images []string, opts removeOptions) error {
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
plan, err := planImageRemoval(ctx, clusterClient, images, cli.ExpandCommaSeparatedValues(opts.machines))
if err != nil {
return fmt.Errorf("plan image removal: %w", err)
}
directConn := uncli.DirectConnection()
contextName := uncli.ContextOverrideOrCurrent()
cluster := ""
if directConn != "" {
cluster = directConn
lipgloss.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
lipgloss.Println()
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
// Only show context if there's more than one to avoid unnecessary clutter.
cluster = contextName
lipgloss.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
lipgloss.Println()
}
lipgloss.Println(plan.Format())
if !opts.yes {
if !tui.IsTerminalAvailable() {
return errors.New("cannot ask to confirm image removal in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
}
title := "Proceed with image removal?"
if cluster != "" {
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
title = "Proceed with image removal on " + tui.NameStyle.Render(cluster) + confirmStyle.Render("?")
}
confirmed, err := tui.Confirm(title)
if err != nil {
return fmt.Errorf("confirm image removal: %w", err)
}
if !confirmed {
return cli.Cancelled("Image removal cancelled. No images were removed.")
}
}
untagged := make(map[string]bool)
var errs []error
for i, group := range plan.GroupPlans {
if i > 0 {
fmt.Println()
}
err := progress.RunWithTitle(ctx, func(ctx context.Context) error {
return group.Execute(ctx, clusterClient, untagged)
}, uncli.ProgressOut(), "Removing image "+group.Image)
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
+426
View File
@@ -0,0 +1,426 @@
package image
import (
"context"
"errors"
"fmt"
"path"
"regexp"
"slices"
"strings"
"charm.land/lipgloss/v2"
"github.com/distribution/reference"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
)
var imageIDRegex = regexp.MustCompile(`^(sha256:)?[a-f0-9]{4,64}$`)
// imageRemovalPlan contains all image removal operations grouped by the user-supplied image name, ID, or pattern.
type imageRemovalPlan struct {
GroupPlans []*removeImageGroupPlan
}
func (p *imageRemovalPlan) IsEmpty() bool { return len(p.GroupPlans) == 0 }
func (p *imageRemovalPlan) Format() string {
var out strings.Builder
machines := make(map[string]struct{})
removeCount := 0
for _, group := range p.GroupPlans {
out.WriteString(group.Format())
for _, op := range group.Operations {
machines[op.MachineName] = struct{}{}
removeCount++
}
}
summary := fmt.Sprintf("%s remove %s across %s %s",
tui.BoldRed.Render(fmt.Sprint(removeCount)),
tui.Faint.Render("·"),
tui.Bold.Render(fmt.Sprint(len(machines))),
pluralMachines(len(machines)),
)
out.WriteString("\n" + tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))) + "\n" + summary + "\n")
return out.String()
}
func pluralMachines(count int) string {
if count == 1 {
return "machine"
}
return "machines"
}
// Execute applies the frozen plan, collecting failures without discovering new targets.
func (p *imageRemovalPlan) Execute(ctx context.Context, cli api.ImageClient) error {
untagged := make(map[string]bool)
var errs []error
for _, group := range p.GroupPlans {
if err := group.Execute(ctx, cli, untagged); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// removeImageGroupPlan groups removals for one image name, ID, or pattern supplied by the user.
type removeImageGroupPlan struct {
// Image is the image name, ID, or pattern supplied by the user.
Image string
Operations []*removeImageOperation
}
func (o *removeImageGroupPlan) Execute(ctx context.Context, cli api.ImageClient, untagged map[string]bool) error {
var errs []error
for _, op := range o.Operations {
if err := ctx.Err(); err != nil {
return errors.Join(append(errs, err)...)
}
if err := op.Execute(ctx, cli, untagged); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (o *removeImageGroupPlan) Format() string {
var out strings.Builder
out.WriteString(tui.BoldRed.Render("-") + " remove image " + tui.NameStyle.Render(o.Image) + "\n")
for i, op := range o.Operations {
branch := " ├── "
if i == len(o.Operations)-1 {
branch = " ╰── "
}
out.WriteString(tui.Faint.Render(branch) + op.Format() + "\n")
}
return out.String()
}
func (o *removeImageGroupPlan) String() string {
return fmt.Sprintf("removeImageGroupPlan[image=%s]", o.Image)
}
// removeImageOperation removes an image name or an unnamed image by ID on a machine.
type removeImageOperation struct {
MachineName string
ImageID string
// Image is the exact image name or ID sent to Docker.
Image string
}
func (o *removeImageOperation) Execute(ctx context.Context, cli api.ImageClient, untagged map[string]bool) error {
if untagged[o.MachineName+"\x00"+o.Image] {
return nil
}
results, err := cli.RemoveImage(ctx, o.Image, api.RemoveImageOptions{Machines: []string{o.MachineName}})
// A removal may also untag other planned names. Track actual results across all groups, per machine.
for _, result := range results {
for _, removal := range result.Results {
if removal.Untagged != "" {
untagged[result.Metadata.MachineName+"\x00"+removal.Untagged] = true
}
}
}
if errors.Is(err, api.ErrNotFound) {
return nil
}
if err != nil {
return fmt.Errorf("remove image '%s': %w", o.Image, err)
}
return nil
}
func (o *removeImageOperation) Format() string {
return tui.BoldRed.Render("-") + " " + tui.FormatImage(o.Image, tui.NoStyle) +
tui.Faint.Render(" ("+stringid.TruncateID(o.ImageID)+") on ") + o.MachineName
}
func (o *removeImageOperation) String() string {
return fmt.Sprintf("removeImageOperation[machine_name=%s image=%s]", o.MachineName, o.Image)
}
type machineImageInventory struct {
machineName string
images []image.Summary
}
// planImageRemoval builds a removal plan grouped by input argument from images on the selected machines.
// An empty machines list selects all machines.
//
// Arguments expand as follows:
// - Name: without a tag or digest, select all stored names in that exact repository across image IDs.
// An explicit tag selects only that tag. Familiar names are normalised.
// - ID: select all RepoTags for the matching image, or its full ID if unnamed. A prefix must identify
// one unique full ID across the selected machines.
// - Pattern: match RepoTags only. A matching digest also selects that image's names in the same repository.
// - Digest: resolve through RepoTags, RepoDigests, or the image ID, then select that image's names in
// the same repository. If none exist but RepoDigests contains the reference, select the digest itself.
//
// Selecting all tags for a repository/image on a machine also adds its stored digest references after
// the tags, even if those digests did not match an argument. Stored names are kept as is, and overlapping
// selections produce only one operation per machine and name.
//
// Failed image listings produce warnings and exclude those machines from the plan. If no machine can
// be queried, planning fails. Every argument must match on a successfully queried machine. Unmatched
// or invalid arguments and ambiguous ID prefixes reject the whole plan before any removal.
//
// Planning does not check container usage. The backend checks each removal during execution, and failures
// do not stop other operations. Tag removal may implicitly untag a used digest. Execution skips names
// already untagged by earlier operations and tolerates names that disappeared after planning.
func planImageRemoval(ctx context.Context, cli api.ImageClient, images, machines []string) (*imageRemovalPlan, error) {
if len(images) == 0 {
return nil, errors.New("at least one image is required")
}
var clusterImages []api.MachineImages
err := tui.RunSpinner(ctx, "Finding images to remove...", func(ctx context.Context) error {
var err error
clusterImages, err = cli.ListImages(ctx, api.ImageFilter{Machines: machines})
return err
})
if err != nil {
return nil, fmt.Errorf("list images: %w", err)
}
var inventory []*machineImageInventory
for _, machineImages := range clusterImages {
if err := machineImages.Metadata.Err(); err != nil {
tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s",
machineImages.Metadata.MachineName, err))
continue
}
machine := &machineImageInventory{
machineName: machineImages.Metadata.MachineName,
images: machineImages.Images,
}
inventory = append(inventory, machine)
}
if len(inventory) == 0 {
return nil, errors.New("failed to list images on any machine")
}
return planImageRemovalFromInventory(images, inventory)
}
func planImageRemovalFromInventory(images []string, inventory []*machineImageInventory) (*imageRemovalPlan, error) {
plan := &imageRemovalPlan{}
seen := make(map[string]bool)
var errs []error
for _, imageArg := range images {
group := &removeImageGroupPlan{Image: imageArg}
isID := imageIDRegex.MatchString(imageArg)
isPattern := strings.ContainsAny(imageArg, "*?[")
var namedRef reference.Named
if isPattern {
if _, err := path.Match(imageArg, ""); err != nil {
return nil, fmt.Errorf("invalid image pattern '%s': %w", imageArg, err)
}
} else if !isID {
var err error
namedRef, err = reference.ParseNormalizedNamed(imageArg)
if err != nil {
return nil, fmt.Errorf("invalid image '%s': %w", imageArg, err)
}
}
if isID {
matches := make(map[string]bool)
for _, machine := range inventory {
for _, img := range machine.images {
if imageIDMatches(img.ID, imageArg) {
matches[img.ID] = true
}
}
}
if len(matches) > 1 {
var candidates []string
for id := range matches {
candidates = append(candidates, id)
}
slices.Sort(candidates)
errs = append(errs,
fmt.Errorf("image ID '%s' is ambiguous: %s", imageArg, strings.Join(candidates, ", ")))
continue
}
}
matched := false
for _, machine := range inventory {
for _, img := range machine.images {
var imagesToRemove []string
switch {
case isID:
if !imageIDMatches(img.ID, imageArg) {
continue
}
matched = true
// Expand only stored names, matching uc image ls. RepoDigests remains available for digest lookup.
imagesToRemove = img.RepoTags
if len(imagesToRemove) == 0 {
imagesToRemove = []string{img.ID}
}
case isPattern:
for _, ref := range img.RepoTags {
if ok, _ := path.Match(imageArg, ref); ok {
named, _ := reference.ParseDockerRef(ref)
if _, digest := named.(reference.Canonical); digest {
imagesToRemove = append(imagesToRemove,
imageRepositoryReferences(img.RepoTags, named.Name())...)
} else {
imagesToRemove = append(imagesToRemove, ref)
}
}
}
case reference.IsNameOnly(namedRef):
imagesToRemove = imageRepositoryReferences(img.RepoTags, namedRef.Name())
default:
if canonical, digest := namedRef.(reference.Canonical); digest {
ref := reference.FamiliarString(namedRef)
if canonical.Digest().String() != img.ID && !slices.Contains(img.RepoTags, ref) &&
!slices.Contains(img.RepoDigests, ref) {
continue
}
imagesToRemove = imageRepositoryReferences(img.RepoTags, namedRef.Name())
if len(imagesToRemove) == 0 && slices.Contains(img.RepoDigests, ref) {
// An explicitly requested digest can resolve even when no stored name is listed.
imagesToRemove = []string{ref}
}
} else if slices.Contains(img.RepoTags, reference.FamiliarString(namedRef)) {
imagesToRemove = []string{reference.FamiliarString(namedRef)}
}
}
for _, ref := range imagesToRemove {
matched = true
key := machine.machineName + "\x00" + ref
if !seen[key] {
seen[key] = true
group.Operations = append(group.Operations, &removeImageOperation{
MachineName: machine.machineName, ImageID: img.ID, Image: ref,
})
}
}
}
}
if !matched {
errs = append(errs, fmt.Errorf("image '%s' not found on any machine", imageArg))
}
slices.SortFunc(group.Operations, compareImageRemovalOperations)
if len(group.Operations) > 0 {
plan.GroupPlans = append(plan.GroupPlans, group)
}
}
planDigestRemovals(plan, inventory)
if err := errors.Join(errs...); err != nil {
return nil, err
}
return plan, nil
}
func imageRepositoryReferences(refs []string, repository string) []string {
var matches []string
for _, ref := range refs {
if named, err := reference.ParseDockerRef(ref); err == nil && named.Name() == repository {
matches = append(matches, ref)
}
}
return matches
}
// planDigestRemovals includes stored digests with the group removing their last repository tag.
// RepoDigests also contains synthesised references, so only digest names stored in RepoTags get operations here.
func planDigestRemovals(plan *imageRemovalPlan, inventory []*machineImageInventory) {
type repository struct {
remainingTags int
digests []string
}
repositories := make(map[string]*repository)
for _, machine := range inventory {
for _, img := range machine.images {
names := slices.Clone(img.RepoTags)
slices.Sort(names)
for _, ref := range slices.Compact(names) {
named, err := reference.ParseDockerRef(ref)
if err != nil {
continue
}
key := machine.machineName + "\x00" + img.ID + "\x00" + named.Name()
repo := repositories[key]
if repo == nil {
repo = &repository{}
repositories[key] = repo
}
if _, tagged := named.(reference.Tagged); tagged {
repo.remainingTags++
} else if _, digest := named.(reference.Canonical); digest {
repo.digests = append(repo.digests, ref)
}
}
}
}
emitted := make(map[string]bool)
var groups []*removeImageGroupPlan
for _, group := range plan.GroupPlans {
var operations []*removeImageOperation
for _, op := range group.Operations {
key := op.MachineName + "\x00" + op.Image
if emitted[key] {
continue
}
emitted[key] = true
operations = append(operations, op)
named, err := reference.ParseDockerRef(op.Image)
if err != nil {
continue
}
if _, tagged := named.(reference.Tagged); !tagged {
continue
}
repo := repositories[op.MachineName+"\x00"+op.ImageID+"\x00"+named.Name()]
if repo == nil {
continue
}
repo.remainingTags--
if repo.remainingTags != 0 {
continue
}
for _, digest := range repo.digests {
key := op.MachineName + "\x00" + digest
if !emitted[key] {
emitted[key] = true
operations = append(operations, &removeImageOperation{
MachineName: op.MachineName, ImageID: op.ImageID, Image: digest,
})
}
}
}
group.Operations = operations
slices.SortFunc(group.Operations, compareImageRemovalOperations)
if len(group.Operations) > 0 {
groups = append(groups, group)
}
}
plan.GroupPlans = groups
}
func compareImageRemovalOperations(a, b *removeImageOperation) int {
aDigest, bDigest := strings.Contains(a.Image, "@"), strings.Contains(b.Image, "@")
if aDigest != bDigest {
if aDigest {
return 1
}
return -1
}
if c := strings.Compare(a.Image, b.Image); c != 0 {
return c
}
return strings.Compare(a.MachineName, b.MachineName)
}
func imageIDMatches(id, prefix string) bool {
return strings.HasPrefix(strings.TrimPrefix(id, "sha256:"), strings.TrimPrefix(prefix, "sha256:"))
}
+405
View File
@@ -0,0 +1,405 @@
package image
import (
"strings"
"testing"
"github.com/docker/docker/api/types/image"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPlanImageRemovalFromInventory_Selectors(t *testing.T) {
idA, idB := testImageID("a"), testImageID("b")
digestRef := "myapp@" + testImageID("c")
tests := []struct {
name string
selector string
inventory []*machineImageInventory
want []removeImageOperation
}{
{
name: "repository selects all stored names across images and machines", selector: "myapp",
inventory: []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "other:keep"),
testImage(idB, "myapp:latest"),
testImage(testImageID("c"), digestRef),
testImage(testImageID("d"), "myapp-extra:latest", "team/myapp:latest", "myapp/worker:latest"),
}),
testMachineInventory("machine2", []image.Summary{testImage(idB, "myapp:test")}),
},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idB, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "machine2", ImageID: idB, Image: "myapp:test"},
{MachineName: "dev1", ImageID: testImageID("c"), Image: digestRef},
},
},
{
name: "repository without latest includes its tags and stored digest", selector: "myapp",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", digestRef, "other:keep"),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "explicit latest selects only that tag", selector: "myapp:latest",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", digestRef),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}},
},
{
name: "qualified repository selects familiar names", selector: "docker.io/library/myapp",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", "other:keep"),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
},
},
{
name: "registry port does not count as an explicit tag", selector: "registry.example.com:5000/team/myapp",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "registry.example.com:5000/team/myapp:old", "registry.example.com:5000/team/myapp:new",
"registry.example.com:5001/team/myapp:keep", "registry.example.com:5000/team/myapp/worker:keep"),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:new"},
{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:old"},
},
},
{
name: "explicit tag with registry port stays exact", selector: "registry.example.com:5000/team/myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "registry.example.com:5000/team/myapp:old", "registry.example.com:5000/team/myapp:new"),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:old"}},
},
{
name: "tag uses each machines local image and allows missing copies", selector: "myapp:old",
inventory: []*machineImageInventory{
testMachineInventory("machine2", []image.Summary{testImage(idB, "myapp:old")}),
testMachineInventory("empty", nil),
testMachineInventory("dev1", []image.Summary{testImage(idA, "myapp:old")}),
},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "machine2", ImageID: idB, Image: "myapp:old"},
},
},
{
name: "qualified names use familiar references", selector: "docker.io/library/myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old"),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "full ID expands all repositories and deduplicates references per machine", selector: idA,
inventory: []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{
{
ID: idA,
RepoTags: []string{"other:keep", "myapp:old", "myapp:old", digestRef, "other@" + testImageID("c")},
RepoDigests: []string{
digestRef, "other@" + testImageID("c"), "other@" + testImageID("c"), testImageID("c"),
},
},
testImage(idB, "unrelated:keep"),
}),
testMachineInventory("machine2", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old", digestRef}, RepoDigests: []string{digestRef},
}}),
},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "machine2", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: "other:keep"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
{MachineName: "machine2", ImageID: idA, Image: digestRef},
{MachineName: "dev1", ImageID: idA, Image: "other@" + testImageID("c")},
},
},
{
name: "same full ID on multiple machines is unambiguous", selector: "aaaaaaaa",
inventory: []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{testImage(idA, "myapp:old")}),
testMachineInventory("dev1", []image.Summary{testImage(idB, "myapp:latest")}),
testMachineInventory("machine2", []image.Summary{testImage(idA, "other:test")}),
},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "machine2", ImageID: idA, Image: "other:test"},
},
},
{
name: "short ID with algorithm prefix", selector: "sha256:aaaaaaaa",
inventory: []*machineImageInventory{testMachineInventory("dev1",
[]image.Summary{testImage(idA, "myapp:old")})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "unnamed image is removed by its full ID", selector: idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{ID: idA}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: idA}},
},
{
name: "ID expands only stored names", selector: idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:latest"},
RepoDigests: []string{"myapp@" + idA, "other@" + idA},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}},
},
{
name: "ID removes an untagged image by ID even if RepoDigests is populated", selector: idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoDigests: []string{digestRef},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: idA}},
},
{
name: "synthesized digest selects repository tags without a digest row", selector: digestRef,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old", "other:keep", "myapp:latest"},
RepoDigests: []string{digestRef, "other@" + testImageID("c")},
}})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
},
},
{
name: "explicit digest lookup works without stored names", selector: digestRef,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoDigests: []string{digestRef},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
},
{
name: "synthesized digest resolves by target and repository even if not listed", selector: "myapp@" + idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old", "other:keep"}, RepoDigests: []string{"other@" + idA},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "stored digest follows its repository tags", selector: digestRef,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", digestRef, "other:keep"),
testImage(idB, "myapp@"+idB),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "digest pattern also expands matching repository tags", selector: "myapp@sha256:c*",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", digestRef, "other:keep"),
testImage(idB, "myapp@"+idB),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "digest pattern matches stored names and ignores derived names", selector: "myapp@sha256:*",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", digestRef),
{ID: idB, RepoTags: []string{"myapp:latest"}, RepoDigests: []string{"myapp@" + idB}},
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "last repository tag includes its stored digest", selector: "myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", digestRef, "other:keep"),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "last tag does not add a synthesized digest row", selector: "myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old", "other:keep"}, RepoDigests: []string{digestRef},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "remaining repository tag preserves its stored digest", selector: "myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", digestRef),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "digest without repository tags remains executable", selector: digestRef,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, digestRef, "other:keep"),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
},
{
name: "digest-only image keeps its named reference", selector: idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{digestRef}, RepoDigests: []string{digestRef, testImageID("c")},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
},
{
name: "tag pattern selects only matching tags", selector: "myapp:1.[12]",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:1.3", "myapp:1.2", "myapp:1.1"}, RepoDigests: []string{digestRef},
}})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:1.1"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:1.2"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plan, err := planImageRemovalFromInventory([]string{tt.selector}, tt.inventory)
require.NoError(t, err)
require.Len(t, plan.GroupPlans, 1)
assert.Equal(t, tt.selector, plan.GroupPlans[0].Image)
var got []removeImageOperation
for _, op := range plan.GroupPlans[0].Operations {
got = append(got, *op)
}
assert.Equal(t, tt.want, got)
})
}
}
func TestPlanImageRemovalFromInventory_OverlappingSelectors(t *testing.T) {
inventory := []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{testImage(testImageID("a"), "myapp:old", "myapp:latest")}),
testMachineInventory("machine2", []image.Summary{testImage(testImageID("b"), "myapp:test", "myapp:old")}),
}
plan, err := planImageRemovalFromInventory([]string{"myapp:old", "myapp", "myapp:*", testImageID("a"), "myapp:old"},
inventory)
require.NoError(t, err)
assert.Equal(t, &imageRemovalPlan{GroupPlans: []*removeImageGroupPlan{
{Image: "myapp:old", Operations: []*removeImageOperation{
{MachineName: "dev1", ImageID: testImageID("a"), Image: "myapp:old"},
{MachineName: "machine2", ImageID: testImageID("b"), Image: "myapp:old"},
}},
{Image: "myapp", Operations: []*removeImageOperation{
{MachineName: "dev1", ImageID: testImageID("a"), Image: "myapp:latest"},
{MachineName: "machine2", ImageID: testImageID("b"), Image: "myapp:test"},
}},
}}, plan, "the first selector owns each removal and empty groups are omitted")
}
func TestPlanImageRemovalFromInventory_DigestFollowsLastTagAcrossSelectors(t *testing.T) {
imageID := testImageID("a")
digestRef := "myapp@" + imageID
inventory := []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(imageID, "myapp:old", "myapp:latest", "myapp:old", digestRef, digestRef, "other:keep"),
})}
plan, err := planImageRemovalFromInventory([]string{"myapp:old", "myapp:latest", digestRef}, inventory)
require.NoError(t, err)
assert.Equal(t, &imageRemovalPlan{GroupPlans: []*removeImageGroupPlan{
{Image: "myapp:old", Operations: []*removeImageOperation{
{MachineName: "dev1", ImageID: imageID, Image: "myapp:old"},
}},
{Image: "myapp:latest", Operations: []*removeImageOperation{
{MachineName: "dev1", ImageID: imageID, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: imageID, Image: digestRef},
}},
}}, plan, "digest cleanup belongs to the last tag removal, so the later digest selector has no extra operation")
}
func TestPlanImageRemovalFromInventory_RejectsInvalidOrMissingSelectors(t *testing.T) {
idA := "sha256:abcdef" + strings.Repeat("a", 58)
idB := "sha256:abcdef" + strings.Repeat("b", 58)
inventory := []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old"}, RepoDigests: []string{"myapp@" + idA, "derived@" + idA},
}}),
testMachineInventory("machine2", []image.Summary{{ID: idB, RepoTags: []string{"myapp:old"}}}),
}
tests := []struct {
name string
images []string
err string
}{
{"missing repository", []string{"missing"}, "not found"},
{"repository only has a derived digest", []string{"derived"}, "not found"},
{"missing tag", []string{"myapp:missing"}, "not found"},
{"missing pattern", []string{"other:*"}, "not found"},
{"pattern only matches a derived digest", []string{"myapp@sha256:*"}, "not found"},
{"missing ID", []string{testImageID("c")}, "not found"},
{"ambiguous ID prefix", []string{"abcdef"}, "ambiguous"},
{"invalid name", []string{"MyApp:old"}, "invalid image"},
{"invalid pattern", []string{"myapp:["}, "invalid image pattern"},
{"one missing selector rejects the whole plan", []string{"myapp:old", "myapp:missing"}, "not found"},
{"one invalid selector rejects the whole plan", []string{"myapp:old", "myapp:["}, "invalid image pattern"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plan, err := planImageRemovalFromInventory(tt.images, inventory)
require.ErrorContains(t, err, tt.err)
assert.Nil(t, plan, "a failed selector must not return a partial plan")
})
}
t.Run("ID prefix resolves within the supplied machines", func(t *testing.T) {
plan, err := planImageRemovalFromInventory([]string{"abcdef"}, inventory[:1])
require.NoError(t, err)
require.Len(t, plan.GroupPlans, 1)
require.Len(t, plan.GroupPlans[0].Operations, 1)
assert.Equal(t, idA, plan.GroupPlans[0].Operations[0].ImageID)
})
}
func TestPlanImageRemovalFromInventory_InUseImagesAreStillPlanned(t *testing.T) {
img := testImage(testImageID("a"), "myapp:old", "myapp:current")
img.Containers = 1
for _, selector := range []string{"myapp", "myapp:current", img.ID, "myapp:*"} {
t.Run(selector, func(t *testing.T) {
plan, err := planImageRemovalFromInventory([]string{selector}, []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{img}),
})
require.NoError(t, err)
assert.False(t, plan.IsEmpty(), "usage protection belongs to the backend at execution time")
})
}
}
func testImageID(char string) string { return "sha256:" + strings.Repeat(char, 64) }
func testImage(imageID string, tags ...string) image.Summary {
return image.Summary{ID: imageID, RepoTags: tags}
}
func testMachineInventory(name string, images []image.Summary) *machineImageInventory {
return &machineImageInventory{machineName: name, images: images}
}
+1
View File
@@ -13,6 +13,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(
NewListCommand(),
NewPushCommand(),
NewRemoveCommand(),
)
return cmd
+12 -7
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"slices"
"strings"
"time"
"github.com/psviderski/uncloud/internal/cli"
"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 {
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 {
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)
if err != nil {
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{
Follow: opts.Follow,
Tail: tail,
Since: opts.Since,
Until: opts.Until,
Since: since,
Until: until,
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
}
+25
View File
@@ -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")
}
}
+2 -2
View File
@@ -54,8 +54,8 @@ func rtt(ctx context.Context, uncli *cli.CLI) error {
tui.PrintWarning("metadata is missing in response from unknown server")
continue
}
if m.Metadata.Error != "" {
tui.PrintWarning(fmt.Sprintf("failed to inspect machine '%s': %s", m.Metadata.MachineName, m.Metadata.Error))
if err := m.Metadata.Err(); err != nil {
tui.PrintWarning(fmt.Sprintf("failed to inspect machine '%s': %s", m.Metadata.MachineName, err))
continue
}
if m.Machine == nil {
+2 -2
View File
@@ -207,9 +207,9 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo
machineName := msc.Metadata.MachineName
if msc.Metadata.Error != "" {
if err := msc.Metadata.Err(); err != nil {
tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine %s: %s",
machineName, msc.Metadata.Error))
machineName, err))
continue
}
+12 -8
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
"time"
mapset "github.com/deckarep/golang-set/v2"
"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 {
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)
if err != nil {
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)
if err != nil {
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{
Follow: opts.Follow,
Tail: tail,
Since: opts.Since,
Until: opts.Until,
Since: since,
Until: until,
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
}
+25
View File
@@ -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")
}
}
+66
View File
@@ -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'
}
+62
View File
@@ -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)
})
}
}
+11 -6
View File
@@ -10,6 +10,7 @@ import (
"time"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/colorprofile"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
@@ -24,6 +25,10 @@ type Formatter struct {
maxServiceWidth int
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 {
@@ -50,6 +55,8 @@ func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter {
maxMachineWidth: maxMachineWidth,
maxServiceWidth: maxServiceWidth,
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.
if entry.Stream == api.LogStreamStderr {
fmt.Fprint(os.Stderr, output.String())
fmt.Fprint(f.stderr, output.String())
} 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) {
if entry.Metadata.ServiceName == "" {
msg := fmt.Sprintf("ERROR: %v", entry.Err)
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.BrightRed)
fmt.Fprintln(os.Stderr, style.Render(msg))
fmt.Fprintln(f.stderr, tui.BoldRed.Render(msg))
return
}
@@ -171,8 +177,7 @@ func (f *Formatter) printError(entry api.ServiceLogEntry) {
msg += fmt.Sprintf(": %v", entry.Err)
}
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.BrightYellow)
fmt.Fprintln(os.Stderr, style.Render(msg))
fmt.Fprintln(f.stderr, tui.BoldYellow.Render(msg))
}
// palette is available colors for machine/service differentiation.
+22 -7
View File
@@ -19,6 +19,18 @@ type Options struct {
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 {
set := &pflag.FlagSet{}
@@ -29,14 +41,17 @@ func Flags(options *Options) *pflag.FlagSet {
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 1h45m Relative duration (1 hour 45 minutes ago)\n"+
" Supported units: d (day = 24h), h (hour), m (minute),\n"+
" s (second), ms (millisecond),\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 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.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.\n"+
"Defaults to 100, or 'all' when --since is set.")
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.")
@@ -46,7 +61,7 @@ func Flags(options *Options) *pflag.FlagSet {
return set
}
func Tail(tail string) (int, error) {
func ParseTail(tail string) (int, error) {
if tail == "all" {
return -1, nil
}
+36
View File
@@ -7,6 +7,42 @@ import (
"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) {
t.Parallel()
+72
View File
@@ -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
}
+87
View File
@@ -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")
})
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ import (
type eventIDKey struct{}
// WithEventID returns a context that overrides the default event ID used by client methods.
// WithEventID returns a context that overrides the event ID already set in the context.
func WithEventID(ctx context.Context, eventID string) context.Context {
return context.WithValue(ctx, eventIDKey{}, eventID)
}
@@ -48,7 +48,7 @@ func OldPreDeployHookEventID(serviceName, containerID, machineName string) strin
tui.Faint.Render(" on ") + machineName
}
// ImageEventID returns a progress event ID for image pull operations.
// ImageEventID returns a progress event ID for image operations on a machine.
func ImageEventID(image, machineName string) string {
return tui.Faint.Render("Image ") + image +
tui.Faint.Render(" on ") + machineName
+3 -1
View File
@@ -3,9 +3,11 @@ package tui
import (
"fmt"
"os"
"charm.land/lipgloss/v2"
)
func PrintWarning(msg string) {
styledMsg := BoldYellow.Render(fmt.Sprintf("WARNING: %s", msg))
fmt.Fprintln(os.Stderr, styledMsg)
lipgloss.Fprintln(os.Stderr, styledMsg)
}
+15 -2
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os/exec"
"time"
"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 != "" {
args = append(args, "-S")
args = append(args, opts.Since)
args = append(args, journalTimestamp(opts.Since))
}
if opts.Until != "" {
args = append(args, "-U")
args = append(args, opts.Until)
args = append(args, journalTimestamp(opts.Until))
}
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
}
// 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.
// It stops when the reader is exhausted or the context is cancelled.
func follow(ctx context.Context, reader io.Reader, outCh chan api.LogEntry) {
+38
View File
@@ -115,6 +115,9 @@ func TestEntry(t *testing.T) {
}
func TestLogs(t *testing.T) {
originalCommandContext := commandContext
t.Cleanup(func() { commandContext = originalCommandContext })
commandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
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.
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)
}
+10 -1
View File
@@ -16,10 +16,19 @@ func NewMockCaddyfileValidator(t interface {
mock.TestingT
Cleanup(func())
}) *MockCaddyfileValidator {
if helper, ok := t.(interface{ Helper() }); ok {
helper.Helper()
}
mock := &MockCaddyfileValidator{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
t.Cleanup(func() {
if helper, ok := t.(interface{ Helper() }); ok {
helper.Helper()
}
mock.AssertExpectations(t)
})
return mock
}
+6
View File
@@ -14,6 +14,7 @@ import (
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/internal/machine/store"
"github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"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(
ctx context.Context, req *pb.AddMachineRequest,
) (*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 {
return nil, status.Error(codes.InvalidArgument, "network not set")
}
+28
View File
@@ -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")
})
}
}
+8 -4
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api"
)
// NewMachineID generates a new unique machine ID.
@@ -27,7 +28,7 @@ func NewRandomMachineName() (string, error) {
// a numeric suffix ("-1", "-2", etc.) if needed.
func DefaultMachineName(hostname string, existing []string) (string, error) {
name := machineNameFromHostname(hostname)
if name == "" {
if api.ValidateMachineName(name) != nil {
var err error
if name, err = NewRandomMachineName(); err != nil {
return "", err
@@ -38,7 +39,9 @@ func DefaultMachineName(hostname string, existing []string) (string, error) {
return name, nil
}
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) {
return candidate, nil
}
@@ -55,12 +58,13 @@ func machineNameFromHostname(hostname string) string {
var b strings.Builder
for _, r := range label {
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)
default:
b.WriteRune('-')
}
}
// 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)], "-")
}
+52 -10
View File
@@ -1,8 +1,10 @@
package cluster
import (
"strings"
"testing"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -18,9 +20,11 @@ func TestMachineNameFromHostname(t *testing.T) {
{"simple", "web", "web"},
{"fqdn uses first label", "web-1.example.com", "web-1"},
{"uppercase lowercased", "Web-Server", "web-server"},
{"invalid chars replaced", "host_name@1", "host_name-1"},
{"trim surrounding hyphens", "-_host_-", "_host_"},
{"invalid chars replaced", "host_name@1", "host-name-1"},
{"trim surrounding hyphens", "-_host_-", "host"},
{"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", "", ""},
{"only invalid chars", "@#", ""},
{"dot only", ".example.com", ""},
@@ -37,10 +41,12 @@ func TestDefaultMachineName(t *testing.T) {
t.Parallel()
t.Run("falls back to random", func(t *testing.T) {
// An empty or fully invalid hostname falls back to a random "machine-xxxx" name.
got, err := DefaultMachineName("***", nil)
require.NoError(t, err)
assert.Regexp(t, `^machine-[a-zA-Z0-9]{4}$`, got)
for _, hostname := range []string{"***", "rr", "nearest", strings.Repeat("a", 32)} {
got, err := DefaultMachineName(hostname, nil)
require.NoError(t, err)
assert.Regexp(t, `^machine-[a-z0-9]{4}$`, got)
require.NoError(t, api.ValidateMachineName(got))
}
})
tests := []struct {
@@ -49,10 +55,45 @@ func TestDefaultMachineName(t *testing.T) {
existing []string
want string
}{
{"from hostname", "web-1.example.com", nil, "web-1"},
{"dedup against existing", "web", []string{"web"}, "web-1"},
{"dedup multiple", "web", []string{"web", "web-1"}, "web-2"},
{"sanitized", "My_Host", nil, "my_host"},
{
name: "from hostname",
hostname: "web-1.example.com",
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 {
t.Run(tt.name, func(t *testing.T) {
@@ -60,6 +101,7 @@ func TestDefaultMachineName(t *testing.T) {
got, err := DefaultMachineName(tt.hostname, tt.existing)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
require.NoError(t, api.ValidateMachineName(got))
})
}
}
+65 -11
View File
@@ -10,6 +10,8 @@ import (
"sync"
"time"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/internal/machine/store"
)
@@ -19,7 +21,9 @@ type ClusterResolver struct {
store *store.Store
// serviceIPs maps service names to container IPs.
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
// lastUpdate tracks when records were last updated.
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.
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 {
select {
case _, ok := <-changes:
@@ -59,9 +70,22 @@ func (r *ClusterResolver) Run(ctx context.Context) error {
r.log.Error("Failed to list containers.", "err", err)
continue
}
// TODO: implement machine membership check using Corrossion Admin client to filter available 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():
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)
}
// Resolve returns IP addresses of the service containers.
func (r *ClusterResolver) Resolve(serviceName string) []netip.Addr {
func (r *ClusterResolver) updateMachineIPs(machines []*pb.MachineInfo) {
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()
defer r.mu.RUnlock()
ips, ok := r.serviceIPs[serviceName]
if !ok || len(ips) == 0 {
return nil
// Return a copy of the IPs slice to prevent modification of the original.
ips, ok := r.serviceIPs[name]
if ok && len(ips) > 0 {
return slices.Clone(ips)
}
// Return a copy of the IPs slice to prevent modification of the original.
ipsCopy := make([]netip.Addr, len(ips))
copy(ipsCopy, ips)
ip, ok := r.machineIPs[name]
if ok {
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
}
+31
View File
@@ -1,11 +1,13 @@
package dns
import (
"net/netip"
"reflect"
"testing"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/machine/store"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
@@ -75,3 +77,32 @@ func newRecord(serviceID, serviceName, ip, machineID string) store.ContainerReco
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)),
},
}
}
+4 -4
View File
@@ -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.
// 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 {
serviceName, mode := extractModeFromDomain(trimInternalDomain(name))
ips := s.resolver.Resolve(serviceName)
unname, mode := extractModeFromDomain(trimInternalDomain(name))
ips := s.resolver.Resolve(unname)
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
}
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 {
// Shuffle the IPs to approximate round-robin.
+6 -6
View File
@@ -167,7 +167,7 @@ func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions)
machineContainers := make([]MachineContainers, len(resp.Messages))
for i, msg := range resp.Messages {
machineContainers[i].Metadata = msg.Metadata
if msg.Metadata != nil && msg.Metadata.Error != "" {
if msg.Metadata.Err() != nil {
continue
}
@@ -258,7 +258,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
notFoundCount := 0
for _, msg := range resp.Messages {
if msg.Metadata != nil && msg.Metadata.Status != nil && codes.Code(msg.Metadata.Status.Code) == codes.NotFound {
if status.Code(msg.Metadata.Err()) == codes.NotFound {
notFoundCount++
}
}
@@ -269,7 +269,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
images := make([]api.MachineImage, len(resp.Messages))
for i, msg := range resp.Messages {
images[i].Metadata = msg.Metadata
if msg.Metadata != nil && msg.Metadata.Error != "" {
if msg.Metadata.Err() != nil {
continue
}
@@ -312,7 +312,7 @@ func parseRemoteImageMessage(msg *pb.RemoteImage) (api.MachineRemoteImage, error
mri := api.MachineRemoteImage{
Metadata: msg.Metadata,
}
if msg.Metadata != nil && msg.Metadata.Error != "" {
if msg.Metadata.Err() != nil {
return mri, nil
}
@@ -393,7 +393,7 @@ func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]Ma
machineVolumes := make([]MachineVolumes, len(resp.Messages))
for i, msg := range resp.Messages {
machineVolumes[i].Metadata = msg.Metadata
if msg.Metadata != nil && msg.Metadata.Error != "" {
if msg.Metadata.Err() != nil {
continue
}
@@ -472,7 +472,7 @@ func (c *Client) ListServiceContainers(
machineContainers := make([]MachineServiceContainers, len(resp.Messages))
for i, msg := range resp.Messages {
machineContainers[i].Metadata = msg.Metadata
if msg.Metadata != nil && msg.Metadata.Error != "" {
if msg.Metadata.Err() != nil {
continue
}
+1 -1
View File
@@ -27,7 +27,7 @@ const (
type Controller struct {
// machineID is the ID of the machine where the managed Docker daemon is running.
machineID string
client *client.Client
client client.APIClient
service *Service
store *store.Store
}
+79
View File
@@ -0,0 +1,79 @@
package docker
import (
"context"
"fmt"
"strings"
"github.com/containerd/errdefs"
"github.com/distribution/reference"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/pkg/api"
)
// RemoveImage removes an image while protecting names used by containers, even when force is requested.
func (s *Service) RemoveImage(ctx context.Context, id string, opts image.RemoveOptions) ([]image.DeleteResponse, error) {
if err := s.checkImageRemovalConflicts(ctx, id); err != nil {
return nil, err
}
// Docker enforces its own conflicts, including protection of a used image's final reference.
return s.Client.ImageRemove(ctx, id, opts)
}
// checkImageRemovalConflicts rejects an image ID used by a running or stopped container, or a name used by a
// container while it still resolves to that container's image. A digest request also checks the repository tags
// Docker would remove with it. Unused aliases are allowed.
func (s *Service) checkImageRemovalConflicts(ctx context.Context, id string) error {
img, err := s.Client.ImageInspect(ctx, id)
if err != nil {
return err
}
// Match Docker's resolved ID before parsing names, since a short ID also parses as a repository name.
isID := strings.HasPrefix(strings.TrimPrefix(img.ID, "sha256:"), strings.TrimPrefix(id, "sha256:"))
var named reference.Named
if !isID {
named, err = reference.ParseDockerRef(id)
if err != nil {
return errdefs.ErrInvalidArgument.WithMessage(err.Error())
}
}
_, isDigest := named.(reference.Canonical)
containers, err := s.Client.ContainerList(ctx, container.ListOptions{
All: true, Filters: filters.NewArgs(filters.Arg("ancestor", img.ID)),
})
if err != nil {
return fmt.Errorf("list containers using image: %w", err)
}
for _, ctr := range containers {
// The ancestor filter can include descendants. A retagged name must also be checked against its current ID.
if ctr.ImageID != img.ID {
continue
}
usedRef, err := reference.ParseDockerRef(ctr.Image)
// Familiar names make "alpine:latest" and "docker.io/library/alpine:latest" identify the same tag.
removesUsedName := err == nil && named != nil && reference.FamiliarString(usedRef) == reference.FamiliarString(named)
if isDigest && err == nil && usedRef.Name() == named.Name() {
// Removing a digest also removes this repository's tags pointing at the same image.
removesUsedName = true
}
if isID || removesUsedName {
ctrID, state := stringid.TruncateID(ctr.ID), "stopped"
if serviceName := ctr.Labels[api.LabelServiceName]; serviceName != "" {
ctrID = serviceName + "/" + ctrID
}
if ctr.State == container.StateRunning || ctr.State == container.StatePaused {
state = container.StateRunning
}
return errdefs.ErrConflict.WithMessage(
fmt.Sprintf("image '%s' is used by container '%s' (%s)", id, ctrID, state))
}
}
return nil
}
+175
View File
@@ -0,0 +1,175 @@
package docker
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestCheckImageRemovalConflicts(t *testing.T) {
imageID := "sha256:" + strings.Repeat("a", 64)
otherID := "sha256:" + strings.Repeat("b", 64)
digestRef := "myapp@" + imageID
tests := []struct {
name string
requested string
configured string
containerImageID string
serviceName string
blocked bool
}{
{name: "used tag", requested: "myapp:current", configured: "myapp:current", serviceName: "web", blocked: true},
{name: "qualified request", requested: "docker.io/library/myapp:current", configured: "myapp:current", blocked: true},
{name: "qualified container reference", requested: "myapp:current", configured: "docker.io/library/myapp:current", blocked: true},
{name: "implicit latest", requested: "myapp", configured: "myapp:latest", blocked: true},
{name: "unused alias", requested: "myapp:old", configured: "myapp:current"},
{name: "unused alias of ID-used image", requested: "myapp:old", configured: imageID},
{name: "tag may clean up a used digest", requested: "myapp:old", configured: digestRef},
{name: "used digest", requested: digestRef, configured: digestRef, blocked: true},
{name: "digest also removes used tag", requested: digestRef, configured: "myapp:current", blocked: true},
{name: "unused repository digest", requested: digestRef, configured: "other:keep"},
{name: "image ID", requested: imageID, configured: "myapp:current", blocked: true},
{name: "short image ID", requested: "aaaaaaaa", configured: "myapp:current", blocked: true},
{name: "short image ID with algorithm", requested: "sha256:aaaaaaaa", configured: "myapp:current", blocked: true},
{name: "retagged name", requested: "myapp:current", configured: "myapp:current", containerImageID: otherID},
}
for _, tt := range tests {
for _, state := range []string{container.StateExited, container.StateRunning} {
t.Run(fmt.Sprintf("%s/%s", tt.name, state), func(t *testing.T) {
containerImageID := tt.containerImageID
if containerImageID == "" {
containerImageID = imageID
}
cli := NewMockAPIClient(t)
cli.EXPECT().ImageInspect(mock.Anything, tt.requested).
Return(image.InspectResponse{ID: imageID}, nil).Once()
cli.EXPECT().ContainerList(mock.Anything, mock.MatchedBy(func(opts container.ListOptions) bool {
return opts.All // Stopped containers must be checked too.
})).Return([]container.Summary{{
ID: strings.Repeat("c", 64),
Labels: map[string]string{api.LabelServiceName: tt.serviceName},
Names: []string{"/web"},
ImageID: containerImageID,
Image: tt.configured,
State: state,
}}, nil).Once()
service := NewService(cli, nil)
err := service.checkImageRemovalConflicts(context.Background(), tt.requested)
if tt.blocked {
require.True(t, errdefs.IsConflict(err), "%v", err)
assert.ErrorContains(t, err, tt.requested)
if tt.serviceName != "" {
assert.ErrorContains(t, err, "container 'web/cccccccccccc'")
} else {
assert.ErrorContains(t, err, "container 'cccccccccccc'")
}
wantState := "stopped"
if state == container.StateRunning {
wantState = state
}
assert.ErrorContains(t, err, wantState)
} else {
require.NoError(t, err)
}
})
}
}
}
func TestCheckImageRemovalConflicts_PropagatesLookupErrors(t *testing.T) {
imageID := "sha256:" + strings.Repeat("a", 64)
t.Run("image lookup fails", func(t *testing.T) {
cli := NewMockAPIClient(t)
lookupErr := errdefs.ErrNotFound.WithMessage("no such image")
cli.EXPECT().ImageInspect(mock.Anything, "myapp:old").Return(image.InspectResponse{}, lookupErr).Once()
service := NewService(cli, nil)
require.ErrorIs(t, service.checkImageRemovalConflicts(context.Background(), "myapp:old"), lookupErr)
})
t.Run("container lookup fails", func(t *testing.T) {
cli := NewMockAPIClient(t)
lookupErr := errors.New("Docker unavailable")
cli.EXPECT().ImageInspect(mock.Anything, "myapp:old").Return(image.InspectResponse{ID: imageID}, nil).Once()
cli.EXPECT().ContainerList(mock.Anything, mock.Anything).Return(nil, lookupErr).Once()
service := NewService(cli, nil)
err := service.checkImageRemovalConflicts(context.Background(), "myapp:old")
require.ErrorIs(t, err, lookupErr)
assert.ErrorContains(t, err, "list containers using image")
})
}
func TestServiceRemoveImage_RejectsUsedNameBeforeDeletion(t *testing.T) {
imageID := "sha256:" + strings.Repeat("a", 64)
cli := NewMockAPIClient(t)
cli.EXPECT().ImageInspect(mock.Anything, "myapp:current").Return(image.InspectResponse{ID: imageID}, nil).Once()
cli.EXPECT().ContainerList(mock.Anything, mock.Anything).Return([]container.Summary{{
ID: "container-id", ImageID: imageID, Image: "myapp:current", State: container.StateRunning,
}}, nil).Once()
service := NewService(cli, nil)
// ImageRemove has no expectation: any deletion call would fail this test.
results, err := service.RemoveImage(context.Background(), "myapp:current", image.RemoveOptions{Force: true})
require.True(t, errdefs.IsConflict(err), "%v", err)
assert.Nil(t, results)
}
func TestServiceRemoveImage_DelegatesToDocker(t *testing.T) {
imageID := "sha256:" + strings.Repeat("a", 64)
tests := []struct {
name string
opts image.RemoveOptions
results []image.DeleteResponse
err error
}{
{
name: "options and results pass through",
opts: image.RemoveOptions{Force: true, PruneChildren: true, Platforms: []ocispec.Platform{{OS: "linux", Architecture: "amd64"}}},
results: []image.DeleteResponse{{Deleted: imageID}},
},
{
name: "Docker rejects final reference removal",
opts: image.RemoveOptions{PruneChildren: true},
err: errdefs.ErrConflict.WithMessage("container is using its referenced image"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cli := NewMockAPIClient(t)
cli.EXPECT().ImageInspect(mock.Anything, "myapp:old").Return(image.InspectResponse{ID: imageID}, nil).Once()
cli.EXPECT().ContainerList(mock.Anything, mock.Anything).Return([]container.Summary{{
ID: "container-id", ImageID: imageID, Image: "myapp@" + imageID, State: container.StateRunning,
}}, nil).Once()
cli.EXPECT().ImageRemove(mock.Anything, "myapp:old", tt.opts).Return(tt.results, tt.err).Once()
service := NewService(cli, nil)
results, err := service.RemoveImage(context.Background(), "myapp:old", tt.opts)
if tt.err != nil {
require.ErrorIs(t, err, tt.err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.results, results)
})
}
}
File diff suppressed because it is too large Load Diff
+36 -1
View File
@@ -56,7 +56,7 @@ var fullDockerIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
// Server implements the gRPC Docker service that proxies requests to the Docker daemon.
type Server struct {
pb.UnimplementedDockerServer
client *client.Client
client client.APIClient
service *Service
db *sqlx.DB
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
@@ -349,6 +349,41 @@ func (s *Server) InspectImage(ctx context.Context, req *pb.InspectImageRequest)
}, nil
}
// RemoveImage removes an image while protecting names used by containers, even when force is requested.
func (s *Server) RemoveImage(ctx context.Context, req *pb.RemoveImageRequest) (*pb.RemoveImageResponse, error) {
opts := image.RemoveOptions{Force: req.Force, PruneChildren: req.PruneChildren}
for _, platform := range req.Platforms {
opts.Platforms = append(opts.Platforms, ocispec.Platform{
Architecture: platform.Architecture,
OS: platform.Os,
OSVersion: platform.OsVersion,
OSFeatures: platform.OsFeatures,
Variant: platform.Variant,
})
}
removals, err := s.service.RemoveImage(ctx, req.Id, opts)
if err != nil {
switch {
case errdefs.IsNotFound(err):
return nil, status.Error(codes.NotFound, err.Error())
case errdefs.IsConflict(err):
return nil, status.Error(codes.FailedPrecondition, err.Error())
case errdefs.IsInvalidArgument(err):
return nil, status.Error(codes.InvalidArgument, err.Error())
default:
return nil, status.Error(codes.Internal, err.Error())
}
}
result := &pb.MachineImageRemoval{}
for _, removal := range removals {
result.Results = append(result.Results, &pb.ImageRemoval{Deleted: removal.Deleted, Untagged: removal.Untagged})
}
return &pb.RemoveImageResponse{Messages: []*pb.MachineImageRemoval{result}}, nil
}
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's Docker auth
// credentials if necessary.
func (s *Server) InspectRemoteImage(
+2 -2
View File
@@ -31,13 +31,13 @@ import (
// from the machine database.
type Service struct {
// Client is a Docker client for managing Docker resources.
Client *client.Client
Client client.APIClient
// db is a connection to the machine database.
db *sqlx.DB
}
// NewService creates a new Docker service instance.
func NewService(client *client.Client, db *sqlx.DB) *Service {
func NewService(client client.APIClient, db *sqlx.DB) *Service {
return &Service{
Client: client,
db: db,
+7 -2
View File
@@ -836,6 +836,11 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
if m.Initialised() {
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()
if err != nil {
@@ -1220,8 +1225,8 @@ func (m *Machine) applyMachineUpdate(ctx context.Context, req *pb.UpdateMachineR
defer m.state.mu.Unlock()
if req.Name != nil {
if *req.Name == "" {
return status.Error(codes.InvalidArgument, "machine name cannot be empty")
if err := api.ValidateMachineName(*req.Name); err != nil {
return status.Error(codes.InvalidArgument, err.Error())
}
// Check for duplicate names across the cluster, excluding this machine.
if *req.Name != m.state.Name {
+38
View File
@@ -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)
}
}
+29 -2
View File
@@ -23,6 +23,10 @@ const (
// 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.
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 {
@@ -92,6 +96,9 @@ func normaliseContainerForStore(ctr *api.ServiceContainer) {
// Remove the environment variables to avoid leaking secrets.
ctr.Config.Env = nil
ctr.ServiceSpec.Container.Env = nil
if ctr.ServiceSpec.PreDeploy != nil {
ctr.ServiceSpec.PreDeploy.Env = nil
}
// Docker returns Mounts in a non-deterministic order so sort them.
slices.SortFunc(ctr.Mounts, func(a, b container.MountPoint) int {
@@ -267,6 +274,13 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-c
changes := make(chan struct{})
go func() {
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 {
select {
case <-ctx.Done():
@@ -279,8 +293,21 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-c
}
return
}
// Just signal that there is a change in the containers list.
changes <- struct{}{}
if debouncerCh == nil {
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
}
}
}()
+67
View File
@@ -0,0 +1,67 @@
package store
import (
"testing"
"github.com/docker/docker/api/types/container"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestNormaliseContainerForStore_StripsEnv pins the behaviour that all fields carrying environment variables
// are cleared before a container record is written to the replicated store, regardless of which part of the
// spec they live in. See https://github.com/psviderski/uncloud/issues/422.
func TestNormaliseContainerForStore_StripsEnv(t *testing.T) {
t.Parallel()
newContainer := func(preDeploy *api.PreDeployHook) api.ServiceContainer {
return api.ServiceContainer{
Container: api.Container{
InspectResponse: container.InspectResponse{
Config: &container.Config{
Env: []string{"CONTROL_TOKEN=control-value-aaa"},
},
},
},
ServiceSpec: api.ServiceSpec{
Container: api.ContainerSpec{
Env: api.EnvVars{"CONTROL_TOKEN": "control-value-aaa"},
},
PreDeploy: preDeploy,
},
}
}
t.Run("service with pre-deploy hook", func(t *testing.T) {
t.Parallel()
ctr := newContainer(&api.PreDeployHook{
Command: []string{"/bin/true"},
Env: api.EnvVars{"HOOK_TOKEN": "hook-value-bbb"},
})
normaliseContainerForStore(&ctr)
assert.Nil(t, ctr.Config.Env, "Config.Env should be stripped")
assert.Nil(t, ctr.ServiceSpec.Container.Env, "ServiceSpec.Container.Env should be stripped")
require.NotNil(t, ctr.ServiceSpec.PreDeploy, "PreDeploy itself should be left in place")
assert.Nil(t, ctr.ServiceSpec.PreDeploy.Env, "ServiceSpec.PreDeploy.Env should be stripped")
// Non-env fields of the hook must survive the normalisation.
assert.Equal(t, []string{"/bin/true"}, ctr.ServiceSpec.PreDeploy.Command)
})
t.Run("service without pre-deploy hook", func(t *testing.T) {
t.Parallel()
ctr := newContainer(nil)
require.NotPanics(t, func() {
normaliseContainerForStore(&ctr)
})
assert.Nil(t, ctr.Config.Env)
assert.Nil(t, ctr.ServiceSpec.Container.Env)
assert.Nil(t, ctr.ServiceSpec.PreDeploy)
})
}
+146 -27
View File
@@ -1,40 +1,58 @@
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
lockfile_version = 3
[[tools."aqua:vektra/mockery"]]
version = "3.5.3"
version = "3.8.1"
backend = "aqua:vektra/mockery"
specifiers = ["3.8.1"]
[tools."aqua:vektra/mockery"."platforms.linux-arm64"]
checksum = "sha256:ec02ca72b7ad1ff5a1de5503a27d3b1b3f230d722eb35c5b92e43e295a8e1290"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_arm64.tar.gz"
checksum = "sha256:d1f481f3a6e9868dd3ad65da265238ee109ab3a5dd7508958d5811953696f452"
checksum_source = "mise-versions"
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915757"
[tools."aqua:vektra/mockery"."platforms.linux-arm64-musl"]
checksum = "sha256:ec02ca72b7ad1ff5a1de5503a27d3b1b3f230d722eb35c5b92e43e295a8e1290"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_arm64.tar.gz"
checksum = "sha256:d1f481f3a6e9868dd3ad65da265238ee109ab3a5dd7508958d5811953696f452"
checksum_source = "mise-versions"
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915757"
[tools."aqua:vektra/mockery"."platforms.linux-x64"]
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz"
checksum = "sha256:953c7d38b58dd6398b1c595f27caf8bbf27e9b733b49c404d2e124e86d2ae72a"
checksum_source = "mise-versions"
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Linux_x86_64.tar.gz"
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915756"
[tools."aqua:vektra/mockery"."platforms.linux-x64-musl"]
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz"
checksum = "sha256:953c7d38b58dd6398b1c595f27caf8bbf27e9b733b49c404d2e124e86d2ae72a"
checksum_source = "mise-versions"
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Linux_x86_64.tar.gz"
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915756"
[tools."aqua:vektra/mockery"."platforms.macos-arm64"]
checksum = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_arm64.tar.gz"
checksum = "sha256:303a794484243906b37da0c37056f974bf94278c2e10d7b1372e6bd53a2d5034"
checksum_source = "mise-versions"
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915806"
[tools."aqua:vektra/mockery"."platforms.macos-x64"]
checksum = "sha256:8bd2b3e4722e476400395920fb5bbd68be4ea22ec12cddf1e424c40610fda863"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_x86_64.tar.gz"
checksum = "sha256:6882331344e946268b304dce042b01371a0213a896536413be8abe49d532ca6f"
checksum_source = "mise-versions"
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Darwin_x86_64.tar.gz"
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915746"
[tools."aqua:vektra/mockery"."platforms.windows-x64"]
checksum = "sha256:3b4264066db615a56a8b59abda992f6db82b8cd7666937ed9dd8f4a4bfc424a0"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Windows_x86_64.tar.gz"
checksum = "sha256:7a7458756e9d7bce5f9936b7a63394000f3affa751b9740e65350c4bdcb627dc"
checksum_source = "mise-versions"
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Windows_x86_64.tar.gz"
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915759"
[[tools.go]]
version = "1.26.1"
backend = "core:go"
specifiers = ["1.26.1"]
[tools.go."platforms.linux-arm64"]
checksum = "sha256:a290581cfe4fe28ddd737dde3095f3dbeb7f2e4065cab4eae44dfc53b760c2f7"
@@ -67,95 +85,118 @@ url = "https://dl.google.com/go/go1.26.1.windows-amd64.zip"
[[tools.golangci-lint]]
version = "2.11.2"
backend = "aqua:golangci/golangci-lint"
specifiers = ["2.11.2"]
[tools.golangci-lint."platforms.linux-arm64"]
checksum = "sha256:c3c83c7ed003e881991af21361c487151abd37969927ed9062cd0b48f3cd1d8d"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099658"
provenance = "github-attestations"
[tools.golangci-lint."platforms.linux-arm64-musl"]
checksum = "sha256:c3c83c7ed003e881991af21361c487151abd37969927ed9062cd0b48f3cd1d8d"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099658"
provenance = "github-attestations"
[tools.golangci-lint."platforms.linux-x64"]
checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-amd64.tar.gz"
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099674"
provenance = "github-attestations"
[tools.golangci-lint."platforms.linux-x64-musl"]
checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-amd64.tar.gz"
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099674"
provenance = "github-attestations"
[tools.golangci-lint."platforms.macos-arm64"]
checksum = "sha256:11e9549b955bae4bfb71aa3b5c2762e446e2d3f975fe963d6d767b8f745d9344"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099671"
provenance = "github-attestations"
[tools.golangci-lint."platforms.macos-x64"]
checksum = "sha256:1dbbbe745eadba9cf012d49bd0b8c9290cbcb50dbf2cf9611f75a715b46ae3af"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-darwin-amd64.tar.gz"
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099653"
provenance = "github-attestations"
[tools.golangci-lint."platforms.windows-x64"]
checksum = "sha256:60e50178253d276d1c808ef00845eddcabca63b7d69d86a6dbaa41e88bc843f3"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-windows-amd64.zip"
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099651"
provenance = "github-attestations"
[[tools.goreleaser]]
version = "2.15.2"
backend = "aqua:goreleaser/goreleaser"
specifiers = ["2.15.2"]
[tools.goreleaser."platforms.linux-arm64"]
checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz"
provenance = "cosign"
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702105"
provenance = "github-attestations"
[tools.goreleaser."platforms.linux-arm64-musl"]
checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz"
provenance = "cosign"
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702105"
provenance = "github-attestations"
[tools.goreleaser."platforms.linux-x64"]
checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz"
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702106"
provenance = "github-attestations"
[tools.goreleaser."platforms.linux-x64-musl"]
checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz"
provenance = "cosign"
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702106"
provenance = "github-attestations"
[tools.goreleaser."platforms.macos-arm64"]
checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz"
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702148"
provenance = "github-attestations"
[tools.goreleaser."platforms.macos-x64"]
checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz"
provenance = "cosign"
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702148"
provenance = "github-attestations"
[tools.goreleaser."platforms.windows-x64"]
checksum = "sha256:7459832946dbe122c144f8d7f87484d8572ca005b779310aa6bb03346e8de17a"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Windows_x86_64.zip"
provenance = "cosign"
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702135"
provenance = "github-attestations"
[[tools.node]]
version = "22.20.0"
backend = "core:node"
specifiers = ["22.20.0"]
[tools.node."platforms.linux-arm64"]
checksum = "sha256:4181609e03dcb9880e7e5bf956061ecc0503c77a480c6631d868cb1f65a2c7dd"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-arm64.tar.gz"
[tools.node."platforms.linux-arm64-musl"]
checksum = "sha256:4181609e03dcb9880e7e5bf956061ecc0503c77a480c6631d868cb1f65a2c7dd"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-arm64.tar.gz"
checksum = "sha256:79a087c1e23fc97d173ad6676df5b1a4e1a02daf1acca05ee25662747c5d492c"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0.tar.gz"
install = "source"
[tools.node."platforms.linux-x64"]
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
[tools.node."platforms.linux-x64-musl"]
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
checksum = "sha256:461aafed5eb607357479c6de5df978515d74daf7e0169e972e61c8390c035f1a"
url = "https://unofficial-builds.nodejs.org/download/release/v22.20.0/node-v22.20.0-linux-x64-musl.tar.gz"
[tools.node."platforms.macos-arm64"]
checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
@@ -172,41 +213,119 @@ url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-win-x64.zip"
[[tools.protoc]]
version = "27.3"
backend = "aqua:protocolbuffers/protobuf/protoc"
specifiers = ["27.3"]
[tools.protoc."platforms.linux-arm64"]
checksum = "sha256:bdad36f3ad7472281d90568c4956ea2e203c216e0de005c6bd486f1920f2751c"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-aarch_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006374"
[tools.protoc."platforms.linux-arm64-musl"]
checksum = "sha256:bdad36f3ad7472281d90568c4956ea2e203c216e0de005c6bd486f1920f2751c"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-aarch_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006374"
[tools.protoc."platforms.linux-x64"]
checksum = "blake3:8c2938accf7061164ea3968529a47ced1db4824ca553857f6903c68ae4347739"
checksum = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-x86_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006380"
[tools.protoc."platforms.linux-x64-musl"]
checksum = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-x86_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006380"
[tools.protoc."platforms.macos-arm64"]
checksum = "sha256:b22116bd97cdbd7ea25346abe635a9df268515fe5ef5afa93cd9a68fc2513f84"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-osx-aarch_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006383"
[tools.protoc."platforms.macos-x64"]
checksum = "sha256:ce282648fed0e7fbd6237d606dc9ec168dd2c1863889b04efa0b19c47da65d1b"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-osx-x86_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006381"
[tools.protoc."platforms.windows-x64"]
checksum = "sha256:bdccd341a0c25ff4f65d8530f170c78ce5681e42920aa5d892f27ac06cc3bba0"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-win64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006385"
[[tools.protoc-gen-go]]
version = "1.34.2"
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
specifiers = ["1.34.2"]
[tools.protoc-gen-go."platforms.linux-arm64"]
checksum = "sha256:63d400167e75ab9f6690688f6fdc6a9455aa20bc1faa71e32149dbd322f7f198"
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.arm64.tar.gz"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109690"
[tools.protoc-gen-go."platforms.linux-arm64-musl"]
checksum = "sha256:63d400167e75ab9f6690688f6fdc6a9455aa20bc1faa71e32149dbd322f7f198"
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.arm64.tar.gz"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109690"
[tools.protoc-gen-go."platforms.linux-x64"]
checksum = "blake3:f765e393e0f0b25070c13848d88f5e6303e66090132afa1b753e981507a4174e"
checksum = "sha256:b87bc134dee55576a842141bf0ed27761c635d746780fce5dee038c6dd16554f"
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109692"
[tools.protoc-gen-go."platforms.linux-x64-musl"]
checksum = "sha256:b87bc134dee55576a842141bf0ed27761c635d746780fce5dee038c6dd16554f"
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109692"
[tools.protoc-gen-go."platforms.macos-arm64"]
checksum = "sha256:17aca7f948dbb624049030cf841e35895cf34183ba006e721247fdeb95ff2780"
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.arm64.tar.gz"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109697"
[tools.protoc-gen-go."platforms.macos-x64"]
checksum = "sha256:9b48d8f90add02e8e94e14962fed74e7ce2b2d6bda4dd42f1f4fbccf0f766f1a"
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.amd64.tar.gz"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109698"
[tools.protoc-gen-go."platforms.windows-x64"]
checksum = "sha256:abafd39612177dd4e9a65207cadd5374a9352d8611e8e040f8462fcfa3010daf"
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.windows.amd64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109685"
[[tools.protoc-gen-go-grpc]]
version = "1.5.1"
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
specifiers = ["1.5.1"]
[tools.protoc-gen-go-grpc."platforms.linux-arm64"]
checksum = "sha256:370a7cfa5786e61e6fdc5aa8f689e35c2d27d036a49caac7aa25f962c89a01e8"
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.arm64.tar.gz"
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572238"
[tools.protoc-gen-go-grpc."platforms.linux-arm64-musl"]
checksum = "sha256:370a7cfa5786e61e6fdc5aa8f689e35c2d27d036a49caac7aa25f962c89a01e8"
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.arm64.tar.gz"
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572238"
[tools.protoc-gen-go-grpc."platforms.linux-x64"]
checksum = "blake3:197739132f0408f017d0c83773ac1ace85c0572473fdd658ec6e4ccbf20e0552"
checksum = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz"
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572229"
[tools.protoc-gen-go-grpc."platforms.linux-x64-musl"]
checksum = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz"
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572229"
[tools.protoc-gen-go-grpc."platforms.macos-arm64"]
checksum = "sha256:d6083feb51dcfe59f26793e99ef01ef5eac68b64294ec2546711f614ac5878f3"
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz"
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572243"
[tools.protoc-gen-go-grpc."platforms.macos-x64"]
checksum = "sha256:c12c259d59c8b451f24b6abdad6d055fd0884c5268b7247fd7e5ec9d2432d1f1"
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.darwin.amd64.tar.gz"
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572208"
[tools.protoc-gen-go-grpc."platforms.windows-x64"]
checksum = "sha256:c2ad1460c9e34b5b37759826da61220be6ee0a8a11431723d205512a69319991"
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.windows.amd64.tar.gz"
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572216"
+16 -4
View File
@@ -1,8 +1,9 @@
[settings]
experimental = true
lockfile = true
[tools]
"aqua:vektra/mockery" = "3.5.3"
"aqua:vektra/mockery" = "3.8.1"
go = "1.26.1"
golangci-lint = "2.11.2"
goreleaser = "2.15.2"
@@ -36,9 +37,18 @@ GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/psviderski/uncloud/inte
"""
outputs = ["uncloudd-linux-arm64"]
[tasks."mise:lock"]
description = "Update mise.lock with hashes for all platforms"
run = "mise lock --platform linux-x64,macos-arm64"
[tasks."docs:cli"]
description = "Regenerate the CLI reference docs"
run = "go run ./cmd/uc docs"
[tasks."docs:dev"]
description = "Run the documentation website locally"
dir = "website"
run = "npm start --"
[tasks.mocks]
description = "Regenerate Go test mocks"
run = "mockery"
[tasks.proto]
description = "Regenerate gRPC API code from .proto files"
@@ -49,10 +59,12 @@ protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=p
[tasks.uc]
description = "Run the Uncloud CLI"
raw_args = true
run = "go run ./cmd/uc"
[tasks.ucind]
description = "Run ucind CLI for managing local Docker-based clusters"
raw_args = true
run = "go run ./cmd/ucind"
[tasks."ucind:image"]
+2
View File
@@ -42,6 +42,8 @@ type DNSClient interface {
type ImageClient interface {
InspectImage(ctx context.Context, id string) ([]MachineImage, error)
InspectRemoteImage(ctx context.Context, id string) ([]MachineRemoteImage, error)
ListImages(ctx context.Context, filter ImageFilter) ([]MachineImages, error)
RemoveImage(ctx context.Context, image string, opts RemoveImageOptions) ([]MachineImageRemoval, error)
}
type LogsClient interface {
+12 -10
View File
@@ -1,8 +1,6 @@
package api
import (
"errors"
"github.com/distribution/reference"
"github.com/docker/docker/api/types/image"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
@@ -24,14 +22,6 @@ type MachineImages struct {
ContainerdStore bool
}
// Error returns a non-nil error if listing images on this machine failed.
func (mi MachineImages) Error() error {
if mi.Metadata != nil && mi.Metadata.Error != "" {
return errors.New(mi.Metadata.Error)
}
return nil
}
// ImageFilter defines criteria to filter images in ListImages.
type ImageFilter struct {
// Machines filters images to those present on the specified machines (names or IDs).
@@ -42,6 +32,18 @@ type ImageFilter struct {
Name string
}
// RemoveImageOptions defines options for removing an image from cluster machines.
type RemoveImageOptions struct {
// Machines selects machines to remove the image from (names or IDs). If empty, all cluster machines are selected.
Machines []string
}
// MachineImageRemoval contains the outcome of removing an image on one machine.
type MachineImageRemoval struct {
Metadata *pb.Metadata
Results []image.DeleteResponse
}
// MachineRemoteImage represents an image in a remote registry fetched by a particular machine.
type MachineRemoteImage struct {
Metadata *pb.Metadata
+21
View File
@@ -1,12 +1,33 @@
package api
import (
"fmt"
"net/netip"
"strings"
"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.
type MachineFilter struct {
// Available filters machines that are not DOWN.
+47
View File
@@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"net/netip"
"strings"
"testing"
"github.com/psviderski/uncloud/api/pb"
@@ -10,6 +11,52 @@ import (
"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) {
t.Parallel()
+23 -9
View File
@@ -41,12 +41,30 @@ const (
)
var (
serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
dnsLabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
IDRegex = regexp.MustCompile("^[0-9a-f]{32}$")
DNSLabelRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$`)
)
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.
@@ -147,12 +165,8 @@ func (s *ServiceSpec) Validate() error {
}
if s.Name != "" {
if len(s.Name) > 63 {
return fmt.Errorf("service name too long (max 63 characters): %q", s.Name)
}
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)
if err := ValidateServiceName(s.Name); err != nil {
return err
}
}
+54
View File
@@ -2,6 +2,7 @@ package api
import (
"os"
"strings"
"testing"
"github.com/docker/docker/api/types/container"
@@ -9,6 +10,59 @@ import (
"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) {
tests := []struct {
name string
+1
View File
@@ -86,6 +86,7 @@ func (cli *Client) progressOut() *streams.Out {
// ProxyMachinesContext returns a new context that proxies gRPC requests to the specified machines.
// If namesOrIDs is nil or empty, all machines are included.
// This triggers One2Many proxying, which always injects metadata into the response.
// TODO: validate namesOrIDs can't contain "*" as this is reserved as internal value and may break the caller logic.
func ProxyMachinesContext(ctx context.Context, namesOrIDs []string) context.Context {
md := outgoingMetadataWithoutProxyTargets(ctx)
if len(namesOrIDs) == 0 {
+1 -1
View File
@@ -13,8 +13,8 @@ import (
"github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
"github.com/psviderski/uncloud/pkg/client/operation"
)
type Client interface {
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/operation"
)
// Plan holds the compose-level deployment plan with volume and service operations.
+54 -23
View File
@@ -89,40 +89,53 @@ func ResolveSecrets(ctx context.Context, project *types.Project) error {
return v, nil
}
// Resolve only secret references set as values for environment variables in enabled services.
for _, service := range project.Services {
for k, v := range service.Environment {
if v == nil {
continue
}
secretName, ok := secretRefName(*v)
if !ok {
continue
}
value, err := resolve(secretName)
if err != nil {
// Resolve secret references set as values for environment variables in the service.
if err := resolveEnvSecrets(service.Environment, resolve); err != nil {
return err
}
// Resolve secret references set as values for the pre-deploy hook's environment variables, if any.
if hook, ok := service.Extensions[PreDeployHookExtensionKey].(PreDeployHook); ok {
if err := resolveEnvSecrets(hook.Environment, resolve); err != nil {
return err
}
service.Environment[k] = &value
}
}
return nil
}
// HasCommandSecretRefs reports whether any service environment references a secret that is resolved by running
// a command.
// resolveEnvSecrets resolves 'secret://name' references found as values in env to actual secret values using
// resolve, setting each referenced variable to the secret's value in place. Variables that are not secret
// references are left untouched.
func resolveEnvSecrets(env types.MappingWithEquals, resolve func(string) (string, error)) error {
for k, v := range env {
if v == nil {
continue
}
secretName, ok := secretRefName(*v)
if !ok {
continue
}
value, err := resolve(secretName)
if err != nil {
return err
}
env[k] = &value
}
return nil
}
// HasCommandSecretRefs reports whether any service environment, including a pre-deploy hook's environment,
// references a secret that is resolved by running a command.
func HasCommandSecretRefs(project *types.Project) bool {
for _, service := range project.Services {
for _, v := range service.Environment {
if v == nil {
continue
}
name, ok := secretRefName(*v)
if !ok {
continue
}
if secret, ok := project.Secrets[name]; ok && secret.Driver == secretExecDriver {
if envHasCommandSecretRef(service.Environment, project) {
return true
}
if hook, ok := service.Extensions[PreDeployHookExtensionKey].(PreDeployHook); ok {
if envHasCommandSecretRef(hook.Environment, project) {
return true
}
}
@@ -130,6 +143,24 @@ func HasCommandSecretRefs(project *types.Project) bool {
return false
}
// envHasCommandSecretRef reports whether env contains a 'secret://name' reference to a secret resolved
// by running a command.
func envHasCommandSecretRef(env types.MappingWithEquals, project *types.Project) bool {
for _, v := range env {
if v == nil {
continue
}
name, ok := secretRefName(*v)
if !ok {
continue
}
if secret, ok := project.Secrets[name]; ok && secret.Driver == secretExecDriver {
return true
}
}
return false
}
// secretValue resolves a secret to its value depending on its source: an 'exec' driver command,
// an environment variable, or a file. A single trailing newline ('\n' or '\r\n') is stripped from the command
// output as command-line tools commonly append one. All other whitespace is kept. Environment and file values are
+84
View File
@@ -328,6 +328,74 @@ secrets:
assert.Equal(t, "run\n", string(runs), "command should run exactly once across services and repeated resolutions")
}
// TestResolveSecrets_PreDeployHook pins the behaviour that 'secret://name' references in the pre-deploy hook's
// environment are resolved just like references in the service's own environment.
// See https://github.com/psviderski/uncloud/issues/422.
func TestResolveSecrets_PreDeployHook(t *testing.T) {
t.Parallel()
content := `
services:
foo:
image: foo
environment:
CONTROL_TOKEN: secret://control_token
x-pre_deploy:
command: ["./migrate.sh"]
environment:
HOOK_TOKEN: secret://hook_token
PLAIN: hello
secrets:
control_token:
x-command: printf control-value
hook_token:
x-command: printf hook-value
`
project := loadProject(t, content)
require.NoError(t, ResolveSecrets(context.Background(), project))
assert.Equal(t, env(map[string]string{"CONTROL_TOKEN": "control-value"}), project.Services["foo"].Environment)
hook, ok := project.Services["foo"].Extensions[PreDeployHookExtensionKey].(PreDeployHook)
require.True(t, ok, "x-pre_deploy extension not found")
assert.Equal(t, env(map[string]string{"HOOK_TOKEN": "hook-value", "PLAIN": "hello"}), hook.Environment)
}
// TestResolveSecrets_PreDeployHook_ResolvedOnce verifies that a secret referenced from both the service's
// environment and its pre-deploy hook's environment is only resolved once.
func TestResolveSecrets_PreDeployHook_ResolvedOnce(t *testing.T) {
t.Parallel()
counter := filepath.Join(t.TempDir(), "runs")
content := fmt.Sprintf(`
services:
foo:
image: foo
environment:
TOKEN: secret://token
x-pre_deploy:
command: ["./migrate.sh"]
environment:
TOKEN: secret://token
secrets:
token:
x-command: "sh -c 'echo run >> %s; printf abc'"
`, counter)
project := loadProject(t, content)
require.NoError(t, ResolveSecrets(context.Background(), project))
assert.Equal(t, "abc", *project.Services["foo"].Environment["TOKEN"])
hook, ok := project.Services["foo"].Extensions[PreDeployHookExtensionKey].(PreDeployHook)
require.True(t, ok, "x-pre_deploy extension not found")
assert.Equal(t, "abc", *hook.Environment["TOKEN"])
runs, err := os.ReadFile(counter)
require.NoError(t, err)
assert.Equal(t, "run\n", string(runs),
"command should run exactly once across the service and hook environments")
}
func TestHasCommandSecretRefs(t *testing.T) {
t.Parallel()
@@ -387,6 +455,22 @@ services:
`,
want: false,
},
{
name: "command secret referenced from pre-deploy hook",
content: `
services:
foo:
image: foo
x-pre_deploy:
command: ["./migrate.sh"]
environment:
TOKEN: secret://token
secrets:
token:
x-command: printf abc
`,
want: true,
},
}
for _, tt := range tests {
+1 -1
View File
@@ -12,8 +12,8 @@ import (
"github.com/distribution/reference"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
"github.com/psviderski/uncloud/pkg/client/operation"
)
type Client interface {
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
"github.com/psviderski/uncloud/pkg/client/operation"
)
// Strategy defines how a service should be deployed or updated. Different implementations can provide various
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
"github.com/psviderski/uncloud/pkg/client/operation"
"github.com/stretchr/testify/assert"
)
+82 -3
View File
@@ -23,9 +23,11 @@ import (
"github.com/docker/docker/api/types/image"
dockerclient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/jsonmessage"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-connections/nat"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/psviderski/uncloud/api/pb"
cliprogress "github.com/psviderski/uncloud/internal/cli/progress"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/docker"
"github.com/psviderski/uncloud/internal/machine/constants"
@@ -34,6 +36,8 @@ import (
"github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api"
netproxy "golang.org/x/net/proxy"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// This is the container image used to run socat proxy containers.
@@ -61,7 +65,7 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap
// Broadcast the image list request to the specified machines or all machines if none specified.
listCtx := ProxyMachinesContext(ctx, filter.Machines)
opts := image.ListOptions{Manifests: true}
opts := image.ListOptions{All: true, Manifests: true}
if filter.Name != "" {
opts.Filters = filters.NewArgs(
filters.Arg("reference", filter.Name),
@@ -92,7 +96,7 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap
ContainerdStore: msg.ContainerdStore,
}
if msg.Metadata.Error == "" && len(msg.Images) > 0 {
if msg.Metadata.Err() == nil && len(msg.Images) > 0 {
if err = json.Unmarshal(msg.Images, &mi.Images); err != nil {
return nil, fmt.Errorf("unmarshal images: %w", err)
}
@@ -104,6 +108,81 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap
return machineImages, nil
}
// RemoveImage removes one exact image name or ID on the selected machines with protection of used references.
// Results include the outcome of removing an image on each machine.
//
// An absent image on some machines is normal if removal succeeds on at least one machine. If every selected machine
// reports NotFound, the returned error is api.ErrNotFound. Other machine failures are combined, with successful
// results still returned.
func (cli *Client) RemoveImage(
ctx context.Context, imageNameOrID string, opts api.RemoveImageOptions,
) ([]api.MachineImageRemoval, error) {
if strings.TrimSpace(imageNameOrID) == "" {
return nil, errors.New("image name or ID is required")
}
resp, err := cli.Docker.GRPCClient.RemoveImage(ProxyMachinesContext(ctx, opts.Machines),
&pb.RemoveImageRequest{Id: imageNameOrID, PruneChildren: true})
if err != nil {
return nil, err
}
pw := progress.ContextWriter(ctx)
var results []api.MachineImageRemoval
var errs []error
notFoundCount := 0
for _, msg := range resp.Messages {
result := api.MachineImageRemoval{Metadata: msg.Metadata}
for _, removal := range msg.Results {
result.Results = append(result.Results, image.DeleteResponse{
Deleted: removal.Deleted,
Untagged: removal.Untagged,
})
}
results = append(results, result)
eventID := cliprogress.ImageEventID(imageNameOrID, msg.Metadata.MachineName)
if err = result.Metadata.Err(); err != nil {
event := progress.NewEvent(eventID, progress.Done, "Not found")
if status.Code(err) == codes.NotFound {
notFoundCount++
} else {
errs = append(errs, fmt.Errorf("machine '%s': %w", msg.Metadata.MachineName, err))
event.Status = progress.Error
event.StatusText = status.Convert(err).Message()
}
pw.Event(event)
continue
}
// An unnamed image can return Deleted without Untagged. Use the requested name or ID as its row fallback.
deleted := false
for _, removal := range result.Results {
if removal.Untagged != "" {
eventID = cliprogress.ImageEventID(removal.Untagged, msg.Metadata.MachineName)
pw.Event(progress.NewEvent(eventID, progress.Done, "Untagged"))
}
if removal.Deleted != "" && !deleted {
// Docker reports the deletion of the selected image before any pruned parents or released layers.
// Only that first deletion updates the last untagged row marking the image as Removed.
deleted = true
pw.Event(progress.NewEvent(eventID, progress.Done,
fmt.Sprintf("Removed (%s)", stringid.TruncateID(removal.Deleted))))
}
}
}
if err = errors.Join(errs...); err != nil {
return results, err
}
if notFoundCount == len(results) {
return results, api.ErrNotFound
}
return results, nil
}
// TODO: move PushImageOptions to api package and include PushImage in ImageClient interface.
type PushImageOptions struct {
// AllMachines pushes the image to all machines in the cluster. Takes precedence over Machines field.
AllMachines bool
@@ -215,7 +294,7 @@ func (cli *Client) pushImageToMachine(
Name: "%invalid-name-to-only-check-store-type%",
})
if err == nil {
err = images[0].Error()
err = images[0].Metadata.Err()
}
if err != nil {
return fmt.Errorf("check Docker image store type on machine '%s': %w", machine.Name, err)
@@ -173,6 +173,16 @@ type ReplaceContainerOperation struct {
func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) error {
stopFirst := o.Order == api.UpdateOrderStopFirst
// Create the new container before stopping the old one. CreateContainer also pulls the image according to the
// service pull policy, so the stop-first downtime only includes stopping the old container and starting and
// monitoring the new one.
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
if err != nil {
return fmt.Errorf("create new container: %w", err)
}
// Override event ID so StartContainer and WaitContainerHealthy update the same progress line as creation.
newCtx := cliprogress.WithEventID(ctx, cliprogress.NewContainerEventID(ctx, resp.Name, o.MachineName))
wasRunning := false
if stopFirst {
// Inspect the old container to remember its running state before stopping.
@@ -189,12 +199,6 @@ func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) err
}
}
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
if err != nil {
return fmt.Errorf("create new container: %w", err)
}
// Override event ID so StartContainer and WaitContainerHealthy update the same progress line as creation.
newCtx := cliprogress.WithEventID(ctx, cliprogress.NewContainerEventID(ctx, resp.Name, o.MachineName))
if err = cli.StartContainer(newCtx, o.ServiceID, resp.ID); err != nil {
return fmt.Errorf("start new container: %w", err)
}
+155
View File
@@ -0,0 +1,155 @@
package operation
import (
"context"
"errors"
"testing"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReplaceContainerOperation_StopFirstPreparation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
createErr error
wantErr string
wantCalls []string
}{
{
name: "prepare before cutover",
wantCalls: []string{
"create:service",
"inspect:old",
"stop:old",
"start:new",
"wait:new",
"remove:old",
},
},
{
name: "create failure does not interrupt old container",
createErr: errors.New("pull failed"),
wantErr: "create new container: pull failed",
wantCalls: []string{"create:service"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cli := &recordingClient{
oldContainerRunning: true,
createErr: tt.createErr,
}
op := newStopFirstReplaceContainerOperation()
err := op.Execute(context.Background(), cli)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantCalls, cli.calls)
})
}
}
func newStopFirstReplaceContainerOperation() *ReplaceContainerOperation {
return &ReplaceContainerOperation{
ServiceID: "service-id",
Spec: api.ServiceSpec{Name: "service"},
MachineID: "machine-id",
MachineName: "machine",
OldContainer: api.ServiceContainer{Container: api.Container{InspectResponse: container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ID: "old"}}}},
Order: api.UpdateOrderStopFirst,
}
}
type recordingClient struct {
calls []string
oldContainerRunning bool
createErr error
}
func (c *recordingClient) CreateContainer(
_ context.Context, _ string, spec api.ServiceSpec, _ string,
) (api.CreateContainerResponse, error) {
c.calls = append(c.calls, "create:"+spec.Name)
return api.CreateContainerResponse{
CreateResponse: container.CreateResponse{ID: "new"},
Name: "new",
}, c.createErr
}
func (c *recordingClient) InspectContainer(
_ context.Context, _, containerID string,
) (api.MachineServiceContainer, error) {
c.calls = append(c.calls, "inspect:"+containerID)
return api.MachineServiceContainer{
Container: api.ServiceContainer{
Container: api.Container{InspectResponse: container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{
ID: containerID,
State: &container.State{Running: c.oldContainerRunning},
},
}},
},
}, nil
}
func (c *recordingClient) StartContainer(_ context.Context, _, containerID string) error {
c.calls = append(c.calls, "start:"+containerID)
return nil
}
func (c *recordingClient) StopContainer(
_ context.Context, _, containerID string, _ container.StopOptions,
) error {
c.calls = append(c.calls, "stop:"+containerID)
return nil
}
func (c *recordingClient) RemoveContainer(
_ context.Context, _, containerID string, _ container.RemoveOptions,
) error {
c.calls = append(c.calls, "remove:"+containerID)
return nil
}
func (c *recordingClient) CreatePreDeployHookContainer(
context.Context, string, api.ServiceSpec, string,
) (api.CreateContainerResponse, error) {
panic("unexpected CreatePreDeployHookContainer call")
}
func (c *recordingClient) ExecContainer(context.Context, string, string, api.ExecOptions) (int, error) {
panic("unexpected ExecContainer call")
}
func (c *recordingClient) WaitContainerHealthy(
_ context.Context, _, containerID string, _ api.WaitContainerHealthyOptions,
) error {
c.calls = append(c.calls, "wait:"+containerID)
return nil
}
func (c *recordingClient) CreateVolume(
context.Context, string, volume.CreateOptions,
) (api.MachineVolume, error) {
panic("unexpected CreateVolume call")
}
func (c *recordingClient) ListVolumes(context.Context, *api.VolumeFilter) ([]api.MachineVolume, error) {
panic("unexpected ListVolumes call")
}
func (c *recordingClient) RemoveVolume(context.Context, string, string, bool) error {
panic("unexpected RemoveVolume call")
}
@@ -6,8 +6,8 @@ import (
"github.com/psviderski/uncloud/pkg/api"
)
// Operation represents a single atomic operation in a deployment process.
// Operations can be composed to form complex deployment strategies.
// Operation represents a single operation in the cluster that can be executed.
// Operations can be composed to form complex workflows (plans).
type Operation interface {
// Execute performs the operation using the provided client.
Execute(ctx context.Context, cli Client) error
@@ -16,7 +16,7 @@ type Operation interface {
String() string
}
// Client defines the interface required to execute deployment operations.
// Client defines the interface required to execute cluster operations.
type Client interface {
api.ContainerClient
api.VolumeClient
+4 -4
View File
@@ -125,10 +125,10 @@ func (cli *Client) InspectService(ctx context.Context, nameOrID string) (api.Ser
continue
}
if mc.Metadata.Error != "" {
if err := mc.Metadata.Err(); err != nil {
// TODO: return failed machines in the response.
tui.PrintWarning(fmt.Sprintf("failed to list containers on machine '%s': %s",
mc.Metadata.MachineName, mc.Metadata.Error))
mc.Metadata.MachineName, err))
continue
}
@@ -333,10 +333,10 @@ func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
// Group containers by service ID using the broadcast results.
servicesByID := make(map[string]api.Service)
for _, mc := range machineContainers {
if mc.Metadata.Error != "" {
if err := mc.Metadata.Err(); err != nil {
// TODO: return failed machines in the response.
tui.PrintWarning(fmt.Sprintf("failed to list containers on machine '%s': %s",
mc.Metadata.MachineName, mc.Metadata.Error))
mc.Metadata.MachineName, err))
continue
}
+2 -2
View File
@@ -70,10 +70,10 @@ func (cli *Client) ListVolumes(ctx context.Context, filter *api.VolumeFilter) ([
continue
}
if mv.Metadata.Error != "" {
if err := mv.Metadata.Err(); err != nil {
// TODO: return failed machines in the response.
tui.PrintWarning(fmt.Sprintf("failed to list volumes on machine '%s': %s", mv.Metadata.MachineName,
mv.Metadata.Error))
err))
continue
}
+48 -27
View File
@@ -14,6 +14,7 @@ import (
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/volume"
"github.com/docker/go-units"
@@ -1265,10 +1266,10 @@ myapp.example.com {
"Containers should be on machines where the volume exists")
})
t.Run("list images for replicated service", func(t *testing.T) {
t.Run("image lifecycle for replicated service", func(t *testing.T) {
t.Parallel()
serviceName := "test-list-images-replicated"
serviceName := "test-image-lifecycle-replicated"
uniqueImage := "portainer/pause:3.9" // Unique image not used by other tests.
t.Cleanup(func() {
err := cli.RemoveService(ctx, serviceName)
@@ -1301,39 +1302,59 @@ myapp.example.com {
machinesWithContainers := serviceMachines(svc)
assert.Len(t, machinesWithContainers.ToSlice(), 2, "Containers should be on 2 different machines")
var machineWithoutContainer string
for _, m := range c.Machines {
if !machinesWithContainers.Contains(m.ID) {
machineWithoutContainer = m.ID
break
}
}
assert.NotEmpty(t, machineWithoutContainer, "Should have found a machine without container")
machineImages, err := cli.ListImages(ctx, api.ImageFilter{})
machineImages, err := cli.ListImages(ctx, api.ImageFilter{Name: uniqueImage})
require.NoError(t, err)
assert.Len(t, machineImages, 3, "Should get images from all 3 machines")
require.Len(t, machineImages, len(c.Machines))
for _, mi := range machineImages {
// Checking only DockerImages because the machines in ucind cluster don't use the containerd image store.
require.NoError(t, mi.Metadata.Err())
if !machinesWithContainers.Contains(mi.Metadata.MachineId) {
// This is the machine without service containers, it should not have the unique image.
for _, img := range mi.Images {
assert.NotContains(t, img.RepoTags, uniqueImage)
}
assert.Empty(t, mi.Images)
continue
}
// Check if the unique image is present on the machine where a service container is running.
hasImage := false
for _, img := range mi.Images {
if slices.Contains(img.RepoTags, uniqueImage) {
hasImage = true
break
}
require.Len(t, mi.Images, 1)
assert.Contains(t, mi.Images[0].RepoTags, uniqueImage)
}
// The used tag must remain on both machines while their service containers exist.
results, err := cli.RemoveImage(ctx, uniqueImage, api.RemoveImageOptions{Machines: spec.Placement.Machines})
require.Error(t, err)
assert.ErrorContains(t, err, "used by container")
require.Len(t, results, 2)
for _, result := range results {
assert.Equal(t, codes.FailedPrecondition, status.Code(result.Metadata.Err()))
}
require.NoError(t, cli.RemoveService(ctx, serviceName))
// Broadcast removal after deleting the containers. Absence on the third machine is not an overall error.
results, err = cli.RemoveImage(ctx, uniqueImage, api.RemoveImageOptions{})
require.NoError(t, err)
require.Len(t, results, len(c.Machines))
for _, result := range results {
if !machinesWithContainers.Contains(result.Metadata.MachineId) {
assert.Equal(t, codes.NotFound, status.Code(result.Metadata.Err()))
continue
}
assert.True(t, hasImage, "Machine %s with container should have image %s",
mi.Metadata.MachineId, uniqueImage)
require.NoError(t, result.Metadata.Err())
assert.Contains(t, result.Results, image.DeleteResponse{Untagged: uniqueImage})
}
results, err = cli.RemoveImage(ctx, uniqueImage, api.RemoveImageOptions{})
require.ErrorIs(t, err, api.ErrNotFound)
require.Len(t, results, len(c.Machines))
for _, result := range results {
assert.Equal(t, codes.NotFound, status.Code(result.Metadata.Err()))
assert.Empty(t, result.Results)
}
machineImages, err = cli.ListImages(ctx, api.ImageFilter{Name: uniqueImage})
require.NoError(t, err)
require.Len(t, machineImages, len(c.Machines))
for _, mi := range machineImages {
require.NoError(t, mi.Metadata.Err())
assert.Empty(t, mi.Images)
}
})
+13 -5
View File
@@ -18,14 +18,22 @@ npm run dev
`/docs` and `/blog` are built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
### Local Development
### Local development
```
npm start
To preview the documentation website, install its dependencies first:
```shell
npm ci
```
This command starts a local development server and opens up a browser window. Most changes are reflected live without
having to restart the server.
Then start the local development server:
```shell
mise run docs:dev
```
It opens up a browser window at http://localhost:3000/. Most changes are reflected live without having to restart the
server.
## Deployment
@@ -15,6 +15,15 @@ includes it and configure Caddy to use it.
## 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
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:
```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,
@@ -55,4 +64,10 @@ List issued certificates in cluster storage with [`uc caddy cert ls`](../../9-cl
```shell
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:
## Service name
```
$ nslookup nats.internal
Server: 127.0.0.11
@@ -30,6 +31,7 @@ Address: 10.210.1.4
```
## Service ID
```
$ nslookup 3ecb3a8bbec5fd3f46efb056a934714a.internal
Server: 127.0.0.11
@@ -40,6 +42,7 @@ Address: 10.210.0.4
```
## Machine ID scoped service name
```
$ nslookup 0903f0ee483aa97d559eeeaac5e22283.m.nats.internal
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.
### `rr` (round-robin) *current default*
### `rr` (round-robin) _current default_
Randomly shuffled order on each lookup.
```
@@ -96,9 +100,11 @@ Address: 10.210.0.3
```
## Nearest scope
Returns machine-local instances first.
`machine-a`:
```
$ nslookup nearest.worker.internal
Server: 127.0.0.11
@@ -115,6 +121,7 @@ Address: 10.210.1.4
```
`machine-b`:
```
$ nslookup nearest.worker.internal
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`).
## 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
```
+2 -2
View File
@@ -12,8 +12,8 @@ values at deploy time from your password manager, a cloud secrets store, or loca
Two things go into your Compose file:
1. Define a secret in the top-level `secrets` section. This tells Uncloud how to get the secret value.
2. Reference that secret value in a service's `environment` using the `secret://<name>` format. This tells Uncloud to
resolve the secret and set the environment variable to its value at deploy time.
2. Reference that secret value in a service's `environment` or `x-pre_deploy.environment` using the `secret://<name>`
format. This tells Uncloud to resolve the secret and set the environment variable to its value at deploy time.
```yaml title="compose.yaml"
services:
@@ -105,13 +105,13 @@ services:
### Attributes
| Attribute | Type | Default | Description |
|---------------|-------------------------|-----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `command` | string / list | (required) | The command to run in the hook container (same format as the service's [`command`](https://github.com/compose-spec/compose-spec/blob/main/05-services.md#command)) |
| `environment` | map / list of KEY=VALUE | - | Additional env vars that override or extend the service's [`environment`](https://github.com/compose-spec/compose-spec/blob/main/05-services.md#environment) |
| `privileged` | bool | service's value | Override the service's [`privileged`](https://github.com/compose-spec/compose-spec/blob/main/05-services.md#privileged) mode |
| `timeout` | duration | `5m` | Max time to wait for the command to finish before killing it (e.g., `1m30s`, `30m`, `1h`) |
| `user` | string | service's value | Override the service's [`user`](https://github.com/compose-spec/compose-spec/blob/main/05-services.md#user) to run as (`user`, `UID`, `user:group`, or `UID:GID`) |
| Attribute | Type | Default | Description |
|---------------|-------------------------|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `command` | string / list | (required) | The command to run in the hook container (same format as the service's [`command`](https://github.com/compose-spec/compose-spec/blob/main/05-services.md#command)) |
| `environment` | map / list of KEY=VALUE | - | Additional env vars that override or extend the service's [`environment`](https://github.com/compose-spec/compose-spec/blob/main/05-services.md#environment). Supports `secret://<name>` references. See [Secrets](../3-concepts/8-secrets.md). |
| `privileged` | bool | service's value | Override the service's [`privileged`](https://github.com/compose-spec/compose-spec/blob/main/05-services.md#privileged) mode |
| `timeout` | duration | `5m` | Max time to wait for the command to finish before killing it (e.g., `1m30s`, `30m`, `1h`) |
| `user` | string | service's value | Override the service's [`user`](https://github.com/compose-spec/compose-spec/blob/main/05-services.md#user) to run as (`user`, `UID`, `user:group`, or `UID:GID`) |
The hook container also gets `UNCLOUD_HOOK_PRE_DEPLOY=true` environment variable set automatically.
@@ -15,7 +15,6 @@ uc caddy config [flags]
```
-h, --help help for config
-m, --machine string Name or ID of the machine to get the configuration from. (default is connected machine)
--no-color Disable syntax highlighting for the output.
```
## Options inherited from parent commands
@@ -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.
--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 1h45m Relative duration (1 hour 45 minutes ago)
Supported units: d (day = 24h), h (hour), m (minute),
s (second), ms (millisecond),
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 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.
See --since for examples.
--utc Print timestamps in UTC instead of local timezone.
+1
View File
@@ -22,4 +22,5 @@ Manage images on machines in the cluster.
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes.
* [uc image ls](uc_image_ls.md) - List images on machines in the cluster.
* [uc image push](uc_image_push.md) - Upload a local Docker image to the cluster.
* [uc image rm](uc_image_rm.md) - Remove images from machines in the cluster.
@@ -0,0 +1,61 @@
# uc image rm
Remove images from machines in the cluster.
## Synopsis
Remove images from all or selected machines in the cluster.
An image name without a tag selects all its tags. Specify a tag, such as myapp:latest, to remove
only that tag.
Wildcard patterns are supported for image names and tags and should be quoted to prevent your
shell from expanding them. Removing by image ID attempts to remove each name for that image
independently.
Images used by running or stopped containers cannot be removed. Unused tags can be removed even
when the same image is used under another tag. Failed removals do not prevent other removals.
```
uc image rm IMAGE [IMAGE...] [flags]
```
## Examples
```
# Remove a tag from all machines after confirming the plan.
uc image rm myapp:old
# Remove all tags for an image name from all machines.
uc image rm myapp
# Remove matching tags from two machines without confirmation.
uc image rm 'myapp:1.*' 'otherapp:*' -m machine1,machine2 --yes
# Remove all names for an image by ID from all machines.
uc image rm ee8fed0e9618
```
## Options
```
-h, --help help for rm
-m, --machine strings Machine names or IDs to remove images from. Can be specified multiple times or as a comma-separated list.
If not specified, the matching images will be removed from all machines.
-y, --yes Auto-confirm the removal plan. Should be explicitly set when running non-interactively,
e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]
```
## 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 image](uc_image.md) - Manage images on machines in the cluster.
+8 -5
View File
@@ -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.
--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 1h45m Relative duration (1 hour 45 minutes ago)
Supported units: d (day = 24h), h (hour), m (minute),
s (second), ms (millisecond),
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 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.
See --since for examples.
--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.
--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 1h45m Relative duration (1 hour 45 minutes ago)
Supported units: d (day = 24h), h (hour), m (minute),
s (second), ms (millisecond),
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 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.
See --since for examples.
--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.
--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 1h45m Relative duration (1 hour 45 minutes ago)
Supported units: d (day = 24h), h (hour), m (minute),
s (second), ms (millisecond),
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 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.
See --since for examples.
--utc Print timestamps in UTC instead of local timezone.