Compare commits

..
41 Commits
Author SHA1 Message Date
Pasha Sviderski dac194752a feat(images): implement RemoveImage gRPC API with extra protection for used images 2026-10-28 20:21:09 +10:00
Pasha Sviderski df493d0b60 chore: minor comments in common proto 2026-10-28 17:28:23 +10:00
Pasha Sviderski 7f68e1f838 refactor(images): simplify image name handling when listing images 2026-10-28 17:25:15 +10:00
Pasha Sviderski fab2a20242 refactor(grpc-proxy): use Metadata.Err() method to get upstream error for a broadcasted request 2026-10-27 10:05:35 +10:00
Pasha Sviderski bb8fb3a380 feat(images): include all tags and digest references when listing images 2026-10-26 20:00:46 +10:00
Pasha Sviderski e50c7a0cf6 move deploy.operation package to operation 2026-10-26 15:19:38 +10:00
Pasha Sviderski c16e452439 fix: handle output redirect and NO_COLOR for 'uc caddy config' 2026-10-19 12:15:01 +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
Pasha Sviderski c58108aa82 chore(.editorconfig): ignore formatting for release notes 2026-10-02 13:56:49 +10:00
Pasha Sviderski 5d7952952a docs(README): list cluster storage for certificates in features 2026-10-02 13:56:08 +10:00
Pasha Sviderski 87e99ae369 docs(caddy): new page Cluster storage for Caddy 2026-10-02 13:27:07 +10:00
Pasha Sviderski f78a8ca8b5 docs(caddy): regenerate CLI reference for 'caddy cert ls' command 2026-10-01 21:00:05 +10:00
Pasha Sviderski e4bd1ad443 feat(store): improve waiting for store replication to skip unavailable members 2026-10-01 20:59:15 +10:00
Pasha Sviderski 0e53f505c0 refactor(client): speed up service list call (N -> 1 broadcast RPC) 2026-10-01 19:52:18 +10:00
Pasha Sviderski 9c21ae6b08 feat(caddy): CLI command to list certificates stored in the Uncloud cluster storage for Caddy 2026-10-01 14:19:53 +10:00
Pasha Sviderski 70de7b1b8f feat(caddy): add healthcheck to default Caddy spec and Compose deployment in docs 2026-10-01 09:25:39 +10:00
Pasha Sviderski af277639dd chore(dns): require minimum server version 0.21.0 for 'uc dns set' command 2026-09-30 19:21:59 +10:00
Pasha Sviderski 25ca0a57e8 lint 2026-09-30 18:49:53 +10:00
Pasha Sviderski ebecf334d7 chore(dns): require minimum server version 0.21 2026-09-25 17:24:13 +10:00
Pasha Sviderski 9ee3ca15eb feat(dns): allow setting an externally managed cluster domain 2026-09-25 16:20:54 +10:00
Miek Gieben b28c458029 Rebase and fix
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-09-15 14:39:48 +02:00
Miek Gieben 3fc1cddf01 Rebase and and generate protos again
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-09-15 14:36:28 +02:00
Miek Gieben 54a7328a34 mise proto && make cli-docs
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-09-15 14:36:02 +02:00
Miek Gieben 47a4a9158e from main
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-09-15 14:35:43 +02:00
Miek Gieben ad2e1a06d5 Ignore domain when there is no endpoint
Prevents this error, when you know what you're doing:

    Error: failed to update DNS records pointing to caddy service: create cluster domain records in Uncloud DNS: rpc error: code = Unknown desc = Post "/domains/ctrl.u.science.ru.nl/records": unsupported protocol scheme ""

Signed-off-by: Miek Gieben <miek@miek.nl>
2026-09-15 14:35:33 +02:00
Miek Gieben 81d36edc66 No need to call out to update anything, as set doesnt use uncloud dns
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-09-15 14:35:33 +02:00
Miek Gieben 5fb80e2071 check validatity
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-09-15 14:35:33 +02:00
Miek Gieben d1fe23dec0 docs
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-09-15 14:35:33 +02:00
Miek Gieben cec3c75a10 feat: add uc dns set
This allows manual setting of the cluster's domain name. I use this to
make the local cluster admin aware that this has been done.
(and then manually update the external dns)

```
% uc dns set ctrl.u.science.ru.nl
Set cluster domain: ctrl.u.science.ru.nl
Deploy the Caddy reverse proxy service ('uc caddy deploy') to enable internet access to your services via the reserved or your custom domain.

% uc dns show
ctrl.u.science.ru.nl

% uc dns set ctrl.u.science.ru.nl
Error: domain already reserved

% uc dns release
Released cluster domain: ctrl.u.science.ru.nl
```

See: #37

