mirror of
https://github.com/psviderski/uncloud.git
synced 2026-11-10 20:54:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dac194752a | ||
|
|
df493d0b60 | ||
|
|
7f68e1f838 | ||
|
|
fab2a20242 | ||
|
|
bb8fb3a380 | ||
|
|
e50c7a0cf6 | ||
|
|
c16e452439 |
@@ -14,13 +14,12 @@ on:
|
||||
- "**.proto"
|
||||
- "go.*"
|
||||
- "Makefile"
|
||||
- "mise.toml"
|
||||
- "scripts/**"
|
||||
- "test/**"
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
MISE_VERSION: "2026.10.22"
|
||||
MISE_VERSION: "2026.3.7"
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -43,9 +42,9 @@ jobs:
|
||||
|
||||
- name: Generate docs
|
||||
run: |
|
||||
mise run docs:cli
|
||||
make cli-docs
|
||||
git diff --exit-code ||
|
||||
(echo "Documentation is out of date. Please run 'mise run docs:cli' and commit the changes." && exit 1)
|
||||
(echo "Documentation is out of date. Please run 'make cli-docs' and commit the changes." && exit 1)
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
@@ -71,7 +70,7 @@ jobs:
|
||||
version: ${{ env.MISE_VERSION }}
|
||||
|
||||
# TODO: figure out how to properly lock all platforms in the lockfile and remove this hack.
|
||||
# `mise lock` doesn't generate checksums for some of the tools, e.g. protoc*
|
||||
# `mise lock` (mise run 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.10.22"
|
||||
MISE_VERSION: "2026.3.7"
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -30,6 +30,6 @@ jobs:
|
||||
run: git checkout mise.lock
|
||||
|
||||
- name: Lint
|
||||
run: mise run lint
|
||||
run: make lint
|
||||
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -8,7 +8,7 @@ permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
MISE_VERSION: "2026.10.22"
|
||||
MISE_VERSION: "2026.3.7"
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
|
||||
@@ -98,6 +98,33 @@ 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
|
||||
@@ -108,18 +135,16 @@ go build -o uc ./cmd/uc
|
||||
go build -o uncloudd ./cmd/uncloudd
|
||||
```
|
||||
|
||||
### 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
|
||||
### Key Make Targets
|
||||
|
||||
- `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
|
||||
|
||||
|
||||
+4
-33
@@ -35,11 +35,10 @@ Build the CLI:
|
||||
go build -o uc ./cmd/uc
|
||||
```
|
||||
|
||||
Or build and run the CLI with mise. It forwards arguments, including help flags, directly to the CLI:
|
||||
Or build and run the CLI with a single command:
|
||||
|
||||
```shell
|
||||
mise uc --help
|
||||
mise uc image rm -h
|
||||
go run ./cmd/uc --help
|
||||
```
|
||||
|
||||
The Uncloud daemon (`uncloudd`) only supports Linux, so you need to cross-compile it if you're developing on macOS or
|
||||
@@ -100,16 +99,10 @@ mise run dev:reset user@host1 user@host2
|
||||
|
||||
## Linting and formatting
|
||||
|
||||
Check the code and formatting without changing files:
|
||||
Lint and format the code:
|
||||
|
||||
```shell
|
||||
mise run lint
|
||||
```
|
||||
|
||||
Apply formatting and available lint fixes:
|
||||
|
||||
```shell
|
||||
mise run lint:fix
|
||||
make lint-and-fix
|
||||
```
|
||||
|
||||
## Code generation
|
||||
@@ -126,28 +119,6 @@ Regenerate 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.
|
||||
|
||||
@@ -47,3 +47,25 @@ 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**: [Cluster storage](https://github.com/unlabs-dev/caddy-uncloud) for Caddy
|
||||
shares certificates and ACME challenge tokens across machines and coordinates issuance.
|
||||
* **Cluster storage for TLS certificates**: Native cluster storage for Caddy shares certificates and ACME challenge
|
||||
tokens across machines and coordinates issuance.
|
||||
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
||||
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
|
||||
|
||||
|
||||
+189
-380
@@ -2259,137 +2259,6 @@ func (x *ServiceContainer) GetServiceSpec() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
type UpdateServiceContainerRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
// Fields that are not set are left unchanged. CPU and memory limits can be changed but not removed.
|
||||
// CPU limit in nanocores.
|
||||
Cpu *int64 `protobuf:"varint,2,opt,name=cpu,proto3,oneof" json:"cpu,omitempty"`
|
||||
// Memory limit in bytes.
|
||||
Memory *int64 `protobuf:"varint,3,opt,name=memory,proto3,oneof" json:"memory,omitempty"`
|
||||
// Memory reservation in bytes.
|
||||
MemoryReservation *int64 `protobuf:"varint,4,opt,name=memory_reservation,json=memoryReservation,proto3,oneof" json:"memory_reservation,omitempty"`
|
||||
// Service labels that replace all current service labels.
|
||||
ServiceLabels *Labels `protobuf:"bytes,5,opt,name=service_labels,json=serviceLabels,proto3" json:"service_labels,omitempty"`
|
||||
}
|
||||
|
||||
func (x *UpdateServiceContainerRequest) Reset() {
|
||||
*x = UpdateServiceContainerRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_pb_docker_proto_msgTypes[38]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *UpdateServiceContainerRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*UpdateServiceContainerRequest) ProtoMessage() {}
|
||||
|
||||
func (x *UpdateServiceContainerRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_pb_docker_proto_msgTypes[38]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use UpdateServiceContainerRequest.ProtoReflect.Descriptor instead.
|
||||
func (*UpdateServiceContainerRequest) Descriptor() ([]byte, []int) {
|
||||
return file_api_pb_docker_proto_rawDescGZIP(), []int{38}
|
||||
}
|
||||
|
||||
func (x *UpdateServiceContainerRequest) GetId() string {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpdateServiceContainerRequest) GetCpu() int64 {
|
||||
if x != nil && x.Cpu != nil {
|
||||
return *x.Cpu
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *UpdateServiceContainerRequest) GetMemory() int64 {
|
||||
if x != nil && x.Memory != nil {
|
||||
return *x.Memory
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *UpdateServiceContainerRequest) GetMemoryReservation() int64 {
|
||||
if x != nil && x.MemoryReservation != nil {
|
||||
return *x.MemoryReservation
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *UpdateServiceContainerRequest) GetServiceLabels() *Labels {
|
||||
if x != nil {
|
||||
return x.ServiceLabels
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Labels struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Labels map[string]string `protobuf:"bytes,1,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
|
||||
}
|
||||
|
||||
func (x *Labels) Reset() {
|
||||
*x = Labels{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_pb_docker_proto_msgTypes[39]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *Labels) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Labels) ProtoMessage() {}
|
||||
|
||||
func (x *Labels) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_pb_docker_proto_msgTypes[39]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Labels.ProtoReflect.Descriptor instead.
|
||||
func (*Labels) Descriptor() ([]byte, []int) {
|
||||
return file_api_pb_docker_proto_rawDescGZIP(), []int{39}
|
||||
}
|
||||
|
||||
func (x *Labels) GetLabels() map[string]string {
|
||||
if x != nil {
|
||||
return x.Labels
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ListServiceContainersRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -2403,7 +2272,7 @@ type ListServiceContainersRequest struct {
|
||||
func (x *ListServiceContainersRequest) Reset() {
|
||||
*x = ListServiceContainersRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_pb_docker_proto_msgTypes[40]
|
||||
mi := &file_api_pb_docker_proto_msgTypes[38]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -2416,7 +2285,7 @@ func (x *ListServiceContainersRequest) String() string {
|
||||
func (*ListServiceContainersRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ListServiceContainersRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_pb_docker_proto_msgTypes[40]
|
||||
mi := &file_api_pb_docker_proto_msgTypes[38]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -2429,7 +2298,7 @@ func (x *ListServiceContainersRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ListServiceContainersRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ListServiceContainersRequest) Descriptor() ([]byte, []int) {
|
||||
return file_api_pb_docker_proto_rawDescGZIP(), []int{40}
|
||||
return file_api_pb_docker_proto_rawDescGZIP(), []int{38}
|
||||
}
|
||||
|
||||
func (x *ListServiceContainersRequest) GetServiceId() string {
|
||||
@@ -2459,7 +2328,7 @@ type ListServiceContainersResponse struct {
|
||||
func (x *ListServiceContainersResponse) Reset() {
|
||||
*x = ListServiceContainersResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_pb_docker_proto_msgTypes[41]
|
||||
mi := &file_api_pb_docker_proto_msgTypes[39]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -2472,7 +2341,7 @@ func (x *ListServiceContainersResponse) String() string {
|
||||
func (*ListServiceContainersResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ListServiceContainersResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_pb_docker_proto_msgTypes[41]
|
||||
mi := &file_api_pb_docker_proto_msgTypes[39]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -2485,7 +2354,7 @@ func (x *ListServiceContainersResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ListServiceContainersResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ListServiceContainersResponse) Descriptor() ([]byte, []int) {
|
||||
return file_api_pb_docker_proto_rawDescGZIP(), []int{41}
|
||||
return file_api_pb_docker_proto_rawDescGZIP(), []int{39}
|
||||
}
|
||||
|
||||
func (x *ListServiceContainersResponse) GetMessages() []*MachineServiceContainers {
|
||||
@@ -2509,7 +2378,7 @@ type MachineServiceContainers struct {
|
||||
func (x *MachineServiceContainers) Reset() {
|
||||
*x = MachineServiceContainers{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_pb_docker_proto_msgTypes[42]
|
||||
mi := &file_api_pb_docker_proto_msgTypes[40]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -2522,7 +2391,7 @@ func (x *MachineServiceContainers) String() string {
|
||||
func (*MachineServiceContainers) ProtoMessage() {}
|
||||
|
||||
func (x *MachineServiceContainers) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_pb_docker_proto_msgTypes[42]
|
||||
mi := &file_api_pb_docker_proto_msgTypes[40]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -2535,7 +2404,7 @@ func (x *MachineServiceContainers) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use MachineServiceContainers.ProtoReflect.Descriptor instead.
|
||||
func (*MachineServiceContainers) Descriptor() ([]byte, []int) {
|
||||
return file_api_pb_docker_proto_rawDescGZIP(), []int{42}
|
||||
return file_api_pb_docker_proto_rawDescGZIP(), []int{40}
|
||||
}
|
||||
|
||||
func (x *MachineServiceContainers) GetMetadata() *Metadata {
|
||||
@@ -2769,153 +2638,125 @@ var file_api_pb_docker_proto_rawDesc = []byte{
|
||||
0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69,
|
||||
0x6e, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x73,
|
||||
0x70, 0x65, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69,
|
||||
0x63, 0x65, 0x53, 0x70, 0x65, 0x63, 0x22, 0xf5, 0x01, 0x0a, 0x1d, 0x55, 0x70, 0x64, 0x61, 0x74,
|
||||
0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65,
|
||||
0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x15, 0x0a, 0x03, 0x63, 0x70, 0x75, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x03, 0x48, 0x00, 0x52, 0x03, 0x63, 0x70, 0x75, 0x88, 0x01, 0x01, 0x12,
|
||||
0x1b, 0x0a, 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x48,
|
||||
0x01, 0x52, 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x88, 0x01, 0x01, 0x12, 0x32, 0x0a, 0x12,
|
||||
0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x5f, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x48, 0x02, 0x52, 0x11, 0x6d, 0x65, 0x6d, 0x6f,
|
||||
0x72, 0x79, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01,
|
||||
0x12, 0x32, 0x0a, 0x0e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6c, 0x61, 0x62, 0x65,
|
||||
0x6c, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c,
|
||||
0x61, 0x62, 0x65, 0x6c, 0x73, 0x52, 0x0d, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4c, 0x61,
|
||||
0x62, 0x65, 0x6c, 0x73, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x63, 0x70, 0x75, 0x42, 0x09, 0x0a, 0x07,
|
||||
0x5f, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x6d, 0x65, 0x6d, 0x6f,
|
||||
0x72, 0x79, 0x5f, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x74,
|
||||
0x0a, 0x06, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x2f, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65,
|
||||
0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c,
|
||||
0x61, 0x62, 0x65, 0x6c, 0x73, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72,
|
||||
0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62,
|
||||
0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61,
|
||||
0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
|
||||
0x3a, 0x02, 0x38, 0x01, 0x22, 0x57, 0x0a, 0x1c, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76,
|
||||
0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63,
|
||||
0x65, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x5a, 0x0a,
|
||||
0x1d, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74,
|
||||
0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x39,
|
||||
0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b,
|
||||
0x32, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x53, 0x65,
|
||||
0x63, 0x65, 0x53, 0x70, 0x65, 0x63, 0x22, 0x57, 0x0a, 0x1c, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65,
|
||||
0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52,
|
||||
0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0xbc, 0x01, 0x0a, 0x18, 0x4d, 0x61,
|
||||
0x63, 0x68, 0x69, 0x6e, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74,
|
||||
0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x29, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61,
|
||||
0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d,
|
||||
0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
|
||||
0x61, 0x12, 0x35, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18,
|
||||
0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76,
|
||||
0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0a, 0x63, 0x6f,
|
||||
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x3e, 0x0a, 0x0f, 0x68, 0x6f, 0x6f, 0x6b,
|
||||
0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,
|
||||
0x0b, 0x32, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43,
|
||||
0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0e, 0x68, 0x6f, 0x6f, 0x6b, 0x43, 0x6f,
|
||||
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x32, 0x93, 0x0c, 0x0a, 0x06, 0x44, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x72, 0x12, 0x4c, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e,
|
||||
0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65,
|
||||
0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
|
||||
0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x4f, 0x0a, 0x10, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74,
|
||||
0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70,
|
||||
0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63,
|
||||
0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x44, 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61,
|
||||
0x69, 0x6e, 0x65, 0x72, 0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74,
|
||||
0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
|
||||
0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x42, 0x0a, 0x0d, 0x53, 0x74, 0x6f, 0x70,
|
||||
0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
||||
0x53, 0x74, 0x6f, 0x70, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
|
||||
0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x49, 0x0a, 0x0e,
|
||||
0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x1a,
|
||||
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e,
|
||||
0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69,
|
||||
0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x0f, 0x52, 0x65, 0x6d, 0x6f, 0x76,
|
||||
0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69,
|
||||
0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12,
|
||||
0x4a, 0x0a, 0x0d, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72,
|
||||
0x12, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61,
|
||||
0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x32, 0x0a, 0x0d, 0x43,
|
||||
0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x10, 0x2e, 0x61,
|
||||
0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0d,
|
||||
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x30, 0x01, 0x12,
|
||||
0x36, 0x0a, 0x09, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x15, 0x2e, 0x61,
|
||||
0x70, 0x69, 0x2e, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4a, 0x53, 0x4f, 0x4e, 0x4d, 0x65,
|
||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x30, 0x01, 0x12, 0x43, 0x0a, 0x0c, 0x49, 0x6e, 0x73, 0x70, 0x65,
|
||||
0x63, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e,
|
||||
0x73, 0x70, 0x65, 0x63, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x49,
|
||||
0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x55, 0x0a, 0x12,
|
||||
0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61,
|
||||
0x67, 0x65, 0x12, 0x1e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74,
|
||||
0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74,
|
||||
0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x0a, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65,
|
||||
0x73, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6d, 0x61, 0x67,
|
||||
0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
||||
0x4c, 0x69, 0x73, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x6d, 0x61, 0x67,
|
||||
0x65, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x6d,
|
||||
0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69,
|
||||
0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x56, 0x6f,
|
||||
0x6c, 0x75, 0x6d, 0x65, 0x12, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74,
|
||||
0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19,
|
||||
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d,
|
||||
0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x4c, 0x69, 0x73,
|
||||
0x74, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c,
|
||||
0x69, 0x73, 0x74, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x6f, 0x6c, 0x75,
|
||||
0x6d, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0c, 0x52,
|
||||
0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x18, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x5a, 0x0a,
|
||||
0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f,
|
||||
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72,
|
||||
0x65, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61,
|
||||
0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65,
|
||||
0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x17, 0x49, 0x6e, 0x73,
|
||||
0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61,
|
||||
0x69, 0x6e, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65,
|
||||
0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
|
||||
0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x54, 0x0a, 0x16, 0x55, 0x70, 0x64,
|
||||
0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69,
|
||||
0x6e, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63,
|
||||
0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76,
|
||||
0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22,
|
||||
0x5a, 0x0a, 0x1d, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f,
|
||||
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x39, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03,
|
||||
0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65,
|
||||
0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72,
|
||||
0x73, 0x52, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0xbc, 0x01, 0x0a, 0x18,
|
||||
0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f,
|
||||
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x29, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61,
|
||||
0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69,
|
||||
0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64,
|
||||
0x61, 0x74, 0x61, 0x12, 0x35, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72,
|
||||
0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65,
|
||||
0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0a,
|
||||
0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x3e, 0x0a, 0x0f, 0x68, 0x6f,
|
||||
0x6f, 0x6b, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20,
|
||||
0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
|
||||
0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0e, 0x68, 0x6f, 0x6f, 0x6b,
|
||||
0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x32, 0xbd, 0x0b, 0x0a, 0x06, 0x44,
|
||||
0x6f, 0x63, 0x6b, 0x65, 0x72, 0x12, 0x4c, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43,
|
||||
0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43,
|
||||
0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61,
|
||||
0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x10, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f,
|
||||
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e,
|
||||
0x73, 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70,
|
||||
0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x44, 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, 0x6f, 0x6e,
|
||||
0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61,
|
||||
0x72, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x42, 0x0a, 0x0d, 0x53, 0x74,
|
||||
0x6f, 0x70, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x19, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x49,
|
||||
0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73,
|
||||
0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61,
|
||||
0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61,
|
||||
0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72,
|
||||
0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x0f, 0x52, 0x65, 0x6d,
|
||||
0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61,
|
||||
0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e,
|
||||
0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
|
||||
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74,
|
||||
0x79, 0x12, 0x4a, 0x0a, 0x0d, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e,
|
||||
0x65, 0x72, 0x12, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e,
|
||||
0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e,
|
||||
0x61, 0x70, 0x69, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65,
|
||||
0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x32, 0x0a,
|
||||
0x0d, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x10,
|
||||
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x1a, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x30,
|
||||
0x01, 0x12, 0x36, 0x0a, 0x09, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x15,
|
||||
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4a, 0x53, 0x4f, 0x4e,
|
||||
0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x30, 0x01, 0x12, 0x43, 0x0a, 0x0c, 0x49, 0x6e, 0x73,
|
||||
0x70, 0x65, 0x63, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
||||
0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63,
|
||||
0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x55,
|
||||
0x0a, 0x12, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49,
|
||||
0x6d, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65,
|
||||
0x63, 0x74, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65,
|
||||
0x63, 0x74, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x0a, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6d, 0x61,
|
||||
0x67, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6d,
|
||||
0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x6d,
|
||||
0x61, 0x67, 0x65, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65,
|
||||
0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61,
|
||||
0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
|
||||
0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65,
|
||||
0x61, 0x74, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x56, 0x6f, 0x6c,
|
||||
0x75, 0x6d, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x4c,
|
||||
0x69, 0x73, 0x74, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69,
|
||||
0x2e, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x6f,
|
||||
0x6c, 0x75, 0x6d, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a,
|
||||
0x0c, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x18, 0x2e,
|
||||
0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12,
|
||||
0x5e, 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f,
|
||||
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x21, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c,
|
||||
0x5a, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
|
||||
0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
||||
0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e,
|
||||
0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e,
|
||||
0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69,
|
||||
0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x17, 0x49,
|
||||
0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e,
|
||||
0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73,
|
||||
0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69,
|
||||
0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x5e, 0x0a, 0x15, 0x4c,
|
||||
0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69,
|
||||
0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e,
|
||||
0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
||||
0x4d, 0x0a, 0x16, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
|
||||
0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
||||
0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x42, 0x26,
|
||||
0x5a, 0x24, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76,
|
||||
0x69, 0x64, 0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f,
|
||||
0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x6e, 0x65, 0x72, 0x73, 0x12, 0x21, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53,
|
||||
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69,
|
||||
0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e,
|
||||
0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x16, 0x52,
|
||||
0x65, 0x6d, 0x6f, 0x76, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74,
|
||||
0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f,
|
||||
0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x42, 0x26, 0x5a, 0x24, 0x67, 0x69,
|
||||
0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72,
|
||||
0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x61, 0x70, 0x69, 0x2f,
|
||||
0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -2931,7 +2772,7 @@ func file_api_pb_docker_proto_rawDescGZIP() []byte {
|
||||
}
|
||||
|
||||
var file_api_pb_docker_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_api_pb_docker_proto_msgTypes = make([]protoimpl.MessageInfo, 44)
|
||||
var file_api_pb_docker_proto_msgTypes = make([]protoimpl.MessageInfo, 41)
|
||||
var file_api_pb_docker_proto_goTypes = []any{
|
||||
(CreateServiceContainerRequest_ContainerType)(0), // 0: api.CreateServiceContainerRequest.ContainerType
|
||||
(*CreateContainerRequest)(nil), // 1: api.CreateContainerRequest
|
||||
@@ -2972,88 +2813,81 @@ var file_api_pb_docker_proto_goTypes = []any{
|
||||
(*RemoveVolumeRequest)(nil), // 36: api.RemoveVolumeRequest
|
||||
(*CreateServiceContainerRequest)(nil), // 37: api.CreateServiceContainerRequest
|
||||
(*ServiceContainer)(nil), // 38: api.ServiceContainer
|
||||
(*UpdateServiceContainerRequest)(nil), // 39: api.UpdateServiceContainerRequest
|
||||
(*Labels)(nil), // 40: api.Labels
|
||||
(*ListServiceContainersRequest)(nil), // 41: api.ListServiceContainersRequest
|
||||
(*ListServiceContainersResponse)(nil), // 42: api.ListServiceContainersResponse
|
||||
(*MachineServiceContainers)(nil), // 43: api.MachineServiceContainers
|
||||
nil, // 44: api.Labels.LabelsEntry
|
||||
(*Metadata)(nil), // 45: api.Metadata
|
||||
(*LogsRequest)(nil), // 46: api.LogsRequest
|
||||
(*emptypb.Empty)(nil), // 47: google.protobuf.Empty
|
||||
(*LogEntry)(nil), // 48: api.LogEntry
|
||||
(*ListServiceContainersRequest)(nil), // 39: api.ListServiceContainersRequest
|
||||
(*ListServiceContainersResponse)(nil), // 40: api.ListServiceContainersResponse
|
||||
(*MachineServiceContainers)(nil), // 41: api.MachineServiceContainers
|
||||
(*Metadata)(nil), // 42: api.Metadata
|
||||
(*LogsRequest)(nil), // 43: api.LogsRequest
|
||||
(*emptypb.Empty)(nil), // 44: google.protobuf.Empty
|
||||
(*LogEntry)(nil), // 45: api.LogEntry
|
||||
}
|
||||
var file_api_pb_docker_proto_depIdxs = []int32{
|
||||
9, // 0: api.ListContainersResponse.messages:type_name -> api.MachineContainers
|
||||
45, // 1: api.MachineContainers.metadata:type_name -> api.Metadata
|
||||
42, // 1: api.MachineContainers.metadata:type_name -> api.Metadata
|
||||
12, // 2: api.ExecContainerRequest.config:type_name -> api.ExecConfig
|
||||
13, // 3: api.ExecContainerRequest.resize:type_name -> api.ResizeEvent
|
||||
19, // 4: api.InspectImageResponse.messages:type_name -> api.Image
|
||||
45, // 5: api.Image.metadata:type_name -> api.Metadata
|
||||
42, // 5: api.Image.metadata:type_name -> api.Metadata
|
||||
22, // 6: api.InspectRemoteImageResponse.messages:type_name -> api.RemoteImage
|
||||
45, // 7: api.RemoteImage.metadata:type_name -> api.Metadata
|
||||
42, // 7: api.RemoteImage.metadata:type_name -> api.Metadata
|
||||
25, // 8: api.RemoveImageRequest.platforms:type_name -> api.ImagePlatform
|
||||
27, // 9: api.RemoveImageResponse.messages:type_name -> api.MachineImageRemoval
|
||||
45, // 10: api.MachineImageRemoval.metadata:type_name -> api.Metadata
|
||||
42, // 10: api.MachineImageRemoval.metadata:type_name -> api.Metadata
|
||||
28, // 11: api.MachineImageRemoval.results:type_name -> api.ImageRemoval
|
||||
30, // 12: api.ListImagesResponse.messages:type_name -> api.MachineImages
|
||||
45, // 13: api.MachineImages.metadata:type_name -> api.Metadata
|
||||
42, // 13: api.MachineImages.metadata:type_name -> api.Metadata
|
||||
35, // 14: api.ListVolumesResponse.messages:type_name -> api.MachineVolumes
|
||||
45, // 15: api.MachineVolumes.metadata:type_name -> api.Metadata
|
||||
42, // 15: api.MachineVolumes.metadata:type_name -> api.Metadata
|
||||
0, // 16: api.CreateServiceContainerRequest.container_type:type_name -> api.CreateServiceContainerRequest.ContainerType
|
||||
40, // 17: api.UpdateServiceContainerRequest.service_labels:type_name -> api.Labels
|
||||
44, // 18: api.Labels.labels:type_name -> api.Labels.LabelsEntry
|
||||
43, // 19: api.ListServiceContainersResponse.messages:type_name -> api.MachineServiceContainers
|
||||
45, // 20: api.MachineServiceContainers.metadata:type_name -> api.Metadata
|
||||
38, // 21: api.MachineServiceContainers.containers:type_name -> api.ServiceContainer
|
||||
38, // 22: api.MachineServiceContainers.hook_containers:type_name -> api.ServiceContainer
|
||||
1, // 23: api.Docker.CreateContainer:input_type -> api.CreateContainerRequest
|
||||
3, // 24: api.Docker.InspectContainer:input_type -> api.InspectContainerRequest
|
||||
5, // 25: api.Docker.StartContainer:input_type -> api.StartContainerRequest
|
||||
6, // 26: api.Docker.StopContainer:input_type -> api.StopContainerRequest
|
||||
7, // 27: api.Docker.ListContainers:input_type -> api.ListContainersRequest
|
||||
10, // 28: api.Docker.RemoveContainer:input_type -> api.RemoveContainerRequest
|
||||
11, // 29: api.Docker.ExecContainer:input_type -> api.ExecContainerRequest
|
||||
46, // 30: api.Docker.ContainerLogs:input_type -> api.LogsRequest
|
||||
15, // 31: api.Docker.PullImage:input_type -> api.PullImageRequest
|
||||
17, // 32: api.Docker.InspectImage:input_type -> api.InspectImageRequest
|
||||
20, // 33: api.Docker.InspectRemoteImage:input_type -> api.InspectRemoteImageRequest
|
||||
23, // 34: api.Docker.ListImages:input_type -> api.ListImagesRequest
|
||||
24, // 35: api.Docker.RemoveImage:input_type -> api.RemoveImageRequest
|
||||
31, // 36: api.Docker.CreateVolume:input_type -> api.CreateVolumeRequest
|
||||
33, // 37: api.Docker.ListVolumes:input_type -> api.ListVolumesRequest
|
||||
36, // 38: api.Docker.RemoveVolume:input_type -> api.RemoveVolumeRequest
|
||||
37, // 39: api.Docker.CreateServiceContainer:input_type -> api.CreateServiceContainerRequest
|
||||
3, // 40: api.Docker.InspectServiceContainer:input_type -> api.InspectContainerRequest
|
||||
39, // 41: api.Docker.UpdateServiceContainer:input_type -> api.UpdateServiceContainerRequest
|
||||
41, // 42: api.Docker.ListServiceContainers:input_type -> api.ListServiceContainersRequest
|
||||
10, // 43: api.Docker.RemoveServiceContainer:input_type -> api.RemoveContainerRequest
|
||||
2, // 44: api.Docker.CreateContainer:output_type -> api.CreateContainerResponse
|
||||
4, // 45: api.Docker.InspectContainer:output_type -> api.InspectContainerResponse
|
||||
47, // 46: api.Docker.StartContainer:output_type -> google.protobuf.Empty
|
||||
47, // 47: api.Docker.StopContainer:output_type -> google.protobuf.Empty
|
||||
8, // 48: api.Docker.ListContainers:output_type -> api.ListContainersResponse
|
||||
47, // 49: api.Docker.RemoveContainer:output_type -> google.protobuf.Empty
|
||||
14, // 50: api.Docker.ExecContainer:output_type -> api.ExecContainerResponse
|
||||
48, // 51: api.Docker.ContainerLogs:output_type -> api.LogEntry
|
||||
16, // 52: api.Docker.PullImage:output_type -> api.JSONMessage
|
||||
18, // 53: api.Docker.InspectImage:output_type -> api.InspectImageResponse
|
||||
21, // 54: api.Docker.InspectRemoteImage:output_type -> api.InspectRemoteImageResponse
|
||||
29, // 55: api.Docker.ListImages:output_type -> api.ListImagesResponse
|
||||
26, // 56: api.Docker.RemoveImage:output_type -> api.RemoveImageResponse
|
||||
32, // 57: api.Docker.CreateVolume:output_type -> api.CreateVolumeResponse
|
||||
34, // 58: api.Docker.ListVolumes:output_type -> api.ListVolumesResponse
|
||||
47, // 59: api.Docker.RemoveVolume:output_type -> google.protobuf.Empty
|
||||
2, // 60: api.Docker.CreateServiceContainer:output_type -> api.CreateContainerResponse
|
||||
38, // 61: api.Docker.InspectServiceContainer:output_type -> api.ServiceContainer
|
||||
47, // 62: api.Docker.UpdateServiceContainer:output_type -> google.protobuf.Empty
|
||||
42, // 63: api.Docker.ListServiceContainers:output_type -> api.ListServiceContainersResponse
|
||||
47, // 64: api.Docker.RemoveServiceContainer:output_type -> google.protobuf.Empty
|
||||
44, // [44:65] is the sub-list for method output_type
|
||||
23, // [23:44] is the sub-list for method input_type
|
||||
23, // [23:23] is the sub-list for extension type_name
|
||||
23, // [23:23] is the sub-list for extension extendee
|
||||
0, // [0:23] is the sub-list for field type_name
|
||||
41, // 17: api.ListServiceContainersResponse.messages:type_name -> api.MachineServiceContainers
|
||||
42, // 18: api.MachineServiceContainers.metadata:type_name -> api.Metadata
|
||||
38, // 19: api.MachineServiceContainers.containers:type_name -> api.ServiceContainer
|
||||
38, // 20: api.MachineServiceContainers.hook_containers:type_name -> api.ServiceContainer
|
||||
1, // 21: api.Docker.CreateContainer:input_type -> api.CreateContainerRequest
|
||||
3, // 22: api.Docker.InspectContainer:input_type -> api.InspectContainerRequest
|
||||
5, // 23: api.Docker.StartContainer:input_type -> api.StartContainerRequest
|
||||
6, // 24: api.Docker.StopContainer:input_type -> api.StopContainerRequest
|
||||
7, // 25: api.Docker.ListContainers:input_type -> api.ListContainersRequest
|
||||
10, // 26: api.Docker.RemoveContainer:input_type -> api.RemoveContainerRequest
|
||||
11, // 27: api.Docker.ExecContainer:input_type -> api.ExecContainerRequest
|
||||
43, // 28: api.Docker.ContainerLogs:input_type -> api.LogsRequest
|
||||
15, // 29: api.Docker.PullImage:input_type -> api.PullImageRequest
|
||||
17, // 30: api.Docker.InspectImage:input_type -> api.InspectImageRequest
|
||||
20, // 31: api.Docker.InspectRemoteImage:input_type -> api.InspectRemoteImageRequest
|
||||
23, // 32: api.Docker.ListImages:input_type -> api.ListImagesRequest
|
||||
24, // 33: api.Docker.RemoveImage:input_type -> api.RemoveImageRequest
|
||||
31, // 34: api.Docker.CreateVolume:input_type -> api.CreateVolumeRequest
|
||||
33, // 35: api.Docker.ListVolumes:input_type -> api.ListVolumesRequest
|
||||
36, // 36: api.Docker.RemoveVolume:input_type -> api.RemoveVolumeRequest
|
||||
37, // 37: api.Docker.CreateServiceContainer:input_type -> api.CreateServiceContainerRequest
|
||||
3, // 38: api.Docker.InspectServiceContainer:input_type -> api.InspectContainerRequest
|
||||
39, // 39: api.Docker.ListServiceContainers:input_type -> api.ListServiceContainersRequest
|
||||
10, // 40: api.Docker.RemoveServiceContainer:input_type -> api.RemoveContainerRequest
|
||||
2, // 41: api.Docker.CreateContainer:output_type -> api.CreateContainerResponse
|
||||
4, // 42: api.Docker.InspectContainer:output_type -> api.InspectContainerResponse
|
||||
44, // 43: api.Docker.StartContainer:output_type -> google.protobuf.Empty
|
||||
44, // 44: api.Docker.StopContainer:output_type -> google.protobuf.Empty
|
||||
8, // 45: api.Docker.ListContainers:output_type -> api.ListContainersResponse
|
||||
44, // 46: api.Docker.RemoveContainer:output_type -> google.protobuf.Empty
|
||||
14, // 47: api.Docker.ExecContainer:output_type -> api.ExecContainerResponse
|
||||
45, // 48: api.Docker.ContainerLogs:output_type -> api.LogEntry
|
||||
16, // 49: api.Docker.PullImage:output_type -> api.JSONMessage
|
||||
18, // 50: api.Docker.InspectImage:output_type -> api.InspectImageResponse
|
||||
21, // 51: api.Docker.InspectRemoteImage:output_type -> api.InspectRemoteImageResponse
|
||||
29, // 52: api.Docker.ListImages:output_type -> api.ListImagesResponse
|
||||
26, // 53: api.Docker.RemoveImage:output_type -> api.RemoveImageResponse
|
||||
32, // 54: api.Docker.CreateVolume:output_type -> api.CreateVolumeResponse
|
||||
34, // 55: api.Docker.ListVolumes:output_type -> api.ListVolumesResponse
|
||||
44, // 56: api.Docker.RemoveVolume:output_type -> google.protobuf.Empty
|
||||
2, // 57: api.Docker.CreateServiceContainer:output_type -> api.CreateContainerResponse
|
||||
38, // 58: api.Docker.InspectServiceContainer:output_type -> api.ServiceContainer
|
||||
40, // 59: api.Docker.ListServiceContainers:output_type -> api.ListServiceContainersResponse
|
||||
44, // 60: api.Docker.RemoveServiceContainer:output_type -> google.protobuf.Empty
|
||||
41, // [41:61] is the sub-list for method output_type
|
||||
21, // [21:41] is the sub-list for method input_type
|
||||
21, // [21:21] is the sub-list for extension type_name
|
||||
21, // [21:21] is the sub-list for extension extendee
|
||||
0, // [0:21] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_api_pb_docker_proto_init() }
|
||||
@@ -3520,30 +3354,6 @@ func file_api_pb_docker_proto_init() {
|
||||
}
|
||||
}
|
||||
file_api_pb_docker_proto_msgTypes[38].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*UpdateServiceContainerRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_pb_docker_proto_msgTypes[39].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*Labels); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_pb_docker_proto_msgTypes[40].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*ListServiceContainersRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
@@ -3555,7 +3365,7 @@ func file_api_pb_docker_proto_init() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_pb_docker_proto_msgTypes[41].Exporter = func(v any, i int) any {
|
||||
file_api_pb_docker_proto_msgTypes[39].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*ListServiceContainersResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
@@ -3567,7 +3377,7 @@ func file_api_pb_docker_proto_init() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_pb_docker_proto_msgTypes[42].Exporter = func(v any, i int) any {
|
||||
file_api_pb_docker_proto_msgTypes[40].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*MachineServiceContainers); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
@@ -3591,14 +3401,13 @@ func file_api_pb_docker_proto_init() {
|
||||
(*ExecContainerResponse_Stderr)(nil),
|
||||
(*ExecContainerResponse_ExitCode)(nil),
|
||||
}
|
||||
file_api_pb_docker_proto_msgTypes[38].OneofWrappers = []any{}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_api_pb_docker_proto_rawDesc,
|
||||
NumEnums: 1,
|
||||
NumMessages: 44,
|
||||
NumMessages: 41,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -32,8 +32,6 @@ 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);
|
||||
}
|
||||
@@ -283,23 +281,6 @@ 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.
|
||||
|
||||
@@ -38,7 +38,6 @@ const (
|
||||
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"
|
||||
)
|
||||
@@ -67,8 +66,6 @@ type DockerClient interface {
|
||||
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)
|
||||
}
|
||||
@@ -282,16 +279,6 @@ 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)
|
||||
@@ -336,8 +323,6 @@ type DockerServer interface {
|
||||
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()
|
||||
@@ -404,9 +389,6 @@ 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")
|
||||
}
|
||||
@@ -733,24 +715,6 @@ 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 {
|
||||
@@ -854,10 +818,6 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "InspectServiceContainer",
|
||||
Handler: _Docker_InspectServiceContainer_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateServiceContainer",
|
||||
Handler: _Docker_UpdateServiceContainer_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListServiceContainers",
|
||||
Handler: _Docker_ListServiceContainers_Handler,
|
||||
|
||||
@@ -17,7 +17,6 @@ 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"
|
||||
@@ -36,13 +35,10 @@ func NewListCommand() *cobra.Command {
|
||||
Short: "List certificates in cluster storage for Caddy.",
|
||||
Long: `List certificates stored in the Uncloud cluster storage for Caddy.
|
||||
|
||||
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.
|
||||
Caddy must use the Uncloud storage module (https://github.com/unlabs-dev/caddy-uncloud)
|
||||
configured with 'storage uncloud' in the global options for its certificates to appear here.
|
||||
|
||||
This inventory does not check whether Caddy currently serves or trusts a certificate.
|
||||
|
||||
See ` + version.DocsURL + `/concepts/ingress/cluster-storage-for-caddy for details.`,
|
||||
This inventory does not check whether Caddy currently serves or trusts a certificate.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
+20
-192
@@ -16,7 +16,6 @@ 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"
|
||||
@@ -26,10 +25,6 @@ type deployOptions struct {
|
||||
caddyfile string
|
||||
image string
|
||||
machines []string
|
||||
|
||||
caddyfileChanged bool
|
||||
imageChanged bool
|
||||
machinesChanged bool
|
||||
}
|
||||
|
||||
func NewDeployCommand() *cobra.Command {
|
||||
@@ -38,62 +33,21 @@ 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.
|
||||
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 ""`,
|
||||
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.",
|
||||
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.\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)")
|
||||
"Path to a custom global Caddy config (Caddyfile) that will be prepended to the auto-generated Caddy config.")
|
||||
cmd.Flags().StringVar(&opts.image, "image", "",
|
||||
"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)")
|
||||
"Caddy Docker image to deploy. (default caddy: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.\n"+
|
||||
"An empty value resets it to all machines.\n"+
|
||||
"(default is the current machines or all machines)")
|
||||
"Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated "+
|
||||
"list. (default is all machines)")
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
@@ -101,13 +55,13 @@ Requires Uncloud daemon ` + client.MinCaddyDaemonVersion + ` or newer on all mac
|
||||
}
|
||||
|
||||
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
config := ""
|
||||
caddyfile := ""
|
||||
if opts.caddyfile != "" {
|
||||
data, err := os.ReadFile(opts.caddyfile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Caddyfile: %w", err)
|
||||
}
|
||||
config = strings.TrimSpace(string(data))
|
||||
caddyfile = strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
@@ -116,10 +70,6 @@ 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) {
|
||||
@@ -150,48 +100,26 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
current := svc.Spec()
|
||||
deployOpts := resolveDeploymentOptions(current, opts, config)
|
||||
configChanged := deployOpts.ConfigOrDefault() != current.CaddyConfig()
|
||||
|
||||
if svc.ID != "" && configChanged {
|
||||
printCaddyConfig("current global Caddy config:", current.CaddyConfig())
|
||||
if opts.image != "" {
|
||||
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(opts.image, tui.Green))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Preparing a deployment plan...")
|
||||
|
||||
d, err := clusterClient.Caddy.NewDeployment(ctx, deployOpts)
|
||||
placement := api.Placement{
|
||||
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||
}
|
||||
d, err := clusterClient.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{
|
||||
Image: opts.image, Config: caddyfile, Placement: placement,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create caddy deployment: %w", err)
|
||||
}
|
||||
target := d.Spec
|
||||
|
||||
// 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())
|
||||
}
|
||||
if opts.image == "" {
|
||||
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(d.Spec.Container.Image,
|
||||
tui.Green) + tui.Faint.Render(" (latest stable)"))
|
||||
}
|
||||
|
||||
plan, err := d.Plan(ctx)
|
||||
@@ -203,7 +131,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(deployOpts.Placement.Machines) > 0 {
|
||||
if len(opts.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.")
|
||||
@@ -235,14 +163,6 @@ 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.
|
||||
@@ -275,98 +195,6 @@ 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 {
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -22,6 +22,15 @@ func NewShowCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func show(uncli *cli.CLI) error {
|
||||
fmt.Println(uncli.ContextOverrideOrCurrent())
|
||||
// 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)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+3
-8
@@ -91,14 +91,9 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||
|
||||
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||
|
||||
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
|
||||
clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{
|
||||
Machines: machines,
|
||||
Name: opts.nameFilter,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list images: %w", err)
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
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...)
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
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:"))
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
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,7 +13,6 @@ func NewRootCommand() *cobra.Command {
|
||||
cmd.AddCommand(
|
||||
NewListCommand(),
|
||||
NewPushCommand(),
|
||||
NewRemoveCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
||||
+22
-4
@@ -187,12 +187,14 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
|
||||
}
|
||||
fmt.Println("Machine joined the cluster.")
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
// 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) {
|
||||
@@ -201,10 +203,26 @@ 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 := clusterClient.NewDeployment(caddySvc.Spec(), nil)
|
||||
d, err := clusterClient.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{Image: caddyImage})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create caddy deployment: %w", err)
|
||||
}
|
||||
|
||||
plan, err := d.Plan(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
@@ -59,16 +58,6 @@ 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.
|
||||
@@ -141,10 +130,3 @@ 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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ func NewRootCommand() *cobra.Command {
|
||||
NewScaleCommand(""),
|
||||
NewStartCommand(""),
|
||||
NewStopCommand(""),
|
||||
NewUpdateCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
+7
-53
@@ -21,16 +21,13 @@ 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
|
||||
@@ -67,9 +64,6 @@ 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.")
|
||||
@@ -78,9 +72,6 @@ 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).",
|
||||
@@ -92,9 +83,6 @@ 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"+
|
||||
@@ -106,12 +94,11 @@ 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. 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"+
|
||||
"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"+
|
||||
"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 Publish port 8080 as HTTPS via reverse proxy with custom hostname\n"+
|
||||
" -p app.example.com:8080/https 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"+
|
||||
@@ -205,18 +192,6 @@ 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:
|
||||
@@ -257,20 +232,17 @@ 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(),
|
||||
MemoryReservation: opts.memoryReservation.Value(),
|
||||
SharedMemory: opts.shmSize.Value(),
|
||||
Ulimits: ulimits,
|
||||
CPU: opts.cpu.Value(),
|
||||
Memory: opts.memory.Value(),
|
||||
SharedMemory: opts.shmSize.Value(),
|
||||
Ulimits: ulimits,
|
||||
},
|
||||
User: opts.user,
|
||||
VolumeMounts: mounts,
|
||||
},
|
||||
Labels: labels,
|
||||
Mode: opts.mode,
|
||||
Name: opts.name,
|
||||
Placement: placement,
|
||||
@@ -329,24 +301,6 @@ 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]).
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
type eventIDKey struct{}
|
||||
|
||||
// WithEventID returns a context that overrides the event ID already set in the context.
|
||||
// WithEventID returns a context that overrides the default event ID used by client methods.
|
||||
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 operations on a machine.
|
||||
// ImageEventID returns a progress event ID for image pull operations.
|
||||
func ImageEventID(image, machineName string) string {
|
||||
return tui.Faint.Render("Image ") + image +
|
||||
tui.Faint.Render(" on ") + machineName
|
||||
|
||||
@@ -129,11 +129,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -594,26 +594,21 @@ 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: labels,
|
||||
User: spec.Container.User,
|
||||
Tty: spec.Container.Tty,
|
||||
OpenStdin: spec.Container.OpenStdin,
|
||||
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,
|
||||
}
|
||||
if spec.Mode == "" {
|
||||
config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated
|
||||
@@ -734,10 +729,12 @@ 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 {
|
||||
// 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.Labels = map[string]string{
|
||||
api.LabelServiceID: req.ServiceId,
|
||||
api.LabelServiceName: spec.Name,
|
||||
api.LabelHook: api.LabelHookPreDeploy,
|
||||
api.LabelManaged: "",
|
||||
}
|
||||
config.Healthcheck = &container.HealthConfig{
|
||||
Test: []string{"NONE"},
|
||||
}
|
||||
@@ -1182,36 +1179,6 @@ 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,7 +15,6 @@ 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"
|
||||
@@ -35,9 +34,6 @@ type Service struct {
|
||||
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.
|
||||
@@ -45,26 +41,9 @@ 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{} {
|
||||
@@ -162,90 +141,6 @@ 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 {
|
||||
|
||||
@@ -1,58 +1,47 @@
|
||||
# @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.8.1"
|
||||
backend = "aqua:vektra/mockery"
|
||||
specifiers = ["3.8.1"]
|
||||
|
||||
[tools."aqua:vektra/mockery"."platforms.linux-arm64"]
|
||||
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: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: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: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: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: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: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"
|
||||
@@ -85,7 +74,6 @@ 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"
|
||||
@@ -132,7 +120,6 @@ 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"
|
||||
@@ -179,7 +166,6 @@ provenance = "github-attestations"
|
||||
[[tools.node]]
|
||||
version = "22.20.0"
|
||||
backend = "core:node"
|
||||
specifiers = ["22.20.0"]
|
||||
|
||||
[tools.node."platforms.linux-arm64"]
|
||||
checksum = "sha256:4181609e03dcb9880e7e5bf956061ecc0503c77a480c6631d868cb1f65a2c7dd"
|
||||
@@ -213,119 +199,98 @@ 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 = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
|
||||
checksum = "blake3:8c2938accf7061164ea3968529a47ced1db4824ca553857f6903c68ae4347739"
|
||||
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 = "sha256:b87bc134dee55576a842141bf0ed27761c635d746780fce5dee038c6dd16554f"
|
||||
checksum = "blake3:f765e393e0f0b25070c13848d88f5e6303e66090132afa1b753e981507a4174e"
|
||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
|
||||
url_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 = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
|
||||
checksum = "blake3:197739132f0408f017d0c83773ac1ace85c0572473fdd658ec6e4ccbf20e0552"
|
||||
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz"
|
||||
url_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,6 +1,5 @@
|
||||
[settings]
|
||||
experimental = true
|
||||
lockfile = true
|
||||
|
||||
[tools]
|
||||
"aqua:vektra/mockery" = "3.8.1"
|
||||
@@ -37,33 +36,9 @@ GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/psviderski/uncloud/inte
|
||||
"""
|
||||
outputs = ["uncloudd-linux-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."mise:lock"]
|
||||
description = "Update mise.lock with hashes for all platforms"
|
||||
run = "mise lock --platform linux-x64,macos-arm64"
|
||||
|
||||
[tasks.mocks]
|
||||
description = "Regenerate Go test mocks"
|
||||
@@ -78,12 +53,10 @@ 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,15 +5,6 @@ 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,7 +30,6 @@ 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
|
||||
|
||||
@@ -19,11 +19,6 @@ 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"
|
||||
|
||||
@@ -180,19 +175,6 @@ 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
|
||||
@@ -213,21 +195,6 @@ 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
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
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 (
|
||||
@@ -31,35 +27,6 @@ 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.
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
mapset "github.com/deckarep/golang-set/v2"
|
||||
"github.com/distribution/reference"
|
||||
@@ -78,9 +77,6 @@ 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
|
||||
@@ -161,11 +157,6 @@ 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:
|
||||
@@ -252,7 +243,6 @@ 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 {
|
||||
@@ -289,10 +279,6 @@ 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.
|
||||
@@ -347,9 +333,6 @@ 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 {
|
||||
@@ -407,7 +390,6 @@ 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 {
|
||||
@@ -446,34 +428,6 @@ 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".
|
||||
@@ -612,36 +566,6 @@ 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]()
|
||||
|
||||
+2
-190
@@ -63,84 +63,6 @@ func TestServiceSpec_Validate_Name(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -345,9 +267,8 @@ func TestContainerSpec_Clone(t *testing.T) {
|
||||
"FOO": "bar",
|
||||
"BAZ": "qux",
|
||||
},
|
||||
Image: "nginx:latest",
|
||||
Init: new(true),
|
||||
Labels: map[string]string{"com.example.team": "web"},
|
||||
Image: "nginx:latest",
|
||||
Init: new(true),
|
||||
LogDriver: &LogDriver{
|
||||
Name: "json-file",
|
||||
Options: map[string]string{
|
||||
@@ -392,7 +313,6 @@ 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
|
||||
@@ -408,7 +328,6 @@ 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"])
|
||||
@@ -434,110 +353,3 @@ 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"])
|
||||
})
|
||||
}
|
||||
|
||||
+17
-165
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
@@ -12,7 +11,6 @@ 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"
|
||||
@@ -20,33 +18,11 @@ import (
|
||||
|
||||
const (
|
||||
CaddyServiceName = "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"
|
||||
// CaddyImage is the official Caddy Docker image on Docker Hub: https://hub.docker.com/_/caddy
|
||||
CaddyImage = "caddy"
|
||||
)
|
||||
|
||||
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+)`)
|
||||
)
|
||||
var caddyImageTagRegex = regexp.MustCompile(`^2\.\d+\.\d+$`)
|
||||
|
||||
// CaddyClient provides Caddy operations over its parent Client's connection.
|
||||
// The parent client owns the connection and must be used to close it.
|
||||
@@ -88,28 +64,19 @@ func (c *CaddyClient) Config(ctx context.Context, opts CaddyConfigOptions) (api.
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// CaddyDeploymentOptions configures a Caddy reverse proxy deployment. The zero value deploys the latest version of
|
||||
// CaddyImage with DefaultCaddyConfig to all machines.
|
||||
// CaddyDeploymentOptions configures a Caddy reverse proxy deployment.
|
||||
type CaddyDeploymentOptions struct {
|
||||
// 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 defaults to the latest stable 2.x.x official Caddy image.
|
||||
// Custom images must include curl and start Caddy with /etc/caddy/Caddyfile.
|
||||
Image string
|
||||
// Config is the global Caddyfile. Nil means DefaultCaddyConfig. Any other value, even empty, is marked as custom.
|
||||
Config *string
|
||||
// Config contains an optional global Caddyfile.
|
||||
Config string
|
||||
Placement api.Placement
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func (c *CaddyClient) NewDeployment(ctx context.Context, opts CaddyDeploymentOptions) (*deploy.Deployment, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
@@ -123,10 +90,6 @@ 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{
|
||||
@@ -223,119 +186,17 @@ func (c *CaddyClient) NewDeployment(ctx context.Context, opts CaddyDeploymentOpt
|
||||
},
|
||||
}
|
||||
|
||||
if config != "" {
|
||||
if opts.Config != "" {
|
||||
spec.Caddy = &api.CaddySpec{
|
||||
Config: config,
|
||||
Config: opts.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
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func latestCaddyImage(ctx context.Context) (reference.NamedTagged, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
@@ -361,7 +222,7 @@ func latestCaddyImage(ctx context.Context) (reference.NamedTagged, error) {
|
||||
return imageWithTag, nil
|
||||
}
|
||||
|
||||
// latestCaddyTag selects the newest tag matching caddyImageTagRegex and caddyImageConstraint, falling back to latest.
|
||||
// latestCaddyTag selects the newest stable 2.x.x tag, falling back to latest.
|
||||
func latestCaddyTag(tags []string) string {
|
||||
latestTag := "latest"
|
||||
var latestVersion *semver.Version
|
||||
@@ -371,7 +232,7 @@ func latestCaddyTag(tags []string) string {
|
||||
}
|
||||
|
||||
v, err := semver.NewVersion(t)
|
||||
if err != nil || !caddyImageConstraint.Check(v) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if latestVersion == nil || v.GreaterThan(latestVersion) {
|
||||
@@ -382,12 +243,3 @@ 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
|
||||
}
|
||||
|
||||
+6
-203
@@ -5,8 +5,6 @@ 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"
|
||||
)
|
||||
@@ -17,7 +15,7 @@ func TestLatestCaddyImage(t *testing.T) {
|
||||
image, err := latestCaddyImage(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Regexp(t, `^ghcr\.io/unlabs-dev/caddy-uncloud:0\.\d+\.\d+$`, reference.FamiliarString(image))
|
||||
assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, reference.FamiliarString(image))
|
||||
}
|
||||
|
||||
func TestLatestCaddyTag(t *testing.T) {
|
||||
@@ -29,209 +27,14 @@ func TestLatestCaddyTag(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{name: "empty", 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"},
|
||||
{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"},
|
||||
} {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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, updateCount, removeCount int
|
||||
var createCount, startFirstCount, stopFirstCount, removeCount int
|
||||
machines := make(map[string]struct{})
|
||||
|
||||
for _, op := range p.Volumes {
|
||||
@@ -76,15 +76,12 @@ 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++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,10 +99,6 @@ 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"))
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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,6 +344,20 @@ 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:
|
||||
|
||||
@@ -66,10 +66,6 @@ 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{
|
||||
@@ -128,13 +124,6 @@ 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?
|
||||
@@ -491,6 +480,9 @@ 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"))
|
||||
}
|
||||
@@ -525,6 +517,9 @@ 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,117 +875,6 @@ 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,39 +343,6 @@ 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 (
|
||||
"maps"
|
||||
"reflect"
|
||||
"sort"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@@ -12,8 +12,7 @@ import (
|
||||
type ContainerSpecStatus string
|
||||
|
||||
const (
|
||||
ContainerUpToDate ContainerSpecStatus = "up-to-date"
|
||||
// ContainerNeedsUpdate means the container can be updated in place without recreating it.
|
||||
ContainerUpToDate ContainerSpecStatus = "up-to-date"
|
||||
ContainerNeedsUpdate ContainerSpecStatus = "needs-update"
|
||||
ContainerNeedsRecreate ContainerSpecStatus = "needs-recreate"
|
||||
)
|
||||
@@ -84,7 +83,15 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta
|
||||
}
|
||||
}
|
||||
|
||||
if current.Container.Resources.ValidateUpdateInPlace(newResources) != nil {
|
||||
// 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) {
|
||||
return ContainerNeedsRecreate
|
||||
}
|
||||
|
||||
@@ -93,10 +100,8 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta
|
||||
return ContainerNeedsRecreate
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
// Remaining resources are mutable.
|
||||
if !reflect.DeepEqual(current.Container.Resources, newResources) {
|
||||
return ContainerNeedsUpdate
|
||||
}
|
||||
|
||||
@@ -114,53 +119,3 @@ 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,223 +86,6 @@ 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()
|
||||
|
||||
@@ -344,7 +127,7 @@ func TestEvalContainerSpecChange_ContainerResources(t *testing.T) {
|
||||
CPU: 1000000000,
|
||||
},
|
||||
new: api.ContainerResources{},
|
||||
want: ContainerNeedsRecreate,
|
||||
want: ContainerNeedsUpdate,
|
||||
},
|
||||
|
||||
// Memory
|
||||
@@ -406,33 +189,19 @@ 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: ContainerNeedsRecreate,
|
||||
want: ContainerNeedsUpdate,
|
||||
},
|
||||
{
|
||||
name: "unset reservation",
|
||||
current: api.ContainerResources{Memory: 100 * 1024 * 1024, MemoryReservation: 50 * 1024 * 1024},
|
||||
new: api.ContainerResources{Memory: 100 * 1024 * 1024},
|
||||
want: ContainerNeedsRecreate,
|
||||
want: ContainerNeedsUpdate,
|
||||
},
|
||||
{
|
||||
name: "unset memory and reservation",
|
||||
current: api.ContainerResources{Memory: 100 * 1024 * 1024, MemoryReservation: 50 * 1024 * 1024},
|
||||
new: api.ContainerResources{},
|
||||
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,
|
||||
want: ContainerNeedsUpdate,
|
||||
},
|
||||
|
||||
// Memory and CPU
|
||||
|
||||
+19
-175
@@ -1,15 +1,11 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/table"
|
||||
@@ -54,31 +50,25 @@ 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.
|
||||
// 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.
|
||||
// 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.
|
||||
var hasRun, hasRemove bool
|
||||
var replacedSpec, updatedSpec, removedSpec *api.ServiceSpec
|
||||
var oldSpec *api.ServiceSpec
|
||||
for _, op := range sp.Operations {
|
||||
switch o := op.(type) {
|
||||
case *operation.RunContainerOperation:
|
||||
hasRun = true
|
||||
case *operation.ReplaceContainerOperation:
|
||||
if replacedSpec == nil {
|
||||
replacedSpec = &o.OldContainer.ServiceSpec
|
||||
}
|
||||
case *operation.UpdateContainerOperation:
|
||||
if updatedSpec == nil {
|
||||
updatedSpec = &o.Container.ServiceSpec
|
||||
if oldSpec == nil {
|
||||
oldSpec = &o.OldContainer.ServiceSpec
|
||||
}
|
||||
case *operation.RemoveContainerOperation:
|
||||
hasRemove = true
|
||||
if removedSpec == nil {
|
||||
removedSpec = &o.Container.ServiceSpec
|
||||
if oldSpec == nil {
|
||||
oldSpec = &o.Container.ServiceSpec
|
||||
}
|
||||
}
|
||||
}
|
||||
oldSpec := cmp.Or(replacedSpec, updatedSpec, removedSpec)
|
||||
|
||||
// Service line modifier and verb.
|
||||
var modifier, verb string
|
||||
@@ -101,7 +91,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, replicas, and labels.
|
||||
// TODO: print diff for all changed attributes, not just image and replicas.
|
||||
// Consider reusing the logic in EvalContainerSpecChange to return a structured diff.
|
||||
specTable := table.New().
|
||||
Border(lipgloss.Border{}).
|
||||
@@ -134,22 +124,6 @@ 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)
|
||||
@@ -167,19 +141,17 @@ func (sp *ServicePlan) Format() string {
|
||||
}
|
||||
}
|
||||
|
||||
specDiff := specTable.String() + "\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)
|
||||
}
|
||||
|
||||
// Stack " │ " tree prefixes vertically, then join horizontally with the table.
|
||||
tableStr := specTable.String()
|
||||
treePrefix := tui.Faint.Render(" │ ")
|
||||
for line := range strings.Lines(specDiff) {
|
||||
out.WriteString(treePrefix + line)
|
||||
treeColRows := make([]string, specTable.GetData().Rows())
|
||||
for i := range treeColRows {
|
||||
treeColRows[i] = treePrefix
|
||||
}
|
||||
treeCol := lipgloss.JoinVertical(lipgloss.Left, treeColRows...)
|
||||
|
||||
out.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, treeCol, tableStr))
|
||||
out.WriteString("\n")
|
||||
|
||||
// Blank separator line before container operations.
|
||||
out.WriteString(tui.Faint.Render(" │"))
|
||||
@@ -201,7 +173,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, updateCount, removeCount int
|
||||
var createCount, startFirstCount, stopFirstCount, removeCount int
|
||||
machines := make(map[string]struct{})
|
||||
|
||||
for _, op := range sp.Operations {
|
||||
@@ -216,15 +188,12 @@ 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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,10 +210,6 @@ 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"))
|
||||
@@ -300,127 +265,6 @@ 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 {
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
reusableContainersOnMachine := make(map[string]int)
|
||||
upToDateContainersOnMachine := make(map[string]int)
|
||||
containerSpecStatuses := make(map[string]ContainerSpecStatus)
|
||||
if svc != nil {
|
||||
for _, c := range svc.Containers {
|
||||
@@ -105,50 +105,40 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
|
||||
if s.ForceRecreate {
|
||||
status = ContainerNeedsRecreate
|
||||
} else {
|
||||
status = EvalContainerChange(c.Container, spec)
|
||||
status = EvalContainerSpecChange(c.Container.ServiceSpec, spec)
|
||||
}
|
||||
containerSpecStatuses[c.Container.ID] = status
|
||||
|
||||
if status != ContainerNeedsRecreate {
|
||||
reusableContainersOnMachine[c.MachineID] += 1
|
||||
if status == ContainerUpToDate {
|
||||
upToDateContainersOnMachine[c.MachineID] += 1
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// Sort containers such that running containers with the desired spec are first.
|
||||
slices.SortFunc(svc.Containers, func(c1, c2 api.MachineServiceContainer) int {
|
||||
r1, r2 := isReusable(c1), isReusable(c2)
|
||||
switch {
|
||||
case r1 == r2:
|
||||
return 0
|
||||
case r1:
|
||||
if status, ok := containerSpecStatuses[c1.Container.ID]; ok && status == ContainerUpToDate {
|
||||
return -1
|
||||
default:
|
||||
}
|
||||
if status, ok := containerSpecStatuses[c2.Container.ID]; ok && status == ContainerUpToDate {
|
||||
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 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.
|
||||
// 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.
|
||||
slices.SortFunc(matchedMachines, func(m1, m2 *pb.MachineInfo) int {
|
||||
if reusableContainersOnMachine[m1.Id] > 0 && reusableContainersOnMachine[m2.Id] > 0 {
|
||||
return reusableContainersOnMachine[m2.Id] - reusableContainersOnMachine[m1.Id]
|
||||
if upToDateContainersOnMachine[m1.Id] > 0 && upToDateContainersOnMachine[m2.Id] > 0 {
|
||||
return upToDateContainersOnMachine[m2.Id] - upToDateContainersOnMachine[m1.Id]
|
||||
}
|
||||
if reusableContainersOnMachine[m1.Id] > 0 {
|
||||
if upToDateContainersOnMachine[m1.Id] > 0 {
|
||||
return -1
|
||||
}
|
||||
if reusableContainersOnMachine[m2.Id] > 0 {
|
||||
if upToDateContainersOnMachine[m2.Id] > 0 {
|
||||
return 1
|
||||
}
|
||||
return len(containersOnMachine[m2.Id]) - len(containersOnMachine[m1.Id])
|
||||
@@ -180,16 +170,7 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
|
||||
if status == ContainerUpToDate {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
|
||||
}
|
||||
|
||||
// Replace the old container with a new one.
|
||||
@@ -320,7 +301,7 @@ func reconcileGlobalContainer(
|
||||
}
|
||||
|
||||
// Check if there is a container with the same spec already running. If so, remove the rest.
|
||||
reused := false
|
||||
upToDate := false
|
||||
for i, c := range containers {
|
||||
if !c.Container.State.Running || c.Container.State.Paused {
|
||||
// Skip containers that are not running.
|
||||
@@ -331,21 +312,12 @@ func reconcileGlobalContainer(
|
||||
if forceRecreate {
|
||||
status = ContainerNeedsRecreate
|
||||
} else {
|
||||
status = EvalContainerChange(c.Container, spec)
|
||||
status = EvalContainerSpecChange(c.Container.ServiceSpec, spec)
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
if status == ContainerUpToDate {
|
||||
// The container is already running with the same spec.
|
||||
upToDate = true
|
||||
for j, old := range containers {
|
||||
if i == j {
|
||||
continue
|
||||
@@ -359,8 +331,10 @@ func reconcileGlobalContainer(
|
||||
}
|
||||
break
|
||||
}
|
||||
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
|
||||
// Make sure to update preDeployOperation accordingly.
|
||||
}
|
||||
if reused {
|
||||
if upToDate {
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
@@ -386,7 +360,6 @@ 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,8 +1,6 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
@@ -13,7 +11,6 @@ import (
|
||||
"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) {
|
||||
@@ -363,7 +360,6 @@ 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",
|
||||
@@ -428,121 +424,6 @@ 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"},
|
||||
@@ -621,20 +502,6 @@ 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{
|
||||
@@ -747,8 +614,7 @@ 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},
|
||||
},
|
||||
@@ -794,7 +660,6 @@ func newServiceContainer(id string, state container.State) api.ServiceContainer
|
||||
ID: id,
|
||||
State: &state,
|
||||
},
|
||||
Config: &container.Config{},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -82,9 +82,7 @@ func (o *RunContainerOperation) String() string {
|
||||
|
||||
// StopContainerOperation stops a container on a specific machine.
|
||||
type StopContainerOperation struct {
|
||||
ServiceID string
|
||||
// ServiceName is used for formatting the operation as part of the deployment plan.
|
||||
ServiceName string
|
||||
ServiceID string
|
||||
ContainerID string
|
||||
MachineID string
|
||||
// MachineName is used for formatting the operation as part of the deployment plan.
|
||||
@@ -100,7 +98,8 @@ func (o *StopContainerOperation) Execute(ctx context.Context, cli Client) error
|
||||
}
|
||||
|
||||
func (o *StopContainerOperation) Format() string {
|
||||
displayName := o.ServiceName + tui.Faint.Render("/") + stringid.TruncateID(o.ContainerID)
|
||||
// TODO: pass service name to format the display name consistently with other operations.
|
||||
displayName := stringid.TruncateID(o.ContainerID)
|
||||
|
||||
return tui.BoldRed.Render("-") + " " +
|
||||
tui.Faint.Render("stop container") + " " +
|
||||
@@ -286,39 +285,6 @@ 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 {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
@@ -13,18 +12,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStopContainerOperation_Format(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
op := &StopContainerOperation{
|
||||
ServiceName: "app",
|
||||
ContainerID: "0f6df6593e1b0123456789abcdef",
|
||||
MachineName: "machine-1",
|
||||
}
|
||||
|
||||
assert.Equal(t, "- stop container app/0f6df6593e1b on machine-1", ansi.Strip(op.Format()))
|
||||
}
|
||||
|
||||
func TestReplaceContainerOperation_StopFirstPreparation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -136,13 +123,6 @@ func (c *recordingClient) RemoveContainer(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *recordingClient) UpdateContainer(
|
||||
_ context.Context, _, containerID string, _ api.UpdateContainerOptions,
|
||||
) error {
|
||||
c.calls = append(c.calls, "update:"+containerID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *recordingClient) CreatePreDeployHookContainer(
|
||||
context.Context, string, api.ServiceSpec, string,
|
||||
) (api.CreateContainerResponse, error) {
|
||||
|
||||
+1
-4
@@ -39,7 +39,7 @@ func assertServiceMatchesSpec(t *testing.T, svc api.Service, spec api.ServiceSpe
|
||||
|
||||
func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api.ServiceSpec) {
|
||||
desiredSpec := spec.SetDefaults()
|
||||
status := deploy.EvalContainerChange(ctr, desiredSpec)
|
||||
status := deploy.EvalContainerSpecChange(ctr.ServiceSpec, desiredSpec)
|
||||
assert.Equal(t, deploy.ContainerUpToDate, status)
|
||||
spec = renderRuntimeTemplatesForContainer(t, desiredSpec, ctr.Name)
|
||||
|
||||
@@ -48,9 +48,6 @@ func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api
|
||||
assert.Equal(t, spec.Name, ctr.Config.Labels[api.LabelServiceName])
|
||||
assert.Equal(t, spec.Mode, ctr.Config.Labels[api.LabelServiceMode])
|
||||
assert.Contains(t, ctr.Config.Labels, api.LabelManaged)
|
||||
for k, v := range spec.Container.Labels {
|
||||
assert.Equal(t, v, ctr.Config.Labels[k], "Expected container label '%s'", k)
|
||||
}
|
||||
|
||||
// Command and Entrypoint can only be compared if they are set in the spec.
|
||||
// Otherwise, the container takes them from the image.
|
||||
|
||||
@@ -2,9 +2,7 @@ package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
@@ -12,7 +10,6 @@ import (
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -551,85 +548,6 @@ func TestComposeDeployment(t *testing.T) {
|
||||
"Service containers should be on machines 1 and 3 from comma-separated list")
|
||||
})
|
||||
|
||||
t.Run("labels and in-place update of service labels and resources", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
name := "test-compose-labels"
|
||||
t.Cleanup(func() {
|
||||
removeServices(t, cli, name)
|
||||
})
|
||||
|
||||
composeYAML := func(tier, memLimit string) string {
|
||||
return fmt.Sprintf(`
|
||||
services:
|
||||
%s:
|
||||
image: portainer/pause:3.9
|
||||
mem_limit: %s
|
||||
labels:
|
||||
com.example.team: web
|
||||
deploy:
|
||||
replicas: 2
|
||||
labels:
|
||||
tier: %s
|
||||
`, name, memLimit, tier)
|
||||
}
|
||||
|
||||
project, err := compose.LoadProjectFromContent(ctx, composeYAML("frontend", "64m"))
|
||||
require.NoError(t, err)
|
||||
deployment, err := compose.NewDeployment(ctx, cli, project)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, deployment.Run(ctx))
|
||||
|
||||
svc, err := cli.InspectService(ctx, name)
|
||||
require.NoError(t, err)
|
||||
expectedSpec := api.ServiceSpec{
|
||||
Name: name,
|
||||
Mode: api.ServiceModeReplicated,
|
||||
Container: api.ContainerSpec{
|
||||
Image: "portainer/pause:3.9",
|
||||
Labels: map[string]string{"com.example.team": "web"},
|
||||
Resources: api.ContainerResources{Memory: 64 * 1024 * 1024},
|
||||
},
|
||||
Labels: map[string]string{"tier": "frontend"},
|
||||
Replicas: 2,
|
||||
}
|
||||
assertServiceMatchesSpec(t, svc, expectedSpec)
|
||||
for _, mc := range svc.Containers {
|
||||
assert.NotContains(t, mc.Container.Config.Labels, "tier",
|
||||
"Service labels must not be applied to containers")
|
||||
}
|
||||
containers := serviceContainerIDs(svc)
|
||||
|
||||
// Changing the service labels and memory limit should update the containers in place.
|
||||
project, err = compose.LoadProjectFromContent(ctx, composeYAML("backend", "128m"))
|
||||
require.NoError(t, err)
|
||||
deployment, err = compose.NewDeployment(ctx, cli, project)
|
||||
require.NoError(t, err)
|
||||
|
||||
plan, err := deployment.Plan(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plan.Services, 1)
|
||||
require.Len(t, plan.Services[0].Operations, 2)
|
||||
for _, op := range plan.Services[0].Operations {
|
||||
assert.IsType(t, &operation.UpdateContainerOperation{}, op)
|
||||
}
|
||||
require.NoError(t, deployment.Run(ctx))
|
||||
|
||||
// The updated spec propagates to the cluster store asynchronously.
|
||||
expectedSpec.Labels = map[string]string{"tier": "backend"}
|
||||
expectedSpec.Container.Resources.Memory = 128 * 1024 * 1024
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
svc, err = cli.InspectService(ctx, name)
|
||||
require.NoError(c, err)
|
||||
for _, mc := range svc.Containers {
|
||||
assert.Equal(c, expectedSpec.Labels, mc.Container.ServiceSpec.Labels)
|
||||
assert.Equal(c, expectedSpec.Container.Resources.Memory, mc.Container.HostConfig.Memory)
|
||||
}
|
||||
}, 10*time.Second, 200*time.Millisecond)
|
||||
assertServiceMatchesSpec(t, svc, expectedSpec)
|
||||
assert.Equal(t, containers, serviceContainerIDs(svc), "Expected containers to be updated in place")
|
||||
})
|
||||
|
||||
// Catches regression: https://github.com/psviderski/uncloud/issues/176
|
||||
t.Run("plan new deployment with volumes and recreate strategy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
+7
-165
@@ -319,8 +319,6 @@ func TestDeployment(t *testing.T) {
|
||||
deployment, err := cli.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{})
|
||||
require.NoError(t, err)
|
||||
deployment.Spec.UpdateConfig.MonitorPeriod = &caddyMonitorPeriod
|
||||
image := deployment.Spec.Container.Image
|
||||
assert.Regexp(t, `^ghcr\.io/unlabs-dev/caddy-uncloud:0\.\d+\.\d+$`, image)
|
||||
|
||||
_, err = deployment.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
@@ -329,72 +327,15 @@ func TestDeployment(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, svc.Containers, 3)
|
||||
assertServiceMatchesSpec(t, svc, deployment.Spec)
|
||||
assert.Equal(t, map[string]string{api.LabelCaddyManaged: ""}, svc.Spec().Labels)
|
||||
|
||||
var config api.CaddyConfig
|
||||
require.Eventually(t, func() bool {
|
||||
config, err = cli.Caddy.Config(ctx, client.CaddyConfigOptions{})
|
||||
return err == nil && strings.Contains(config.Caddyfile, "storage uncloud")
|
||||
}, 10*time.Second, 200*time.Millisecond, "expected the Caddyfile to enable cluster storage")
|
||||
assert.Empty(t, config.LastReconciliationError)
|
||||
ctr := svc.Containers[0].Container
|
||||
assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, ctr.Config.Image)
|
||||
|
||||
config, err := cli.Caddy.Config(ctx, client.CaddyConfigOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, config.Caddyfile, "# Caddyfile autogenerated by Uncloud")
|
||||
assert.Contains(t, config.Caddyfile, "handle /.uncloud-verify")
|
||||
|
||||
// Setting the running image as custom only changes the reserved service labels so it must not recreate
|
||||
// the containers.
|
||||
containerIDs := serviceContainerIDs(svc)
|
||||
deployment, err = cli.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{Image: image})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = deployment.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err = cli.InspectService(ctx, client.CaddyServiceName)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, containerIDs.Equal(serviceContainerIDs(svc)), "expected the containers to be updated in place")
|
||||
assert.Equal(t, map[string]string{api.LabelCaddyManaged: "", api.LabelCaddyImageCustom: ""}, svc.Spec().Labels)
|
||||
})
|
||||
|
||||
t.Run("caddy current spec deployment only adds a container on a new machine", func(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
err := cli.RemoveService(ctx, client.CaddyServiceName)
|
||||
if !errors.Is(err, api.ErrNotFound) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
|
||||
deployment, err := cli.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{})
|
||||
require.NoError(t, err)
|
||||
deployment.Spec.UpdateConfig.MonitorPeriod = &caddyMonitorPeriod
|
||||
|
||||
_, err = deployment.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := cli.InspectService(ctx, client.CaddyServiceName)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, svc.Containers, 3)
|
||||
|
||||
// Remove a container to make its machine look like a new one that doesn't run Caddy yet, as 'uc machine add' sees it.
|
||||
removed := svc.Containers[0]
|
||||
err = cli.RemoveContainer(ctx, svc.ID, removed.Container.ID, container.RemoveOptions{Force: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err = cli.InspectService(ctx, client.CaddyServiceName)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, svc.Containers, 2)
|
||||
containerIDs := serviceContainerIDs(svc)
|
||||
|
||||
_, err = cli.NewDeployment(svc.Spec(), nil).Run(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err = cli.InspectService(ctx, client.CaddyServiceName)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, svc.Containers, 3)
|
||||
assert.Len(t, serviceMachines(svc).ToSlice(), 3, "expected 1 container on each machine")
|
||||
assert.True(t, containerIDs.IsSubset(serviceContainerIDs(svc)),
|
||||
"expected other containers to be left unchanged")
|
||||
assertServiceMatchesSpec(t, svc, deployment.Spec)
|
||||
assert.Equal(t, map[string]string{api.LabelCaddyManaged: ""}, svc.Spec().Labels)
|
||||
})
|
||||
|
||||
t.Run("caddy with machine placement", func(t *testing.T) {
|
||||
@@ -424,11 +365,6 @@ func TestDeployment(t *testing.T) {
|
||||
assert.Equal(t, c.Machines[0].ID, svc.Containers[0].MachineID)
|
||||
// initialContainerID := svc.Containers[0].Container.ID
|
||||
|
||||
// Deploying the current spec keeps the placement so it must not add containers on other machines.
|
||||
plan, err := cli.NewDeployment(svc.Spec(), nil).Plan(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, plan.Operations)
|
||||
|
||||
// Deploy to all machines without a placement constraint.
|
||||
deployment, err = cli.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{Image: image})
|
||||
require.NoError(t, err)
|
||||
@@ -511,7 +447,7 @@ func TestDeployment(t *testing.T) {
|
||||
myapp.example.com {
|
||||
reverse_proxy 1.2.3.4:8000
|
||||
}`
|
||||
caddyDeployment, err := cli.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{Config: &caddyCaddyfile})
|
||||
caddyDeployment, err := cli.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{Config: caddyCaddyfile})
|
||||
require.NoError(t, err)
|
||||
caddyDeployment.Spec.UpdateConfig.MonitorPeriod = &caddyMonitorPeriod
|
||||
|
||||
@@ -1825,100 +1761,6 @@ func TestServiceLifecycle(t *testing.T) {
|
||||
require.ErrorIs(t, err, api.ErrNotFound)
|
||||
})
|
||||
|
||||
t.Run("update container in place", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serviceID := newServiceID()
|
||||
spec := api.ServiceSpec{
|
||||
Name: "container-update",
|
||||
Container: api.ContainerSpec{
|
||||
Image: "portainer/pause:latest",
|
||||
Resources: api.ContainerResources{
|
||||
CPU: 100 * api.MilliCore,
|
||||
Memory: 20 * units.MiB,
|
||||
},
|
||||
},
|
||||
Labels: map[string]string{"tier": "frontend"},
|
||||
}
|
||||
|
||||
ctr, err := cli.CreateContainer(ctx, serviceID, spec, c.Machines[0].Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
err := cli.RemoveContainer(ctx, serviceID, ctr.ID, container.RemoveOptions{Force: true})
|
||||
if !errors.Is(err, api.ErrNotFound) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
|
||||
require.NoError(t, cli.StartContainer(ctx, serviceID, ctr.ID))
|
||||
|
||||
// Update the CPU and memory limits and replace the service labels.
|
||||
err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{
|
||||
CPU: new(int64(200 * api.MilliCore)),
|
||||
Memory: new(int64(40 * units.MiB)),
|
||||
MemoryReservation: new(int64(10 * units.MiB)),
|
||||
ServiceLabels: &map[string]string{"tier": "backend"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
spec.Container.Resources.CPU = 200 * api.MilliCore
|
||||
spec.Container.Resources.Memory = 40 * units.MiB
|
||||
spec.Container.Resources.MemoryReservation = 10 * units.MiB
|
||||
spec.Labels = map[string]string{"tier": "backend"}
|
||||
|
||||
mc, err := cli.InspectContainer(ctx, serviceID, ctr.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ctr.ID, mc.Container.ID, "Expected container to be updated in place")
|
||||
assert.True(t, mc.Container.State.Running, "Expected container to keep running")
|
||||
assertContainerMatchesSpec(t, mc.Container, spec)
|
||||
assert.Equal(t, spec.Labels, mc.Container.ServiceSpec.Labels)
|
||||
assert.EqualValues(t, 80*units.MiB, mc.Container.HostConfig.MemorySwap,
|
||||
"Expected swap limit to be twice the memory limit")
|
||||
|
||||
// Remove all service labels and leave the limits unchanged.
|
||||
err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{
|
||||
ServiceLabels: &map[string]string{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
spec.Labels = nil
|
||||
|
||||
mc, err = cli.InspectContainer(ctx, serviceID, ctr.ID)
|
||||
require.NoError(t, err)
|
||||
assertContainerMatchesSpec(t, mc.Container, spec)
|
||||
assert.Nil(t, mc.Container.ServiceSpec.Labels)
|
||||
|
||||
// A limit can't be removed without recreating the container.
|
||||
err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{Memory: new(int64(0))})
|
||||
require.ErrorContains(t, err, "memory limit can't be removed")
|
||||
|
||||
mc, err = cli.InspectContainer(ctx, serviceID, ctr.ID)
|
||||
require.NoError(t, err)
|
||||
assertContainerMatchesSpec(t, mc.Container, spec)
|
||||
|
||||
// Service labels with an invalid key format are rejected.
|
||||
err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{
|
||||
ServiceLabels: &map[string]string{"my label": ""},
|
||||
})
|
||||
require.ErrorContains(t, err, "must not contain whitespace")
|
||||
|
||||
// The daemon accepts reserved service labels because Uncloud sets its own labels on services.
|
||||
err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{
|
||||
ServiceLabels: &map[string]string{"uncloud.future": ""},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mc, err = cli.InspectContainer(ctx, serviceID, ctr.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"uncloud.future": ""}, mc.Container.ServiceSpec.Labels)
|
||||
|
||||
require.NoError(t, cli.RemoveContainer(ctx, serviceID, ctr.ID, container.RemoveOptions{Force: true}))
|
||||
err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{
|
||||
ServiceLabels: &map[string]string{"tier": "frontend"},
|
||||
})
|
||||
require.ErrorIs(t, err, api.ErrNotFound)
|
||||
})
|
||||
|
||||
t.Run("1 replica", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+5
-13
@@ -18,22 +18,14 @@ npm run dev
|
||||
|
||||
`/docs` and `/blog` are built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
### Local development
|
||||
### Local Development
|
||||
|
||||
To preview the documentation website, install its dependencies first:
|
||||
|
||||
```shell
|
||||
npm ci
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
Then start the local development server:
|
||||
|
||||
```shell
|
||||
mise run docs:dev
|
||||
```
|
||||
|
||||
It opens up a browser window at http://localhost:3000/. Most changes are reflected live without having to restart the
|
||||
server.
|
||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without
|
||||
having to restart the server.
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
label: Release notes
|
||||
collapsed: true # keep the category closed by default
|
||||
link:
|
||||
type: generated-index
|
||||
description: What's new in each Uncloud release and how to upgrade.
|
||||
# Each release page sets sidebar_position to the negative minor.patch version (for example, -22.0 for v0.22.0)
|
||||
# to list the newest release first.
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
sidebar_position: -16.0
|
||||
sidebar_label: v0.16.0
|
||||
---
|
||||
|
||||
# v0.16.0
|
||||
|
||||
This page mirrors the [GitHub release](https://github.com/psviderski/uncloud/releases/tag/v0.16.0), which also includes
|
||||
the full list of commits.
|
||||
|
||||
This is a smaller release focused on reliability improvements, with a new WireGuard network inspection command and expanded Compose spec support for container capabilities and sysctls.
|
||||
|
||||
## ✨ Highlights {#highlights}
|
||||
|
||||
### WireGuard network inspection
|
||||
|
||||
PR: [#161](https://github.com/psviderski/uncloud/issues/161). Thanks to [@jabr](https://github.com/jabr) for the contribution ❤️
|
||||
|
||||
The new `uc wg show` command displays the WireGuard network configuration for a machine, including the interface details and all configured peers. This is useful for debugging network connectivity issues and understanding the mesh topology.
|
||||
|
||||
Use the `-m/--machine` flag to inspect the configuration of a specific machine in the cluster or the global `--connect user@host` flag to inspect the machine that has issues connecting to the cluster.
|
||||
|
||||
```bash
|
||||
uc wg show
|
||||
uc wg show -m machine-1
|
||||
uc wg show --connect user@host
|
||||
```
|
||||
|
||||
### Kernel capabilities and `sysctls` support
|
||||
|
||||
PRs: [#238](https://github.com/psviderski/uncloud/issues/238), [#239](https://github.com/psviderski/uncloud/issues/239). Thanks to [@4ndv](https://github.com/4ndv) for the contribution ❤️
|
||||
|
||||
You can now use [`cap_add`](https://github.com/compose-spec/compose-spec/blob/main/spec.md#cap_add), [`cap_drop`](https://github.com/compose-spec/compose-spec/blob/main/spec.md#cap_drop), and [`sysctls`](https://github.com/compose-spec/compose-spec/blob/main/spec.md#sysctls) attribute in your Compose files to add or remove Linux capabilities and configure kernel parameters for service containers. This allows fine-grained control over container privileges and behaviour.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
sysctls:
|
||||
net.core.somaxconn: 65535
|
||||
net.ipv4.tcp_syncookies: 1
|
||||
```
|
||||
|
||||
### Improved cluster initialisation reliability
|
||||
|
||||
Machine initialisation and joining with `uc machine init/add` have been made more reliable that fixed WireGuard peer misconfiguration for machines joining the cluster:
|
||||
- The daemon now waits for the initial Corrosion state sync and cluster components before serving gRPC requests
|
||||
- `uc machine init` and `uc machine add` commands wait for cluster readiness
|
||||
- gRPC auto-retries with exponential backoff for transient connection failures (up to ~8 seconds)
|
||||
|
||||
:::note
|
||||
|
||||
You have to upgrade both the local `uc` CLI and the Uncloud daemon on all your current machines to v0.16.0 to benefit from this improvement.
|
||||
|
||||
:::
|
||||
|
||||
## Other improvements
|
||||
|
||||
- Added `--yes` flag to `uc machine init/add` to auto-confirm machine reset
|
||||
- Added `ctx conn` alias for `uc ctx connection` command
|
||||
- Reduced gRPC connection timeout to proxied machines from 20s to 10s
|
||||
- Machine state file is now saved atomically to prevent corruption
|
||||
- Config mounts are now creating non-existent parent directories (#233)
|
||||
|
||||
## Upgrade to 0.16.0
|
||||
|
||||
### Uncloud CLI locally
|
||||
|
||||
To upgrade the Uncloud CLI (`uc`) locally:
|
||||
|
||||
```bash
|
||||
# Homebrew (macOS, Linux)
|
||||
brew upgrade uncloud
|
||||
|
||||
# Install script (macOS, Linux)
|
||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||
```
|
||||
|
||||
### Machine daemon
|
||||
|
||||
To upgrade the Uncloud daemon on your machines, run the following commands on each machine:
|
||||
|
||||
```bash
|
||||
# AMD64
|
||||
curl -fsSL -o uncloudd.tar.gz https://github.com/psviderski/uncloud/releases/download/v0.16.0/uncloudd_linux_amd64.tar.gz
|
||||
# ARM64
|
||||
# curl -fsSL -o uncloudd.tar.gz https://github.com/psviderski/uncloud/releases/download/v0.16.0/uncloudd_linux_arm64.tar.gz
|
||||
tar -xf uncloudd.tar.gz
|
||||
sudo install uncloudd /usr/local/bin/uncloudd
|
||||
rm uncloudd uncloudd.tar.gz
|
||||
sudo systemctl restart uncloud
|
||||
```
|
||||
@@ -1,141 +0,0 @@
|
||||
---
|
||||
sidebar_position: -17.0
|
||||
sidebar_label: v0.17.0
|
||||
---
|
||||
|
||||
# v0.17.0
|
||||
|
||||
This page mirrors the [GitHub release](https://github.com/psviderski/uncloud/releases/tag/v0.17.0), which also includes
|
||||
the full list of commits.
|
||||
|
||||
This release brings container health monitoring with automatic rollback during rolling deployments, Compose spec support for `healthcheck`, `devices`, and `ulimits`, and various reliability improvements.
|
||||
|
||||
## ✨ Highlights {#highlights}
|
||||
|
||||
### Container health monitoring and rollback
|
||||
|
||||
Changes: [7a9eac5](https://github.com/psviderski/uncloud/commit/7a9eac593f9be0f02b42b1bef5b82f311e6af243), [7b88040](https://github.com/psviderski/uncloud/commit/7b88040c73a43e1dea8db79513fec1c3cd5eac79), [ce3cb8a](https://github.com/psviderski/uncloud/commit/ce3cb8a34f3ae027a4c17b3425eb58c466ce3dbc)
|
||||
|
||||
Rolling deployments now monitor crashes and health checks after starting new containers. If a container keeps restarting or becomes unhealthy, the deployment automatically rolls it back and fails to prevent downtime.
|
||||
|
||||
https://github.com/user-attachments/assets/4ee73048-9ca2-4c8a-b075-f8c6a0840ee7
|
||||
|
||||
#### Compose `healthcheck` support
|
||||
|
||||
Compose files now support the [`healthcheck`](https://github.com/compose-spec/compose-spec/blob/main/spec.md#healthcheck) attribute, allowing you to define health checks for your services:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
healthcheck:
|
||||
test: curl -f http://localhost:8000/health
|
||||
interval: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
start_interval: 1s
|
||||
```
|
||||
|
||||
Use `uc deploy --skip-health` flag to bypass health monitoring for faster emergency deployments.
|
||||
|
||||
See [Rolling deployments](/docs/guides/deployments/rolling-deployments) for more details on how rolling deployments work and how to configure health checks for your services.
|
||||
|
||||
### Control container replacement order during rolling deployments
|
||||
|
||||
PR: [#248](https://github.com/psviderski/uncloud/pull/248). Thanks to [@nick-potts](https://github.com/nick-potts) for the contribution ❤️
|
||||
|
||||
You can now control the order of container replacement during rolling deployments with `deploy.update_config.order` in your Compose file:
|
||||
- `start-first` (default): starts a new container before stopping the old one
|
||||
- `stop-first`: stops the old container before starting a new one (brief downtime)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
deploy:
|
||||
update_config:
|
||||
order: stop-first
|
||||
```
|
||||
|
||||
The default order automatically switches to `stop-first` if a service mounts a volume to prevent data corruption.
|
||||
|
||||
See [Rolling deployments](/docs/guides/deployments/rolling-deployments) for more details.
|
||||
|
||||
### Compose `devices` support
|
||||
|
||||
PR: [#250](https://github.com/psviderski/uncloud/pull/250). Thanks to [@jabr](https://github.com/jabr) for the contribution ❤️
|
||||
|
||||
You can now map host devices into containers, for example, Intel integrated GPU for video transcoding using the [`devices`](https://github.com/compose-spec/compose-spec/blob/main/spec.md#devices) attribute in your Compose file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
- /dev/ttyUSB0:/dev/ttyUSB0:rw
|
||||
```
|
||||
|
||||
### Compose `ulimits` support
|
||||
|
||||
PR: [#244](https://github.com/psviderski/uncloud/pull/244). Thanks to [@ipaddicting](https://github.com/ipaddicting) for the contribution ❤️
|
||||
|
||||
You can now set [`ulimits`](https://github.com/compose-spec/compose-spec/blob/main/spec.md#ulimits) for service containers in your Compose file. For example, to increase the maximum number of open files:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65536
|
||||
hard: 65536
|
||||
```
|
||||
|
||||
### Auto-provision volumes for global services
|
||||
|
||||
PR: [#243](https://github.com/psviderski/uncloud/pull/243). Thanks to [@zasdaym](https://github.com/zasdaym) for the contribution ❤️
|
||||
|
||||
When deploying a [global service](/docs/guides/deployments/deploy-global-services) with a volume, the volume is now automatically created on all machines in the cluster.
|
||||
|
||||
## Improvements
|
||||
|
||||
- SSH connections with `ssh+cli://` prefix (use `ssh` CLI instead of Go native SSH implementation) reuse a single SSH connection via control socket - significantly speeds up `uc` commands
|
||||
- Improved `uc image push` support on macOS by always running proxy to virtualised Docker (except OrbStack that doesn't require it)
|
||||
- Global service deployment stops all running conflicting containers, e.g. left from interrupted deploys, not just the first one
|
||||
- Temporary socat proxy containers created by `uc image push` are labeled with `uncloud.managed`
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fixed IPv6 address formatting for HTTP URLs ([#254](https://github.com/psviderski/uncloud/pull/254))
|
||||
- Fixed `uc image push` when using Rancher Desktop locally ([#251](https://github.com/psviderski/uncloud/pull/251))
|
||||
- Fixed `uc machine init` to reset an already initialised machine when using `ssh+cli://` connection
|
||||
|
||||
## Upgrade to 0.17.0
|
||||
|
||||
### Uncloud CLI locally
|
||||
|
||||
To upgrade the Uncloud CLI (`uc`) locally:
|
||||
|
||||
```bash
|
||||
# Homebrew (macOS, Linux)
|
||||
brew upgrade uncloud
|
||||
|
||||
# Install script (macOS, Linux)
|
||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||
```
|
||||
|
||||
### Machine daemon
|
||||
|
||||
To upgrade the Uncloud daemon on your machines, run the following commands on each machine:
|
||||
|
||||
```bash
|
||||
# AMD64
|
||||
curl -fsSL -o uncloudd.tar.gz https://github.com/psviderski/uncloud/releases/download/v0.17.0/uncloudd_linux_amd64.tar.gz
|
||||
# ARM64
|
||||
# curl -fsSL -o uncloudd.tar.gz https://github.com/psviderski/uncloud/releases/download/v0.17.0/uncloudd_linux_arm64.tar.gz
|
||||
tar -xf uncloudd.tar.gz
|
||||
sudo install uncloudd /usr/local/bin/uncloudd
|
||||
rm uncloudd uncloudd.tar.gz
|
||||
sudo systemctl restart uncloud
|
||||
```
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
sidebar_position: -17.1
|
||||
sidebar_label: v0.17.1
|
||||
---
|
||||
|
||||
# v0.17.1
|
||||
|
||||
This page mirrors the [GitHub release](https://github.com/psviderski/uncloud/releases/tag/v0.17.1), which also includes
|
||||
the full list of commits.
|
||||
|
||||
This is small patch release that adds WireGuard endpoint management for machines, Compose `stop_grace_period` support, and several networking fixes.
|
||||
|
||||
## WireGuard endpoint management
|
||||
|
||||
You can now manually specify what WireGuard endpoint to use when adding or initialising a machine instead of relying on auto-detection. This is useful when a machine is behind a NAT or has multiple network interfaces.
|
||||
|
||||
```shell
|
||||
uc machine init user@host --wg-endpoint 203.0.113.1
|
||||
uc machine add user@host --wg-endpoint 203.0.113.2 --wg-endpoint 192.168.1.5
|
||||
```
|
||||
|
||||
You can also update the WireGuard endpoint for an existing machine:
|
||||
|
||||
```shell
|
||||
uc machine update my-machine --wg-endpoint 203.0.113.1
|
||||
```
|
||||
|
||||
## Compose `stop_grace_period` support
|
||||
|
||||
Control how long to wait before sending SIGKILL to a container during replacements and stops.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
stop_grace_period: 30s
|
||||
```
|
||||
|
||||
## Improvements
|
||||
|
||||
- Tailscale addresses are excluded from auto-detected WireGuard endpoints: see [Discord thread](https://discord.com/channels/1371726032104587335/1477823130670993408)
|
||||
- `uc logs` now prints a warning and continues instead of failing when some Compose services are not found in the cluster
|
||||
- Improved reliability of the initial cluster store sync by waiting for known missing changes
|
||||
- Removed the obsolete `uc machine token` command
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fixed Uncloud DNS resolution for IPv6 machines by creating AAAA records: https://github.com/psviderski/uncloud/pull/257
|
||||
- Fixed connection formatting when changing connection with `uc ctx conn`
|
||||
|
||||
## Upgrade to 0.17.1
|
||||
|
||||
### Uncloud CLI locally
|
||||
|
||||
To upgrade the Uncloud CLI (`uc`) locally:
|
||||
|
||||
```bash
|
||||
# Homebrew (macOS, Linux)
|
||||
brew upgrade uncloud
|
||||
|
||||
# Install script (macOS, Linux)
|
||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||
```
|
||||
|
||||
### Machine daemon
|
||||
|
||||
To upgrade the Uncloud daemon on your machines, run the following commands on each machine:
|
||||
|
||||
```bash
|
||||
# AMD64
|
||||
curl -fsSL -o uncloudd.tar.gz https://github.com/psviderski/uncloud/releases/download/v0.17.1/uncloudd_linux_amd64.tar.gz
|
||||
# ARM64
|
||||
# curl -fsSL -o uncloudd.tar.gz https://github.com/psviderski/uncloud/releases/download/v0.17.1/uncloudd_linux_arm64.tar.gz
|
||||
tar -xf uncloudd.tar.gz
|
||||
sudo install uncloudd /usr/local/bin/uncloudd
|
||||
rm uncloudd uncloudd.tar.gz
|
||||
sudo systemctl restart uncloud
|
||||
```
|
||||
@@ -1,190 +0,0 @@
|
||||
---
|
||||
sidebar_position: -18.0
|
||||
sidebar_label: v0.18.0
|
||||
---
|
||||
|
||||
# v0.18.0
|
||||
|
||||
This page mirrors the [GitHub release](https://github.com/psviderski/uncloud/releases/tag/v0.18.0), which also includes
|
||||
the full list of commits.
|
||||
|
||||
The main highlights of this release are **pre-deploy hooks** for running database migrations and a **redesigned deployment plan** output that makes `uc deploy` much nicer to read and understand.
|
||||
|
||||
On top of that, 0.18.0 also:
|
||||
- makes the system `ssh` CLI the default connection method (with fallback to the Go implementation)
|
||||
- adds Compose support for `pid`, `shm_size`, and the new `x-context` extension to pin a Compose file to a cluster
|
||||
- brings new docs, a bunch of quality-of-life improvements and bug fixes, read on for the full details!
|
||||
|
||||
## Pre-deploy hooks
|
||||
|
||||
⚠️ This requires both `uc` and the daemon to be [upgraded to v0.18.0](#upgrade-to-0180)
|
||||
|
||||
Changes: [f4581ea](https://github.com/psviderski/uncloud/commit/f4581eac1167979e9297d749cb8ac8aa56a1cecc), [1c8b770](https://github.com/psviderski/uncloud/commit/1c8b770), [772b31b](https://github.com/psviderski/uncloud/commit/772b31b), [0e820f9](https://github.com/psviderski/uncloud/commit/0e820f9)
|
||||
|
||||
You can now run a one-off command before deploying a service using the new `x-pre_deploy` Compose extension. This is ideal for database schema migrations, uploading static assets to a CDN, cache invalidation, and any other setup task that needs to run **once** before new code goes live.
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
services:
|
||||
web:
|
||||
build: ..
|
||||
environment:
|
||||
DATABASE_URL: postgres://postgres:${DB_PASSWORD}@db:5432/postgres
|
||||
x-pre_deploy:
|
||||
# Apply Django migrations from the built image before deploying new app containers.
|
||||
command: python manage.py migrate
|
||||
depends_on:
|
||||
- db
|
||||
```
|
||||
|
||||
Here is how it's displayed in the deploy plan when you run `uc deploy`:
|
||||
|
||||
<img width="692" height="115" alt="Screenshot 2026-04-10 at 5 16 56 pm" src="https://github.com/user-attachments/assets/fc921bdd-aeea-43e9-923e-abefa2426358" />
|
||||
|
||||
The hook command runs in a new container that inherits the service's image, environment variables, volumes, placement, and compute resources.
|
||||
|
||||
If the command exits successfully, the deployment continues with a normal rolling update. If it fails or times out, the deployment stops immediately and `uc deploy` displays the latest hook logs to help you diagnose the issue.
|
||||
|
||||

|
||||
|
||||
Container tables in `uc ps` and `uc inspect` now also list pre-deploy hook containers.
|
||||
|
||||
See the docs:
|
||||
- [Pre-deploy hooks](/docs/guides/deployments/pre-deploy-hooks) guide for more details and examples
|
||||
- [`x-pre_deploy`](/docs/compose-file-reference/extensions#x-pre_deploy) reference for all available attributes
|
||||
|
||||
## Restyled deployment plans
|
||||
|
||||
Changes: [0f4c211](https://github.com/psviderski/uncloud/commit/0f4c211), [f5a77b8](https://github.com/psviderski/uncloud/commit/f5a77b8), [b4b1232](https://github.com/psviderski/uncloud/commit/b4b1232)
|
||||
|
||||
The deployment plan output that you see when running `uc deploy`, `uc scale`, and `uc caddy deploy` has been redesigned with consistent styling, clearer operation verbs, and a header and the prompt showing the target context.
|
||||
|
||||
We hope this makes it easier for you to understand what changes will be made and to which cluster before you confirm the deployment.
|
||||
|
||||
<img alt="Screenshot 2026-03-18 at 9 39 38 pm" src="https://github.com/user-attachments/assets/f9d67b36-4a19-4ec6-b115-23c09b90f795" />
|
||||
|
||||
## System `ssh` is now the default connection method
|
||||
|
||||
Change: [06c2bc5](https://github.com/psviderski/uncloud/commit/06c2bc55f2fbf20920613aac45c07952107160a0)
|
||||
|
||||
The `ssh+cli://` connection method that uses your system's `ssh` CLI is now the default for new connections, so the prefix can be omitted:
|
||||
|
||||
```shell
|
||||
# Old
|
||||
uc machine init ssh+cli://user@host
|
||||
# New
|
||||
uc machine init user@host
|
||||
```
|
||||
|
||||
`ssh_cli:` can also be replaced with just `ssh:` in the config file (`~/.config/uncloud/config.yaml`):
|
||||
|
||||
```yaml title="config.yaml"
|
||||
contexts:
|
||||
prod:
|
||||
connections:
|
||||
- ssh: user@host
|
||||
```
|
||||
|
||||
This will by default use your existing SSH config, agent, keys, `ProxyJump`, and `ProxyCommand` setup automatically. You can fall back to the Go native SSH implementation using `ssh+go://` prefix.
|
||||
|
||||
Combined with the SSH control socket reuse introduced in [v0.17.0](https://github.com/psviderski/uncloud/releases/tag/v0.17.0), this makes most `uc` commands noticeably faster and removes a whole class of authentication friction.
|
||||
|
||||
The new [Connecting to a cluster](/docs/concepts/clusters/connecting) doc walks through all the supported connection schemes (`ssh://`, `ssh+go://`, `tcp://`, `unix://`), how `uc` picks a machine to talk to from your config, and how to connect without a config file for CI pipelines and scripts.
|
||||
|
||||
## Cluster context selection in Compose
|
||||
|
||||
Change: [1525d18](https://github.com/psviderski/uncloud/commit/1525d182b516b876510d6e4413ca2fe9ff029b53)
|
||||
|
||||
You can now pin a Compose file to a specific context using the new `x-context` extension. This prevents accidentally deploying to the wrong cluster when you switch contexts.
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
x-context: prod
|
||||
|
||||
services:
|
||||
web:
|
||||
image: myapp
|
||||
```
|
||||
|
||||
When `x-context` is set, `uc deploy` will use that context regardless of your currently selected one in the Uncloud config. The deployment plan output also displays the target context so you always know where your changes are going.
|
||||
|
||||
See [`x-context`](/docs/compose-file-reference/extensions#x-context) reference for more details.
|
||||
|
||||
## Compose `pid` namespace support
|
||||
|
||||
PR: [#276](https://github.com/psviderski/uncloud/pull/276). Thanks to [@miekg](https://github.com/miekg) for the contribution ❤️
|
||||
|
||||
You can now configure the [PID namespace mode](https://github.com/compose-spec/compose-spec/blob/main/spec.md#pid) for service containers. The most common use case is `pid: host`, which lets a container see and interact with processes on the host. This is useful for monitoring agents, debuggers, and similar tooling.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
monitoring:
|
||||
image: agent
|
||||
pid: host
|
||||
```
|
||||
|
||||
## Compose `shm_size` support
|
||||
|
||||
Change: [8ae38cf](https://github.com/psviderski/uncloud/commit/8ae38cfd57d02f42631e0c2bb54c4331b9c072c9)
|
||||
|
||||
You can now set [`shm_size`](https://github.com/compose-spec/compose-spec/blob/main/spec.md#shm_size), the size of the `/dev/shm` shared memory tmpfs. This is useful for databases like Postgres, headless Chromium, and other workloads that need a larger shared memory segment.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
shm_size: 256mb
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Connecting to a cluster](/docs/concepts/clusters/connecting): a complete guide to cluster contexts, connection resolution and all supported types, and how to connect without a config file
|
||||
- [CLI configuration file](/docs/cli-config-reference): a full reference for the `~/.config/uncloud/config.yaml` file, covering contexts, connections, and all supported fields
|
||||
- [Pre-deploy hooks](/docs/guides/deployments/pre-deploy-hooks): a guide for the new feature, with examples for database migrations and other one-off setup tasks
|
||||
- [Compose extensions](/docs/compose-file-reference/extensions): a dedicated reference page for all Uncloud-specific Compose extensions
|
||||
|
||||
## Improvements
|
||||
|
||||
- The `uc` CLI and the daemon now exchange versions on every gRPC call and error if they are incompatible. This is a groundwork to make better user experience when releasing breaking changes in the future ([#260](https://github.com/psviderski/uncloud/pull/260)). Thanks to [@jabr](https://github.com/jabr) for the contribution ❤️
|
||||
- New `uc exec` accepts an optional `--` separator before the command, matching Docker and other CLIs ([#301](https://github.com/psviderski/uncloud/pull/301)). Thanks to [@nick-potts](https://github.com/nick-potts) for the contribution ❤️
|
||||
- New `uc docs --manual` flag generates manual pages for `uc`, making it easier for distribution maintainers to package manpages alongside the binary ([#302](https://github.com/psviderski/uncloud/pull/302)). Thanks to [@miekg](https://github.com/miekg) for the contribution ❤️
|
||||
- The daemon now exposes a gRPC method to fetch journald logs from a machine, laying the groundwork for an upcoming `uc machine logs` command ([#282](https://github.com/psviderski/uncloud/pull/282)). Thanks to [@miekg](https://github.com/miekg) for the contribution ❤️
|
||||
- Warnings are now printed for detected unsupported Compose features ([#288](https://github.com/psviderski/uncloud/pull/288)). Thanks to [@miekg](https://github.com/miekg) for the contribution ❤️
|
||||
- New `--auto-confirm` flag for `uc scale` which now always asks for confirmation before changing replica counts
|
||||
- New `--ulimit` flag for `uc run` to set resource limits
|
||||
- bumped Go to 1.26.1 and modernized the codebase using Go 1.26 ([#278](https://github.com/psviderski/uncloud/pull/278))
|
||||
- Migrated TUI components to `lipgloss/v2`, `bubbletea/v2`, and `huh/v2`
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fixed `uc deploy` crash in an initialised but empty git repository ([#275](https://github.com/psviderski/uncloud/pull/275)). Thanks to [@tonyo](https://github.com/tonyo) for the contribution ❤️
|
||||
- Fixed `ucind` downloading the wrong Corrosion architecture on arm64 ([#285](https://github.com/psviderski/uncloud/pull/285)). Thanks to [@miekg](https://github.com/miekg) for the contribution ❤️
|
||||
- Fixed output contamination in the SSH CLI executor by separating stdout and stderr ([#270](https://github.com/psviderski/uncloud/issues/270))
|
||||
- Fixed trying other machine connections when the first one fails for the new default `ssh` type. Fixed the TUI progress spinner.
|
||||
|
||||
## Upgrade to 0.18.0
|
||||
|
||||
### Uncloud CLI locally
|
||||
|
||||
To upgrade the Uncloud CLI (`uc`) locally:
|
||||
|
||||
```bash
|
||||
# Homebrew (macOS, Linux)
|
||||
brew upgrade uncloud
|
||||
|
||||
# Install script (macOS, Linux)
|
||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||
```
|
||||
|
||||
### Machine daemon
|
||||
|
||||
To upgrade the Uncloud daemon on your machines, run the following commands on each machine:
|
||||
|
||||
```bash
|
||||
# AMD64
|
||||
curl -fsSL -o uncloudd.tar.gz https://github.com/psviderski/uncloud/releases/download/v0.18.0/uncloudd_linux_amd64.tar.gz
|
||||
# ARM64
|
||||
# curl -fsSL -o uncloudd.tar.gz https://github.com/psviderski/uncloud/releases/download/v0.18.0/uncloudd_linux_arm64.tar.gz
|
||||
tar -xf uncloudd.tar.gz
|
||||
sudo install uncloudd /usr/local/bin/uncloudd
|
||||
rm uncloudd uncloudd.tar.gz
|
||||
sudo systemctl restart uncloud
|
||||
```
|
||||
@@ -1,170 +0,0 @@
|
||||
---
|
||||
sidebar_position: -19.0
|
||||
sidebar_label: v0.19.0
|
||||
---
|
||||
|
||||
# v0.19.0
|
||||
|
||||
This page mirrors the [GitHub release](https://github.com/psviderski/uncloud/releases/tag/v0.19.0), which also includes
|
||||
the full list of commits.
|
||||
|
||||
This release brings **easier debugging** of failed deploys, new **troubleshooting commands** (`uc machine logs` and `uc machine rtt`), and a **nightly release** channel.
|
||||
|
||||
Read on for the full list of changes and upgrade instructions.
|
||||
|
||||
## Show logs from a failed deploy
|
||||
|
||||
Changes: [8afe523](https://github.com/psviderski/uncloud/commit/8afe523), [303c8e4](https://github.com/psviderski/uncloud/commit/303c8e4), [56a54ab](https://github.com/psviderski/uncloud/commit/56a54ab)
|
||||
|
||||
When `uc deploy` fails because a pre-deploy hook failed or a new container couldn't become healthy, it now prints the last 10 (configurable) log lines from the failed container.
|
||||
|
||||
This is a quality-of-life improvement that saves you from running a follow-up `uc logs` to figure out what went wrong.
|
||||
|
||||
<img width="1253" height="871" alt="Screenshot 2026-04-24 at 2 01 22 pm" src="https://github.com/user-attachments/assets/69adcf19-ebe8-465e-8834-414c99c15260" />
|
||||
|
||||
See [Failed container logs](/docs/guides/deployments/rolling-deployments/#failed-container-logs) for more details.
|
||||
|
||||
## Filter service logs by container
|
||||
|
||||
Change: [2045819](https://github.com/psviderski/uncloud/commit/2045819)
|
||||
|
||||
`uc logs` now accepts a `SERVICE/CONTAINER` form where `CONTAINER` is a container name, full ID, or unique ID prefix. This is handy when one replica of a service is misbehaving and you want to look at it without the noise from the others:
|
||||
|
||||
```shell
|
||||
uc logs web/2f60
|
||||
# Mix and match
|
||||
uc logs api/61d57fd3428f web/2f60 db
|
||||
```
|
||||
|
||||
## Stream machine logs
|
||||
|
||||
⚠️ This requires both `uc` and the daemon to be [upgraded to v0.19.0](#upgrade-to-0190)
|
||||
|
||||
PRs: [#282](https://github.com/psviderski/uncloud/pull/282), [#283](https://github.com/psviderski/uncloud/pull/283). Thanks to [@miekg](https://github.com/miekg) for the contribution ❤️
|
||||
|
||||
You can now stream logs from the systemd services that run Uncloud itself on remote machines using the new `uc machine logs` command. It covers three main services:
|
||||
|
||||
- `uncloud` - the Uncloud daemon
|
||||
- `uncloud-corrosion` - the Corrosion service providing the distributed cluster store
|
||||
- `docker` - the Docker daemon
|
||||
|
||||
For example, stream the Uncloud daemon logs from all machines with:
|
||||
```shell
|
||||
uc machine logs -f uncloud
|
||||
```
|
||||
|
||||
This is useful for troubleshooting Uncloud operations without having to SSH into every machine and run `journalctl` yourself.
|
||||
|
||||
See [`uc machine logs`](/docs/cli-reference/uc_machine_logs) for more details and examples.
|
||||
|
||||
## Round-trip time between machines
|
||||
|
||||
⚠️ This requires both `uc` and the daemon to be [upgraded to v0.19.0](#upgrade-to-0190)
|
||||
|
||||
PR: [#226](https://github.com/psviderski/uncloud/pull/226). Thanks to [@jabr](https://github.com/jabr) for the contribution ❤️
|
||||
|
||||
The new `uc machine rtt` command shows the round-trip time between every pair of machines in the cluster. This gives you a real-time view of how the mesh network is performing without needing to run manual `ping` tests.
|
||||
|
||||
The data is collected from Corrosion's gossip protocol, which samples latency between peers as part of its normal operation.
|
||||
|
||||
```shell
|
||||
$ uc machine rtt
|
||||
MACHINE PEER MEDIAN STDDEV
|
||||
machine-1 machine-2 140ms ±19.4ms
|
||||
machine-1 machine-3 39ms ±1.1ms
|
||||
machine-2 machine-1 168ms ±18.5ms
|
||||
machine-2 machine-3 203ms ±42.3ms
|
||||
machine-3 machine-1 40ms ±2.0ms
|
||||
machine-3 machine-2 158ms ±15.2ms
|
||||
```
|
||||
|
||||
A new `RTT` column has also been added to `uc wg show` to show the median round-trip time to each WireGuard peer.
|
||||
|
||||
## Local machine upstreams first in Caddyfile
|
||||
|
||||
Change: [45cf87a](https://github.com/psviderski/uncloud/commit/45cf87a)
|
||||
|
||||
The generated Caddyfile now lists upstreams from the local machine first for each service.
|
||||
|
||||
On its own this doesn't change routing behaviour (Caddy's default `random` load balancing policy ignores order). But if you pair it with the `first` policy in a custom Caddy config, you can always prefer the same-host replica and only fall back to remote machines when the local one is unhealthy.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
...
|
||||
x-caddy: |
|
||||
example.com {
|
||||
reverse_proxy {{upstreams 8000}} {
|
||||
import common_proxy
|
||||
lb_policy first
|
||||
}
|
||||
log
|
||||
}
|
||||
```
|
||||
|
||||
This saves a cross-machine WireGuard hop for every request that hits Caddy on the machine which already has a replica of the target service. This is especially useful if you have a multi-region setup.
|
||||
|
||||
## Nightly builds
|
||||
|
||||
PR: [#308](https://github.com/psviderski/uncloud/pull/308). Thanks to [@tonyo](https://github.com/tonyo) for the contribution ❤️
|
||||
|
||||
Every push to `main` now produces a set of nightly binaries tagged as the [`nightly` release](https://github.com/psviderski/uncloud/releases/tag/nightly) on GitHub.
|
||||
|
||||
They're great for testing upcoming unreleased changes and reporting regressions early. They might be unstable, so please don't run them in production.
|
||||
|
||||
Install the nightly `uc` CLI locally:
|
||||
|
||||
```shell
|
||||
curl -fsS https://get.uncloud.run/install.sh | VERSION=nightly sh
|
||||
```
|
||||
|
||||
Initialise a cluster or add a machine with a nightly daemon:
|
||||
|
||||
```shell
|
||||
uc machine init --version nightly user@host
|
||||
uc machine add --version nightly user@host
|
||||
```
|
||||
|
||||
## Improvements
|
||||
|
||||
- `uc machine init/add` now embed the install script into the `uc` binary and send it to the remote machine over the existing SSH connection instead of `curl | bash` ([fe829ef](https://github.com/psviderski/uncloud/commit/fe829ef)).
|
||||
- New `uc ctx show` command prints the name of the currently active cluster context, great for shell prompts and scripts ([#317](https://github.com/psviderski/uncloud/pull/317)).
|
||||
- SSH connections now use `-o StrictHostKeyChecking=accept-new`, which silently accepts host keys on first connection but still protects against key changes later ([#303](https://github.com/psviderski/uncloud/pull/303)).
|
||||
- The `STORE` column in `uc images` is hidden when all machines use the containerd image store, which is the default for new clusters ([c38d916](https://github.com/psviderski/uncloud/commit/c38d916))
|
||||
- Image push errors now include the underlying error from the unregistry proxy, which makes it much easier to tell apart a broken image push from a broken SSH tunnel ([d340659](https://github.com/psviderski/uncloud/commit/d340659))
|
||||
- `ucind` now accepts a `UNCLOUD_CONFIG` environment variable to override the config file path for development clusters ([#315](https://github.com/psviderski/uncloud/pull/315)).
|
||||
- New `ucind cluster ls` command lists the local development clusters managed by `ucind` with their machines ([#316](https://github.com/psviderski/uncloud/pull/316)).
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fixed containerd socket auto-detection for unregistry after a machine reboot ([47199c3](https://github.com/psviderski/uncloud/commit/47199c3)).
|
||||
- Fixed Caddy config being regenerated on every service change because container records were serialised non-deterministically ([#111](https://github.com/psviderski/uncloud/issues/111)).
|
||||
- Fixed SSH control socket path on WSL2 when the runtime directory doesn't exist ([#319](https://github.com/psviderski/uncloud/issues/319)).
|
||||
- Fixed a stale SSH `ControlMaster` connection sometimes causing `uc machine init`/`add` to hang ([fdbffbf](https://github.com/psviderski/uncloud/commit/fdbffbf)).
|
||||
|
||||
## Upgrade to 0.19.0
|
||||
|
||||
### Uncloud CLI locally
|
||||
|
||||
To upgrade the Uncloud CLI (`uc`) locally:
|
||||
|
||||
```bash
|
||||
# Homebrew (macOS, Linux)
|
||||
brew upgrade uncloud
|
||||
|
||||
# Install script (macOS, Linux)
|
||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||
```
|
||||
|
||||
### Machine daemon
|
||||
|
||||
To upgrade the Uncloud daemon on your machines, run the following commands on each machine:
|
||||
|
||||
```bash
|
||||
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
|
||||
curl -fsSL -o uncloudd.tar.gz https://github.com/psviderski/uncloud/releases/download/v0.19.0/uncloudd_linux_${ARCH}.tar.gz
|
||||
tar -xf uncloudd.tar.gz
|
||||
sudo install uncloudd /usr/local/bin/uncloudd
|
||||
rm uncloudd uncloudd.tar.gz
|
||||
sudo systemctl restart uncloud
|
||||
```
|
||||
@@ -1,146 +0,0 @@
|
||||
---
|
||||
sidebar_position: -20.0
|
||||
sidebar_label: v0.20.0
|
||||
---
|
||||
|
||||
# v0.20.0
|
||||
|
||||
This page mirrors the [GitHub release](https://github.com/psviderski/uncloud/releases/tag/v0.20.0), which also includes
|
||||
the full list of commits.
|
||||
|
||||
This release adds **secrets** for passing sensitive data to services, a new **`uc proxy`** command for port-forwarding, a **Prometheus metrics** endpoint, **`uc machine ls`** that surfaces each machine's OS, Docker, and daemon versions. Plus daemon self-upgrade on `uc machine init/add`, WireGuard MTU auto-detection, shell completion, and more.
|
||||
|
||||
⚠️ **This is a breaking release.** Both the `uc` CLI and the `uncloudd` daemon on **every** machine must be upgraded to 0.20. See [Breaking changes](#breaking-changes) below before you upgrade.
|
||||
|
||||
## ⚠️ Breaking changes {#breaking-changes}
|
||||
|
||||
0.20 changes the internal gRPC API proxy format and migrates the cluster store to a new Corrosion version, so a mixed 0.19/0.20 cluster will not work. The built-in version check rejects requests between incompatible CLI and daemon versions, so upgrade everything together. See [Upgrade to 0.20.0](#upgrade-to-0200) for instructions.
|
||||
|
||||
- **Server-side gRPC proxy resolution** ([#247](https://github.com/psviderski/uncloud/pull/247)). API proxy targets (machines) are now resolved on the daemon instead of the CLI. This changes the request format on the wire, so old and new components can't talk to each other. Thanks to [@jabr](https://github.com/jabr) for the contribution ❤️
|
||||
- **Corrosion now runs as a managed container** ([71da325](https://github.com/psviderski/uncloud/commit/71da325)). The Corrosion cluster store moved from a systemd unit to a Docker container and was upgraded to Corrosion v1 ([`2026.6.15`](https://github.com/unlabs-dev/corrosion/releases/tag/v2026.6.15)). Existing data is migrated to the new format **automatically** on first daemon start. Auth is now enabled for local Corrosion API requests too ([#110](https://github.com/psviderski/uncloud/issues/110)).
|
||||
- **CLI artifact renamed `uncloud_*` → `uc_*`** ([a5d73fe](https://github.com/psviderski/uncloud/commit/a5d73fe)). Homebrew and the install script handle this for you. Only update your own scripts if you download the CLI archive directly. The daemon archive name (`uncloudd_*`) is unchanged.
|
||||
- **Unregistry image proxy moved from port `5000` to `51500`** ([d36ff67](https://github.com/psviderski/uncloud/commit/d36ff67)). `uc` handles this automatically when pushing images. Only update your setup if you reach this proxy port directly in your own tooling.
|
||||
|
||||
## Secrets support
|
||||
|
||||
PR: [#403](https://github.com/psviderski/uncloud/pull/403), docs: [e01c8d3](https://github.com/psviderski/uncloud/commit/e01c8d3)
|
||||
|
||||
You can now keep sensitive data like passwords, API tokens, and private keys out of your Compose file. Define a secret with the new `x-command` extension and reference it from a service's `environment` with `secret://<name>`. The command runs locally at deploy time and pulls the value from your password manager, cloud secrets store, or any CLI you already use.
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
services:
|
||||
api:
|
||||
image: myapp:latest
|
||||
environment:
|
||||
DB_PASSWORD: secret://db_password
|
||||
|
||||
secrets:
|
||||
db_password:
|
||||
x-command: op read "op://prod/myapp/db_password"
|
||||
```
|
||||
|
||||
The value never lands in your Compose file or git. See the new [Secrets](/docs/concepts/secrets) doc for supported sources, caveats, and examples.
|
||||
|
||||
## Proxy a service port locally
|
||||
|
||||
PR: [#384](https://github.com/psviderski/uncloud/pull/384). Thanks to [@miekg](https://github.com/miekg) for the contribution ❤️
|
||||
|
||||
The new `uc proxy` command forwards a service port to a local port over the cluster network, similar to `kubectl port-forward`. This is handy for reaching an internal database or admin UI from your laptop without exposing them publicly.
|
||||
|
||||
## Richer `uc machine ls`
|
||||
|
||||
Changes: [8aa0a96](https://github.com/psviderski/uncloud/commit/8aa0a96), [ad43569](https://github.com/psviderski/uncloud/commit/ad43569), [1d5c717](https://github.com/psviderski/uncloud/commit/1d5c717), [23d6b44](https://github.com/psviderski/uncloud/commit/23d6b44)
|
||||
|
||||
`uc machine ls` now shows the `OS`, `KERNEL`, `ARCH`, `DOCKER`, and `VERSION` (daemon) of each machine. You can spot version drift and outdated daemons at a glance.
|
||||
|
||||
```shell
|
||||
$ uc machine ls
|
||||
NAME STATE ADDRESS PUBLIC IP WIREGUARD ENDPOINTS OS KERNEL ARCH DOCKER VERSION
|
||||
machine-1 Up 10.210.0.1/24 203.0.113.10 203.0.113.10:51820 Debian 12.12 6.1.0-41-amd64 amd64 29.4.0 0.20.0
|
||||
machine-2 Up 10.210.1.1/24 198.51.100.7 198.51.100.7:51820 Debian 12.5 6.1.0-18-amd64 amd64 26.0.0 0.20.0
|
||||
machine-3 Up 10.210.2.1/24 - 192.0.2.44:51820 Ubuntu 22.04.5 LTS 6.8.0-1050-oracle arm64 29.5.3 0.20.0
|
||||
```
|
||||
|
||||
Pass `-o json` for machine-readable output to use in scripts.
|
||||
|
||||
## Prometheus metrics
|
||||
|
||||
PR: [#304](https://github.com/psviderski/uncloud/pull/304). Thanks to [@miekg](https://github.com/miekg) for the contribution ❤️
|
||||
|
||||
Each daemon now serves the initial set of Prometheus metrics on `http://<machine-ip>:51090/metrics`, listening on the machine's internal IP. Point your scraper at it to monitor the cluster.
|
||||
|
||||
## Daemon self-upgrade on machine init/add
|
||||
|
||||
Change: [a26af3b](https://github.com/psviderski/uncloud/commit/a26af3b)
|
||||
|
||||
`uc machine init` and `uc machine add` now upgrade the installed daemon binary to the latest (or a specified `--version`) if the daemon was already installed on the machine.
|
||||
|
||||
## Improvements
|
||||
|
||||
- WireGuard now auto-detects the optimal interface MTU, with a new `--wg-mtu` flag to override it ([0c3b8b1](https://github.com/psviderski/uncloud/commit/0c3b8b1)).
|
||||
- New `--wg-port` flag for `uc machine init/add` to customise the WireGuard listen port ([#366](https://github.com/psviderski/uncloud/pull/366)). Thanks to [@dasunsrule32](https://github.com/dasunsrule32) for the contribution ❤️
|
||||
- `x-ports` can bind to multiple host IP addresses given as a CIDR prefix ([#358](https://github.com/psviderski/uncloud/pull/358)). Thanks to [@miekg](https://github.com/miekg) for the contribution ❤️
|
||||
- New `uc version` and `uncloudd version` commands that print full build information, replacing the `--version` flag ([2f15915](https://github.com/psviderski/uncloud/commit/2f15915)).
|
||||
- `uc machine init/add` installs `curl` on the remote machine if it's missing ([#402](https://github.com/psviderski/uncloud/issues/402)).
|
||||
- New `uc caddy logs` alias for `uc logs caddy` ([#381](https://github.com/psviderski/uncloud/pull/381)).
|
||||
- Shell/argument completion for the `uc` and `ucind` CLIs (machines, services, volumes) ([#309](https://github.com/psviderski/uncloud/pull/309), [#344](https://github.com/psviderski/uncloud/pull/344), [#352](https://github.com/psviderski/uncloud/pull/352)). Thanks to [@miekg](https://github.com/miekg) for the contributions ❤️
|
||||
- Support rootless Docker locally when pushing images to the cluster ([#347](https://github.com/psviderski/uncloud/pull/347)). Thanks to [@tonyo](https://github.com/tonyo) for the contribution ❤️
|
||||
- The CLI strips ANSI colors from table output and skips interactive TUI elements when stdout is not a TTY ([e3823c2](https://github.com/psviderski/uncloud/commit/e3823c2), [#386](https://github.com/psviderski/uncloud/issues/386)).
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Reject relative volume source paths in Compose with a clear error ([#353](https://github.com/psviderski/uncloud/pull/353)). Thanks to [@miekg](https://github.com/miekg) for the contribution ❤️
|
||||
- Return a non-zero exit code when a `uc` command is cancelled by declining a confirmation prompt ([#354](https://github.com/psviderski/uncloud/issues/354)).
|
||||
- Don't include outdated containers from a removed machine in the DNS and Caddy configs ([07acb31](https://github.com/psviderski/uncloud/commit/07acb31)).
|
||||
- Improve context cancellation and Corrosion resubscription handling during watch and sync operations ([328d175](https://github.com/psviderski/uncloud/commit/328d175), [83256b5](https://github.com/psviderski/uncloud/commit/83256b5), [99e1ae0](https://github.com/psviderski/uncloud/commit/99e1ae0), [5f07bc5](https://github.com/psviderski/uncloud/commit/5f07bc5)).
|
||||
- Use a forked Bubble Tea that drains pending TTY input on shutdown ([#286](https://github.com/psviderski/uncloud/pull/286)).
|
||||
|
||||
## Upgrade to 0.20.0
|
||||
|
||||
Upgrade **all** machines and your local CLI to 0.20. Corrosion data migrates automatically on the first daemon restart.
|
||||
|
||||
:::important
|
||||
|
||||
On the first restart, each daemon pulls the Corrosion container image `ghcr.io/unlabs-dev/corrosion:2026.6.15`. Make sure your machines can reach `ghcr.io`, or pre-pull the image, otherwise the cluster store won't start.
|
||||
|
||||
:::
|
||||
|
||||
### Uncloud CLI locally
|
||||
|
||||
```bash
|
||||
# Homebrew (macOS, Linux)
|
||||
brew upgrade uncloud
|
||||
|
||||
# Install script (macOS, Linux)
|
||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||
```
|
||||
|
||||
### Machine daemon
|
||||
|
||||
Run the following commands on each machine:
|
||||
|
||||
```bash
|
||||
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
|
||||
curl -fsSL -o uncloudd.tar.gz https://github.com/psviderski/uncloud/releases/download/v0.20.0/uncloudd_linux_${ARCH}.tar.gz
|
||||
tar -xf uncloudd.tar.gz
|
||||
sudo install uncloudd /usr/local/bin/uncloudd
|
||||
rm uncloudd uncloudd.tar.gz
|
||||
sudo systemctl restart uncloud
|
||||
```
|
||||
|
||||
### After upgrading
|
||||
|
||||
On the first restart, the daemon migrates the Corrosion store and stops the legacy systemd service. A couple of leftovers need manual cleanup on each machine:
|
||||
|
||||
- The old store is preserved at `/var/lib/uncloud/corrosion.backup-<timestamp>`. Once you've verified the cluster is healthy (`uc machine ls`), you can remove it:
|
||||
|
||||
```bash
|
||||
sudo rm -rf /var/lib/uncloud/corrosion.backup-*
|
||||
```
|
||||
|
||||
- The legacy Corrosion systemd unit and binary are left in place because the daemon's sandbox doesn't have permission to delete them. Remove them manually:
|
||||
|
||||
```bash
|
||||
sudo rm /etc/systemd/system/uncloud-corrosion.service /usr/local/bin/uncloud-corrosion
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
@@ -1,184 +0,0 @@
|
||||
---
|
||||
sidebar_position: -21.0
|
||||
sidebar_label: v0.21.0
|
||||
---
|
||||
|
||||
# v0.21.0
|
||||
|
||||
This page mirrors the [GitHub release](https://github.com/psviderski/uncloud/releases/tag/v0.21.0), which also includes
|
||||
the full list of commits.
|
||||
|
||||
This release brings **shared TLS certificate storage for Caddy**, **systemd socket activation**, **runtime templates**, and a way to use **your own cluster domain**. It also adds a certificate inventory command, faster service listing, and fixes for proxying, container logs, and terminal output.
|
||||
|
||||
## ⚠️ Breaking changes {#breaking-changes}
|
||||
|
||||
- The API socket moved from `/run/uncloud/uncloud.sock` to `/run/uncloud/api/uncloud.sock` ([f68c985](https://github.com/psviderski/uncloud/commit/f68c985)). Update your custom tools and container mounts if you used the API socket directly. Both old and new `uc` versions handle the socket move automatically.
|
||||
|
||||
## Shared certificate storage for Caddy
|
||||
|
||||
Caddy module: [caddy-uncloud](https://github.com/unlabs-dev/caddy-uncloud), issue: [#31](https://github.com/psviderski/uncloud/issues/31)
|
||||
|
||||
If you run Caddy on multiple machines behind a load balancer or a DNS record with multiple IPs, certificate issuance may not work correctly.
|
||||
|
||||
The new Uncloud storage module lets Caddy instances share certificates, private keys, and challenge tokens through the cluster store. Any instance can read the token to answer a challenge, and once one obtains a certificate, the others can use it too. Distributed locks coordinate issuance across machines.
|
||||
|
||||
Shared storage is **opt-in** for now. See [Cluster storage for Caddy](/docs/concepts/ingress/cluster-storage-for-caddy) for more details.
|
||||
|
||||
## Inspect Caddy certificates
|
||||
|
||||
Changes: [930e7ea](https://github.com/psviderski/uncloud/commit/930e7ea), [9c21ae6](https://github.com/psviderski/uncloud/commit/9c21ae6)
|
||||
|
||||
The new [`uc caddy cert ls`](/docs/cli-reference/uc_caddy_cert_ls) command lists certificates in Caddy's shared cluster storage. This is handy for checking which certificates have been issued and when they expire.
|
||||
|
||||
```shell
|
||||
uc caddy cert ls
|
||||
|
||||
ID NAME ISSUER EXPIRES
|
||||
c111e23615f7 dns.uncloud.run Let's Encrypt 2026-12-31 (2 months)
|
||||
b5dcd2a03e1b nginx.2t5ex2.uncld.dev Let's Encrypt 2026-12-31 (2 months)
|
||||
70707fd2fea2 test-staging.2t5ex2.uncld.dev Let's Encrypt (staging) 2026-12-31 (2 months)
|
||||
c5f9301e1c06 uncloud.run Let's Encrypt 2026-12-31 (2 months)
|
||||
```
|
||||
|
||||
```shell
|
||||
uc caddy cert ls --machine machine-1
|
||||
uc caddy cert ls -o json
|
||||
```
|
||||
|
||||
This requires Caddy to first be deployed with [cluster storage](/docs/concepts/ingress/cluster-storage-for-caddy).
|
||||
|
||||
## Systemd socket activation
|
||||
|
||||
Change: [f68c985](https://github.com/psviderski/uncloud/commit/f68c985)
|
||||
|
||||
Systemd now creates the Uncloud API socket and passes it to the daemon when it starts. The socket is available before Docker starts and stays in place across daemon restarts. This gives Caddy's cluster storage module and other extensions a stable API socket, including after a reboot.
|
||||
|
||||
## Runtime templates for bind mounts
|
||||
|
||||
Change: [5c33e4b](https://github.com/psviderski/uncloud/commit/5c33e4b)
|
||||
|
||||
You can now use per-container metadata in bind mount paths. Uncloud renders these Go templates on the destination machine just before creating each container, so every replica can get its own host directory:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: app:latest
|
||||
scale: 2
|
||||
volumes:
|
||||
- "/var/lib/app/{{.Container.Name}}:/data"
|
||||
```
|
||||
|
||||
For now, runtime templates expose `.Container.Name` and work only in the host and container paths of bind mounts. They could easily be extended to other metadata fields and Compose attributes. Please share your use case on [#431](https://github.com/psviderski/uncloud/issues/431).
|
||||
|
||||
See [Runtime templates](/docs/concepts/services/runtime-templates) for more details and examples.
|
||||
|
||||
## Use your own cluster domain
|
||||
|
||||
PR: [#365](https://github.com/psviderski/uncloud/pull/365). Thanks to [@miekg](https://github.com/miekg) for the contribution ❤️
|
||||
|
||||
The new [`uc dns set`](/docs/cli-reference/uc_dns_set) command lets you use a domain you manage yourself as the default cluster domain for generated service hostnames:
|
||||
|
||||
```shell
|
||||
uc dns set apps.example.com
|
||||
```
|
||||
|
||||
Configure wildcard DNS records for `*.apps.example.com` with your DNS provider, pointing to machines running Caddy. New services published without an explicit hostname can then use addresses like `web.apps.example.com`. Uncloud doesn't create or manage those external records.
|
||||
|
||||
If your cluster already has a reserved `*.uncld.dev` domain, release that reservation with `uc dns release` first.
|
||||
|
||||
## Improvements
|
||||
|
||||
- `uc ls` now collects services and containers with a single broadcast request instead of inspecting every service separately. This significantly speeds it up, especially when you have many services ([0e53f50](https://github.com/psviderski/uncloud/commit/0e53f50)).
|
||||
- Compose now supports `stdin_open` and `tty` ([#419](https://github.com/psviderski/uncloud/pull/419)). Thanks to [@miekg](https://github.com/miekg) for the contribution ❤️
|
||||
- Compose warns about more unsupported options, including external configs, `container_name`, and `deploy.placement` ([#429](https://github.com/psviderski/uncloud/pull/429)). Thanks to [@miekg](https://github.com/miekg) for the contribution ❤️
|
||||
- `uc machine init/add` now accept `UNCLOUD_DAEMON_VERSION` as an alternative to `--version`, useful for scripts and nightly setups ([#409](https://github.com/psviderski/uncloud/pull/409)). Thanks to [@tonyo](https://github.com/tonyo) for the contribution ❤️
|
||||
- Caddy deployments now have a healthcheck that queries the admin API, so the deployment waits for Caddy to load its config before treating a new container as healthy ([70de7b1](https://github.com/psviderski/uncloud/commit/70de7b1)).
|
||||
- The [installation docs](/docs/getting-started/install-cli) now include mise ([#421](https://github.com/psviderski/uncloud/pull/421)).
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Preserve custom global Caddy configuration during regeneration on Caddy restart. `uc caddy config` now warns when the last attempt to load the config failed ([ace8cbf](https://github.com/psviderski/uncloud/commit/ace8cbf)).
|
||||
- Keep `uc proxy` running when an individual client disconnects or a forwarded connection fails. Connection errors now include more useful troubleshooting information ([fa77edf](https://github.com/psviderski/uncloud/commit/fa77edf)).
|
||||
- Stream logs correctly from containers with an allocated TTY ([#419](https://github.com/psviderski/uncloud/pull/419)).
|
||||
- Stop terminal capability replies from leaking into the shell after CLI spinners finish ([#435](https://github.com/psviderski/uncloud/pull/435)). Thanks to [@tonyo](https://github.com/tonyo) for the contribution ❤️
|
||||
- Fix shell completion when using `--connect`, `--context`, or `--uncloud-config` ([351698c](https://github.com/psviderski/uncloud/commit/351698c)).
|
||||
- Return a non-zero exit code consistently when a command is cancelled by declining a confirmation prompt ([#406](https://github.com/psviderski/uncloud/pull/406)). Thanks to [@miekg](https://github.com/miekg) for the contribution ❤️
|
||||
- Extend the systemd startup timeout while pulling the Corrosion image, allowing up to 5 minutes for a slow pull ([dc721e5](https://github.com/psviderski/uncloud/commit/dc721e5)).
|
||||
- Fix daemon version comparisons when upgrading from a nightly build to the latest stable release ([50f8fbc](https://github.com/psviderski/uncloud/commit/50f8fbc)).
|
||||
- Avoid waiting on replication gaps from unavailable cluster members after the requested store versions have been reached ([e4bd1ad](https://github.com/psviderski/uncloud/commit/e4bd1ad)).
|
||||
|
||||
## Upgrade to v0.21.0
|
||||
|
||||
Upgrade your local CLI and the daemon on every machine. If you're upgrading from before v0.20.0, follow the [v0.20.0 upgrade instructions](https://github.com/psviderski/uncloud/releases/tag/v0.20.0#upgrade-to-0200) first for the Corrosion migration.
|
||||
|
||||
### Uncloud CLI locally
|
||||
|
||||
```bash
|
||||
# Homebrew (macOS, Linux)
|
||||
brew upgrade uncloud
|
||||
|
||||
# Install script (macOS, Linux)
|
||||
curl -fsS https://get.uncloud.run/install.sh | VERSION=0.21.0 sh
|
||||
```
|
||||
|
||||
### Machine daemon
|
||||
|
||||
Run the following commands over SSH on each machine to upgrade the daemon binary:
|
||||
|
||||
```bash
|
||||
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
|
||||
curl -fsSL -o uncloudd.tar.gz https://github.com/psviderski/uncloud/releases/download/v0.21.0/uncloudd_linux_${ARCH}.tar.gz
|
||||
tar -xf uncloudd.tar.gz
|
||||
sudo install uncloudd /usr/local/bin/uncloudd
|
||||
rm uncloudd uncloudd.tar.gz
|
||||
```
|
||||
|
||||
**The following systemd unit updates are only needed on existing machines.** New machines set up with `uc machine init` or `uc machine add` using v0.21.0 or later install these units automatically. Replacing the daemon binary alone on an existing machine doesn't install the new `uncloud.socket` unit or update `uncloud.service`.
|
||||
|
||||
Create `/etc/systemd/system/uncloud.socket`:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/systemd/system/uncloud.socket >/dev/null <<'EOF'
|
||||
[Unit]
|
||||
Description=Uncloud API socket
|
||||
Before=docker.service
|
||||
|
||||
[Socket]
|
||||
ListenStream=/run/uncloud/api/uncloud.sock
|
||||
SocketUser=root
|
||||
SocketGroup=uncloud
|
||||
SocketMode=0660
|
||||
DirectoryMode=0750
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
EOF
|
||||
```
|
||||
|
||||
Run this command to add the socket dependency directly to `/etc/systemd/system/uncloud.service`:
|
||||
|
||||
```bash
|
||||
sudo sed -i \
|
||||
's/^After=.*$/Requires=uncloud.socket\
|
||||
After=network-online.target uncloud.socket docker.service/' \
|
||||
/etc/systemd/system/uncloud.service
|
||||
```
|
||||
|
||||
Stop the daemon before starting the socket unit, then reload systemd, enable the socket, and start the upgraded daemon:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop uncloud.service && \
|
||||
sudo systemctl daemon-reload && \
|
||||
sudo systemctl enable uncloud.socket && \
|
||||
sudo systemctl start uncloud.socket && \
|
||||
sudo systemctl start uncloud.service
|
||||
```
|
||||
|
||||
### After upgrading
|
||||
|
||||
Check your CLI and daemon versions:
|
||||
|
||||
```shell
|
||||
uc version
|
||||
uc machine ls
|
||||
```
|
||||
@@ -156,7 +156,7 @@ Waiting for the machine to be ready...
|
||||
Reserved cluster domain: sh8hsb.uncld.dev
|
||||
[+] Deploying service caddy 2/2
|
||||
✔ Container caddy-d7uk on machine-dc3c Running 11.1s
|
||||
✔ Image ghcr.io/unlabs-dev/caddy-uncloud:0.1.3 on machine-dc3c Pulled 3.7s
|
||||
✔ Image caddy:2.11.4 on machine-dc3c Pulled 3.7s
|
||||
|
||||
Updating cluster domain records in Uncloud DNS to point to machines running caddy service...
|
||||
[+] Verifying internet access to caddy service 1/1
|
||||
@@ -216,9 +216,9 @@ uc ls
|
||||
```
|
||||
|
||||
```
|
||||
NAME MODE REPLICAS IMAGE ENDPOINTS
|
||||
caddy global 1 ghcr.io/unlabs-dev/caddy-uncloud:0.1.3
|
||||
excalidraw replicated 1 excalidraw/excalidraw:latest https://excalidraw.sh8hsb.uncld.dev → :80
|
||||
NAME MODE REPLICAS IMAGE ENDPOINTS
|
||||
caddy global 1 caddy:2.11.4
|
||||
excalidraw replicated 1 excalidraw/excalidraw:latest https://excalidraw.sh8hsb.uncld.dev → :80
|
||||
```
|
||||
|
||||
You can see `caddy` service listed here. That's your reverse proxy, running as a regular Uncloud service.
|
||||
|
||||
@@ -25,6 +25,10 @@ When you [publish a service port](2-publishing-services.md), Uncloud automatical
|
||||
For advanced use cases, Uncloud allows to customise the Caddy config using the `x-caddy` extension in Compose files.
|
||||
See [Custom Caddy configuration](2-publishing-services.md#custom-caddy-configuration) for details.
|
||||
|
||||
Uncloud configures Caddy to use cluster storage by default. It shares TLS certificates and ACME challenge tokens across
|
||||
:::tip Shared TLS certificates
|
||||
|
||||
Uncloud also provides native cluster storage for Caddy. It shares TLS certificates and ACME challenge tokens across
|
||||
machines and coordinates certificate issuance. See [Cluster storage for Caddy](4-cluster-storage-for-caddy.md)
|
||||
for details.
|
||||
to enable it.
|
||||
|
||||
:::
|
||||
|
||||
@@ -132,8 +132,8 @@ Use it when you need:
|
||||
- Advanced TLS settings.
|
||||
- Other Caddy features and plugins.
|
||||
|
||||
See [Custom global configuration](3-managing-caddy.md#custom-global-configuration) to learn how to deploy Caddy
|
||||
with a custom global config.
|
||||
See [Deploying or updating Caddy](3-managing-caddy.md#deploying-or-updating-caddy) for details on deploying Caddy with a
|
||||
custom global configuration.
|
||||
|
||||
### Templates
|
||||
|
||||
@@ -216,10 +216,6 @@ Example output:
|
||||
# Docs: https://uncloud.run/docs/concepts/ingress/overview
|
||||
|
||||
# User-defined global config from service 'caddy'.
|
||||
{
|
||||
storage uncloud
|
||||
}
|
||||
|
||||
*.example.com {
|
||||
tls {
|
||||
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||
@@ -278,7 +274,7 @@ example.com {
|
||||
The generated config combines:
|
||||
|
||||
- Global Caddy configuration (`x-caddy` from the `caddy` service).
|
||||
See [Custom global configuration](3-managing-caddy.md#custom-global-configuration) for details.
|
||||
See [Deploying or updating Caddy](3-managing-caddy.md#deploying-or-updating-caddy) for details.
|
||||
- Auto-generated configs from published service ports (`x-ports`).
|
||||
- Custom Caddy configs from services (`x-caddy`).
|
||||
- Skipped invalid configs with error messages as comments.
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
# Managing Caddy
|
||||
|
||||
Uncloud automatically deploys Caddy as a global service named `caddy` when you initialise a cluster with
|
||||
`uc machine init`. By default, it runs on every machine and routes incoming HTTP/HTTPS traffic to your services.
|
||||
Caddy is automatically deployed as a global service `caddy` when you initialise a cluster with `uc machine init`. By
|
||||
default, it runs on every machine to handle incoming HTTP/HTTPS traffic and route it to your services.
|
||||
|
||||
Since [v0.22.0](https://github.com/psviderski/uncloud/releases/tag/v0.22.0), the default Caddy deployment uses the
|
||||
Uncloud Caddy image
|
||||
[`ghcr.io/unlabs-dev/caddy-uncloud`](https://github.com/unlabs-dev/caddy-uncloud/pkgs/container/caddy-uncloud) with
|
||||
[cluster storage](4-cluster-storage-for-caddy.md) to share TLS certificates across machines. This image is a Caddy build
|
||||
that includes the [Uncloud storage module](https://github.com/unlabs-dev/caddy-uncloud) and a few other popular modules
|
||||
such as [caddy-l4](https://github.com/mholt/caddy-l4) and [cloudflare](https://github.com/caddy-dns/cloudflare).
|
||||
|
||||
:::warning
|
||||
|
||||
If you want to upgrade or redeploy Caddy on an existing cluster created with Uncloud v0.21.0 or earlier, follow
|
||||
[Upgrading from earlier versions](4-cluster-storage-for-caddy.md#upgrading-from-earlier-versions) before redeploying
|
||||
Caddy.
|
||||
|
||||
:::
|
||||
Since [v0.21.0](https://github.com/psviderski/uncloud/releases/tag/v0.21.0), Uncloud provides native
|
||||
[cluster storage for Caddy](4-cluster-storage-for-caddy.md) (opt-in) to share TLS certificates and coordinate
|
||||
certificate issuance across machines.
|
||||
|
||||
## Checking status
|
||||
|
||||
@@ -27,92 +16,55 @@ uc inspect caddy
|
||||
```
|
||||
|
||||
```
|
||||
Service ID: b5b269d5dc5ed4fdae6542894f94de82
|
||||
Name: caddy
|
||||
Mode: global
|
||||
Labels:
|
||||
uncloud.caddy.managed=
|
||||
ID: b5b269d5dc5ed4fdae6542894f94de82
|
||||
Name: caddy
|
||||
Mode: global
|
||||
|
||||
CONTAINER ID IMAGE CREATED STATUS IP ADDRESS MACHINE
|
||||
fb8f390e634d ghcr.io/unlabs-dev/caddy-uncloud:0.1.4 3 weeks ago Up 3 weeks 10.210.1.2 prod-ap1
|
||||
0182f5d7bd9f ghcr.io/unlabs-dev/caddy-uncloud:0.1.4 3 weeks ago Up 3 weeks 10.210.0.2 prod-us1
|
||||
```
|
||||
|
||||
## Verifying config
|
||||
|
||||
View the complete generated Caddyfile that the `caddy` service uses. It includes global configuration, sites for
|
||||
published service ports, custom service configs, and comments explaining any skipped invalid configs. It also warns if
|
||||
the last config load failed. Use `--machine NAME` to check a specific machine.
|
||||
|
||||
```shell
|
||||
uc caddy config
|
||||
```
|
||||
|
||||
See [Verifying Caddy config](2-publishing-services.md#verifying-caddy-config) for an example output and details on how
|
||||
it's generated.
|
||||
|
||||
## Listing certificates
|
||||
|
||||
List the issued certificates in cluster storage:
|
||||
|
||||
```shell
|
||||
uc caddy cert ls
|
||||
```
|
||||
|
||||
```
|
||||
ID NAME ISSUER EXPIRES
|
||||
c111e23615f7 dns.uncloud.run Let's Encrypt 2026-12-31 (2 months)
|
||||
b5dcd2a03e1b nginx.2t5ex2.uncld.dev Let's Encrypt 2026-12-31 (2 months)
|
||||
70707fd2fea2 test-staging.2t5ex2.uncld.dev Let's Encrypt (staging) 2026-12-31 (2 months)
|
||||
c5f9301e1c06 uncloud.run Let's Encrypt 2026-12-31 (2 months)
|
||||
CONTAINER ID IMAGE CREATED STATUS MACHINE
|
||||
fb8f390e634d caddy:2.10.0 3 weeks ago Up 3 weeks prod-ap1
|
||||
0182f5d7bd9f caddy:2.10.0 3 months ago Up 3 weeks prod-us1
|
||||
```
|
||||
|
||||
## Deploying or updating Caddy
|
||||
|
||||
Deploy Caddy or upgrade it to the latest version of the Uncloud Caddy image:
|
||||
### Using CLI
|
||||
|
||||
Update to the latest stable version using the [caddy](https://hub.docker.com/_/caddy) image from Docker Hub:
|
||||
|
||||
```shell
|
||||
uc caddy deploy
|
||||
```
|
||||
|
||||
The command shows a deployment plan and asks for confirmation, then performs a rolling update.
|
||||
|
||||
You can set a specific image, global Caddyfile, or limit deployment to specific machines:
|
||||
Deploy a specific version or custom image:
|
||||
|
||||
```shell
|
||||
# Deploy a specific version or custom image and keep it for later deployments.
|
||||
uc caddy deploy --image ghcr.io/unlabs-dev/caddy-uncloud:0.1.4
|
||||
|
||||
# Deploy with a custom global Caddy config and only on selected machines.
|
||||
uc caddy deploy --caddyfile global.Caddyfile --machine machine1,machine2
|
||||
uc caddy deploy --image caddybuilds/caddy-cloudflare:2.11.4
|
||||
```
|
||||
|
||||
The command remembers these values for later deployments. Pass an empty value to reset a flag to its default:
|
||||
Deploy only to a specific machine or a subset of machines (comma-separated list):
|
||||
|
||||
```shell
|
||||
uc caddy deploy --image "" --caddyfile "" --machine ""
|
||||
uc caddy deploy --machine machine1
|
||||
uc caddy deploy --machine machine2,machine3,machine4
|
||||
```
|
||||
|
||||
See [`uc caddy deploy`](../../9-cli-reference/uc_caddy_deploy.md) for all flags and examples.
|
||||
Deploy with custom global configuration:
|
||||
|
||||
## Custom global configuration
|
||||
```shell
|
||||
uc caddy deploy --caddyfile global.Caddyfile
|
||||
```
|
||||
|
||||
A custom Caddyfile replaces the default global config. Uncloud combines this config with the sites generated from
|
||||
[published ports](2-publishing-services.md) and
|
||||
[custom service configs](2-publishing-services.md#custom-caddy-configuration). Include `storage uncloud` to keep using
|
||||
[cluster storage](4-cluster-storage-for-caddy.md):
|
||||
Example global configuration:
|
||||
|
||||
```caddyfile title="global.Caddyfile"
|
||||
```caddyfile title=global.Caddyfile
|
||||
# Global options.
|
||||
{
|
||||
storage uncloud
|
||||
# Uncomment for troubleshooting:
|
||||
# debug
|
||||
debug
|
||||
}
|
||||
|
||||
# A snippet that can be reused in custom Caddy configs for services (x-caddy).
|
||||
(my_snippet) {
|
||||
# ...
|
||||
...
|
||||
}
|
||||
|
||||
# Expose an internal service that is not managed by Uncloud.
|
||||
@@ -121,29 +73,10 @@ internal.example.com {
|
||||
}
|
||||
```
|
||||
|
||||
Deploy your custom global Caddy config:
|
||||
### Using Compose
|
||||
|
||||
```shell
|
||||
uc caddy deploy --caddyfile global.Caddyfile
|
||||
```
|
||||
|
||||
## Custom image
|
||||
|
||||
A custom image must include the [Uncloud storage module](https://github.com/unlabs-dev/caddy-uncloud) to use cluster
|
||||
storage. It must also include `curl` for the health check and start Caddy with `/etc/caddy/Caddyfile`.
|
||||
|
||||
The default Uncloud Caddy image
|
||||
[`ghcr.io/unlabs-dev/caddy-uncloud`](https://github.com/unlabs-dev/caddy-uncloud/pkgs/container/caddy-uncloud) satisfies
|
||||
these requirements. See the [release notes](https://github.com/unlabs-dev/caddy-uncloud/releases/latest) for the Caddy
|
||||
version and the list of included modules.
|
||||
|
||||
If you need other Caddy modules or want to choose a compatible Caddy version, you can
|
||||
[build and deploy your own image](https://github.com/unlabs-dev/caddy-uncloud#building-and-deploying-a-custom-image).
|
||||
|
||||
## Managing Caddy with Compose
|
||||
|
||||
Use Compose when you need more control, such as environment variables for a DNS provider. This example uses the
|
||||
Cloudflare module included in the default image to obtain a wildcard certificate for `*.example.com`:
|
||||
You can manage the Caddy deployment with a Compose file for more control. For example, to deploy a custom global Caddy
|
||||
config that uses the DNS challenge with Cloudflare to obtain a wildcard TLS certificate for `*.example.com`:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="compose.yaml">
|
||||
@@ -151,11 +84,13 @@ Cloudflare module included in the default image to obtain a wildcard certificate
|
||||
```yaml
|
||||
services:
|
||||
caddy:
|
||||
image: ghcr.io/unlabs-dev/caddy-uncloud:0.1.4
|
||||
image: caddybuilds/caddy-cloudflare:2.11.4
|
||||
environment:
|
||||
# unix// is not a typo. Caddy uses network/address format, not a unix:// URL.
|
||||
CADDY_ADMIN: unix//run/caddy/admin.sock
|
||||
CLOUDFLARE_API_TOKEN: secret://cloudflare_api_token
|
||||
env_file:
|
||||
# Contains CLOUDFLARE_API_TOKEN=xxxxx
|
||||
- .env.secrets
|
||||
healthcheck:
|
||||
test: curl -fsS -o /dev/null --unix-socket /run/caddy/admin.sock http://localhost/config/
|
||||
interval: 30s
|
||||
@@ -167,26 +102,22 @@ services:
|
||||
- /var/lib/uncloud/caddy:/etc/caddy:ro
|
||||
# Persists TLS certificates and other assets when using Caddy's default local storage.
|
||||
- /var/lib/uncloud/caddy:/data
|
||||
# Uncloud API socket is required by the cluster storage module (https://github.com/unlabs-dev/caddy-uncloud).
|
||||
- /run/uncloud/caddy:/run/caddy
|
||||
# Required by the Uncloud cluster storage module (https://github.com/unlabs-dev/caddy-uncloud).
|
||||
# Remove this mount if cluster storage is not used for Caddy.
|
||||
# Mount the directory, not the socket file, so Caddy sees a replacement socket after the daemon restarts.
|
||||
- /run/uncloud/api:/run/uncloud/api:ro
|
||||
- /run/uncloud/caddy:/run/caddy
|
||||
x-ports:
|
||||
- 80:80@host
|
||||
- 443:443@host
|
||||
- 443:443/udp@host
|
||||
x-caddy: ./Caddyfile
|
||||
x-caddy: Caddyfile
|
||||
deploy:
|
||||
mode: global
|
||||
# Optional: deploy only to specific machines.
|
||||
# x-machines:
|
||||
# - machine1
|
||||
# - machine2
|
||||
|
||||
secrets:
|
||||
cloudflare_api_token:
|
||||
# Read the secret value from 1Password by running this command.
|
||||
x-command: op read "op://prod/caddy/cloudflare_api_token"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
@@ -195,34 +126,144 @@ secrets:
|
||||
```caddyfile
|
||||
# Global options.
|
||||
{
|
||||
storage uncloud
|
||||
debug
|
||||
}
|
||||
|
||||
# Obtain a wildcard TLS certificate for all subdomains of example.com using DNS challenge with Cloudflare.
|
||||
# It will be used for services that publish ports with hostnames under example.com.
|
||||
# A snippet that can be reused in custom Caddy configs for services (x-caddy).
|
||||
(my_snippet) {
|
||||
...
|
||||
}
|
||||
|
||||
# Obtain a wildcard TLS certificate for all subdomains of example.name using DNS challenge with Cloudflare.
|
||||
# It will be used for services that publish ports with hostnames under example.name.
|
||||
*.example.com {
|
||||
tls {
|
||||
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||
}
|
||||
respond "No host matched" 404
|
||||
tls {
|
||||
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||
}
|
||||
respond "No host matched" 404
|
||||
}
|
||||
|
||||
# Expose an internal service that is not managed by Uncloud.
|
||||
internal.example.com {
|
||||
reverse_proxy 192.168.1.100
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::info note
|
||||
|
||||
The specified `environment`, `volumes`, and `x-ports` properties are essential for Caddy to function correctly in the
|
||||
Uncloud cluster. Do not change the source paths of the volume mounts as the Uncloud daemon relies on them to communicate
|
||||
with Caddy and update its configuration.
|
||||
|
||||
The image must include `curl` for the healthcheck and start Caddy with `/etc/caddy/Caddyfile`. Images based on the
|
||||
official [Caddy image](https://hub.docker.com/_/caddy) do both by default.
|
||||
|
||||
Keep the `/data` mount while using Caddy's default local storage so TLS certificates survive container updates. You can
|
||||
remove this mount when using [cluster storage for Caddy](4-cluster-storage-for-caddy.md).
|
||||
|
||||
:::
|
||||
|
||||
Deploy or update the `caddy` service from the Compose file:
|
||||
|
||||
```shell
|
||||
uc deploy
|
||||
```
|
||||
|
||||
:::info note
|
||||
## Verifying config
|
||||
|
||||
Keep the `environment`, `volumes`, and `x-ports` properties as shown, including the source paths of the volume mounts.
|
||||
The Uncloud daemon and the storage module rely on them.
|
||||
View the complete generated Caddyfile served by the `caddy` service. This is useful for debugging and verifying custom
|
||||
global and service-specific Caddy configs.
|
||||
|
||||
:::
|
||||
```shell
|
||||
uc caddy config
|
||||
```
|
||||
|
||||
Continue using `uc deploy` for a Compose-managed Caddy service. Switching to `uc caddy deploy` replaces its
|
||||
configuration, including environment variables and custom mounts.
|
||||
Example output:
|
||||
|
||||
```caddyfile
|
||||
# Caddyfile autogenerated by Uncloud (DO NOT EDIT): 2025-12-22T10:30:12Z
|
||||
# Automatically updated on service or health status changes.
|
||||
# Docs: https://uncloud.run/docs/concepts/ingress/overview
|
||||
|
||||
# User-defined global config from service 'caddy'.
|
||||
# Global options.
|
||||
{
|
||||
debug
|
||||
}
|
||||
|
||||
# A snippet that can be reused in custom Caddy configs for services (x-caddy).
|
||||
(my_snippet) {
|
||||
...
|
||||
}
|
||||
|
||||
# Obtain a wildcard TLS certificate for all subdomains of example.name using DNS challenge with Cloudflare.
|
||||
# It will be used for services that publish ports with hostnames under example.name.
|
||||
*.example.com {
|
||||
tls {
|
||||
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||
}
|
||||
respond "No host matched" 404
|
||||
}
|
||||
|
||||
# Expose an internal service that is not managed by Uncloud.
|
||||
internal.example.com {
|
||||
reverse_proxy 192.168.1.100
|
||||
}
|
||||
|
||||
# Health check endpoint to verify Caddy reachability on this machine.
|
||||
http:// {
|
||||
handle /.uncloud-verify {
|
||||
respond "a369b9388812f9557feef6a0f5b46f2e" 200
|
||||
}
|
||||
log
|
||||
}
|
||||
|
||||
(common_proxy) {
|
||||
# Retry failed requests up to lb_retries times against other available upstreams.
|
||||
lb_retries 3
|
||||
# Upstreams are marked unhealthy for fail_duration after a failed request (passive health checking).
|
||||
fail_duration 30s
|
||||
}
|
||||
|
||||
# Sites generated from service ports.
|
||||
|
||||
https://app.example.com {
|
||||
reverse_proxy 10.210.1.3:8000 10.210.2.5:8000 {
|
||||
import common_proxy
|
||||
}
|
||||
log
|
||||
}
|
||||
|
||||
https://api.example.com {
|
||||
reverse_proxy 10.210.2.2:9000 10.210.1.7:9000 10.210.2.3:9000 {
|
||||
import common_proxy
|
||||
}
|
||||
log
|
||||
}
|
||||
|
||||
# User-defined config for service 'web'.
|
||||
www.example.com {
|
||||
redir https://example.com{uri} permanent
|
||||
}
|
||||
|
||||
example.com {
|
||||
reverse_proxy 10.210.0.3:8000 {
|
||||
import common_proxy
|
||||
}
|
||||
log
|
||||
}
|
||||
|
||||
# Skipped invalid user-defined configs:
|
||||
# - service 'duplicate-hostname': validation failed: adapting config using caddyfile adapter: ambiguous site definition: example.com
|
||||
# - service 'invalid': validation failed: adapting config using caddyfile adapter: Caddyfile:61: unrecognized directive: invalid_directive
|
||||
```
|
||||
|
||||
The generated config combines:
|
||||
|
||||
- Global Caddy configuration (`x-caddy` from the `caddy` service).
|
||||
- Auto-generated configs from published service ports (`x-ports`).
|
||||
- Custom Caddy configs from services (`x-caddy`).
|
||||
- Skipped invalid configs with error messages as comments.
|
||||
|
||||
@@ -1,120 +1,73 @@
|
||||
# Cluster storage for Caddy
|
||||
|
||||
Starting with Uncloud [v0.22.0](https://github.com/psviderski/uncloud/releases/tag/v0.22.0), Caddy uses cluster storage
|
||||
by default.
|
||||
|
||||
The [Uncloud storage module](https://github.com/unlabs-dev/caddy-uncloud) lets Caddy instances share TLS certificates,
|
||||
private keys, and ACME challenge tokens through Uncloud's cluster store.
|
||||
private keys, and ACME challenge tokens through Uncloud's cluster store. It uses distributed locks to coordinate
|
||||
certificate issuance. Once one instance obtains a certificate, the others can use it too.
|
||||
|
||||
This matters when you run Caddy on multiple machines and distribute traffic between them using DNS or a load balancer.
|
||||
In this case, an ACME challenge can reach a different Caddy instance from the one requesting the certificate. Cluster
|
||||
storage lets any Caddy instance read the challenge token from the shared cluster store and complete the ACME challenge.
|
||||
It uses distributed locks to coordinate certificate issuance. Once one instance obtains a certificate, the others can
|
||||
use it too.
|
||||
## Why use cluster storage?
|
||||
|
||||
## Upgrading from earlier versions
|
||||
By default, each Caddy instance stores its certificates locally. When DNS points to multiple machines or a load balancer
|
||||
distributes traffic between them, an ACME challenge can reach a different instance from the one requesting the
|
||||
certificate. That instance may not have the challenge token, which can delay or prevent certificate issuance.
|
||||
|
||||
Starting with Uncloud v0.22.0, `uc caddy deploy` deploys the Uncloud Caddy image ([
|
||||
`ghcr.io/unlabs-dev/caddy-uncloud`](https://github.com/unlabs-dev/caddy-uncloud/pkgs/container/caddy-uncloud)) with
|
||||
cluster storage enabled by default.
|
||||
Cluster storage is optional. The default Caddy image does not include the module, so you need to deploy an image that
|
||||
includes it and configure Caddy to use it.
|
||||
|
||||
Earlier versions deployed the official [`caddy`](https://hub.docker.com/_/caddy) image with local certificate storage.
|
||||
Upgrading Uncloud does not change the running Caddy service. Redeploy it to switch to the new default image and cluster
|
||||
storage.
|
||||
## Enabling cluster storage
|
||||
|
||||
Before you deploy, upgrade the [`uc` CLI](../../2-getting-started/1-install-cli.md) and the daemon on all machines to
|
||||
v0.22.0 or newer. Follow the [upgrade instructions](https://github.com/psviderski/uncloud/releases/latest) for daemon
|
||||
upgrades, including any required migrations from older releases. Check current versions with `uc version` and
|
||||
`uc machine ls`.
|
||||
:::warning Certificate migration
|
||||
|
||||
The first `uc caddy deploy` after the upgrade doesn't know the values you set with earlier versions of `uc`. It replaces
|
||||
your image, global config, and machine list with the defaults unless you pass them as flags. If you deployed Caddy only
|
||||
to some machines, add `--machine` with the same machines to every `uc caddy deploy` command below.
|
||||
Caddy doesn't migrate existing certificates automatically when you change its storage backend. **All certificates will
|
||||
need to be reissued unless you migrate your existing storage first.** Use the experimental
|
||||
[`caddy storage export` and `caddy storage import`](https://caddyserver.com/docs/command-line#caddy-storage)
|
||||
commands with the old and new configs to transfer the storage contents.
|
||||
|
||||
If you already use `storage uncloud`, your certificates stay where they are. Run `uc caddy deploy` and pass the global
|
||||
config you deployed it with, unless it only contains `storage uncloud`:
|
||||
:::
|
||||
|
||||
```shell
|
||||
uc caddy deploy --caddyfile global.Caddyfile
|
||||
:::info Requirements
|
||||
|
||||
Cluster storage requires Uncloud **v0.21.0 or newer** for both the `uc` CLI and the daemon on every cluster machine.
|
||||
Check your CLI version with `uc version` and daemon versions with `uc machine ls`. Upgrade older versions before
|
||||
enabling cluster storage.
|
||||
|
||||
:::
|
||||
|
||||
Create a global Caddyfile, or add `storage uncloud` to the global options in your existing one:
|
||||
|
||||
```caddyfile title="global.Caddyfile"
|
||||
{
|
||||
storage uncloud
|
||||
|
||||
# Uncomment to enable debug logs useful for troubleshooting storage operations:
|
||||
# debug
|
||||
}
|
||||
```
|
||||
|
||||
### Quick upgrade
|
||||
|
||||
Caddy does not copy certificates when you change storage backends. Unless you migrate them first, Caddy will reissue
|
||||
them. HTTPS may fail for those domains until that finishes, and a large number of certificates can hit
|
||||
[Let's Encrypt rate limits](https://letsencrypt.org/docs/rate-limits/).
|
||||
|
||||
Run the command below if you don't mind reissuing all certificates:
|
||||
Deploy Caddy with the pre-built module image and your global config:
|
||||
|
||||
```shell
|
||||
uc caddy deploy
|
||||
uc caddy deploy --image ghcr.io/unlabs-dev/caddy-uncloud:0.1.3 --caddyfile global.Caddyfile
|
||||
```
|
||||
|
||||
### Migrating certificates
|
||||
See the [module README](https://github.com/unlabs-dev/caddy-uncloud#usage) for custom image builds, Compose deployment,
|
||||
and additional storage options.
|
||||
|
||||
Follow these steps to keep your existing certificates and avoid HTTPS failures:
|
||||
## Verifying storage
|
||||
|
||||
1. Deploy the new image but stay on local storage. Use your existing global config if you have one, or an empty file:
|
||||
Check that `storage uncloud` appears in the Caddy config:
|
||||
|
||||
```shell
|
||||
touch local.Caddyfile
|
||||
uc caddy deploy --caddyfile local.Caddyfile
|
||||
```
|
||||
```shell
|
||||
uc caddy config
|
||||
```
|
||||
|
||||
2. Find the ID of the Caddy container on each machine:
|
||||
List issued certificates in cluster storage with [`uc caddy cert ls`](../../9-cli-reference/uc_caddy_cert_ls.md):
|
||||
|
||||
```shell
|
||||
uc inspect caddy
|
||||
```
|
||||
```shell
|
||||
uc caddy cert ls
|
||||
|
||||
3. Copy the certificates to cluster storage by running this command for each container ID. Each machine stores its own
|
||||
certificates, so don't skip any of them. These
|
||||
[`caddy storage`](https://caddyserver.com/docs/command-line#caddy-storage) commands are experimental.
|
||||
|
||||
```shell
|
||||
uc exec -T --container CONTAINER_ID caddy sh -c '
|
||||
printf "{\n\tstorage uncloud\n}\n" > /tmp/Caddyfile.cluster
|
||||
caddy storage export --config /etc/caddy/Caddyfile --output - |
|
||||
caddy storage import --config /tmp/Caddyfile.cluster --input -
|
||||
rm /tmp/Caddyfile.cluster
|
||||
'
|
||||
```
|
||||
|
||||
4. Check that all your certificates are in cluster storage:
|
||||
|
||||
```shell
|
||||
uc caddy cert ls
|
||||
```
|
||||
|
||||
5. Switch Caddy to cluster storage. If you have a custom global config, add `storage uncloud` to it and pass it with
|
||||
`--caddyfile global.Caddyfile` instead. The command warns that all certificates will be reissued. You can ignore this
|
||||
because you've already copied them.
|
||||
|
||||
```shell
|
||||
uc caddy deploy --caddyfile ""
|
||||
```
|
||||
|
||||
6. Check that the Caddy config includes `storage uncloud` and doesn't show any config load warnings:
|
||||
|
||||
```shell
|
||||
uc caddy config
|
||||
```
|
||||
|
||||
### Upgrading a Compose deployment
|
||||
|
||||
If you manage Caddy with Compose, keep using `uc deploy` and update your Compose file to match the
|
||||
[Compose example](3-managing-caddy.md#managing-caddy-with-compose). To keep your certificates, follow the
|
||||
[migration steps](#migrating-certificates) with these changes:
|
||||
|
||||
- In step 1, update the image and add the `/run/uncloud/api:/run/uncloud/api:ro` volume mount but leave out
|
||||
`storage uncloud` in your global Caddy config (`x-caddy`). Then run `uc deploy`.
|
||||
- In step 5, add `storage uncloud` to your global Caddy config, then run `uc deploy`.
|
||||
|
||||
## Keeping local storage
|
||||
|
||||
To keep local storage, pass a custom Caddyfile without `storage uncloud` using `--caddyfile`. The default Uncloud Caddy
|
||||
image supports local storage too. If you use the official `caddy` image, you must also provide a Caddyfile without
|
||||
`storage uncloud`.
|
||||
|
||||
Deployments using `uc caddy deploy` keep the `/data` mount so local certificates survive container updates. For Compose
|
||||
deployments using local storage, keep or add `/var/lib/uncloud/caddy:/data`.
|
||||
ID NAME ISSUER EXPIRES
|
||||
c111e23615f7 dns.uncloud.run Let's Encrypt 2026-12-31 (2 months)
|
||||
b5dcd2a03e1b nginx.2t5ex2.uncld.dev Let's Encrypt 2026-12-31 (2 months)
|
||||
70707fd2fea2 test-staging.2t5ex2.uncld.dev Let's Encrypt (staging) 2026-12-31 (2 months)
|
||||
c5f9301e1c06 uncloud.run Let's Encrypt 2026-12-31 (2 months)
|
||||
```
|
||||
|
||||
@@ -10,16 +10,16 @@ If you rely on a specific Compose feature that is not supported by Uncloud, plea
|
||||
|
||||
:::
|
||||
|
||||
| Feature | Support Status | Notes |
|
||||
|----------------------------------|---------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Services** | | |
|
||||
| Feature | Support Status | Notes |
|
||||
|----------------------------------|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Services** | | |
|
||||
| `build` | ✅ Supported | Build context and Dockerfile |
|
||||
| `cap_add` | ✅ Supported | Additional kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) |
|
||||
| `cap_drop` | ✅ Supported | Which kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) to drop |
|
||||
| `command` | ✅ Supported | Override container command |
|
||||
| `configs` | ✅ Supported | File-based and inline configs |
|
||||
| `cpus` | ✅ Supported | CPU limit |
|
||||
| `depends_on` | ⚠️ Limited | Deployed in dependency order. Use [pre-deploy hooks](../4-guides/1-deployments/5-pre-deploy-hooks.md) for `service_completed_successfully` |
|
||||
| `depends_on` | ⚠️ Limited | Deployed in dependency order. Use [pre-deploy hooks](../4-guides/1-deployments/5-pre-deploy-hooks.md) for `service_completed_successfully` |
|
||||
| `devices` | ✅ Supported | Device mappings |
|
||||
| `dns` | ❌ Not supported | Built-in service discovery |
|
||||
| `dns_search` | ❌ Not supported | Built-in service discovery |
|
||||
@@ -30,7 +30,7 @@ If you rely on a specific Compose feature that is not supported by Uncloud, plea
|
||||
| `healthcheck` | ✅ Supported | Health check configuration |
|
||||
| `image` | ✅ Supported | Container image specification |
|
||||
| `init` | ✅ Supported | Run init process in container |
|
||||
| `labels` | ✅ Supported | Docker labels on containers |
|
||||
| `labels` | ❌ Not supported | |
|
||||
| `links` | ❌ Not supported | Use service names for communication |
|
||||
| `logging` | ✅ Supported | Defaults to [local](https://docs.docker.com/engine/logging/drivers/local/) log driver |
|
||||
| `mem_limit` | ✅ Supported | Memory limit |
|
||||
@@ -39,10 +39,10 @@ If you rely on a specific Compose feature that is not supported by Uncloud, plea
|
||||
| `memswap_limit` | ❌ Not supported | |
|
||||
| `networks` | ❌ Not supported | All containers share cluster network |
|
||||
| `pid` | ✅ Supported | Set the PID namespace mode, `pid: host` only |
|
||||
| `ports` | ⚠️ Limited | `mode: host` only, use [`x-ports`](2-extensions.md#x-ports) for HTTP/HTTPS |
|
||||
| `ports` | ⚠️ Limited | `mode: host` only, use [`x-ports`](2-extensions.md#x-ports) for HTTP/HTTPS |
|
||||
| `privileged` | ✅ Supported | Run containers in privileged mode |
|
||||
| `pull_policy` | ✅ Supported | `always`, `missing`, `never` |
|
||||
| `secrets` | ⚠️ Limited | Reference secrets in `environment`, see [Secrets](../3-concepts/8-secrets.md). File mounts not supported |
|
||||
| `secrets` | ⚠️ Limited | Reference secrets in `environment`, see [Secrets](../3-concepts/8-secrets.md). File mounts not supported |
|
||||
| `security_opt` | ❌ Not supported | |
|
||||
| `shm_size` | ✅ Supported | Shared memory size |
|
||||
| `stdin_open` | ✅ Supported | Allocate standard input and keep it open |
|
||||
@@ -53,28 +53,28 @@ If you rely on a specific Compose feature that is not supported by Uncloud, plea
|
||||
| `ulimits` | ✅ Supported | Resource limits |
|
||||
| `user` | ✅ Supported | Set container user |
|
||||
| `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs |
|
||||
| **Deploy** | | |
|
||||
| `labels` | ✅ Supported | Service labels, not applied to containers. Use `services.<name>.labels` to set Docker labels on containers |
|
||||
| **Deploy** | | |
|
||||
| `labels` | ❌ Not supported | |
|
||||
| `mode` | ✅ Supported | Either `global` or `replicated` |
|
||||
| `placement` | ❌ Not supported | Use [`x-machines`](2-extensions.md#x-machines) extension |
|
||||
| `replicas` | ✅ Supported | Number of container replicas |
|
||||
| `resources` | ⚠️ Limited | CPU, memory limits and device reservations |
|
||||
| `resources` | ⚠️ Limited | CPU, memory limits and device reservations |
|
||||
| `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` |
|
||||
| `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) |
|
||||
| `update_config` | ⚠️ Limited | `order` and `monitor` supported. See [rolling deployments](../4-guides/1-deployments/4-rolling-deployments.md) |
|
||||
| **Volumes** | | |
|
||||
| `update_config` | ⚠️ Limited | `order` and `monitor` supported. See [rolling deployments](../4-guides/1-deployments/4-rolling-deployments.md) |
|
||||
| **Volumes** | | |
|
||||
| Named volumes | ✅ Supported | Docker volumes |
|
||||
| Bind mounts | ✅ Supported | Host path binding with optional [runtime templates](../3-concepts/6-services/3-runtime-templates.md) |
|
||||
| Tmpfs mounts | ✅ Supported | In-memory filesystems |
|
||||
| Volume labels | ✅ Supported | Custom labels |
|
||||
| External volumes | ✅ Supported | Must exist before deployment |
|
||||
| [Volume drivers][volume-drivers] | ✅ Supported | `local` (supports [NFS][volume-nfs], [CIFS/Samba][volume-cifs]) and manually installed third-party drivers |
|
||||
| **Configs** | | |
|
||||
| **Configs** | | |
|
||||
| File-based configs | ✅ Supported | Read from file |
|
||||
| Inline configs | ✅ Supported | Defined in compose file |
|
||||
| External configs | ❌ Not supported | Not supported |
|
||||
| Short syntax | ❌ Not supported | Use long syntax only |
|
||||
| **Extensions** | | |
|
||||
| **Extensions** | | |
|
||||
| `x-context` | ✅ Uncloud-specific | Cluster context override |
|
||||
| `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration |
|
||||
| `x-machines` | ✅ Uncloud-specific | Machine placement constraints |
|
||||
|
||||
@@ -6,14 +6,11 @@ List certificates in cluster storage for Caddy.
|
||||
|
||||
List certificates stored in the Uncloud cluster storage for Caddy.
|
||||
|
||||
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.
|
||||
Caddy must use the Uncloud storage module (https://github.com/unlabs-dev/caddy-uncloud)
|
||||
configured with 'storage uncloud' in the global options for its certificates to appear here.
|
||||
|
||||
This inventory does not check whether Caddy currently serves or trusts a certificate.
|
||||
|
||||
See https://uncloud.run/docs/concepts/ingress/cluster-storage-for-caddy for details.
|
||||
|
||||
```
|
||||
uc caddy cert ls [flags]
|
||||
```
|
||||
|
||||
@@ -7,59 +7,17 @@ Deploy or upgrade Caddy reverse proxy across all machines in the cluster.
|
||||
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 0.22.0 or newer on all machines.
|
||||
|
||||
```
|
||||
uc caddy deploy [flags]
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
# 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 ""
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
```
|
||||
--caddyfile string Path to a custom global Caddy config (Caddyfile) that will be prepended to the auto-generated Caddy config.
|
||||
An empty value resets it to the default config that enables cluster storage.
|
||||
(default is the current custom config or the default config)
|
||||
-h, --help help for deploy
|
||||
--image string Caddy Docker image to deploy. It must include the Uncloud storage module if the Caddy config uses it.
|
||||
An empty value resets it to the latest version of the Uncloud Caddy image.
|
||||
(default is the current custom image or ghcr.io/unlabs-dev/caddy-uncloud:LATEST_VERSION)
|
||||
-m, --machine strings Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated list.
|
||||
An empty value resets it to all machines.
|
||||
(default is the current machines or all machines)
|
||||
--image string Caddy Docker image to deploy. (default caddy:LATEST_VERSION)
|
||||
-m, --machine strings Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated list. (default is all machines)
|
||||
```
|
||||
|
||||
## Options inherited from parent commands
|
||||
|
||||
@@ -22,5 +22,4 @@ Manage images on machines in the cluster.
|
||||
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes.
|
||||
* [uc image ls](uc_image_ls.md) - List images on machines in the cluster.
|
||||
* [uc image push](uc_image_push.md) - Upload a local Docker image to the cluster.
|
||||
* [uc image rm](uc_image_rm.md) - Remove images from machines in the cluster.
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# uc image rm
|
||||
|
||||
Remove images from machines in the cluster.
|
||||
|
||||
## Synopsis
|
||||
|
||||
Remove images from all or selected machines in the cluster.
|
||||
|
||||
An image name without a tag selects all its tags. Specify a tag, such as myapp:latest, to remove
|
||||
only that tag.
|
||||
|
||||
Wildcard patterns are supported for image names and tags and should be quoted to prevent your
|
||||
shell from expanding them. Removing by image ID attempts to remove each name for that image
|
||||
independently.
|
||||
|
||||
Images used by running or stopped containers cannot be removed. Unused tags can be removed even
|
||||
when the same image is used under another tag. Failed removals do not prevent other removals.
|
||||
|
||||
```
|
||||
uc image rm IMAGE [IMAGE...] [flags]
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
# Remove a tag from all machines after confirming the plan.
|
||||
uc image rm myapp:old
|
||||
|
||||
# Remove all tags for an image name from all machines.
|
||||
uc image rm myapp
|
||||
|
||||
# Remove matching tags from two machines without confirmation.
|
||||
uc image rm 'myapp:1.*' 'otherapp:*' -m machine1,machine2 --yes
|
||||
|
||||
# Remove all names for an image by ID from all machines.
|
||||
uc image rm ee8fed0e9618
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
```
|
||||
-h, --help help for rm
|
||||
-m, --machine strings Machine names or IDs to remove images from. Can be specified multiple times or as a comma-separated list.
|
||||
If not specified, the matching images will be removed from all machines.
|
||||
-y, --yes Auto-confirm the removal plan. Should be explicitly set when running non-interactively,
|
||||
e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]
|
||||
```
|
||||
|
||||
## Options inherited from parent commands
|
||||
|
||||
```
|
||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||
Format: [ssh://]user@host[:port], ssh+go://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock
|
||||
-c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]
|
||||
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
* [uc image](uc_image.md) - Manage images on machines in the cluster.
|
||||
|
||||
@@ -9,56 +9,49 @@ uc run IMAGE [COMMAND...] [flags]
|
||||
## Options
|
||||
|
||||
```
|
||||
--caddyfile string Path to a custom Caddy config (Caddyfile) for the service. Cannot be used together with non-@host published ports.
|
||||
--container-label stringArray Set a Docker label on service containers. Can be specified multiple times.
|
||||
Format: key=value or just key for an empty value.
|
||||
--cpu decimal 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.
|
||||
--entrypoint string Overwrite the default ENTRYPOINT of the image. Pass an empty string "" to reset it.
|
||||
-e, --env strings Set an environment variable for service containers. Can be specified multiple times.
|
||||
Format: VAR=value or just VAR to use the value from the local environment.
|
||||
-h, --help help for run
|
||||
-l, --label stringArray Set a service label. Service labels aren't applied to Docker containers, use --container-label for that.
|
||||
Can be specified multiple times. Format: key=value or just key for an empty value.
|
||||
-m, --machine strings Placement constraint by machine names, limiting which machines the service can run on. Can be specified multiple times or as a comma-separated list of machine names. (default is any suitable machine)
|
||||
--memory bytes Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).
|
||||
Default unit is bytes if no suffix specified.
|
||||
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
|
||||
--memory-reservation bytes Minimum amount of memory reserved for a service container. Value is a positive integer with optional unit suffix (b, k, m, g).
|
||||
Default unit is bytes if no suffix specified.
|
||||
--mode string Replication mode of the service: either 'replicated' (a specified number of containers across the machines) or 'global' (one container on every machine). (default "replicated")
|
||||
-n, --name string Assign a name to the service. A random name is generated if not specified.
|
||||
--privileged Give extended privileges to service containers. This is a security risk and should be used with caution.
|
||||
-p, --publish strings Publish a service port to make it accessible outside the cluster. Can be specified multiple times.
|
||||
Format: [hostname:]container_port[/protocol] or [host_ip|host_prefix:]host_port:container_port[/protocol]@host
|
||||
Supported protocols: tcp, udp, http, https. The protocol defaults to https if a hostname is specified,
|
||||
otherwise tcp. http(s) ports without a hostname use 'service-name.cluster-domain' as the default hostname
|
||||
if a cluster domain is reserved.
|
||||
Examples:
|
||||
-p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname
|
||||
-p app.example.com:8080 Publish port 8080 as HTTPS via reverse proxy with custom hostname
|
||||
-p 53:5353/udp@host Bind UDP port 5353 to host port 53
|
||||
-p 192.168.76.0/24:53:5353/udp@host Bind UDP port 5353 to host port 53 on every host IP address
|
||||
contained in the prefix 192.168.76.0/24
|
||||
--pull string Pull image from the registry before running service containers ('always', 'missing', 'never'). (default "missing")
|
||||
--replicas uint Number of containers to run for the service. Only valid for a replicated service. (default 1)
|
||||
--shm-size bytes Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer
|
||||
with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.
|
||||
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
|
||||
--ulimit strings Set resource limits for service containers. Can be specified multiple times.
|
||||
Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both.
|
||||
Examples:
|
||||
--ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files
|
||||
--ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes
|
||||
-u, --user string User name or UID and optionally group name or GID used for running the command inside service containers.
|
||||
Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.
|
||||
-v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where
|
||||
the volume is located. Can be specified multiple times.
|
||||
Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]
|
||||
Host and container paths support runtime templates such as {{.Container.Name}}.
|
||||
Examples:
|
||||
-v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container
|
||||
-v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container
|
||||
-v /host/path:/container/path:ro Bind mount a host directory or file as read-only
|
||||
--caddyfile string Path to a custom Caddy config (Caddyfile) for the service. Cannot be used together with non-@host published ports.
|
||||
--cpu decimal 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.
|
||||
--entrypoint string Overwrite the default ENTRYPOINT of the image. Pass an empty string "" to reset it.
|
||||
-e, --env strings Set an environment variable for service containers. Can be specified multiple times.
|
||||
Format: VAR=value or just VAR to use the value from the local environment.
|
||||
-h, --help help for run
|
||||
-m, --machine strings Placement constraint by machine names, limiting which machines the service can run on. Can be specified multiple times or as a comma-separated list of machine names. (default is any suitable machine)
|
||||
--memory bytes Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).
|
||||
Default unit is bytes if no suffix specified.
|
||||
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
|
||||
--mode string Replication mode of the service: either 'replicated' (a specified number of containers across the machines) or 'global' (one container on every machine). (default "replicated")
|
||||
-n, --name string Assign a name to the service. A random name is generated if not specified.
|
||||
--privileged Give extended privileges to service containers. This is a security risk and should be used with caution.
|
||||
-p, --publish strings Publish a service port to make it accessible outside the cluster. Can be specified multiple times.
|
||||
Format: [hostname:]container_port[/protocol] or [host_ip|host_prefix:]host_port:container_port[/protocol]@host
|
||||
Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified
|
||||
and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.
|
||||
Examples:
|
||||
-p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname
|
||||
-p app.example.com:8080/https Publish port 8080 as HTTPS via reverse proxy with custom hostname
|
||||
-p 53:5353/udp@host Bind UDP port 5353 to host port 53
|
||||
-p 192.168.76.0/24:53:5353/udp@host Bind UDP port 5353 to host port 53 on every host IP address
|
||||
contained in the prefix 192.168.76.0/24
|
||||
--pull string Pull image from the registry before running service containers ('always', 'missing', 'never'). (default "missing")
|
||||
--replicas uint Number of containers to run for the service. Only valid for a replicated service. (default 1)
|
||||
--shm-size bytes Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer
|
||||
with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.
|
||||
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
|
||||
--ulimit strings Set resource limits for service containers. Can be specified multiple times.
|
||||
Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both.
|
||||
Examples:
|
||||
--ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files
|
||||
--ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes
|
||||
-u, --user string User name or UID and optionally group name or GID used for running the command inside service containers.
|
||||
Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.
|
||||
-v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where
|
||||
the volume is located. Can be specified multiple times.
|
||||
Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]
|
||||
Host and container paths support runtime templates such as {{.Container.Name}}.
|
||||
Examples:
|
||||
-v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container
|
||||
-v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container
|
||||
-v /host/path:/container/path:ro Bind mount a host directory or file as read-only
|
||||
```
|
||||
|
||||
## Options inherited from parent commands
|
||||
|
||||
@@ -29,5 +29,4 @@ Manage services in the cluster.
|
||||
* [uc service scale](uc_service_scale.md) - Scale a replicated service by changing the number of replicas.
|
||||
* [uc service start](uc_service_start.md) - Start one or more services.
|
||||
* [uc service stop](uc_service_stop.md) - Stop one or more services.
|
||||
* [uc service update](uc_service_update.md) - Update a service in place without recreating its containers.
|
||||
|
||||
|
||||
@@ -9,56 +9,49 @@ uc service run IMAGE [COMMAND...] [flags]
|
||||
## Options
|
||||
|
||||
```
|
||||
--caddyfile string Path to a custom Caddy config (Caddyfile) for the service. Cannot be used together with non-@host published ports.
|
||||
--container-label stringArray Set a Docker label on service containers. Can be specified multiple times.
|
||||
Format: key=value or just key for an empty value.
|
||||
--cpu decimal 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.
|
||||
--entrypoint string Overwrite the default ENTRYPOINT of the image. Pass an empty string "" to reset it.
|
||||
-e, --env strings Set an environment variable for service containers. Can be specified multiple times.
|
||||
Format: VAR=value or just VAR to use the value from the local environment.
|
||||
-h, --help help for run
|
||||
-l, --label stringArray Set a service label. Service labels aren't applied to Docker containers, use --container-label for that.
|
||||
Can be specified multiple times. Format: key=value or just key for an empty value.
|
||||
-m, --machine strings Placement constraint by machine names, limiting which machines the service can run on. Can be specified multiple times or as a comma-separated list of machine names. (default is any suitable machine)
|
||||
--memory bytes Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).
|
||||
Default unit is bytes if no suffix specified.
|
||||
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
|
||||
--memory-reservation bytes Minimum amount of memory reserved for a service container. Value is a positive integer with optional unit suffix (b, k, m, g).
|
||||
Default unit is bytes if no suffix specified.
|
||||
--mode string Replication mode of the service: either 'replicated' (a specified number of containers across the machines) or 'global' (one container on every machine). (default "replicated")
|
||||
-n, --name string Assign a name to the service. A random name is generated if not specified.
|
||||
--privileged Give extended privileges to service containers. This is a security risk and should be used with caution.
|
||||
-p, --publish strings Publish a service port to make it accessible outside the cluster. Can be specified multiple times.
|
||||
Format: [hostname:]container_port[/protocol] or [host_ip|host_prefix:]host_port:container_port[/protocol]@host
|
||||
Supported protocols: tcp, udp, http, https. The protocol defaults to https if a hostname is specified,
|
||||
otherwise tcp. http(s) ports without a hostname use 'service-name.cluster-domain' as the default hostname
|
||||
if a cluster domain is reserved.
|
||||
Examples:
|
||||
-p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname
|
||||
-p app.example.com:8080 Publish port 8080 as HTTPS via reverse proxy with custom hostname
|
||||
-p 53:5353/udp@host Bind UDP port 5353 to host port 53
|
||||
-p 192.168.76.0/24:53:5353/udp@host Bind UDP port 5353 to host port 53 on every host IP address
|
||||
contained in the prefix 192.168.76.0/24
|
||||
--pull string Pull image from the registry before running service containers ('always', 'missing', 'never'). (default "missing")
|
||||
--replicas uint Number of containers to run for the service. Only valid for a replicated service. (default 1)
|
||||
--shm-size bytes Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer
|
||||
with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.
|
||||
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
|
||||
--ulimit strings Set resource limits for service containers. Can be specified multiple times.
|
||||
Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both.
|
||||
Examples:
|
||||
--ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files
|
||||
--ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes
|
||||
-u, --user string User name or UID and optionally group name or GID used for running the command inside service containers.
|
||||
Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.
|
||||
-v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where
|
||||
the volume is located. Can be specified multiple times.
|
||||
Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]
|
||||
Host and container paths support runtime templates such as {{.Container.Name}}.
|
||||
Examples:
|
||||
-v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container
|
||||
-v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container
|
||||
-v /host/path:/container/path:ro Bind mount a host directory or file as read-only
|
||||
--caddyfile string Path to a custom Caddy config (Caddyfile) for the service. Cannot be used together with non-@host published ports.
|
||||
--cpu decimal 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.
|
||||
--entrypoint string Overwrite the default ENTRYPOINT of the image. Pass an empty string "" to reset it.
|
||||
-e, --env strings Set an environment variable for service containers. Can be specified multiple times.
|
||||
Format: VAR=value or just VAR to use the value from the local environment.
|
||||
-h, --help help for run
|
||||
-m, --machine strings Placement constraint by machine names, limiting which machines the service can run on. Can be specified multiple times or as a comma-separated list of machine names. (default is any suitable machine)
|
||||
--memory bytes Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).
|
||||
Default unit is bytes if no suffix specified.
|
||||
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
|
||||
--mode string Replication mode of the service: either 'replicated' (a specified number of containers across the machines) or 'global' (one container on every machine). (default "replicated")
|
||||
-n, --name string Assign a name to the service. A random name is generated if not specified.
|
||||
--privileged Give extended privileges to service containers. This is a security risk and should be used with caution.
|
||||
-p, --publish strings Publish a service port to make it accessible outside the cluster. Can be specified multiple times.
|
||||
Format: [hostname:]container_port[/protocol] or [host_ip|host_prefix:]host_port:container_port[/protocol]@host
|
||||
Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified
|
||||
and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.
|
||||
Examples:
|
||||
-p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname
|
||||
-p app.example.com:8080/https Publish port 8080 as HTTPS via reverse proxy with custom hostname
|
||||
-p 53:5353/udp@host Bind UDP port 5353 to host port 53
|
||||
-p 192.168.76.0/24:53:5353/udp@host Bind UDP port 5353 to host port 53 on every host IP address
|
||||
contained in the prefix 192.168.76.0/24
|
||||
--pull string Pull image from the registry before running service containers ('always', 'missing', 'never'). (default "missing")
|
||||
--replicas uint Number of containers to run for the service. Only valid for a replicated service. (default 1)
|
||||
--shm-size bytes Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer
|
||||
with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.
|
||||
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
|
||||
--ulimit strings Set resource limits for service containers. Can be specified multiple times.
|
||||
Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both.
|
||||
Examples:
|
||||
--ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files
|
||||
--ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes
|
||||
-u, --user string User name or UID and optionally group name or GID used for running the command inside service containers.
|
||||
Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.
|
||||
-v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where
|
||||
the volume is located. Can be specified multiple times.
|
||||
Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]
|
||||
Host and container paths support runtime templates such as {{.Container.Name}}.
|
||||
Examples:
|
||||
-v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container
|
||||
-v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container
|
||||
-v /host/path:/container/path:ro Bind mount a host directory or file as read-only
|
||||
```
|
||||
|
||||
## Options inherited from parent commands
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# uc service update
|
||||
|
||||
Update a service in place without recreating its containers.
|
||||
|
||||
## Synopsis
|
||||
|
||||
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'
|
||||
|
||||
```
|
||||
uc service update SERVICE [flags]
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
# 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
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
```
|
||||
--cpu decimal 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.
|
||||
-h, --help help for update
|
||||
--label-add stringArray Add or change a service label. Can be specified multiple times.
|
||||
Format: key=value or just key for an empty value.
|
||||
--label-rm stringArray Remove a service label by its key. Can be specified multiple times.
|
||||
--memory bytes Maximum amount of memory a service container can use. Value is a positive integer with
|
||||
optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.
|
||||
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
|
||||
--memory-reservation bytes Minimum amount of memory reserved for a service container. Value is a positive integer with
|
||||
optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.
|
||||
```
|
||||
|
||||
## Options inherited from parent commands
|
||||
|
||||
```
|
||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||
Format: [ssh://]user@host[:port], ssh+go://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock
|
||||
-c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]
|
||||
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
* [uc service](uc_service.md) - Manage services in the cluster.
|
||||
|
||||
Reference in New Issue
Block a user