mirror of
https://github.com/psviderski/uncloud.git
synced 2026-11-10 12:44:16 +00:00
Compare commits
40
Commits
c58108aa82
...
v0.22.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1d496cf32 | ||
|
|
286d0e6afb | ||
|
|
cef6916544 | ||
|
|
68d2bd5656 | ||
|
|
99c928f490 | ||
|
|
e81115ef37 | ||
|
|
22b1c68798 | ||
|
|
76b04dd56c | ||
|
|
78facdfcc6 | ||
|
|
7b48ac5a4d | ||
|
|
6c7ddeb82f | ||
|
|
ff6ac45e1e | ||
|
|
c1a7f84dad | ||
|
|
81f30aac26 | ||
|
|
b214d23f21 | ||
|
|
965879b69f | ||
|
|
d66ec2bada | ||
|
|
20e2e97c36 | ||
|
|
e9eab32e6d | ||
|
|
7c6d1be8a7 | ||
|
|
a2987d3ff2 | ||
|
|
3351111feb | ||
|
|
bd695f65a3 | ||
|
|
7e8736fa99 | ||
|
|
8160c9b9d0 | ||
|
|
80d2a323c6 | ||
|
|
5d640b9a57 | ||
|
|
ba224bb5be | ||
|
|
8ddbe3c606 | ||
|
|
ce1c6bd05a | ||
|
|
11e1200fe8 | ||
|
|
0cdf721a6a | ||
|
|
5e26cfbead | ||
|
|
409dc3bfe3 | ||
|
|
5fa236871f | ||
|
|
e3479409b1 | ||
|
|
a1b6b31e9c | ||
|
|
d04772dacd | ||
|
|
87691ec029 | ||
|
|
7ecf63169b |
@@ -14,12 +14,13 @@ on:
|
||||
- "**.proto"
|
||||
- "go.*"
|
||||
- "Makefile"
|
||||
- "mise.toml"
|
||||
- "scripts/**"
|
||||
- "test/**"
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
MISE_VERSION: "2026.3.7"
|
||||
MISE_VERSION: "2026.10.22"
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -42,9 +43,9 @@ jobs:
|
||||
|
||||
- name: Generate docs
|
||||
run: |
|
||||
make cli-docs
|
||||
mise run docs:cli
|
||||
git diff --exit-code ||
|
||||
(echo "Documentation is out of date. Please run 'make cli-docs' and commit the changes." && exit 1)
|
||||
(echo "Documentation is out of date. Please run 'mise run docs:cli' and commit the changes." && exit 1)
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
@@ -70,7 +71,7 @@ jobs:
|
||||
version: ${{ env.MISE_VERSION }}
|
||||
|
||||
# TODO: figure out how to properly lock all platforms in the lockfile and remove this hack.
|
||||
# `mise lock` (mise run mise:lock) doesn't generate checksums for some of the tools, e.g. protoc*
|
||||
# `mise lock` doesn't generate checksums for some of the tools, e.g. protoc*
|
||||
- name: Reset changes in mise.lock after fresh mise install
|
||||
run: git checkout mise.lock
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
MISE_VERSION: "2026.3.7"
|
||||
MISE_VERSION: "2026.10.22"
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -30,6 +30,6 @@ jobs:
|
||||
run: git checkout mise.lock
|
||||
|
||||
- name: Lint
|
||||
run: make lint
|
||||
run: mise run lint
|
||||
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -8,7 +8,7 @@ permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
MISE_VERSION: "2026.3.7"
|
||||
MISE_VERSION: "2026.10.22"
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
packages:
|
||||
github.com/docker/docker/client:
|
||||
config:
|
||||
dir: internal/machine/docker
|
||||
pkgname: docker
|
||||
interfaces:
|
||||
APIClient:
|
||||
github.com/psviderski/uncloud/internal/machine/caddyconfig:
|
||||
interfaces:
|
||||
CaddyfileValidator:
|
||||
|
||||
@@ -98,33 +98,6 @@ management.
|
||||
|
||||
- **`misc/`**: Design documents and guides
|
||||
|
||||
## Key Technologies
|
||||
|
||||
### Core Dependencies
|
||||
|
||||
```go
|
||||
// Networking and orchestration
|
||||
github.com/docker/docker // Docker API client
|
||||
github.com/docker/compose/v2 // Docker Compose integration
|
||||
golang.zx2c4.com/wireguard // WireGuard implementation
|
||||
github.com/hashicorp/serf // Gossip protocol
|
||||
|
||||
// State management
|
||||
github.com/ipfs/go-ds-crdt // CRDT distributed storage
|
||||
github.com/dgraph-io/badger/v3 // Embedded database
|
||||
|
||||
// Web proxy
|
||||
github.com/caddyserver/caddy/v2 // HTTP server and reverse proxy
|
||||
|
||||
// CLI and UX
|
||||
github.com/spf13/cobra // CLI framework
|
||||
github.com/charmbracelet/huh // Interactive forms
|
||||
|
||||
// gRPC and networking
|
||||
google.golang.org/grpc // gRPC framework
|
||||
github.com/siderolabs/grpc-proxy // gRPC proxy for forwarding
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Build and Development
|
||||
@@ -135,16 +108,18 @@ go build -o uc ./cmd/uc
|
||||
go build -o uncloudd ./cmd/uncloudd
|
||||
```
|
||||
|
||||
### Key Make Targets
|
||||
### Formatting and Linting
|
||||
|
||||
- `mise run lint`: Check Go code and formatting without changing files
|
||||
- `mise run lint:fix`: Apply formatting and available lint fixes
|
||||
|
||||
### Other Development Tasks
|
||||
|
||||
- `proto`: Generate protobuf code
|
||||
- `ucind-cluster`: Create development cluster
|
||||
- `update-dev`: Deploy to development machines
|
||||
- `demo-reset`: Reset demo environment
|
||||
- `fmt`: Format code
|
||||
- `test`: Run all tests
|
||||
- `lint`: Lint the code using golangci-lint
|
||||
- `lint-and-fix`: Lint the code and fix issues whenever possible
|
||||
|
||||
## CLI Commands Structure
|
||||
|
||||
|
||||
+34
-5
@@ -35,10 +35,11 @@ Build the CLI:
|
||||
go build -o uc ./cmd/uc
|
||||
```
|
||||
|
||||
Or build and run the CLI with a single command:
|
||||
Or build and run the CLI with mise. It forwards arguments, including help flags, directly to the CLI:
|
||||
|
||||
```shell
|
||||
go run ./cmd/uc --help
|
||||
mise uc --help
|
||||
mise uc image rm -h
|
||||
```
|
||||
|
||||
The Uncloud daemon (`uncloudd`) only supports Linux, so you need to cross-compile it if you're developing on macOS or
|
||||
@@ -99,10 +100,16 @@ mise run dev:reset user@host1 user@host2
|
||||
|
||||
## Linting and formatting
|
||||
|
||||
Lint and format the code:
|
||||
Check the code and formatting without changing files:
|
||||
|
||||
```shell
|
||||
make lint-and-fix
|
||||
mise run lint
|
||||
```
|
||||
|
||||
Apply formatting and available lint fixes:
|
||||
|
||||
```shell
|
||||
mise run lint:fix
|
||||
```
|
||||
|
||||
## Code generation
|
||||
@@ -116,9 +123,31 @@ mise run proto
|
||||
Regenerate mocks:
|
||||
|
||||
```shell
|
||||
make mocks
|
||||
mise run mocks
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Regenerate the CLI reference docs after changing commands or flags:
|
||||
|
||||
```shell
|
||||
mise run docs:cli
|
||||
```
|
||||
|
||||
To preview the documentation website, install its dependencies from the project root:
|
||||
|
||||
```shell
|
||||
npm ci --prefix website
|
||||
```
|
||||
|
||||
Then start the local development server:
|
||||
|
||||
```shell
|
||||
mise run docs:dev
|
||||
```
|
||||
|
||||
The server opens the site (http://localhost:3000/) in your browser and reloads it as you edit files in `website/`.
|
||||
|
||||
## Uncloud in Docker (UCinD)
|
||||
|
||||
The [`ucind`](./cmd/ucind) CLI lets you run Uncloud clusters locally using Docker containers instead of real machines.
|
||||
|
||||
@@ -21,10 +21,6 @@ corrosion-multiarch-image-push:
|
||||
ucind-multiarch-image-push:
|
||||
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
|
||||
|
||||
.PHONY: mocks
|
||||
mocks:
|
||||
@mockery
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
ifeq ($(TEST_NAME),)
|
||||
@@ -51,25 +47,3 @@ test-clean:
|
||||
else \
|
||||
echo "Nothing to clean."; \
|
||||
fi
|
||||
|
||||
.PHONY: vet
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
.PHONY: format fmt
|
||||
format fmt:
|
||||
GOOS=linux golangci-lint fmt
|
||||
|
||||
LINT_TARGETS := lint lint-and-fix
|
||||
.PHONY: $(LINT_TARGETS) _lint
|
||||
$(LINT_TARGETS): _lint
|
||||
lint: ARGS=
|
||||
lint-and-fix: ARGS=--fix
|
||||
_lint:
|
||||
# Explicitly set OS to Linux to not skip *_linux.go files when running on macOS.
|
||||
# Uncloud daemon won't likely support OS other than Linux anytime soon, so for now we can rely on that.
|
||||
GOOS=linux golangci-lint run $(ARGS)
|
||||
|
||||
.PHONY: cli-docs
|
||||
cli-docs:
|
||||
go run ./cmd/uc docs
|
||||
|
||||
@@ -44,8 +44,8 @@ complexity of Kubernetes.
|
||||
[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
|
||||
Encrypt.
|
||||
* **Cluster storage for TLS certificates**: Native cluster storage for Caddy shares certificates and ACME challenge
|
||||
tokens across machines and coordinates issuance.
|
||||
* **Cluster storage for TLS certificates**: [Cluster storage](https://github.com/unlabs-dev/caddy-uncloud) for Caddy
|
||||
shares certificates and ACME challenge tokens across machines and coordinates issuance.
|
||||
* **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.
|
||||
|
||||
|
||||
@@ -4,8 +4,16 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Err returns the upstream error, preserving its gRPC status code.
|
||||
// It returns nil for nil metadata or an absent or successful status.
|
||||
func (m *Metadata) Err() error {
|
||||
return status.ErrorProto(m.GetStatus())
|
||||
}
|
||||
|
||||
func NewIP(addr netip.Addr) *IP {
|
||||
// MarshalBinary always returns a nil error.
|
||||
ip, _ := addr.MarshalBinary()
|
||||
|
||||
+2
-1
@@ -87,7 +87,8 @@ type Metadata struct {
|
||||
MachineName string `protobuf:"bytes,5,opt,name=machine_name,json=machineName,proto3" json:"machine_name,omitempty"`
|
||||
// Address of the machine the response came from.
|
||||
MachineAddr string `protobuf:"bytes,1,opt,name=machine_addr,json=machineAddr,proto3" json:"machine_addr,omitempty"`
|
||||
// error is set if the request to upstream failed. The rest of the response is undefined.
|
||||
// error is set if the request to upstream failed. The rest of the response apart from this metadata message
|
||||
// is undefined.
|
||||
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
|
||||
// error as a gRPC Status message.
|
||||
Status *status.Status `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
|
||||
|
||||
+2
-1
@@ -18,7 +18,8 @@ message Metadata {
|
||||
// Address of the machine the response came from.
|
||||
string machine_addr = 1;
|
||||
|
||||
// error is set if the request to upstream failed. The rest of the response is undefined.
|
||||
// error is set if the request to upstream failed. The rest of the response apart from this metadata message
|
||||
// is undefined.
|
||||
string error = 2;
|
||||
// error as a gRPC Status message.
|
||||
google.rpc.Status status = 3;
|
||||
|
||||
+909
-295
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ service Docker {
|
||||
// Docker auth credentials if necessary.
|
||||
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
|
||||
rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
|
||||
rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse);
|
||||
|
||||
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
|
||||
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
|
||||
@@ -31,6 +32,8 @@ service Docker {
|
||||
|
||||
rpc CreateServiceContainer(CreateServiceContainerRequest) returns (CreateContainerResponse);
|
||||
rpc InspectServiceContainer(InspectContainerRequest) returns (ServiceContainer);
|
||||
// UpdateServiceContainer updates the requested properties of a service container in place without recreating it.
|
||||
rpc UpdateServiceContainer(UpdateServiceContainerRequest) returns (google.protobuf.Empty);
|
||||
rpc ListServiceContainers(ListServiceContainersRequest) returns (ListServiceContainersResponse);
|
||||
rpc RemoveServiceContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
||||
}
|
||||
@@ -179,6 +182,40 @@ message ListImagesRequest {
|
||||
bytes options = 1;
|
||||
}
|
||||
|
||||
message RemoveImageRequest {
|
||||
string id = 1;
|
||||
bool force = 2;
|
||||
bool prune_children = 3;
|
||||
repeated ImagePlatform platforms = 4;
|
||||
}
|
||||
|
||||
// ImagePlatform describes an OCI image platform.
|
||||
message ImagePlatform {
|
||||
string architecture = 1;
|
||||
string os = 2;
|
||||
string os_version = 3;
|
||||
repeated string os_features = 4;
|
||||
string variant = 5;
|
||||
}
|
||||
|
||||
message RemoveImageResponse {
|
||||
// Must contain only one repeated messages field to allow broadcasting RemoveImage requests to multiple machines.
|
||||
repeated MachineImageRemoval messages = 1;
|
||||
}
|
||||
|
||||
message MachineImageRemoval {
|
||||
Metadata metadata = 1;
|
||||
repeated ImageRemoval results = 2;
|
||||
}
|
||||
|
||||
// ImageRemoval reports an image deletion or an untagged image reference.
|
||||
message ImageRemoval {
|
||||
// The image ID of an image that was deleted.
|
||||
string deleted = 1;
|
||||
// The image reference of an image that was untagged.
|
||||
string untagged = 2;
|
||||
}
|
||||
|
||||
message ListImagesResponse {
|
||||
// Must contain only one repeated messages field to allow broadcasting ListImages requests to multiple machines.
|
||||
repeated MachineImages messages = 1;
|
||||
@@ -246,6 +283,23 @@ message ServiceContainer {
|
||||
bytes service_spec = 2;
|
||||
}
|
||||
|
||||
message UpdateServiceContainerRequest {
|
||||
string id = 1;
|
||||
// Fields that are not set are left unchanged. CPU and memory limits can be changed but not removed.
|
||||
// CPU limit in nanocores.
|
||||
optional int64 cpu = 2;
|
||||
// Memory limit in bytes.
|
||||
optional int64 memory = 3;
|
||||
// Memory reservation in bytes.
|
||||
optional int64 memory_reservation = 4;
|
||||
// Service labels that replace all current service labels.
|
||||
Labels service_labels = 5;
|
||||
}
|
||||
|
||||
message Labels {
|
||||
map<string, string> labels = 1;
|
||||
}
|
||||
|
||||
message ListServiceContainersRequest {
|
||||
string service_id = 1;
|
||||
// JSON serialised container.ListOptions.
|
||||
|
||||
@@ -32,11 +32,13 @@ const (
|
||||
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
|
||||
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
|
||||
Docker_ListImages_FullMethodName = "/api.Docker/ListImages"
|
||||
Docker_RemoveImage_FullMethodName = "/api.Docker/RemoveImage"
|
||||
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
|
||||
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
|
||||
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
|
||||
Docker_CreateServiceContainer_FullMethodName = "/api.Docker/CreateServiceContainer"
|
||||
Docker_InspectServiceContainer_FullMethodName = "/api.Docker/InspectServiceContainer"
|
||||
Docker_UpdateServiceContainer_FullMethodName = "/api.Docker/UpdateServiceContainer"
|
||||
Docker_ListServiceContainers_FullMethodName = "/api.Docker/ListServiceContainers"
|
||||
Docker_RemoveServiceContainer_FullMethodName = "/api.Docker/RemoveServiceContainer"
|
||||
)
|
||||
@@ -59,11 +61,14 @@ type DockerClient interface {
|
||||
// Docker auth credentials if necessary.
|
||||
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
|
||||
ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error)
|
||||
RemoveImage(ctx context.Context, in *RemoveImageRequest, opts ...grpc.CallOption) (*RemoveImageResponse, error)
|
||||
CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error)
|
||||
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
|
||||
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
CreateServiceContainer(ctx context.Context, in *CreateServiceContainerRequest, opts ...grpc.CallOption) (*CreateContainerResponse, error)
|
||||
InspectServiceContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*ServiceContainer, error)
|
||||
// UpdateServiceContainer updates the requested properties of a service container in place without recreating it.
|
||||
UpdateServiceContainer(ctx context.Context, in *UpdateServiceContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error)
|
||||
RemoveServiceContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
}
|
||||
@@ -217,6 +222,16 @@ func (c *dockerClient) ListImages(ctx context.Context, in *ListImagesRequest, op
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) RemoveImage(ctx context.Context, in *RemoveImageRequest, opts ...grpc.CallOption) (*RemoveImageResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RemoveImageResponse)
|
||||
err := c.cc.Invoke(ctx, Docker_RemoveImage_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreateVolumeResponse)
|
||||
@@ -267,6 +282,16 @@ func (c *dockerClient) InspectServiceContainer(ctx context.Context, in *InspectC
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) UpdateServiceContainer(ctx context.Context, in *UpdateServiceContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(emptypb.Empty)
|
||||
err := c.cc.Invoke(ctx, Docker_UpdateServiceContainer_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListServiceContainersResponse)
|
||||
@@ -305,11 +330,14 @@ type DockerServer interface {
|
||||
// Docker auth credentials if necessary.
|
||||
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
|
||||
ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error)
|
||||
RemoveImage(context.Context, *RemoveImageRequest) (*RemoveImageResponse, error)
|
||||
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
|
||||
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
|
||||
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error)
|
||||
CreateServiceContainer(context.Context, *CreateServiceContainerRequest) (*CreateContainerResponse, error)
|
||||
InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error)
|
||||
// UpdateServiceContainer updates the requested properties of a service container in place without recreating it.
|
||||
UpdateServiceContainer(context.Context, *UpdateServiceContainerRequest) (*emptypb.Empty, error)
|
||||
ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error)
|
||||
RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
||||
mustEmbedUnimplementedDockerServer()
|
||||
@@ -358,6 +386,9 @@ func (UnimplementedDockerServer) InspectRemoteImage(context.Context, *InspectRem
|
||||
func (UnimplementedDockerServer) ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListImages not implemented")
|
||||
}
|
||||
func (UnimplementedDockerServer) RemoveImage(context.Context, *RemoveImageRequest) (*RemoveImageResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RemoveImage not implemented")
|
||||
}
|
||||
func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented")
|
||||
}
|
||||
@@ -373,6 +404,9 @@ func (UnimplementedDockerServer) CreateServiceContainer(context.Context, *Create
|
||||
func (UnimplementedDockerServer) InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method InspectServiceContainer not implemented")
|
||||
}
|
||||
func (UnimplementedDockerServer) UpdateServiceContainer(context.Context, *UpdateServiceContainerRequest) (*emptypb.Empty, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UpdateServiceContainer not implemented")
|
||||
}
|
||||
func (UnimplementedDockerServer) ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListServiceContainers not implemented")
|
||||
}
|
||||
@@ -591,6 +625,24 @@ func _Docker_ListImages_Handler(srv interface{}, ctx context.Context, dec func(i
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Docker_RemoveImage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RemoveImageRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DockerServer).RemoveImage(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Docker_RemoveImage_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DockerServer).RemoveImage(ctx, req.(*RemoveImageRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateVolumeRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -681,6 +733,24 @@ func _Docker_InspectServiceContainer_Handler(srv interface{}, ctx context.Contex
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Docker_UpdateServiceContainer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateServiceContainerRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DockerServer).UpdateServiceContainer(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Docker_UpdateServiceContainer_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DockerServer).UpdateServiceContainer(ctx, req.(*UpdateServiceContainerRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Docker_ListServiceContainers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListServiceContainersRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -760,6 +830,10 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ListImages",
|
||||
Handler: _Docker_ListImages_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RemoveImage",
|
||||
Handler: _Docker_RemoveImage_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreateVolume",
|
||||
Handler: _Docker_CreateVolume_Handler,
|
||||
@@ -780,6 +854,10 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "InspectServiceContainer",
|
||||
Handler: _Docker_InspectServiceContainer_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateServiceContainer",
|
||||
Handler: _Docker_UpdateServiceContainer_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListServiceContainers",
|
||||
Handler: _Docker_ListServiceContainers_Handler,
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/version"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -35,10 +36,13 @@ func NewListCommand() *cobra.Command {
|
||||
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.
|
||||
Caddy uses cluster storage by default. Certificates don't appear here if Caddy uses local storage,
|
||||
for example, if it runs with a custom global config without 'storage uncloud' or was deployed with
|
||||
uc v0.21.0 or earlier and not upgraded.
|
||||
|
||||
This inventory does not check whether Caddy currently serves or trusts a certificate.`,
|
||||
This inventory does not check whether Caddy currently serves or trusts a certificate.
|
||||
|
||||
See ` + version.DocsURL + `/concepts/ingress/cluster-storage-for-caddy for details.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
+4
-11
@@ -3,8 +3,8 @@ package caddy
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/alecthomas/chroma/v2/quick"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
|
||||
type configOptions struct {
|
||||
machine string
|
||||
noColor bool
|
||||
}
|
||||
|
||||
func NewConfigCommand() *cobra.Command {
|
||||
@@ -33,8 +32,6 @@ func NewConfigCommand() *cobra.Command {
|
||||
|
||||
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||
"Name or ID of the machine to get the configuration from. (default is connected machine)")
|
||||
cmd.Flags().BoolVar(&opts.noColor, "no-color", false,
|
||||
"Disable syntax highlighting for the output.")
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
@@ -54,13 +51,9 @@ func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
|
||||
}
|
||||
|
||||
// Print the Caddyfile with syntax highlighting.
|
||||
if opts.noColor {
|
||||
fmt.Print(config.Caddyfile)
|
||||
} else {
|
||||
if err = quick.Highlight(os.Stdout, config.Caddyfile, "caddy", "terminal256", "monokai"); err != nil {
|
||||
// If highlighting fails, fall back to plain output.
|
||||
fmt.Print(config.Caddyfile)
|
||||
}
|
||||
if err = quick.Highlight(lipgloss.Writer, config.Caddyfile, "caddy", "terminal256", "monokai"); err != nil {
|
||||
// If highlighting fails, fall back to plain output.
|
||||
lipgloss.Print(config.Caddyfile)
|
||||
}
|
||||
|
||||
if config.LastReconciliationError != "" {
|
||||
|
||||
+192
-20
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/version"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -25,6 +26,10 @@ type deployOptions struct {
|
||||
caddyfile string
|
||||
image string
|
||||
machines []string
|
||||
|
||||
caddyfileChanged bool
|
||||
imageChanged bool
|
||||
machinesChanged bool
|
||||
}
|
||||
|
||||
func NewDeployCommand() *cobra.Command {
|
||||
@@ -33,21 +38,62 @@ func NewDeployCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "deploy",
|
||||
Short: "Deploy or upgrade Caddy reverse proxy across all machines in the cluster.",
|
||||
Long: "Deploy or upgrade Caddy reverse proxy across all machines in the cluster.\n" +
|
||||
"A rolling update is performed when updating existing containers to minimise disruption.",
|
||||
Long: `Deploy or upgrade Caddy reverse proxy across all machines in the cluster.
|
||||
A rolling update is performed when updating existing containers to minimise disruption.
|
||||
|
||||
By default, this deploys the latest version of the Uncloud Caddy image with a global config that enables cluster
|
||||
storage for TLS certificates. See https://github.com/unlabs-dev/caddy-uncloud for more details.
|
||||
|
||||
Values set with --image, --caddyfile, and --machine are kept for later deployments. Pass an empty value to reset
|
||||
a flag to its default.
|
||||
|
||||
If this command didn't deploy the current caddy service, for example, it was deployed with 'uc deploy'
|
||||
from a Compose file or an older version of uc, the deployment replaces its configuration.
|
||||
|
||||
Requires Uncloud daemon ` + client.MinCaddyDaemonVersion + ` or newer on all machines.`,
|
||||
Example: ` # Deploy Caddy or upgrade it to the latest version of the Uncloud Caddy image.
|
||||
uc caddy deploy
|
||||
|
||||
# Deploy a custom image and keep it for later deployments.
|
||||
uc caddy deploy --image ghcr.io/unlabs-dev/caddy-uncloud:0.1.3
|
||||
|
||||
# Go back to the latest version of the Uncloud Caddy image.
|
||||
uc caddy deploy --image ""
|
||||
|
||||
# Deploy a custom image build and a custom global Caddy config and keep both for later deployments.
|
||||
uc caddy deploy --image myorg/caddy-uncloud:latest --caddyfile global.Caddyfile
|
||||
|
||||
# Go back to the default global Caddy config but keep the custom image.
|
||||
uc caddy deploy --caddyfile ""
|
||||
|
||||
# Deploy only to specific machines and keep them for later deployments.
|
||||
uc caddy deploy --machine machine1,machine2
|
||||
|
||||
# Go back to deploying to all machines.
|
||||
uc caddy deploy --machine ""`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
opts.caddyfileChanged = cmd.Flag("caddyfile").Changed
|
||||
opts.imageChanged = cmd.Flag("image").Changed
|
||||
opts.machinesChanged = cmd.Flag("machine").Changed
|
||||
|
||||
return runDeploy(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
|
||||
"Path to a custom global Caddy config (Caddyfile) that will be prepended to the auto-generated Caddy config.")
|
||||
"Path to a custom global Caddy config (Caddyfile) that will be prepended to the auto-generated Caddy config.\n"+
|
||||
"An empty value resets it to the default config that enables cluster storage.\n"+
|
||||
"(default is the current custom config or the default config)")
|
||||
cmd.Flags().StringVar(&opts.image, "image", "",
|
||||
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
|
||||
"Caddy Docker image to deploy. It must include the Uncloud storage module if the Caddy config uses it.\n"+
|
||||
"An empty value resets it to the latest version of the Uncloud Caddy image.\n"+
|
||||
"(default is the current custom image or "+client.CaddyImage+":LATEST_VERSION)")
|
||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||
"Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated "+
|
||||
"list. (default is all machines)")
|
||||
"Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated list.\n"+
|
||||
"An empty value resets it to all machines.\n"+
|
||||
"(default is the current machines or all machines)")
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
@@ -55,13 +101,13 @@ func NewDeployCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
caddyfile := ""
|
||||
config := ""
|
||||
if opts.caddyfile != "" {
|
||||
data, err := os.ReadFile(opts.caddyfile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Caddyfile: %w", err)
|
||||
}
|
||||
caddyfile = strings.TrimSpace(string(data))
|
||||
config = strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
@@ -70,6 +116,10 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
if err = clusterClient.Caddy.CheckDaemonVersions(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
||||
if err != nil {
|
||||
if !errors.Is(err, api.ErrNotFound) {
|
||||
@@ -100,26 +150,48 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
if opts.image != "" {
|
||||
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(opts.image, tui.Green))
|
||||
current := svc.Spec()
|
||||
deployOpts := resolveDeploymentOptions(current, opts, config)
|
||||
configChanged := deployOpts.ConfigOrDefault() != current.CaddyConfig()
|
||||
|
||||
if svc.ID != "" && configChanged {
|
||||
printCaddyConfig("current global Caddy config:", current.CaddyConfig())
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Preparing a deployment plan...")
|
||||
|
||||
placement := api.Placement{
|
||||
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||
}
|
||||
d, err := clusterClient.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{
|
||||
Image: opts.image, Config: caddyfile, Placement: placement,
|
||||
})
|
||||
d, err := clusterClient.Caddy.NewDeployment(ctx, deployOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create caddy deployment: %w", err)
|
||||
}
|
||||
target := d.Spec
|
||||
|
||||
if opts.image == "" {
|
||||
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(d.Spec.Container.Image,
|
||||
tui.Green) + tui.Faint.Render(" (latest stable)"))
|
||||
// The Uncloud Caddy image resolves to the latest version allowed by this CLI, which is older than the running image
|
||||
// if a newer CLI deployed it. Continuing would downgrade the image and replace its default config.
|
||||
if deployOpts.Image == "" && client.IsNewerCaddyImage(current.Container.Image, target.Container.Image) {
|
||||
return fmt.Errorf("caddy service runs image '%s' which is newer than the latest version '%s' supported by "+
|
||||
"this uc version: upgrade uc or specify the image with --image",
|
||||
current.Container.Image, target.Container.Image)
|
||||
}
|
||||
|
||||
imageNote := " (latest)"
|
||||
if deployOpts.Image != "" {
|
||||
imageNote = " (custom)"
|
||||
if !opts.imageChanged {
|
||||
imageNote = " (custom, use --image \"\" for the latest version)"
|
||||
}
|
||||
}
|
||||
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(target.Container.Image, tui.Green) +
|
||||
tui.Faint.Render(imageNote))
|
||||
|
||||
if configChanged {
|
||||
if deployOpts.Config != nil {
|
||||
// The user provided this config so there is no need to print its content.
|
||||
fmt.Println(tui.Faint.Render("target global Caddy config: ") + opts.caddyfile)
|
||||
} else {
|
||||
printCaddyConfig("target global Caddy config: (default)", deployOpts.ConfigOrDefault())
|
||||
}
|
||||
}
|
||||
|
||||
plan, err := d.Plan(ctx)
|
||||
@@ -131,7 +203,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
|
||||
} else {
|
||||
if svc.ID == "" {
|
||||
if len(opts.machines) > 0 {
|
||||
if len(deployOpts.Placement.Machines) > 0 {
|
||||
fmt.Println("This will run a Caddy container on each selected machine.")
|
||||
} else {
|
||||
fmt.Println("This will run a Caddy container on each machine.")
|
||||
@@ -163,6 +235,14 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
fmt.Println(summary)
|
||||
fmt.Println()
|
||||
|
||||
warnings := deploymentWarnings(current, target, opts)
|
||||
for _, warning := range warnings {
|
||||
tui.PrintWarning(warning)
|
||||
}
|
||||
if len(warnings) > 0 {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
title := "Proceed with deployment?"
|
||||
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
|
||||
// deploying to the wrong cluster.
|
||||
@@ -195,6 +275,98 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
return UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut())
|
||||
}
|
||||
|
||||
// resolveDeploymentOptions returns the deployment options for the flags and the current service spec, which is zero if
|
||||
// the service doesn't exist. Each flag works on its own: a specified flag overrides the value and an empty one resets
|
||||
// it to the default. A flag that isn't specified keeps the explicitly set value.
|
||||
func resolveDeploymentOptions(
|
||||
current api.ServiceSpec, opts deployOptions, config string,
|
||||
) client.CaddyDeploymentOptions {
|
||||
var resolved client.CaddyDeploymentOptions
|
||||
|
||||
if opts.imageChanged {
|
||||
resolved.Image = opts.image
|
||||
} else if _, custom := current.Labels[api.LabelCaddyImageCustom]; custom {
|
||||
resolved.Image = current.Container.Image
|
||||
}
|
||||
|
||||
if opts.caddyfileChanged {
|
||||
if opts.caddyfile != "" {
|
||||
resolved.Config = new(config)
|
||||
}
|
||||
} else if _, custom := current.Labels[api.LabelCaddyConfigCustom]; custom {
|
||||
resolved.Config = new(current.CaddyConfig())
|
||||
}
|
||||
|
||||
if opts.machinesChanged {
|
||||
resolved.Placement.Machines = cli.ExpandCommaSeparatedValues(opts.machines)
|
||||
} else if _, managed := current.Labels[api.LabelCaddyManaged]; managed {
|
||||
resolved.Placement = current.Placement
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// deploymentWarnings returns the consequences of deploying the target spec over the current one, which is zero if
|
||||
// the service doesn't exist.
|
||||
func deploymentWarnings(current, target api.ServiceSpec, opts deployOptions) []string {
|
||||
var warnings []string
|
||||
exists := current.Name != ""
|
||||
|
||||
if _, managed := current.Labels[api.LabelCaddyManaged]; exists && !managed {
|
||||
warnings = append(warnings, "'uc caddy deploy' doesn't manage this caddy service yet, so this deployment "+
|
||||
"replaces its configuration, including any custom global Caddy config.\n"+
|
||||
"If you manage it with a Compose file, cancel and deploy with 'uc deploy' instead.")
|
||||
}
|
||||
|
||||
currentStorage := client.CaddyStorageModule(current.CaddyConfig())
|
||||
targetStorage := client.CaddyStorageModule(target.CaddyConfig())
|
||||
if exists && currentStorage != targetStorage {
|
||||
warning := fmt.Sprintf("Caddy certificate storage changes from %s to %s.\n"+
|
||||
"Caddy doesn't automatically migrate certificates, so all of them will be reissued and HTTPS may fail "+
|
||||
"for those domains until that finishes.\n"+
|
||||
"To migrate them first, see %s/concepts/ingress/cluster-storage-for-caddy",
|
||||
caddyStorageName(currentStorage), caddyStorageName(targetStorage), version.DocsURL)
|
||||
if !opts.caddyfileChanged {
|
||||
warning += "\nTo keep the current storage, specify a custom Caddyfile that uses it with --caddyfile."
|
||||
}
|
||||
warnings = append(warnings, warning)
|
||||
}
|
||||
|
||||
imageOrConfigChanged := current.Container.Image != target.Container.Image ||
|
||||
current.CaddyConfig() != target.CaddyConfig()
|
||||
if targetStorage == client.CaddyStorageUncloud && imageOrConfigChanged &&
|
||||
!client.IsDefaultCaddyImage(target.Container.Image) {
|
||||
warnings = append(warnings, fmt.Sprintf("Image '%s' must include the Uncloud storage module to use "+
|
||||
"'storage uncloud'. Otherwise, Caddy won't start or apply the new config.\n"+
|
||||
"Check 'uc caddy config' after the deployment for errors.", target.Container.Image))
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
// caddyStorageName returns a user-friendly name of the Caddy storage module.
|
||||
func caddyStorageName(module string) string {
|
||||
switch module {
|
||||
case client.CaddyStorageFileSystem:
|
||||
return "local"
|
||||
case client.CaddyStorageUncloud:
|
||||
return "cluster"
|
||||
default:
|
||||
return "'" + module + "'"
|
||||
}
|
||||
}
|
||||
|
||||
// printCaddyConfig prints the title and the indented config or "none" if it's empty.
|
||||
func printCaddyConfig(title, config string) {
|
||||
if config == "" {
|
||||
lipgloss.Println(tui.Faint.Render(title) + " none")
|
||||
return
|
||||
}
|
||||
|
||||
lipgloss.Println(tui.Faint.Render(title))
|
||||
fmt.Println(config)
|
||||
}
|
||||
|
||||
func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, progressOut *streams.Out) error {
|
||||
domain, err := clusterClient.GetDomain(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResolveDeploymentOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
customImage = "example.com/caddy:1.2.3"
|
||||
customConfig = "{\n\tdebug\n}"
|
||||
)
|
||||
machines := api.Placement{Machines: []string{"m1", "m2"}}
|
||||
|
||||
// A service with a custom image, config, and machines that has the given labels.
|
||||
service := func(labels ...string) api.ServiceSpec {
|
||||
spec := api.ServiceSpec{
|
||||
Name: client.CaddyServiceName,
|
||||
Caddy: &api.CaddySpec{Config: customConfig},
|
||||
Container: api.ContainerSpec{Image: customImage},
|
||||
Placement: machines,
|
||||
}
|
||||
if len(labels) > 0 {
|
||||
spec.Labels = make(map[string]string, len(labels))
|
||||
for _, label := range labels {
|
||||
spec.Labels[label] = ""
|
||||
}
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
current api.ServiceSpec
|
||||
opts deployOptions
|
||||
config string
|
||||
want client.CaddyDeploymentOptions
|
||||
}{
|
||||
{
|
||||
name: "no service gets the defaults",
|
||||
},
|
||||
{
|
||||
name: "service that isn't managed gets the defaults",
|
||||
current: service(),
|
||||
},
|
||||
{
|
||||
name: "managed service follows the default image and config and keeps machines",
|
||||
current: service(api.LabelCaddyManaged),
|
||||
want: client.CaddyDeploymentOptions{Placement: machines},
|
||||
},
|
||||
{
|
||||
name: "custom image is kept and config follows the default",
|
||||
current: service(api.LabelCaddyManaged, api.LabelCaddyImageCustom),
|
||||
want: client.CaddyDeploymentOptions{Image: customImage, Placement: machines},
|
||||
},
|
||||
{
|
||||
name: "custom config is kept and image follows the latest version",
|
||||
current: service(api.LabelCaddyManaged, api.LabelCaddyConfigCustom),
|
||||
want: client.CaddyDeploymentOptions{Config: new(customConfig), Placement: machines},
|
||||
},
|
||||
{
|
||||
name: "custom image and config are kept",
|
||||
current: service(api.LabelCaddyManaged, api.LabelCaddyImageCustom, api.LabelCaddyConfigCustom),
|
||||
want: client.CaddyDeploymentOptions{Image: customImage, Config: new(customConfig), Placement: machines},
|
||||
},
|
||||
{
|
||||
name: "custom empty config is kept",
|
||||
current: api.ServiceSpec{
|
||||
Name: client.CaddyServiceName,
|
||||
Labels: map[string]string{api.LabelCaddyManaged: "", api.LabelCaddyConfigCustom: ""},
|
||||
},
|
||||
want: client.CaddyDeploymentOptions{Config: new("")},
|
||||
},
|
||||
{
|
||||
name: "image flag overrides the current image",
|
||||
current: service(api.LabelCaddyManaged, api.LabelCaddyImageCustom),
|
||||
opts: deployOptions{image: "caddy:2", imageChanged: true},
|
||||
want: client.CaddyDeploymentOptions{Image: "caddy:2", Placement: machines},
|
||||
},
|
||||
{
|
||||
name: "empty image flag resets to the latest version",
|
||||
current: service(api.LabelCaddyManaged, api.LabelCaddyImageCustom),
|
||||
opts: deployOptions{imageChanged: true},
|
||||
want: client.CaddyDeploymentOptions{Placement: machines},
|
||||
},
|
||||
{
|
||||
name: "caddyfile flag overrides the current config",
|
||||
current: service(api.LabelCaddyManaged),
|
||||
opts: deployOptions{caddyfile: "global.Caddyfile", caddyfileChanged: true},
|
||||
config: "{\n\tstorage redis\n}",
|
||||
want: client.CaddyDeploymentOptions{Config: new("{\n\tstorage redis\n}"), Placement: machines},
|
||||
},
|
||||
{
|
||||
name: "caddyfile flag with an empty file sets a custom empty config",
|
||||
current: service(api.LabelCaddyManaged),
|
||||
opts: deployOptions{caddyfile: "empty.Caddyfile", caddyfileChanged: true},
|
||||
want: client.CaddyDeploymentOptions{Config: new(""), Placement: machines},
|
||||
},
|
||||
{
|
||||
name: "empty caddyfile flag resets to the default config",
|
||||
current: service(api.LabelCaddyManaged, api.LabelCaddyConfigCustom),
|
||||
opts: deployOptions{caddyfileChanged: true},
|
||||
want: client.CaddyDeploymentOptions{Placement: machines},
|
||||
},
|
||||
{
|
||||
name: "machine flag overrides the current machines",
|
||||
current: service(api.LabelCaddyManaged),
|
||||
opts: deployOptions{machines: []string{"m3,m4", "m5"}, machinesChanged: true},
|
||||
want: client.CaddyDeploymentOptions{Placement: api.Placement{Machines: []string{"m3", "m4", "m5"}}},
|
||||
},
|
||||
{
|
||||
name: "empty machine flag resets to all machines",
|
||||
current: service(api.LabelCaddyManaged),
|
||||
opts: deployOptions{machines: []string{}, machinesChanged: true},
|
||||
},
|
||||
{
|
||||
name: "flags apply to a service that isn't managed",
|
||||
current: service(),
|
||||
opts: deployOptions{
|
||||
image: "caddy:2", imageChanged: true,
|
||||
caddyfile: "global.Caddyfile", caddyfileChanged: true,
|
||||
machines: []string{"m3"}, machinesChanged: true,
|
||||
},
|
||||
config: customConfig,
|
||||
want: client.CaddyDeploymentOptions{
|
||||
Image: "caddy:2",
|
||||
Config: new(customConfig),
|
||||
Placement: api.Placement{Machines: []string{"m3"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, resolveDeploymentOptions(tt.current, tt.opts, tt.config))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploymentWarnings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
defaultImage = client.CaddyImage + ":0.1.3"
|
||||
customImage = "example.com/caddy:1.2.3"
|
||||
localConfig = "{\n\tdebug\n}"
|
||||
override = "doesn't manage this caddy service yet"
|
||||
storage = "certificate storage changes from"
|
||||
keepStorage = "To keep the current storage"
|
||||
module = "must include the Uncloud storage module"
|
||||
)
|
||||
managed := map[string]string{api.LabelCaddyManaged: ""}
|
||||
|
||||
service := func(image, config string, labels map[string]string) api.ServiceSpec {
|
||||
spec := api.ServiceSpec{
|
||||
Name: client.CaddyServiceName,
|
||||
Container: api.ContainerSpec{Image: image},
|
||||
Labels: labels,
|
||||
}
|
||||
if config != "" {
|
||||
spec.Caddy = &api.CaddySpec{Config: config}
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
current api.ServiceSpec
|
||||
target api.ServiceSpec
|
||||
opts deployOptions
|
||||
want []string
|
||||
notWant []string
|
||||
}{
|
||||
{
|
||||
name: "new service with defaults has no warnings",
|
||||
target: service(defaultImage, client.DefaultCaddyConfig, managed),
|
||||
notWant: []string{override, storage, module},
|
||||
},
|
||||
{
|
||||
name: "new service with a custom image",
|
||||
target: service(customImage, client.DefaultCaddyConfig, managed),
|
||||
want: []string{module},
|
||||
notWant: []string{override, storage},
|
||||
},
|
||||
{
|
||||
name: "service that isn't managed moves to cluster storage",
|
||||
current: service("caddy:2.11.4", "", nil),
|
||||
target: service(defaultImage, client.DefaultCaddyConfig, managed),
|
||||
want: []string{override, "from local to cluster", keepStorage},
|
||||
notWant: []string{module},
|
||||
},
|
||||
{
|
||||
name: "service that isn't managed already uses cluster storage",
|
||||
current: service(defaultImage, client.DefaultCaddyConfig, nil),
|
||||
target: service(defaultImage, client.DefaultCaddyConfig, managed),
|
||||
want: []string{override},
|
||||
notWant: []string{storage, module},
|
||||
},
|
||||
{
|
||||
name: "managed service without changes has no warnings",
|
||||
current: service(defaultImage, client.DefaultCaddyConfig, managed),
|
||||
target: service(defaultImage, client.DefaultCaddyConfig, managed),
|
||||
notWant: []string{override, storage, module},
|
||||
},
|
||||
{
|
||||
name: "managed service upgrades the default image",
|
||||
current: service(client.CaddyImage+":0.1.2", client.DefaultCaddyConfig, managed),
|
||||
target: service(defaultImage, client.DefaultCaddyConfig, managed),
|
||||
notWant: []string{override, storage, module},
|
||||
},
|
||||
{
|
||||
name: "custom caddyfile moves to local storage",
|
||||
current: service(defaultImage, client.DefaultCaddyConfig, managed),
|
||||
target: service(defaultImage, localConfig, managed),
|
||||
opts: deployOptions{caddyfile: "global.Caddyfile", caddyfileChanged: true},
|
||||
want: []string{"from cluster to local"},
|
||||
notWant: []string{override, keepStorage, module},
|
||||
},
|
||||
{
|
||||
name: "other storage module is named",
|
||||
current: service(defaultImage, "{\n\tstorage redis\n}", managed),
|
||||
target: service(defaultImage, client.DefaultCaddyConfig, managed),
|
||||
want: []string{"from 'redis' to cluster", keepStorage},
|
||||
},
|
||||
{
|
||||
name: "custom image is unchanged with the same config",
|
||||
current: service(customImage, client.DefaultCaddyConfig, managed),
|
||||
target: service(customImage, client.DefaultCaddyConfig, managed),
|
||||
notWant: []string{override, storage, module},
|
||||
},
|
||||
{
|
||||
name: "custom image receives a changed default config",
|
||||
current: service(customImage, "{\n\tstorage uncloud\n\tdebug\n}", managed),
|
||||
target: service(customImage, client.DefaultCaddyConfig, managed),
|
||||
want: []string{module},
|
||||
notWant: []string{override, storage},
|
||||
},
|
||||
{
|
||||
name: "custom image with local storage",
|
||||
current: service(defaultImage, localConfig, managed),
|
||||
target: service(customImage, localConfig, managed),
|
||||
notWant: []string{override, storage, module},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
warnings := strings.Join(deploymentWarnings(tt.current, tt.target, tt.opts), "\n")
|
||||
for _, want := range tt.want {
|
||||
assert.Contains(t, warnings, want)
|
||||
}
|
||||
for _, notWant := range tt.notWant {
|
||||
assert.NotContains(t, warnings, notWant)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+1
-10
@@ -22,15 +22,6 @@ func NewShowCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func show(uncli *cli.CLI) error {
|
||||
// discard errors, only show the current context, otherwise nothing
|
||||
if uncli.Config == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(uncli.Config.Contexts) == 0 {
|
||||
return nil
|
||||
}
|
||||
fmt.Println(uncli.Config.CurrentContext)
|
||||
|
||||
fmt.Println(uncli.ContextOverrideOrCurrent())
|
||||
return nil
|
||||
}
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ import (
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
||||
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -305,7 +305,7 @@ const defaultFailedContainerLogsTail = 10
|
||||
// UNCLOUD_FAILED_CONTAINER_LOGS_TAIL environment variable override when set and valid.
|
||||
func failedContainerLogsTail() int {
|
||||
if v := os.Getenv("UNCLOUD_FAILED_CONTAINER_LOGS_TAIL"); v != "" {
|
||||
if tail, err := logs.Tail(v); err == nil && (tail == -1 || tail > 0) {
|
||||
if tail, err := logs.ParseTail(v); err == nil && (tail == -1 || tail > 0) {
|
||||
return tail
|
||||
}
|
||||
}
|
||||
|
||||
+54
-48
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/containerd/platforms"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"github.com/docker/go-units"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
@@ -90,9 +91,14 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||
|
||||
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||
|
||||
clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{
|
||||
Machines: machines,
|
||||
Name: opts.nameFilter,
|
||||
var clusterImages []api.MachineImages
|
||||
err = tui.RunSpinner(ctx, "Listing images...", func(ctx context.Context) error {
|
||||
var err error
|
||||
clusterImages, err = clusterClient.ListImages(ctx, api.ImageFilter{
|
||||
Machines: machines,
|
||||
Name: opts.nameFilter,
|
||||
})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list images: %w", err)
|
||||
@@ -102,7 +108,7 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||
var rows []imageRow
|
||||
|
||||
for _, machineImages := range clusterImages {
|
||||
if err := machineImages.Error(); err != nil {
|
||||
if err := machineImages.Metadata.Err(); err != nil {
|
||||
tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s",
|
||||
machineImages.Metadata.MachineName, err))
|
||||
continue
|
||||
@@ -116,49 +122,7 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||
store = "containerd"
|
||||
}
|
||||
|
||||
// Process each image for this machine.
|
||||
for _, img := range machineImages.Images {
|
||||
// Show the first 12 chars without 'sha256:' as the image ID like Docker does.
|
||||
id := strings.TrimPrefix(img.ID, "sha256:")[:12]
|
||||
|
||||
name := "<none>"
|
||||
if len(img.RepoTags) > 0 && img.RepoTags[0] != "<none>:<none>" {
|
||||
name = img.RepoTags[0]
|
||||
}
|
||||
|
||||
imgPlatforms, _ := imagePlatforms(img)
|
||||
formattedPlatforms := formatPlatforms(imgPlatforms)
|
||||
|
||||
created := ""
|
||||
createdAt := time.Unix(img.Created, 0)
|
||||
if !createdAt.IsZero() {
|
||||
created = units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
|
||||
}
|
||||
|
||||
size := units.HumanSizeWithPrecision(float64(img.Size), 3)
|
||||
|
||||
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
|
||||
inUse := "-"
|
||||
if img.Containers != -1 { // -1 means the info is not available.
|
||||
if img.Containers > 0 {
|
||||
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
|
||||
} else {
|
||||
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, imageRow{
|
||||
id: id,
|
||||
name: name,
|
||||
platforms: formattedPlatforms,
|
||||
createdHuman: created,
|
||||
createdUnix: img.Created,
|
||||
size: size,
|
||||
inUse: inUse,
|
||||
store: store,
|
||||
machine: machineName,
|
||||
})
|
||||
}
|
||||
rows = append(rows, imageRows(machineImages.Images, machineName, store)...)
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
@@ -184,13 +148,55 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// imageRows keeps every stored name visible and retains digest-only and unnamed images.
|
||||
func imageRows(images []image.Summary, machineName, store string) []imageRow {
|
||||
var rows []imageRow
|
||||
for _, img := range images {
|
||||
// RepoTags includes stored digest names on containerd. RepoDigests can include derived names.
|
||||
imageNames := img.RepoTags
|
||||
if len(imageNames) == 0 {
|
||||
imageNames = []string{"<untagged>"}
|
||||
}
|
||||
|
||||
imgPlatforms, _ := imagePlatforms(img)
|
||||
created := ""
|
||||
if img.Created > 0 {
|
||||
created = units.HumanDuration(time.Since(time.Unix(img.Created, 0))) + " ago"
|
||||
}
|
||||
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
|
||||
inUse := "-"
|
||||
if img.Containers >= 0 { // -1 means the info is not available.
|
||||
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
|
||||
if img.Containers > 0 {
|
||||
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range imageNames {
|
||||
rows = append(rows, imageRow{
|
||||
id: stringid.TruncateID(img.ID),
|
||||
name: name,
|
||||
platforms: formatPlatforms(imgPlatforms),
|
||||
createdHuman: created,
|
||||
createdUnix: img.Created,
|
||||
size: units.HumanSizeWithPrecision(float64(img.Size), 3),
|
||||
inUse: inUse,
|
||||
store: store,
|
||||
machine: machineName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
// imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform.
|
||||
func imagePlatforms(img image.Summary) ([]string, bool) {
|
||||
var formattedPlatforms []string
|
||||
multiPlatform := false
|
||||
|
||||
for _, m := range img.Manifests {
|
||||
if m.Kind != image.ManifestKindImage || !m.Available {
|
||||
if m.Kind != image.ManifestKindImage || !m.Available || m.ImageData == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type removeOptions struct {
|
||||
machines []string
|
||||
yes bool
|
||||
}
|
||||
|
||||
func NewRemoveCommand() *cobra.Command {
|
||||
opts := removeOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "rm IMAGE [IMAGE...]",
|
||||
Aliases: []string{"remove", "delete"},
|
||||
Short: "Remove images from machines in the cluster.",
|
||||
Long: `Remove images from all or selected machines in the cluster.
|
||||
|
||||
An image name without a tag selects all its tags. Specify a tag, such as myapp:latest, to remove
|
||||
only that tag.
|
||||
|
||||
Wildcard patterns are supported for image names and tags and should be quoted to prevent your
|
||||
shell from expanding them. Removing by image ID attempts to remove each name for that image
|
||||
independently.
|
||||
|
||||
Images used by running or stopped containers cannot be removed. Unused tags can be removed even
|
||||
when the same image is used under another tag. Failed removals do not prevent other removals.`,
|
||||
Example: ` # Remove a tag from all machines after confirming the plan.
|
||||
uc image rm myapp:old
|
||||
|
||||
# Remove all tags for an image name from all machines.
|
||||
uc image rm myapp
|
||||
|
||||
# Remove matching tags from two machines without confirmation.
|
||||
uc image rm 'myapp:1.*' 'otherapp:*' -m machine1,machine2 --yes
|
||||
|
||||
# Remove all names for an image by ID from all machines.
|
||||
uc image rm ee8fed0e9618`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return remove(cmd.Context(), uncli, args, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||
"Machine names or IDs to remove images from. Can be specified multiple times or as a comma-separated list.\n"+
|
||||
"If not specified, the matching images will be removed from all machines.")
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Auto-confirm the removal plan. Should be explicitly set when running non-interactively,\n"+
|
||||
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, uncli *cli.CLI, images []string, opts removeOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
plan, err := planImageRemoval(ctx, clusterClient, images, cli.ExpandCommaSeparatedValues(opts.machines))
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan image removal: %w", err)
|
||||
}
|
||||
directConn := uncli.DirectConnection()
|
||||
contextName := uncli.ContextOverrideOrCurrent()
|
||||
cluster := ""
|
||||
if directConn != "" {
|
||||
cluster = directConn
|
||||
lipgloss.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
|
||||
lipgloss.Println()
|
||||
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
|
||||
// Only show context if there's more than one to avoid unnecessary clutter.
|
||||
cluster = contextName
|
||||
lipgloss.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
|
||||
lipgloss.Println()
|
||||
}
|
||||
|
||||
lipgloss.Println(plan.Format())
|
||||
|
||||
if !opts.yes {
|
||||
if !tui.IsTerminalAvailable() {
|
||||
return errors.New("cannot ask to confirm image removal in non-interactive mode, " +
|
||||
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
|
||||
}
|
||||
|
||||
title := "Proceed with image removal?"
|
||||
if cluster != "" {
|
||||
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
||||
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
|
||||
title = "Proceed with image removal on " + tui.NameStyle.Render(cluster) + confirmStyle.Render("?")
|
||||
}
|
||||
|
||||
confirmed, err := tui.Confirm(title)
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm image removal: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
return cli.Cancelled("Image removal cancelled. No images were removed.")
|
||||
}
|
||||
}
|
||||
|
||||
untagged := make(map[string]bool)
|
||||
var errs []error
|
||||
for i, group := range plan.GroupPlans {
|
||||
if i > 0 {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
err := progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
return group.Execute(ctx, clusterClient, untagged)
|
||||
}, uncli.ProgressOut(), "Removing image "+group.Image)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
var imageIDRegex = regexp.MustCompile(`^(sha256:)?[a-f0-9]{4,64}$`)
|
||||
|
||||
// imageRemovalPlan contains all image removal operations grouped by the user-supplied image name, ID, or pattern.
|
||||
type imageRemovalPlan struct {
|
||||
GroupPlans []*removeImageGroupPlan
|
||||
}
|
||||
|
||||
func (p *imageRemovalPlan) IsEmpty() bool { return len(p.GroupPlans) == 0 }
|
||||
|
||||
func (p *imageRemovalPlan) Format() string {
|
||||
var out strings.Builder
|
||||
machines := make(map[string]struct{})
|
||||
removeCount := 0
|
||||
for _, group := range p.GroupPlans {
|
||||
out.WriteString(group.Format())
|
||||
for _, op := range group.Operations {
|
||||
machines[op.MachineName] = struct{}{}
|
||||
removeCount++
|
||||
}
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("%s remove %s across %s %s",
|
||||
tui.BoldRed.Render(fmt.Sprint(removeCount)),
|
||||
tui.Faint.Render("·"),
|
||||
tui.Bold.Render(fmt.Sprint(len(machines))),
|
||||
pluralMachines(len(machines)),
|
||||
)
|
||||
out.WriteString("\n" + tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))) + "\n" + summary + "\n")
|
||||
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func pluralMachines(count int) string {
|
||||
if count == 1 {
|
||||
return "machine"
|
||||
}
|
||||
return "machines"
|
||||
}
|
||||
|
||||
// Execute applies the frozen plan, collecting failures without discovering new targets.
|
||||
func (p *imageRemovalPlan) Execute(ctx context.Context, cli api.ImageClient) error {
|
||||
untagged := make(map[string]bool)
|
||||
var errs []error
|
||||
for _, group := range p.GroupPlans {
|
||||
if err := group.Execute(ctx, cli, untagged); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// removeImageGroupPlan groups removals for one image name, ID, or pattern supplied by the user.
|
||||
type removeImageGroupPlan struct {
|
||||
// Image is the image name, ID, or pattern supplied by the user.
|
||||
Image string
|
||||
Operations []*removeImageOperation
|
||||
}
|
||||
|
||||
func (o *removeImageGroupPlan) Execute(ctx context.Context, cli api.ImageClient, untagged map[string]bool) error {
|
||||
var errs []error
|
||||
for _, op := range o.Operations {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return errors.Join(append(errs, err)...)
|
||||
}
|
||||
if err := op.Execute(ctx, cli, untagged); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (o *removeImageGroupPlan) Format() string {
|
||||
var out strings.Builder
|
||||
out.WriteString(tui.BoldRed.Render("-") + " remove image " + tui.NameStyle.Render(o.Image) + "\n")
|
||||
for i, op := range o.Operations {
|
||||
branch := " ├── "
|
||||
if i == len(o.Operations)-1 {
|
||||
branch = " ╰── "
|
||||
}
|
||||
out.WriteString(tui.Faint.Render(branch) + op.Format() + "\n")
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func (o *removeImageGroupPlan) String() string {
|
||||
return fmt.Sprintf("removeImageGroupPlan[image=%s]", o.Image)
|
||||
}
|
||||
|
||||
// removeImageOperation removes an image name or an unnamed image by ID on a machine.
|
||||
type removeImageOperation struct {
|
||||
MachineName string
|
||||
ImageID string
|
||||
// Image is the exact image name or ID sent to Docker.
|
||||
Image string
|
||||
}
|
||||
|
||||
func (o *removeImageOperation) Execute(ctx context.Context, cli api.ImageClient, untagged map[string]bool) error {
|
||||
if untagged[o.MachineName+"\x00"+o.Image] {
|
||||
return nil
|
||||
}
|
||||
results, err := cli.RemoveImage(ctx, o.Image, api.RemoveImageOptions{Machines: []string{o.MachineName}})
|
||||
// A removal may also untag other planned names. Track actual results across all groups, per machine.
|
||||
for _, result := range results {
|
||||
for _, removal := range result.Results {
|
||||
if removal.Untagged != "" {
|
||||
untagged[result.Metadata.MachineName+"\x00"+removal.Untagged] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if errors.Is(err, api.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove image '%s': %w", o.Image, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *removeImageOperation) Format() string {
|
||||
return tui.BoldRed.Render("-") + " " + tui.FormatImage(o.Image, tui.NoStyle) +
|
||||
tui.Faint.Render(" ("+stringid.TruncateID(o.ImageID)+") on ") + o.MachineName
|
||||
}
|
||||
|
||||
func (o *removeImageOperation) String() string {
|
||||
return fmt.Sprintf("removeImageOperation[machine_name=%s image=%s]", o.MachineName, o.Image)
|
||||
}
|
||||
|
||||
type machineImageInventory struct {
|
||||
machineName string
|
||||
images []image.Summary
|
||||
}
|
||||
|
||||
// planImageRemoval builds a removal plan grouped by input argument from images on the selected machines.
|
||||
// An empty machines list selects all machines.
|
||||
//
|
||||
// Arguments expand as follows:
|
||||
// - Name: without a tag or digest, select all stored names in that exact repository across image IDs.
|
||||
// An explicit tag selects only that tag. Familiar names are normalised.
|
||||
// - ID: select all RepoTags for the matching image, or its full ID if unnamed. A prefix must identify
|
||||
// one unique full ID across the selected machines.
|
||||
// - Pattern: match RepoTags only. A matching digest also selects that image's names in the same repository.
|
||||
// - Digest: resolve through RepoTags, RepoDigests, or the image ID, then select that image's names in
|
||||
// the same repository. If none exist but RepoDigests contains the reference, select the digest itself.
|
||||
//
|
||||
// Selecting all tags for a repository/image on a machine also adds its stored digest references after
|
||||
// the tags, even if those digests did not match an argument. Stored names are kept as is, and overlapping
|
||||
// selections produce only one operation per machine and name.
|
||||
//
|
||||
// Failed image listings produce warnings and exclude those machines from the plan. If no machine can
|
||||
// be queried, planning fails. Every argument must match on a successfully queried machine. Unmatched
|
||||
// or invalid arguments and ambiguous ID prefixes reject the whole plan before any removal.
|
||||
//
|
||||
// Planning does not check container usage. The backend checks each removal during execution, and failures
|
||||
// do not stop other operations. Tag removal may implicitly untag a used digest. Execution skips names
|
||||
// already untagged by earlier operations and tolerates names that disappeared after planning.
|
||||
func planImageRemoval(ctx context.Context, cli api.ImageClient, images, machines []string) (*imageRemovalPlan, error) {
|
||||
if len(images) == 0 {
|
||||
return nil, errors.New("at least one image is required")
|
||||
}
|
||||
|
||||
var clusterImages []api.MachineImages
|
||||
err := tui.RunSpinner(ctx, "Finding images to remove...", func(ctx context.Context) error {
|
||||
var err error
|
||||
clusterImages, err = cli.ListImages(ctx, api.ImageFilter{Machines: machines})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list images: %w", err)
|
||||
}
|
||||
|
||||
var inventory []*machineImageInventory
|
||||
for _, machineImages := range clusterImages {
|
||||
if err := machineImages.Metadata.Err(); err != nil {
|
||||
tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s",
|
||||
machineImages.Metadata.MachineName, err))
|
||||
continue
|
||||
}
|
||||
machine := &machineImageInventory{
|
||||
machineName: machineImages.Metadata.MachineName,
|
||||
images: machineImages.Images,
|
||||
}
|
||||
inventory = append(inventory, machine)
|
||||
}
|
||||
if len(inventory) == 0 {
|
||||
return nil, errors.New("failed to list images on any machine")
|
||||
}
|
||||
|
||||
return planImageRemovalFromInventory(images, inventory)
|
||||
}
|
||||
|
||||
func planImageRemovalFromInventory(images []string, inventory []*machineImageInventory) (*imageRemovalPlan, error) {
|
||||
plan := &imageRemovalPlan{}
|
||||
seen := make(map[string]bool)
|
||||
var errs []error
|
||||
for _, imageArg := range images {
|
||||
group := &removeImageGroupPlan{Image: imageArg}
|
||||
isID := imageIDRegex.MatchString(imageArg)
|
||||
isPattern := strings.ContainsAny(imageArg, "*?[")
|
||||
var namedRef reference.Named
|
||||
if isPattern {
|
||||
if _, err := path.Match(imageArg, ""); err != nil {
|
||||
return nil, fmt.Errorf("invalid image pattern '%s': %w", imageArg, err)
|
||||
}
|
||||
} else if !isID {
|
||||
var err error
|
||||
namedRef, err = reference.ParseNormalizedNamed(imageArg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid image '%s': %w", imageArg, err)
|
||||
}
|
||||
}
|
||||
if isID {
|
||||
matches := make(map[string]bool)
|
||||
for _, machine := range inventory {
|
||||
for _, img := range machine.images {
|
||||
if imageIDMatches(img.ID, imageArg) {
|
||||
matches[img.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
var candidates []string
|
||||
for id := range matches {
|
||||
candidates = append(candidates, id)
|
||||
}
|
||||
slices.Sort(candidates)
|
||||
errs = append(errs,
|
||||
fmt.Errorf("image ID '%s' is ambiguous: %s", imageArg, strings.Join(candidates, ", ")))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
matched := false
|
||||
for _, machine := range inventory {
|
||||
for _, img := range machine.images {
|
||||
var imagesToRemove []string
|
||||
switch {
|
||||
case isID:
|
||||
if !imageIDMatches(img.ID, imageArg) {
|
||||
continue
|
||||
}
|
||||
matched = true
|
||||
// Expand only stored names, matching uc image ls. RepoDigests remains available for digest lookup.
|
||||
imagesToRemove = img.RepoTags
|
||||
if len(imagesToRemove) == 0 {
|
||||
imagesToRemove = []string{img.ID}
|
||||
}
|
||||
case isPattern:
|
||||
for _, ref := range img.RepoTags {
|
||||
if ok, _ := path.Match(imageArg, ref); ok {
|
||||
named, _ := reference.ParseDockerRef(ref)
|
||||
if _, digest := named.(reference.Canonical); digest {
|
||||
imagesToRemove = append(imagesToRemove,
|
||||
imageRepositoryReferences(img.RepoTags, named.Name())...)
|
||||
} else {
|
||||
imagesToRemove = append(imagesToRemove, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
case reference.IsNameOnly(namedRef):
|
||||
imagesToRemove = imageRepositoryReferences(img.RepoTags, namedRef.Name())
|
||||
default:
|
||||
if canonical, digest := namedRef.(reference.Canonical); digest {
|
||||
ref := reference.FamiliarString(namedRef)
|
||||
if canonical.Digest().String() != img.ID && !slices.Contains(img.RepoTags, ref) &&
|
||||
!slices.Contains(img.RepoDigests, ref) {
|
||||
continue
|
||||
}
|
||||
imagesToRemove = imageRepositoryReferences(img.RepoTags, namedRef.Name())
|
||||
if len(imagesToRemove) == 0 && slices.Contains(img.RepoDigests, ref) {
|
||||
// An explicitly requested digest can resolve even when no stored name is listed.
|
||||
imagesToRemove = []string{ref}
|
||||
}
|
||||
} else if slices.Contains(img.RepoTags, reference.FamiliarString(namedRef)) {
|
||||
imagesToRemove = []string{reference.FamiliarString(namedRef)}
|
||||
}
|
||||
}
|
||||
for _, ref := range imagesToRemove {
|
||||
matched = true
|
||||
key := machine.machineName + "\x00" + ref
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
group.Operations = append(group.Operations, &removeImageOperation{
|
||||
MachineName: machine.machineName, ImageID: img.ID, Image: ref,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
errs = append(errs, fmt.Errorf("image '%s' not found on any machine", imageArg))
|
||||
}
|
||||
slices.SortFunc(group.Operations, compareImageRemovalOperations)
|
||||
if len(group.Operations) > 0 {
|
||||
plan.GroupPlans = append(plan.GroupPlans, group)
|
||||
}
|
||||
}
|
||||
|
||||
planDigestRemovals(plan, inventory)
|
||||
if err := errors.Join(errs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func imageRepositoryReferences(refs []string, repository string) []string {
|
||||
var matches []string
|
||||
for _, ref := range refs {
|
||||
if named, err := reference.ParseDockerRef(ref); err == nil && named.Name() == repository {
|
||||
matches = append(matches, ref)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
// planDigestRemovals includes stored digests with the group removing their last repository tag.
|
||||
// RepoDigests also contains synthesised references, so only digest names stored in RepoTags get operations here.
|
||||
func planDigestRemovals(plan *imageRemovalPlan, inventory []*machineImageInventory) {
|
||||
type repository struct {
|
||||
remainingTags int
|
||||
digests []string
|
||||
}
|
||||
repositories := make(map[string]*repository)
|
||||
for _, machine := range inventory {
|
||||
for _, img := range machine.images {
|
||||
names := slices.Clone(img.RepoTags)
|
||||
slices.Sort(names)
|
||||
for _, ref := range slices.Compact(names) {
|
||||
named, err := reference.ParseDockerRef(ref)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
key := machine.machineName + "\x00" + img.ID + "\x00" + named.Name()
|
||||
repo := repositories[key]
|
||||
if repo == nil {
|
||||
repo = &repository{}
|
||||
repositories[key] = repo
|
||||
}
|
||||
if _, tagged := named.(reference.Tagged); tagged {
|
||||
repo.remainingTags++
|
||||
} else if _, digest := named.(reference.Canonical); digest {
|
||||
repo.digests = append(repo.digests, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
emitted := make(map[string]bool)
|
||||
var groups []*removeImageGroupPlan
|
||||
for _, group := range plan.GroupPlans {
|
||||
var operations []*removeImageOperation
|
||||
for _, op := range group.Operations {
|
||||
key := op.MachineName + "\x00" + op.Image
|
||||
if emitted[key] {
|
||||
continue
|
||||
}
|
||||
emitted[key] = true
|
||||
operations = append(operations, op)
|
||||
named, err := reference.ParseDockerRef(op.Image)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, tagged := named.(reference.Tagged); !tagged {
|
||||
continue
|
||||
}
|
||||
repo := repositories[op.MachineName+"\x00"+op.ImageID+"\x00"+named.Name()]
|
||||
if repo == nil {
|
||||
continue
|
||||
}
|
||||
repo.remainingTags--
|
||||
if repo.remainingTags != 0 {
|
||||
continue
|
||||
}
|
||||
for _, digest := range repo.digests {
|
||||
key := op.MachineName + "\x00" + digest
|
||||
if !emitted[key] {
|
||||
emitted[key] = true
|
||||
operations = append(operations, &removeImageOperation{
|
||||
MachineName: op.MachineName, ImageID: op.ImageID, Image: digest,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
group.Operations = operations
|
||||
slices.SortFunc(group.Operations, compareImageRemovalOperations)
|
||||
if len(group.Operations) > 0 {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
plan.GroupPlans = groups
|
||||
}
|
||||
|
||||
func compareImageRemovalOperations(a, b *removeImageOperation) int {
|
||||
aDigest, bDigest := strings.Contains(a.Image, "@"), strings.Contains(b.Image, "@")
|
||||
if aDigest != bDigest {
|
||||
if aDigest {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if c := strings.Compare(a.Image, b.Image); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.MachineName, b.MachineName)
|
||||
}
|
||||
|
||||
func imageIDMatches(id, prefix string) bool {
|
||||
return strings.HasPrefix(strings.TrimPrefix(id, "sha256:"), strings.TrimPrefix(prefix, "sha256:"))
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPlanImageRemovalFromInventory_Selectors(t *testing.T) {
|
||||
idA, idB := testImageID("a"), testImageID("b")
|
||||
digestRef := "myapp@" + testImageID("c")
|
||||
tests := []struct {
|
||||
name string
|
||||
selector string
|
||||
inventory []*machineImageInventory
|
||||
want []removeImageOperation
|
||||
}{
|
||||
{
|
||||
name: "repository selects all stored names across images and machines", selector: "myapp",
|
||||
inventory: []*machineImageInventory{
|
||||
testMachineInventory("dev1", []image.Summary{
|
||||
testImage(idA, "myapp:old", "other:keep"),
|
||||
testImage(idB, "myapp:latest"),
|
||||
testImage(testImageID("c"), digestRef),
|
||||
testImage(testImageID("d"), "myapp-extra:latest", "team/myapp:latest", "myapp/worker:latest"),
|
||||
}),
|
||||
testMachineInventory("machine2", []image.Summary{testImage(idB, "myapp:test")}),
|
||||
},
|
||||
want: []removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: idB, Image: "myapp:latest"},
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
||||
{MachineName: "machine2", ImageID: idB, Image: "myapp:test"},
|
||||
{MachineName: "dev1", ImageID: testImageID("c"), Image: digestRef},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "repository without latest includes its tags and stored digest", selector: "myapp",
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
||||
testImage(idA, "myapp:old", digestRef, "other:keep"),
|
||||
})},
|
||||
want: []removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
||||
{MachineName: "dev1", ImageID: idA, Image: digestRef},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit latest selects only that tag", selector: "myapp:latest",
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
||||
testImage(idA, "myapp:old", "myapp:latest", digestRef),
|
||||
})},
|
||||
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}},
|
||||
},
|
||||
{
|
||||
name: "qualified repository selects familiar names", selector: "docker.io/library/myapp",
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
||||
testImage(idA, "myapp:old", "myapp:latest", "other:keep"),
|
||||
})},
|
||||
want: []removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "registry port does not count as an explicit tag", selector: "registry.example.com:5000/team/myapp",
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
||||
testImage(idA, "registry.example.com:5000/team/myapp:old", "registry.example.com:5000/team/myapp:new",
|
||||
"registry.example.com:5001/team/myapp:keep", "registry.example.com:5000/team/myapp/worker:keep"),
|
||||
})},
|
||||
want: []removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:new"},
|
||||
{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:old"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit tag with registry port stays exact", selector: "registry.example.com:5000/team/myapp:old",
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
||||
testImage(idA, "registry.example.com:5000/team/myapp:old", "registry.example.com:5000/team/myapp:new"),
|
||||
})},
|
||||
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:old"}},
|
||||
},
|
||||
{
|
||||
name: "tag uses each machines local image and allows missing copies", selector: "myapp:old",
|
||||
inventory: []*machineImageInventory{
|
||||
testMachineInventory("machine2", []image.Summary{testImage(idB, "myapp:old")}),
|
||||
testMachineInventory("empty", nil),
|
||||
testMachineInventory("dev1", []image.Summary{testImage(idA, "myapp:old")}),
|
||||
},
|
||||
want: []removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
||||
{MachineName: "machine2", ImageID: idB, Image: "myapp:old"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "qualified names use familiar references", selector: "docker.io/library/myapp:old",
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
||||
testImage(idA, "myapp:old"),
|
||||
})},
|
||||
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
|
||||
},
|
||||
{
|
||||
name: "full ID expands all repositories and deduplicates references per machine", selector: idA,
|
||||
inventory: []*machineImageInventory{
|
||||
testMachineInventory("dev1", []image.Summary{
|
||||
{
|
||||
ID: idA,
|
||||
RepoTags: []string{"other:keep", "myapp:old", "myapp:old", digestRef, "other@" + testImageID("c")},
|
||||
RepoDigests: []string{
|
||||
digestRef, "other@" + testImageID("c"), "other@" + testImageID("c"), testImageID("c"),
|
||||
},
|
||||
},
|
||||
testImage(idB, "unrelated:keep"),
|
||||
}),
|
||||
testMachineInventory("machine2", []image.Summary{{
|
||||
ID: idA, RepoTags: []string{"myapp:old", digestRef}, RepoDigests: []string{digestRef},
|
||||
}}),
|
||||
},
|
||||
want: []removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
||||
{MachineName: "machine2", ImageID: idA, Image: "myapp:old"},
|
||||
{MachineName: "dev1", ImageID: idA, Image: "other:keep"},
|
||||
{MachineName: "dev1", ImageID: idA, Image: digestRef},
|
||||
{MachineName: "machine2", ImageID: idA, Image: digestRef},
|
||||
{MachineName: "dev1", ImageID: idA, Image: "other@" + testImageID("c")},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "same full ID on multiple machines is unambiguous", selector: "aaaaaaaa",
|
||||
inventory: []*machineImageInventory{
|
||||
testMachineInventory("dev1", []image.Summary{testImage(idA, "myapp:old")}),
|
||||
testMachineInventory("dev1", []image.Summary{testImage(idB, "myapp:latest")}),
|
||||
testMachineInventory("machine2", []image.Summary{testImage(idA, "other:test")}),
|
||||
},
|
||||
want: []removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
||||
{MachineName: "machine2", ImageID: idA, Image: "other:test"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "short ID with algorithm prefix", selector: "sha256:aaaaaaaa",
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1",
|
||||
[]image.Summary{testImage(idA, "myapp:old")})},
|
||||
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
|
||||
},
|
||||
{
|
||||
name: "unnamed image is removed by its full ID", selector: idA,
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{ID: idA}})},
|
||||
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: idA}},
|
||||
},
|
||||
{
|
||||
name: "ID expands only stored names", selector: idA,
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
||||
ID: idA, RepoTags: []string{"myapp:latest"},
|
||||
RepoDigests: []string{"myapp@" + idA, "other@" + idA},
|
||||
}})},
|
||||
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}},
|
||||
},
|
||||
{
|
||||
name: "ID removes an untagged image by ID even if RepoDigests is populated", selector: idA,
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
||||
ID: idA, RepoDigests: []string{digestRef},
|
||||
}})},
|
||||
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: idA}},
|
||||
},
|
||||
{
|
||||
name: "synthesized digest selects repository tags without a digest row", selector: digestRef,
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
||||
ID: idA, RepoTags: []string{"myapp:old", "other:keep", "myapp:latest"},
|
||||
RepoDigests: []string{digestRef, "other@" + testImageID("c")},
|
||||
}})},
|
||||
want: []removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit digest lookup works without stored names", selector: digestRef,
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
||||
ID: idA, RepoDigests: []string{digestRef},
|
||||
}})},
|
||||
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
|
||||
},
|
||||
{
|
||||
name: "synthesized digest resolves by target and repository even if not listed", selector: "myapp@" + idA,
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
||||
ID: idA, RepoTags: []string{"myapp:old", "other:keep"}, RepoDigests: []string{"other@" + idA},
|
||||
}})},
|
||||
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
|
||||
},
|
||||
{
|
||||
name: "stored digest follows its repository tags", selector: digestRef,
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
||||
testImage(idA, "myapp:old", "myapp:latest", digestRef, "other:keep"),
|
||||
testImage(idB, "myapp@"+idB),
|
||||
})},
|
||||
want: []removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
||||
{MachineName: "dev1", ImageID: idA, Image: digestRef},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "digest pattern also expands matching repository tags", selector: "myapp@sha256:c*",
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
||||
testImage(idA, "myapp:old", "myapp:latest", digestRef, "other:keep"),
|
||||
testImage(idB, "myapp@"+idB),
|
||||
})},
|
||||
want: []removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
||||
{MachineName: "dev1", ImageID: idA, Image: digestRef},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "digest pattern matches stored names and ignores derived names", selector: "myapp@sha256:*",
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
||||
testImage(idA, "myapp:old", digestRef),
|
||||
{ID: idB, RepoTags: []string{"myapp:latest"}, RepoDigests: []string{"myapp@" + idB}},
|
||||
})},
|
||||
want: []removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
||||
{MachineName: "dev1", ImageID: idA, Image: digestRef},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "last repository tag includes its stored digest", selector: "myapp:old",
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
||||
testImage(idA, "myapp:old", digestRef, "other:keep"),
|
||||
})},
|
||||
want: []removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
||||
{MachineName: "dev1", ImageID: idA, Image: digestRef},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "last tag does not add a synthesized digest row", selector: "myapp:old",
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
||||
ID: idA, RepoTags: []string{"myapp:old", "other:keep"}, RepoDigests: []string{digestRef},
|
||||
}})},
|
||||
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
|
||||
},
|
||||
{
|
||||
name: "remaining repository tag preserves its stored digest", selector: "myapp:old",
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
||||
testImage(idA, "myapp:old", "myapp:latest", digestRef),
|
||||
})},
|
||||
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
|
||||
},
|
||||
{
|
||||
name: "digest without repository tags remains executable", selector: digestRef,
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
||||
testImage(idA, digestRef, "other:keep"),
|
||||
})},
|
||||
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
|
||||
},
|
||||
{
|
||||
name: "digest-only image keeps its named reference", selector: idA,
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
||||
ID: idA, RepoTags: []string{digestRef}, RepoDigests: []string{digestRef, testImageID("c")},
|
||||
}})},
|
||||
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
|
||||
},
|
||||
{
|
||||
name: "tag pattern selects only matching tags", selector: "myapp:1.[12]",
|
||||
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
||||
ID: idA, RepoTags: []string{"myapp:1.3", "myapp:1.2", "myapp:1.1"}, RepoDigests: []string{digestRef},
|
||||
}})},
|
||||
want: []removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:1.1"},
|
||||
{MachineName: "dev1", ImageID: idA, Image: "myapp:1.2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plan, err := planImageRemovalFromInventory([]string{tt.selector}, tt.inventory)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, plan.GroupPlans, 1)
|
||||
assert.Equal(t, tt.selector, plan.GroupPlans[0].Image)
|
||||
|
||||
var got []removeImageOperation
|
||||
for _, op := range plan.GroupPlans[0].Operations {
|
||||
got = append(got, *op)
|
||||
}
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanImageRemovalFromInventory_OverlappingSelectors(t *testing.T) {
|
||||
inventory := []*machineImageInventory{
|
||||
testMachineInventory("dev1", []image.Summary{testImage(testImageID("a"), "myapp:old", "myapp:latest")}),
|
||||
testMachineInventory("machine2", []image.Summary{testImage(testImageID("b"), "myapp:test", "myapp:old")}),
|
||||
}
|
||||
|
||||
plan, err := planImageRemovalFromInventory([]string{"myapp:old", "myapp", "myapp:*", testImageID("a"), "myapp:old"},
|
||||
inventory)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, &imageRemovalPlan{GroupPlans: []*removeImageGroupPlan{
|
||||
{Image: "myapp:old", Operations: []*removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: testImageID("a"), Image: "myapp:old"},
|
||||
{MachineName: "machine2", ImageID: testImageID("b"), Image: "myapp:old"},
|
||||
}},
|
||||
{Image: "myapp", Operations: []*removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: testImageID("a"), Image: "myapp:latest"},
|
||||
{MachineName: "machine2", ImageID: testImageID("b"), Image: "myapp:test"},
|
||||
}},
|
||||
}}, plan, "the first selector owns each removal and empty groups are omitted")
|
||||
}
|
||||
|
||||
func TestPlanImageRemovalFromInventory_DigestFollowsLastTagAcrossSelectors(t *testing.T) {
|
||||
imageID := testImageID("a")
|
||||
digestRef := "myapp@" + imageID
|
||||
inventory := []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
||||
testImage(imageID, "myapp:old", "myapp:latest", "myapp:old", digestRef, digestRef, "other:keep"),
|
||||
})}
|
||||
|
||||
plan, err := planImageRemovalFromInventory([]string{"myapp:old", "myapp:latest", digestRef}, inventory)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, &imageRemovalPlan{GroupPlans: []*removeImageGroupPlan{
|
||||
{Image: "myapp:old", Operations: []*removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: imageID, Image: "myapp:old"},
|
||||
}},
|
||||
{Image: "myapp:latest", Operations: []*removeImageOperation{
|
||||
{MachineName: "dev1", ImageID: imageID, Image: "myapp:latest"},
|
||||
{MachineName: "dev1", ImageID: imageID, Image: digestRef},
|
||||
}},
|
||||
}}, plan, "digest cleanup belongs to the last tag removal, so the later digest selector has no extra operation")
|
||||
}
|
||||
|
||||
func TestPlanImageRemovalFromInventory_RejectsInvalidOrMissingSelectors(t *testing.T) {
|
||||
idA := "sha256:abcdef" + strings.Repeat("a", 58)
|
||||
idB := "sha256:abcdef" + strings.Repeat("b", 58)
|
||||
inventory := []*machineImageInventory{
|
||||
testMachineInventory("dev1", []image.Summary{{
|
||||
ID: idA, RepoTags: []string{"myapp:old"}, RepoDigests: []string{"myapp@" + idA, "derived@" + idA},
|
||||
}}),
|
||||
testMachineInventory("machine2", []image.Summary{{ID: idB, RepoTags: []string{"myapp:old"}}}),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
images []string
|
||||
err string
|
||||
}{
|
||||
{"missing repository", []string{"missing"}, "not found"},
|
||||
{"repository only has a derived digest", []string{"derived"}, "not found"},
|
||||
{"missing tag", []string{"myapp:missing"}, "not found"},
|
||||
{"missing pattern", []string{"other:*"}, "not found"},
|
||||
{"pattern only matches a derived digest", []string{"myapp@sha256:*"}, "not found"},
|
||||
{"missing ID", []string{testImageID("c")}, "not found"},
|
||||
{"ambiguous ID prefix", []string{"abcdef"}, "ambiguous"},
|
||||
{"invalid name", []string{"MyApp:old"}, "invalid image"},
|
||||
{"invalid pattern", []string{"myapp:["}, "invalid image pattern"},
|
||||
{"one missing selector rejects the whole plan", []string{"myapp:old", "myapp:missing"}, "not found"},
|
||||
{"one invalid selector rejects the whole plan", []string{"myapp:old", "myapp:["}, "invalid image pattern"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plan, err := planImageRemovalFromInventory(tt.images, inventory)
|
||||
require.ErrorContains(t, err, tt.err)
|
||||
assert.Nil(t, plan, "a failed selector must not return a partial plan")
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("ID prefix resolves within the supplied machines", func(t *testing.T) {
|
||||
plan, err := planImageRemovalFromInventory([]string{"abcdef"}, inventory[:1])
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plan.GroupPlans, 1)
|
||||
require.Len(t, plan.GroupPlans[0].Operations, 1)
|
||||
assert.Equal(t, idA, plan.GroupPlans[0].Operations[0].ImageID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPlanImageRemovalFromInventory_InUseImagesAreStillPlanned(t *testing.T) {
|
||||
img := testImage(testImageID("a"), "myapp:old", "myapp:current")
|
||||
img.Containers = 1
|
||||
|
||||
for _, selector := range []string{"myapp", "myapp:current", img.ID, "myapp:*"} {
|
||||
t.Run(selector, func(t *testing.T) {
|
||||
plan, err := planImageRemovalFromInventory([]string{selector}, []*machineImageInventory{
|
||||
testMachineInventory("dev1", []image.Summary{img}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, plan.IsEmpty(), "usage protection belongs to the backend at execution time")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testImageID(char string) string { return "sha256:" + strings.Repeat(char, 64) }
|
||||
|
||||
func testImage(imageID string, tags ...string) image.Summary {
|
||||
return image.Summary{ID: imageID, RepoTags: tags}
|
||||
}
|
||||
|
||||
func testMachineInventory(name string, images []image.Summary) *machineImageInventory {
|
||||
return &machineImageInventory{machineName: name, images: images}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ func NewRootCommand() *cobra.Command {
|
||||
cmd.AddCommand(
|
||||
NewListCommand(),
|
||||
NewPushCommand(),
|
||||
NewRemoveCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
||||
+4
-22
@@ -187,14 +187,12 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
|
||||
}
|
||||
fmt.Println("Machine joined the cluster.")
|
||||
|
||||
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
||||
// that may cause a small downtime.
|
||||
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
|
||||
// use the deployed image version.
|
||||
// Deploy the current caddy service spec to run a container on the added machine. This keeps the service
|
||||
// configuration however it was deployed and shouldn't change the containers on other machines if specs on them
|
||||
// haven't diverged.
|
||||
// NOTE: We use the cluster client to inspect and scale the Caddy service because the newly added machine may have
|
||||
// issues accessing the API of existing machines in the cluster.
|
||||
// See the issue for more details: https://github.com/psviderski/uncloud/issues/65.
|
||||
caddyImage := ""
|
||||
caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrNotFound) {
|
||||
@@ -203,26 +201,10 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
|
||||
}
|
||||
return fmt.Errorf("inspect caddy service: %w", err)
|
||||
}
|
||||
caddyImage = caddySvc.Containers[0].Container.Config.Image
|
||||
// Find the latest created container and use its image.
|
||||
var latestCreated time.Time
|
||||
for _, c := range caddySvc.Containers[1:] {
|
||||
created, err := time.Parse(time.RFC3339Nano, c.Container.Created)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if created.After(latestCreated) {
|
||||
latestCreated = created
|
||||
caddyImage = c.Container.Config.Image
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Preparing Caddy deployment...")
|
||||
d, err := clusterClient.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{Image: caddyImage})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create caddy deployment: %w", err)
|
||||
}
|
||||
d := clusterClient.NewDeployment(caddySvc.Spec(), nil)
|
||||
|
||||
plan, err := d.Plan(ctx)
|
||||
if err != nil {
|
||||
|
||||
+12
-7
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
@@ -64,6 +65,15 @@ If no services are specified, streams logs from the uncloud service.`,
|
||||
}
|
||||
|
||||
func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.Options) error {
|
||||
since, until, err := logs.TimeRange(opts.Since, opts.Until, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tail, err := opts.TailLines()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
services = []string{api.SystemServiceUncloud}
|
||||
}
|
||||
@@ -74,11 +84,6 @@ func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.O
|
||||
}
|
||||
}
|
||||
|
||||
tail, err := logs.Tail(opts.Tail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
@@ -88,8 +93,8 @@ func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.O
|
||||
logsOpts := api.ServiceLogsOptions{
|
||||
Follow: opts.Follow,
|
||||
Tail: tail,
|
||||
Since: opts.Since,
|
||||
Until: opts.Until,
|
||||
Since: since,
|
||||
Until: until,
|
||||
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -54,8 +54,8 @@ func rtt(ctx context.Context, uncli *cli.CLI) error {
|
||||
tui.PrintWarning("metadata is missing in response from unknown server")
|
||||
continue
|
||||
}
|
||||
if m.Metadata.Error != "" {
|
||||
tui.PrintWarning(fmt.Sprintf("failed to inspect machine '%s': %s", m.Metadata.MachineName, m.Metadata.Error))
|
||||
if err := m.Metadata.Err(); err != nil {
|
||||
tui.PrintWarning(fmt.Sprintf("failed to inspect machine '%s': %s", m.Metadata.MachineName, err))
|
||||
continue
|
||||
}
|
||||
if m.Machine == nil {
|
||||
|
||||
+2
-2
@@ -207,9 +207,9 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo
|
||||
|
||||
machineName := msc.Metadata.MachineName
|
||||
|
||||
if msc.Metadata.Error != "" {
|
||||
if err := msc.Metadata.Err(); err != nil {
|
||||
tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine %s: %s",
|
||||
machineName, msc.Metadata.Error))
|
||||
machineName, err))
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
@@ -58,6 +59,16 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||
fmt.Printf("Service ID: %s\n", svc.ID)
|
||||
fmt.Printf("Name: %s\n", svc.Name)
|
||||
fmt.Printf("Mode: %s\n", svc.Mode)
|
||||
|
||||
spec := svc.Spec()
|
||||
if len(spec.Labels) > 0 {
|
||||
fmt.Println("Labels:")
|
||||
printLabels(spec.Labels)
|
||||
}
|
||||
if len(spec.Container.Labels) > 0 {
|
||||
fmt.Println("Container labels:")
|
||||
printLabels(spec.Container.Labels)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Combine regular and hook containers.
|
||||
@@ -130,3 +141,10 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||
lipgloss.Println(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// printLabels prints the labels sorted by key followed by a blank line.
|
||||
func printLabels(labels map[string]string) {
|
||||
for _, key := range slices.Sorted(maps.Keys(labels)) {
|
||||
lipgloss.Println(" " + key + tui.Faint.Render("=") + labels[key])
|
||||
}
|
||||
}
|
||||
|
||||
+12
-8
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mapset "github.com/deckarep/golang-set/v2"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
@@ -78,6 +79,15 @@ If no services are specified, streams logs from all services defined in the Comp
|
||||
}
|
||||
|
||||
func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Options) error {
|
||||
since, until, err := logs.TimeRange(opts.Since, opts.Until, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tail, err := opts.TailLines()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serviceArgs, err := logs.ParseServiceArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -106,12 +116,6 @@ func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Optio
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tail option.
|
||||
tail, err := logs.Tail(opts.Tail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
@@ -121,8 +125,8 @@ func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Optio
|
||||
baseOpts := api.ServiceLogsOptions{
|
||||
Follow: opts.Follow,
|
||||
Tail: tail,
|
||||
Since: opts.Since,
|
||||
Until: opts.Until,
|
||||
Since: since,
|
||||
Until: until,
|
||||
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ func NewRootCommand() *cobra.Command {
|
||||
NewScaleCommand(""),
|
||||
NewStartCommand(""),
|
||||
NewStopCommand(""),
|
||||
NewUpdateCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
+53
-7
@@ -21,13 +21,16 @@ import (
|
||||
type runOptions struct {
|
||||
caddyfile string
|
||||
command []string
|
||||
containerLabels []string
|
||||
cpu dockeropts.NanoCPUs
|
||||
entrypoint string
|
||||
entrypointChanged bool
|
||||
env []string
|
||||
image string
|
||||
labels []string
|
||||
machines []string
|
||||
memory dockeropts.MemBytes
|
||||
memoryReservation dockeropts.MemBytes
|
||||
mode string
|
||||
shmSize dockeropts.MemBytes
|
||||
name string
|
||||
@@ -64,6 +67,9 @@ func NewRunCommand(groupID string) *cobra.Command {
|
||||
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
|
||||
"Path to a custom Caddy config (Caddyfile) for the service. "+
|
||||
"Cannot be used together with non-@host published ports.")
|
||||
cmd.Flags().StringArrayVar(&opts.containerLabels, "container-label", nil,
|
||||
"Set a Docker label on service containers. Can be specified multiple times.\n"+
|
||||
"Format: key=value or just key for an empty value.")
|
||||
cmd.Flags().VarP(&opts.cpu, "cpu", "",
|
||||
"Maximum number of CPU cores a service container can use. Fractional values are allowed: "+
|
||||
"0.5 for half a core or 2.25 for two and a quarter cores.")
|
||||
@@ -72,6 +78,9 @@ func NewRunCommand(groupID string) *cobra.Command {
|
||||
cmd.Flags().StringSliceVarP(&opts.env, "env", "e", nil,
|
||||
"Set an environment variable for service containers. Can be specified multiple times.\n"+
|
||||
"Format: VAR=value or just VAR to use the value from the local environment.")
|
||||
cmd.Flags().StringArrayVarP(&opts.labels, "label", "l", nil,
|
||||
"Set a service label. Service labels aren't applied to Docker containers, use --container-label for that.\n"+
|
||||
"Can be specified multiple times. Format: key=value or just key for an empty value.")
|
||||
cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated,
|
||||
fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+
|
||||
"the machines) or '%s' (one container on every machine).",
|
||||
@@ -83,6 +92,9 @@ func NewRunCommand(groupID string) *cobra.Command {
|
||||
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+
|
||||
"Default unit is bytes if no suffix specified.\n"+
|
||||
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
|
||||
cmd.Flags().Var(&opts.memoryReservation, "memory-reservation",
|
||||
"Minimum amount of memory reserved for a service container. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+
|
||||
"Default unit is bytes if no suffix specified.")
|
||||
cmd.Flags().VarP(&opts.shmSize, "shm-size", "",
|
||||
"Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer\n"+
|
||||
"with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.\n"+
|
||||
@@ -94,11 +106,12 @@ func NewRunCommand(groupID string) *cobra.Command {
|
||||
cmd.Flags().StringSliceVarP(&opts.publish, "publish", "p", nil,
|
||||
"Publish a service port to make it accessible outside the cluster. Can be specified multiple times.\n"+
|
||||
"Format: [hostname:]container_port[/protocol] or [host_ip|host_prefix:]host_port:container_port[/protocol]@host\n"+
|
||||
"Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified\n"+
|
||||
"and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.\n"+
|
||||
"Supported protocols: tcp, udp, http, https. The protocol defaults to https if a hostname is specified,\n"+
|
||||
"otherwise tcp. http(s) ports without a hostname use 'service-name.cluster-domain' as the default hostname\n"+
|
||||
"if a cluster domain is reserved.\n"+
|
||||
"Examples:\n"+
|
||||
" -p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname\n"+
|
||||
" -p app.example.com:8080/https Publish port 8080 as HTTPS via reverse proxy with custom hostname\n"+
|
||||
" -p app.example.com:8080 Publish port 8080 as HTTPS via reverse proxy with custom hostname\n"+
|
||||
// TODO: add support for publishing L4 tcp/udp ports.
|
||||
//" -p 9000:8080 Publish port 8080 as TCP port 9000 via reverse proxy\n"+
|
||||
" -p 53:5353/udp@host Bind UDP port 5353 to host port 53\n"+
|
||||
@@ -192,6 +205,18 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
||||
return spec, err
|
||||
}
|
||||
|
||||
labels, err := parseLabels(opts.labels)
|
||||
if err != nil {
|
||||
return spec, err
|
||||
}
|
||||
if err = api.ValidateLabels(labels); err != nil {
|
||||
return spec, fmt.Errorf("invalid service labels: %w", err)
|
||||
}
|
||||
containerLabels, err := parseLabels(opts.containerLabels)
|
||||
if err != nil {
|
||||
return spec, err
|
||||
}
|
||||
|
||||
switch opts.mode {
|
||||
case api.ServiceModeReplicated, api.ServiceModeGlobal:
|
||||
default:
|
||||
@@ -232,17 +257,20 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
||||
Command: opts.command,
|
||||
Env: env,
|
||||
Image: opts.image,
|
||||
Labels: containerLabels,
|
||||
Privileged: opts.privileged,
|
||||
PullPolicy: opts.pull,
|
||||
Resources: api.ContainerResources{
|
||||
CPU: opts.cpu.Value(),
|
||||
Memory: opts.memory.Value(),
|
||||
SharedMemory: opts.shmSize.Value(),
|
||||
Ulimits: ulimits,
|
||||
CPU: opts.cpu.Value(),
|
||||
Memory: opts.memory.Value(),
|
||||
MemoryReservation: opts.memoryReservation.Value(),
|
||||
SharedMemory: opts.shmSize.Value(),
|
||||
Ulimits: ulimits,
|
||||
},
|
||||
User: opts.user,
|
||||
VolumeMounts: mounts,
|
||||
},
|
||||
Labels: labels,
|
||||
Mode: opts.mode,
|
||||
Name: opts.name,
|
||||
Placement: placement,
|
||||
@@ -301,6 +329,24 @@ func parseEnv(env []string) (api.EnvVars, error) {
|
||||
return envVars, nil
|
||||
}
|
||||
|
||||
// parseLabels parses labels from the command line arguments in the format "key=value" or just "key" for an empty value.
|
||||
func parseLabels(labels []string) (map[string]string, error) {
|
||||
if len(labels) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(labels))
|
||||
for _, label := range labels {
|
||||
key, value, _ := strings.Cut(label, "=")
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return nil, fmt.Errorf("invalid label '%s': key must not be empty", label)
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseVolumeFlags parses volume flag values in Docker CLI format and returns VolumeSpecs and VolumeMounts.
|
||||
// It handles both named volumes (volume_name:/container/path[:ro|volume-nocopy])
|
||||
// and bind mounts (/host/path:/container/path[:ro]).
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/go-units"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// validRunOptions returns the minimal options that produce a valid service spec.
|
||||
func validRunOptions() runOptions {
|
||||
return runOptions{
|
||||
image: "nginx:1.27",
|
||||
name: "web",
|
||||
mode: api.ServiceModeReplicated,
|
||||
pull: api.PullPolicyMissing,
|
||||
replicas: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLabels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
labels, err := parseLabels(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, labels)
|
||||
|
||||
labels, err = parseLabels([]string{"tier=frontend", "empty", "list=a,b", "expr=a=b", "tier=backend"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{
|
||||
"tier": "backend",
|
||||
"empty": "",
|
||||
"list": "a,b",
|
||||
"expr": "a=b",
|
||||
}, labels)
|
||||
|
||||
_, err = parseLabels([]string{"=value"})
|
||||
require.ErrorContains(t, err, "key must not be empty")
|
||||
|
||||
_, err = parseLabels([]string{" \t =value"})
|
||||
require.ErrorContains(t, err, "key must not be empty")
|
||||
|
||||
labels, err = parseLabels([]string{"key=value with spaces"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"key": "value with spaces"}, labels)
|
||||
}
|
||||
|
||||
func TestPrepareServiceSpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("minimal options", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := prepareServiceSpec(validRunOptions())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "web", spec.Name)
|
||||
assert.Equal(t, api.ServiceModeReplicated, spec.Mode)
|
||||
assert.EqualValues(t, 1, spec.Replicas)
|
||||
assert.Equal(t, "nginx:1.27", spec.Container.Image)
|
||||
assert.Equal(t, api.PullPolicyMissing, spec.Container.PullPolicy)
|
||||
assert.Nil(t, spec.Caddy)
|
||||
assert.Nil(t, spec.Labels)
|
||||
assert.Nil(t, spec.Container.Labels)
|
||||
assert.Nil(t, spec.Container.Entrypoint)
|
||||
assert.Nil(t, spec.Container.Resources.Ulimits)
|
||||
assert.Nil(t, spec.Placement.Machines)
|
||||
assert.Empty(t, spec.Container.Env)
|
||||
assert.Empty(t, spec.Ports)
|
||||
assert.Empty(t, spec.Volumes)
|
||||
assert.Empty(t, spec.Container.VolumeMounts)
|
||||
})
|
||||
|
||||
t.Run("all options", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := validRunOptions()
|
||||
opts.command = []string{"nginx", "-g", "daemon off;"}
|
||||
opts.env = []string{"FOO=bar", "EMPTY=", "EXPR=a=b"}
|
||||
opts.labels = []string{"tier=frontend"}
|
||||
opts.containerLabels = []string{"logging=enabled"}
|
||||
opts.machines = []string{"m1,m2", "m3"}
|
||||
opts.privileged = true
|
||||
opts.pull = api.PullPolicyAlways
|
||||
opts.publish = []string{"app.example.com:8080/https", "53:5353/udp@host"}
|
||||
opts.replicas = 3
|
||||
opts.ulimits = []string{"nofile=1024:2048", "nproc=65535"}
|
||||
opts.user = "1000:1000"
|
||||
opts.volumes = []string{"data:/var/lib/data:ro"}
|
||||
require.NoError(t, opts.cpu.Set("0.5"))
|
||||
require.NoError(t, opts.memory.Set("128m"))
|
||||
require.NoError(t, opts.memoryReservation.Set("32m"))
|
||||
require.NoError(t, opts.shmSize.Set("64m"))
|
||||
|
||||
spec, err := prepareServiceSpec(opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Command: []string{"nginx", "-g", "daemon off;"},
|
||||
Env: api.EnvVars{"FOO": "bar", "EMPTY": "", "EXPR": "a=b"},
|
||||
Image: "nginx:1.27",
|
||||
Labels: map[string]string{"logging": "enabled"},
|
||||
Privileged: true,
|
||||
PullPolicy: api.PullPolicyAlways,
|
||||
Resources: api.ContainerResources{
|
||||
CPU: 500 * api.MilliCore,
|
||||
Memory: 128 * units.MiB,
|
||||
MemoryReservation: 32 * units.MiB,
|
||||
SharedMemory: 64 * units.MiB,
|
||||
Ulimits: map[string]api.Ulimit{
|
||||
"nofile": {Soft: 1024, Hard: 2048},
|
||||
"nproc": {Soft: 65535, Hard: 65535},
|
||||
},
|
||||
},
|
||||
User: "1000:1000",
|
||||
VolumeMounts: []api.VolumeMount{
|
||||
{VolumeName: "data", ContainerPath: "/var/lib/data", ReadOnly: true},
|
||||
},
|
||||
},
|
||||
Labels: map[string]string{"tier": "frontend"},
|
||||
Mode: api.ServiceModeReplicated,
|
||||
Name: "web",
|
||||
Placement: api.Placement{Machines: []string{"m1", "m2", "m3"}},
|
||||
Ports: []api.PortSpec{
|
||||
{
|
||||
Hostname: "app.example.com",
|
||||
ContainerPort: 8080,
|
||||
Protocol: api.ProtocolHTTPS,
|
||||
Mode: api.PortModeIngress,
|
||||
},
|
||||
{
|
||||
PublishedPort: 53,
|
||||
ContainerPort: 5353,
|
||||
Protocol: api.ProtocolUDP,
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
},
|
||||
Replicas: 3,
|
||||
Volumes: []api.VolumeSpec{
|
||||
{
|
||||
Name: "data",
|
||||
Type: api.VolumeTypeVolume,
|
||||
VolumeOptions: &api.VolumeOptions{Name: "data"},
|
||||
},
|
||||
},
|
||||
}, spec)
|
||||
})
|
||||
|
||||
t.Run("generates a name when not specified", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := validRunOptions()
|
||||
opts.name = ""
|
||||
opts.image = "bitnami/nginx:latest"
|
||||
|
||||
spec, err := prepareServiceSpec(opts)
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, `^nginx-[a-z0-9]{4}$`, spec.Name)
|
||||
})
|
||||
|
||||
t.Run("keeps the specified name", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := validRunOptions()
|
||||
opts.name = "custom"
|
||||
|
||||
spec, err := prepareServiceSpec(opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "custom", spec.Name)
|
||||
})
|
||||
|
||||
t.Run("global mode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := validRunOptions()
|
||||
opts.mode = api.ServiceModeGlobal
|
||||
|
||||
spec, err := prepareServiceSpec(opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, api.ServiceModeGlobal, spec.Mode)
|
||||
})
|
||||
|
||||
t.Run("entrypoint", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := validRunOptions()
|
||||
opts.entrypoint = "/bin/sh"
|
||||
opts.entrypointChanged = true
|
||||
spec, err := prepareServiceSpec(opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"/bin/sh"}, spec.Container.Entrypoint)
|
||||
|
||||
// An explicitly empty entrypoint resets the one from the image.
|
||||
opts = validRunOptions()
|
||||
opts.entrypointChanged = true
|
||||
spec, err = prepareServiceSpec(opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{""}, spec.Container.Entrypoint)
|
||||
})
|
||||
|
||||
t.Run("env without a value is omitted when not set locally", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := validRunOptions()
|
||||
opts.env = []string{"UNCLOUD_TEST_ENV_THAT_IS_NOT_SET"}
|
||||
|
||||
spec, err := prepareServiceSpec(opts)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, spec.Container.Env, "UNCLOUD_TEST_ENV_THAT_IS_NOT_SET")
|
||||
})
|
||||
|
||||
t.Run("bind mount", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := validRunOptions()
|
||||
opts.volumes = []string{"/data/uploads:/app/uploads"}
|
||||
|
||||
spec, err := prepareServiceSpec(opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, spec.Volumes, 1)
|
||||
assert.Regexp(t, `^bind-[a-z0-9]{4}$`, spec.Volumes[0].Name)
|
||||
assert.Equal(t, api.VolumeTypeBind, spec.Volumes[0].Type)
|
||||
require.NotNil(t, spec.Volumes[0].BindOptions)
|
||||
assert.Equal(t, "/data/uploads", spec.Volumes[0].BindOptions.HostPath)
|
||||
assert.True(t, spec.Volumes[0].BindOptions.CreateHostPath)
|
||||
|
||||
require.Len(t, spec.Container.VolumeMounts, 1)
|
||||
assert.Equal(t, spec.Volumes[0].Name, spec.Container.VolumeMounts[0].VolumeName)
|
||||
assert.Equal(t, "/app/uploads", spec.Container.VolumeMounts[0].ContainerPath)
|
||||
})
|
||||
|
||||
t.Run("named volume mounted twice creates one volume", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := validRunOptions()
|
||||
opts.volumes = []string{"data:/a", "data:/b"}
|
||||
|
||||
spec, err := prepareServiceSpec(opts)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, spec.Volumes, 1)
|
||||
assert.Len(t, spec.Container.VolumeMounts, 2)
|
||||
})
|
||||
|
||||
t.Run("caddyfile content is trimmed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "Caddyfile")
|
||||
require.NoError(t, os.WriteFile(path, []byte("\nexample.com {\n}\n\n"), 0o600))
|
||||
|
||||
opts := validRunOptions()
|
||||
opts.caddyfile = path
|
||||
|
||||
spec, err := prepareServiceSpec(opts)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, spec.Caddy)
|
||||
assert.Equal(t, "example.com {\n}", spec.Caddy.Config)
|
||||
})
|
||||
|
||||
t.Run("empty caddyfile is ignored", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "Caddyfile")
|
||||
require.NoError(t, os.WriteFile(path, []byte(" \n"), 0o600))
|
||||
|
||||
opts := validRunOptions()
|
||||
opts.caddyfile = path
|
||||
|
||||
spec, err := prepareServiceSpec(opts)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, spec.Caddy)
|
||||
})
|
||||
|
||||
t.Run("caddyfile can be used with host ports", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "Caddyfile")
|
||||
require.NoError(t, os.WriteFile(path, []byte("example.com {\n}"), 0o600))
|
||||
|
||||
opts := validRunOptions()
|
||||
opts.caddyfile = path
|
||||
opts.publish = []string{"53:5353/udp@host"}
|
||||
|
||||
_, err := prepareServiceSpec(opts)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrepareServiceSpec_LocalEnv(t *testing.T) {
|
||||
// Not parallel because t.Setenv changes the environment of the whole process.
|
||||
t.Setenv("UNCLOUD_TEST_LOCAL_ENV", "from-local")
|
||||
|
||||
opts := validRunOptions()
|
||||
opts.env = []string{"UNCLOUD_TEST_LOCAL_ENV"}
|
||||
|
||||
spec, err := prepareServiceSpec(opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "from-local", spec.Container.Env["UNCLOUD_TEST_LOCAL_ENV"])
|
||||
}
|
||||
|
||||
func TestPrepareServiceSpec_Invalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
caddyfile := filepath.Join(t.TempDir(), "Caddyfile")
|
||||
require.NoError(t, os.WriteFile(caddyfile, []byte("example.com {\n}"), 0o600))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
modify func(opts *runOptions)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "invalid image",
|
||||
modify: func(opts *runOptions) { opts.image = "INVALID IMAGE" },
|
||||
wantErr: "invalid image",
|
||||
},
|
||||
{
|
||||
name: "invalid name",
|
||||
modify: func(opts *runOptions) { opts.name = "Not_Valid" },
|
||||
wantErr: "invalid service name",
|
||||
},
|
||||
{
|
||||
name: "invalid mode",
|
||||
modify: func(opts *runOptions) { opts.mode = "unknown" },
|
||||
wantErr: "invalid replication mode: 'unknown'",
|
||||
},
|
||||
{
|
||||
name: "invalid pull policy",
|
||||
modify: func(opts *runOptions) { opts.pull = "sometimes" },
|
||||
wantErr: "invalid pull policy: 'sometimes'",
|
||||
},
|
||||
{
|
||||
name: "invalid env",
|
||||
modify: func(opts *runOptions) { opts.env = []string{"=value"} },
|
||||
wantErr: "invalid environment variable: '=value'",
|
||||
},
|
||||
{
|
||||
name: "invalid port",
|
||||
modify: func(opts *runOptions) { opts.publish = []string{"not-a-port"} },
|
||||
wantErr: "invalid service port 'not-a-port'",
|
||||
},
|
||||
{
|
||||
name: "invalid ulimit",
|
||||
modify: func(opts *runOptions) { opts.ulimits = []string{"nofile"} },
|
||||
wantErr: "invalid ulimit 'nofile'",
|
||||
},
|
||||
{
|
||||
name: "volume without a container path",
|
||||
modify: func(opts *runOptions) { opts.volumes = []string{"data"} },
|
||||
wantErr: "invalid volume mount 'data'",
|
||||
},
|
||||
{
|
||||
name: "volume with a relative container path",
|
||||
modify: func(opts *runOptions) { opts.volumes = []string{"data:relative"} },
|
||||
wantErr: "must be absolute path",
|
||||
},
|
||||
{
|
||||
name: "volume with an unknown option",
|
||||
modify: func(opts *runOptions) { opts.volumes = []string{"data:/data:unknown"} },
|
||||
wantErr: "invalid option: 'unknown'",
|
||||
},
|
||||
{
|
||||
name: "missing caddyfile",
|
||||
modify: func(opts *runOptions) { opts.caddyfile = filepath.Join(os.TempDir(), "uncloud-test-no-such-file") },
|
||||
wantErr: "read Caddyfile",
|
||||
},
|
||||
{
|
||||
name: "caddyfile with an ingress port",
|
||||
modify: func(opts *runOptions) {
|
||||
opts.caddyfile = caddyfile
|
||||
opts.publish = []string{"app.example.com:8080/https"}
|
||||
},
|
||||
wantErr: "cannot be specified simultaneously",
|
||||
},
|
||||
{
|
||||
name: "service label without a key",
|
||||
modify: func(opts *runOptions) { opts.labels = []string{"=value"} },
|
||||
wantErr: "key must not be empty",
|
||||
},
|
||||
{
|
||||
name: "container label without a key",
|
||||
modify: func(opts *runOptions) { opts.containerLabels = []string{"=value"} },
|
||||
wantErr: "key must not be empty",
|
||||
},
|
||||
{
|
||||
name: "service label with whitespace",
|
||||
modify: func(opts *runOptions) { opts.labels = []string{"my label=value"} },
|
||||
wantErr: "invalid service labels: label 'my label' must not contain whitespace",
|
||||
},
|
||||
{
|
||||
name: "container label with whitespace",
|
||||
modify: func(opts *runOptions) { opts.containerLabels = []string{"my label=value"} },
|
||||
wantErr: "invalid container labels: label 'my label' must not contain whitespace",
|
||||
},
|
||||
{
|
||||
name: "reserved service label",
|
||||
modify: func(opts *runOptions) { opts.labels = []string{api.LabelManaged + "=true"} },
|
||||
wantErr: "invalid service labels",
|
||||
},
|
||||
{
|
||||
name: "reserved container label",
|
||||
modify: func(opts *runOptions) { opts.containerLabels = []string{api.LabelManaged + "=true"} },
|
||||
wantErr: "invalid container labels",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := validRunOptions()
|
||||
tt.modify(&opts)
|
||||
|
||||
_, err := prepareServiceSpec(opts)
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
dockeropts "github.com/docker/cli/opts"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"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/deploy"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type updateOptions struct {
|
||||
service string
|
||||
|
||||
cpu dockeropts.NanoCPUs
|
||||
cpuChanged bool
|
||||
memory dockeropts.MemBytes
|
||||
memoryChanged bool
|
||||
memoryReservation dockeropts.MemBytes
|
||||
reservationChanged bool
|
||||
labelAdd []string
|
||||
labelRm []string
|
||||
}
|
||||
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
opts := updateOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "update SERVICE",
|
||||
Short: "Update a service in place without recreating its containers.",
|
||||
Long: `Update service configuration in place without recreating its containers.
|
||||
|
||||
A CPU or memory limit can be changed but not removed. To remove a limit or change anything else,
|
||||
redeploy the service with 'uc deploy'`,
|
||||
Example: ` # Change the CPU and memory limits.
|
||||
uc service update web --cpu 0.5 --memory 512m
|
||||
|
||||
# Add or change a service label and remove another one.
|
||||
uc service update web --label-add tier=frontend --label-rm legacy`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
opts.service = args[0]
|
||||
opts.cpuChanged = cmd.Flag("cpu").Changed
|
||||
opts.memoryChanged = cmd.Flag("memory").Changed
|
||||
opts.reservationChanged = cmd.Flag("memory-reservation").Changed
|
||||
|
||||
return update(cmd.Context(), uncli, opts)
|
||||
},
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Services(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Var(&opts.cpu, "cpu",
|
||||
"Maximum number of CPU cores a service container can use.\n"+
|
||||
"Fractional values are allowed: 0.5 for half a core or 2.25 for two and a quarter cores.")
|
||||
cmd.Flags().Var(&opts.memory, "memory",
|
||||
"Maximum amount of memory a service container can use. Value is a positive integer with\n"+
|
||||
"optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.\n"+
|
||||
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
|
||||
cmd.Flags().Var(&opts.memoryReservation, "memory-reservation",
|
||||
"Minimum amount of memory reserved for a service container. Value is a positive integer with\n"+
|
||||
"optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.")
|
||||
cmd.Flags().StringArrayVar(&opts.labelAdd, "label-add", nil,
|
||||
"Add or change a service label. Can be specified multiple times.\n"+
|
||||
"Format: key=value or just key for an empty value.")
|
||||
cmd.Flags().StringArrayVar(&opts.labelRm, "label-rm", nil,
|
||||
"Remove a service label by its key. Can be specified multiple times.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func update(ctx context.Context, uncli *cli.CLI, opts updateOptions) error {
|
||||
if !opts.cpuChanged && !opts.memoryChanged && !opts.reservationChanged &&
|
||||
len(opts.labelAdd) == 0 && len(opts.labelRm) == 0 {
|
||||
return errors.New("no changes specified, set at least one of the flags: " +
|
||||
"--cpu, --memory, --memory-reservation, --label-add, --label-rm")
|
||||
}
|
||||
|
||||
labelAdd, err := parseLabels(opts.labelAdd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = api.ValidateLabels(labelAdd); err != nil {
|
||||
return fmt.Errorf("invalid service labels: %w", err)
|
||||
}
|
||||
for _, key := range opts.labelRm {
|
||||
if _, ok := labelAdd[key]; ok {
|
||||
return fmt.Errorf("label '%s' cannot be both added and removed", key)
|
||||
}
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
svc, err := clusterClient.InspectService(ctx, opts.service)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect service '%s': %w", opts.service, err)
|
||||
}
|
||||
|
||||
// Check all containers before changing any of them to avoid a partially updated service when a limit can't be
|
||||
// changed in place. Containers may have different specs after a failed deployment so each one is updated from
|
||||
// its own spec.
|
||||
updates := make(map[string]api.UpdateContainerOptions, len(svc.Containers))
|
||||
for _, mc := range svc.Containers {
|
||||
spec := applyServiceUpdate(mc.Container.ServiceSpec, opts, labelAdd)
|
||||
|
||||
resources := mc.Container.AppliedResources()
|
||||
if err = resources.ValidateUpdateInPlace(spec.Container.Resources); err != nil {
|
||||
return fmt.Errorf("container '%s' on machine '%s' can't be updated without recreating it: %w",
|
||||
stringid.TruncateID(mc.Container.ID), mc.MachineName, err)
|
||||
}
|
||||
|
||||
containerOpts := deploy.ContainerUpdateOptions(mc.Container, spec)
|
||||
if containerOpts != (api.UpdateContainerOptions{}) {
|
||||
updates[mc.Container.ID] = containerOpts
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
fmt.Printf("Service %s is already up to date. No changes required.\n", tui.NameStyle.Render(svc.Name))
|
||||
return nil
|
||||
}
|
||||
|
||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
// Keep updating the remaining containers if one fails, for example, because its machine is down.
|
||||
// Running the command again only updates the containers that still differ.
|
||||
var errs []error
|
||||
for id, containerOpts := range updates {
|
||||
if err := clusterClient.UpdateContainer(ctx, svc.ID, id, containerOpts); err != nil {
|
||||
errs = append(errs, fmt.Errorf("update container '%s': %w", stringid.TruncateID(id), err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}, uncli.ProgressOut(), "Updating service "+tui.NameStyle.Render(svc.Name))
|
||||
}
|
||||
|
||||
// applyServiceUpdate returns a copy of the spec with the requested changes applied.
|
||||
func applyServiceUpdate(spec api.ServiceSpec, opts updateOptions, labelAdd map[string]string) api.ServiceSpec {
|
||||
updated := spec.Clone()
|
||||
|
||||
if opts.cpuChanged {
|
||||
updated.Container.Resources.CPU = opts.cpu.Value()
|
||||
}
|
||||
if opts.memoryChanged {
|
||||
updated.Container.Resources.Memory = opts.memory.Value()
|
||||
}
|
||||
if opts.reservationChanged {
|
||||
updated.Container.Resources.MemoryReservation = opts.memoryReservation.Value()
|
||||
}
|
||||
|
||||
if len(labelAdd) > 0 && updated.Labels == nil {
|
||||
updated.Labels = make(map[string]string, len(labelAdd))
|
||||
}
|
||||
maps.Copy(updated.Labels, labelAdd)
|
||||
|
||||
for key := range updated.Labels {
|
||||
if slices.Contains(opts.labelRm, key) {
|
||||
delete(updated.Labels, key)
|
||||
}
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplyServiceUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newSpec := func() api.ServiceSpec {
|
||||
return api.ServiceSpec{
|
||||
Name: "web",
|
||||
Container: api.ContainerSpec{
|
||||
Image: "nginx",
|
||||
Labels: map[string]string{"container": "keep"},
|
||||
Resources: api.ContainerResources{
|
||||
CPU: 100 * api.MilliCore,
|
||||
Memory: 1024,
|
||||
MemoryReservation: 512,
|
||||
},
|
||||
},
|
||||
Labels: map[string]string{"tier": "frontend", "legacy": "true"},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("adds, changes and removes labels", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := newSpec()
|
||||
updated := applyServiceUpdate(spec, updateOptions{labelRm: []string{"legacy", "missing"}},
|
||||
map[string]string{"tier": "backend", "team": "core"})
|
||||
|
||||
assert.Equal(t, map[string]string{"tier": "backend", "team": "core"}, updated.Labels)
|
||||
assert.Equal(t, map[string]string{"container": "keep"}, updated.Container.Labels)
|
||||
assert.Equal(t, spec.Container.Resources, updated.Container.Resources)
|
||||
assert.Equal(t, map[string]string{"tier": "frontend", "legacy": "true"}, spec.Labels,
|
||||
"Expected the original spec to stay unchanged")
|
||||
})
|
||||
|
||||
t.Run("adds labels to a spec without labels", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := newSpec()
|
||||
spec.Labels = nil
|
||||
updated := applyServiceUpdate(spec, updateOptions{}, map[string]string{"tier": "backend"})
|
||||
|
||||
assert.Equal(t, map[string]string{"tier": "backend"}, updated.Labels)
|
||||
})
|
||||
|
||||
t.Run("changes only requested limits", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := updateOptions{cpuChanged: true}
|
||||
require.NoError(t, opts.cpu.Set("0.5"))
|
||||
|
||||
updated := applyServiceUpdate(newSpec(), opts, nil)
|
||||
|
||||
assert.EqualValues(t, 500*api.MilliCore, updated.Container.Resources.CPU)
|
||||
assert.EqualValues(t, 1024, updated.Container.Resources.Memory)
|
||||
assert.EqualValues(t, 512, updated.Container.Resources.MemoryReservation)
|
||||
})
|
||||
|
||||
t.Run("sends only changed fields to the container", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := updateOptions{memoryChanged: true}
|
||||
require.NoError(t, opts.memory.Set("2048"))
|
||||
|
||||
ctr := api.ServiceContainer{ServiceSpec: newSpec()}
|
||||
containerOpts := deploy.ContainerUpdateOptions(ctr, applyServiceUpdate(ctr.ServiceSpec, opts, nil))
|
||||
|
||||
require.NotNil(t, containerOpts.Memory)
|
||||
assert.EqualValues(t, 2048, *containerOpts.Memory)
|
||||
assert.Nil(t, containerOpts.CPU)
|
||||
assert.Nil(t, containerOpts.MemoryReservation)
|
||||
assert.Nil(t, containerOpts.ServiceLabels)
|
||||
})
|
||||
|
||||
t.Run("removing the last label clears service labels", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctr := api.ServiceContainer{ServiceSpec: newSpec()}
|
||||
containerOpts := deploy.ContainerUpdateOptions(ctr, applyServiceUpdate(
|
||||
ctr.ServiceSpec, updateOptions{labelRm: []string{"tier", "legacy"}}, nil,
|
||||
))
|
||||
|
||||
require.NotNil(t, containerOpts.ServiceLabels)
|
||||
assert.Empty(t, *containerOpts.ServiceLabels)
|
||||
})
|
||||
|
||||
t.Run("no changes produce empty container options", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctr := api.ServiceContainer{ServiceSpec: newSpec()}
|
||||
containerOpts := deploy.ContainerUpdateOptions(ctr, applyServiceUpdate(
|
||||
ctr.ServiceSpec, updateOptions{}, map[string]string{"tier": "frontend"},
|
||||
))
|
||||
|
||||
assert.Equal(t, api.UpdateContainerOptions{}, containerOpts)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Duration is an extended standard time.Duration that also supports days as a unit.
|
||||
type Duration = time.Duration
|
||||
|
||||
// ParseDuration parses a Go duration with the additional unit d, meaning exactly 24 hours.
|
||||
// Days can be fractional or combined with other units, such as "1.5d" or "2d3h".
|
||||
func ParseDuration(value string) (Duration, error) {
|
||||
if !strings.Contains(value, "d") {
|
||||
return time.ParseDuration(value)
|
||||
}
|
||||
|
||||
rest := value
|
||||
var normalised strings.Builder
|
||||
if len(rest) > 0 && (rest[0] == '-' || rest[0] == '+') {
|
||||
normalised.WriteByte(rest[0])
|
||||
rest = rest[1:]
|
||||
}
|
||||
for len(rest) > 0 {
|
||||
// Each component is a decimal number followed by a unit. Only the leading sign is allowed.
|
||||
numberEnd := 0
|
||||
for numberEnd < len(rest) && (isDigit(rest[numberEnd]) || rest[numberEnd] == '.') {
|
||||
numberEnd++
|
||||
}
|
||||
unitEnd := numberEnd
|
||||
for unitEnd < len(rest) && !isDigit(rest[unitEnd]) && rest[unitEnd] != '.' {
|
||||
unitEnd++
|
||||
}
|
||||
if numberEnd == 0 || unitEnd == numberEnd {
|
||||
return 0, fmt.Errorf("time: invalid duration '%s'", value)
|
||||
}
|
||||
number, unit := rest[:numberEnd], rest[numberEnd:unitEnd]
|
||||
if unit == "d" {
|
||||
// Use exact decimal arithmetic to avoid float rounding and retain nanosecond precision.
|
||||
days, ok := new(big.Rat).SetString(number)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("time: invalid duration '%s'", value)
|
||||
}
|
||||
days.Mul(days, new(big.Rat).SetInt64(int64(24*time.Hour)))
|
||||
nanoseconds := new(big.Int).Quo(days.Num(), days.Denom())
|
||||
normalised.WriteString(nanoseconds.String())
|
||||
normalised.WriteString("ns")
|
||||
} else {
|
||||
normalised.WriteString(rest[:unitEnd])
|
||||
}
|
||||
rest = rest[unitEnd:]
|
||||
}
|
||||
|
||||
// The standard parser validates the remaining units and checks the total for overflow.
|
||||
duration, err := time.ParseDuration(normalised.String())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("time: invalid duration '%s': %w", value, err)
|
||||
}
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
func isDigit(c byte) bool {
|
||||
return c >= '0' && c <= '9'
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tt := range []struct {
|
||||
input string
|
||||
want time.Duration
|
||||
}{
|
||||
{"0", 0},
|
||||
{"2m30s", 150 * time.Second},
|
||||
{"1µs", time.Microsecond},
|
||||
{"1μs", time.Microsecond},
|
||||
{"2d", 48 * time.Hour},
|
||||
{"010d", 240 * time.Hour},
|
||||
{"08d", 192 * time.Hour},
|
||||
{"2d3h", 51 * time.Hour},
|
||||
{"3h2d", 51 * time.Hour},
|
||||
{"1d1d", 48 * time.Hour},
|
||||
{"1.5d", 36 * time.Hour},
|
||||
{".5d", 12 * time.Hour},
|
||||
{"1.d", 24 * time.Hour},
|
||||
{"-2d3h", -51 * time.Hour},
|
||||
{"+2d", 48 * time.Hour},
|
||||
{"0d", 0},
|
||||
{"0d1ns", time.Nanosecond},
|
||||
{"0.000000000001d", 86 * time.Nanosecond},
|
||||
{"-0.000000000001d", -86 * time.Nanosecond},
|
||||
{"106751d23h47m16.854775807s", time.Duration(math.MaxInt64)},
|
||||
{"-106751d23h47m16.854775808s", time.Duration(math.MinInt64)},
|
||||
} {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
actual, err := ParseDuration(tt.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration_Invalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, input := range []string{
|
||||
"", "d", "2d3", "1..2d", ".d", "1d-2h", "1d+2h", "1d 2h", "1day", "2D", "1w",
|
||||
"1e2d", "1d2w", "106752d", "-106752d", "106751d23h47m16.854775808s",
|
||||
"-106751d23h47m16.854775809s", "999999999999999999999999999d",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := ParseDuration(input)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
@@ -24,6 +25,10 @@ type Formatter struct {
|
||||
maxServiceWidth int
|
||||
|
||||
utc bool
|
||||
|
||||
// Cache each stream's terminal profile to avoid detection on every log entry.
|
||||
stdout *colorprofile.Writer
|
||||
stderr *colorprofile.Writer
|
||||
}
|
||||
|
||||
func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter {
|
||||
@@ -50,6 +55,8 @@ func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter {
|
||||
maxMachineWidth: maxMachineWidth,
|
||||
maxServiceWidth: maxServiceWidth,
|
||||
utc: utc,
|
||||
stdout: colorprofile.NewWriter(os.Stdout, os.Environ()),
|
||||
stderr: colorprofile.NewWriter(os.Stderr, os.Environ()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,9 +145,9 @@ func (f *Formatter) PrintEntry(entry api.ServiceLogEntry) {
|
||||
|
||||
// Print to appropriate stream.
|
||||
if entry.Stream == api.LogStreamStderr {
|
||||
fmt.Fprint(os.Stderr, output.String())
|
||||
fmt.Fprint(f.stderr, output.String())
|
||||
} else {
|
||||
fmt.Print(output.String())
|
||||
fmt.Fprint(f.stdout, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,8 +155,7 @@ func (f *Formatter) PrintEntry(entry api.ServiceLogEntry) {
|
||||
func (f *Formatter) printError(entry api.ServiceLogEntry) {
|
||||
if entry.Metadata.ServiceName == "" {
|
||||
msg := fmt.Sprintf("ERROR: %v", entry.Err)
|
||||
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.BrightRed)
|
||||
fmt.Fprintln(os.Stderr, style.Render(msg))
|
||||
fmt.Fprintln(f.stderr, tui.BoldRed.Render(msg))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -171,8 +177,7 @@ func (f *Formatter) printError(entry api.ServiceLogEntry) {
|
||||
msg += fmt.Sprintf(": %v", entry.Err)
|
||||
}
|
||||
|
||||
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.BrightYellow)
|
||||
fmt.Fprintln(os.Stderr, style.Render(msg))
|
||||
fmt.Fprintln(f.stderr, tui.BoldYellow.Render(msg))
|
||||
}
|
||||
|
||||
// palette is available colors for machine/service differentiation.
|
||||
|
||||
@@ -19,6 +19,18 @@ type Options struct {
|
||||
Machines []string
|
||||
}
|
||||
|
||||
// TailLines resolves the default tail limit after parsing flags. An empty opts.Tail means the user
|
||||
// did not specify a limit, so --since can select all matching logs without overriding an explicit --tail.
|
||||
func (opts Options) TailLines() (int, error) {
|
||||
if opts.Tail == "" {
|
||||
if opts.Since != "" {
|
||||
return -1, nil
|
||||
}
|
||||
return 100, nil
|
||||
}
|
||||
return ParseTail(opts.Tail)
|
||||
}
|
||||
|
||||
func Flags(options *Options) *pflag.FlagSet {
|
||||
set := &pflag.FlagSet{}
|
||||
|
||||
@@ -29,14 +41,17 @@ func Flags(options *Options) *pflag.FlagSet {
|
||||
set.StringVar(&options.Since, "since", "",
|
||||
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||
"Examples:\n"+
|
||||
" --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+
|
||||
" --since 1h Relative duration (1 hour ago)\n"+
|
||||
" --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+
|
||||
" --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+
|
||||
" --since 1h45m Relative duration (1 hour 45 minutes ago)\n"+
|
||||
" Supported units: d (day = 24h), h (hour), m (minute),\n"+
|
||||
" s (second), ms (millisecond),\n"+
|
||||
" us/µs (microsecond), ns (nanosecond)\n"+
|
||||
" --since 2025-11-24 RFC 3339 date only (midnight using client local timezone)\n"+
|
||||
" --since 2024-05-14T22:50:00 RFC 3339 date/time using client local timezone\n"+
|
||||
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
|
||||
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
|
||||
set.StringVarP(&options.Tail, "tail", "n", "100",
|
||||
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.")
|
||||
set.StringVarP(&options.Tail, "tail", "n", "",
|
||||
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.\n"+
|
||||
"Defaults to 100, or 'all' when --since is set.")
|
||||
set.StringVar(&options.Until, "until", "",
|
||||
"Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||
"See --since for examples.")
|
||||
@@ -46,7 +61,7 @@ func Flags(options *Options) *pflag.FlagSet {
|
||||
return set
|
||||
}
|
||||
|
||||
func Tail(tail string) (int, error) {
|
||||
func ParseTail(tail string) (int, error) {
|
||||
if tail == "all" {
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,42 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOptionsTailLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want int
|
||||
}{
|
||||
{"default", nil, 100},
|
||||
{"follow", []string{"-f"}, 100},
|
||||
{"since", []string{"--since", "1h"}, -1},
|
||||
{"since and follow", []string{"--since", "1h", "-f"}, -1},
|
||||
{"until only", []string{"--until", "1h"}, 100},
|
||||
{"time range", []string{"--since", "3h", "--until", "1h"}, -1},
|
||||
{"explicit default with since", []string{"--since", "1h", "--tail", "100"}, 100},
|
||||
{"explicit limit with since", []string{"--since", "1h", "-n", "20"}, 20},
|
||||
{"explicit limit before since", []string{"-n", "20", "--since", "1h"}, 20},
|
||||
{"explicit all", []string{"--tail", "all"}, -1},
|
||||
{"explicit zero with since", []string{"--since", "1h", "-n", "0", "-f"}, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var options Options
|
||||
require.NoError(t, Flags(&options).Parse(tt.args))
|
||||
tail, err := options.TailLines()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, tail)
|
||||
})
|
||||
}
|
||||
|
||||
var options Options
|
||||
require.NoError(t, Flags(&options).Parse([]string{"--since", "1h", "--tail", "invalid"}))
|
||||
_, err := options.TailLines()
|
||||
require.ErrorContains(t, err, "invalid --tail value")
|
||||
}
|
||||
|
||||
func TestParseServiceArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimeRange resolves log filters that could be RFC 3339, timestamp, or relative duration to UTC timestamps.
|
||||
// Dates without a timezone use now's location. Relative durations are computed from now.
|
||||
func TimeRange(since, until string, now time.Time) (string, string, error) {
|
||||
var err error
|
||||
since, err = timestamp(since, now)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid --since value: %w", err)
|
||||
}
|
||||
until, err = timestamp(until, now)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid --until value: %w", err)
|
||||
}
|
||||
|
||||
return since, until, nil
|
||||
}
|
||||
|
||||
func timestamp(value string, now time.Time) (string, error) {
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
// A bare zero is the Unix epoch, matching Docker's log filters.
|
||||
if duration, err := ParseDuration(value); value != "0" && err == nil {
|
||||
return now.Add(-duration).UTC().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
|
||||
// Keep Docker's supported date layouts, but use the location's offset at the requested date.
|
||||
for _, layout := range []string{
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02T15:04Z07:00",
|
||||
"2006-01-02T15Z07:00",
|
||||
"2006-01-02Z07:00",
|
||||
"2006-01-02T15:04:05.999999999",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02T15",
|
||||
"2006-01-02",
|
||||
} {
|
||||
if t, err := time.ParseInLocation(layout, value, now.Location()); err == nil {
|
||||
return t.UTC().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
}
|
||||
|
||||
seconds, fraction, hasFraction := strings.Cut(value, ".")
|
||||
sec, err := strconv.ParseInt(seconds, 10, 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse '%s' as a time or duration", value)
|
||||
}
|
||||
|
||||
var nsec int64
|
||||
if hasFraction {
|
||||
if len(fraction) == 0 || len(fraction) > 9 || strings.ContainsAny(fraction, "+-") {
|
||||
return "", fmt.Errorf("invalid Unix timestamp fraction in '%s'", value)
|
||||
}
|
||||
nsec, err = strconv.ParseInt(fraction+strings.Repeat("0", 9-len(fraction)), 10, 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid Unix timestamp fraction in '%s': %w", value, err)
|
||||
}
|
||||
}
|
||||
t := time.Unix(sec, nsec).UTC()
|
||||
if t.Year() < 0 || t.Year() > 9999 {
|
||||
return "", fmt.Errorf("Unix timestamp '%s' is outside the RFC 3339 date range", value)
|
||||
}
|
||||
return t.Format(time.RFC3339Nano), nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
timetypes "github.com/docker/docker/api/types/time"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
location, err := time.LoadLocation("Australia/Sydney")
|
||||
require.NoError(t, err)
|
||||
// October is daylight-saving time, but July timestamps must use the winter offset.
|
||||
// Daylight saving starts on 4 October. Day durations must still mean 24 elapsed hours.
|
||||
now := time.Date(2026, 10, 5, 12, 0, 0, 123456789, location)
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"2026-07-01", "2026-06-30T14:00:00Z"},
|
||||
{"2026-07-01T10", "2026-07-01T00:00:00Z"},
|
||||
{"2026-07-01T10:30", "2026-07-01T00:30:00Z"},
|
||||
{"2026-07-01T10:30:45.123456789", "2026-07-01T00:30:45.123456789Z"},
|
||||
{"2026-01-01T10:00:00", "2025-12-31T23:00:00Z"},
|
||||
{"2026-07-01T10:30:45Z", "2026-07-01T10:30:45Z"},
|
||||
{"2026-07-01T10:30:45+02:00", "2026-07-01T08:30:45Z"},
|
||||
{"2026-07-01T10:30:45-04:00", "2026-07-01T14:30:45Z"},
|
||||
{"2026-07-01T10+02:00", "2026-07-01T08:00:00Z"},
|
||||
{"2026-07-01T10:30+02:00", "2026-07-01T08:30:00Z"},
|
||||
{"2026-07-01+02:00", "2026-06-30T22:00:00Z"},
|
||||
{"1763953966", "2025-11-24T03:12:46Z"},
|
||||
{"1763953966.000000001", "2025-11-24T03:12:46.000000001Z"},
|
||||
{"0", "1970-01-01T00:00:00Z"},
|
||||
{"2m30s", "2026-10-05T00:57:30.123456789Z"},
|
||||
{"1d", "2026-10-04T01:00:00.123456789Z"},
|
||||
{"2d", "2026-10-03T01:00:00.123456789Z"},
|
||||
{"2d3h", "2026-10-02T22:00:00.123456789Z"},
|
||||
{"1.5d", "2026-10-03T13:00:00.123456789Z"},
|
||||
{"-2d", "2026-10-07T01:00:00.123456789Z"},
|
||||
{"-1h", "2026-10-05T02:00:00.123456789Z"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
since, until, err := TimeRange(tt.input, tt.input, now)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, since)
|
||||
assert.Equal(t, tt.want, until)
|
||||
|
||||
if since != "" {
|
||||
// The server-side Docker SDK must preserve the cutoff even in another timezone.
|
||||
actual, err := timetypes.GetTimestamp(since, now.In(time.UTC))
|
||||
require.NoError(t, err)
|
||||
expected, err := time.Parse(time.RFC3339Nano, tt.want)
|
||||
require.NoError(t, err)
|
||||
sec, nsec, err := timetypes.ParseTimestamps(actual, 0)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, expected.Equal(time.Unix(sec, nsec)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
since, until, err := TimeRange("3h", "1h30m", now)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2026-10-04T22:00:00.123456789Z", since)
|
||||
assert.Equal(t, "2026-10-04T23:30:00.123456789Z", until)
|
||||
}
|
||||
|
||||
func TestTimeRange_Invalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, input := range []string{
|
||||
"invalid", "2026-02-30", "2026-01-01T25:00:00", "1763953966.xyz",
|
||||
"1763953966.1234567890", "253402300800",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, _, err := TimeRange(input, "", time.Now())
|
||||
require.ErrorContains(t, err, "invalid --since value")
|
||||
_, _, err = TimeRange("", input, time.Now())
|
||||
require.ErrorContains(t, err, "invalid --until value")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
type eventIDKey struct{}
|
||||
|
||||
// WithEventID returns a context that overrides the default event ID used by client methods.
|
||||
// WithEventID returns a context that overrides the event ID already set in the context.
|
||||
func WithEventID(ctx context.Context, eventID string) context.Context {
|
||||
return context.WithValue(ctx, eventIDKey{}, eventID)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func OldPreDeployHookEventID(serviceName, containerID, machineName string) strin
|
||||
tui.Faint.Render(" on ") + machineName
|
||||
}
|
||||
|
||||
// ImageEventID returns a progress event ID for image pull operations.
|
||||
// ImageEventID returns a progress event ID for image operations on a machine.
|
||||
func ImageEventID(image, machineName string) string {
|
||||
return tui.Faint.Render("Image ") + image +
|
||||
tui.Faint.Render(" on ") + machineName
|
||||
|
||||
@@ -3,9 +3,11 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func PrintWarning(msg string) {
|
||||
styledMsg := BoldYellow.Render(fmt.Sprintf("WARNING: %s", msg))
|
||||
fmt.Fprintln(os.Stderr, styledMsg)
|
||||
lipgloss.Fprintln(os.Stderr, styledMsg)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
@@ -31,11 +32,11 @@ func logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (io.Rea
|
||||
|
||||
if opts.Since != "" {
|
||||
args = append(args, "-S")
|
||||
args = append(args, opts.Since)
|
||||
args = append(args, journalTimestamp(opts.Since))
|
||||
}
|
||||
if opts.Until != "" {
|
||||
args = append(args, "-U")
|
||||
args = append(args, opts.Until)
|
||||
args = append(args, journalTimestamp(opts.Until))
|
||||
}
|
||||
|
||||
cmd := commandContext(ctx, journalctl, args...)
|
||||
@@ -51,6 +52,18 @@ func logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (io.Rea
|
||||
return p, cmd.Wait, nil
|
||||
}
|
||||
|
||||
// journalTimestamp formats normalised client timestamps for journalctl versions that do not
|
||||
// accept RFC 3339 timezone suffixes. Support for timestamps containing T and Z was added in
|
||||
// systemd 255 (6 December 2023). Debian 12 ships systemd 252, so it still needs this conversion.
|
||||
// Journald timestamps have microsecond precision.
|
||||
func journalTimestamp(value string) string {
|
||||
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return t.UTC().Format("2006-01-02 15:04:05.999999") + " UTC"
|
||||
}
|
||||
// Preserve raw filters from older clients and SDK callers.
|
||||
return value
|
||||
}
|
||||
|
||||
// follow synchronously follows the io.Reader, writing each new journal entry to channel.
|
||||
// It stops when the reader is exhausted or the context is cancelled.
|
||||
func follow(ctx context.Context, reader io.Reader, outCh chan api.LogEntry) {
|
||||
|
||||
@@ -115,6 +115,9 @@ func TestEntry(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLogs(t *testing.T) {
|
||||
originalCommandContext := commandContext
|
||||
t.Cleanup(func() { commandContext = originalCommandContext })
|
||||
|
||||
commandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
|
||||
return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs")
|
||||
}
|
||||
@@ -150,3 +153,38 @@ func TestLogs(t *testing.T) {
|
||||
// Still six because heartbeats are not written here and Tail is ignored as the command is overridden.
|
||||
assert.Equal(t, 6, i)
|
||||
}
|
||||
|
||||
func TestLogs_TimeFilters(t *testing.T) {
|
||||
originalCommandContext := commandContext
|
||||
t.Cleanup(func() { commandContext = originalCommandContext })
|
||||
|
||||
var args []string
|
||||
commandContext = func(ctx context.Context, command string, commandArgs ...string) *exec.Cmd {
|
||||
assert.Equal(t, "journalctl", command)
|
||||
args = commandArgs
|
||||
return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs")
|
||||
}
|
||||
|
||||
ch, err := Logs(context.Background(), "uncloud", api.ServiceLogsOptions{
|
||||
Since: "2026-07-01T10:30:45.123456789+10:00",
|
||||
Until: "2026-07-01T01:30:45Z",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
for range ch {
|
||||
}
|
||||
assert.Equal(t, []string{
|
||||
"-u", "uncloud", "--no-hostname", "-n", "0", "-o", "short-unix",
|
||||
"-S", "2026-07-01 00:30:45.123456 UTC",
|
||||
"-U", "2026-07-01 01:30:45 UTC",
|
||||
}, args)
|
||||
|
||||
// Older clients and SDK callers can still pass journalctl's native filters.
|
||||
ch, err = Logs(context.Background(), "uncloud", api.ServiceLogsOptions{Since: "1h ago", Until: "today"})
|
||||
require.NoError(t, err)
|
||||
for range ch {
|
||||
}
|
||||
assert.Equal(t, []string{
|
||||
"-u", "uncloud", "--no-hostname", "-n", "0", "-o", "short-unix",
|
||||
"-S", "1h ago", "-U", "today",
|
||||
}, args)
|
||||
}
|
||||
|
||||
@@ -16,10 +16,19 @@ func NewMockCaddyfileValidator(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockCaddyfileValidator {
|
||||
if helper, ok := t.(interface{ Helper() }); ok {
|
||||
helper.Helper()
|
||||
}
|
||||
|
||||
mock := &MockCaddyfileValidator{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
t.Cleanup(func() {
|
||||
if helper, ok := t.(interface{ Helper() }); ok {
|
||||
helper.Helper()
|
||||
}
|
||||
mock.AssertExpectations(t)
|
||||
})
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
@@ -87,6 +88,11 @@ func (c *Cluster) AddMachine(ctx context.Context, req *pb.AddMachineRequest) (*p
|
||||
func (c *Cluster) AddMachineWithoutReadyCheck(
|
||||
ctx context.Context, req *pb.AddMachineRequest,
|
||||
) (*pb.AddMachineResponse, error) {
|
||||
if req.Name != "" {
|
||||
if err := api.ValidateMachineName(req.Name); err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
}
|
||||
if req.Network == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "network not set")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/psviderski/uncloud/api/pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestAddMachine_InvalidName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ready := make(chan struct{})
|
||||
close(ready)
|
||||
// Invalid names must be rejected before any store access.
|
||||
c := NewCluster(nil, nil, nil, ready)
|
||||
for _, name := range []string{"VPS1", "worker.example", "rr", "nearest", "c337f00600de51ef4375c9a9a267dba5"} {
|
||||
t.Run("add", func(t *testing.T) {
|
||||
resp, err := c.AddMachine(context.Background(), &pb.AddMachineRequest{Name: name})
|
||||
require.Nil(t, resp)
|
||||
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||
require.ErrorContains(t, err, "invalid machine name")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
// NewMachineID generates a new unique machine ID.
|
||||
@@ -27,7 +28,7 @@ func NewRandomMachineName() (string, error) {
|
||||
// a numeric suffix ("-1", "-2", etc.) if needed.
|
||||
func DefaultMachineName(hostname string, existing []string) (string, error) {
|
||||
name := machineNameFromHostname(hostname)
|
||||
if name == "" {
|
||||
if api.ValidateMachineName(name) != nil {
|
||||
var err error
|
||||
if name, err = NewRandomMachineName(); err != nil {
|
||||
return "", err
|
||||
@@ -38,7 +39,9 @@ func DefaultMachineName(hostname string, existing []string) (string, error) {
|
||||
return name, nil
|
||||
}
|
||||
for i := 1; ; i++ {
|
||||
candidate := fmt.Sprintf("%s-%d", name, i)
|
||||
suffix := fmt.Sprintf("-%d", i)
|
||||
base := strings.TrimRight(name[:min(len(name), 63-len(suffix))], "-")
|
||||
candidate := base + suffix
|
||||
if !slices.Contains(existing, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
@@ -55,12 +58,13 @@ func machineNameFromHostname(hostname string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range label {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
// Trim leading and trailing hyphens that may result from the sanitisation above.
|
||||
return strings.Trim(b.String(), "-")
|
||||
name := strings.Trim(b.String(), "-")
|
||||
return strings.TrimRight(name[:min(len(name), 63)], "-")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -18,9 +20,11 @@ func TestMachineNameFromHostname(t *testing.T) {
|
||||
{"simple", "web", "web"},
|
||||
{"fqdn uses first label", "web-1.example.com", "web-1"},
|
||||
{"uppercase lowercased", "Web-Server", "web-server"},
|
||||
{"invalid chars replaced", "host_name@1", "host_name-1"},
|
||||
{"trim surrounding hyphens", "-_host_-", "_host_"},
|
||||
{"invalid chars replaced", "host_name@1", "host-name-1"},
|
||||
{"trim surrounding hyphens", "-_host_-", "host"},
|
||||
{"whitespace trimmed", " myhost ", "myhost"},
|
||||
{"long hostname truncated", strings.Repeat("a", 64), strings.Repeat("a", 63)},
|
||||
{"truncation trims trailing hyphen", strings.Repeat("a", 62) + "-b", strings.Repeat("a", 62)},
|
||||
{"empty", "", ""},
|
||||
{"only invalid chars", "@#", ""},
|
||||
{"dot only", ".example.com", ""},
|
||||
@@ -37,10 +41,12 @@ func TestDefaultMachineName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("falls back to random", func(t *testing.T) {
|
||||
// An empty or fully invalid hostname falls back to a random "machine-xxxx" name.
|
||||
got, err := DefaultMachineName("***", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, `^machine-[a-zA-Z0-9]{4}$`, got)
|
||||
for _, hostname := range []string{"***", "rr", "nearest", strings.Repeat("a", 32)} {
|
||||
got, err := DefaultMachineName(hostname, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, `^machine-[a-z0-9]{4}$`, got)
|
||||
require.NoError(t, api.ValidateMachineName(got))
|
||||
}
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
@@ -49,10 +55,45 @@ func TestDefaultMachineName(t *testing.T) {
|
||||
existing []string
|
||||
want string
|
||||
}{
|
||||
{"from hostname", "web-1.example.com", nil, "web-1"},
|
||||
{"dedup against existing", "web", []string{"web"}, "web-1"},
|
||||
{"dedup multiple", "web", []string{"web", "web-1"}, "web-2"},
|
||||
{"sanitized", "My_Host", nil, "my_host"},
|
||||
{
|
||||
name: "from hostname",
|
||||
hostname: "web-1.example.com",
|
||||
want: "web-1",
|
||||
},
|
||||
{
|
||||
name: "dedup against existing",
|
||||
hostname: "web",
|
||||
existing: []string{"web"},
|
||||
want: "web-1",
|
||||
},
|
||||
{
|
||||
name: "dedup multiple",
|
||||
hostname: "web",
|
||||
existing: []string{"web", "web-1"},
|
||||
want: "web-2",
|
||||
},
|
||||
{
|
||||
name: "sanitized",
|
||||
hostname: "My_Host",
|
||||
want: "my-host",
|
||||
},
|
||||
{
|
||||
name: "long hostname",
|
||||
hostname: strings.Repeat("a", 64),
|
||||
want: strings.Repeat("a", 63),
|
||||
},
|
||||
{
|
||||
name: "dedup maximum length",
|
||||
hostname: strings.Repeat("a", 63),
|
||||
existing: []string{strings.Repeat("a", 63)},
|
||||
want: strings.Repeat("a", 61) + "-1",
|
||||
},
|
||||
{
|
||||
name: "dedup trims trailing hyphen",
|
||||
hostname: strings.Repeat("a", 60) + "-bb",
|
||||
existing: []string{strings.Repeat("a", 60) + "-bb"},
|
||||
want: strings.Repeat("a", 60) + "-1",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -60,6 +101,7 @@ func TestDefaultMachineName(t *testing.T) {
|
||||
got, err := DefaultMachineName(tt.hostname, tt.existing)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
require.NoError(t, api.ValidateMachineName(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/psviderski/uncloud/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
)
|
||||
|
||||
@@ -19,7 +21,9 @@ type ClusterResolver struct {
|
||||
store *store.Store
|
||||
// serviceIPs maps service names to container IPs.
|
||||
serviceIPs map[string][]netip.Addr
|
||||
// mu protects the serviceIPs map.
|
||||
// machineIPs maps machine IDs and names to their IP.
|
||||
machineIPs map[string]netip.Addr
|
||||
// mu protects the serviceIPs and machineIPs map.
|
||||
mu sync.RWMutex
|
||||
// lastUpdate tracks when records were last updated.
|
||||
lastUpdate time.Time
|
||||
@@ -46,6 +50,13 @@ func (r *ClusterResolver) Run(ctx context.Context) error {
|
||||
// TODO: implement machine membership check using Corrossion Admin client to filter available containers.
|
||||
r.updateServiceIPs(containers)
|
||||
|
||||
machines, mchanges, err := r.store.SubscribeMachines(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscribe to machine changes: %w", err)
|
||||
}
|
||||
r.log.Info("Subscribed to machine changes in the clsuter to keep machine DNS records updated.")
|
||||
r.updateMachineIPs(machines)
|
||||
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-changes:
|
||||
@@ -59,9 +70,22 @@ func (r *ClusterResolver) Run(ctx context.Context) error {
|
||||
r.log.Error("Failed to list containers.", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO: implement machine membership check using Corrossion Admin client to filter available containers.
|
||||
r.updateServiceIPs(containers)
|
||||
|
||||
case _, ok := <-mchanges:
|
||||
if !ok {
|
||||
return fmt.Errorf("machine subscription failed")
|
||||
}
|
||||
r.log.Debug("Cluster machines changed, updating DNS records.")
|
||||
|
||||
machines, err := r.store.ListMachines(ctx)
|
||||
if err != nil {
|
||||
r.log.Error("Failed to list machines.", "err", err)
|
||||
continue
|
||||
}
|
||||
r.updateMachineIPs(machines)
|
||||
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
@@ -121,19 +145,49 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
|
||||
r.log.Info("DNS records updated.", "services", len(newServiceIPs)/3, "containers", containersCount)
|
||||
}
|
||||
|
||||
// Resolve returns IP addresses of the service containers.
|
||||
func (r *ClusterResolver) Resolve(serviceName string) []netip.Addr {
|
||||
func (r *ClusterResolver) updateMachineIPs(machines []*pb.MachineInfo) {
|
||||
newMachineIPs := make(map[string]netip.Addr, len(machines))
|
||||
|
||||
for _, machine := range machines {
|
||||
subnet, err := machine.Network.Subnet.ToPrefix()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
addr := network.MachineIP(subnet)
|
||||
newMachineIPs[machine.Name+".m"] = addr
|
||||
newMachineIPs[machine.Id+".m"] = addr
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.machineIPs = newMachineIPs
|
||||
r.mu.Unlock()
|
||||
|
||||
r.log.Info("DNS records updated.", "machines", len(machines))
|
||||
}
|
||||
|
||||
// Resolve returns IP addresses of the service containers or machines.
|
||||
func (r *ClusterResolver) Resolve(name string) []netip.Addr {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
ips, ok := r.serviceIPs[serviceName]
|
||||
if !ok || len(ips) == 0 {
|
||||
return nil
|
||||
// Return a copy of the IPs slice to prevent modification of the original.
|
||||
ips, ok := r.serviceIPs[name]
|
||||
if ok && len(ips) > 0 {
|
||||
return slices.Clone(ips)
|
||||
}
|
||||
|
||||
// Return a copy of the IPs slice to prevent modification of the original.
|
||||
ipsCopy := make([]netip.Addr, len(ips))
|
||||
copy(ipsCopy, ips)
|
||||
ip, ok := r.machineIPs[name]
|
||||
if ok {
|
||||
return slices.Clone([]netip.Addr{ip})
|
||||
}
|
||||
|
||||
return ipsCopy
|
||||
if name == "m" { // all machines subdomain
|
||||
// collect all unique IP address
|
||||
uniq := map[string]netip.Addr{}
|
||||
for _, addr := range r.machineIPs {
|
||||
uniq[addr.String()] = addr
|
||||
}
|
||||
return slices.Clone(slices.Collect(maps.Values(uniq)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/psviderski/uncloud/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -75,3 +77,32 @@ func newRecord(serviceID, serviceName, ip, machineID string) store.ContainerReco
|
||||
MachineID: machineID,
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterResolver_UpdateMachineIPs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
machines := []*pb.MachineInfo{
|
||||
newMachineRecord("x0y0z0", "mach-1", "10.210.0.0/24"),
|
||||
newMachineRecord("x1y1z1", "mach-2", "10.210.1.0/24"),
|
||||
newMachineRecord("x2y2z2", "mach-3", "10.210.2.0/24"),
|
||||
}
|
||||
|
||||
r := NewClusterResolver(nil)
|
||||
r.updateMachineIPs(machines)
|
||||
|
||||
assert.NotEmpty(t, r.Resolve("mach-1.m"))
|
||||
assert.NotEmpty(t, r.Resolve("mach-3.m"))
|
||||
assert.NotEmpty(t, r.Resolve("x0y0z0.m"))
|
||||
|
||||
assert.Equal(t, 3, len(r.Resolve("m")))
|
||||
}
|
||||
|
||||
func newMachineRecord(machineID, machineName, prefix string) *pb.MachineInfo {
|
||||
return &pb.MachineInfo{
|
||||
Id: machineID,
|
||||
Name: machineName,
|
||||
Network: &pb.NetworkConfig{
|
||||
Subnet: pb.NewIPPrefix(netip.MustParsePrefix(prefix)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,13 +293,13 @@ func (s *Server) forwardRequest(req *dns.Msg, proto string) (*dns.Msg, error) {
|
||||
// handleAQuery processes an A query for the internal domain and returns A records for the requested name.
|
||||
// The internal domain suffix is already stripped from the name. An empty list is returned if no records are found.
|
||||
func (s *Server) handleAQuery(name string) []dns.RR {
|
||||
serviceName, mode := extractModeFromDomain(trimInternalDomain(name))
|
||||
ips := s.resolver.Resolve(serviceName)
|
||||
unname, mode := extractModeFromDomain(trimInternalDomain(name))
|
||||
ips := s.resolver.Resolve(unname)
|
||||
if len(ips) == 0 {
|
||||
s.log.Debug("Failed to resolve service name.", "service", serviceName)
|
||||
s.log.Debug("Failed to resolve internal name.", "name", name)
|
||||
return nil
|
||||
}
|
||||
s.log.Debug("Resolved service name.", "service", serviceName, "ips", ips)
|
||||
s.log.Debug("Resolved service internal name.", "name", name, "ips", ips)
|
||||
|
||||
if len(ips) > 1 {
|
||||
// Shuffle the IPs to approximate round-robin.
|
||||
|
||||
@@ -167,7 +167,7 @@ func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions)
|
||||
machineContainers := make([]MachineContainers, len(resp.Messages))
|
||||
for i, msg := range resp.Messages {
|
||||
machineContainers[i].Metadata = msg.Metadata
|
||||
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||
if msg.Metadata.Err() != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
|
||||
|
||||
notFoundCount := 0
|
||||
for _, msg := range resp.Messages {
|
||||
if msg.Metadata != nil && msg.Metadata.Status != nil && codes.Code(msg.Metadata.Status.Code) == codes.NotFound {
|
||||
if status.Code(msg.Metadata.Err()) == codes.NotFound {
|
||||
notFoundCount++
|
||||
}
|
||||
}
|
||||
@@ -269,7 +269,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
|
||||
images := make([]api.MachineImage, len(resp.Messages))
|
||||
for i, msg := range resp.Messages {
|
||||
images[i].Metadata = msg.Metadata
|
||||
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||
if msg.Metadata.Err() != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ func parseRemoteImageMessage(msg *pb.RemoteImage) (api.MachineRemoteImage, error
|
||||
mri := api.MachineRemoteImage{
|
||||
Metadata: msg.Metadata,
|
||||
}
|
||||
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||
if msg.Metadata.Err() != nil {
|
||||
return mri, nil
|
||||
}
|
||||
|
||||
@@ -393,7 +393,7 @@ func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]Ma
|
||||
machineVolumes := make([]MachineVolumes, len(resp.Messages))
|
||||
for i, msg := range resp.Messages {
|
||||
machineVolumes[i].Metadata = msg.Metadata
|
||||
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||
if msg.Metadata.Err() != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -472,7 +472,7 @@ func (c *Client) ListServiceContainers(
|
||||
machineContainers := make([]MachineServiceContainers, len(resp.Messages))
|
||||
for i, msg := range resp.Messages {
|
||||
machineContainers[i].Metadata = msg.Metadata
|
||||
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||
if msg.Metadata.Err() != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const (
|
||||
type Controller struct {
|
||||
// machineID is the ID of the machine where the managed Docker daemon is running.
|
||||
machineID string
|
||||
client *client.Client
|
||||
client client.APIClient
|
||||
service *Service
|
||||
store *store.Store
|
||||
}
|
||||
@@ -129,6 +129,11 @@ func (c *Controller) WatchAndSyncContainers(ctx context.Context) error {
|
||||
"container_name", e.Actor.Attributes["name"],
|
||||
"action", e.Action)
|
||||
|
||||
if err := c.syncContainersToStore(ctx); err != nil {
|
||||
return fmt.Errorf("sync containers to cluster store: %w", err)
|
||||
}
|
||||
case <-c.service.ContainersChanged():
|
||||
slog.Debug("Syncing containers to cluster store triggered by a container change in machine database.")
|
||||
if err := c.syncContainersToStore(ctx); err != nil {
|
||||
return fmt.Errorf("sync containers to cluster store: %w", err)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -56,7 +56,7 @@ var fullDockerIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
// Server implements the gRPC Docker service that proxies requests to the Docker daemon.
|
||||
type Server struct {
|
||||
pb.UnimplementedDockerServer
|
||||
client *client.Client
|
||||
client client.APIClient
|
||||
service *Service
|
||||
db *sqlx.DB
|
||||
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
|
||||
@@ -349,6 +349,41 @@ func (s *Server) InspectImage(ctx context.Context, req *pb.InspectImageRequest)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RemoveImage removes an image while protecting names used by containers, even when force is requested.
|
||||
func (s *Server) RemoveImage(ctx context.Context, req *pb.RemoveImageRequest) (*pb.RemoveImageResponse, error) {
|
||||
opts := image.RemoveOptions{Force: req.Force, PruneChildren: req.PruneChildren}
|
||||
for _, platform := range req.Platforms {
|
||||
opts.Platforms = append(opts.Platforms, ocispec.Platform{
|
||||
Architecture: platform.Architecture,
|
||||
OS: platform.Os,
|
||||
OSVersion: platform.OsVersion,
|
||||
OSFeatures: platform.OsFeatures,
|
||||
Variant: platform.Variant,
|
||||
})
|
||||
}
|
||||
|
||||
removals, err := s.service.RemoveImage(ctx, req.Id, opts)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errdefs.IsNotFound(err):
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
case errdefs.IsConflict(err):
|
||||
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
||||
case errdefs.IsInvalidArgument(err):
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
default:
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
result := &pb.MachineImageRemoval{}
|
||||
for _, removal := range removals {
|
||||
result.Results = append(result.Results, &pb.ImageRemoval{Deleted: removal.Deleted, Untagged: removal.Untagged})
|
||||
}
|
||||
|
||||
return &pb.RemoveImageResponse{Messages: []*pb.MachineImageRemoval{result}}, nil
|
||||
}
|
||||
|
||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's Docker auth
|
||||
// credentials if necessary.
|
||||
func (s *Server) InspectRemoteImage(
|
||||
@@ -559,21 +594,26 @@ func (s *Server) CreateServiceContainer(
|
||||
}
|
||||
}
|
||||
|
||||
// Apply user-defined labels first so that the system labels always take precedence.
|
||||
labels := maps.Clone(spec.Container.Labels)
|
||||
if labels == nil {
|
||||
labels = make(map[string]string)
|
||||
}
|
||||
labels[api.LabelServiceID] = req.ServiceId
|
||||
labels[api.LabelServiceName] = spec.Name
|
||||
labels[api.LabelServiceMode] = spec.Mode
|
||||
labels[api.LabelManaged] = ""
|
||||
|
||||
config := &container.Config{
|
||||
Cmd: spec.Container.Command,
|
||||
Env: envVars.ToSlice(),
|
||||
Entrypoint: spec.Container.Entrypoint,
|
||||
Hostname: containerName,
|
||||
Image: spec.Container.Image,
|
||||
Labels: map[string]string{
|
||||
api.LabelServiceID: req.ServiceId,
|
||||
api.LabelServiceName: spec.Name,
|
||||
api.LabelServiceMode: spec.Mode,
|
||||
api.LabelManaged: "",
|
||||
},
|
||||
User: spec.Container.User,
|
||||
Tty: spec.Container.Tty,
|
||||
OpenStdin: spec.Container.OpenStdin,
|
||||
Labels: labels,
|
||||
User: spec.Container.User,
|
||||
Tty: spec.Container.Tty,
|
||||
OpenStdin: spec.Container.OpenStdin,
|
||||
}
|
||||
if spec.Mode == "" {
|
||||
config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated
|
||||
@@ -694,12 +734,10 @@ func (s *Server) CreateServiceContainer(
|
||||
|
||||
// Strip the container configuration that doesn't make sense for the pre-deploy hook and apply its overrides.
|
||||
if spec.PreDeploy != nil && req.ContainerType == pb.CreateServiceContainerRequest_PRE_DEPLOY {
|
||||
config.Labels = map[string]string{
|
||||
api.LabelServiceID: req.ServiceId,
|
||||
api.LabelServiceName: spec.Name,
|
||||
api.LabelHook: api.LabelHookPreDeploy,
|
||||
api.LabelManaged: "",
|
||||
}
|
||||
// Keep the user-defined labels but replace the service mode and ports labels with the hook label.
|
||||
delete(config.Labels, api.LabelServiceMode)
|
||||
delete(config.Labels, api.LabelServicePorts)
|
||||
config.Labels[api.LabelHook] = api.LabelHookPreDeploy
|
||||
config.Healthcheck = &container.HealthConfig{
|
||||
Test: []string{"NONE"},
|
||||
}
|
||||
@@ -1144,6 +1182,36 @@ func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveConta
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// UpdateServiceContainer updates the requested properties of a service container in place without recreating it.
|
||||
func (s *Server) UpdateServiceContainer(
|
||||
ctx context.Context, req *pb.UpdateServiceContainerRequest,
|
||||
) (*emptypb.Empty, error) {
|
||||
opts := api.UpdateContainerOptions{
|
||||
CPU: req.Cpu,
|
||||
Memory: req.Memory,
|
||||
MemoryReservation: req.MemoryReservation,
|
||||
}
|
||||
if req.ServiceLabels != nil {
|
||||
labels := req.ServiceLabels.Labels
|
||||
opts.ServiceLabels = &labels
|
||||
}
|
||||
|
||||
if err := s.service.UpdateServiceContainer(ctx, req.Id, opts); err != nil {
|
||||
switch {
|
||||
case errdefs.IsNotFound(err):
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
case errdefs.IsInvalidArgument(err):
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
case errdefs.IsFailedPrecondition(err):
|
||||
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
||||
default:
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
// logsHeartbeatInterval is the interval at which heartbeat entries are sent when there are no logs to stream.
|
||||
const logsHeartbeatInterval = 200 * time.Millisecond
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/events"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
@@ -31,19 +32,39 @@ import (
|
||||
// from the machine database.
|
||||
type Service struct {
|
||||
// Client is a Docker client for managing Docker resources.
|
||||
Client *client.Client
|
||||
Client client.APIClient
|
||||
// db is a connection to the machine database.
|
||||
db *sqlx.DB
|
||||
// containersChanged signals that service containers changed in the machine database without a corresponding
|
||||
// Docker event, for example, when a container spec is updated in place.
|
||||
containersChanged chan struct{}
|
||||
}
|
||||
|
||||
// NewService creates a new Docker service instance.
|
||||
func NewService(client *client.Client, db *sqlx.DB) *Service {
|
||||
func NewService(client client.APIClient, db *sqlx.DB) *Service {
|
||||
return &Service{
|
||||
Client: client,
|
||||
db: db,
|
||||
// Buffering as a pending signal is enough for the consumer to pick up all changes made before it reacts.
|
||||
containersChanged: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyContainersChanged signals that service containers changed in the machine database. It doesn't block
|
||||
// if a signal is already pending.
|
||||
func (s *Service) NotifyContainersChanged() {
|
||||
select {
|
||||
case s.containersChanged <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// ContainersChanged returns a channel that receives a signal when service containers change in the machine database
|
||||
// without a corresponding Docker event.
|
||||
func (s *Service) ContainersChanged() <-chan struct{} {
|
||||
return s.containersChanged
|
||||
}
|
||||
|
||||
// WatchDaemonRestart watches the Docker engine and signals on the returned channel whenever the daemon
|
||||
// becomes reachable again after a disconnect, which typically indicates a restart.
|
||||
func (s *Service) WatchDaemonRestart(ctx context.Context) <-chan struct{} {
|
||||
@@ -141,6 +162,90 @@ func (s *Service) InspectServiceContainer(ctx context.Context, nameOrID string)
|
||||
return serviceCtr, nil
|
||||
}
|
||||
|
||||
// UpdateServiceContainer updates the requested properties of a service container in place without recreating it.
|
||||
// It applies the CPU and memory limits to the container and updates the service spec stored for the container.
|
||||
func (s *Service) UpdateServiceContainer(ctx context.Context, id string, opts api.UpdateContainerOptions) error {
|
||||
if opts.ServiceLabels != nil {
|
||||
if err := api.ValidateLabelKeys(*opts.ServiceLabels); err != nil {
|
||||
return errdefs.ErrInvalidArgument.WithMessage(fmt.Sprintf("invalid service labels: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
ctr, err := s.InspectServiceContainer(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec := ctr.ServiceSpec
|
||||
|
||||
resources := spec.Container.Resources
|
||||
if opts.CPU != nil {
|
||||
resources.CPU = *opts.CPU
|
||||
}
|
||||
if opts.Memory != nil {
|
||||
resources.Memory = *opts.Memory
|
||||
}
|
||||
if opts.MemoryReservation != nil {
|
||||
resources.MemoryReservation = *opts.MemoryReservation
|
||||
}
|
||||
if resources.CPU < 0 || resources.Memory < 0 || resources.MemoryReservation < 0 {
|
||||
return errdefs.ErrInvalidArgument.WithMessage("CPU and memory limits must not be negative")
|
||||
}
|
||||
// Compare with the limits Docker applies to the container rather than the stored spec. They may differ if
|
||||
// a previous update applied the limits but failed to store the spec, or the container was updated outside
|
||||
// of Uncloud.
|
||||
applied := ctr.AppliedResources()
|
||||
if err = applied.ValidateUpdateInPlace(resources); err != nil {
|
||||
return errdefs.ErrFailedPrecondition.WithMessage(
|
||||
fmt.Sprintf("container '%s' can't be updated without recreating it: %v", ctr.ID, err))
|
||||
}
|
||||
|
||||
// Only CPU and memory limits can differ after the in-place update check above.
|
||||
if !applied.Equals(resources) {
|
||||
updateResources := container.Resources{
|
||||
NanoCPUs: resources.CPU,
|
||||
Memory: resources.Memory,
|
||||
MemoryReservation: resources.MemoryReservation,
|
||||
}
|
||||
// Docker defaults the swap limit to twice the memory limit when creating a container and rejects raising
|
||||
// the memory limit above the current swap limit, so update both to keep the same semantics.
|
||||
if resources.Memory > 0 {
|
||||
updateResources.MemorySwap = 2 * resources.Memory
|
||||
}
|
||||
if _, err = s.Client.ContainerUpdate(ctx, ctr.ID, container.UpdateConfig{
|
||||
Resources: updateResources,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("update container resources: %w", err)
|
||||
}
|
||||
}
|
||||
spec.Container.Resources = resources
|
||||
|
||||
if opts.ServiceLabels != nil {
|
||||
spec.Labels = nil
|
||||
if len(*opts.ServiceLabels) > 0 {
|
||||
spec.Labels = *opts.ServiceLabels
|
||||
}
|
||||
}
|
||||
|
||||
specBytes, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal service spec: %w", err)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE containers SET service_spec = $1, updated_at = datetime('subsecond') WHERE id = $2`,
|
||||
string(specBytes), ctr.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update container in database: %w", err)
|
||||
}
|
||||
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
||||
return errdefs.ErrNotFound.WithMessage(fmt.Sprintf("container '%s' not found in machine database", ctr.ID))
|
||||
}
|
||||
|
||||
// Propagate the updated spec to the cluster store without waiting for the next regular sync.
|
||||
s.NotifyContainersChanged()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListServiceContainersResult holds the result of listing service containers, split into regular
|
||||
// service containers and one-off hook containers.
|
||||
type ListServiceContainersResult struct {
|
||||
|
||||
@@ -836,6 +836,11 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
|
||||
if m.Initialised() {
|
||||
return nil, status.Error(codes.FailedPrecondition, "machine is already configured as a cluster member")
|
||||
}
|
||||
if req.MachineName != "" {
|
||||
if err := api.ValidateMachineName(req.MachineName); err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
clusterNetwork, err := req.Network.ToPrefix()
|
||||
if err != nil {
|
||||
@@ -1220,8 +1225,8 @@ func (m *Machine) applyMachineUpdate(ctx context.Context, req *pb.UpdateMachineR
|
||||
defer m.state.mu.Unlock()
|
||||
|
||||
if req.Name != nil {
|
||||
if *req.Name == "" {
|
||||
return status.Error(codes.InvalidArgument, "machine name cannot be empty")
|
||||
if err := api.ValidateMachineName(*req.Name); err != nil {
|
||||
return status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
// Check for duplicate names across the cluster, excluding this machine.
|
||||
if *req.Name != m.state.Name {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/psviderski/uncloud/api/pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestInitCluster_InvalidMachineName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, name := range []string{"VPS1", "rr", "nearest", "c337f00600de51ef4375c9a9a267dba5"} {
|
||||
// Invalid names must be rejected before initializing cluster state.
|
||||
m := &Machine{state: &State{}}
|
||||
|
||||
resp, err := m.InitCluster(context.Background(), &pb.InitClusterRequest{MachineName: name})
|
||||
require.Nil(t, resp)
|
||||
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||
require.ErrorContains(t, err, "invalid machine name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMachine_InvalidName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, name := range []string{"", "VPS1", "worker.example", "rr", "nearest", "c337f00600de51ef4375c9a9a267dba5"} {
|
||||
m := &Machine{state: &State{ID: "machine-id", Name: "worker"}}
|
||||
resp, err := m.UpdateMachine(context.Background(), &pb.UpdateMachineRequest{Name: &name})
|
||||
require.Nil(t, resp)
|
||||
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||
require.ErrorContains(t, err, "invalid machine name")
|
||||
require.Equal(t, "worker", m.state.Name)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ const (
|
||||
// SyncStatusOutdated indicates that a container record may be outdated, for example, due to being unable
|
||||
// to retrieve the container's state from the Docker daemon or when the machine is being stopped or restarted.
|
||||
SyncStatusOutdated = "outdated"
|
||||
|
||||
// containerChangesDebounceInterval defines how long to wait before notifying subscribers about container changes.
|
||||
// Multiple changes within this window are grouped into a single notification to prevent system overload.
|
||||
containerChangesDebounceInterval = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
type ContainerRecord struct {
|
||||
@@ -92,6 +96,9 @@ func normaliseContainerForStore(ctr *api.ServiceContainer) {
|
||||
// Remove the environment variables to avoid leaking secrets.
|
||||
ctr.Config.Env = nil
|
||||
ctr.ServiceSpec.Container.Env = nil
|
||||
if ctr.ServiceSpec.PreDeploy != nil {
|
||||
ctr.ServiceSpec.PreDeploy.Env = nil
|
||||
}
|
||||
|
||||
// Docker returns Mounts in a non-deterministic order so sort them.
|
||||
slices.SortFunc(ctr.Mounts, func(a, b container.MountPoint) int {
|
||||
@@ -267,6 +274,13 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-c
|
||||
changes := make(chan struct{})
|
||||
go func() {
|
||||
defer close(changes)
|
||||
// Coalesce bursts of rapid-fire row-level change events (e.g. from frequent health-check updates
|
||||
// across many services) into a single signal per debounce window, instead of forwarding one signal
|
||||
// per event. Every subscriber of this channel (e.g. the Caddy and DNS reconcilers) otherwise reruns
|
||||
// its full reconciliation on every single event, which can burn significant CPU across the cluster
|
||||
// when there's a lot of container churn.
|
||||
var debouncer *time.Timer
|
||||
var debouncerCh <-chan time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -279,8 +293,21 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-c
|
||||
}
|
||||
return
|
||||
}
|
||||
// Just signal that there is a change in the containers list.
|
||||
changes <- struct{}{}
|
||||
if debouncerCh == nil {
|
||||
if debouncer == nil {
|
||||
debouncer = time.NewTimer(containerChangesDebounceInterval)
|
||||
} else {
|
||||
debouncer.Reset(containerChangesDebounceInterval)
|
||||
}
|
||||
debouncerCh = debouncer.C
|
||||
}
|
||||
case <-debouncerCh:
|
||||
select {
|
||||
case changes <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
debouncerCh = nil
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -1,40 +1,58 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
|
||||
|
||||
lockfile_version = 3
|
||||
|
||||
[[tools."aqua:vektra/mockery"]]
|
||||
version = "3.5.3"
|
||||
version = "3.8.1"
|
||||
backend = "aqua:vektra/mockery"
|
||||
specifiers = ["3.8.1"]
|
||||
|
||||
[tools."aqua:vektra/mockery"."platforms.linux-arm64"]
|
||||
checksum = "sha256:ec02ca72b7ad1ff5a1de5503a27d3b1b3f230d722eb35c5b92e43e295a8e1290"
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_arm64.tar.gz"
|
||||
checksum = "sha256:d1f481f3a6e9868dd3ad65da265238ee109ab3a5dd7508958d5811953696f452"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915757"
|
||||
|
||||
[tools."aqua:vektra/mockery"."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:ec02ca72b7ad1ff5a1de5503a27d3b1b3f230d722eb35c5b92e43e295a8e1290"
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_arm64.tar.gz"
|
||||
checksum = "sha256:d1f481f3a6e9868dd3ad65da265238ee109ab3a5dd7508958d5811953696f452"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915757"
|
||||
|
||||
[tools."aqua:vektra/mockery"."platforms.linux-x64"]
|
||||
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz"
|
||||
checksum = "sha256:953c7d38b58dd6398b1c595f27caf8bbf27e9b733b49c404d2e124e86d2ae72a"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Linux_x86_64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915756"
|
||||
|
||||
[tools."aqua:vektra/mockery"."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz"
|
||||
checksum = "sha256:953c7d38b58dd6398b1c595f27caf8bbf27e9b733b49c404d2e124e86d2ae72a"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Linux_x86_64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915756"
|
||||
|
||||
[tools."aqua:vektra/mockery"."platforms.macos-arm64"]
|
||||
checksum = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_arm64.tar.gz"
|
||||
checksum = "sha256:303a794484243906b37da0c37056f974bf94278c2e10d7b1372e6bd53a2d5034"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915806"
|
||||
|
||||
[tools."aqua:vektra/mockery"."platforms.macos-x64"]
|
||||
checksum = "sha256:8bd2b3e4722e476400395920fb5bbd68be4ea22ec12cddf1e424c40610fda863"
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_x86_64.tar.gz"
|
||||
checksum = "sha256:6882331344e946268b304dce042b01371a0213a896536413be8abe49d532ca6f"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Darwin_x86_64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915746"
|
||||
|
||||
[tools."aqua:vektra/mockery"."platforms.windows-x64"]
|
||||
checksum = "sha256:3b4264066db615a56a8b59abda992f6db82b8cd7666937ed9dd8f4a4bfc424a0"
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Windows_x86_64.tar.gz"
|
||||
checksum = "sha256:7a7458756e9d7bce5f9936b7a63394000f3affa751b9740e65350c4bdcb627dc"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Windows_x86_64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915759"
|
||||
|
||||
[[tools.go]]
|
||||
version = "1.26.1"
|
||||
backend = "core:go"
|
||||
specifiers = ["1.26.1"]
|
||||
|
||||
[tools.go."platforms.linux-arm64"]
|
||||
checksum = "sha256:a290581cfe4fe28ddd737dde3095f3dbeb7f2e4065cab4eae44dfc53b760c2f7"
|
||||
@@ -67,95 +85,118 @@ url = "https://dl.google.com/go/go1.26.1.windows-amd64.zip"
|
||||
[[tools.golangci-lint]]
|
||||
version = "2.11.2"
|
||||
backend = "aqua:golangci/golangci-lint"
|
||||
specifiers = ["2.11.2"]
|
||||
|
||||
[tools.golangci-lint."platforms.linux-arm64"]
|
||||
checksum = "sha256:c3c83c7ed003e881991af21361c487151abd37969927ed9062cd0b48f3cd1d8d"
|
||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099658"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.golangci-lint."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:c3c83c7ed003e881991af21361c487151abd37969927ed9062cd0b48f3cd1d8d"
|
||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099658"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.golangci-lint."platforms.linux-x64"]
|
||||
checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9"
|
||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099674"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.golangci-lint."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9"
|
||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099674"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.golangci-lint."platforms.macos-arm64"]
|
||||
checksum = "sha256:11e9549b955bae4bfb71aa3b5c2762e446e2d3f975fe963d6d767b8f745d9344"
|
||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-darwin-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099671"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.golangci-lint."platforms.macos-x64"]
|
||||
checksum = "sha256:1dbbbe745eadba9cf012d49bd0b8c9290cbcb50dbf2cf9611f75a715b46ae3af"
|
||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-darwin-amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099653"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.golangci-lint."platforms.windows-x64"]
|
||||
checksum = "sha256:60e50178253d276d1c808ef00845eddcabca63b7d69d86a6dbaa41e88bc843f3"
|
||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-windows-amd64.zip"
|
||||
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099651"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.goreleaser]]
|
||||
version = "2.15.2"
|
||||
backend = "aqua:goreleaser/goreleaser"
|
||||
specifiers = ["2.15.2"]
|
||||
|
||||
[tools.goreleaser."platforms.linux-arm64"]
|
||||
checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9"
|
||||
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz"
|
||||
provenance = "cosign"
|
||||
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702105"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.goreleaser."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9"
|
||||
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz"
|
||||
provenance = "cosign"
|
||||
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702105"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.goreleaser."platforms.linux-x64"]
|
||||
checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc"
|
||||
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702106"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.goreleaser."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc"
|
||||
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz"
|
||||
provenance = "cosign"
|
||||
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702106"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.goreleaser."platforms.macos-arm64"]
|
||||
checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee"
|
||||
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz"
|
||||
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702148"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.goreleaser."platforms.macos-x64"]
|
||||
checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee"
|
||||
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz"
|
||||
provenance = "cosign"
|
||||
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702148"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.goreleaser."platforms.windows-x64"]
|
||||
checksum = "sha256:7459832946dbe122c144f8d7f87484d8572ca005b779310aa6bb03346e8de17a"
|
||||
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Windows_x86_64.zip"
|
||||
provenance = "cosign"
|
||||
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702135"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.node]]
|
||||
version = "22.20.0"
|
||||
backend = "core:node"
|
||||
specifiers = ["22.20.0"]
|
||||
|
||||
[tools.node."platforms.linux-arm64"]
|
||||
checksum = "sha256:4181609e03dcb9880e7e5bf956061ecc0503c77a480c6631d868cb1f65a2c7dd"
|
||||
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-arm64.tar.gz"
|
||||
|
||||
[tools.node."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:4181609e03dcb9880e7e5bf956061ecc0503c77a480c6631d868cb1f65a2c7dd"
|
||||
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-arm64.tar.gz"
|
||||
checksum = "sha256:79a087c1e23fc97d173ad6676df5b1a4e1a02daf1acca05ee25662747c5d492c"
|
||||
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0.tar.gz"
|
||||
install = "source"
|
||||
|
||||
[tools.node."platforms.linux-x64"]
|
||||
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
|
||||
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
|
||||
|
||||
[tools.node."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
|
||||
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
|
||||
checksum = "sha256:461aafed5eb607357479c6de5df978515d74daf7e0169e972e61c8390c035f1a"
|
||||
url = "https://unofficial-builds.nodejs.org/download/release/v22.20.0/node-v22.20.0-linux-x64-musl.tar.gz"
|
||||
|
||||
[tools.node."platforms.macos-arm64"]
|
||||
checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
|
||||
@@ -172,41 +213,119 @@ url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-win-x64.zip"
|
||||
[[tools.protoc]]
|
||||
version = "27.3"
|
||||
backend = "aqua:protocolbuffers/protobuf/protoc"
|
||||
specifiers = ["27.3"]
|
||||
|
||||
[tools.protoc."platforms.linux-arm64"]
|
||||
checksum = "sha256:bdad36f3ad7472281d90568c4956ea2e203c216e0de005c6bd486f1920f2751c"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-aarch_64.zip"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006374"
|
||||
|
||||
[tools.protoc."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:bdad36f3ad7472281d90568c4956ea2e203c216e0de005c6bd486f1920f2751c"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-aarch_64.zip"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006374"
|
||||
|
||||
[tools.protoc."platforms.linux-x64"]
|
||||
checksum = "blake3:8c2938accf7061164ea3968529a47ced1db4824ca553857f6903c68ae4347739"
|
||||
checksum = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-x86_64.zip"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006380"
|
||||
|
||||
[tools.protoc."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-x86_64.zip"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006380"
|
||||
|
||||
[tools.protoc."platforms.macos-arm64"]
|
||||
checksum = "sha256:b22116bd97cdbd7ea25346abe635a9df268515fe5ef5afa93cd9a68fc2513f84"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-osx-aarch_64.zip"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006383"
|
||||
|
||||
[tools.protoc."platforms.macos-x64"]
|
||||
checksum = "sha256:ce282648fed0e7fbd6237d606dc9ec168dd2c1863889b04efa0b19c47da65d1b"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-osx-x86_64.zip"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006381"
|
||||
|
||||
[tools.protoc."platforms.windows-x64"]
|
||||
checksum = "sha256:bdccd341a0c25ff4f65d8530f170c78ce5681e42920aa5d892f27ac06cc3bba0"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-win64.zip"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006385"
|
||||
|
||||
[[tools.protoc-gen-go]]
|
||||
version = "1.34.2"
|
||||
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
|
||||
specifiers = ["1.34.2"]
|
||||
|
||||
[tools.protoc-gen-go."platforms.linux-arm64"]
|
||||
checksum = "sha256:63d400167e75ab9f6690688f6fdc6a9455aa20bc1faa71e32149dbd322f7f198"
|
||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109690"
|
||||
|
||||
[tools.protoc-gen-go."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:63d400167e75ab9f6690688f6fdc6a9455aa20bc1faa71e32149dbd322f7f198"
|
||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109690"
|
||||
|
||||
[tools.protoc-gen-go."platforms.linux-x64"]
|
||||
checksum = "blake3:f765e393e0f0b25070c13848d88f5e6303e66090132afa1b753e981507a4174e"
|
||||
checksum = "sha256:b87bc134dee55576a842141bf0ed27761c635d746780fce5dee038c6dd16554f"
|
||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109692"
|
||||
|
||||
[tools.protoc-gen-go."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:b87bc134dee55576a842141bf0ed27761c635d746780fce5dee038c6dd16554f"
|
||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109692"
|
||||
|
||||
[tools.protoc-gen-go."platforms.macos-arm64"]
|
||||
checksum = "sha256:17aca7f948dbb624049030cf841e35895cf34183ba006e721247fdeb95ff2780"
|
||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109697"
|
||||
|
||||
[tools.protoc-gen-go."platforms.macos-x64"]
|
||||
checksum = "sha256:9b48d8f90add02e8e94e14962fed74e7ce2b2d6bda4dd42f1f4fbccf0f766f1a"
|
||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109698"
|
||||
|
||||
[tools.protoc-gen-go."platforms.windows-x64"]
|
||||
checksum = "sha256:abafd39612177dd4e9a65207cadd5374a9352d8611e8e040f8462fcfa3010daf"
|
||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.windows.amd64.zip"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109685"
|
||||
|
||||
[[tools.protoc-gen-go-grpc]]
|
||||
version = "1.5.1"
|
||||
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
|
||||
specifiers = ["1.5.1"]
|
||||
|
||||
[tools.protoc-gen-go-grpc."platforms.linux-arm64"]
|
||||
checksum = "sha256:370a7cfa5786e61e6fdc5aa8f689e35c2d27d036a49caac7aa25f962c89a01e8"
|
||||
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572238"
|
||||
|
||||
[tools.protoc-gen-go-grpc."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:370a7cfa5786e61e6fdc5aa8f689e35c2d27d036a49caac7aa25f962c89a01e8"
|
||||
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572238"
|
||||
|
||||
[tools.protoc-gen-go-grpc."platforms.linux-x64"]
|
||||
checksum = "blake3:197739132f0408f017d0c83773ac1ace85c0572473fdd658ec6e4ccbf20e0552"
|
||||
checksum = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
|
||||
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572229"
|
||||
|
||||
[tools.protoc-gen-go-grpc."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
|
||||
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572229"
|
||||
|
||||
[tools.protoc-gen-go-grpc."platforms.macos-arm64"]
|
||||
checksum = "sha256:d6083feb51dcfe59f26793e99ef01ef5eac68b64294ec2546711f614ac5878f3"
|
||||
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572243"
|
||||
|
||||
[tools.protoc-gen-go-grpc."platforms.macos-x64"]
|
||||
checksum = "sha256:c12c259d59c8b451f24b6abdad6d055fd0884c5268b7247fd7e5ec9d2432d1f1"
|
||||
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.darwin.amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572208"
|
||||
|
||||
[tools.protoc-gen-go-grpc."platforms.windows-x64"]
|
||||
checksum = "sha256:c2ad1460c9e34b5b37759826da61220be6ee0a8a11431723d205512a69319991"
|
||||
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.windows.amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572216"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
[settings]
|
||||
experimental = true
|
||||
lockfile = true
|
||||
|
||||
[tools]
|
||||
"aqua:vektra/mockery" = "3.5.3"
|
||||
"aqua:vektra/mockery" = "3.8.1"
|
||||
go = "1.26.1"
|
||||
golangci-lint = "2.11.2"
|
||||
goreleaser = "2.15.2"
|
||||
@@ -36,9 +37,37 @@ GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/psviderski/uncloud/inte
|
||||
"""
|
||||
outputs = ["uncloudd-linux-arm64"]
|
||||
|
||||
[tasks."mise:lock"]
|
||||
description = "Update mise.lock with hashes for all platforms"
|
||||
run = "mise lock --platform linux-x64,macos-arm64"
|
||||
[tasks."docs:cli"]
|
||||
description = "Regenerate the CLI reference docs"
|
||||
run = "go run ./cmd/uc docs"
|
||||
|
||||
[tasks."docs:dev"]
|
||||
description = "Run the documentation website locally"
|
||||
dir = "website"
|
||||
run = "npm start --"
|
||||
|
||||
[tasks.fmt]
|
||||
description = "Format Go code and imports"
|
||||
alias = "format"
|
||||
env.GOOS = "linux"
|
||||
run = "golangci-lint fmt"
|
||||
|
||||
[tasks.lint]
|
||||
description = "Check Go code and formatting"
|
||||
# Explicitly set OS to Linux to not skip *_linux.go files when running on macOS.
|
||||
# Uncloud daemon won't likely support OS other than Linux anytime soon, so for now we can rely on that.
|
||||
env.GOOS = "linux"
|
||||
run = "golangci-lint run"
|
||||
|
||||
[tasks."lint:fix"]
|
||||
description = "Format Go code and apply available lint fixes"
|
||||
alias = "lint-and-fix"
|
||||
env.GOOS = "linux"
|
||||
run = "golangci-lint run --fix"
|
||||
|
||||
[tasks.mocks]
|
||||
description = "Regenerate Go test mocks"
|
||||
run = "mockery"
|
||||
|
||||
[tasks.proto]
|
||||
description = "Regenerate gRPC API code from .proto files"
|
||||
@@ -49,10 +78,12 @@ protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=p
|
||||
|
||||
[tasks.uc]
|
||||
description = "Run the Uncloud CLI"
|
||||
raw_args = true
|
||||
run = "go run ./cmd/uc"
|
||||
|
||||
[tasks.ucind]
|
||||
description = "Run ucind CLI for managing local Docker-based clusters"
|
||||
raw_args = true
|
||||
run = "go run ./cmd/ucind"
|
||||
|
||||
[tasks."ucind:image"]
|
||||
|
||||
@@ -5,6 +5,15 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// LabelCaddyManaged marks the caddy service as managed by 'uc caddy deploy'.
|
||||
LabelCaddyManaged = LabelReservedPrefix + "caddy.managed"
|
||||
// LabelCaddyImageCustom marks the caddy image as explicitly set instead of following the latest default image.
|
||||
LabelCaddyImageCustom = LabelReservedPrefix + "caddy.image.custom"
|
||||
// LabelCaddyConfigCustom marks the global Caddy config as explicitly set instead of following the default config.
|
||||
LabelCaddyConfigCustom = LabelReservedPrefix + "caddy.config.custom"
|
||||
)
|
||||
|
||||
// CaddyConfig is the saved Caddy configuration on a machine.
|
||||
type CaddyConfig struct {
|
||||
Caddyfile string
|
||||
|
||||
@@ -30,6 +30,7 @@ type ContainerClient interface {
|
||||
StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error
|
||||
StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error
|
||||
RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
|
||||
UpdateContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts UpdateContainerOptions) error
|
||||
WaitContainerHealthy(
|
||||
ctx context.Context, serviceNameOrID, containerNameOrID string, opts WaitContainerHealthyOptions,
|
||||
) error
|
||||
@@ -42,6 +43,8 @@ type DNSClient interface {
|
||||
type ImageClient interface {
|
||||
InspectImage(ctx context.Context, id string) ([]MachineImage, error)
|
||||
InspectRemoteImage(ctx context.Context, id string) ([]MachineRemoteImage, error)
|
||||
ListImages(ctx context.Context, filter ImageFilter) ([]MachineImages, error)
|
||||
RemoveImage(ctx context.Context, image string, opts RemoveImageOptions) ([]MachineImageRemoval, error)
|
||||
}
|
||||
|
||||
type LogsClient interface {
|
||||
|
||||
@@ -19,6 +19,11 @@ const (
|
||||
// in internal/machine/docker/manager.go.
|
||||
DockerNetworkName = "uncloud"
|
||||
|
||||
// LabelReservedPrefix is the prefix of labels reserved for Uncloud that users can't set on services and containers.
|
||||
LabelReservedPrefix = "uncloud."
|
||||
// LabelDaemonReservedPrefix is the prefix of labels reserved for the Uncloud daemon system containers.
|
||||
LabelDaemonReservedPrefix = "uncloudd."
|
||||
|
||||
// LabelDaemonManaged marks system containers as managed by the Uncloud daemon.
|
||||
LabelDaemonManaged = "uncloudd.managed"
|
||||
|
||||
@@ -175,6 +180,19 @@ type CreateContainerResponse struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// UpdateContainerOptions defines the properties of a service container to update in place without recreating it.
|
||||
// Nil fields are left unchanged.
|
||||
type UpdateContainerOptions struct {
|
||||
// CPU is the new CPU limit in nanocores. The limit can be changed but not removed.
|
||||
CPU *int64
|
||||
// Memory is the new memory limit in bytes. The limit can be changed but not removed.
|
||||
Memory *int64
|
||||
// MemoryReservation is the new memory reservation in bytes. The reservation can be changed but not removed.
|
||||
MemoryReservation *int64
|
||||
// ServiceLabels replaces all service labels. A pointer to an empty map removes all service labels.
|
||||
ServiceLabels *map[string]string
|
||||
}
|
||||
|
||||
type ServiceContainer struct {
|
||||
Container
|
||||
ServiceSpec ServiceSpec
|
||||
@@ -195,6 +213,21 @@ func (c *ServiceContainer) ServiceName() string {
|
||||
return c.Config.Labels[LabelServiceName]
|
||||
}
|
||||
|
||||
// AppliedResources returns the resources from the service spec with the CPU and memory limits that Docker currently
|
||||
// applies to the container. The applied limits may differ from the spec if an in-place update was interrupted or
|
||||
// the container was updated outside of Uncloud.
|
||||
func (c *ServiceContainer) AppliedResources() ContainerResources {
|
||||
resources := c.ServiceSpec.Container.Resources
|
||||
if c.ContainerJSONBase == nil || c.HostConfig == nil {
|
||||
return resources
|
||||
}
|
||||
|
||||
resources.CPU = c.HostConfig.NanoCPUs
|
||||
resources.Memory = c.HostConfig.Memory
|
||||
resources.MemoryReservation = c.HostConfig.MemoryReservation
|
||||
return resources
|
||||
}
|
||||
|
||||
// ServiceMode returns the replication mode of the service this container belongs to.
|
||||
func (c *ServiceContainer) ServiceMode() string {
|
||||
return c.ServiceSpec.Mode
|
||||
|
||||
+12
-10
@@ -1,8 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
@@ -24,14 +22,6 @@ type MachineImages struct {
|
||||
ContainerdStore bool
|
||||
}
|
||||
|
||||
// Error returns a non-nil error if listing images on this machine failed.
|
||||
func (mi MachineImages) Error() error {
|
||||
if mi.Metadata != nil && mi.Metadata.Error != "" {
|
||||
return errors.New(mi.Metadata.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImageFilter defines criteria to filter images in ListImages.
|
||||
type ImageFilter struct {
|
||||
// Machines filters images to those present on the specified machines (names or IDs).
|
||||
@@ -42,6 +32,18 @@ type ImageFilter struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// RemoveImageOptions defines options for removing an image from cluster machines.
|
||||
type RemoveImageOptions struct {
|
||||
// Machines selects machines to remove the image from (names or IDs). If empty, all cluster machines are selected.
|
||||
Machines []string
|
||||
}
|
||||
|
||||
// MachineImageRemoval contains the outcome of removing an image on one machine.
|
||||
type MachineImageRemoval struct {
|
||||
Metadata *pb.Metadata
|
||||
Results []image.DeleteResponse
|
||||
}
|
||||
|
||||
// MachineRemoteImage represents an image in a remote registry fetched by a particular machine.
|
||||
type MachineRemoteImage struct {
|
||||
Metadata *pb.Metadata
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/api/pb"
|
||||
)
|
||||
|
||||
// ValidateMachineName checks that a machine name is a lowercase DNS label and doesn't conflict with
|
||||
// internal DNS query modes or machine IDs.
|
||||
func ValidateMachineName(name string) error {
|
||||
if !DNSLabelRegex.MatchString(name) {
|
||||
return fmt.Errorf("invalid machine name %q: must be 1-63 characters, lowercase letters, numbers, "+
|
||||
"and hyphens only, starting and ending with a letter or number", name)
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "rr", "nearest":
|
||||
return fmt.Errorf("invalid machine name %q: reserved for internal DNS query modes", name)
|
||||
}
|
||||
if IDRegex.MatchString(name) {
|
||||
return fmt.Errorf(
|
||||
"invalid machine name %q: must not match the machine ID format (32 hexadecimal characters)", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MachineFilter defines criteria to filter machines in ListMachines.
|
||||
type MachineFilter struct {
|
||||
// Available filters machines that are not DOWN.
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/psviderski/uncloud/api/pb"
|
||||
@@ -10,6 +11,52 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateMachineName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "single letter", input: "a"},
|
||||
{name: "single digit", input: "1"},
|
||||
{name: "two characters", input: "a1"},
|
||||
{name: "generated name", input: "machine-ab12"},
|
||||
{name: "hyphens and digits", input: "1-worker-2"},
|
||||
{name: "maximum length", input: strings.Repeat("a", 63)},
|
||||
{name: "maximum length with hyphens", input: "a" + strings.Repeat("-", 61) + "1"},
|
||||
{name: "machine namespace label", input: "m"},
|
||||
{name: "mode prefix", input: "nearest-worker"},
|
||||
{name: "short hexadecimal name", input: strings.Repeat("a", 31)},
|
||||
{name: "long hexadecimal name", input: strings.Repeat("a", 33)},
|
||||
{name: "non-hexadecimal 32 characters", input: strings.Repeat("g", 32)},
|
||||
{name: "empty", wantErr: "must be 1-63 characters"},
|
||||
{name: "too long", input: strings.Repeat("a", 64), wantErr: "must be 1-63 characters"},
|
||||
{name: "uppercase", input: "VPS1", wantErr: "lowercase letters"},
|
||||
{name: "leading hyphen", input: "-worker", wantErr: "starting and ending"},
|
||||
{name: "trailing hyphen", input: "worker-", wantErr: "starting and ending"},
|
||||
{name: "underscore", input: "worker_1", wantErr: "hyphens only"},
|
||||
{name: "dot", input: "worker.example", wantErr: "hyphens only"},
|
||||
{name: "space", input: "worker 1", wantErr: "hyphens only"},
|
||||
{name: "leading whitespace", input: " worker", wantErr: "hyphens only"},
|
||||
{name: "trailing whitespace", input: "worker\t", wantErr: "hyphens only"},
|
||||
{name: "non-ASCII", input: "wörker", wantErr: "lowercase letters"},
|
||||
{name: "round-robin mode", input: "rr", wantErr: "reserved for internal DNS query modes"},
|
||||
{name: "nearest mode", input: "nearest", wantErr: "reserved for internal DNS query modes"},
|
||||
{name: "machine ID", input: "c337f00600de51ef4375c9a9a267dba5", wantErr: "machine ID format"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
err := ValidateMachineName(tt.input)
|
||||
if tt.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMachineMembersList_Info(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -27,6 +31,35 @@ type ContainerResources struct {
|
||||
Ulimits map[string]Ulimit
|
||||
}
|
||||
|
||||
// Equals returns true if the resources are equal, treating nil and empty slices and maps as equal.
|
||||
func (r ContainerResources) Equals(other ContainerResources) bool {
|
||||
return cmp.Equal(r, other, cmpopts.EquateEmpty())
|
||||
}
|
||||
|
||||
// ValidateUpdateInPlace returns an error explaining why a container created with these resources can't be updated
|
||||
// to the new resources without recreating it. It returns nil if the container can be updated in place.
|
||||
// Docker can only change the CPU and memory limits of an existing container and can't remove a limit once it's set.
|
||||
func (r ContainerResources) ValidateUpdateInPlace(new ContainerResources) error {
|
||||
// Docker treats zero values as unchanged when updating a container.
|
||||
if r.CPU != 0 && new.CPU == 0 {
|
||||
return errors.New("CPU limit can't be removed")
|
||||
}
|
||||
if r.Memory != 0 && new.Memory == 0 {
|
||||
return errors.New("memory limit can't be removed")
|
||||
}
|
||||
if r.MemoryReservation != 0 && new.MemoryReservation == 0 {
|
||||
return errors.New("memory reservation can't be removed")
|
||||
}
|
||||
|
||||
r.CPU = new.CPU
|
||||
r.Memory = new.Memory
|
||||
r.MemoryReservation = new.MemoryReservation
|
||||
if !r.Equals(new) {
|
||||
return errors.New("only CPU and memory limits can be changed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeviceMapping represents a device mapping between host and container.
|
||||
type DeviceMapping struct {
|
||||
// HostPath is the path to the device on the host.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestContainerResources_ValidateUpdateInPlace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
current := ContainerResources{
|
||||
CPU: Core,
|
||||
Memory: 512 * 1024 * 1024,
|
||||
MemoryReservation: 256 * 1024 * 1024,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
new func(r *ContainerResources)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "same resources",
|
||||
new: func(*ContainerResources) {},
|
||||
},
|
||||
{
|
||||
name: "change CPU and memory limits",
|
||||
new: func(r *ContainerResources) {
|
||||
r.CPU = 2 * Core
|
||||
r.Memory = 1024 * 1024 * 1024
|
||||
r.MemoryReservation = 128 * 1024 * 1024
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove CPU limit",
|
||||
new: func(r *ContainerResources) { r.CPU = 0 },
|
||||
wantErr: "CPU limit can't be removed",
|
||||
},
|
||||
{
|
||||
name: "remove memory limit",
|
||||
new: func(r *ContainerResources) { r.Memory = 0 },
|
||||
wantErr: "memory limit can't be removed",
|
||||
},
|
||||
{
|
||||
name: "remove memory reservation",
|
||||
new: func(r *ContainerResources) { r.MemoryReservation = 0 },
|
||||
wantErr: "memory reservation can't be removed",
|
||||
},
|
||||
{
|
||||
name: "change shared memory",
|
||||
new: func(r *ContainerResources) { r.SharedMemory = 64 * 1024 * 1024 },
|
||||
wantErr: "only CPU and memory limits can be changed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newResources := current
|
||||
tt.new(&newResources)
|
||||
|
||||
err := current.ValidateUpdateInPlace(newResources)
|
||||
if tt.wantErr == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+99
-9
@@ -8,6 +8,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
mapset "github.com/deckarep/golang-set/v2"
|
||||
"github.com/distribution/reference"
|
||||
@@ -41,12 +42,30 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
|
||||
dnsLabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
|
||||
IDRegex = regexp.MustCompile("^[0-9a-f]{32}$")
|
||||
DNSLabelRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$`)
|
||||
)
|
||||
|
||||
func ValidateServiceID(id string) bool {
|
||||
return serviceIDRegexp.MatchString(id)
|
||||
return IDRegex.MatchString(id)
|
||||
}
|
||||
|
||||
// ValidateServiceName checks that a service name is a lowercase DNS label and doesn't conflict with
|
||||
// the machine DNS namespace or service IDs.
|
||||
func ValidateServiceName(name string) error {
|
||||
if !DNSLabelRegex.MatchString(name) {
|
||||
return fmt.Errorf("invalid service name %q: must be 1-63 characters, lowercase letters, numbers, "+
|
||||
"and hyphens only, starting and ending with a letter or number", name)
|
||||
}
|
||||
if name == "m" {
|
||||
return fmt.Errorf("invalid service name %q: reserved for the machine DNS namespace", name)
|
||||
}
|
||||
if IDRegex.MatchString(name) {
|
||||
return fmt.Errorf(
|
||||
"invalid service name %q: must not match the service ID format (32 hexadecimal characters)", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServiceSpec defines the desired state of a service.
|
||||
@@ -59,6 +78,9 @@ type ServiceSpec struct {
|
||||
Configs []ConfigSpec
|
||||
// Container defines the desired state of each container in the service.
|
||||
Container ContainerSpec
|
||||
// Labels are key-value metadata attached to the service. Unlike container labels, they're not applied to Docker
|
||||
// containers. The "uncloud." key prefix is reserved for labels set by Uncloud.
|
||||
Labels map[string]string
|
||||
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
|
||||
Mode string
|
||||
Name string
|
||||
@@ -139,6 +161,11 @@ func (s *ServiceSpec) Validate() error {
|
||||
if err := s.Container.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
// We want to allow reserved uncloud. labels in the service spec, because we can't distinguish between user-defined
|
||||
// in a Compose file and those set by a uc command.
|
||||
if err := ValidateLabelKeys(s.Labels); err != nil {
|
||||
return fmt.Errorf("invalid service labels: %w", err)
|
||||
}
|
||||
|
||||
switch s.Mode {
|
||||
case "", ServiceModeGlobal, ServiceModeReplicated:
|
||||
@@ -147,12 +174,8 @@ func (s *ServiceSpec) Validate() error {
|
||||
}
|
||||
|
||||
if s.Name != "" {
|
||||
if len(s.Name) > 63 {
|
||||
return fmt.Errorf("service name too long (max 63 characters): %q", s.Name)
|
||||
}
|
||||
if !dnsLabelRegexp.MatchString(s.Name) {
|
||||
return fmt.Errorf("invalid service name: %q. must be 1-63 characters, lowercase letters, numbers, "+
|
||||
"and dashes only; must start and end with a letter or number", s.Name)
|
||||
if err := ValidateServiceName(s.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +252,7 @@ func (s *ServiceSpec) Clone() ServiceSpec {
|
||||
spec.Caddy = &caddyCopy
|
||||
}
|
||||
spec.Container = s.Container.Clone()
|
||||
spec.Labels = maps.Clone(s.Labels)
|
||||
spec.PreDeploy = s.PreDeploy.Clone()
|
||||
|
||||
if s.Ports != nil {
|
||||
@@ -265,6 +289,10 @@ type ContainerSpec struct {
|
||||
Image string
|
||||
// Run a custom init inside the container. If nil, use the daemon's configured settings.
|
||||
Init *bool
|
||||
// Labels are user-defined key-value metadata applied as Docker labels to each container in the service.
|
||||
// Docker labels are immutable so changing them requires recreating the containers.
|
||||
// Keys must not use the reserved "uncloud." prefix.
|
||||
Labels map[string]string
|
||||
// LogDriver overrides the default logging driver for the container. Each Docker daemon can have its own default.
|
||||
LogDriver *LogDriver
|
||||
// PidMode sets the PID namespace mode for the container. Currently only "" or "host" is supported.
|
||||
@@ -319,6 +347,9 @@ func (s *ContainerSpec) Validate() error {
|
||||
if _, err := reference.ParseDockerRef(s.Image); err != nil {
|
||||
return fmt.Errorf("invalid image '%s': %w", s.Image, err)
|
||||
}
|
||||
if err := ValidateLabels(s.Labels); err != nil {
|
||||
return fmt.Errorf("invalid container labels: %w", err)
|
||||
}
|
||||
|
||||
for _, m := range s.VolumeMounts {
|
||||
if err := m.Validate(); err != nil {
|
||||
@@ -376,6 +407,7 @@ func (s *ContainerSpec) Clone() ContainerSpec {
|
||||
hc.Test = slices.Clone(s.Healthcheck.Test)
|
||||
spec.Healthcheck = &hc
|
||||
}
|
||||
spec.Labels = maps.Clone(s.Labels)
|
||||
if s.LogDriver != nil {
|
||||
logDriver := *s.LogDriver
|
||||
if s.LogDriver.Options != nil {
|
||||
@@ -414,6 +446,34 @@ func (s *ContainerSpec) Clone() ContainerSpec {
|
||||
return spec
|
||||
}
|
||||
|
||||
// ValidateLabelKeys checks that label keys are non-empty and don't contain whitespace.
|
||||
func ValidateLabelKeys(labels map[string]string) error {
|
||||
for k := range labels {
|
||||
if strings.TrimSpace(k) == "" {
|
||||
return fmt.Errorf("label key must not be empty")
|
||||
}
|
||||
if strings.ContainsFunc(k, unicode.IsSpace) {
|
||||
return fmt.Errorf("label '%s' must not contain whitespace", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateLabels checks that label keys are valid and don't use the reserved Uncloud prefixes. Use it for labels
|
||||
// provided by users and for container labels.
|
||||
func ValidateLabels(labels map[string]string) error {
|
||||
if err := ValidateLabelKeys(labels); err != nil {
|
||||
return err
|
||||
}
|
||||
for k := range labels {
|
||||
if strings.HasPrefix(k, LabelReservedPrefix) || strings.HasPrefix(k, LabelDaemonReservedPrefix) {
|
||||
return fmt.Errorf("label '%s' cannot use the reserved prefix '%s' or '%s'",
|
||||
k, LabelReservedPrefix, LabelDaemonReservedPrefix)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type EnvVars map[string]string
|
||||
|
||||
// ToSlice converts the environment variables to a slice of strings in the format "key=value".
|
||||
@@ -552,6 +612,36 @@ func (s *Service) FindContainer(nameOrID string) (MachineServiceContainer, error
|
||||
return MachineServiceContainer{}, ErrNotFound
|
||||
}
|
||||
|
||||
// Spec returns the service spec derived from its containers.
|
||||
// Containers may have different specs, for example, after a failed deployment. The result is the spec of the newest
|
||||
// running container, or the newest container if none are running. Replicas is set to the actual number of containers
|
||||
// for a replicated service.
|
||||
func (s *Service) Spec() ServiceSpec {
|
||||
if len(s.Containers) == 0 {
|
||||
return ServiceSpec{}
|
||||
}
|
||||
|
||||
isRunning := func(c MachineServiceContainer) bool {
|
||||
return c.Container.State.Running && !c.Container.State.Paused
|
||||
}
|
||||
selected := slices.MaxFunc(s.Containers, func(a, b MachineServiceContainer) int {
|
||||
if aRunning, bRunning := isRunning(a), isRunning(b); aRunning != bRunning {
|
||||
if aRunning {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
return a.Container.CreatedTime().Compare(b.Container.CreatedTime())
|
||||
})
|
||||
|
||||
spec := selected.Container.ServiceSpec.Clone()
|
||||
// The replicas stored in a container spec go stale when the service is scaled without recreating the container.
|
||||
if spec.Mode == ServiceModeReplicated {
|
||||
spec.Replicas = uint(len(s.Containers))
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
// MachineIDs returns a list of unique machine IDs where the service containers are running.
|
||||
func (s *Service) MachineIDs() []string {
|
||||
ids := mapset.NewSet[string]()
|
||||
|
||||
+244
-2
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
@@ -9,6 +10,137 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServiceSpec_Validate_Name(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty allows generated name"},
|
||||
{name: "single character", input: "a"},
|
||||
{name: "single digit", input: "1"},
|
||||
{name: "two characters", input: "a1"},
|
||||
{name: "hyphens and digits", input: "1-web-2"},
|
||||
{name: "consecutive hyphens", input: "web--1"},
|
||||
{name: "maximum length", input: strings.Repeat("a", 63)},
|
||||
{name: "maximum length with hyphens", input: "a" + strings.Repeat("-", 61) + "1"},
|
||||
{name: "round-robin mode is a valid service name", input: "rr"},
|
||||
{name: "nearest mode is a valid service name", input: "nearest"},
|
||||
{name: "machine namespace prefix", input: "m-service"},
|
||||
{name: "short hexadecimal name", input: strings.Repeat("a", 31)},
|
||||
{name: "long hexadecimal name", input: strings.Repeat("a", 33)},
|
||||
{name: "non-hexadecimal 32 characters", input: strings.Repeat("g", 32)},
|
||||
{name: "too long", input: strings.Repeat("a", 64), wantErr: "must be 1-63 characters"},
|
||||
{name: "uppercase", input: "WEB1", wantErr: "lowercase letters"},
|
||||
{name: "leading hyphen", input: "-web", wantErr: "starting and ending"},
|
||||
{name: "trailing hyphen", input: "web-", wantErr: "starting and ending"},
|
||||
{name: "hyphen only", input: "-", wantErr: "starting and ending"},
|
||||
{name: "underscore", input: "web_1", wantErr: "hyphens only"},
|
||||
{name: "dot", input: "web.example", wantErr: "hyphens only"},
|
||||
{name: "slash", input: "web/api", wantErr: "hyphens only"},
|
||||
{name: "space", input: "web 1", wantErr: "hyphens only"},
|
||||
{name: "leading whitespace", input: " web", wantErr: "hyphens only"},
|
||||
{name: "trailing whitespace", input: "web ", wantErr: "hyphens only"},
|
||||
{name: "tab", input: "web\t1", wantErr: "hyphens only"},
|
||||
{name: "newline", input: "web\n", wantErr: "hyphens only"},
|
||||
{name: "non-ASCII", input: "wéb", wantErr: "lowercase letters"},
|
||||
{name: "machine DNS namespace", input: "m", wantErr: "reserved for the machine DNS namespace"},
|
||||
{name: "service ID", input: "c337f00600de51ef4375c9a9a267dba5", wantErr: "service ID format"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
spec := ServiceSpec{Name: tt.input, Container: ContainerSpec{Image: "nginx:latest"}}
|
||||
err := spec.Validate()
|
||||
if tt.wantErr != "" {
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSpec_Validate_Labels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "nil labels"},
|
||||
{name: "valid labels", labels: map[string]string{"com.example.team": "web", "tier": ""}},
|
||||
{name: "prefix without dot", labels: map[string]string{"uncloudfoo": "bar"}},
|
||||
{name: "whitespace in value", labels: map[string]string{"tier": "value with spaces"}},
|
||||
{name: "empty key", labels: map[string]string{"": "bar"}, wantErr: "must not be empty"},
|
||||
{name: "only whitespace in key", labels: map[string]string{" ": "bar"}, wantErr: "must not be empty"},
|
||||
{name: "space in key", labels: map[string]string{"my label": "bar"}, wantErr: "must not contain whitespace"},
|
||||
{name: "leading space in key", labels: map[string]string{" tier": "bar"}, wantErr: "must not contain whitespace"},
|
||||
{name: "trailing space in key", labels: map[string]string{"tier ": "bar"}, wantErr: "must not contain whitespace"},
|
||||
{name: "tab in key", labels: map[string]string{"my\tlabel": "bar"}, wantErr: "must not contain whitespace"},
|
||||
{name: "newline in key", labels: map[string]string{"my\nlabel": "bar"}, wantErr: "must not contain whitespace"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name+"/service", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
spec := ServiceSpec{Container: ContainerSpec{Image: "nginx:latest"}, Labels: tt.labels}
|
||||
err := spec.Validate()
|
||||
if tt.wantErr != "" {
|
||||
require.ErrorContains(t, err, "invalid service labels")
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
t.Run(tt.name+"/container", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
spec := ServiceSpec{Container: ContainerSpec{Image: "nginx:latest", Labels: tt.labels}}
|
||||
err := spec.Validate()
|
||||
if tt.wantErr != "" {
|
||||
require.ErrorContains(t, err, "invalid container labels")
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSpec_Validate_ReservedLabels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
}{
|
||||
{name: "reserved prefix", labels: map[string]string{LabelManaged: ""}},
|
||||
{name: "reserved daemon prefix", labels: map[string]string{"uncloudd.foo": "bar"}},
|
||||
{name: "managed service", labels: map[string]string{
|
||||
LabelReservedPrefix + "service.managed": "",
|
||||
LabelReservedPrefix + "custom.key": "value",
|
||||
}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Uncloud sets its own labels on services, so a service spec can have them.
|
||||
spec := ServiceSpec{Container: ContainerSpec{Image: "nginx:latest"}, Labels: tt.labels}
|
||||
require.NoError(t, spec.Validate())
|
||||
|
||||
// Container labels and labels provided by users can't use the reserved prefixes.
|
||||
spec = ServiceSpec{Container: ContainerSpec{Image: "nginx:latest", Labels: tt.labels}}
|
||||
err := spec.Validate()
|
||||
require.ErrorContains(t, err, "invalid container labels")
|
||||
require.ErrorContains(t, err, "reserved prefix")
|
||||
require.ErrorContains(t, ValidateLabels(tt.labels), "reserved prefix")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -213,8 +345,9 @@ func TestContainerSpec_Clone(t *testing.T) {
|
||||
"FOO": "bar",
|
||||
"BAZ": "qux",
|
||||
},
|
||||
Image: "nginx:latest",
|
||||
Init: new(true),
|
||||
Image: "nginx:latest",
|
||||
Init: new(true),
|
||||
Labels: map[string]string{"com.example.team": "web"},
|
||||
LogDriver: &LogDriver{
|
||||
Name: "json-file",
|
||||
Options: map[string]string{
|
||||
@@ -259,6 +392,7 @@ func TestContainerSpec_Clone(t *testing.T) {
|
||||
original.Command[0] = stringModified
|
||||
original.Entrypoint[0] = stringModified
|
||||
original.Env["FOO"] = stringModified
|
||||
original.Labels["com.example.team"] = stringModified
|
||||
original.LogDriver.Options["max-size"] = stringModified
|
||||
original.Volumes[0] = stringModified
|
||||
original.VolumeMounts[0].ContainerPath = stringModified
|
||||
@@ -274,6 +408,7 @@ func TestContainerSpec_Clone(t *testing.T) {
|
||||
assert.Equal(t, "NET_ADMIN", cloned.CapAdd[0])
|
||||
assert.Equal(t, "ALL", cloned.CapDrop[0])
|
||||
assert.Equal(t, "sh", cloned.Command[0])
|
||||
assert.Equal(t, "web", cloned.Labels["com.example.team"])
|
||||
assert.Equal(t, "/bin/bash", cloned.Entrypoint[0])
|
||||
assert.Equal(t, "bar", cloned.Env["FOO"])
|
||||
assert.Equal(t, "qux", cloned.Env["BAZ"])
|
||||
@@ -299,3 +434,110 @@ func TestContainerSpec_Clone(t *testing.T) {
|
||||
assert.Equal(t, os.FileMode(0o644), *cloned.ConfigMounts[0].Mode, "Mode should be deep copied")
|
||||
assert.Equal(t, "1", cloned.Sysctls["net.ipv4.ip_forward"])
|
||||
}
|
||||
|
||||
func TestService_Spec(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newContainer := func(name, created string, running bool, mode string, replicas uint) MachineServiceContainer {
|
||||
return MachineServiceContainer{Container: ServiceContainer{
|
||||
Container: Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
Created: created,
|
||||
State: &container.State{Running: running},
|
||||
},
|
||||
}},
|
||||
ServiceSpec: ServiceSpec{
|
||||
Labels: map[string]string{"from": name},
|
||||
Mode: mode,
|
||||
Replicas: replicas,
|
||||
},
|
||||
}}
|
||||
}
|
||||
const (
|
||||
older = "2026-01-01T00:00:00Z"
|
||||
newer = "2026-01-02T00:00:00Z"
|
||||
)
|
||||
|
||||
t.Run("no containers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := Service{HookContainers: []MachineServiceContainer{
|
||||
newContainer("hook", newer, true, ServiceModeReplicated, 1),
|
||||
}}
|
||||
|
||||
assert.Equal(t, ServiceSpec{}, svc.Spec())
|
||||
})
|
||||
|
||||
t.Run("newest running container wins", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := Service{Containers: []MachineServiceContainer{
|
||||
newContainer("old-running", older, true, ServiceModeReplicated, 1),
|
||||
newContainer("new-running", newer, true, ServiceModeReplicated, 1),
|
||||
}}
|
||||
|
||||
spec := svc.Spec()
|
||||
assert.Equal(t, "new-running", spec.Labels["from"])
|
||||
})
|
||||
|
||||
t.Run("running container wins over newer stopped one", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := Service{Containers: []MachineServiceContainer{
|
||||
newContainer("new-stopped", newer, false, ServiceModeReplicated, 1),
|
||||
newContainer("old-running", older, true, ServiceModeReplicated, 1),
|
||||
}}
|
||||
|
||||
spec := svc.Spec()
|
||||
assert.Equal(t, "old-running", spec.Labels["from"])
|
||||
})
|
||||
|
||||
t.Run("newest container wins when none are running", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := Service{Containers: []MachineServiceContainer{
|
||||
newContainer("old-stopped", older, false, ServiceModeReplicated, 1),
|
||||
newContainer("new-stopped", newer, false, ServiceModeReplicated, 1),
|
||||
}}
|
||||
|
||||
spec := svc.Spec()
|
||||
assert.Equal(t, "new-stopped", spec.Labels["from"])
|
||||
})
|
||||
|
||||
t.Run("replicas match the number of containers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := Service{Containers: []MachineServiceContainer{
|
||||
newContainer("a", older, true, ServiceModeReplicated, 5),
|
||||
newContainer("b", newer, true, ServiceModeReplicated, 5),
|
||||
newContainer("c", newer, false, ServiceModeReplicated, 5),
|
||||
}}
|
||||
|
||||
spec := svc.Spec()
|
||||
assert.EqualValues(t, 3, spec.Replicas)
|
||||
})
|
||||
|
||||
t.Run("global service has no replicas", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := Service{Containers: []MachineServiceContainer{
|
||||
newContainer("a", older, true, ServiceModeGlobal, 0),
|
||||
newContainer("b", newer, true, ServiceModeGlobal, 0),
|
||||
}}
|
||||
|
||||
spec := svc.Spec()
|
||||
assert.Zero(t, spec.Replicas)
|
||||
})
|
||||
|
||||
t.Run("result is independent of the container spec", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := Service{Containers: []MachineServiceContainer{
|
||||
newContainer("a", older, true, ServiceModeReplicated, 1),
|
||||
}}
|
||||
|
||||
spec := svc.Spec()
|
||||
spec.Labels["from"] = "changed"
|
||||
assert.Equal(t, "a", svc.Containers[0].Container.ServiceSpec.Labels["from"])
|
||||
})
|
||||
}
|
||||
|
||||
+165
-17
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/psviderski/uncloud/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/version"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
@@ -18,11 +20,33 @@ import (
|
||||
|
||||
const (
|
||||
CaddyServiceName = "caddy"
|
||||
// CaddyImage is the official Caddy Docker image on Docker Hub: https://hub.docker.com/_/caddy
|
||||
CaddyImage = "caddy"
|
||||
// CaddyImage is the Uncloud Caddy image that is deployed by default. It includes the Uncloud storage module for
|
||||
// sharing TLS certificates across machines: https://github.com/unlabs-dev/caddy-uncloud
|
||||
CaddyImage = "ghcr.io/unlabs-dev/caddy-uncloud"
|
||||
// DefaultCaddyConfig is the default global Caddyfile. It must be compatible with all versions of CaddyImage allowed
|
||||
// by caddyImageConstraint.
|
||||
DefaultCaddyConfig = "{\n\tstorage uncloud\n}"
|
||||
// CaddyStorageFileSystem is the name of the default Caddy storage module that stores TLS assets locally.
|
||||
CaddyStorageFileSystem = "file_system"
|
||||
// CaddyStorageUncloud is the name of the Caddy storage module that stores TLS assets in the cluster.
|
||||
CaddyStorageUncloud = "uncloud"
|
||||
// MinCaddyDaemonVersion is the minimum daemon version required for 'uc caddy deploy'. Older daemons don't store
|
||||
// service labels that mark the service as managed by this command. Distributed locks were introduced in 0.21.0,
|
||||
// which is the minimum required by the cluster storage even on machines that don't run the Caddy service.
|
||||
MinCaddyDaemonVersion = "0.22.0"
|
||||
|
||||
// officialCaddyImage is the official Caddy Docker image on Docker Hub: https://hub.docker.com/_/caddy
|
||||
officialCaddyImage = "docker.io/library/caddy"
|
||||
)
|
||||
|
||||
var caddyImageTagRegex = regexp.MustCompile(`^2\.\d+\.\d+$`)
|
||||
var (
|
||||
// caddyImageConstraint limits CaddyImage to the major version that is known to be compatible with
|
||||
// DefaultCaddyConfig.
|
||||
caddyImageConstraint = mustNewConstraint("<1.0.0")
|
||||
// caddyImageTagRegex matches the exact version tags of CaddyImage, skipping floating tags and image variants.
|
||||
caddyImageTagRegex = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
|
||||
caddyStorageRegex = regexp.MustCompile(`(?m)^[ \t]*storage[ \t]+(\S+)`)
|
||||
)
|
||||
|
||||
// CaddyClient provides Caddy operations over its parent Client's connection.
|
||||
// The parent client owns the connection and must be used to close it.
|
||||
@@ -64,19 +88,28 @@ func (c *CaddyClient) Config(ctx context.Context, opts CaddyConfigOptions) (api.
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// CaddyDeploymentOptions configures a Caddy reverse proxy deployment.
|
||||
// CaddyDeploymentOptions configures a Caddy reverse proxy deployment. The zero value deploys the latest version of
|
||||
// CaddyImage with DefaultCaddyConfig to all machines.
|
||||
type CaddyDeploymentOptions struct {
|
||||
// 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 defaults to the latest version of CaddyImage. A non-empty value is marked as custom.
|
||||
// Custom images must include curl and start Caddy with /etc/caddy/Caddyfile. They must also include the Uncloud
|
||||
// storage module if the config uses 'storage uncloud'.
|
||||
Image string
|
||||
// Config contains an optional global Caddyfile.
|
||||
Config string
|
||||
// Config is the global Caddyfile. Nil means DefaultCaddyConfig. Any other value, even empty, is marked as custom.
|
||||
Config *string
|
||||
Placement api.Placement
|
||||
}
|
||||
|
||||
// NewDeployment creates a new deployment for a Caddy reverse proxy service.
|
||||
// The service is deployed in global mode to all machines in the cluster. If the image is not provided, the latest
|
||||
// version of the official Caddy Docker image is used.
|
||||
// ConfigOrDefault returns the global Caddyfile of the deployment.
|
||||
func (o CaddyDeploymentOptions) ConfigOrDefault() string {
|
||||
if o.Config != nil {
|
||||
return *o.Config
|
||||
}
|
||||
return DefaultCaddyConfig
|
||||
}
|
||||
|
||||
// NewDeployment creates a new managed deployment for a Caddy reverse proxy service.
|
||||
// The service is deployed in global mode to all machines in the cluster or the machines set in the placement.
|
||||
func (c *CaddyClient) NewDeployment(ctx context.Context, opts CaddyDeploymentOptions) (*deploy.Deployment, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
@@ -90,6 +123,10 @@ func (c *CaddyClient) NewDeployment(ctx context.Context, opts CaddyDeploymentOpt
|
||||
|
||||
image = reference.FamiliarString(latest)
|
||||
}
|
||||
config := opts.ConfigOrDefault()
|
||||
if err := validateCaddyImageStorage(image, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
@@ -186,17 +223,119 @@ func (c *CaddyClient) NewDeployment(ctx context.Context, opts CaddyDeploymentOpt
|
||||
},
|
||||
}
|
||||
|
||||
if opts.Config != "" {
|
||||
if config != "" {
|
||||
spec.Caddy = &api.CaddySpec{
|
||||
Config: opts.Config,
|
||||
Config: config,
|
||||
}
|
||||
}
|
||||
|
||||
spec.Labels = map[string]string{api.LabelCaddyManaged: ""}
|
||||
if opts.Image != "" {
|
||||
spec.Labels[api.LabelCaddyImageCustom] = ""
|
||||
}
|
||||
if opts.Config != nil {
|
||||
spec.Labels[api.LabelCaddyConfigCustom] = ""
|
||||
}
|
||||
|
||||
return c.client.NewDeployment(spec, nil), nil
|
||||
}
|
||||
|
||||
// latestCaddyImage returns the latest image of the official Caddy Docker image on Docker Hub.
|
||||
// The latest image is determined by the latest version tag 2.x.x.
|
||||
// CheckDaemonVersions returns an error naming the available machines whose daemons are older than
|
||||
// MinCaddyDaemonVersion. Machines that are down are skipped because a deployment doesn't touch them.
|
||||
func (c *CaddyClient) CheckDaemonVersions(ctx context.Context) error {
|
||||
machines, err := c.client.ListMachines(ctx, &api.MachineFilter{Available: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
|
||||
if outdated := outdatedCaddyDaemons(machines); len(outdated) > 0 {
|
||||
return fmt.Errorf("deploying Caddy requires Uncloud daemon %s or newer on all machines, upgrade these "+
|
||||
"machines and try again: %s. See https://github.com/psviderski/uncloud/releases/latest",
|
||||
MinCaddyDaemonVersion, strings.Join(outdated, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// outdatedCaddyDaemons returns descriptions of the machines with outdated daemons. Prereleases and development builds
|
||||
// aren't considered outdated because their versions don't show whether they're older than a stable release.
|
||||
func outdatedCaddyDaemons(machines api.MachineMembersList) []string {
|
||||
minVersion := semver.MustParse(MinCaddyDaemonVersion)
|
||||
|
||||
var outdated []string
|
||||
for _, m := range machines {
|
||||
v, err := semver.NewVersion(m.Machine.DaemonVersion)
|
||||
if err != nil || v.Prerelease() != "" {
|
||||
continue
|
||||
}
|
||||
if v.LessThan(minVersion) {
|
||||
outdated = append(outdated, fmt.Sprintf("%s (%s)", m.Machine.Name, m.Machine.DaemonVersion))
|
||||
}
|
||||
}
|
||||
|
||||
return outdated
|
||||
}
|
||||
|
||||
// CaddyStorageModule returns the name of the storage module set in the global options of a Caddyfile.
|
||||
func CaddyStorageModule(config string) string {
|
||||
if match := caddyStorageRegex.FindStringSubmatch(config); match != nil {
|
||||
return match[1]
|
||||
}
|
||||
return CaddyStorageFileSystem
|
||||
}
|
||||
|
||||
// IsDefaultCaddyImage returns whether the image is any version of CaddyImage.
|
||||
func IsDefaultCaddyImage(image string) bool {
|
||||
return imageName(image) == CaddyImage
|
||||
}
|
||||
|
||||
// IsNewerCaddyImage returns whether the image is a newer version of CaddyImage than the other image.
|
||||
// It returns false if any of the images isn't a version of CaddyImage.
|
||||
func IsNewerCaddyImage(image, other string) bool {
|
||||
version, otherVersion := caddyImageVersion(image), caddyImageVersion(other)
|
||||
return version != nil && otherVersion != nil && version.GreaterThan(otherVersion)
|
||||
}
|
||||
|
||||
// caddyImageVersion returns the version in the image tag if the image is a version of CaddyImage, or nil otherwise.
|
||||
func caddyImageVersion(image string) *semver.Version {
|
||||
named, err := reference.ParseNormalizedNamed(image)
|
||||
if err != nil || named.Name() != CaddyImage {
|
||||
return nil
|
||||
}
|
||||
tagged, ok := named.(reference.Tagged)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
version, err := semver.NewVersion(tagged.Tag())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
// imageName returns the normalised name of the image without its tag or digest.
|
||||
// It returns an empty string if the image reference is invalid.
|
||||
func imageName(image string) string {
|
||||
named, err := reference.ParseNormalizedNamed(image)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return named.Name()
|
||||
}
|
||||
|
||||
// validateCaddyImageStorage rejects the official Caddy image with a config that uses the Uncloud storage module
|
||||
// because it doesn't include this module. Otherwise, a new container could start from the previously saved Caddyfile
|
||||
// and pass its health check, but its routes would stop updating as Caddy would reject the new config.
|
||||
func validateCaddyImageStorage(image, config string) error {
|
||||
if imageName(image) == officialCaddyImage && CaddyStorageModule(config) == CaddyStorageUncloud {
|
||||
return fmt.Errorf("official Caddy image '%s' doesn't support 'storage uncloud' in the global Caddy config: "+
|
||||
"use an image that includes the Uncloud storage module or a Caddyfile without this option. "+
|
||||
"See %s/concepts/ingress/cluster-storage-for-caddy", image, version.DocsURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// latestCaddyImage returns the latest version of CaddyImage allowed by caddyImageConstraint.
|
||||
func latestCaddyImage(ctx context.Context) (reference.NamedTagged, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
@@ -222,7 +361,7 @@ func latestCaddyImage(ctx context.Context) (reference.NamedTagged, error) {
|
||||
return imageWithTag, nil
|
||||
}
|
||||
|
||||
// latestCaddyTag selects the newest stable 2.x.x tag, falling back to latest.
|
||||
// latestCaddyTag selects the newest tag matching caddyImageTagRegex and caddyImageConstraint, falling back to latest.
|
||||
func latestCaddyTag(tags []string) string {
|
||||
latestTag := "latest"
|
||||
var latestVersion *semver.Version
|
||||
@@ -232,7 +371,7 @@ func latestCaddyTag(tags []string) string {
|
||||
}
|
||||
|
||||
v, err := semver.NewVersion(t)
|
||||
if err != nil {
|
||||
if err != nil || !caddyImageConstraint.Check(v) {
|
||||
continue
|
||||
}
|
||||
if latestVersion == nil || v.GreaterThan(latestVersion) {
|
||||
@@ -243,3 +382,12 @@ func latestCaddyTag(tags []string) string {
|
||||
|
||||
return latestTag
|
||||
}
|
||||
|
||||
// mustNewConstraint parses a version constraint and panics if it's invalid.
|
||||
func mustNewConstraint(constraint string) *semver.Constraints {
|
||||
c, err := semver.NewConstraint(constraint)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("invalid version constraint '%s': %v", constraint, err))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
+203
-6
@@ -5,6 +5,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/psviderski/uncloud/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -15,7 +17,7 @@ func TestLatestCaddyImage(t *testing.T) {
|
||||
image, err := latestCaddyImage(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, reference.FamiliarString(image))
|
||||
assert.Regexp(t, `^ghcr\.io/unlabs-dev/caddy-uncloud:0\.\d+\.\d+$`, reference.FamiliarString(image))
|
||||
}
|
||||
|
||||
func TestLatestCaddyTag(t *testing.T) {
|
||||
@@ -27,14 +29,209 @@ func TestLatestCaddyTag(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{name: "empty", want: "latest"},
|
||||
{name: "semantic ordering", tags: []string{"2.9.9", "2.11.4", "2.10.0", "2.11.3"}, want: "2.11.4"},
|
||||
{name: "ignore other versions and variants", tags: []string{
|
||||
"latest", "1.0.0", "3.0.0", "2.12.0-rc.1", "2.12.0-alpine", "2.12", "v2.12.0", "2.11.4",
|
||||
}, want: "2.11.4"},
|
||||
{name: "no stable version", tags: []string{"builder", "2.12.0-beta.1"}, want: "latest"},
|
||||
{name: "semantic ordering", tags: []string{"0.1.9", "0.1.11", "0.1.10", "0.1.2"}, want: "0.1.11"},
|
||||
{name: "newest minor version", tags: []string{"0.1.3", "0.2.0", "0.0.9"}, want: "0.2.0"},
|
||||
{name: "ignore newer major versions", tags: []string{"1.0.0", "0.1.3", "2.3.4", "1.0.1"}, want: "0.1.3"},
|
||||
{name: "ignore variants", tags: []string{
|
||||
"latest", "0.1.4-rc.1", "0.1.4-alpine", "0.1", "v0.1.4", "1.0.0-rc.1", "0.1.3",
|
||||
}, want: "0.1.3"},
|
||||
{name: "no stable version", tags: []string{"builder", "0.1.4-beta.1"}, want: "latest"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, latestCaddyTag(tt.tags))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCaddyConfig_UsesUncloudStorage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, CaddyStorageUncloud, CaddyStorageModule(DefaultCaddyConfig))
|
||||
}
|
||||
|
||||
func TestCaddyStorageModule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
config string
|
||||
want string
|
||||
}{
|
||||
{name: "empty config", want: CaddyStorageFileSystem},
|
||||
{name: "no storage option", config: "{\n\tdebug\n}", want: CaddyStorageFileSystem},
|
||||
{name: "uncloud", config: "{\n\tstorage uncloud\n}", want: CaddyStorageUncloud},
|
||||
{name: "uncloud with options", config: "{\n\tstorage uncloud {\n\t\tfoo bar\n\t}\n}", want: CaddyStorageUncloud},
|
||||
{name: "spaces around", config: "{\n storage uncloud \n}", want: CaddyStorageUncloud},
|
||||
{name: "other module", config: "{\n\tstorage redis\n}", want: "redis"},
|
||||
{name: "explicit file system", config: "{\n\tstorage file_system /data\n}", want: CaddyStorageFileSystem},
|
||||
{name: "commented out", config: "{\n\t# storage uncloud\n}", want: CaddyStorageFileSystem},
|
||||
{name: "similar option", config: "{\n\tstorage_clean_interval 24h\n}", want: CaddyStorageFileSystem},
|
||||
{name: "no module on the same line", config: "{\n\tstorage\n\tdebug\n}", want: CaddyStorageFileSystem},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, CaddyStorageModule(tt.config))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDefaultCaddyImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.True(t, IsDefaultCaddyImage("ghcr.io/unlabs-dev/caddy-uncloud"))
|
||||
assert.True(t, IsDefaultCaddyImage("ghcr.io/unlabs-dev/caddy-uncloud:0.1.3"))
|
||||
assert.False(t, IsDefaultCaddyImage("caddy:2.11.4"))
|
||||
assert.False(t, IsDefaultCaddyImage("example.com/unlabs-dev/caddy-uncloud:0.1.3"))
|
||||
assert.False(t, IsDefaultCaddyImage(""))
|
||||
}
|
||||
|
||||
func TestIsNewerCaddyImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const repo = "ghcr.io/unlabs-dev/caddy-uncloud"
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
image string
|
||||
other string
|
||||
want bool
|
||||
}{
|
||||
{name: "newer major version", image: repo + ":1.0.0", other: repo + ":0.1.3", want: true},
|
||||
{name: "newer minor version", image: repo + ":0.2.0", other: repo + ":0.1.3", want: true},
|
||||
{name: "same", image: repo + ":0.1.3", other: repo + ":0.1.3"},
|
||||
{name: "older", image: repo + ":0.1.2", other: repo + ":0.1.3"},
|
||||
{name: "different repo", image: "example.com/custom:1.0.0", other: repo + ":0.1.3"},
|
||||
{name: "official image", image: "caddy:2.11.4", other: repo + ":0.1.3"},
|
||||
{name: "tag without version", image: repo + ":latest", other: repo + ":0.1.3"},
|
||||
{name: "no tag", image: repo, other: repo + ":0.1.3"},
|
||||
{name: "fallback to latest tag", image: repo + ":1.0.0", other: repo + ":latest"},
|
||||
{name: "no current image", other: repo + ":0.1.3"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, IsNewerCaddyImage(tt.image, tt.other))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaddyClientNewDeployment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const customImage = "example.com/caddy:1.2.3"
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
opts CaddyDeploymentOptions
|
||||
wantConfig string
|
||||
wantLabels map[string]string
|
||||
}{
|
||||
{
|
||||
name: "defaults",
|
||||
wantConfig: DefaultCaddyConfig,
|
||||
wantLabels: map[string]string{api.LabelCaddyManaged: ""},
|
||||
},
|
||||
{
|
||||
name: "custom image",
|
||||
opts: CaddyDeploymentOptions{Image: customImage},
|
||||
wantConfig: DefaultCaddyConfig,
|
||||
wantLabels: map[string]string{api.LabelCaddyManaged: "", api.LabelCaddyImageCustom: ""},
|
||||
},
|
||||
{
|
||||
name: "custom config",
|
||||
opts: CaddyDeploymentOptions{Config: new("{\n\tdebug\n}")},
|
||||
wantConfig: "{\n\tdebug\n}",
|
||||
wantLabels: map[string]string{api.LabelCaddyManaged: "", api.LabelCaddyConfigCustom: ""},
|
||||
},
|
||||
{
|
||||
name: "custom empty config",
|
||||
opts: CaddyDeploymentOptions{Config: new("")},
|
||||
wantLabels: map[string]string{api.LabelCaddyManaged: "", api.LabelCaddyConfigCustom: ""},
|
||||
},
|
||||
{
|
||||
name: "custom config matching the default is still custom",
|
||||
opts: CaddyDeploymentOptions{Config: new(DefaultCaddyConfig)},
|
||||
wantConfig: DefaultCaddyConfig,
|
||||
wantLabels: map[string]string{api.LabelCaddyManaged: "", api.LabelCaddyConfigCustom: ""},
|
||||
},
|
||||
{
|
||||
name: "custom image and config",
|
||||
opts: CaddyDeploymentOptions{
|
||||
Image: customImage,
|
||||
Config: new("{\n\tdebug\n}"),
|
||||
},
|
||||
wantConfig: "{\n\tdebug\n}",
|
||||
wantLabels: map[string]string{
|
||||
api.LabelCaddyManaged: "",
|
||||
api.LabelCaddyImageCustom: "",
|
||||
api.LabelCaddyConfigCustom: "",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &CaddyClient{client: &Client{}}
|
||||
d, err := c.NewDeployment(context.Background(), tt.opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.wantConfig, d.Spec.CaddyConfig())
|
||||
assert.Equal(t, tt.wantLabels, d.Spec.Labels)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("official image needs a config without cluster storage", func(t *testing.T) {
|
||||
c := &CaddyClient{client: &Client{}}
|
||||
_, err := c.NewDeployment(context.Background(), CaddyDeploymentOptions{Image: "caddy:2.11.4"})
|
||||
require.ErrorContains(t, err, "doesn't support 'storage uncloud'")
|
||||
|
||||
_, err = c.NewDeployment(context.Background(), CaddyDeploymentOptions{Image: "caddy:2.11.4", Config: new("")})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateCaddyImageStorage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const storageConfig = "{\n\tstorage uncloud\n}"
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
image string
|
||||
config string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "official image with uncloud storage", image: "caddy:2.11.4", config: storageConfig, wantErr: true},
|
||||
{name: "official image with registry", image: "docker.io/library/caddy:2", config: storageConfig, wantErr: true},
|
||||
{name: "official image without config", image: "caddy:2.11.4"},
|
||||
{name: "official image with other config", image: "caddy:2.11.4", config: "{\n\tdebug\n}"},
|
||||
{name: "default image with uncloud storage", image: CaddyImage + ":0.1.3", config: storageConfig},
|
||||
{name: "custom image with uncloud storage", image: "example.com/caddy:1", config: storageConfig},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateCaddyImageStorage(tt.image, tt.config)
|
||||
if tt.wantErr {
|
||||
require.ErrorContains(t, err, "doesn't support 'storage uncloud'")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutdatedCaddyDaemons(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
machine := func(name, version string) *pb.MachineMember {
|
||||
return &pb.MachineMember{Machine: &pb.MachineInfo{Name: name, DaemonVersion: version}}
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"old (0.21.0)", "older (0.20.0)", "unsupported (0.9.3)"},
|
||||
outdatedCaddyDaemons(api.MachineMembersList{
|
||||
machine("old", "0.21.0"),
|
||||
machine("older", "0.20.0"),
|
||||
machine("unsupported", "0.9.3"),
|
||||
machine("no-version", ""),
|
||||
machine("required", "0.22.0"),
|
||||
machine("newer", "0.23.1"),
|
||||
machine("major", "1.0.0"),
|
||||
machine("nightly", "0.22.0-nightly-abc1234"),
|
||||
machine("old-dev-build", "v0.21.0-5-g6c7ddeb-dirty"),
|
||||
machine("local-build", "999.0.0-dev"),
|
||||
machine("not-semver", "6c7ddeb"),
|
||||
}))
|
||||
assert.Empty(t, outdatedCaddyDaemons(api.MachineMembersList{machine("required", "0.22.0")}))
|
||||
assert.Empty(t, outdatedCaddyDaemons(nil))
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ func (cli *Client) progressOut() *streams.Out {
|
||||
// ProxyMachinesContext returns a new context that proxies gRPC requests to the specified machines.
|
||||
// If namesOrIDs is nil or empty, all machines are included.
|
||||
// This triggers One2Many proxying, which always injects metadata into the response.
|
||||
// TODO: validate namesOrIDs can't contain "*" as this is reserved as internal value and may break the caller logic.
|
||||
func ProxyMachinesContext(ctx context.Context, namesOrIDs []string) context.Context {
|
||||
md := outgoingMetadataWithoutProxyTargets(ctx)
|
||||
if len(namesOrIDs) == 0 {
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
||||
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
||||
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||
)
|
||||
|
||||
// Plan holds the compose-level deployment plan with volume and service operations.
|
||||
@@ -55,7 +55,7 @@ func (p *Plan) Format() string {
|
||||
|
||||
// formatSummary counts all operations across the plan and renders the summary footer.
|
||||
func (p *Plan) formatSummary() string {
|
||||
var createCount, startFirstCount, stopFirstCount, removeCount int
|
||||
var createCount, startFirstCount, stopFirstCount, updateCount, removeCount int
|
||||
machines := make(map[string]struct{})
|
||||
|
||||
for _, op := range p.Volumes {
|
||||
@@ -76,12 +76,15 @@ func (p *Plan) formatSummary() string {
|
||||
} else {
|
||||
startFirstCount++
|
||||
}
|
||||
case *operation.UpdateContainerOperation:
|
||||
machines[o.MachineID] = struct{}{}
|
||||
updateCount++
|
||||
case *operation.RemoveContainerOperation:
|
||||
machines[o.MachineID] = struct{}{}
|
||||
removeCount++
|
||||
case *operation.StopContainerOperation:
|
||||
// Stop operations aren't counted because a later operation in the plan removes the stopped container.
|
||||
machines[o.MachineID] = struct{}{}
|
||||
removeCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +102,10 @@ func (p *Plan) formatSummary() string {
|
||||
parts = append(parts,
|
||||
tui.BoldYellow.Render(strconv.Itoa(stopFirstCount))+" "+tui.Yellow.Render("replace (stop-first)"))
|
||||
}
|
||||
if updateCount > 0 {
|
||||
parts = append(parts,
|
||||
tui.BoldYellow.Render(strconv.Itoa(updateCount))+" "+tui.Yellow.Render("update (in place)"))
|
||||
}
|
||||
if removeCount > 0 {
|
||||
parts = append(parts,
|
||||
tui.BoldRed.Render(strconv.Itoa(removeCount))+" "+tui.Red.Render("remove"))
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPlan_FormatSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
web := &deploy.ServicePlan{}
|
||||
web.Operations = []operation.Operation{
|
||||
&operation.UpdateContainerOperation{MachineID: "m-1"},
|
||||
&operation.RemoveContainerOperation{MachineID: "m-2"},
|
||||
}
|
||||
db := &deploy.ServicePlan{}
|
||||
db.Operations = []operation.Operation{
|
||||
&operation.UpdateContainerOperation{MachineID: "m-2"},
|
||||
}
|
||||
plan := &Plan{
|
||||
Volumes: []*operation.CreateVolumeOperation{{MachineID: "m-3"}},
|
||||
Services: []*deploy.ServicePlan{web, db},
|
||||
}
|
||||
|
||||
assert.Equal(t, "1 create · 2 update (in place) · 1 remove · across 3 machines", ansi.Strip(plan.formatSummary()))
|
||||
}
|
||||
|
||||
func TestPlan_FormatSummary_StopOperationsNotCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &deploy.ServicePlan{}
|
||||
svc.Operations = []operation.Operation{
|
||||
&operation.StopContainerOperation{MachineID: "m-1"},
|
||||
&operation.StopContainerOperation{MachineID: "m-1"},
|
||||
&operation.ReplaceContainerOperation{MachineID: "m-1", Order: api.UpdateOrderStopFirst},
|
||||
&operation.RemoveContainerOperation{MachineID: "m-1"},
|
||||
}
|
||||
plan := &Plan{Services: []*deploy.ServicePlan{svc}}
|
||||
|
||||
// Stop operations aren't counted because a later operation in the plan removes the stopped container.
|
||||
assert.Equal(t, "1 replace (stop-first) · 1 remove · across 1 machine", ansi.Strip(plan.formatSummary()))
|
||||
}
|
||||
@@ -344,20 +344,6 @@ configs:
|
||||
warnCount: 1,
|
||||
warnContains: []string{"container_name"},
|
||||
},
|
||||
{
|
||||
name: "deploy labels",
|
||||
composeYAML: `services:
|
||||
app:
|
||||
image: myapp:latest
|
||||
deploy:
|
||||
mode: replicated
|
||||
labels:
|
||||
- foo
|
||||
replicas: 2
|
||||
`,
|
||||
warnCount: 1,
|
||||
warnContains: []string{"deploy labels"},
|
||||
},
|
||||
{
|
||||
name: "deploy restart_policy",
|
||||
composeYAML: `services:
|
||||
|
||||
@@ -89,40 +89,53 @@ func ResolveSecrets(ctx context.Context, project *types.Project) error {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Resolve only secret references set as values for environment variables in enabled services.
|
||||
for _, service := range project.Services {
|
||||
for k, v := range service.Environment {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
secretName, ok := secretRefName(*v)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value, err := resolve(secretName)
|
||||
if err != nil {
|
||||
// Resolve secret references set as values for environment variables in the service.
|
||||
if err := resolveEnvSecrets(service.Environment, resolve); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Resolve secret references set as values for the pre-deploy hook's environment variables, if any.
|
||||
if hook, ok := service.Extensions[PreDeployHookExtensionKey].(PreDeployHook); ok {
|
||||
if err := resolveEnvSecrets(hook.Environment, resolve); err != nil {
|
||||
return err
|
||||
}
|
||||
service.Environment[k] = &value
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasCommandSecretRefs reports whether any service environment references a secret that is resolved by running
|
||||
// a command.
|
||||
// resolveEnvSecrets resolves 'secret://name' references found as values in env to actual secret values using
|
||||
// resolve, setting each referenced variable to the secret's value in place. Variables that are not secret
|
||||
// references are left untouched.
|
||||
func resolveEnvSecrets(env types.MappingWithEquals, resolve func(string) (string, error)) error {
|
||||
for k, v := range env {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
secretName, ok := secretRefName(*v)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value, err := resolve(secretName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env[k] = &value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasCommandSecretRefs reports whether any service environment, including a pre-deploy hook's environment,
|
||||
// references a secret that is resolved by running a command.
|
||||
func HasCommandSecretRefs(project *types.Project) bool {
|
||||
for _, service := range project.Services {
|
||||
for _, v := range service.Environment {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
name, ok := secretRefName(*v)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if secret, ok := project.Secrets[name]; ok && secret.Driver == secretExecDriver {
|
||||
if envHasCommandSecretRef(service.Environment, project) {
|
||||
return true
|
||||
}
|
||||
if hook, ok := service.Extensions[PreDeployHookExtensionKey].(PreDeployHook); ok {
|
||||
if envHasCommandSecretRef(hook.Environment, project) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -130,6 +143,24 @@ func HasCommandSecretRefs(project *types.Project) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// envHasCommandSecretRef reports whether env contains a 'secret://name' reference to a secret resolved
|
||||
// by running a command.
|
||||
func envHasCommandSecretRef(env types.MappingWithEquals, project *types.Project) bool {
|
||||
for _, v := range env {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
name, ok := secretRefName(*v)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if secret, ok := project.Secrets[name]; ok && secret.Driver == secretExecDriver {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// secretValue resolves a secret to its value depending on its source: an 'exec' driver command,
|
||||
// an environment variable, or a file. A single trailing newline ('\n' or '\r\n') is stripped from the command
|
||||
// output as command-line tools commonly append one. All other whitespace is kept. Environment and file values are
|
||||
|
||||
@@ -328,6 +328,74 @@ secrets:
|
||||
assert.Equal(t, "run\n", string(runs), "command should run exactly once across services and repeated resolutions")
|
||||
}
|
||||
|
||||
// TestResolveSecrets_PreDeployHook pins the behaviour that 'secret://name' references in the pre-deploy hook's
|
||||
// environment are resolved just like references in the service's own environment.
|
||||
// See https://github.com/psviderski/uncloud/issues/422.
|
||||
func TestResolveSecrets_PreDeployHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := `
|
||||
services:
|
||||
foo:
|
||||
image: foo
|
||||
environment:
|
||||
CONTROL_TOKEN: secret://control_token
|
||||
x-pre_deploy:
|
||||
command: ["./migrate.sh"]
|
||||
environment:
|
||||
HOOK_TOKEN: secret://hook_token
|
||||
PLAIN: hello
|
||||
secrets:
|
||||
control_token:
|
||||
x-command: printf control-value
|
||||
hook_token:
|
||||
x-command: printf hook-value
|
||||
`
|
||||
project := loadProject(t, content)
|
||||
require.NoError(t, ResolveSecrets(context.Background(), project))
|
||||
|
||||
assert.Equal(t, env(map[string]string{"CONTROL_TOKEN": "control-value"}), project.Services["foo"].Environment)
|
||||
|
||||
hook, ok := project.Services["foo"].Extensions[PreDeployHookExtensionKey].(PreDeployHook)
|
||||
require.True(t, ok, "x-pre_deploy extension not found")
|
||||
assert.Equal(t, env(map[string]string{"HOOK_TOKEN": "hook-value", "PLAIN": "hello"}), hook.Environment)
|
||||
}
|
||||
|
||||
// TestResolveSecrets_PreDeployHook_ResolvedOnce verifies that a secret referenced from both the service's
|
||||
// environment and its pre-deploy hook's environment is only resolved once.
|
||||
func TestResolveSecrets_PreDeployHook_ResolvedOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
counter := filepath.Join(t.TempDir(), "runs")
|
||||
content := fmt.Sprintf(`
|
||||
services:
|
||||
foo:
|
||||
image: foo
|
||||
environment:
|
||||
TOKEN: secret://token
|
||||
x-pre_deploy:
|
||||
command: ["./migrate.sh"]
|
||||
environment:
|
||||
TOKEN: secret://token
|
||||
secrets:
|
||||
token:
|
||||
x-command: "sh -c 'echo run >> %s; printf abc'"
|
||||
`, counter)
|
||||
|
||||
project := loadProject(t, content)
|
||||
require.NoError(t, ResolveSecrets(context.Background(), project))
|
||||
|
||||
assert.Equal(t, "abc", *project.Services["foo"].Environment["TOKEN"])
|
||||
hook, ok := project.Services["foo"].Extensions[PreDeployHookExtensionKey].(PreDeployHook)
|
||||
require.True(t, ok, "x-pre_deploy extension not found")
|
||||
assert.Equal(t, "abc", *hook.Environment["TOKEN"])
|
||||
|
||||
runs, err := os.ReadFile(counter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "run\n", string(runs),
|
||||
"command should run exactly once across the service and hook environments")
|
||||
}
|
||||
|
||||
func TestHasCommandSecretRefs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -387,6 +455,22 @@ services:
|
||||
`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "command secret referenced from pre-deploy hook",
|
||||
content: `
|
||||
services:
|
||||
foo:
|
||||
image: foo
|
||||
x-pre_deploy:
|
||||
command: ["./migrate.sh"]
|
||||
environment:
|
||||
TOKEN: secret://token
|
||||
secrets:
|
||||
token:
|
||||
x-command: printf abc
|
||||
`,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -66,6 +66,10 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
||||
Mode: api.ServiceModeReplicated,
|
||||
}
|
||||
|
||||
if labels := mergeLabels(service.Labels, service.CustomLabels); len(labels) > 0 {
|
||||
spec.Container.Labels = labels
|
||||
}
|
||||
|
||||
// Map x-caddy extension to spec.Caddy if specified.
|
||||
if caddy, ok := service.Extensions[CaddyExtensionKey].(Caddy); ok && caddy.Config != "" {
|
||||
spec.Caddy = &api.CaddySpec{
|
||||
@@ -124,6 +128,13 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
||||
d := time.Duration(cfg.Monitor)
|
||||
spec.UpdateConfig.MonitorPeriod = &d
|
||||
}
|
||||
|
||||
if len(service.Deploy.Labels) > 0 {
|
||||
if err = api.ValidateLabels(service.Deploy.Labels); err != nil {
|
||||
return spec, fmt.Errorf("invalid deploy.labels: %w", err)
|
||||
}
|
||||
spec.Labels = maps.Clone(service.Deploy.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: can service.tmpfs be handled as tmpfs volume mounts as well?
|
||||
@@ -480,9 +491,6 @@ func validateServicesFeatures(project *types.Project) []error {
|
||||
if service.DNSSearch != nil {
|
||||
errs = append(errs, err(service.Name, "dns_search"))
|
||||
}
|
||||
if service.Labels != nil {
|
||||
errs = append(errs, err(service.Name, "labels"))
|
||||
}
|
||||
if service.Links != nil {
|
||||
errs = append(errs, err(service.Name, "links"))
|
||||
}
|
||||
@@ -517,9 +525,6 @@ func validateServicesFeatures(project *types.Project) []error {
|
||||
if service.Deploy.RollbackConfig != nil {
|
||||
errs = append(errs, err(service.Name, "deploy rollback_config"))
|
||||
}
|
||||
if len(service.Deploy.Labels) != 0 {
|
||||
errs = append(errs, err(service.Name, "deploy labels"))
|
||||
}
|
||||
}
|
||||
// we only allow the 'default' network, nothing else.
|
||||
if x := service.Networks; x != nil {
|
||||
|
||||
@@ -875,6 +875,117 @@ services:
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSpecFromCompose_Labels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
composeYAML string
|
||||
expectedContainerLabels map[string]string
|
||||
expectedServiceLabels map[string]string
|
||||
}{
|
||||
{
|
||||
name: "no labels",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "container labels as list",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
labels:
|
||||
- com.example.team=web
|
||||
- com.example.empty
|
||||
`,
|
||||
expectedContainerLabels: map[string]string{
|
||||
"com.example.team": "web",
|
||||
"com.example.empty": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "container and service labels as maps",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
labels:
|
||||
com.example.team: web
|
||||
deploy:
|
||||
labels:
|
||||
tier: frontend
|
||||
`,
|
||||
expectedContainerLabels: map[string]string{"com.example.team": "web"},
|
||||
expectedServiceLabels: map[string]string{"tier": "frontend"},
|
||||
},
|
||||
{
|
||||
name: "service labels only",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
deploy:
|
||||
labels:
|
||||
- tier=frontend
|
||||
`,
|
||||
expectedServiceLabels: map[string]string{"tier": "frontend"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
|
||||
require.NoError(t, err)
|
||||
|
||||
spec, err := ServiceSpecFromCompose(project, "web")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expectedContainerLabels, spec.Container.Labels)
|
||||
assert.Equal(t, tt.expectedServiceLabels, spec.Labels)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSpecFromCompose_InvalidServiceLabels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
label string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "reserved prefix", label: api.LabelManaged, wantErr: "reserved prefix"},
|
||||
{name: "reserved daemon prefix", label: "uncloudd.foo", wantErr: "reserved prefix"},
|
||||
{name: "managed service", label: api.LabelReservedPrefix + "service.managed", wantErr: "reserved prefix"},
|
||||
{name: "whitespace in key", label: "my label", wantErr: "must not contain whitespace"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
composeYAML := `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
deploy:
|
||||
labels:
|
||||
- ` + tt.label + `=value
|
||||
`
|
||||
project, err := LoadProjectFromContent(context.Background(), composeYAML)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ServiceSpecFromCompose(project, "web")
|
||||
require.ErrorContains(t, err, "invalid deploy.labels")
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSpecFromCompose_UpdateConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -343,6 +343,39 @@ func (cli *Client) RemoveContainer(
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateContainer updates the requested properties of the container within the service in place without recreating it.
|
||||
func (cli *Client) UpdateContainer(
|
||||
ctx context.Context, serviceNameOrID, containerNameOrID string, opts api.UpdateContainerOptions,
|
||||
) error {
|
||||
op, err := cli.resolveContainerOperation(ctx, serviceNameOrID, containerNameOrID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req := &pb.UpdateServiceContainerRequest{
|
||||
Id: op.containerID,
|
||||
Cpu: opts.CPU,
|
||||
Memory: opts.Memory,
|
||||
MemoryReservation: opts.MemoryReservation,
|
||||
}
|
||||
if opts.ServiceLabels != nil {
|
||||
req.ServiceLabels = &pb.Labels{Labels: *opts.ServiceLabels}
|
||||
}
|
||||
|
||||
pw := progress.ContextWriter(op.ctx)
|
||||
pw.Event(progress.NewEvent(op.eventID, progress.Working, "Updating"))
|
||||
if _, err = cli.Docker.GRPCClient.UpdateServiceContainer(op.ctx, req); err != nil {
|
||||
pw.Event(progress.ErrorEvent(op.eventID))
|
||||
if status.Code(err) == codes.NotFound {
|
||||
return api.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
pw.Event(progress.NewEvent(op.eventID, progress.Done, "Updated"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecContainer executes a command in a container within the service.
|
||||
// If containerNameOrID is empty, the first container in the service will be used.
|
||||
func (cli *Client) ExecContainer(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"maps"
|
||||
"sort"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
type ContainerSpecStatus string
|
||||
|
||||
const (
|
||||
ContainerUpToDate ContainerSpecStatus = "up-to-date"
|
||||
ContainerUpToDate ContainerSpecStatus = "up-to-date"
|
||||
// ContainerNeedsUpdate means the container can be updated in place without recreating it.
|
||||
ContainerNeedsUpdate ContainerSpecStatus = "needs-update"
|
||||
ContainerNeedsRecreate ContainerSpecStatus = "needs-recreate"
|
||||
)
|
||||
@@ -83,15 +84,7 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta
|
||||
}
|
||||
}
|
||||
|
||||
// Device reservations and mappings are immutable, so we'll need to recreate if any have changed
|
||||
if !reflect.DeepEqual(current.Container.Resources.DeviceReservations, newResources.DeviceReservations) {
|
||||
return ContainerNeedsRecreate
|
||||
}
|
||||
if !reflect.DeepEqual(current.Container.Resources.Devices, newResources.Devices) {
|
||||
return ContainerNeedsRecreate
|
||||
}
|
||||
// Ulimits are immutable, so we'll need to recreate if any have changed.
|
||||
if !reflect.DeepEqual(current.Container.Resources.Ulimits, newResources.Ulimits) {
|
||||
if current.Container.Resources.ValidateUpdateInPlace(newResources) != nil {
|
||||
return ContainerNeedsRecreate
|
||||
}
|
||||
|
||||
@@ -100,8 +93,10 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta
|
||||
return ContainerNeedsRecreate
|
||||
}
|
||||
|
||||
// Remaining resources are mutable.
|
||||
if !reflect.DeepEqual(current.Container.Resources, newResources) {
|
||||
// CPU and memory limits are updated on the running container. Service labels are only stored in the service spec
|
||||
// and not applied to Docker containers.
|
||||
if !current.Container.Resources.Equals(newResources) ||
|
||||
!cmp.Equal(current.Labels, new.Labels, cmpopts.EquateEmpty()) {
|
||||
return ContainerNeedsUpdate
|
||||
}
|
||||
|
||||
@@ -119,3 +114,53 @@ func sortConfigs(configs []api.ConfigSpec) {
|
||||
return configs[i].Name < configs[j].Name
|
||||
})
|
||||
}
|
||||
|
||||
// EvalContainerChange evaluates whether the running container matches the new spec, can be updated in place to
|
||||
// match it, or needs to be recreated. Unlike EvalContainerSpecChange, it also compares the new spec with the CPU and
|
||||
// memory limits that Docker applies to the container, so the container is updated when they drifted from its spec.
|
||||
func EvalContainerChange(current api.ServiceContainer, new api.ServiceSpec) ContainerSpecStatus {
|
||||
status := EvalContainerSpecChange(current.ServiceSpec, new)
|
||||
if status == ContainerNeedsRecreate {
|
||||
return status
|
||||
}
|
||||
|
||||
applied := current.AppliedResources()
|
||||
newResources := new.Container.Resources
|
||||
if applied.ValidateUpdateInPlace(newResources) != nil {
|
||||
return ContainerNeedsRecreate
|
||||
}
|
||||
// Only CPU and memory limits can differ after the in-place update check above.
|
||||
if !applied.Equals(newResources) {
|
||||
return ContainerNeedsUpdate
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// ContainerUpdateOptions returns the container properties that can be updated in place and differ between the new
|
||||
// spec and either the container spec or the limits Docker applies to the container. It should only be used when
|
||||
// EvalContainerChange returns ContainerNeedsUpdate.
|
||||
func ContainerUpdateOptions(current api.ServiceContainer, new api.ServiceSpec) api.UpdateContainerOptions {
|
||||
var opts api.UpdateContainerOptions
|
||||
|
||||
stored, applied := current.ServiceSpec.Container.Resources, current.AppliedResources()
|
||||
res := new.Container.Resources
|
||||
if res.CPU != stored.CPU || res.CPU != applied.CPU {
|
||||
opts.CPU = &res.CPU
|
||||
}
|
||||
if res.Memory != stored.Memory || res.Memory != applied.Memory {
|
||||
opts.Memory = &res.Memory
|
||||
}
|
||||
if res.MemoryReservation != stored.MemoryReservation || res.MemoryReservation != applied.MemoryReservation {
|
||||
opts.MemoryReservation = &res.MemoryReservation
|
||||
}
|
||||
|
||||
if !cmp.Equal(current.ServiceSpec.Labels, new.Labels, cmpopts.EquateEmpty()) {
|
||||
labels := maps.Clone(new.Labels)
|
||||
if labels == nil {
|
||||
labels = map[string]string{}
|
||||
}
|
||||
opts.ServiceLabels = &labels
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
@@ -86,6 +86,223 @@ func TestEvalContainerSpecChange_ContainerTty(t *testing.T) {
|
||||
assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(newSpec, currentSpec))
|
||||
}
|
||||
|
||||
func TestEvalContainerSpecChange_ContainerLabels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
currentSpec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
}
|
||||
newSpec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
Labels: map[string]string{"com.example.team": "web"},
|
||||
},
|
||||
}
|
||||
emptySpec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
Labels: map[string]string{},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(currentSpec, newSpec))
|
||||
assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(newSpec, currentSpec))
|
||||
assert.Equal(t, ContainerUpToDate, EvalContainerSpecChange(newSpec, newSpec))
|
||||
assert.Equal(t, ContainerUpToDate, EvalContainerSpecChange(currentSpec, emptySpec))
|
||||
}
|
||||
|
||||
func TestEvalContainerSpecChange_ServiceLabels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
currentSpec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
}
|
||||
newSpec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
Labels: map[string]string{"tier": "frontend"},
|
||||
}
|
||||
emptySpec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
Labels: map[string]string{},
|
||||
}
|
||||
|
||||
assert.Equal(t, ContainerNeedsUpdate, EvalContainerSpecChange(currentSpec, newSpec))
|
||||
assert.Equal(t, ContainerNeedsUpdate, EvalContainerSpecChange(newSpec, currentSpec))
|
||||
assert.Equal(t, ContainerUpToDate, EvalContainerSpecChange(newSpec, newSpec))
|
||||
assert.Equal(t, ContainerUpToDate, EvalContainerSpecChange(currentSpec, emptySpec))
|
||||
|
||||
// Changes that require recreating the container take precedence over the in-place update.
|
||||
newSpecWithImage := newSpec.Clone()
|
||||
newSpecWithImage.Container.Image = "nginx:alpine"
|
||||
assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(currentSpec, newSpecWithImage))
|
||||
}
|
||||
|
||||
func TestEvalContainerChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
Resources: api.ContainerResources{CPU: api.Core, Memory: 256 * 1024 * 1024},
|
||||
},
|
||||
}
|
||||
appliedSpec := container.Resources{NanoCPUs: api.Core, Memory: 256 * 1024 * 1024}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hostConfig *container.HostConfig
|
||||
change func(spec *api.ServiceSpec)
|
||||
expected ContainerSpecStatus
|
||||
}{
|
||||
{
|
||||
name: "applied limits match spec",
|
||||
hostConfig: &container.HostConfig{Resources: appliedSpec},
|
||||
change: func(*api.ServiceSpec) {},
|
||||
expected: ContainerUpToDate,
|
||||
},
|
||||
{
|
||||
name: "unknown host config falls back to spec limits",
|
||||
change: func(*api.ServiceSpec) {},
|
||||
expected: ContainerUpToDate,
|
||||
},
|
||||
{
|
||||
name: "applied memory limit drifted from spec",
|
||||
hostConfig: &container.HostConfig{Resources: container.Resources{NanoCPUs: api.Core, Memory: 512 * 1024 * 1024}},
|
||||
change: func(*api.ServiceSpec) {},
|
||||
expected: ContainerNeedsUpdate,
|
||||
},
|
||||
{
|
||||
name: "applied limits already match new spec but stored spec doesn't",
|
||||
hostConfig: &container.HostConfig{Resources: container.Resources{NanoCPUs: api.Core, Memory: 512 * 1024 * 1024}},
|
||||
change: func(spec *api.ServiceSpec) { spec.Container.Resources.Memory = 512 * 1024 * 1024 },
|
||||
expected: ContainerNeedsUpdate,
|
||||
},
|
||||
{
|
||||
name: "applied limit not in spec can't be removed in place",
|
||||
hostConfig: &container.HostConfig{Resources: container.Resources{
|
||||
NanoCPUs: api.Core, Memory: 256 * 1024 * 1024, MemoryReservation: 128 * 1024 * 1024,
|
||||
}},
|
||||
change: func(*api.ServiceSpec) {},
|
||||
expected: ContainerNeedsRecreate,
|
||||
},
|
||||
{
|
||||
name: "spec change that requires recreating",
|
||||
hostConfig: &container.HostConfig{Resources: appliedSpec},
|
||||
change: func(spec *api.ServiceSpec) { spec.Container.Image = "nginx:alpine" },
|
||||
expected: ContainerNeedsRecreate,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newSpec := spec.Clone()
|
||||
tt.change(&newSpec)
|
||||
assert.Equal(t, tt.expected, EvalContainerChange(serviceContainer(spec, tt.hostConfig), newSpec))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerUpdateOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
current := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
Resources: api.ContainerResources{CPU: api.Core, Memory: 256 * 1024 * 1024},
|
||||
},
|
||||
Labels: map[string]string{"tier": "frontend"},
|
||||
}
|
||||
applied := &container.HostConfig{Resources: container.Resources{NanoCPUs: api.Core, Memory: 256 * 1024 * 1024}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hostConfig *container.HostConfig
|
||||
change func(spec *api.ServiceSpec)
|
||||
expected api.UpdateContainerOptions
|
||||
}{
|
||||
{
|
||||
name: "no changes",
|
||||
hostConfig: applied,
|
||||
change: func(*api.ServiceSpec) {},
|
||||
},
|
||||
{
|
||||
name: "CPU only",
|
||||
hostConfig: applied,
|
||||
change: func(spec *api.ServiceSpec) { spec.Container.Resources.CPU = 2 * api.Core },
|
||||
expected: api.UpdateContainerOptions{CPU: new(int64(2 * api.Core))},
|
||||
},
|
||||
{
|
||||
name: "memory and reservation",
|
||||
hostConfig: applied,
|
||||
change: func(spec *api.ServiceSpec) {
|
||||
spec.Container.Resources.Memory = 512 * 1024 * 1024
|
||||
spec.Container.Resources.MemoryReservation = 128 * 1024 * 1024
|
||||
},
|
||||
expected: api.UpdateContainerOptions{
|
||||
Memory: new(int64(512 * 1024 * 1024)),
|
||||
MemoryReservation: new(int64(128 * 1024 * 1024)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "applied memory limit drifted from spec",
|
||||
hostConfig: &container.HostConfig{Resources: container.Resources{
|
||||
NanoCPUs: api.Core, Memory: 512 * 1024 * 1024,
|
||||
}},
|
||||
change: func(*api.ServiceSpec) {},
|
||||
expected: api.UpdateContainerOptions{Memory: new(int64(256 * 1024 * 1024))},
|
||||
},
|
||||
{
|
||||
name: "applied memory limit already matches new spec",
|
||||
hostConfig: &container.HostConfig{Resources: container.Resources{
|
||||
NanoCPUs: api.Core, Memory: 512 * 1024 * 1024,
|
||||
}},
|
||||
change: func(spec *api.ServiceSpec) { spec.Container.Resources.Memory = 512 * 1024 * 1024 },
|
||||
expected: api.UpdateContainerOptions{Memory: new(int64(512 * 1024 * 1024))},
|
||||
},
|
||||
{
|
||||
name: "change service labels",
|
||||
hostConfig: applied,
|
||||
change: func(spec *api.ServiceSpec) { spec.Labels = map[string]string{"tier": "backend"} },
|
||||
expected: api.UpdateContainerOptions{ServiceLabels: &map[string]string{"tier": "backend"}},
|
||||
},
|
||||
{
|
||||
name: "remove all service labels",
|
||||
hostConfig: applied,
|
||||
change: func(spec *api.ServiceSpec) { spec.Labels = nil },
|
||||
expected: api.UpdateContainerOptions{ServiceLabels: &map[string]string{}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newSpec := current.Clone()
|
||||
tt.change(&newSpec)
|
||||
assert.Equal(t, tt.expected, ContainerUpdateOptions(serviceContainer(current, tt.hostConfig), newSpec))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func serviceContainer(spec api.ServiceSpec, hostConfig *container.HostConfig) api.ServiceContainer {
|
||||
return api.ServiceContainer{
|
||||
Container: api.Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{HostConfig: hostConfig},
|
||||
}},
|
||||
ServiceSpec: spec,
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalContainerSpecChange_ContainerResources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -127,7 +344,7 @@ func TestEvalContainerSpecChange_ContainerResources(t *testing.T) {
|
||||
CPU: 1000000000,
|
||||
},
|
||||
new: api.ContainerResources{},
|
||||
want: ContainerNeedsUpdate,
|
||||
want: ContainerNeedsRecreate,
|
||||
},
|
||||
|
||||
// Memory
|
||||
@@ -189,19 +406,33 @@ func TestEvalContainerSpecChange_ContainerResources(t *testing.T) {
|
||||
name: "unset memory",
|
||||
current: api.ContainerResources{Memory: 100 * 1024 * 1024, MemoryReservation: 50 * 1024 * 1024},
|
||||
new: api.ContainerResources{MemoryReservation: 50 * 1024 * 1024},
|
||||
want: ContainerNeedsUpdate,
|
||||
want: ContainerNeedsRecreate,
|
||||
},
|
||||
{
|
||||
name: "unset reservation",
|
||||
current: api.ContainerResources{Memory: 100 * 1024 * 1024, MemoryReservation: 50 * 1024 * 1024},
|
||||
new: api.ContainerResources{Memory: 100 * 1024 * 1024},
|
||||
want: ContainerNeedsUpdate,
|
||||
want: ContainerNeedsRecreate,
|
||||
},
|
||||
{
|
||||
name: "unset memory and reservation",
|
||||
current: api.ContainerResources{Memory: 100 * 1024 * 1024, MemoryReservation: 50 * 1024 * 1024},
|
||||
new: api.ContainerResources{},
|
||||
want: ContainerNeedsUpdate,
|
||||
want: ContainerNeedsRecreate,
|
||||
},
|
||||
|
||||
// Shared memory
|
||||
{
|
||||
name: "change shared memory",
|
||||
current: api.ContainerResources{SharedMemory: 64 * 1024 * 1024},
|
||||
new: api.ContainerResources{SharedMemory: 128 * 1024 * 1024},
|
||||
want: ContainerNeedsRecreate,
|
||||
},
|
||||
{
|
||||
name: "change memory and shared memory",
|
||||
current: api.ContainerResources{Memory: 100 * 1024 * 1024},
|
||||
new: api.ContainerResources{Memory: 200 * 1024 * 1024, SharedMemory: 64 * 1024 * 1024},
|
||||
want: ContainerNeedsRecreate,
|
||||
},
|
||||
|
||||
// Memory and CPU
|
||||
|
||||
+177
-21
@@ -1,19 +1,23 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/table"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
||||
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
@@ -50,25 +54,31 @@ type ServicePlan struct {
|
||||
// Format renders the service plan as a styled block with a spec diff and nested container operations.
|
||||
func (sp *ServicePlan) Format() string {
|
||||
// Determine service-level operation type and extract the old spec from container operations.
|
||||
// Assume replace operations precede remove operations (rolling strategy) so the first replace operation
|
||||
// (if exists) determines the old spec for the diff. Otherwise, fallback to the first remove operation.
|
||||
// Containers may have different specs after a failed deployment. The first replaced container determines the old
|
||||
// spec for the diff because a container updated in place already has the new image. Otherwise, fallback to the
|
||||
// first updated container and then the first removed one.
|
||||
var hasRun, hasRemove bool
|
||||
var oldSpec *api.ServiceSpec
|
||||
var replacedSpec, updatedSpec, removedSpec *api.ServiceSpec
|
||||
for _, op := range sp.Operations {
|
||||
switch o := op.(type) {
|
||||
case *operation.RunContainerOperation:
|
||||
hasRun = true
|
||||
case *operation.ReplaceContainerOperation:
|
||||
if oldSpec == nil {
|
||||
oldSpec = &o.OldContainer.ServiceSpec
|
||||
if replacedSpec == nil {
|
||||
replacedSpec = &o.OldContainer.ServiceSpec
|
||||
}
|
||||
case *operation.UpdateContainerOperation:
|
||||
if updatedSpec == nil {
|
||||
updatedSpec = &o.Container.ServiceSpec
|
||||
}
|
||||
case *operation.RemoveContainerOperation:
|
||||
hasRemove = true
|
||||
if oldSpec == nil {
|
||||
oldSpec = &o.Container.ServiceSpec
|
||||
if removedSpec == nil {
|
||||
removedSpec = &o.Container.ServiceSpec
|
||||
}
|
||||
}
|
||||
}
|
||||
oldSpec := cmp.Or(replacedSpec, updatedSpec, removedSpec)
|
||||
|
||||
// Service line modifier and verb.
|
||||
var modifier, verb string
|
||||
@@ -91,7 +101,7 @@ func (sp *ServicePlan) Format() string {
|
||||
out.WriteString("\n")
|
||||
|
||||
// Build spec diff table: columns are [modifier, attribute, value or change].
|
||||
// TODO: print diff for all changed attributes, not just image and replicas.
|
||||
// TODO: print diff for all changed attributes, not just image, replicas, and labels.
|
||||
// Consider reusing the logic in EvalContainerSpecChange to return a structured diff.
|
||||
specTable := table.New().
|
||||
Border(lipgloss.Border{}).
|
||||
@@ -124,6 +134,22 @@ func (sp *ServicePlan) Format() string {
|
||||
specTable.Row(mod, "image:", formatImageDiff(oldSpec.Container.Image, sp.Spec.Container.Image))
|
||||
}
|
||||
|
||||
// An existing service without an old spec only has new containers added to up-to-date ones so its Caddy config and
|
||||
// labels didn't change.
|
||||
specChanged := sp.IsNewService || oldSpec != nil
|
||||
var old api.ServiceSpec
|
||||
if oldSpec != nil {
|
||||
old = *oldSpec
|
||||
}
|
||||
|
||||
// Caddy config row.
|
||||
if specChanged {
|
||||
mod, value := formatCaddyConfigDiff(old.CaddyConfig(), sp.Spec.CaddyConfig(), sp.IsNewService)
|
||||
if value != "" {
|
||||
specTable.Row(mod, "caddy config:", value)
|
||||
}
|
||||
}
|
||||
|
||||
// Replicas row for replicated services.
|
||||
if sp.Spec.Mode == api.ServiceModeReplicated {
|
||||
replicasStr := fmt.Sprintf("%d", sp.Spec.Replicas)
|
||||
@@ -141,17 +167,19 @@ func (sp *ServicePlan) Format() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Stack " │ " tree prefixes vertically, then join horizontally with the table.
|
||||
tableStr := specTable.String()
|
||||
treePrefix := tui.Faint.Render(" │ ")
|
||||
treeColRows := make([]string, specTable.GetData().Rows())
|
||||
for i := range treeColRows {
|
||||
treeColRows[i] = treePrefix
|
||||
}
|
||||
treeCol := lipgloss.JoinVertical(lipgloss.Left, treeColRows...)
|
||||
specDiff := specTable.String() + "\n"
|
||||
|
||||
out.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, treeCol, tableStr))
|
||||
out.WriteString("\n")
|
||||
// Render the labels outside the table because they span multiple lines.
|
||||
if specChanged {
|
||||
specDiff += formatLabelsDiff("labels:", old.Labels, sp.Spec.Labels, sp.IsNewService)
|
||||
specDiff += formatLabelsDiff("container labels:", old.Container.Labels, sp.Spec.Container.Labels,
|
||||
sp.IsNewService)
|
||||
}
|
||||
|
||||
treePrefix := tui.Faint.Render(" │ ")
|
||||
for line := range strings.Lines(specDiff) {
|
||||
out.WriteString(treePrefix + line)
|
||||
}
|
||||
|
||||
// Blank separator line before container operations.
|
||||
out.WriteString(tui.Faint.Render(" │"))
|
||||
@@ -173,7 +201,7 @@ func (sp *ServicePlan) Format() string {
|
||||
|
||||
// FormatSummary counts operations in the service plan and renders a styled summary line.
|
||||
func (sp *ServicePlan) FormatSummary() string {
|
||||
var createCount, startFirstCount, stopFirstCount, removeCount int
|
||||
var createCount, startFirstCount, stopFirstCount, updateCount, removeCount int
|
||||
machines := make(map[string]struct{})
|
||||
|
||||
for _, op := range sp.Operations {
|
||||
@@ -188,12 +216,15 @@ func (sp *ServicePlan) FormatSummary() string {
|
||||
} else {
|
||||
startFirstCount++
|
||||
}
|
||||
case *operation.UpdateContainerOperation:
|
||||
machines[o.MachineID] = struct{}{}
|
||||
updateCount++
|
||||
case *operation.RemoveContainerOperation:
|
||||
machines[o.MachineID] = struct{}{}
|
||||
removeCount++
|
||||
case *operation.StopContainerOperation:
|
||||
// Stop operations aren't counted because a later operation in the plan removes the stopped container.
|
||||
machines[o.MachineID] = struct{}{}
|
||||
removeCount++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +241,10 @@ func (sp *ServicePlan) FormatSummary() string {
|
||||
parts = append(parts,
|
||||
tui.BoldYellow.Render(strconv.Itoa(stopFirstCount))+" "+tui.Yellow.Render("replace (stop-first)"))
|
||||
}
|
||||
if updateCount > 0 {
|
||||
parts = append(parts,
|
||||
tui.BoldYellow.Render(strconv.Itoa(updateCount))+" "+tui.Yellow.Render("update (in place)"))
|
||||
}
|
||||
if removeCount > 0 {
|
||||
parts = append(parts,
|
||||
tui.BoldRed.Render(strconv.Itoa(removeCount))+" "+tui.Red.Render("remove"))
|
||||
@@ -265,6 +300,127 @@ func formatImageDiff(oldImage, newImage string) string {
|
||||
tui.FormatImage(newImage, tui.Green)
|
||||
}
|
||||
|
||||
// formatCaddyConfigDiff returns the modifier and the number of added and removed lines in the Caddy config. It omits
|
||||
// the config content because it can be large. It returns an empty value if the configs are the same.
|
||||
func formatCaddyConfigDiff(oldConfig, newConfig string, isNewService bool) (mod, value string) {
|
||||
if oldConfig == newConfig {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Count lines regardless of their order, which is enough for a summary and doesn't require a diff algorithm.
|
||||
remaining := make(map[string]int)
|
||||
var removed int
|
||||
if oldConfig != "" {
|
||||
for line := range strings.SplitSeq(oldConfig, "\n") {
|
||||
remaining[line]++
|
||||
removed++
|
||||
}
|
||||
}
|
||||
var added int
|
||||
if newConfig != "" {
|
||||
for line := range strings.SplitSeq(newConfig, "\n") {
|
||||
if remaining[line] > 0 {
|
||||
remaining[line]--
|
||||
removed--
|
||||
} else {
|
||||
added++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var parts []string
|
||||
if added > 0 {
|
||||
parts = append(parts, tui.Green.Render("+"+strconv.Itoa(added)))
|
||||
}
|
||||
if removed > 0 {
|
||||
parts = append(parts, tui.Red.Render("-"+strconv.Itoa(removed)))
|
||||
}
|
||||
|
||||
switch {
|
||||
case added+removed == 0:
|
||||
value = "changed"
|
||||
case added+removed == 1:
|
||||
value = parts[0] + " line"
|
||||
default:
|
||||
value = strings.Join(parts, " ") + " lines"
|
||||
}
|
||||
|
||||
switch {
|
||||
case isNewService:
|
||||
mod = ""
|
||||
case oldConfig == "":
|
||||
mod = tui.Green.Render("+")
|
||||
case newConfig == "":
|
||||
mod = tui.Red.Render("-")
|
||||
default:
|
||||
mod = "~"
|
||||
}
|
||||
|
||||
return mod, value
|
||||
}
|
||||
|
||||
// formatLabelsDiff renders a block with the attribute name followed by a nested line for each added, changed, or
|
||||
// removed label, sorted by key. It returns an empty string if the labels are the same.
|
||||
func formatLabelsDiff(attribute string, oldLabels, newLabels map[string]string, isNewService bool) string {
|
||||
keys := slices.Concat(slices.Collect(maps.Keys(oldLabels)), slices.Collect(maps.Keys(newLabels)))
|
||||
slices.Sort(keys)
|
||||
|
||||
var lines strings.Builder
|
||||
for _, key := range slices.Compact(keys) {
|
||||
oldValue, inOld := oldLabels[key]
|
||||
newValue, inNew := newLabels[key]
|
||||
|
||||
var mod, diff string
|
||||
switch {
|
||||
case !inOld:
|
||||
mod = tui.Green.Render("+")
|
||||
// All attributes of a new service are new so only the green color marks them, without a modifier.
|
||||
if isNewService {
|
||||
mod = " "
|
||||
}
|
||||
diff = tui.Green.Render(key+tui.Faint.Render("=")) + tui.Green.Render(formatLabelValue(newValue))
|
||||
case !inNew:
|
||||
mod = tui.Red.Render("-")
|
||||
diff = tui.Red.Render(key+tui.Faint.Render("=")) + tui.Red.Render(formatLabelValue(oldValue))
|
||||
case oldValue != newValue:
|
||||
mod = tui.Yellow.Render("~")
|
||||
diff = key +
|
||||
tui.Faint.Render("=") +
|
||||
tui.Red.Render(formatLabelValue(oldValue)) + " " +
|
||||
tui.Faint.Render("→") + " " +
|
||||
tui.Green.Render(formatLabelValue(newValue))
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
lines.WriteString(" " + mod + " " + diff + "\n")
|
||||
}
|
||||
if lines.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
blockMod := tui.Yellow.Render("~")
|
||||
switch {
|
||||
case isNewService:
|
||||
blockMod = " "
|
||||
case len(oldLabels) == 0:
|
||||
blockMod = tui.Green.Render("+")
|
||||
case len(newLabels) == 0:
|
||||
blockMod = tui.Red.Render("-")
|
||||
}
|
||||
|
||||
return blockMod + " " + tui.Faint.Render(attribute) + "\n" + lines.String()
|
||||
}
|
||||
|
||||
// formatLabelValue returns the label value on a single line. It quotes and escapes empty values and values with
|
||||
// non-printable characters such as newlines so that they're visible in the diff. Other values are returned as is.
|
||||
func formatLabelValue(value string) string {
|
||||
if value == "" || strings.ContainsFunc(value, func(r rune) bool { return !unicode.IsPrint(r) }) {
|
||||
return strconv.Quote(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// NewDeployment creates a new deployment for the given service specification.
|
||||
// If strategy is nil, a default RollingStrategy will be used.
|
||||
func NewDeployment(cli Client, spec api.ServiceSpec, strategy Strategy) *Deployment {
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestServicePlan_FormatSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ops []operation.Operation
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "in-place updates only",
|
||||
ops: []operation.Operation{
|
||||
&operation.UpdateContainerOperation{MachineID: "m-1"},
|
||||
&operation.UpdateContainerOperation{MachineID: "m-2"},
|
||||
},
|
||||
expected: "2 update (in place) · across 2 machines",
|
||||
},
|
||||
{
|
||||
name: "stop operations not counted as they are followed by remove",
|
||||
ops: []operation.Operation{
|
||||
&operation.StopContainerOperation{MachineID: "m-1"},
|
||||
&operation.StopContainerOperation{MachineID: "m-1"},
|
||||
&operation.ReplaceContainerOperation{MachineID: "m-1", Order: api.UpdateOrderStopFirst},
|
||||
&operation.RemoveContainerOperation{MachineID: "m-1"},
|
||||
},
|
||||
expected: "1 replace (stop-first) · 1 remove · across 1 machine",
|
||||
},
|
||||
{
|
||||
name: "all operation types",
|
||||
ops: []operation.Operation{
|
||||
&operation.RunContainerOperation{MachineID: "m-1"},
|
||||
&operation.ReplaceContainerOperation{MachineID: "m-1", Order: api.UpdateOrderStartFirst},
|
||||
&operation.ReplaceContainerOperation{MachineID: "m-2", Order: api.UpdateOrderStopFirst},
|
||||
&operation.UpdateContainerOperation{MachineID: "m-2"},
|
||||
&operation.RemoveContainerOperation{MachineID: "m-3"},
|
||||
},
|
||||
expected: "1 create · 1 replace (start-first) · 1 replace (stop-first) · 1 update (in place) · 1 remove · " +
|
||||
"across 3 machines",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plan := ServicePlan{}
|
||||
plan.Operations = tt.ops
|
||||
assert.Equal(t, tt.expected, ansi.Strip(plan.FormatSummary()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePlan_Format(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newContainer := func(spec api.ServiceSpec) api.ServiceContainer {
|
||||
return api.ServiceContainer{
|
||||
Container: api.Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{ID: "0f6df6593e1b0123456789abcdef"},
|
||||
}},
|
||||
ServiceSpec: spec,
|
||||
}
|
||||
}
|
||||
spec := func(image string, labels, containerLabels map[string]string) api.ServiceSpec {
|
||||
return api.ServiceSpec{
|
||||
Name: "web",
|
||||
Mode: api.ServiceModeReplicated,
|
||||
Replicas: 1,
|
||||
Labels: labels,
|
||||
Container: api.ContainerSpec{Image: image, Labels: containerLabels},
|
||||
}
|
||||
}
|
||||
|
||||
withCaddy := func(config string) api.ServiceSpec {
|
||||
s := spec("nginx:1.25", nil, nil)
|
||||
s.Caddy = &api.CaddySpec{Config: config}
|
||||
return s
|
||||
}
|
||||
replaceFrom := func(old api.ServiceSpec) func(api.ServiceSpec) []operation.Operation {
|
||||
return func(new api.ServiceSpec) []operation.Operation {
|
||||
return []operation.Operation{&operation.ReplaceContainerOperation{
|
||||
Spec: new,
|
||||
MachineName: "machine-1",
|
||||
OldContainer: newContainer(old),
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
isNewService bool
|
||||
spec api.ServiceSpec
|
||||
ops func(spec api.ServiceSpec) []operation.Operation
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "caddy config lines added and removed",
|
||||
spec: withCaddy("{\n\tstorage uncloud\n}"),
|
||||
ops: replaceFrom(withCaddy("{\n\tdebug\n\tauto_https off\n}")),
|
||||
expected: []string{
|
||||
"~ update service web",
|
||||
" │ image: nginx:1.25",
|
||||
" │ ~ caddy config: +1 -2 lines",
|
||||
" │",
|
||||
" ╰── +/- replace container web/0f6df6593e1b on machine-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "caddy config single line added",
|
||||
spec: withCaddy("{\n\tstorage uncloud\n\tdebug\n}"),
|
||||
ops: replaceFrom(withCaddy("{\n\tstorage uncloud\n}")),
|
||||
expected: []string{
|
||||
"~ update service web",
|
||||
" │ image: nginx:1.25",
|
||||
" │ ~ caddy config: +1 line",
|
||||
" │",
|
||||
" ╰── +/- replace container web/0f6df6593e1b on machine-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "caddy config added to existing service",
|
||||
spec: withCaddy("{\n\tstorage uncloud\n}"),
|
||||
ops: replaceFrom(spec("nginx:1.25", nil, nil)),
|
||||
expected: []string{
|
||||
"~ update service web",
|
||||
" │ image: nginx:1.25",
|
||||
" │ + caddy config: +3 lines",
|
||||
" │",
|
||||
" ╰── +/- replace container web/0f6df6593e1b on machine-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "caddy config removed",
|
||||
spec: spec("nginx:1.25", nil, nil),
|
||||
ops: replaceFrom(withCaddy("{\n\tstorage uncloud\n}")),
|
||||
expected: []string{
|
||||
"~ update service web",
|
||||
" │ image: nginx:1.25",
|
||||
" │ - caddy config: -3 lines",
|
||||
" │",
|
||||
" ╰── +/- replace container web/0f6df6593e1b on machine-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "caddy config with only reordered lines",
|
||||
spec: withCaddy("a {\n}\nb {\n}"),
|
||||
ops: replaceFrom(withCaddy("b {\n}\na {\n}")),
|
||||
expected: []string{
|
||||
"~ update service web",
|
||||
" │ image: nginx:1.25",
|
||||
" │ ~ caddy config: changed",
|
||||
" │",
|
||||
" ╰── +/- replace container web/0f6df6593e1b on machine-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "caddy config unchanged ignoring surrounding whitespace",
|
||||
spec: withCaddy("{\n\tdebug\n}"),
|
||||
ops: replaceFrom(withCaddy("\n{\n\tdebug\n}\n")),
|
||||
expected: []string{
|
||||
"~ update service web",
|
||||
" │ image: nginx:1.25",
|
||||
" │",
|
||||
" ╰── +/- replace container web/0f6df6593e1b on machine-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "new service with caddy config",
|
||||
isNewService: true,
|
||||
spec: withCaddy("{\n\tstorage uncloud\n}"),
|
||||
ops: func(new api.ServiceSpec) []operation.Operation {
|
||||
return []operation.Operation{&operation.RunContainerOperation{Spec: new, MachineName: "machine-1"}}
|
||||
},
|
||||
expected: []string{
|
||||
"+ create service web",
|
||||
" │ image: nginx:1.25",
|
||||
" │ caddy config: +3 lines",
|
||||
" │",
|
||||
" ╰── + run container web on machine-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "caddy config before replicas",
|
||||
isNewService: true,
|
||||
spec: func() api.ServiceSpec {
|
||||
s := withCaddy("{\n\tstorage uncloud\n}")
|
||||
s.Replicas = 3
|
||||
return s
|
||||
}(),
|
||||
ops: func(new api.ServiceSpec) []operation.Operation {
|
||||
return []operation.Operation{&operation.RunContainerOperation{Spec: new, MachineName: "machine-1"}}
|
||||
},
|
||||
expected: []string{
|
||||
"+ create service web",
|
||||
" │ image: nginx:1.25",
|
||||
" │ caddy config: +3 lines",
|
||||
" │ replicas: 3",
|
||||
" │",
|
||||
" ╰── + run container web on machine-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "service labels added, changed, and removed in place",
|
||||
spec: spec("nginx:1.25", map[string]string{"env": "prod", "team": "platform", "same": "1"}, nil),
|
||||
ops: func(api.ServiceSpec) []operation.Operation {
|
||||
return []operation.Operation{&operation.UpdateContainerOperation{
|
||||
MachineName: "machine-1",
|
||||
Container: newContainer(spec(
|
||||
"nginx:1.25", map[string]string{"old": "legacy", "team": "backend", "same": "1"}, nil,
|
||||
)),
|
||||
}}
|
||||
},
|
||||
expected: []string{
|
||||
"~ update service web",
|
||||
" │ image: nginx:1.25",
|
||||
" │ ~ labels:",
|
||||
" │ + env=prod",
|
||||
" │ - old=legacy",
|
||||
" │ ~ team=backend → platform",
|
||||
" │",
|
||||
" ╰── ~ update container web/0f6df6593e1b on machine-1 (in place)",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "container labels changed with image on replace",
|
||||
spec: spec("nginx:1.26", nil, map[string]string{"proxy": "on"}),
|
||||
ops: func(new api.ServiceSpec) []operation.Operation {
|
||||
return []operation.Operation{&operation.ReplaceContainerOperation{
|
||||
Spec: new,
|
||||
MachineName: "machine-1",
|
||||
OldContainer: newContainer(spec("nginx:1.25", nil, map[string]string{"proxy": "off"})),
|
||||
}}
|
||||
},
|
||||
expected: []string{
|
||||
"~ update service web",
|
||||
" │ ~ image: nginx:1.25 → 1.26",
|
||||
" │ ~ container labels:",
|
||||
" │ ~ proxy=off → on",
|
||||
" │",
|
||||
" ╰── +/- replace container web/0f6df6593e1b on machine-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no label blocks when labels are the same",
|
||||
spec: spec("nginx:1.26", map[string]string{}, map[string]string{"proxy": "on"}),
|
||||
ops: func(new api.ServiceSpec) []operation.Operation {
|
||||
return []operation.Operation{&operation.ReplaceContainerOperation{
|
||||
Spec: new,
|
||||
MachineName: "machine-1",
|
||||
OldContainer: newContainer(spec("nginx:1.25", nil, map[string]string{"proxy": "on"})),
|
||||
}}
|
||||
},
|
||||
expected: []string{
|
||||
"~ update service web",
|
||||
" │ ~ image: nginx:1.25 → 1.26",
|
||||
" │",
|
||||
" ╰── +/- replace container web/0f6df6593e1b on machine-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replaced container determines old spec over updated one",
|
||||
spec: spec("nginx:1.26", map[string]string{"team": "platform"}, nil),
|
||||
ops: func(new api.ServiceSpec) []operation.Operation {
|
||||
return []operation.Operation{
|
||||
&operation.UpdateContainerOperation{
|
||||
MachineName: "machine-1",
|
||||
Container: newContainer(spec("nginx:1.26", map[string]string{"team": "backend"}, nil)),
|
||||
},
|
||||
&operation.ReplaceContainerOperation{
|
||||
Spec: new,
|
||||
MachineName: "machine-2",
|
||||
OldContainer: newContainer(spec("nginx:1.25", map[string]string{"team": "backend"}, nil)),
|
||||
},
|
||||
}
|
||||
},
|
||||
expected: []string{
|
||||
"~ update service web",
|
||||
" │ ~ image: nginx:1.25 → 1.26",
|
||||
" │ ~ labels:",
|
||||
" │ ~ team=backend → platform",
|
||||
" │",
|
||||
" ├── ~ update container web/0f6df6593e1b on machine-1 (in place)",
|
||||
" ╰── +/- replace container web/0f6df6593e1b on machine-2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all labels removed",
|
||||
spec: spec("nginx:1.25", map[string]string{}, nil),
|
||||
ops: func(api.ServiceSpec) []operation.Operation {
|
||||
return []operation.Operation{&operation.UpdateContainerOperation{
|
||||
MachineName: "machine-1",
|
||||
Container: newContainer(spec("nginx:1.25", map[string]string{"a": "1", "b": "2"}, nil)),
|
||||
}}
|
||||
},
|
||||
expected: []string{
|
||||
"~ update service web",
|
||||
" │ image: nginx:1.25",
|
||||
" │ - labels:",
|
||||
" │ - a=1",
|
||||
" │ - b=2",
|
||||
" │",
|
||||
" ╰── ~ update container web/0f6df6593e1b on machine-1 (in place)",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "first labels added to existing service",
|
||||
spec: spec("nginx:1.25", map[string]string{"a": "1"}, nil),
|
||||
ops: func(api.ServiceSpec) []operation.Operation {
|
||||
return []operation.Operation{&operation.UpdateContainerOperation{
|
||||
MachineName: "machine-1",
|
||||
Container: newContainer(spec("nginx:1.25", nil, nil)),
|
||||
}}
|
||||
},
|
||||
expected: []string{
|
||||
"~ update service web",
|
||||
" │ image: nginx:1.25",
|
||||
" │ + labels:",
|
||||
" │ + a=1",
|
||||
" │",
|
||||
" ╰── ~ update container web/0f6df6593e1b on machine-1 (in place)",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty and non-printable label values are escaped on a single line",
|
||||
spec: spec("nginx:1.25", map[string]string{
|
||||
"added": "1\n2",
|
||||
"cleared": "",
|
||||
"json": `{"a":1}`,
|
||||
"multi": "a\nb\nc",
|
||||
"tab": "a\tb",
|
||||
"trailing": "hello\n",
|
||||
"unicode": "héllo wörld 🚀",
|
||||
}, nil),
|
||||
ops: func(api.ServiceSpec) []operation.Operation {
|
||||
return []operation.Operation{&operation.UpdateContainerOperation{
|
||||
MachineName: "machine-1",
|
||||
Container: newContainer(spec("nginx:1.25", map[string]string{
|
||||
"cleared": "x",
|
||||
"multi": "a\nb",
|
||||
"trailing": "hello",
|
||||
}, nil)),
|
||||
}}
|
||||
},
|
||||
expected: []string{
|
||||
"~ update service web",
|
||||
" │ image: nginx:1.25",
|
||||
" │ ~ labels:",
|
||||
` │ + added="1\n2"`,
|
||||
` │ ~ cleared=x → ""`,
|
||||
` │ + json={"a":1}`,
|
||||
` │ ~ multi="a\nb" → "a\nb\nc"`,
|
||||
` │ + tab="a\tb"`,
|
||||
` │ ~ trailing=hello → "hello\n"`,
|
||||
" │ + unicode=héllo wörld 🚀",
|
||||
" │",
|
||||
" ╰── ~ update container web/0f6df6593e1b on machine-1 (in place)",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "new service with labels",
|
||||
isNewService: true,
|
||||
spec: spec("nginx:1.25", map[string]string{"team": "platform"}, map[string]string{"proxy": "on"}),
|
||||
ops: func(new api.ServiceSpec) []operation.Operation {
|
||||
return []operation.Operation{&operation.RunContainerOperation{Spec: new, MachineName: "machine-1"}}
|
||||
},
|
||||
expected: []string{
|
||||
"+ create service web",
|
||||
" │ image: nginx:1.25",
|
||||
" │ labels:",
|
||||
" │ team=platform",
|
||||
" │ container labels:",
|
||||
" │ proxy=on",
|
||||
" │",
|
||||
" ╰── + run container web on machine-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plan := ServicePlan{ServiceName: "web", IsNewService: tt.isNewService, Spec: tt.spec}
|
||||
plan.Operations = tt.ops(tt.spec)
|
||||
|
||||
// The table pads cells to the column width so ignore trailing whitespace.
|
||||
var lines []string
|
||||
for line := range strings.Lines(ansi.Strip(plan.Format())) {
|
||||
lines = append(lines, strings.TrimRight(line, " \n"))
|
||||
}
|
||||
assert.Equal(t, tt.expected, lines)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"github.com/psviderski/uncloud/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
||||
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||
)
|
||||
|
||||
// Strategy defines how a service should be deployed or updated. Different implementations can provide various
|
||||
@@ -92,7 +92,7 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
|
||||
|
||||
// Organise existing containers by machine.
|
||||
containersOnMachine := make(map[string][]api.ServiceContainer)
|
||||
upToDateContainersOnMachine := make(map[string]int)
|
||||
reusableContainersOnMachine := make(map[string]int)
|
||||
containerSpecStatuses := make(map[string]ContainerSpecStatus)
|
||||
if svc != nil {
|
||||
for _, c := range svc.Containers {
|
||||
@@ -105,40 +105,50 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
|
||||
if s.ForceRecreate {
|
||||
status = ContainerNeedsRecreate
|
||||
} else {
|
||||
status = EvalContainerSpecChange(c.Container.ServiceSpec, spec)
|
||||
status = EvalContainerChange(c.Container, spec)
|
||||
}
|
||||
containerSpecStatuses[c.Container.ID] = status
|
||||
|
||||
if status == ContainerUpToDate {
|
||||
upToDateContainersOnMachine[c.MachineID] += 1
|
||||
if status != ContainerNeedsRecreate {
|
||||
reusableContainersOnMachine[c.MachineID] += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Sort containers such that running containers with the desired spec are first.
|
||||
// Sort containers such that running containers with the desired spec or that can be updated in place to
|
||||
// the desired spec are first.
|
||||
// TODO: consider ranking up-to-date containers above ones that need an in-place update (here, when sorting
|
||||
// machines, and in reconcileGlobalContainer) to avoid updating a container while removing an up-to-date one.
|
||||
isReusable := func(c api.MachineServiceContainer) bool {
|
||||
// Not running containers have no status.
|
||||
status, ok := containerSpecStatuses[c.Container.ID]
|
||||
return ok && status != ContainerNeedsRecreate
|
||||
}
|
||||
slices.SortFunc(svc.Containers, func(c1, c2 api.MachineServiceContainer) int {
|
||||
if status, ok := containerSpecStatuses[c1.Container.ID]; ok && status == ContainerUpToDate {
|
||||
r1, r2 := isReusable(c1), isReusable(c2)
|
||||
switch {
|
||||
case r1 == r2:
|
||||
return 0
|
||||
case r1:
|
||||
return -1
|
||||
}
|
||||
if status, ok := containerSpecStatuses[c2.Container.ID]; ok && status == ContainerUpToDate {
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
for _, c := range svc.Containers {
|
||||
containersOnMachine[c.MachineID] = append(containersOnMachine[c.MachineID], c.Container)
|
||||
}
|
||||
|
||||
// Sort machines such that machines with the most up-to-date containers are first, followed by machines with
|
||||
// existing containers, and finally machines without containers.
|
||||
// Sort machines such that machines with the most containers that are up to date or can be updated in place
|
||||
// are first, followed by machines with existing containers, and finally machines without containers.
|
||||
slices.SortFunc(matchedMachines, func(m1, m2 *pb.MachineInfo) int {
|
||||
if upToDateContainersOnMachine[m1.Id] > 0 && upToDateContainersOnMachine[m2.Id] > 0 {
|
||||
return upToDateContainersOnMachine[m2.Id] - upToDateContainersOnMachine[m1.Id]
|
||||
if reusableContainersOnMachine[m1.Id] > 0 && reusableContainersOnMachine[m2.Id] > 0 {
|
||||
return reusableContainersOnMachine[m2.Id] - reusableContainersOnMachine[m1.Id]
|
||||
}
|
||||
if upToDateContainersOnMachine[m1.Id] > 0 {
|
||||
if reusableContainersOnMachine[m1.Id] > 0 {
|
||||
return -1
|
||||
}
|
||||
if upToDateContainersOnMachine[m2.Id] > 0 {
|
||||
if reusableContainersOnMachine[m2.Id] > 0 {
|
||||
return 1
|
||||
}
|
||||
return len(containersOnMachine[m2.Id]) - len(containersOnMachine[m1.Id])
|
||||
@@ -170,7 +180,16 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
|
||||
if status == ContainerUpToDate {
|
||||
continue
|
||||
}
|
||||
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
|
||||
if status == ContainerNeedsUpdate {
|
||||
plan.Operations = append(plan.Operations, &operation.UpdateContainerOperation{
|
||||
ServiceID: plan.ServiceID,
|
||||
MachineID: m.Id,
|
||||
MachineName: m.Name,
|
||||
Container: ctr,
|
||||
Options: ContainerUpdateOptions(ctr, spec),
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the old container with a new one.
|
||||
@@ -301,7 +320,7 @@ func reconcileGlobalContainer(
|
||||
}
|
||||
|
||||
// Check if there is a container with the same spec already running. If so, remove the rest.
|
||||
upToDate := false
|
||||
reused := false
|
||||
for i, c := range containers {
|
||||
if !c.Container.State.Running || c.Container.State.Paused {
|
||||
// Skip containers that are not running.
|
||||
@@ -312,12 +331,21 @@ func reconcileGlobalContainer(
|
||||
if forceRecreate {
|
||||
status = ContainerNeedsRecreate
|
||||
} else {
|
||||
status = EvalContainerSpecChange(c.Container.ServiceSpec, spec)
|
||||
status = EvalContainerChange(c.Container, spec)
|
||||
}
|
||||
|
||||
if status == ContainerUpToDate {
|
||||
// The container is already running with the same spec.
|
||||
upToDate = true
|
||||
if status != ContainerNeedsRecreate {
|
||||
// The container is already running with the desired spec or can be updated in place to it.
|
||||
reused = true
|
||||
if status == ContainerNeedsUpdate {
|
||||
ops = append(ops, &operation.UpdateContainerOperation{
|
||||
ServiceID: serviceID,
|
||||
MachineID: machine.Id,
|
||||
MachineName: machine.Name,
|
||||
Container: c.Container,
|
||||
Options: ContainerUpdateOptions(c.Container, spec),
|
||||
})
|
||||
}
|
||||
for j, old := range containers {
|
||||
if i == j {
|
||||
continue
|
||||
@@ -331,10 +359,8 @@ func reconcileGlobalContainer(
|
||||
}
|
||||
break
|
||||
}
|
||||
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
|
||||
// Make sure to update preDeployOperation accordingly.
|
||||
}
|
||||
if upToDate {
|
||||
if reused {
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
@@ -360,6 +386,7 @@ func reconcileGlobalContainer(
|
||||
if err != nil || len(conflictingPorts) > 0 {
|
||||
ops = append(ops, &operation.StopContainerOperation{
|
||||
ServiceID: serviceID,
|
||||
ServiceName: c.Container.ServiceSpec.Name,
|
||||
ContainerID: c.Container.ID,
|
||||
MachineID: machine.Id,
|
||||
MachineName: machine.Name,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
@@ -8,9 +10,10 @@ import (
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/psviderski/uncloud/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
||||
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDetermineUpdateOrder(t *testing.T) {
|
||||
@@ -360,6 +363,7 @@ func TestReconcileGlobalContainer(t *testing.T) {
|
||||
expectedOps: []operation.Operation{
|
||||
&operation.StopContainerOperation{
|
||||
ServiceID: "service-1",
|
||||
ServiceName: container2WithPort9090.ServiceSpec.Name,
|
||||
ContainerID: "container-2",
|
||||
MachineID: "machine-1",
|
||||
MachineName: "machine-1",
|
||||
@@ -424,6 +428,121 @@ func TestReconcileGlobalContainer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollingStrategy_Plan_UpdateInPlace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := &scheduler.ClusterState{
|
||||
Machines: []*scheduler.Machine{
|
||||
{Info: &pb.MachineInfo{Id: "m-1", Name: "machine-1"}},
|
||||
{Info: &pb.MachineInfo{Id: "m-2", Name: "machine-2"}},
|
||||
},
|
||||
}
|
||||
newContainer := func(id string, spec api.ServiceSpec) api.ServiceContainer {
|
||||
ctr := newServiceContainer(id, container.State{Running: true, Status: "running"})
|
||||
ctr.ServiceSpec = spec
|
||||
return ctr
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
change func(spec *api.ServiceSpec)
|
||||
// options are the expected in-place update options. Nil means the containers must be recreated.
|
||||
options *api.UpdateContainerOptions
|
||||
}{
|
||||
{
|
||||
name: "service labels",
|
||||
change: func(spec *api.ServiceSpec) { spec.Labels = map[string]string{"tier": "backend"} },
|
||||
options: &api.UpdateContainerOptions{ServiceLabels: &map[string]string{"tier": "backend"}},
|
||||
},
|
||||
{
|
||||
name: "CPU and memory limits",
|
||||
change: func(spec *api.ServiceSpec) {
|
||||
spec.Container.Resources.CPU = 2 * api.Core
|
||||
spec.Container.Resources.Memory = 512 * 1024 * 1024
|
||||
},
|
||||
options: &api.UpdateContainerOptions{
|
||||
CPU: new(int64(2 * api.Core)),
|
||||
Memory: new(int64(512 * 1024 * 1024)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove memory limit",
|
||||
change: func(spec *api.ServiceSpec) { spec.Container.Resources.Memory = 0 },
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, mode := range []string{api.ServiceModeReplicated, api.ServiceModeGlobal} {
|
||||
t.Run(tt.name+"/"+mode, func(t *testing.T) {
|
||||
currentSpec := api.ServiceSpec{
|
||||
Name: "app",
|
||||
Mode: mode,
|
||||
Container: api.ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
Resources: api.ContainerResources{
|
||||
CPU: api.Core,
|
||||
Memory: 256 * 1024 * 1024,
|
||||
},
|
||||
},
|
||||
Labels: map[string]string{"tier": "frontend"},
|
||||
PreDeploy: &api.PreDeployHook{Command: []string{"db", "migrate"}},
|
||||
}
|
||||
if mode == api.ServiceModeReplicated {
|
||||
currentSpec.Replicas = 2
|
||||
}
|
||||
newSpec := currentSpec.Clone()
|
||||
tt.change(&newSpec)
|
||||
|
||||
ctr1 := newContainer("container-1", currentSpec)
|
||||
ctr2 := newContainer("container-2", currentSpec)
|
||||
svc := &api.Service{
|
||||
ID: "svc-1",
|
||||
Name: "app",
|
||||
Mode: mode,
|
||||
Containers: []api.MachineServiceContainer{
|
||||
{MachineID: "m-1", MachineName: "machine-1", Container: ctr1},
|
||||
{MachineID: "m-2", MachineName: "machine-2", Container: ctr2},
|
||||
},
|
||||
}
|
||||
|
||||
plan, err := (&RollingStrategy{}).Plan(state, svc, newSpec)
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.options == nil {
|
||||
require.NotEmpty(t, plan.Operations)
|
||||
for _, op := range plan.Operations {
|
||||
_, isUpdate := op.(*operation.UpdateContainerOperation)
|
||||
assert.False(t, isUpdate, "Expected containers to be recreated, got %s", op)
|
||||
}
|
||||
return
|
||||
}
|
||||
// The replicated strategy shuffles machines so sort the operations to compare them deterministically.
|
||||
ops := slices.Clone(plan.Operations)
|
||||
slices.SortFunc(ops, func(a, b operation.Operation) int {
|
||||
return strings.Compare(a.String(), b.String())
|
||||
})
|
||||
// The containers are updated in place and the pre-deploy hook is not run.
|
||||
assertOperationsEqual(t, []operation.Operation{
|
||||
&operation.UpdateContainerOperation{
|
||||
ServiceID: "svc-1",
|
||||
MachineID: "m-1",
|
||||
MachineName: "machine-1",
|
||||
Container: ctr1,
|
||||
Options: *tt.options,
|
||||
},
|
||||
&operation.UpdateContainerOperation{
|
||||
ServiceID: "svc-1",
|
||||
MachineID: "m-2",
|
||||
MachineName: "machine-2",
|
||||
Container: ctr2,
|
||||
Options: *tt.options,
|
||||
},
|
||||
}, ops)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreDeployOperations(t *testing.T) {
|
||||
hook := &api.PreDeployHook{
|
||||
Command: []string{"db", "migrate"},
|
||||
@@ -502,6 +621,20 @@ func TestPreDeployOperations(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "plan has only UpdateContainerOperation",
|
||||
plan: ServicePlan{
|
||||
ServiceID: "svc-1",
|
||||
ServiceName: "app",
|
||||
Spec: api.ServiceSpec{PreDeploy: hook},
|
||||
SequenceOperation: operation.SequenceOperation{
|
||||
Operations: []operation.Operation{
|
||||
&operation.UpdateContainerOperation{MachineID: "m-1", MachineName: "machine-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "plan has only RemoveContainerOperation",
|
||||
plan: ServicePlan{
|
||||
@@ -614,7 +747,8 @@ func TestPreDeployOperations(t *testing.T) {
|
||||
},
|
||||
svc: &api.Service{
|
||||
HookContainers: []api.MachineServiceContainer{
|
||||
{MachineID: "m-1", Container: newServiceContainer("stopped-hook", container.State{Status: "exited"})},
|
||||
{MachineID: "m-1", Container: newServiceContainer("stopped-hook",
|
||||
container.State{Status: "exited"})},
|
||||
{MachineID: "m-1", Container: runningHook1},
|
||||
{MachineID: "m-3", Container: runningHook2},
|
||||
},
|
||||
@@ -660,6 +794,7 @@ func newServiceContainer(id string, state container.State) api.ServiceContainer
|
||||
ID: id,
|
||||
State: &state,
|
||||
},
|
||||
Config: &container.Config{},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
+82
-3
@@ -23,9 +23,11 @@ import (
|
||||
"github.com/docker/docker/api/types/image"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"github.com/docker/go-connections/nat"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/psviderski/uncloud/api/pb"
|
||||
cliprogress "github.com/psviderski/uncloud/internal/cli/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/docker"
|
||||
"github.com/psviderski/uncloud/internal/machine/constants"
|
||||
@@ -34,6 +36,8 @@ import (
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
netproxy "golang.org/x/net/proxy"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is the container image used to run socat proxy containers.
|
||||
@@ -61,7 +65,7 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap
|
||||
// Broadcast the image list request to the specified machines or all machines if none specified.
|
||||
listCtx := ProxyMachinesContext(ctx, filter.Machines)
|
||||
|
||||
opts := image.ListOptions{Manifests: true}
|
||||
opts := image.ListOptions{All: true, Manifests: true}
|
||||
if filter.Name != "" {
|
||||
opts.Filters = filters.NewArgs(
|
||||
filters.Arg("reference", filter.Name),
|
||||
@@ -92,7 +96,7 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap
|
||||
ContainerdStore: msg.ContainerdStore,
|
||||
}
|
||||
|
||||
if msg.Metadata.Error == "" && len(msg.Images) > 0 {
|
||||
if msg.Metadata.Err() == nil && len(msg.Images) > 0 {
|
||||
if err = json.Unmarshal(msg.Images, &mi.Images); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal images: %w", err)
|
||||
}
|
||||
@@ -104,6 +108,81 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap
|
||||
return machineImages, nil
|
||||
}
|
||||
|
||||
// RemoveImage removes one exact image name or ID on the selected machines with protection of used references.
|
||||
// Results include the outcome of removing an image on each machine.
|
||||
//
|
||||
// An absent image on some machines is normal if removal succeeds on at least one machine. If every selected machine
|
||||
// reports NotFound, the returned error is api.ErrNotFound. Other machine failures are combined, with successful
|
||||
// results still returned.
|
||||
func (cli *Client) RemoveImage(
|
||||
ctx context.Context, imageNameOrID string, opts api.RemoveImageOptions,
|
||||
) ([]api.MachineImageRemoval, error) {
|
||||
if strings.TrimSpace(imageNameOrID) == "" {
|
||||
return nil, errors.New("image name or ID is required")
|
||||
}
|
||||
|
||||
resp, err := cli.Docker.GRPCClient.RemoveImage(ProxyMachinesContext(ctx, opts.Machines),
|
||||
&pb.RemoveImageRequest{Id: imageNameOrID, PruneChildren: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
var results []api.MachineImageRemoval
|
||||
var errs []error
|
||||
notFoundCount := 0
|
||||
for _, msg := range resp.Messages {
|
||||
result := api.MachineImageRemoval{Metadata: msg.Metadata}
|
||||
for _, removal := range msg.Results {
|
||||
result.Results = append(result.Results, image.DeleteResponse{
|
||||
Deleted: removal.Deleted,
|
||||
Untagged: removal.Untagged,
|
||||
})
|
||||
}
|
||||
results = append(results, result)
|
||||
|
||||
eventID := cliprogress.ImageEventID(imageNameOrID, msg.Metadata.MachineName)
|
||||
if err = result.Metadata.Err(); err != nil {
|
||||
event := progress.NewEvent(eventID, progress.Done, "Not found")
|
||||
if status.Code(err) == codes.NotFound {
|
||||
notFoundCount++
|
||||
} else {
|
||||
errs = append(errs, fmt.Errorf("machine '%s': %w", msg.Metadata.MachineName, err))
|
||||
event.Status = progress.Error
|
||||
event.StatusText = status.Convert(err).Message()
|
||||
}
|
||||
pw.Event(event)
|
||||
continue
|
||||
}
|
||||
|
||||
// An unnamed image can return Deleted without Untagged. Use the requested name or ID as its row fallback.
|
||||
deleted := false
|
||||
for _, removal := range result.Results {
|
||||
if removal.Untagged != "" {
|
||||
eventID = cliprogress.ImageEventID(removal.Untagged, msg.Metadata.MachineName)
|
||||
pw.Event(progress.NewEvent(eventID, progress.Done, "Untagged"))
|
||||
}
|
||||
if removal.Deleted != "" && !deleted {
|
||||
// Docker reports the deletion of the selected image before any pruned parents or released layers.
|
||||
// Only that first deletion updates the last untagged row marking the image as Removed.
|
||||
deleted = true
|
||||
pw.Event(progress.NewEvent(eventID, progress.Done,
|
||||
fmt.Sprintf("Removed (%s)", stringid.TruncateID(removal.Deleted))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err = errors.Join(errs...); err != nil {
|
||||
return results, err
|
||||
}
|
||||
if notFoundCount == len(results) {
|
||||
return results, api.ErrNotFound
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// TODO: move PushImageOptions to api package and include PushImage in ImageClient interface.
|
||||
type PushImageOptions struct {
|
||||
// AllMachines pushes the image to all machines in the cluster. Takes precedence over Machines field.
|
||||
AllMachines bool
|
||||
@@ -215,7 +294,7 @@ func (cli *Client) pushImageToMachine(
|
||||
Name: "%invalid-name-to-only-check-store-type%",
|
||||
})
|
||||
if err == nil {
|
||||
err = images[0].Error()
|
||||
err = images[0].Metadata.Err()
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("check Docker image store type on machine '%s': %w", machine.Name, err)
|
||||
|
||||
@@ -82,7 +82,9 @@ func (o *RunContainerOperation) String() string {
|
||||
|
||||
// StopContainerOperation stops a container on a specific machine.
|
||||
type StopContainerOperation struct {
|
||||
ServiceID string
|
||||
ServiceID string
|
||||
// ServiceName is used for formatting the operation as part of the deployment plan.
|
||||
ServiceName string
|
||||
ContainerID string
|
||||
MachineID string
|
||||
// MachineName is used for formatting the operation as part of the deployment plan.
|
||||
@@ -98,8 +100,7 @@ func (o *StopContainerOperation) Execute(ctx context.Context, cli Client) error
|
||||
}
|
||||
|
||||
func (o *StopContainerOperation) Format() string {
|
||||
// TODO: pass service name to format the display name consistently with other operations.
|
||||
displayName := stringid.TruncateID(o.ContainerID)
|
||||
displayName := o.ServiceName + tui.Faint.Render("/") + stringid.TruncateID(o.ContainerID)
|
||||
|
||||
return tui.BoldRed.Render("-") + " " +
|
||||
tui.Faint.Render("stop container") + " " +
|
||||
@@ -173,6 +174,16 @@ type ReplaceContainerOperation struct {
|
||||
func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) error {
|
||||
stopFirst := o.Order == api.UpdateOrderStopFirst
|
||||
|
||||
// Create the new container before stopping the old one. CreateContainer also pulls the image according to the
|
||||
// service pull policy, so the stop-first downtime only includes stopping the old container and starting and
|
||||
// monitoring the new one.
|
||||
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create new container: %w", err)
|
||||
}
|
||||
// Override event ID so StartContainer and WaitContainerHealthy update the same progress line as creation.
|
||||
newCtx := cliprogress.WithEventID(ctx, cliprogress.NewContainerEventID(ctx, resp.Name, o.MachineName))
|
||||
|
||||
wasRunning := false
|
||||
if stopFirst {
|
||||
// Inspect the old container to remember its running state before stopping.
|
||||
@@ -189,12 +200,6 @@ func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) err
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create new container: %w", err)
|
||||
}
|
||||
// Override event ID so StartContainer and WaitContainerHealthy update the same progress line as creation.
|
||||
newCtx := cliprogress.WithEventID(ctx, cliprogress.NewContainerEventID(ctx, resp.Name, o.MachineName))
|
||||
if err = cli.StartContainer(newCtx, o.ServiceID, resp.ID); err != nil {
|
||||
return fmt.Errorf("start new container: %w", err)
|
||||
}
|
||||
@@ -281,6 +286,39 @@ func (o *ReplaceContainerOperation) String() string {
|
||||
o.MachineID, o.ServiceID, o.OldContainer.ID, o.Order)
|
||||
}
|
||||
|
||||
// UpdateContainerOperation updates the requested properties of a running container in place without recreating it.
|
||||
type UpdateContainerOperation struct {
|
||||
ServiceID string
|
||||
MachineID string
|
||||
// MachineName is used for formatting the operation as part of the deployment plan.
|
||||
MachineName string
|
||||
Container api.ServiceContainer
|
||||
Options api.UpdateContainerOptions
|
||||
}
|
||||
|
||||
func (o *UpdateContainerOperation) Execute(ctx context.Context, cli Client) error {
|
||||
if err := cli.UpdateContainer(ctx, o.ServiceID, o.Container.ID, o.Options); err != nil {
|
||||
return fmt.Errorf("update container: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *UpdateContainerOperation) Format() string {
|
||||
displayName := o.Container.ServiceSpec.Name + tui.Faint.Render("/") + o.Container.ShortID()
|
||||
|
||||
return tui.BoldYellow.Render("~") + " " +
|
||||
tui.Faint.Render("update container") + " " +
|
||||
displayName + " " +
|
||||
tui.Faint.Render("on") + " " +
|
||||
o.MachineName + " " +
|
||||
tui.Faint.Render("(in place)")
|
||||
}
|
||||
|
||||
func (o *UpdateContainerOperation) String() string {
|
||||
return fmt.Sprintf("UpdateContainerOperation[machine_id=%s service_id=%s container_id=%s]",
|
||||
o.MachineID, o.ServiceID, o.Container.ID)
|
||||
}
|
||||
|
||||
// stopOptions converts a stop grace period duration to Docker container stop options.
|
||||
func stopOptions(gracePeriod *time.Duration) container.StopOptions {
|
||||
if gracePeriod == nil {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user