Signed-off-by: Miek Gieben <miek@miek.nl>
2026-09-15 14:35:31 +02:00
104 changed files with 11711 additions and 677 deletions
+3
View File
@@ -18,3 +18,6 @@ indent_size = 4
[*.sh] [*.sh]
indent_style = space indent_style = space
indent_size = 4 indent_size = 4
[release-notes-*.md]
max_line_length = off
+6
View File
@@ -1,4 +1,10 @@
packages: packages:
github.com/docker/docker/client:
config:
dir: internal/machine/docker
pkgname: docker
interfaces:
APIClient:
github.com/psviderski/uncloud/internal/machine/caddyconfig: github.com/psviderski/uncloud/internal/machine/caddyconfig:
interfaces: interfaces:
CaddyfileValidator: CaddyfileValidator:
+1 -1
View File
@@ -116,7 +116,7 @@ mise run proto
Regenerate mocks: Regenerate mocks:
```shell ```shell
make mocks mise run mocks
``` ```
## Uncloud in Docker (UCinD) ## Uncloud in Docker (UCinD)
-4
View File
@@ -21,10 +21,6 @@ corrosion-multiarch-image-push:
ucind-multiarch-image-push: ucind-multiarch-image-push:
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind . docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
.PHONY: mocks
mocks:
@mockery
.PHONY: test .PHONY: test
test: test:
ifeq ($(TEST_NAME),) ifeq ($(TEST_NAME),)
+2
View File
@@ -44,6 +44,8 @@ complexity of Kubernetes.
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service. [Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's * **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
Encrypt. Encrypt.
* **Cluster storage for TLS certificates**: Native cluster storage for Caddy shares certificates and ACME challenge
tokens across machines and coordinates issuance.
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications. * **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster. * **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
+8
View File
@@ -4,8 +4,16 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"net/netip" "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 { func NewIP(addr netip.Addr) *IP {
// MarshalBinary always returns a nil error. // MarshalBinary always returns a nil error.
ip, _ := addr.MarshalBinary() 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"` MachineName string `protobuf:"bytes,5,opt,name=machine_name,json=machineName,proto3" json:"machine_name,omitempty"`
// Address of the machine the response came from. // Address of the machine the response came from.
MachineAddr string `protobuf:"bytes,1,opt,name=machine_addr,json=machineAddr,proto3" json:"machine_addr,omitempty"` 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 string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
// error as a gRPC Status message. // error as a gRPC Status message.
Status *status.Status `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` 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. // Address of the machine the response came from.
string machine_addr = 1; 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; string error = 2;
// error as a gRPC Status message. // error as a gRPC Status message.
google.rpc.Status status = 3; 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. // Docker auth credentials if necessary.
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse); rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
rpc ListImages(ListImagesRequest) returns (ListImagesResponse); rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse);
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse); rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse); rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
@@ -179,6 +180,40 @@ message ListImagesRequest {
bytes options = 1; 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 { message ListImagesResponse {
// Must contain only one repeated messages field to allow broadcasting ListImages requests to multiple machines. // Must contain only one repeated messages field to allow broadcasting ListImages requests to multiple machines.
repeated MachineImages messages = 1; repeated MachineImages messages = 1;
+38
View File
@@ -32,6 +32,7 @@ const (
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage" Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage" Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
Docker_ListImages_FullMethodName = "/api.Docker/ListImages" Docker_ListImages_FullMethodName = "/api.Docker/ListImages"
Docker_RemoveImage_FullMethodName = "/api.Docker/RemoveImage"
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume" Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes" Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume" Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
@@ -59,6 +60,7 @@ type DockerClient interface {
// Docker auth credentials if necessary. // Docker auth credentials if necessary.
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error) InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, 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) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error)
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error) ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, 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 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) { func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateVolumeResponse) out := new(CreateVolumeResponse)
@@ -305,6 +317,7 @@ type DockerServer interface {
// Docker auth credentials if necessary. // Docker auth credentials if necessary.
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error) InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error) ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error)
RemoveImage(context.Context, *RemoveImageRequest) (*RemoveImageResponse, error)
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error) ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, 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) { func (UnimplementedDockerServer) ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListImages not implemented") 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) { func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented") 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) 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) { func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateVolumeRequest) in := new(CreateVolumeRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@@ -760,6 +794,10 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListImages", MethodName: "ListImages",
Handler: _Docker_ListImages_Handler, Handler: _Docker_ListImages_Handler,
}, },
{
MethodName: "RemoveImage",
Handler: _Docker_RemoveImage_Handler,
},
{ {
MethodName: "CreateVolume", MethodName: "CreateVolume",
Handler: _Docker_CreateVolume_Handler, Handler: _Docker_CreateVolume_Handler,
+1 -1
View File
@@ -20,7 +20,7 @@ service Machine {
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines. // InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
rpc InspectMachine(google.protobuf.Empty) returns (InspectMachineResponse); rpc InspectMachine(google.protobuf.Empty) returns (InspectMachineResponse);
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with // WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
// no known missing or pending transactions through those versions. // no known missing or pending transactions through those versions from active members.
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes // Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
// have been superseded. // have been superseded.
// //
+2 -2
View File
@@ -48,7 +48,7 @@ type MachineClient interface {
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines. // InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
InspectMachine(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectMachineResponse, error) InspectMachine(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectMachineResponse, error)
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with // WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
// no known missing or pending transactions through those versions. // no known missing or pending transactions through those versions from active members.
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes // Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
// have been superseded. // have been superseded.
// //
@@ -224,7 +224,7 @@ type MachineServer interface {
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines. // InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
InspectMachine(context.Context, *emptypb.Empty) (*InspectMachineResponse, error) InspectMachine(context.Context, *emptypb.Empty) (*InspectMachineResponse, error)
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with // WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
// no known missing or pending transactions through those versions. // no known missing or pending transactions through those versions from active members.
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes // Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
// have been superseded. // have been superseded.
// //
+167
View File
@@ -0,0 +1,167 @@
package cert
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"slices"
"strings"
"time"
"charm.land/lipgloss/v2"
"github.com/caddyserver/certmagic"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra"
)
type listOptions struct {
machine string
output string
}
func NewListCommand() *cobra.Command {
opts := listOptions{}
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List certificates in cluster storage for Caddy.",
Long: `List certificates stored in the Uncloud cluster storage for Caddy.
Caddy must use the Uncloud storage module (https://github.com/unlabs-dev/caddy-uncloud)
configured with 'storage uncloud' in the global options for its certificates to appear here.
This inventory does not check whether Caddy currently serves or trusts a certificate.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(cmd.Context(), uncli, opts)
},
}
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
"Name or ID of the machine to read certificate storage from. (default is connected machine)")
cmd.Flags().StringVarP(&opts.output, "output", "o", "",
"Output format: 'json' or empty for a human-readable table.")
completion.MachinesFlag(cmd)
return cmd
}
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
if opts.output != "" && opts.output != "json" {
return fmt.Errorf("unsupported output format '%s' (supported: json)", opts.output)
}
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
certs, listErr := clusterClient.Caddy.ListCertificates(ctx,
client.CaddyListCertificatesOptions{Machine: opts.machine})
if len(certs) > 0 || listErr == nil {
if err = printCertificates(os.Stdout, certs, opts.output); err != nil {
return fmt.Errorf("print Caddy certificates: %w", err)
}
}
if listErr != nil {
if len(certs) > 0 {
return fmt.Errorf("certificate list is partial: %w", listErr)
}
return fmt.Errorf("list Caddy certificates: %w", listErr)
}
return nil
}
func printCertificates(out io.Writer, certs []api.IssuedCertificate, output string) error {
type certificate struct {
api.IssuedCertificate
Fingerprint string
}
items := make([]certificate, 0, len(certs))
for _, cert := range certs {
fingerprint := cert.Fingerprint()
items = append(items, certificate{
IssuedCertificate: cert,
Fingerprint: hex.EncodeToString(fingerprint[:]),
})
}
slices.SortFunc(items, func(a, b certificate) int {
if cmp := strings.Compare(a.SAN, b.SAN); cmp != 0 {
return cmp
}
if cmp := a.Chain[0].NotAfter.Compare(b.Chain[0].NotAfter); cmp != 0 {
return cmp
}
return strings.Compare(a.Fingerprint, b.Fingerprint)
})
if output == "json" {
encoder := json.NewEncoder(out)
encoder.SetIndent("", " ")
if err := encoder.Encode(items); err != nil {
return fmt.Errorf("marshal certificates: %w", err)
}
return nil
}
if len(items) == 0 {
_, err := fmt.Fprintln(out, "No Caddy certificates found in cluster storage.")
return err
}
t := tui.NewTable()
t.Headers("ID", "NAME", "ISSUER", "EXPIRES")
for _, item := range items {
t.Row(
item.Fingerprint[:12],
item.SAN,
formatIssuer(item.IssuerData),
formatExpiry(item.Chain[0].NotAfter),
)
}
_, err := lipgloss.Fprintln(out, t)
return err
}
func formatIssuer(data api.CertificateIssuerData) string {
if data.ACME == nil {
return "unknown"
}
switch data.ACME.CA {
case certmagic.LetsEncryptProductionCA:
return "Let's Encrypt"
case certmagic.LetsEncryptStagingCA:
return "Let's Encrypt (staging)"
case certmagic.ZeroSSLProductionCA:
return "ZeroSSL"
case certmagic.GoogleTrustProductionCA:
return "Google Trust Services"
case certmagic.GoogleTrustStagingCA:
return "Google Trust Services (staging)"
default:
return data.ACME.CA
}
}
func formatExpiry(expires time.Time) string {
delta := expires.Sub(time.Now())
if delta <= 0 {
return fmt.Sprintf("%s (expired %s ago)", expires.UTC().Format(time.DateOnly),
strings.ToLower(units.HumanDuration(-delta)))
}
return fmt.Sprintf("%s (%s)", expires.UTC().Format(time.DateOnly),
strings.ToLower(units.HumanDuration(delta)))
}
+128
View File
@@ -0,0 +1,128 @@
package cert
import (
"bytes"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/json"
"strings"
"testing"
"time"
"github.com/caddyserver/certmagic"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPrintCertificates(t *testing.T) {
now := time.Now().UTC().Truncate(time.Hour)
certs := []api.IssuedCertificate{
testIssuedCertificate("z.example.com", "z", now.Add(48*time.Hour), certmagic.LetsEncryptProductionCA),
testIssuedCertificate("a.example.com", "old", now.Add(-48*time.Hour), certmagic.LetsEncryptStagingCA),
testIssuedCertificate("a.example.com", "new", now.Add(72*time.Hour), "https://ca.example/directory"),
testIssuedCertificate("unknown.example.com", "unknown", now.Add(96*time.Hour), ""),
}
var table bytes.Buffer
require.NoError(t, printCertificates(&table, certs, ""))
output := table.String()
assert.Contains(t, output, "ID")
assert.Contains(t, output, "NAME")
assert.Contains(t, output, "ISSUER")
assert.Contains(t, output, "EXPIRES")
assert.Contains(t, output, now.Add(-48*time.Hour).Format(time.DateOnly)+" (expired ")
assert.Contains(t, output, now.Add(48*time.Hour).Format(time.DateOnly)+" (")
assert.Contains(t, output, "Let's Encrypt (staging)")
assert.Contains(t, output, "https://ca.example/directory")
assert.Less(t, strings.Index(output, now.Add(-48*time.Hour).Format(time.DateOnly)),
strings.Index(output, now.Add(72*time.Hour).Format(time.DateOnly)))
assert.Less(t, strings.Index(output, "a.example.com"), strings.Index(output, "z.example.com"))
assert.Contains(t, output, testFingerprint("old")[:12])
var encoded bytes.Buffer
require.NoError(t, printCertificates(&encoded, certs, "json"))
var items []struct {
api.IssuedCertificate
Fingerprint string
}
require.NoError(t, json.Unmarshal(encoded.Bytes(), &items))
require.Len(t, items, 4)
assert.Equal(t, testFingerprint("old"), items[0].Fingerprint)
assert.Equal(t, "a.example.com", items[0].SAN)
require.Len(t, items[0].Chain, 1)
assert.Equal(t, []byte("old"), items[0].Chain[0].Raw)
assert.Equal(t, now.Add(-48*time.Hour), items[0].Chain[0].NotAfter)
require.NotNil(t, items[0].IssuerData.ACME)
assert.Equal(t, certmagic.LetsEncryptStagingCA, items[0].IssuerData.ACME.CA)
assert.Equal(t, testFingerprint("new"), items[1].Fingerprint)
require.NotNil(t, items[1].IssuerData.ACME)
assert.Equal(t, "https://ca.example/directory", items[1].IssuerData.ACME.CA)
assert.Nil(t, items[2].IssuerData.ACME)
assert.Equal(t, "z.example.com", items[3].SAN)
}
func TestPrintCertificates_Empty(t *testing.T) {
for _, tt := range []struct {
output string
want string
}{
{output: "", want: "No Caddy certificates found in cluster storage.\n"},
{output: "json", want: "[]\n"},
} {
var out bytes.Buffer
require.NoError(t, printCertificates(&out, nil, tt.output))
assert.Equal(t, tt.want, out.String())
}
}
func TestFormatIssuer(t *testing.T) {
for _, tt := range []struct {
name string
ca string
want string
}{
{name: "production", ca: certmagic.LetsEncryptProductionCA, want: "Let's Encrypt"},
{name: "staging", ca: certmagic.LetsEncryptStagingCA, want: "Let's Encrypt (staging)"},
{name: "ZeroSSL", ca: certmagic.ZeroSSLProductionCA, want: "ZeroSSL"},
{name: "Google Trust Services", ca: certmagic.GoogleTrustProductionCA, want: "Google Trust Services"},
{name: "Google Trust Services staging", ca: certmagic.GoogleTrustStagingCA, want: "Google Trust Services (staging)"},
{name: "other", ca: "https://ca.example/directory", want: "https://ca.example/directory"},
} {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, formatIssuer(api.CertificateIssuerData{
ACME: &api.ACMEIssuerData{CA: tt.ca},
}))
})
}
assert.Equal(t, "unknown", formatIssuer(api.CertificateIssuerData{}))
}
func TestFormatExpiry_UTC(t *testing.T) {
now := time.Now().UTC()
brisbane := time.FixedZone("AEST", 10*60*60)
expires := time.Date(now.Year(), now.Month(), now.Day()+3, 3, 0, 0, 0, brisbane)
assert.True(t, strings.HasPrefix(formatExpiry(expires), expires.UTC().Format(time.DateOnly)+" ("))
assert.NotEqual(t, expires.Format(time.DateOnly), expires.UTC().Format(time.DateOnly))
expired := now.Add(-2 * time.Hour)
assert.True(t, strings.HasPrefix(formatExpiry(expired), expired.UTC().Format(time.DateOnly)+" (expired "))
assert.Contains(t, formatExpiry(expired), " ago)")
}
func testIssuedCertificate(name, raw string, expires time.Time, ca string) api.IssuedCertificate {
cert := api.IssuedCertificate{
SAN: name,
Chain: []*x509.Certificate{{Raw: []byte(raw), NotAfter: expires}},
}
if ca != "" {
cert.IssuerData.ACME = &api.ACMEIssuerData{CA: ca}
}
return cert
}
func testFingerprint(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
+12
View File
@@ -0,0 +1,12 @@
package cert
import "github.com/spf13/cobra"
func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cert",
Short: "Inspect certificates in cluster storage for Caddy.",
}
cmd.AddCommand(NewListCommand())
return cmd
}
+3 -10
View File
@@ -3,8 +3,8 @@ package caddy
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"charm.land/lipgloss/v2"
"github.com/alecthomas/chroma/v2/quick" "github.com/alecthomas/chroma/v2/quick"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/completion"
@@ -15,7 +15,6 @@ import (
type configOptions struct { type configOptions struct {
machine string machine string
noColor bool
} }
func NewConfigCommand() *cobra.Command { func NewConfigCommand() *cobra.Command {
@@ -33,8 +32,6 @@ func NewConfigCommand() *cobra.Command {
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "", cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
"Name or ID of the machine to get the configuration from. (default is connected machine)") "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) completion.MachinesFlag(cmd)
@@ -54,13 +51,9 @@ func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
} }
// Print the Caddyfile with syntax highlighting. // Print the Caddyfile with syntax highlighting.
if opts.noColor { if err = quick.Highlight(lipgloss.Writer, config.Caddyfile, "caddy", "terminal256", "monokai"); err != nil {
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. // If highlighting fails, fall back to plain output.
fmt.Print(config.Caddyfile) lipgloss.Print(config.Caddyfile)
}
} }
if config.LastReconciliationError != "" { if config.LastReconciliationError != "" {
+2
View File
@@ -1,6 +1,7 @@
package caddy package caddy
import ( import (
"github.com/psviderski/uncloud/cmd/uc/caddy/cert"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -10,6 +11,7 @@ func NewRootCommand() *cobra.Command {
Short: "Manage Caddy reverse proxy service.", Short: "Manage Caddy reverse proxy service.",
} }
cmd.AddCommand( cmd.AddCommand(
cert.NewRootCommand(),
NewConfigCommand(), NewConfigCommand(),
NewDeployCommand(), NewDeployCommand(),
NewLogsCommand(), NewLogsCommand(),
+2 -2
View File
@@ -18,7 +18,7 @@ import (
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose" "github.com/psviderski/uncloud/pkg/client/compose"
"github.com/psviderski/uncloud/pkg/client/deploy" "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" "github.com/spf13/cobra"
) )
@@ -305,7 +305,7 @@ const defaultFailedContainerLogsTail = 10
// UNCLOUD_FAILED_CONTAINER_LOGS_TAIL environment variable override when set and valid. // UNCLOUD_FAILED_CONTAINER_LOGS_TAIL environment variable override when set and valid.
func failedContainerLogsTail() int { func failedContainerLogsTail() int {
if v := os.Getenv("UNCLOUD_FAILED_CONTAINER_LOGS_TAIL"); v != "" { if v := os.Getenv("UNCLOUD_FAILED_CONTAINER_LOGS_TAIL"); v != "" {
if tail, err := logs.Tail(v); err == nil && (tail == -1 || tail > 0) { if tail, err := logs.ParseTail(v); err == nil && (tail == -1 || tail > 0) {
return tail return tail
} }
} }
+3
View File
@@ -9,8 +9,10 @@ import (
"github.com/psviderski/uncloud/api/pb" "github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/grpcversion"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
) )
@@ -49,6 +51,7 @@ func set(ctx context.Context, uncli *cli.CLI, name string) error {
} }
defer clusterClient.Close() defer clusterClient.Close()
ctx = metadata.AppendToOutgoingContext(ctx, grpcversion.MetadataKeyMinServerVersion, "0.21.0")
_, err = clusterClient.SetDomain(ctx, &pb.SetDomainRequest{Name: name}) _, err = clusterClient.SetDomain(ctx, &pb.SetDomainRequest{Name: name})
if err != nil { if err != nil {
if status.Convert(err).Code() == codes.AlreadyExists { if status.Convert(err).Code() == codes.AlreadyExists {
+46 -45
View File
@@ -13,6 +13,7 @@ import (
"github.com/charmbracelet/colorprofile" "github.com/charmbracelet/colorprofile"
"github.com/containerd/platforms" "github.com/containerd/platforms"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
@@ -102,7 +103,7 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
var rows []imageRow var rows []imageRow
for _, machineImages := range clusterImages { 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", tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s",
machineImages.Metadata.MachineName, err)) machineImages.Metadata.MachineName, err))
continue continue
@@ -116,49 +117,7 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
store = "containerd" store = "containerd"
} }
// Process each image for this machine. rows = append(rows, imageRows(machineImages.Images, machineName, store)...)
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,
})
}
} }
if len(rows) == 0 { if len(rows) == 0 {
@@ -184,13 +143,55 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
return nil 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. // 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) { func imagePlatforms(img image.Summary) ([]string, bool) {
var formattedPlatforms []string var formattedPlatforms []string
multiPlatform := false multiPlatform := false
for _, m := range img.Manifests { for _, m := range img.Manifests {
if m.Kind != image.ManifestKindImage || !m.Available { if m.Kind != image.ManifestKindImage || !m.Available || m.ImageData == nil {
continue continue
} }
+12 -7
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
"time"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/completion"
@@ -64,6 +65,15 @@ If no services are specified, streams logs from the uncloud service.`,
} }
func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.Options) error { func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.Options) error {
since, until, err := logs.TimeRange(opts.Since, opts.Until, time.Now())
if err != nil {
return err
}
tail, err := opts.TailLines()
if err != nil {
return err
}
if len(services) == 0 { if len(services) == 0 {
services = []string{api.SystemServiceUncloud} services = []string{api.SystemServiceUncloud}
} }
@@ -74,11 +84,6 @@ func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.O
} }
} }
tail, err := logs.Tail(opts.Tail)
if err != nil {
return err
}
c, err := uncli.ConnectCluster(ctx) c, err := uncli.ConnectCluster(ctx)
if err != nil { if err != nil {
return fmt.Errorf("connect to cluster: %w", err) return fmt.Errorf("connect to cluster: %w", err)
@@ -88,8 +93,8 @@ func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.O
logsOpts := api.ServiceLogsOptions{ logsOpts := api.ServiceLogsOptions{
Follow: opts.Follow, Follow: opts.Follow,
Tail: tail, Tail: tail,
Since: opts.Since, Since: since,
Until: opts.Until, Until: until,
Machines: cli.ExpandCommaSeparatedValues(opts.Machines), Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
} }
+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") tui.PrintWarning("metadata is missing in response from unknown server")
continue continue
} }
if m.Metadata.Error != "" { if err := m.Metadata.Err(); err != nil {
tui.PrintWarning(fmt.Sprintf("failed to inspect machine '%s': %s", m.Metadata.MachineName, m.Metadata.Error)) tui.PrintWarning(fmt.Sprintf("failed to inspect machine '%s': %s", m.Metadata.MachineName, err))
continue continue
} }
if m.Machine == nil { 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 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", tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine %s: %s",
machineName, msc.Metadata.Error)) machineName, err))
continue continue
} }
+12 -8
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
mapset "github.com/deckarep/golang-set/v2" mapset "github.com/deckarep/golang-set/v2"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
@@ -78,6 +79,15 @@ If no services are specified, streams logs from all services defined in the Comp
} }
func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Options) error { func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Options) error {
since, until, err := logs.TimeRange(opts.Since, opts.Until, time.Now())
if err != nil {
return err
}
tail, err := opts.TailLines()
if err != nil {
return err
}
serviceArgs, err := logs.ParseServiceArgs(args) serviceArgs, err := logs.ParseServiceArgs(args)
if err != nil { if err != nil {
return err return err
@@ -106,12 +116,6 @@ func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Optio
} }
} }
// Parse tail option.
tail, err := logs.Tail(opts.Tail)
if err != nil {
return err
}
c, err := uncli.ConnectCluster(ctx) c, err := uncli.ConnectCluster(ctx)
if err != nil { if err != nil {
return fmt.Errorf("connect to cluster: %w", err) return fmt.Errorf("connect to cluster: %w", err)
@@ -121,8 +125,8 @@ func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Optio
baseOpts := api.ServiceLogsOptions{ baseOpts := api.ServiceLogsOptions{
Follow: opts.Follow, Follow: opts.Follow,
Tail: tail, Tail: tail,
Since: opts.Since, Since: since,
Until: opts.Until, Until: until,
Machines: cli.ExpandCommaSeparatedValues(opts.Machines), Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
} }
+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" "time"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/charmbracelet/colorprofile"
"github.com/docker/docker/pkg/stringid" "github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
@@ -24,6 +25,10 @@ type Formatter struct {
maxServiceWidth int maxServiceWidth int
utc bool utc bool
// Cache each stream's terminal profile to avoid detection on every log entry.
stdout *colorprofile.Writer
stderr *colorprofile.Writer
} }
func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter { func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter {
@@ -50,6 +55,8 @@ func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter {
maxMachineWidth: maxMachineWidth, maxMachineWidth: maxMachineWidth,
maxServiceWidth: maxServiceWidth, maxServiceWidth: maxServiceWidth,
utc: utc, utc: utc,
stdout: colorprofile.NewWriter(os.Stdout, os.Environ()),
stderr: colorprofile.NewWriter(os.Stderr, os.Environ()),
} }
} }
@@ -138,9 +145,9 @@ func (f *Formatter) PrintEntry(entry api.ServiceLogEntry) {
// Print to appropriate stream. // Print to appropriate stream.
if entry.Stream == api.LogStreamStderr { if entry.Stream == api.LogStreamStderr {
fmt.Fprint(os.Stderr, output.String()) fmt.Fprint(f.stderr, output.String())
} else { } else {
fmt.Print(output.String()) fmt.Fprint(f.stdout, output.String())
} }
} }
@@ -148,8 +155,7 @@ func (f *Formatter) PrintEntry(entry api.ServiceLogEntry) {
func (f *Formatter) printError(entry api.ServiceLogEntry) { func (f *Formatter) printError(entry api.ServiceLogEntry) {
if entry.Metadata.ServiceName == "" { if entry.Metadata.ServiceName == "" {
msg := fmt.Sprintf("ERROR: %v", entry.Err) msg := fmt.Sprintf("ERROR: %v", entry.Err)
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.BrightRed) fmt.Fprintln(f.stderr, tui.BoldRed.Render(msg))
fmt.Fprintln(os.Stderr, style.Render(msg))
return return
} }
@@ -171,8 +177,7 @@ func (f *Formatter) printError(entry api.ServiceLogEntry) {
msg += fmt.Sprintf(": %v", entry.Err) msg += fmt.Sprintf(": %v", entry.Err)
} }
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.BrightYellow) fmt.Fprintln(f.stderr, tui.BoldYellow.Render(msg))
fmt.Fprintln(os.Stderr, style.Render(msg))
} }
// palette is available colors for machine/service differentiation. // palette is available colors for machine/service differentiation.
+22 -7
View File
@@ -19,6 +19,18 @@ type Options struct {
Machines []string Machines []string
} }
// TailLines resolves the default tail limit after parsing flags. An empty opts.Tail means the user
// did not specify a limit, so --since can select all matching logs without overriding an explicit --tail.
func (opts Options) TailLines() (int, error) {
if opts.Tail == "" {
if opts.Since != "" {
return -1, nil
}
return 100, nil
}
return ParseTail(opts.Tail)
}
func Flags(options *Options) *pflag.FlagSet { func Flags(options *Options) *pflag.FlagSet {
set := &pflag.FlagSet{} set := &pflag.FlagSet{}
@@ -29,14 +41,17 @@ func Flags(options *Options) *pflag.FlagSet {
set.StringVar(&options.Since, "since", "", set.StringVar(&options.Since, "since", "",
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+ "Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
"Examples:\n"+ "Examples:\n"+
" --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+ " --since 1h45m Relative duration (1 hour 45 minutes ago)\n"+
" --since 1h Relative duration (1 hour ago)\n"+ " Supported units: d (day = 24h), h (hour), m (minute),\n"+
" --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+ " s (second), ms (millisecond),\n"+
" --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+ " us/µs (microsecond), ns (nanosecond)\n"+
" --since 2025-11-24 RFC 3339 date only (midnight using client local timezone)\n"+
" --since 2024-05-14T22:50:00 RFC 3339 date/time using client local timezone\n"+
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+ " --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)") " --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
set.StringVarP(&options.Tail, "tail", "n", "100", set.StringVarP(&options.Tail, "tail", "n", "",
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.") "Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.\n"+
"Defaults to 100, or 'all' when --since is set.")
set.StringVar(&options.Until, "until", "", set.StringVar(&options.Until, "until", "",
"Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+ "Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
"See --since for examples.") "See --since for examples.")
@@ -46,7 +61,7 @@ func Flags(options *Options) *pflag.FlagSet {
return set return set
} }
func Tail(tail string) (int, error) { func ParseTail(tail string) (int, error) {
if tail == "all" { if tail == "all" {
return -1, nil return -1, nil
} }
+36
View File
@@ -7,6 +7,42 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestOptionsTailLines(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
want int
}{
{"default", nil, 100},
{"follow", []string{"-f"}, 100},
{"since", []string{"--since", "1h"}, -1},
{"since and follow", []string{"--since", "1h", "-f"}, -1},
{"until only", []string{"--until", "1h"}, 100},
{"time range", []string{"--since", "3h", "--until", "1h"}, -1},
{"explicit default with since", []string{"--since", "1h", "--tail", "100"}, 100},
{"explicit limit with since", []string{"--since", "1h", "-n", "20"}, 20},
{"explicit limit before since", []string{"-n", "20", "--since", "1h"}, 20},
{"explicit all", []string{"--tail", "all"}, -1},
{"explicit zero with since", []string{"--since", "1h", "-n", "0", "-f"}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var options Options
require.NoError(t, Flags(&options).Parse(tt.args))
tail, err := options.TailLines()
require.NoError(t, err)
assert.Equal(t, tt.want, tail)
})
}
var options Options
require.NoError(t, Flags(&options).Parse([]string{"--since", "1h", "--tail", "invalid"}))
_, err := options.TailLines()
require.ErrorContains(t, err, "invalid --tail value")
}
func TestParseServiceArgs(t *testing.T) { func TestParseServiceArgs(t *testing.T) {
t.Parallel() t.Parallel()
+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")
})
}
}
+3 -1
View File
@@ -3,9 +3,11 @@ package tui
import ( import (
"fmt" "fmt"
"os" "os"
"charm.land/lipgloss/v2"
) )
func PrintWarning(msg string) { func PrintWarning(msg string) {
styledMsg := BoldYellow.Render(fmt.Sprintf("WARNING: %s", msg)) styledMsg := BoldYellow.Render(fmt.Sprintf("WARNING: %s", msg))
fmt.Fprintln(os.Stderr, styledMsg) lipgloss.Fprintln(os.Stderr, styledMsg)
} }
+8 -1
View File
@@ -13,6 +13,8 @@ import (
"time" "time"
) )
const adminCommandTimeout = 5 * time.Second
// AdminClient is a client for the Corrosion admin API. // AdminClient is a client for the Corrosion admin API.
type AdminClient struct { type AdminClient struct {
sockPath string sockPath string
@@ -32,10 +34,15 @@ type Response struct {
// The channel will be closed after sending the last or error response. The caller must read from the channel until // The channel will be closed after sending the last or error response. The caller must read from the channel until
// it is closed. // it is closed.
func (c *AdminClient) SendCommand(cmd []byte) (<-chan Response, error) { func (c *AdminClient) SendCommand(cmd []byte) (<-chan Response, error) {
conn, err := net.Dial("unix", c.sockPath) // TODO: Accept contexts in admin methods so callers can cancel requests and set their deadlines.
conn, err := net.DialTimeout("unix", c.sockPath, adminCommandTimeout)
if err != nil { if err != nil {
return nil, fmt.Errorf("connect to admin socket: %w", err) return nil, fmt.Errorf("connect to admin socket: %w", err)
} }
if err = conn.SetDeadline(time.Now().Add(adminCommandTimeout)); err != nil {
conn.Close()
return nil, fmt.Errorf("set admin connection deadline: %w", err)
}
if _, err = conn.Write(encodeFrame(cmd)); err != nil { if _, err = conn.Write(encodeFrame(cmd)); err != nil {
conn.Close() conn.Close()
+15 -2
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"os/exec" "os/exec"
"time"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
) )
@@ -31,11 +32,11 @@ func logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (io.Rea
if opts.Since != "" { if opts.Since != "" {
args = append(args, "-S") args = append(args, "-S")
args = append(args, opts.Since) args = append(args, journalTimestamp(opts.Since))
} }
if opts.Until != "" { if opts.Until != "" {
args = append(args, "-U") args = append(args, "-U")
args = append(args, opts.Until) args = append(args, journalTimestamp(opts.Until))
} }
cmd := commandContext(ctx, journalctl, args...) cmd := commandContext(ctx, journalctl, args...)
@@ -51,6 +52,18 @@ func logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (io.Rea
return p, cmd.Wait, nil return p, cmd.Wait, nil
} }
// journalTimestamp formats normalised client timestamps for journalctl versions that do not
// accept RFC 3339 timezone suffixes. Support for timestamps containing T and Z was added in
// systemd 255 (6 December 2023). Debian 12 ships systemd 252, so it still needs this conversion.
// Journald timestamps have microsecond precision.
func journalTimestamp(value string) string {
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
return t.UTC().Format("2006-01-02 15:04:05.999999") + " UTC"
}
// Preserve raw filters from older clients and SDK callers.
return value
}
// follow synchronously follows the io.Reader, writing each new journal entry to channel. // follow synchronously follows the io.Reader, writing each new journal entry to channel.
// It stops when the reader is exhausted or the context is cancelled. // It stops when the reader is exhausted or the context is cancelled.
func follow(ctx context.Context, reader io.Reader, outCh chan api.LogEntry) { func follow(ctx context.Context, reader io.Reader, outCh chan api.LogEntry) {
+38
View File
@@ -115,6 +115,9 @@ func TestEntry(t *testing.T) {
} }
func TestLogs(t *testing.T) { func TestLogs(t *testing.T) {
originalCommandContext := commandContext
t.Cleanup(func() { commandContext = originalCommandContext })
commandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd { commandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs") return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs")
} }
@@ -150,3 +153,38 @@ func TestLogs(t *testing.T) {
// Still six because heartbeats are not written here and Tail is ignored as the command is overridden. // Still six because heartbeats are not written here and Tail is ignored as the command is overridden.
assert.Equal(t, 6, i) assert.Equal(t, 6, i)
} }
func TestLogs_TimeFilters(t *testing.T) {
originalCommandContext := commandContext
t.Cleanup(func() { commandContext = originalCommandContext })
var args []string
commandContext = func(ctx context.Context, command string, commandArgs ...string) *exec.Cmd {
assert.Equal(t, "journalctl", command)
args = commandArgs
return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs")
}
ch, err := Logs(context.Background(), "uncloud", api.ServiceLogsOptions{
Since: "2026-07-01T10:30:45.123456789+10:00",
Until: "2026-07-01T01:30:45Z",
})
require.NoError(t, err)
for range ch {
}
assert.Equal(t, []string{
"-u", "uncloud", "--no-hostname", "-n", "0", "-o", "short-unix",
"-S", "2026-07-01 00:30:45.123456 UTC",
"-U", "2026-07-01 01:30:45 UTC",
}, args)
// Older clients and SDK callers can still pass journalctl's native filters.
ch, err = Logs(context.Background(), "uncloud", api.ServiceLogsOptions{Since: "1h ago", Until: "today"})
require.NoError(t, err)
for range ch {
}
assert.Equal(t, []string{
"-u", "uncloud", "--no-hostname", "-n", "0", "-o", "short-unix",
"-S", "1h ago", "-U", "today",
}, args)
}
+10 -1
View File
@@ -16,10 +16,19 @@ func NewMockCaddyfileValidator(t interface {
mock.TestingT mock.TestingT
Cleanup(func()) Cleanup(func())
}) *MockCaddyfileValidator { }) *MockCaddyfileValidator {
if helper, ok := t.(interface{ Helper() }); ok {
helper.Helper()
}
mock := &MockCaddyfileValidator{} mock := &MockCaddyfileValidator{}
mock.Mock.Test(t) 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 return mock
} }
+6
View File
@@ -14,6 +14,7 @@ import (
"github.com/psviderski/uncloud/internal/machine/network" "github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/internal/machine/store" "github.com/psviderski/uncloud/internal/machine/store"
"github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/emptypb"
@@ -87,6 +88,11 @@ func (c *Cluster) AddMachine(ctx context.Context, req *pb.AddMachineRequest) (*p
func (c *Cluster) AddMachineWithoutReadyCheck( func (c *Cluster) AddMachineWithoutReadyCheck(
ctx context.Context, req *pb.AddMachineRequest, ctx context.Context, req *pb.AddMachineRequest,
) (*pb.AddMachineResponse, error) { ) (*pb.AddMachineResponse, error) {
if req.Name != "" {
if err := api.ValidateMachineName(req.Name); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
}
if req.Network == nil { if req.Network == nil {
return nil, status.Error(codes.InvalidArgument, "network not set") return nil, status.Error(codes.InvalidArgument, "network not set")
} }
+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" "strings"
"github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api"
) )
// NewMachineID generates a new unique machine ID. // NewMachineID generates a new unique machine ID.
@@ -27,7 +28,7 @@ func NewRandomMachineName() (string, error) {
// a numeric suffix ("-1", "-2", etc.) if needed. // a numeric suffix ("-1", "-2", etc.) if needed.
func DefaultMachineName(hostname string, existing []string) (string, error) { func DefaultMachineName(hostname string, existing []string) (string, error) {
name := machineNameFromHostname(hostname) name := machineNameFromHostname(hostname)
if name == "" { if api.ValidateMachineName(name) != nil {
var err error var err error
if name, err = NewRandomMachineName(); err != nil { if name, err = NewRandomMachineName(); err != nil {
return "", err return "", err
@@ -38,7 +39,9 @@ func DefaultMachineName(hostname string, existing []string) (string, error) {
return name, nil return name, nil
} }
for i := 1; ; i++ { for i := 1; ; i++ {
candidate := fmt.Sprintf("%s-%d", name, i) suffix := fmt.Sprintf("-%d", i)
base := strings.TrimRight(name[:min(len(name), 63-len(suffix))], "-")
candidate := base + suffix
if !slices.Contains(existing, candidate) { if !slices.Contains(existing, candidate) {
return candidate, nil return candidate, nil
} }
@@ -55,12 +58,13 @@ func machineNameFromHostname(hostname string) string {
var b strings.Builder var b strings.Builder
for _, r := range label { for _, r := range label {
switch { switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_': case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
b.WriteRune(r) b.WriteRune(r)
default: default:
b.WriteRune('-') b.WriteRune('-')
} }
} }
// Trim leading and trailing hyphens that may result from the sanitisation above. // Trim leading and trailing hyphens that may result from the sanitisation above.
return strings.Trim(b.String(), "-") name := strings.Trim(b.String(), "-")
return strings.TrimRight(name[:min(len(name), 63)], "-")
} }
+51 -9
View File
@@ -1,8 +1,10 @@
package cluster package cluster
import ( import (
"strings"
"testing" "testing"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -18,9 +20,11 @@ func TestMachineNameFromHostname(t *testing.T) {
{"simple", "web", "web"}, {"simple", "web", "web"},
{"fqdn uses first label", "web-1.example.com", "web-1"}, {"fqdn uses first label", "web-1.example.com", "web-1"},
{"uppercase lowercased", "Web-Server", "web-server"}, {"uppercase lowercased", "Web-Server", "web-server"},
{"invalid chars replaced", "host_name@1", "host_name-1"}, {"invalid chars replaced", "host_name@1", "host-name-1"},
{"trim surrounding hyphens", "-_host_-", "_host_"}, {"trim surrounding hyphens", "-_host_-", "host"},
{"whitespace trimmed", " myhost ", "myhost"}, {"whitespace trimmed", " myhost ", "myhost"},
{"long hostname truncated", strings.Repeat("a", 64), strings.Repeat("a", 63)},
{"truncation trims trailing hyphen", strings.Repeat("a", 62) + "-b", strings.Repeat("a", 62)},
{"empty", "", ""}, {"empty", "", ""},
{"only invalid chars", "@#", ""}, {"only invalid chars", "@#", ""},
{"dot only", ".example.com", ""}, {"dot only", ".example.com", ""},
@@ -37,10 +41,12 @@ func TestDefaultMachineName(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("falls back to random", func(t *testing.T) { t.Run("falls back to random", func(t *testing.T) {
// An empty or fully invalid hostname falls back to a random "machine-xxxx" name. for _, hostname := range []string{"***", "rr", "nearest", strings.Repeat("a", 32)} {
got, err := DefaultMachineName("***", nil) got, err := DefaultMachineName(hostname, nil)
require.NoError(t, err) require.NoError(t, err)
assert.Regexp(t, `^machine-[a-zA-Z0-9]{4}$`, got) assert.Regexp(t, `^machine-[a-z0-9]{4}$`, got)
require.NoError(t, api.ValidateMachineName(got))
}
}) })
tests := []struct { tests := []struct {
@@ -49,10 +55,45 @@ func TestDefaultMachineName(t *testing.T) {
existing []string existing []string
want string want string
}{ }{
{"from hostname", "web-1.example.com", nil, "web-1"}, {
{"dedup against existing", "web", []string{"web"}, "web-1"}, name: "from hostname",
{"dedup multiple", "web", []string{"web", "web-1"}, "web-2"}, hostname: "web-1.example.com",
{"sanitized", "My_Host", nil, "my_host"}, want: "web-1",
},
{
name: "dedup against existing",
hostname: "web",
existing: []string{"web"},
want: "web-1",
},
{
name: "dedup multiple",
hostname: "web",
existing: []string{"web", "web-1"},
want: "web-2",
},
{
name: "sanitized",
hostname: "My_Host",
want: "my-host",
},
{
name: "long hostname",
hostname: strings.Repeat("a", 64),
want: strings.Repeat("a", 63),
},
{
name: "dedup maximum length",
hostname: strings.Repeat("a", 63),
existing: []string{strings.Repeat("a", 63)},
want: strings.Repeat("a", 61) + "-1",
},
{
name: "dedup trims trailing hyphen",
hostname: strings.Repeat("a", 60) + "-bb",
existing: []string{strings.Repeat("a", 60) + "-bb"},
want: strings.Repeat("a", 60) + "-1",
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -60,6 +101,7 @@ func TestDefaultMachineName(t *testing.T) {
got, err := DefaultMachineName(tt.hostname, tt.existing) got, err := DefaultMachineName(tt.hostname, tt.existing)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, tt.want, got) assert.Equal(t, tt.want, got)
require.NoError(t, api.ValidateMachineName(got))
}) })
} }
} }
+67 -13
View File
@@ -10,6 +10,8 @@ import (
"sync" "sync"
"time" "time"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/internal/machine/store" "github.com/psviderski/uncloud/internal/machine/store"
) )
@@ -19,7 +21,9 @@ type ClusterResolver struct {
store *store.Store store *store.Store
// serviceIPs maps service names to container IPs. // serviceIPs maps service names to container IPs.
serviceIPs map[string][]netip.Addr serviceIPs map[string][]netip.Addr
// mu protects the serviceIPs map. // machineIPs maps machine IDs and names to their IP.
machineIPs map[string]netip.Addr
// mu protects the serviceIPs and machineIPs map.
mu sync.RWMutex mu sync.RWMutex
// lastUpdate tracks when records were last updated. // lastUpdate tracks when records were last updated.
lastUpdate time.Time lastUpdate time.Time
@@ -46,6 +50,13 @@ func (r *ClusterResolver) Run(ctx context.Context) error {
// TODO: implement machine membership check using Corrossion Admin client to filter available containers. // TODO: implement machine membership check using Corrossion Admin client to filter available containers.
r.updateServiceIPs(containers) r.updateServiceIPs(containers)
machines, mchanges, err := r.store.SubscribeMachines(ctx)
if err != nil {
return fmt.Errorf("subscribe to machine changes: %w", err)
}
r.log.Info("Subscribed to machine changes in the clsuter to keep machine DNS records updated.")
r.updateMachineIPs(machines)
for { for {
select { select {
case _, ok := <-changes: case _, ok := <-changes:
@@ -59,9 +70,22 @@ func (r *ClusterResolver) Run(ctx context.Context) error {
r.log.Error("Failed to list containers.", "err", err) r.log.Error("Failed to list containers.", "err", err)
continue continue
} }
// TODO: implement machine membership check using Corrossion Admin client to filter available containers. // TODO: implement machine membership check using Corrossion Admin client to filter available containers.
r.updateServiceIPs(containers) r.updateServiceIPs(containers)
case _, ok := <-mchanges:
if !ok {
return fmt.Errorf("machine subscription failed")
}
r.log.Debug("Cluster machines changed, updating DNS records.")
machines, err := r.store.ListMachines(ctx)
if err != nil {
r.log.Error("Failed to list machines.", "err", err)
continue
}
r.updateMachineIPs(machines)
case <-ctx.Done(): case <-ctx.Done():
return nil return nil
} }
@@ -121,19 +145,49 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
r.log.Info("DNS records updated.", "services", len(newServiceIPs)/3, "containers", containersCount) r.log.Info("DNS records updated.", "services", len(newServiceIPs)/3, "containers", containersCount)
} }
// Resolve returns IP addresses of the service containers. func (r *ClusterResolver) updateMachineIPs(machines []*pb.MachineInfo) {
func (r *ClusterResolver) Resolve(serviceName string) []netip.Addr { newMachineIPs := make(map[string]netip.Addr, len(machines))
for _, machine := range machines {
subnet, err := machine.Network.Subnet.ToPrefix()
if err != nil {
continue
}
addr := network.MachineIP(subnet)
newMachineIPs[machine.Name+".m"] = addr
newMachineIPs[machine.Id+".m"] = addr
}
r.mu.Lock()
r.machineIPs = newMachineIPs
r.mu.Unlock()
r.log.Info("DNS records updated.", "machines", len(machines))
}
// Resolve returns IP addresses of the service containers or machines.
func (r *ClusterResolver) Resolve(name string) []netip.Addr {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
ips, ok := r.serviceIPs[serviceName] // Return a copy of the IPs slice to prevent modification of the original.
if !ok || len(ips) == 0 { ips, ok := r.serviceIPs[name]
if ok && len(ips) > 0 {
return slices.Clone(ips)
}
ip, ok := r.machineIPs[name]
if ok {
return slices.Clone([]netip.Addr{ip})
}
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 return nil
} }
// Return a copy of the IPs slice to prevent modification of the original.
ipsCopy := make([]netip.Addr, len(ips))
copy(ipsCopy, ips)
return ipsCopy
}
+31
View File
@@ -1,11 +1,13 @@
package dns package dns
import ( import (
"net/netip"
"reflect" "reflect"
"testing" "testing"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/machine/store" "github.com/psviderski/uncloud/internal/machine/store"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -75,3 +77,32 @@ func newRecord(serviceID, serviceName, ip, machineID string) store.ContainerReco
MachineID: machineID, MachineID: machineID,
} }
} }
func TestClusterResolver_UpdateMachineIPs(t *testing.T) {
t.Parallel()
machines := []*pb.MachineInfo{
newMachineRecord("x0y0z0", "mach-1", "10.210.0.0/24"),
newMachineRecord("x1y1z1", "mach-2", "10.210.1.0/24"),
newMachineRecord("x2y2z2", "mach-3", "10.210.2.0/24"),
}
r := NewClusterResolver(nil)
r.updateMachineIPs(machines)
assert.NotEmpty(t, r.Resolve("mach-1.m"))
assert.NotEmpty(t, r.Resolve("mach-3.m"))
assert.NotEmpty(t, r.Resolve("x0y0z0.m"))
assert.Equal(t, 3, len(r.Resolve("m")))
}
func newMachineRecord(machineID, machineName, prefix string) *pb.MachineInfo {
return &pb.MachineInfo{
Id: machineID,
Name: machineName,
Network: &pb.NetworkConfig{
Subnet: pb.NewIPPrefix(netip.MustParsePrefix(prefix)),
},
}
}
+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. // handleAQuery processes an A query for the internal domain and returns A records for the requested name.
// The internal domain suffix is already stripped from the name. An empty list is returned if no records are found. // The internal domain suffix is already stripped from the name. An empty list is returned if no records are found.
func (s *Server) handleAQuery(name string) []dns.RR { func (s *Server) handleAQuery(name string) []dns.RR {
serviceName, mode := extractModeFromDomain(trimInternalDomain(name)) unname, mode := extractModeFromDomain(trimInternalDomain(name))
ips := s.resolver.Resolve(serviceName) ips := s.resolver.Resolve(unname)
if len(ips) == 0 { if len(ips) == 0 {
s.log.Debug("Failed to resolve service name.", "service", serviceName) s.log.Debug("Failed to resolve internal name.", "name", name)
return nil return nil
} }
s.log.Debug("Resolved service name.", "service", serviceName, "ips", ips) s.log.Debug("Resolved service internal name.", "name", name, "ips", ips)
if len(ips) > 1 { if len(ips) > 1 {
// Shuffle the IPs to approximate round-robin. // Shuffle the IPs to approximate round-robin.
+6 -6
View File
@@ -167,7 +167,7 @@ func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions)
machineContainers := make([]MachineContainers, len(resp.Messages)) machineContainers := make([]MachineContainers, len(resp.Messages))
for i, msg := range resp.Messages { for i, msg := range resp.Messages {
machineContainers[i].Metadata = msg.Metadata machineContainers[i].Metadata = msg.Metadata
if msg.Metadata != nil && msg.Metadata.Error != "" { if msg.Metadata.Err() != nil {
continue continue
} }
@@ -258,7 +258,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
notFoundCount := 0 notFoundCount := 0
for _, msg := range resp.Messages { 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++ notFoundCount++
} }
} }
@@ -269,7 +269,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
images := make([]api.MachineImage, len(resp.Messages)) images := make([]api.MachineImage, len(resp.Messages))
for i, msg := range resp.Messages { for i, msg := range resp.Messages {
images[i].Metadata = msg.Metadata images[i].Metadata = msg.Metadata
if msg.Metadata != nil && msg.Metadata.Error != "" { if msg.Metadata.Err() != nil {
continue continue
} }
@@ -312,7 +312,7 @@ func parseRemoteImageMessage(msg *pb.RemoteImage) (api.MachineRemoteImage, error
mri := api.MachineRemoteImage{ mri := api.MachineRemoteImage{
Metadata: msg.Metadata, Metadata: msg.Metadata,
} }
if msg.Metadata != nil && msg.Metadata.Error != "" { if msg.Metadata.Err() != nil {
return mri, 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)) machineVolumes := make([]MachineVolumes, len(resp.Messages))
for i, msg := range resp.Messages { for i, msg := range resp.Messages {
machineVolumes[i].Metadata = msg.Metadata machineVolumes[i].Metadata = msg.Metadata
if msg.Metadata != nil && msg.Metadata.Error != "" { if msg.Metadata.Err() != nil {
continue continue
} }
@@ -472,7 +472,7 @@ func (c *Client) ListServiceContainers(
machineContainers := make([]MachineServiceContainers, len(resp.Messages)) machineContainers := make([]MachineServiceContainers, len(resp.Messages))
for i, msg := range resp.Messages { for i, msg := range resp.Messages {
machineContainers[i].Metadata = msg.Metadata machineContainers[i].Metadata = msg.Metadata
if msg.Metadata != nil && msg.Metadata.Error != "" { if msg.Metadata.Err() != nil {
continue continue
} }
+1 -1
View File
@@ -27,7 +27,7 @@ const (
type Controller struct { type Controller struct {
// machineID is the ID of the machine where the managed Docker daemon is running. // machineID is the ID of the machine where the managed Docker daemon is running.
machineID string machineID string
client *client.Client client client.APIClient
service *Service service *Service
store *store.Store 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. // Server implements the gRPC Docker service that proxies requests to the Docker daemon.
type Server struct { type Server struct {
pb.UnimplementedDockerServer pb.UnimplementedDockerServer
client *client.Client client client.APIClient
service *Service service *Service
db *sqlx.DB db *sqlx.DB
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty // 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 }, 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 // InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's Docker auth
// credentials if necessary. // credentials if necessary.
func (s *Server) InspectRemoteImage( func (s *Server) InspectRemoteImage(
+2 -2
View File
@@ -31,13 +31,13 @@ import (
// from the machine database. // from the machine database.
type Service struct { type Service struct {
// Client is a Docker client for managing Docker resources. // Client is a Docker client for managing Docker resources.
Client *client.Client Client client.APIClient
// db is a connection to the machine database. // db is a connection to the machine database.
db *sqlx.DB db *sqlx.DB
} }
// NewService creates a new Docker service instance. // 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{ return &Service{
Client: client, Client: client,
db: db, db: db,
+8 -3
View File
@@ -262,11 +262,11 @@ func NewMachine(config *Config) (*Machine, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("create corrosion API client: %w", err) return nil, fmt.Errorf("create corrosion API client: %w", err)
} }
corroStore := store.New(corro)
corroAdmin, err := corrosion.NewAdminClient(config.CorrosionAdminSockPath) corroAdmin, err := corrosion.NewAdminClient(config.CorrosionAdminSockPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("create corrosion admin client: %w", err) return nil, fmt.Errorf("create corrosion admin client: %w", err)
} }
corroStore := store.New(corro, corroAdmin)
initialised := make(chan struct{}) initialised := make(chan struct{})
clusterReady := make(chan struct{}) clusterReady := make(chan struct{})
@@ -836,6 +836,11 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
if m.Initialised() { if m.Initialised() {
return nil, status.Error(codes.FailedPrecondition, "machine is already configured as a cluster member") return nil, status.Error(codes.FailedPrecondition, "machine is already configured as a cluster member")
} }
if req.MachineName != "" {
if err := api.ValidateMachineName(req.MachineName); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
}
clusterNetwork, err := req.Network.ToPrefix() clusterNetwork, err := req.Network.ToPrefix()
if err != nil { if err != nil {
@@ -1220,8 +1225,8 @@ func (m *Machine) applyMachineUpdate(ctx context.Context, req *pb.UpdateMachineR
defer m.state.mu.Unlock() defer m.state.mu.Unlock()
if req.Name != nil { if req.Name != nil {
if *req.Name == "" { if err := api.ValidateMachineName(*req.Name); err != nil {
return status.Error(codes.InvalidArgument, "machine name cannot be empty") return status.Error(codes.InvalidArgument, err.Error())
} }
// Check for duplicate names across the cluster, excluding this machine. // Check for duplicate names across the cluster, excluding this machine.
if *req.Name != m.state.Name { if *req.Name != m.state.Name {
+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 // SyncStatusOutdated indicates that a container record may be outdated, for example, due to being unable
// to retrieve the container's state from the Docker daemon or when the machine is being stopped or restarted. // to retrieve the container's state from the Docker daemon or when the machine is being stopped or restarted.
SyncStatusOutdated = "outdated" SyncStatusOutdated = "outdated"
// containerChangesDebounceInterval defines how long to wait before notifying subscribers about container changes.
// Multiple changes within this window are grouped into a single notification to prevent system overload.
containerChangesDebounceInterval = 250 * time.Millisecond
) )
type ContainerRecord struct { type ContainerRecord struct {
@@ -92,6 +96,9 @@ func normaliseContainerForStore(ctr *api.ServiceContainer) {
// Remove the environment variables to avoid leaking secrets. // Remove the environment variables to avoid leaking secrets.
ctr.Config.Env = nil ctr.Config.Env = nil
ctr.ServiceSpec.Container.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. // Docker returns Mounts in a non-deterministic order so sort them.
slices.SortFunc(ctr.Mounts, func(a, b container.MountPoint) int { 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{}) changes := make(chan struct{})
go func() { go func() {
defer close(changes) defer close(changes)
// Coalesce bursts of rapid-fire row-level change events (e.g. from frequent health-check updates
// across many services) into a single signal per debounce window, instead of forwarding one signal
// per event. Every subscriber of this channel (e.g. the Caddy and DNS reconcilers) otherwise reruns
// its full reconciliation on every single event, which can burn significant CPU across the cluster
// when there's a lot of container churn.
var debouncer *time.Timer
var debouncerCh <-chan time.Time
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -279,8 +293,21 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-c
} }
return return
} }
// Just signal that there is a change in the containers list. if debouncerCh == nil {
changes <- struct{}{} if debouncer == nil {
debouncer = time.NewTimer(containerChangesDebounceInterval)
} else {
debouncer.Reset(containerChangesDebounceInterval)
}
debouncerCh = debouncer.C
}
case <-debouncerCh:
select {
case changes <- struct{}{}:
case <-ctx.Done():
return
}
debouncerCh = nil
} }
} }
}() }()
+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)
})
}
+55 -13
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/psviderski/uncloud/internal/corrosion"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
) )
@@ -15,8 +16,8 @@ import (
var ErrInvalidStoreVersion = errors.New("invalid store version") var ErrInvalidStoreVersion = errors.New("invalid store version")
// WaitForVersion waits until the local store has reached each actor's minimum version in minVersion, with no known // WaitForVersion waits until the local store has reached each actor's minimum version in minVersion, with no known
// missing or pending transactions through those versions. Corrosion may satisfy a version by applying its surviving // missing or pending transactions through those versions from active members. Corrosion may satisfy a version
// changes or by marking it complete because its changes have been superseded. // by applying its surviving changes or by marking it complete because its changes have been superseded.
// //
// Waiting normally makes the captured data available locally. However, if another write replaces some of that data // Waiting normally makes the captured data available locally. However, if another write replaces some of that data
// before it arrives, Corrosion can complete the older version without transferring the replaced data. If the // before it arrives, Corrosion can complete the older version without transferring the replaced data. If the
@@ -53,14 +54,50 @@ func (s *Store) WaitForVersion(ctx context.Context, minVersion api.StoreVersion)
return nil return nil
} }
// Check all bookkeeping in one SQLite snapshot. Raw buffered rows can remain after application, queryPrefix := `WITH min_version(actor_id, version) AS (VALUES ` + strings.Join(placeholders, ", ") + `)
// but sequence bookkeeping is removed in the same transaction that applies or clears a version.
query := `WITH min_version(actor_id, version) AS (VALUES ` + strings.Join(placeholders, ", ") + `)
SELECT NOT EXISTS ( SELECT NOT EXISTS (
SELECT 1 FROM min_version SELECT 1 FROM min_version
LEFT JOIN crsql_db_versions AS current ON current.site_id = min_version.actor_id LEFT JOIN crsql_db_versions AS current ON current.site_id = min_version.actor_id
WHERE COALESCE(current.db_version, 0) < min_version.version WHERE COALESCE(current.db_version, 0) < min_version.version`
OR EXISTS (
versionsReached := false
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
query := queryPrefix
queryArgs := args
// Check for gaps and pending transactions only after the requested versions are reached.
if versionsReached {
states, err := s.corroAdmin.ClusterMembershipStates(true)
if err != nil {
return fmt.Errorf("get cluster membership for store replication: %w", err)
}
activePlaceholders := make([]string, 0, len(states))
queryArgs = make([]any, len(args), len(args)+len(states))
copy(queryArgs, args)
for _, state := range states {
if state.State != corrosion.MembershipStateAlive && state.State != corrosion.MembershipStateSuspect {
continue
}
actorID, err := uuid.Parse(state.ID)
if err != nil {
return fmt.Errorf("parse cluster member actor '%s': %w", state.ID, err)
}
activePlaceholders = append(activePlaceholders, "?")
queryArgs = append(queryArgs, [16]byte(actorID))
}
if len(activePlaceholders) > 0 {
// Check active members' gaps and pending sequences in the same query for simplicity.
// Buffered rows can remain in __corro_buffered_changes after application, but sequence bookkeeping
// is removed when a version completes.
query += `
OR (
min_version.actor_id IN (` + strings.Join(activePlaceholders, ", ") + `)
AND (
EXISTS (
SELECT 1 FROM __corro_bookkeeping_gaps SELECT 1 FROM __corro_bookkeeping_gaps
WHERE actor_id = min_version.actor_id AND start <= min_version.version WHERE actor_id = min_version.actor_id AND start <= min_version.version
) )
@@ -68,13 +105,13 @@ func (s *Store) WaitForVersion(ctx context.Context, minVersion api.StoreVersion)
SELECT 1 FROM __corro_seq_bookkeeping SELECT 1 FROM __corro_seq_bookkeeping
WHERE site_id = min_version.actor_id AND db_version <= min_version.version WHERE site_id = min_version.actor_id AND db_version <= min_version.version
) )
)
)` )`
}
}
query += ")"
ticker := time.NewTicker(100 * time.Millisecond) rows, err := s.corro.QueryContext(ctx, query, queryArgs...)
defer ticker.Stop()
for {
rows, err := s.corro.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return fmt.Errorf("check store replication: %w", err) return fmt.Errorf("check store replication: %w", err)
} }
@@ -84,6 +121,7 @@ func (s *Store) WaitForVersion(ctx context.Context, minVersion api.StoreVersion)
} }
return errors.New("check store replication: store version query returned no rows") return errors.New("check store replication: store version query returned no rows")
} }
var reached int var reached int
if err = rows.Scan(&reached); err != nil { if err = rows.Scan(&reached); err != nil {
rows.Close() rows.Close()
@@ -95,7 +133,11 @@ func (s *Store) WaitForVersion(ctx context.Context, minVersion api.StoreVersion)
return fmt.Errorf("check store replication: %w", err) return fmt.Errorf("check store replication: %w", err)
} }
if reached == 1 { if reached == 1 {
return ctx.Err() if versionsReached {
return nil
}
versionsReached = true
continue
} }
select { select {
+3 -2
View File
@@ -25,10 +25,11 @@ var (
// Store is a cluster store backed by a distributed Corrosion database. // Store is a cluster store backed by a distributed Corrosion database.
type Store struct { type Store struct {
corro *corrosion.APIClient corro *corrosion.APIClient
corroAdmin *corrosion.AdminClient
} }
func New(corro *corrosion.APIClient) *Store { func New(corro *corrosion.APIClient, corroAdmin *corrosion.AdminClient) *Store {
return &Store{corro: corro} return &Store{corro: corro, corroAdmin: corroAdmin}
} }
// Get retrieves an unnamespaced legacy value. // Get retrieves an unnamespaced legacy value.
+108 -24
View File
@@ -1,36 +1,43 @@
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html # @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
[[tools."aqua:vektra/mockery"]] [[tools."aqua:vektra/mockery"]]
version = "3.5.3" version = "3.8.1"
backend = "aqua:vektra/mockery" backend = "aqua:vektra/mockery"
[tools."aqua:vektra/mockery"."platforms.linux-arm64"] [tools."aqua:vektra/mockery"."platforms.linux-arm64"]
checksum = "sha256:ec02ca72b7ad1ff5a1de5503a27d3b1b3f230d722eb35c5b92e43e295a8e1290" checksum = "sha256:d1f481f3a6e9868dd3ad65da265238ee109ab3a5dd7508958d5811953696f452"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_arm64.tar.gz" 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"] [tools."aqua:vektra/mockery"."platforms.linux-arm64-musl"]
checksum = "sha256:ec02ca72b7ad1ff5a1de5503a27d3b1b3f230d722eb35c5b92e43e295a8e1290" checksum = "sha256:d1f481f3a6e9868dd3ad65da265238ee109ab3a5dd7508958d5811953696f452"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_arm64.tar.gz" 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"] [tools."aqua:vektra/mockery"."platforms.linux-x64"]
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c" checksum = "sha256:953c7d38b58dd6398b1c595f27caf8bbf27e9b733b49c404d2e124e86d2ae72a"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz" 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"] [tools."aqua:vektra/mockery"."platforms.linux-x64-musl"]
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c" checksum = "sha256:953c7d38b58dd6398b1c595f27caf8bbf27e9b733b49c404d2e124e86d2ae72a"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz" 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"] [tools."aqua:vektra/mockery"."platforms.macos-arm64"]
checksum = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704" checksum = "sha256:303a794484243906b37da0c37056f974bf94278c2e10d7b1372e6bd53a2d5034"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_arm64.tar.gz" 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"] [tools."aqua:vektra/mockery"."platforms.macos-x64"]
checksum = "sha256:8bd2b3e4722e476400395920fb5bbd68be4ea22ec12cddf1e424c40610fda863" checksum = "sha256:6882331344e946268b304dce042b01371a0213a896536413be8abe49d532ca6f"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_x86_64.tar.gz" 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"] [tools."aqua:vektra/mockery"."platforms.windows-x64"]
checksum = "sha256:3b4264066db615a56a8b59abda992f6db82b8cd7666937ed9dd8f4a4bfc424a0" checksum = "sha256:7a7458756e9d7bce5f9936b7a63394000f3affa751b9740e65350c4bdcb627dc"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Windows_x86_64.tar.gz" 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]] [[tools.go]]
version = "1.26.1" version = "1.26.1"
@@ -71,32 +78,44 @@ backend = "aqua:golangci/golangci-lint"
[tools.golangci-lint."platforms.linux-arm64"] [tools.golangci-lint."platforms.linux-arm64"]
checksum = "sha256:c3c83c7ed003e881991af21361c487151abd37969927ed9062cd0b48f3cd1d8d" 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 = "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"] [tools.golangci-lint."platforms.linux-arm64-musl"]
checksum = "sha256:c3c83c7ed003e881991af21361c487151abd37969927ed9062cd0b48f3cd1d8d" 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 = "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"] [tools.golangci-lint."platforms.linux-x64"]
checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9" 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 = "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" provenance = "github-attestations"
[tools.golangci-lint."platforms.linux-x64-musl"] [tools.golangci-lint."platforms.linux-x64-musl"]
checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9" 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 = "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"] [tools.golangci-lint."platforms.macos-arm64"]
checksum = "sha256:11e9549b955bae4bfb71aa3b5c2762e446e2d3f975fe963d6d767b8f745d9344" 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 = "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" provenance = "github-attestations"
[tools.golangci-lint."platforms.macos-x64"] [tools.golangci-lint."platforms.macos-x64"]
checksum = "sha256:1dbbbe745eadba9cf012d49bd0b8c9290cbcb50dbf2cf9611f75a715b46ae3af" 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 = "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"] [tools.golangci-lint."platforms.windows-x64"]
checksum = "sha256:60e50178253d276d1c808ef00845eddcabca63b7d69d86a6dbaa41e88bc843f3" checksum = "sha256:60e50178253d276d1c808ef00845eddcabca63b7d69d86a6dbaa41e88bc843f3"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-windows-amd64.zip" 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]] [[tools.goreleaser]]
version = "2.15.2" version = "2.15.2"
@@ -105,37 +124,44 @@ backend = "aqua:goreleaser/goreleaser"
[tools.goreleaser."platforms.linux-arm64"] [tools.goreleaser."platforms.linux-arm64"]
checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9" checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz" 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"] [tools.goreleaser."platforms.linux-arm64-musl"]
checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9" checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz" 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"] [tools.goreleaser."platforms.linux-x64"]
checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc" checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz" 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" provenance = "github-attestations"
[tools.goreleaser."platforms.linux-x64-musl"] [tools.goreleaser."platforms.linux-x64-musl"]
checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc" checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz" 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"] [tools.goreleaser."platforms.macos-arm64"]
checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee" checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz" 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" provenance = "github-attestations"
[tools.goreleaser."platforms.macos-x64"] [tools.goreleaser."platforms.macos-x64"]
checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee" checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz" 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"] [tools.goreleaser."platforms.windows-x64"]
checksum = "sha256:7459832946dbe122c144f8d7f87484d8572ca005b779310aa6bb03346e8de17a" checksum = "sha256:7459832946dbe122c144f8d7f87484d8572ca005b779310aa6bb03346e8de17a"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Windows_x86_64.zip" 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]] [[tools.node]]
version = "22.20.0" version = "22.20.0"
@@ -146,16 +172,17 @@ checksum = "sha256:4181609e03dcb9880e7e5bf956061ecc0503c77a480c6631d868cb1f65a2c
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-arm64.tar.gz" url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-arm64.tar.gz"
[tools.node."platforms.linux-arm64-musl"] [tools.node."platforms.linux-arm64-musl"]
checksum = "sha256:4181609e03dcb9880e7e5bf956061ecc0503c77a480c6631d868cb1f65a2c7dd" checksum = "sha256:79a087c1e23fc97d173ad6676df5b1a4e1a02daf1acca05ee25662747c5d492c"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-arm64.tar.gz" url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0.tar.gz"
install = "source"
[tools.node."platforms.linux-x64"] [tools.node."platforms.linux-x64"]
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29" checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz" url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
[tools.node."platforms.linux-x64-musl"] [tools.node."platforms.linux-x64-musl"]
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29" checksum = "sha256:461aafed5eb607357479c6de5df978515d74daf7e0169e972e61c8390c035f1a"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz" 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"] [tools.node."platforms.macos-arm64"]
checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949" checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
@@ -173,9 +200,34 @@ url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-win-x64.zip"
version = "27.3" version = "27.3"
backend = "aqua:protocolbuffers/protobuf/protoc" backend = "aqua:protocolbuffers/protobuf/protoc"
[tools.protoc."platforms.linux-arm64"]
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"]
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"] [tools.protoc."platforms.linux-x64"]
checksum = "blake3:8c2938accf7061164ea3968529a47ced1db4824ca553857f6903c68ae4347739" checksum = "blake3:8c2938accf7061164ea3968529a47ced1db4824ca553857f6903c68ae4347739"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-x86_64.zip" 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"]
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"]
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"]
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"]
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]] [[tools.protoc-gen-go]]
version = "1.34.2" version = "1.34.2"
@@ -183,30 +235,62 @@ backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
[tools.protoc-gen-go."platforms.linux-arm64"] [tools.protoc-gen-go."platforms.linux-arm64"]
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.arm64.tar.gz" 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"] [tools.protoc-gen-go."platforms.linux-arm64-musl"]
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.arm64.tar.gz" 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"] [tools.protoc-gen-go."platforms.linux-x64"]
checksum = "blake3:f765e393e0f0b25070c13848d88f5e6303e66090132afa1b753e981507a4174e" checksum = "blake3:f765e393e0f0b25070c13848d88f5e6303e66090132afa1b753e981507a4174e"
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz" 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"] [tools.protoc-gen-go."platforms.linux-x64-musl"]
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz" 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"] [tools.protoc-gen-go."platforms.macos-arm64"]
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.arm64.tar.gz" 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"] [tools.protoc-gen-go."platforms.macos-x64"]
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.amd64.tar.gz" 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"] [tools.protoc-gen-go."platforms.windows-x64"]
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.windows.amd64.zip" 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]] [[tools.protoc-gen-go-grpc]]
version = "1.5.1" version = "1.5.1"
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc" backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
[tools.protoc-gen-go-grpc."platforms.linux-arm64"]
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"]
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"] [tools.protoc-gen-go-grpc."platforms.linux-x64"]
checksum = "blake3:197739132f0408f017d0c83773ac1ace85c0572473fdd658ec6e4ccbf20e0552" checksum = "blake3:197739132f0408f017d0c83773ac1ace85c0572473fdd658ec6e4ccbf20e0552"
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 = "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"]
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"]
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"]
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"]
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"
+5 -1
View File
@@ -2,7 +2,7 @@
experimental = true experimental = true
[tools] [tools]
"aqua:vektra/mockery" = "3.5.3" "aqua:vektra/mockery" = "3.8.1"
go = "1.26.1" go = "1.26.1"
golangci-lint = "2.11.2" golangci-lint = "2.11.2"
goreleaser = "2.15.2" goreleaser = "2.15.2"
@@ -40,6 +40,10 @@ outputs = ["uncloudd-linux-arm64"]
description = "Update mise.lock with hashes for all platforms" description = "Update mise.lock with hashes for all platforms"
run = "mise lock --platform linux-x64,macos-arm64" run = "mise lock --platform linux-x64,macos-arm64"
[tasks.mocks]
description = "Regenerate Go test mocks"
run = "mockery"
[tasks.proto] [tasks.proto]
description = "Regenerate gRPC API code from .proto files" description = "Regenerate gRPC API code from .proto files"
run = """ run = """
+6
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"crypto/sha256"
"crypto/x509" "crypto/x509"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
@@ -23,6 +24,11 @@ type IssuedCertificate struct {
IssuerData CertificateIssuerData IssuerData CertificateIssuerData
} }
// Fingerprint returns the SHA-256 fingerprint of the leaf certificate.
func (c IssuedCertificate) Fingerprint() [sha256.Size]byte {
return sha256.Sum256(c.Chain[0].Raw)
}
// CertificateIssuerData preserves issuer-specific metadata and provides a best-effort typed view of ACME records. // CertificateIssuerData preserves issuer-specific metadata and provides a best-effort typed view of ACME records.
type CertificateIssuerData struct { type CertificateIssuerData struct {
// Raw is the original issuer_data JSON, including unrecognized formats and fields. // Raw is the original issuer_data JSON, including unrecognized formats and fields.
+35 -11
View File
@@ -4,6 +4,7 @@ import (
"crypto/ed25519" "crypto/ed25519"
"crypto/rand" "crypto/rand"
"crypto/x509" "crypto/x509"
"encoding/hex"
"encoding/pem" "encoding/pem"
"math/big" "math/big"
"testing" "testing"
@@ -45,6 +46,17 @@ func TestIssuedCertificateFromProto(t *testing.T) {
} }
} }
func TestIssuedCertificateFingerprint(t *testing.T) {
cert := IssuedCertificate{Chain: []*x509.Certificate{
{Raw: []byte("abc")},
{Raw: []byte("issuer")},
}}
want, err := hex.DecodeString("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
require.NoError(t, err)
fingerprint := cert.Fingerprint()
assert.Equal(t, want, fingerprint[:])
}
func TestIssuedCertificateFromProto_InvalidCertificate(t *testing.T) { func TestIssuedCertificateFromProto_InvalidCertificate(t *testing.T) {
valid := testCertificatePEM(t, 1) valid := testCertificatePEM(t, 1)
invalidDER := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("invalid")}) invalidDER := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("invalid")})
@@ -57,15 +69,25 @@ func TestIssuedCertificateFromProto_InvalidCertificate(t *testing.T) {
{name: "missing SAN", input: &pb.IssuedCertificate{Chain: valid}, wantErr: "missing SAN"}, {name: "missing SAN", input: &pb.IssuedCertificate{Chain: valid}, wantErr: "missing SAN"},
{name: "blank SAN", input: &pb.IssuedCertificate{San: " \t", Chain: valid}, wantErr: "missing SAN"}, {name: "blank SAN", input: &pb.IssuedCertificate{San: " \t", Chain: valid}, wantErr: "missing SAN"},
{name: "empty chain", input: &pb.IssuedCertificate{San: "app.example.com"}, wantErr: "empty certificate chain"}, {name: "empty chain", input: &pb.IssuedCertificate{San: "app.example.com"}, wantErr: "empty certificate chain"},
{name: "invalid PEM", input: &pb.IssuedCertificate{San: "app.example.com", Chain: []byte("not PEM")}, {
wantErr: "empty certificate chain"}, name: "invalid PEM", input: &pb.IssuedCertificate{San: "app.example.com", Chain: []byte("not PEM")},
{name: "wrong PEM block type", input: &pb.IssuedCertificate{San: "app.example.com", wantErr: "empty certificate chain",
Chain: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("key")})}, },
wantErr: "unexpected PEM block type"}, {
{name: "invalid DER", input: &pb.IssuedCertificate{San: "app.example.com", Chain: invalidDER}, name: "wrong PEM block type", input: &pb.IssuedCertificate{
wantErr: "parse X.509 certificate"}, San: "app.example.com",
{name: "invalid certificate after valid leaf", input: &pb.IssuedCertificate{San: "app.example.com", Chain: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("key")}),
Chain: append(append([]byte(nil), valid...), invalidDER...)}, wantErr: "parse X.509 certificate"}, },
wantErr: "unexpected PEM block type",
},
{
name: "invalid DER", input: &pb.IssuedCertificate{San: "app.example.com", Chain: invalidDER},
wantErr: "parse X.509 certificate",
},
{name: "invalid certificate after valid leaf", input: &pb.IssuedCertificate{
San: "app.example.com",
Chain: append(append([]byte(nil), valid...), invalidDER...),
}, wantErr: "parse X.509 certificate"},
} { } {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
cert, err := IssuedCertificateFromProto(tt.input) cert, err := IssuedCertificateFromProto(tt.input)
@@ -126,8 +148,10 @@ func TestIssuedCertificateFromProto_IssuerData(t *testing.T) {
{name: "missing certificate URL", raw: `{"ca":"https://ca.example/directory"}`}, {name: "missing certificate URL", raw: `{"ca":"https://ca.example/directory"}`},
{name: "invalid JSON", raw: "{"}, {name: "invalid JSON", raw: "{"},
{name: "invalid field types", raw: `{"url":123,"ca":false}`}, {name: "invalid field types", raw: `{"url":123,"ca":false}`},
{name: "malformed renewal information", {
raw: `{"url":"https://ca.example/cert/1","ca":"https://ca.example/directory","renewal_info":{"_selectedTime":"invalid"}}`}, name: "malformed renewal information",
raw: `{"url":"https://ca.example/cert/1","ca":"https://ca.example/directory","renewal_info":{"_selectedTime":"invalid"}}`,
},
} { } {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
cert, err := IssuedCertificateFromProto(&pb.IssuedCertificate{ cert, err := IssuedCertificateFromProto(&pb.IssuedCertificate{
+2
View File
@@ -42,6 +42,8 @@ type DNSClient interface {
type ImageClient interface { type ImageClient interface {
InspectImage(ctx context.Context, id string) ([]MachineImage, error) InspectImage(ctx context.Context, id string) ([]MachineImage, error)
InspectRemoteImage(ctx context.Context, id string) ([]MachineRemoteImage, 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 { type LogsClient interface {
+12 -10
View File
@@ -1,8 +1,6 @@
package api package api
import ( import (
"errors"
"github.com/distribution/reference" "github.com/distribution/reference"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
v1 "github.com/opencontainers/image-spec/specs-go/v1" v1 "github.com/opencontainers/image-spec/specs-go/v1"
@@ -24,14 +22,6 @@ type MachineImages struct {
ContainerdStore bool 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. // ImageFilter defines criteria to filter images in ListImages.
type ImageFilter struct { type ImageFilter struct {
// Machines filters images to those present on the specified machines (names or IDs). // Machines filters images to those present on the specified machines (names or IDs).
@@ -42,6 +32,18 @@ type ImageFilter struct {
Name string 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. // MachineRemoteImage represents an image in a remote registry fetched by a particular machine.
type MachineRemoteImage struct { type MachineRemoteImage struct {
Metadata *pb.Metadata Metadata *pb.Metadata
+21
View File
@@ -1,12 +1,33 @@
package api package api
import ( import (
"fmt"
"net/netip" "net/netip"
"strings" "strings"
"github.com/psviderski/uncloud/api/pb" "github.com/psviderski/uncloud/api/pb"
) )
// ValidateMachineName checks that a machine name is a lowercase DNS label and doesn't conflict with
// internal DNS query modes or machine IDs.
func ValidateMachineName(name string) error {
if !DNSLabelRegex.MatchString(name) {
return fmt.Errorf("invalid machine name %q: must be 1-63 characters, lowercase letters, numbers, "+
"and hyphens only, starting and ending with a letter or number", name)
}
switch name {
case "rr", "nearest":
return fmt.Errorf("invalid machine name %q: reserved for internal DNS query modes", name)
}
if IDRegex.MatchString(name) {
return fmt.Errorf(
"invalid machine name %q: must not match the machine ID format (32 hexadecimal characters)", name)
}
return nil
}
// MachineFilter defines criteria to filter machines in ListMachines. // MachineFilter defines criteria to filter machines in ListMachines.
type MachineFilter struct { type MachineFilter struct {
// Available filters machines that are not DOWN. // Available filters machines that are not DOWN.
+47
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"encoding/json" "encoding/json"
"net/netip" "net/netip"
"strings"
"testing" "testing"
"github.com/psviderski/uncloud/api/pb" "github.com/psviderski/uncloud/api/pb"
@@ -10,6 +11,52 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestValidateMachineName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantErr string
}{
{name: "single letter", input: "a"},
{name: "single digit", input: "1"},
{name: "two characters", input: "a1"},
{name: "generated name", input: "machine-ab12"},
{name: "hyphens and digits", input: "1-worker-2"},
{name: "maximum length", input: strings.Repeat("a", 63)},
{name: "maximum length with hyphens", input: "a" + strings.Repeat("-", 61) + "1"},
{name: "machine namespace label", input: "m"},
{name: "mode prefix", input: "nearest-worker"},
{name: "short hexadecimal name", input: strings.Repeat("a", 31)},
{name: "long hexadecimal name", input: strings.Repeat("a", 33)},
{name: "non-hexadecimal 32 characters", input: strings.Repeat("g", 32)},
{name: "empty", wantErr: "must be 1-63 characters"},
{name: "too long", input: strings.Repeat("a", 64), wantErr: "must be 1-63 characters"},
{name: "uppercase", input: "VPS1", wantErr: "lowercase letters"},
{name: "leading hyphen", input: "-worker", wantErr: "starting and ending"},
{name: "trailing hyphen", input: "worker-", wantErr: "starting and ending"},
{name: "underscore", input: "worker_1", wantErr: "hyphens only"},
{name: "dot", input: "worker.example", wantErr: "hyphens only"},
{name: "space", input: "worker 1", wantErr: "hyphens only"},
{name: "leading whitespace", input: " worker", wantErr: "hyphens only"},
{name: "trailing whitespace", input: "worker\t", wantErr: "hyphens only"},
{name: "non-ASCII", input: "wörker", wantErr: "lowercase letters"},
{name: "round-robin mode", input: "rr", wantErr: "reserved for internal DNS query modes"},
{name: "nearest mode", input: "nearest", wantErr: "reserved for internal DNS query modes"},
{name: "machine ID", input: "c337f00600de51ef4375c9a9a267dba5", wantErr: "machine ID format"},
}
for _, tt := range tests {
err := ValidateMachineName(tt.input)
if tt.wantErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tt.wantErr)
}
}
}
func TestMachineMembersList_Info(t *testing.T) { func TestMachineMembersList_Info(t *testing.T) {
t.Parallel() t.Parallel()
+23 -9
View File
@@ -41,12 +41,30 @@ const (
) )
var ( var (
serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$") IDRegex = regexp.MustCompile("^[0-9a-f]{32}$")
dnsLabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) DNSLabelRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$`)
) )
func ValidateServiceID(id string) bool { func ValidateServiceID(id string) bool {
return serviceIDRegexp.MatchString(id) return IDRegex.MatchString(id)
}
// ValidateServiceName checks that a service name is a lowercase DNS label and doesn't conflict with
// the machine DNS namespace or service IDs.
func ValidateServiceName(name string) error {
if !DNSLabelRegex.MatchString(name) {
return fmt.Errorf("invalid service name %q: must be 1-63 characters, lowercase letters, numbers, "+
"and hyphens only, starting and ending with a letter or number", name)
}
if name == "m" {
return fmt.Errorf("invalid service name %q: reserved for the machine DNS namespace", name)
}
if IDRegex.MatchString(name) {
return fmt.Errorf(
"invalid service name %q: must not match the service ID format (32 hexadecimal characters)", name)
}
return nil
} }
// ServiceSpec defines the desired state of a service. // ServiceSpec defines the desired state of a service.
@@ -147,12 +165,8 @@ func (s *ServiceSpec) Validate() error {
} }
if s.Name != "" { if s.Name != "" {
if len(s.Name) > 63 { if err := ValidateServiceName(s.Name); err != nil {
return fmt.Errorf("service name too long (max 63 characters): %q", s.Name) return err
}
if !dnsLabelRegexp.MatchString(s.Name) {
return fmt.Errorf("invalid service name: %q. must be 1-63 characters, lowercase letters, numbers, "+
"and dashes only; must start and end with a letter or number", s.Name)
} }
} }
+54
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"os" "os"
"strings"
"testing" "testing"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@@ -9,6 +10,59 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestServiceSpec_Validate_Name(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantErr string
}{
{name: "empty allows generated name"},
{name: "single character", input: "a"},
{name: "single digit", input: "1"},
{name: "two characters", input: "a1"},
{name: "hyphens and digits", input: "1-web-2"},
{name: "consecutive hyphens", input: "web--1"},
{name: "maximum length", input: strings.Repeat("a", 63)},
{name: "maximum length with hyphens", input: "a" + strings.Repeat("-", 61) + "1"},
{name: "round-robin mode is a valid service name", input: "rr"},
{name: "nearest mode is a valid service name", input: "nearest"},
{name: "machine namespace prefix", input: "m-service"},
{name: "short hexadecimal name", input: strings.Repeat("a", 31)},
{name: "long hexadecimal name", input: strings.Repeat("a", 33)},
{name: "non-hexadecimal 32 characters", input: strings.Repeat("g", 32)},
{name: "too long", input: strings.Repeat("a", 64), wantErr: "must be 1-63 characters"},
{name: "uppercase", input: "WEB1", wantErr: "lowercase letters"},
{name: "leading hyphen", input: "-web", wantErr: "starting and ending"},
{name: "trailing hyphen", input: "web-", wantErr: "starting and ending"},
{name: "hyphen only", input: "-", wantErr: "starting and ending"},
{name: "underscore", input: "web_1", wantErr: "hyphens only"},
{name: "dot", input: "web.example", wantErr: "hyphens only"},
{name: "slash", input: "web/api", wantErr: "hyphens only"},
{name: "space", input: "web 1", wantErr: "hyphens only"},
{name: "leading whitespace", input: " web", wantErr: "hyphens only"},
{name: "trailing whitespace", input: "web ", wantErr: "hyphens only"},
{name: "tab", input: "web\t1", wantErr: "hyphens only"},
{name: "newline", input: "web\n", wantErr: "hyphens only"},
{name: "non-ASCII", input: "wéb", wantErr: "lowercase letters"},
{name: "machine DNS namespace", input: "m", wantErr: "reserved for the machine DNS namespace"},
{name: "service ID", input: "c337f00600de51ef4375c9a9a267dba5", wantErr: "service ID format"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
spec := ServiceSpec{Name: tt.input, Container: ContainerSpec{Image: "nginx:latest"}}
err := spec.Validate()
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
})
}
}
func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) { func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+19 -2
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"regexp" "regexp"
"time"
"github.com/Masterminds/semver" "github.com/Masterminds/semver"
"github.com/distribution/reference" "github.com/distribution/reference"
@@ -66,6 +67,7 @@ func (c *CaddyClient) Config(ctx context.Context, opts CaddyConfigOptions) (api.
// CaddyDeploymentOptions configures a Caddy reverse proxy deployment. // CaddyDeploymentOptions configures a Caddy reverse proxy deployment.
type CaddyDeploymentOptions struct { type CaddyDeploymentOptions struct {
// Image defaults to the latest stable 2.x.x official Caddy image. // Image defaults to the latest stable 2.x.x official Caddy image.
// Custom images must include curl and start Caddy with /etc/caddy/Caddyfile.
Image string Image string
// Config contains an optional global Caddyfile. // Config contains an optional global Caddyfile.
Config string Config string
@@ -91,17 +93,32 @@ func (c *CaddyClient) NewDeployment(ctx context.Context, opts CaddyDeploymentOpt
spec := api.ServiceSpec{ spec := api.ServiceSpec{
Container: api.ContainerSpec{ Container: api.ContainerSpec{
Command: []string{"caddy", "run", "-c", "/config/Caddyfile"},
Env: map[string]string{ Env: map[string]string{
"CADDY_ADMIN": "unix//run/caddy/admin.sock", "CADDY_ADMIN": "unix//run/caddy/admin.sock",
}, },
Healthcheck: &api.HealthcheckSpec{
Test: []string{
"CMD",
"curl", "-fsS",
"-o", "/dev/null",
"--unix-socket", "/run/caddy/admin.sock",
"http://localhost/config/",
},
Interval: 30 * time.Second,
Timeout: 5 * time.Second,
Retries: 3,
StartPeriod: 10 * time.Second,
StartInterval: 1 * time.Second,
},
Image: image, Image: image,
VolumeMounts: []api.VolumeMount{ VolumeMounts: []api.VolumeMount{
{ {
VolumeName: "data", VolumeName: "data",
ContainerPath: "/config", ContainerPath: "/etc/caddy",
ReadOnly: true,
}, },
{ {
// Keep local TLS assets persistent while Caddy uses the default file system storage.
VolumeName: "data", VolumeName: "data",
ContainerPath: "/data", ContainerPath: "/data",
}, },
+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. // ProxyMachinesContext returns a new context that proxies gRPC requests to the specified machines.
// If namesOrIDs is nil or empty, all machines are included. // If namesOrIDs is nil or empty, all machines are included.
// This triggers One2Many proxying, which always injects metadata into the response. // 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 { func ProxyMachinesContext(ctx context.Context, namesOrIDs []string) context.Context {
md := outgoingMetadataWithoutProxyTargets(ctx) md := outgoingMetadataWithoutProxyTargets(ctx)
if len(namesOrIDs) == 0 { if len(namesOrIDs) == 0 {
+1 -1
View File
@@ -13,8 +13,8 @@ import (
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy" "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/deploy/scheduler"
"github.com/psviderski/uncloud/pkg/client/operation"
) )
type Client interface { type Client interface {
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy" "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. // Plan holds the compose-level deployment plan with volume and service operations.
+40 -9
View File
@@ -89,9 +89,28 @@ func ResolveSecrets(ctx context.Context, project *types.Project) error {
return v, nil return v, nil
} }
// Resolve only secret references set as values for environment variables in enabled services.
for _, service := range project.Services { for _, service := range project.Services {
for k, v := range service.Environment { // 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
}
}
}
return nil
}
// 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 { if v == nil {
continue continue
} }
@@ -103,18 +122,31 @@ func ResolveSecrets(ctx context.Context, project *types.Project) error {
if err != nil { if err != nil {
return err return err
} }
service.Environment[k] = &value env[k] = &value
} }
}
return nil return nil
} }
// HasCommandSecretRefs reports whether any service environment references a secret that is resolved by running // HasCommandSecretRefs reports whether any service environment, including a pre-deploy hook's environment,
// a command. // references a secret that is resolved by running a command.
func HasCommandSecretRefs(project *types.Project) bool { func HasCommandSecretRefs(project *types.Project) bool {
for _, service := range project.Services { for _, service := range project.Services {
for _, v := range service.Environment { if envHasCommandSecretRef(service.Environment, project) {
return true
}
if hook, ok := service.Extensions[PreDeployHookExtensionKey].(PreDeployHook); ok {
if envHasCommandSecretRef(hook.Environment, project) {
return true
}
}
}
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 { if v == nil {
continue continue
} }
@@ -126,7 +158,6 @@ func HasCommandSecretRefs(project *types.Project) bool {
return true return true
} }
} }
}
return false return false
} }
+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") 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) { func TestHasCommandSecretRefs(t *testing.T) {
t.Parallel() t.Parallel()
@@ -387,6 +455,22 @@ services:
`, `,
want: false, 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 { for _, tt := range tests {
+1 -1
View File
@@ -12,8 +12,8 @@ import (
"github.com/distribution/reference" "github.com/distribution/reference"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler" "github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
"github.com/psviderski/uncloud/pkg/client/operation"
) )
type Client interface { type Client interface {
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"github.com/psviderski/uncloud/api/pb" "github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api" "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/deploy/scheduler"
"github.com/psviderski/uncloud/pkg/client/operation"
) )
// Strategy defines how a service should be deployed or updated. Different implementations can provide various // 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/google/go-cmp/cmp/cmpopts"
"github.com/psviderski/uncloud/api/pb" "github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/pkg/api" "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/deploy/scheduler"
"github.com/psviderski/uncloud/pkg/client/operation"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
+82 -3
View File
@@ -23,9 +23,11 @@ import (
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
dockerclient "github.com/docker/docker/client" dockerclient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/jsonmessage" "github.com/docker/docker/pkg/jsonmessage"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
ocispec "github.com/opencontainers/image-spec/specs-go/v1" ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/psviderski/uncloud/api/pb" "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/cli/tui"
"github.com/psviderski/uncloud/internal/docker" "github.com/psviderski/uncloud/internal/docker"
"github.com/psviderski/uncloud/internal/machine/constants" "github.com/psviderski/uncloud/internal/machine/constants"
@@ -34,6 +36,8 @@ import (
"github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
netproxy "golang.org/x/net/proxy" 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. // 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. // Broadcast the image list request to the specified machines or all machines if none specified.
listCtx := ProxyMachinesContext(ctx, filter.Machines) listCtx := ProxyMachinesContext(ctx, filter.Machines)
opts := image.ListOptions{Manifests: true} opts := image.ListOptions{All: true, Manifests: true}
if filter.Name != "" { if filter.Name != "" {
opts.Filters = filters.NewArgs( opts.Filters = filters.NewArgs(
filters.Arg("reference", filter.Name), filters.Arg("reference", filter.Name),
@@ -92,7 +96,7 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap
ContainerdStore: msg.ContainerdStore, 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 { if err = json.Unmarshal(msg.Images, &mi.Images); err != nil {
return nil, fmt.Errorf("unmarshal images: %w", err) 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 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 { type PushImageOptions struct {
// AllMachines pushes the image to all machines in the cluster. Takes precedence over Machines field. // AllMachines pushes the image to all machines in the cluster. Takes precedence over Machines field.
AllMachines bool AllMachines bool
@@ -215,7 +294,7 @@ func (cli *Client) pushImageToMachine(
Name: "%invalid-name-to-only-check-store-type%", Name: "%invalid-name-to-only-check-store-type%",
}) })
if err == nil { if err == nil {
err = images[0].Error() err = images[0].Metadata.Err()
} }
if err != nil { if err != nil {
return fmt.Errorf("check Docker image store type on machine '%s': %w", machine.Name, err) return fmt.Errorf("check Docker image store type on machine '%s': %w", machine.Name, err)
+1 -1
View File
@@ -143,7 +143,7 @@ func (cli *Client) WaitClusterReady(ctx context.Context, timeout time.Duration)
} }
// WaitForStoreVersion waits until the cluster store on the target machine has reached each requested actor version // WaitForStoreVersion waits until the cluster store on the target machine has reached each requested actor version
// in minVersion, with no known missing or pending transactions through those versions. // in minVersion, with no known missing or pending transactions through those versions from active members.
// The context controls cancellation and the deadline. An empty minVersion requires no replication. // The context controls cancellation and the deadline. An empty minVersion requires no replication.
// This method observes replication without initiating synchronisation. // This method observes replication without initiating synchronisation.
// //
@@ -173,6 +173,16 @@ type ReplaceContainerOperation struct {
func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) error { func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) error {
stopFirst := o.Order == api.UpdateOrderStopFirst 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 wasRunning := false
if stopFirst { if stopFirst {
// Inspect the old container to remember its running state before stopping. // 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 { if err = cli.StartContainer(newCtx, o.ServiceID, resp.ID); err != nil {
return fmt.Errorf("start new container: %w", err) 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" "github.com/psviderski/uncloud/pkg/api"
) )
// Operation represents a single atomic operation in a deployment process. // Operation represents a single operation in the cluster that can be executed.
// Operations can be composed to form complex deployment strategies. // Operations can be composed to form complex workflows (plans).
type Operation interface { type Operation interface {
// Execute performs the operation using the provided client. // Execute performs the operation using the provided client.
Execute(ctx context.Context, cli Client) error Execute(ctx context.Context, cli Client) error
@@ -16,7 +16,7 @@ type Operation interface {
String() string String() string
} }
// Client defines the interface required to execute deployment operations. // Client defines the interface required to execute cluster operations.
type Client interface { type Client interface {
api.ContainerClient api.ContainerClient
api.VolumeClient api.VolumeClient
+31 -47
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"maps"
"slices" "slices"
"sync" "sync"
@@ -124,10 +125,10 @@ func (cli *Client) InspectService(ctx context.Context, nameOrID string) (api.Ser
continue continue
} }
if mc.Metadata.Error != "" { if err := mc.Metadata.Err(); err != nil {
// TODO: return failed machines in the response. // TODO: return failed machines in the response.
tui.PrintWarning(fmt.Sprintf("failed to list containers on machine '%s': %s", tui.PrintWarning(fmt.Sprintf("failed to list containers on machine '%s': %s",
mc.Metadata.MachineName, mc.Metadata.Error)) mc.Metadata.MachineName, err))
continue continue
} }
@@ -320,67 +321,50 @@ func (cli *Client) StartService(ctx context.Context, id string) error {
// ListServices returns a list of all services and their containers. // ListServices returns a list of all services and their containers.
func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) { func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
machines, err := cli.ListMachines(ctx, nil) // Broadcast the container list request to all machines in the cluster.
if err != nil { listCtx := ProxyMachinesContext(ctx, nil)
return nil, fmt.Errorf("list machines: %w", err) // List all containers including stopped ones and deployment hooks.
}
// Broadcast the container list request to all available machines.
md := metadata.New(nil)
for _, m := range machines {
if m.State == pb.MachineMember_UP || m.State == pb.MachineMember_SUSPECT {
md.Append("machines", m.Machine.Id)
} else {
tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine '%s' (state is %s). "+
"The results may be incomplete.", m.Machine.Name, m.State.String()))
}
}
listCtx := metadata.NewOutgoingContext(ctx, md)
// List all containers including stopped ones.
opts := container.ListOptions{All: true} opts := container.ListOptions{All: true}
machineContainers, err := cli.Docker.ListServiceContainers(listCtx, "", opts) machineContainers, err := cli.Docker.ListServiceContainers(listCtx, "", opts)
if err != nil { if err != nil {
return nil, fmt.Errorf("list containers: %w", err) return nil, fmt.Errorf("list containers: %w", err)
} }
// TODO: optimise by extracting services from the list of all containers instead of inspecting each service. // Group containers by service ID using the broadcast results.
// Most of the code can be reused in both InspectService and ListServices.
servicesByID := make(map[string]api.Service) servicesByID := make(map[string]api.Service)
for _, mc := range machineContainers { for _, mc := range machineContainers {
// NOTE: Metadata should never be nil in practice. This is legacy fallback that will be removed. if err := mc.Metadata.Err(); err != nil {
if mc.Metadata == nil {
tui.PrintWarning("metadata is missing in response from unknown server")
continue
}
if mc.Metadata.Error != "" {
// TODO: return failed machines in the response. // TODO: return failed machines in the response.
tui.PrintWarning(fmt.Sprintf("failed to list containers on machine '%s': %s", tui.PrintWarning(fmt.Sprintf("failed to list containers on machine '%s': %s",
mc.Metadata.MachineName, mc.Metadata.Error)) mc.Metadata.MachineName, err))
continue continue
} }
for _, ctr := range append(mc.Containers, mc.HookContainers...) { for _, ctr := range append(mc.Containers, mc.HookContainers...) {
if _, ok := servicesByID[ctr.ServiceID()]; ok { serviceID := ctr.ServiceID()
continue svc, ok := servicesByID[serviceID]
} if !ok {
svc = api.Service{
svc, err := cli.InspectService(ctx, ctr.ServiceID()) ID: serviceID,
if err != nil { Name: ctr.ServiceName(),
if errors.Is(err, api.ErrNotFound) { Mode: ctr.ServiceMode(),
continue
}
return nil, fmt.Errorf("inspect service: %w", err)
}
servicesByID[ctr.ServiceID()] = svc
} }
} }
services := make([]api.Service, 0, len(servicesByID)) machineContainer := api.MachineServiceContainer{
for _, svc := range servicesByID { MachineID: mc.Metadata.MachineId,
services = append(services, svc) MachineName: mc.Metadata.MachineName,
Container: ctr,
} }
return services, nil if ctr.IsHook() {
svc.HookContainers = append(svc.HookContainers, machineContainer)
} else {
svc.Containers = append(svc.Containers, machineContainer)
}
servicesByID[serviceID] = svc
}
}
return slices.Collect(maps.Values(servicesByID)), nil
} }
+2 -2
View File
@@ -70,10 +70,10 @@ func (cli *Client) ListVolumes(ctx context.Context, filter *api.VolumeFilter) ([
continue continue
} }
if mv.Metadata.Error != "" { if err := mv.Metadata.Err(); err != nil {
// TODO: return failed machines in the response. // TODO: return failed machines in the response.
tui.PrintWarning(fmt.Sprintf("failed to list volumes on machine '%s': %s", mv.Metadata.MachineName, tui.PrintWarning(fmt.Sprintf("failed to list volumes on machine '%s': %s", mv.Metadata.MachineName,
mv.Metadata.Error)) err))
continue continue
} }
+47 -26
View File
@@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/docker/docker/api/types/container" "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/mount"
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
"github.com/docker/go-units" "github.com/docker/go-units"
@@ -1265,10 +1266,10 @@ myapp.example.com {
"Containers should be on machines where the volume exists") "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() t.Parallel()
serviceName := "test-list-images-replicated" serviceName := "test-image-lifecycle-replicated"
uniqueImage := "portainer/pause:3.9" // Unique image not used by other tests. uniqueImage := "portainer/pause:3.9" // Unique image not used by other tests.
t.Cleanup(func() { t.Cleanup(func() {
err := cli.RemoveService(ctx, serviceName) err := cli.RemoveService(ctx, serviceName)
@@ -1301,39 +1302,59 @@ myapp.example.com {
machinesWithContainers := serviceMachines(svc) machinesWithContainers := serviceMachines(svc)
assert.Len(t, machinesWithContainers.ToSlice(), 2, "Containers should be on 2 different machines") assert.Len(t, machinesWithContainers.ToSlice(), 2, "Containers should be on 2 different machines")
var machineWithoutContainer string machineImages, err := cli.ListImages(ctx, api.ImageFilter{Name: uniqueImage})
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{})
require.NoError(t, err) 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 { 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) { if !machinesWithContainers.Contains(mi.Metadata.MachineId) {
// This is the machine without service containers, it should not have the unique image. assert.Empty(t, mi.Images)
for _, img := range mi.Images {
assert.NotContains(t, img.RepoTags, uniqueImage)
}
continue continue
} }
// Check if the unique image is present on the machine where a service container is running. require.Len(t, mi.Images, 1)
hasImage := false assert.Contains(t, mi.Images[0].RepoTags, uniqueImage)
for _, img := range mi.Images {
if slices.Contains(img.RepoTags, uniqueImage) {
hasImage = true
break
} }
// 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()))
} }
assert.True(t, hasImage, "Machine %s with container should have image %s",
mi.Metadata.MachineId, uniqueImage) 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
}
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)
} }
}) })
+1 -1
View File
@@ -20,7 +20,7 @@ traditional container orchestrators like Kubernetes or Swarm:
* Scaling services across multiple machines * Scaling services across multiple machines
* Cross-machine service communication without exposing ports to the internet * Cross-machine service communication without exposing ports to the internet
* DNS-based service discovery * DNS-based service discovery
* Automatic HTTPS and reverse proxy configuration * Automatic HTTPS and reverse proxy configuration with clustered storage for certificates
* Load balancing * Load balancing
* Persistent storage * Persistent storage
@@ -24,3 +24,11 @@ When you [publish a service port](2-publishing-services.md), Uncloud automatical
For advanced use cases, Uncloud allows to customise the Caddy config using the `x-caddy` extension in Compose files. For advanced use cases, Uncloud allows to customise the Caddy config using the `x-caddy` extension in Compose files.
See [Custom Caddy configuration](2-publishing-services.md#custom-caddy-configuration) for details. See [Custom Caddy configuration](2-publishing-services.md#custom-caddy-configuration) for details.
:::tip Shared TLS certificates
Uncloud also provides native cluster storage for Caddy. It shares TLS certificates and ACME challenge tokens across
machines and coordinates certificate issuance. See [Cluster storage for Caddy](4-cluster-storage-for-caddy.md)
to enable it.
:::
@@ -3,6 +3,10 @@
Caddy is automatically deployed as a global service `caddy` when you initialise a cluster with `uc machine init`. By Caddy is automatically deployed as a global service `caddy` when you initialise a cluster with `uc machine init`. By
default, it runs on every machine to handle incoming HTTP/HTTPS traffic and route it to your services. default, it runs on every machine to handle incoming HTTP/HTTPS traffic and route it to your services.
Since [v0.21.0](https://github.com/psviderski/uncloud/releases/tag/v0.21.0), Uncloud provides native
[cluster storage for Caddy](4-cluster-storage-for-caddy.md) (opt-in) to share TLS certificates and coordinate
certificate issuance across machines.
## Checking status ## Checking status
View the `caddy` service status and which machines it's running on: View the `caddy` service status and which machines it's running on:
@@ -34,7 +38,7 @@ uc caddy deploy
Deploy a specific version or custom image: Deploy a specific version or custom image:
```shell ```shell
uc caddy deploy --image caddybuilds/caddy-cloudflare:2.10.2 uc caddy deploy --image caddybuilds/caddy-cloudflare:2.11.4
``` ```
Deploy only to a specific machine or a subset of machines (comma-separated list): Deploy only to a specific machine or a subset of machines (comma-separated list):
@@ -80,19 +84,27 @@ config that uses the DNS challenge with Cloudflare to obtain a wildcard TLS cert
```yaml ```yaml
services: services:
caddy: caddy:
image: caddybuilds/caddy-cloudflare:2.10.2 image: caddybuilds/caddy-cloudflare:2.11.4
command: caddy run -c /config/Caddyfile
environment: environment:
# unix// is not a typo. Caddy uses network/address format, not a unix:// URL. # unix// is not a typo. Caddy uses network/address format, not a unix:// URL.
CADDY_ADMIN: unix//run/caddy/admin.sock CADDY_ADMIN: unix//run/caddy/admin.sock
env_file: env_file:
# Contains CLOUDFLARE_API_TOKEN=xxxxx # Contains CLOUDFLARE_API_TOKEN=xxxxx
- .env.secrets - .env.secrets
healthcheck:
test: curl -fsS -o /dev/null --unix-socket /run/caddy/admin.sock http://localhost/config/
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
start_interval: 1s
volumes: volumes:
- /var/lib/uncloud/caddy:/etc/caddy:ro
# Persists TLS certificates and other assets when using Caddy's default local storage.
- /var/lib/uncloud/caddy:/data - /var/lib/uncloud/caddy:/data
- /var/lib/uncloud/caddy:/config
- /run/uncloud/caddy:/run/caddy - /run/uncloud/caddy:/run/caddy
# Required by caddy.storage.uncloud module. Remove this mount if Uncloud cluster storage is not used for Caddy. # Required by the Uncloud cluster storage module (https://github.com/unlabs-dev/caddy-uncloud).
# Remove this mount if cluster storage is not used for Caddy.
# Mount the directory, not the socket file, so Caddy sees a replacement socket after the daemon restarts. # Mount the directory, not the socket file, so Caddy sees a replacement socket after the daemon restarts.
- /run/uncloud/api:/run/uncloud/api:ro - /run/uncloud/api:/run/uncloud/api:ro
x-ports: x-ports:
@@ -142,9 +154,15 @@ internal.example.com {
:::info note :::info note
The specified `command`, `environment`, `volumes`, and `x-ports` properties are essential for Caddy to function The specified `environment`, `volumes`, and `x-ports` properties are essential for Caddy to function correctly in the
correctly in the Uncloud cluster. Do not change the source paths of the volume mounts as the Uncloud daemon relies on Uncloud cluster. Do not change the source paths of the volume mounts as the Uncloud daemon relies on them to communicate
them to communicate with Caddy and update its configuration. with Caddy and update its configuration.
The image must include `curl` for the healthcheck and start Caddy with `/etc/caddy/Caddyfile`. Images based on the
official [Caddy image](https://hub.docker.com/_/caddy) do both by default.
Keep the `/data` mount while using Caddy's default local storage so TLS certificates survive container updates. You can
remove this mount when using [cluster storage for Caddy](4-cluster-storage-for-caddy.md).
::: :::
@@ -0,0 +1,73 @@
# Cluster storage for Caddy
The [Uncloud storage module](https://github.com/unlabs-dev/caddy-uncloud) lets Caddy instances share TLS certificates,
private keys, and ACME challenge tokens through Uncloud's cluster store. It uses distributed locks to coordinate
certificate issuance. Once one instance obtains a certificate, the others can use it too.
## Why use cluster storage?
By default, each Caddy instance stores its certificates locally. When DNS points to multiple machines or a load balancer
distributes traffic between them, an ACME challenge can reach a different instance from the one requesting the
certificate. That instance may not have the challenge token, which can delay or prevent certificate issuance.
Cluster storage is optional. The default Caddy image does not include the module, so you need to deploy an image that
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.
Check your CLI version with `uc version` and daemon versions with `uc machine ls`. Upgrade older versions before
enabling cluster storage.
:::
Create a global Caddyfile, or add `storage uncloud` to the global options in your existing one:
```caddyfile title="global.Caddyfile"
{
storage uncloud
# Uncomment to enable debug logs useful for troubleshooting storage operations:
# debug
}
```
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.3 --caddyfile global.Caddyfile
```
See the [module README](https://github.com/unlabs-dev/caddy-uncloud#usage) for custom image builds, Compose deployment,
and additional storage options.
## Verifying storage
Check that `storage uncloud` appears in the Caddy config:
```shell
uc caddy config
```
List issued certificates in cluster storage with [`uc caddy cert ls`](../../9-cli-reference/uc_caddy_cert_ls.md):
```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: Services can be addressed on the internal WireGuard network by service name, service ID, or a machine-scoped service name:
## Service name ## Service name
``` ```
$ nslookup nats.internal $ nslookup nats.internal
Server: 127.0.0.11 Server: 127.0.0.11
@@ -30,6 +31,7 @@ Address: 10.210.1.4
``` ```
## Service ID ## Service ID
``` ```
$ nslookup 3ecb3a8bbec5fd3f46efb056a934714a.internal $ nslookup 3ecb3a8bbec5fd3f46efb056a934714a.internal
Server: 127.0.0.11 Server: 127.0.0.11
@@ -40,6 +42,7 @@ Address: 10.210.0.4
``` ```
## Machine ID scoped service name ## Machine ID scoped service name
``` ```
$ nslookup 0903f0ee483aa97d559eeeaac5e22283.m.nats.internal $ nslookup 0903f0ee483aa97d559eeeaac5e22283.m.nats.internal
Server: 127.0.0.11 Server: 127.0.0.11
@@ -64,7 +67,8 @@ Address: 10.210.1.4
Additionally, the IP ordering preference can be specified with a `rr` (round-robin) or `nearest` subdomain prefix. Additionally, the IP ordering preference can be specified with a `rr` (round-robin) or `nearest` subdomain prefix.
### `rr` (round-robin) *current default* ### `rr` (round-robin) _current default_
Randomly shuffled order on each lookup. Randomly shuffled order on each lookup.
``` ```
@@ -96,9 +100,11 @@ Address: 10.210.0.3
``` ```
## Nearest scope ## Nearest scope
Returns machine-local instances first. Returns machine-local instances first.
`machine-a`: `machine-a`:
``` ```
$ nslookup nearest.worker.internal $ nslookup nearest.worker.internal
Server: 127.0.0.11 Server: 127.0.0.11
@@ -115,6 +121,7 @@ Address: 10.210.1.4
``` ```
`machine-b`: `machine-b`:
``` ```
$ nslookup nearest.worker.internal $ nslookup nearest.worker.internal
Server: 127.0.0.11 Server: 127.0.0.11
@@ -131,3 +138,28 @@ Address: 10.210.0.4
``` ```
The prefixes can be used with service ID and machine-scoped service names, as well (e.g. `nearest.3ecb3a8bbec5fd3f46efb056a934714a.internal` or `rr.0903f0ee483aa97d559eeeaac5e22283.m.worker.internal`). The prefixes can be used with service ID and machine-scoped service names, as well (e.g. `nearest.3ecb3a8bbec5fd3f46efb056a934714a.internal` or `rr.0903f0ee483aa97d559eeeaac5e22283.m.worker.internal`).
## Machine name
Machines can be addressed too on the internal WireGuard network, either by name of by ID, these names are
available from the `m.internal` zone.
```
$ nslookup machine-1.m.internal
Server: 127.0.0.11
Address: 127.0.0.11#53
Name: machine-1.m.internal
Address: 10.210.0.1
```
## Machine ID
```
$ nslookup c337f00600de51ef4375c9a9a267dba5.m.internal
Server: 127.0.0.11
Address: 127.0.0.11#53
Name: c337f00600de51ef4375c9a9a267dba5.m.internal
Address: 10.210.0.1
```
+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: 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. 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 2. Reference that secret value in a service's `environment` or `x-pre_deploy.environment` using the `secret://<name>`
resolve the secret and set the environment variable to its value at deploy time. format. This tells Uncloud to resolve the secret and set the environment variable to its value at deploy time.
```yaml title="compose.yaml" ```yaml title="compose.yaml"
services: services:
@@ -106,9 +106,9 @@ services:
### Attributes ### Attributes
| Attribute | Type | Default | Description | | 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)) | | `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) | | `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 | | `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`) | | `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`) | | `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`) |
+1
View File
@@ -20,6 +20,7 @@ Manage Caddy reverse proxy service.
## See also ## See also
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes. * [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes.
* [uc caddy cert](uc_caddy_cert.md) - Inspect certificates in cluster storage for Caddy.
* [uc caddy config](uc_caddy_config.md) - Show the current Caddy configuration (Caddyfile). * [uc caddy config](uc_caddy_config.md) - Show the current Caddy configuration (Caddyfile).
* [uc caddy deploy](uc_caddy_deploy.md) - Deploy or upgrade Caddy reverse proxy across all machines in the cluster. * [uc caddy deploy](uc_caddy_deploy.md) - Deploy or upgrade Caddy reverse proxy across all machines in the cluster.
* [uc caddy logs](uc_caddy_logs.md) - View caddy logs. * [uc caddy logs](uc_caddy_logs.md) - View caddy logs.
@@ -0,0 +1,24 @@
# uc caddy cert
Inspect certificates in cluster storage for Caddy.
## Options
```
-h, --help help for cert
```
## 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 caddy](uc_caddy.md) - Manage Caddy reverse proxy service.
* [uc caddy cert ls](uc_caddy_cert_ls.md) - List certificates in cluster storage for Caddy.
@@ -0,0 +1,38 @@
# uc caddy cert ls
List certificates in cluster storage for Caddy.
## Synopsis
List certificates stored in the Uncloud cluster storage for Caddy.
Caddy must use the Uncloud storage module (https://github.com/unlabs-dev/caddy-uncloud)
configured with 'storage uncloud' in the global options for its certificates to appear here.
This inventory does not check whether Caddy currently serves or trusts a certificate.
```
uc caddy cert ls [flags]
```
## Options
```
-h, --help help for ls
-m, --machine string Name or ID of the machine to read certificate storage from. (default is connected machine)
-o, --output string Output format: 'json' or empty for a human-readable table.
```
## 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 caddy cert](uc_caddy_cert.md) - Inspect certificates in cluster storage for Caddy.
@@ -15,7 +15,6 @@ uc caddy config [flags]
``` ```
-h, --help help for config -h, --help help for config
-m, --machine string Name or ID of the machine to get the configuration from. (default is connected machine) -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 ## Options inherited from parent commands

Some files were not shown because too many files have changed in this diff Show More