Compare commits

...
64 Commits
Author SHA1 Message Date
Pasha Sviderski d1d496cf32 docs(releases): copy release notes from github to docs for v0.16.0-v0.21.0 2026-11-06 20:02:34 +10:00
Pasha Sviderski 286d0e6afb docs(caddy): update description for 'uc caddy cert ls' command 2026-11-06 19:41:26 +10:00
Pasha Sviderski cef6916544 docs(caddy): update references to custom global configuration in deployment instructions, typo fixes 2026-11-06 19:29:32 +10:00
Pasha Sviderski 68d2bd5656 docs(caddy): update managing Caddy documentation for v0.22.0 features and upgrade instructions 2026-11-06 19:15:10 +10:00
Pasha Sviderski 99c928f490 fix(docs): correct published port protocol default for 'uc run' 2026-11-06 12:13:27 +10:00
Pasha Sviderski e81115ef37 docs(caddy): update example caddy config 2026-11-05 19:16:37 +10:00
Pasha Sviderski 22b1c68798 feat(caddy): make cluster storage for Caddy default, remember 'uc caddy deploy' options 2026-11-05 16:03:34 +10:00
Pasha Sviderski 76b04dd56c fix(labels): allow setting 'uncloud.' prefixed labels on services using a client but not CLI or Compose 2026-11-04 13:51:53 +10:00
Pasha Sviderski 78facdfcc6 fix(labels): format adding new labels in service plan with + modifier 2026-11-03 18:46:45 +10:00
Pasha Sviderski 7b48ac5a4d feat(labels): include detailed labels diff in service plan 2026-11-03 16:08:21 +10:00
Pasha Sviderski 6c7ddeb82f fix(plan): make stop operation formatting consistent and do not count it as removal in summary 2026-11-03 12:54:57 +10:00
Pasha Sviderski ff6ac45e1e feat(update): add 'uc service update' command to update service configuration in place 2026-11-03 09:54:33 +10:00
Pasha Sviderski c1a7f84dad feat(labels): add support for service and container labels in uc run and inspect commands 2026-11-03 09:54:00 +10:00
Pasha Sviderski 81f30aac26 feat(labels): support service and container labels, update labels and cpu/memory in-place 2026-11-02 14:57:33 +10:00
Pasha Sviderski b214d23f21 chore: migrate linting and formatting commands to mise tasks 2026-10-30 21:33:07 +10:00
Pasha Sviderski 965879b69f docs: regenerate CLI reference 2026-10-29 14:08:59 +10:00
Pasha Sviderski d66ec2bada chore: bump mise to 2026.10.22 on CI 2026-10-29 14:05:19 +10:00
Pasha Sviderski 20e2e97c36 chore(docs): update documentation generation commands and regenerate reference docs 2026-10-29 13:52:38 +10:00
Pasha SviderskiandMiek Gieben e9eab32e6d feat(images): add 'uc image rm' command to remove images from cluster machines
Co-authored-by: Miek Gieben <miek@miek.nl>
2026-10-29 13:17:20 +10:00
Pasha Sviderski 7c6d1be8a7 feat(images): implement RemoveImage gRPC API with extra protection for used images 2026-10-28 20:24:16 +10:00
Pasha Sviderski a2987d3ff2 chore: minor comments in common proto 2026-10-28 20:24:16 +10:00
Pasha Sviderski 3351111feb refactor(images): simplify image name handling when listing images 2026-10-28 20:24:16 +10:00
Pasha Sviderski bd695f65a3 refactor(grpc-proxy): use Metadata.Err() method to get upstream error for a broadcasted request 2026-10-28 20:24:16 +10:00
Pasha Sviderski 7e8736fa99 feat(images): include all tags and digest references when listing images 2026-10-28 20:24:16 +10:00
Pasha Sviderski 8160c9b9d0 move deploy.operation package to operation 2026-10-28 20:24:16 +10:00
Pasha Sviderski 80d2a323c6 fix: handle output redirect and NO_COLOR for 'uc caddy config' 2026-10-28 20:24:16 +10:00
Miek GiebenandGitHub 5d640b9a57 uc context show: take UNCLOUD_CONTEXT into consideration (#444)
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-10-14 10:32:55 +10:00
NickandGitHub ba224bb5be fix(deploy): prepare replacement before stop-first cutover (#420) 2026-10-12 16:02:54 +10:00
8ddbe3c606 fix(compose): strip and resolve pre-deploy hook secrets like service env (#424)
* fix(compose): strip and resolve pre-deploy hook secrets like service env (fixes #422)

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

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

Add tests pinning both behaviours.

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

---------

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

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

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

Fixes: #340

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

* Implement getting all machine address

A query for m.internal returns all ip address

There is no overflow check

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

---------

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

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

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

* bound debounced changes by the window

---------

Co-authored-by: Pasha Sviderski <me@psviderski.name>
2026-10-07 13:30:09 +10:00
Pasha Sviderski c58108aa82 chore(.editorconfig): ignore formatting for release notes 2026-10-02 13:56:49 +10:00
Pasha Sviderski 5d7952952a docs(README): list cluster storage for certificates in features 2026-10-02 13:56:08 +10:00
Pasha Sviderski 87e99ae369 docs(caddy): new page Cluster storage for Caddy 2026-10-02 13:27:07 +10:00
Pasha Sviderski f78a8ca8b5 docs(caddy): regenerate CLI reference for 'caddy cert ls' command 2026-10-01 21:00:05 +10:00
Pasha Sviderski e4bd1ad443 feat(store): improve waiting for store replication to skip unavailable members 2026-10-01 20:59:15 +10:00
Pasha Sviderski 0e53f505c0 refactor(client): speed up service list call (N -> 1 broadcast RPC) 2026-10-01 19:52:18 +10:00
Pasha Sviderski 9c21ae6b08 feat(caddy): CLI command to list certificates stored in the Uncloud cluster storage for Caddy 2026-10-01 14:19:53 +10:00
Pasha Sviderski 70de7b1b8f feat(caddy): add healthcheck to default Caddy spec and Compose deployment in docs 2026-10-01 09:25:39 +10:00
Pasha Sviderski af277639dd chore(dns): require minimum server version 0.21.0 for 'uc dns set' command 2026-09-30 19:21:59 +10:00
Pasha Sviderski 25ca0a57e8 lint 2026-09-30 18:49:53 +10:00
Pasha Sviderski 4dae187aa5 chore(caddystorage): move storage module code to a separate unlabs-dev/caddy-uncloud repo 2026-09-30 16:43:46 +10:00
Pasha Sviderski 930e7ea637 feat(caddy): add ListCertificates method, refactor Caddy client layout (#31) 2026-09-30 16:43:46 +10:00
Pasha Sviderski e50c5fbe57 refactor(api): replace map[string]uint64 with api.StoreVersion for store version handling 2026-09-30 16:43:46 +10:00
Pasha Sviderski ebecf334d7 chore(dns): require minimum server version 0.21 2026-09-25 17:24:13 +10:00
Pasha Sviderski 9ee3ca15eb feat(dns): allow setting an externally managed cluster domain 2026-09-25 16:20:54 +10:00
Miek Gieben b28c458029 Rebase and fix
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-09-15 14:39:48 +02:00
Miek Gieben 3fc1cddf01 Rebase and and generate protos again
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-09-15 14:36:28 +02:00
Miek Gieben 54a7328a34 mise proto && make cli-docs
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-09-15 14:36:02 +02:00
Miek Gieben 47a4a9158e from main
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-09-15 14:35:43 +02:00
Miek Gieben ad2e1a06d5 Ignore domain when there is no endpoint
Prevents this error, when you know what you're doing:

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

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

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

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

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

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

See: #37

Signed-off-by: Miek Gieben <miek@miek.nl>
2026-09-15 14:35:31 +02:00
176 changed files with 19393 additions and 1793 deletions
+3
View File
@@ -18,3 +18,6 @@ indent_size = 4
[*.sh] [*.sh]
indent_style = space indent_style = space
indent_size = 4 indent_size = 4
[release-notes-*.md]
max_line_length = off
+5 -4
View File
@@ -14,12 +14,13 @@ on:
- "**.proto" - "**.proto"
- "go.*" - "go.*"
- "Makefile" - "Makefile"
- "mise.toml"
- "scripts/**" - "scripts/**"
- "test/**" - "test/**"
permissions: permissions:
contents: read contents: read
env: env:
MISE_VERSION: "2026.3.7" MISE_VERSION: "2026.10.22"
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -42,9 +43,9 @@ jobs:
- name: Generate docs - name: Generate docs
run: | run: |
make cli-docs mise run docs:cli
git diff --exit-code || git diff --exit-code ||
(echo "Documentation is out of date. Please run 'make cli-docs' and commit the changes." && exit 1) (echo "Documentation is out of date. Please run 'mise run docs:cli' and commit the changes." && exit 1)
- name: Run tests - name: Run tests
run: | run: |
@@ -70,7 +71,7 @@ jobs:
version: ${{ env.MISE_VERSION }} version: ${{ env.MISE_VERSION }}
# TODO: figure out how to properly lock all platforms in the lockfile and remove this hack. # TODO: figure out how to properly lock all platforms in the lockfile and remove this hack.
# `mise lock` (mise run mise:lock) doesn't generate checksums for some of the tools, e.g. protoc* # `mise lock` doesn't generate checksums for some of the tools, e.g. protoc*
- name: Reset changes in mise.lock after fresh mise install - name: Reset changes in mise.lock after fresh mise install
run: git checkout mise.lock run: git checkout mise.lock
+2 -2
View File
@@ -14,7 +14,7 @@ on:
permissions: permissions:
contents: read contents: read
env: env:
MISE_VERSION: "2026.3.7" MISE_VERSION: "2026.10.22"
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -30,6 +30,6 @@ jobs:
run: git checkout mise.lock run: git checkout mise.lock
- name: Lint - name: Lint
run: make lint run: mise run lint
timeout-minutes: 10 timeout-minutes: 10
+1 -1
View File
@@ -8,7 +8,7 @@ permissions:
contents: write contents: write
env: env:
MISE_VERSION: "2026.3.7" MISE_VERSION: "2026.10.22"
jobs: jobs:
nightly: nightly:
+6
View File
@@ -1,4 +1,10 @@
packages: packages:
github.com/docker/docker/client:
config:
dir: internal/machine/docker
pkgname: docker
interfaces:
APIClient:
github.com/psviderski/uncloud/internal/machine/caddyconfig: github.com/psviderski/uncloud/internal/machine/caddyconfig:
interfaces: interfaces:
CaddyfileValidator: CaddyfileValidator:
+6 -31
View File
@@ -98,33 +98,6 @@ management.
- **`misc/`**: Design documents and guides - **`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 ## Development Workflow
### Build and Development ### Build and Development
@@ -135,16 +108,18 @@ go build -o uc ./cmd/uc
go build -o uncloudd ./cmd/uncloudd go build -o uncloudd ./cmd/uncloudd
``` ```
### Key Make Targets ### Formatting and Linting
- `mise run lint`: Check Go code and formatting without changing files
- `mise run lint:fix`: Apply formatting and available lint fixes
### Other Development Tasks
- `proto`: Generate protobuf code - `proto`: Generate protobuf code
- `ucind-cluster`: Create development cluster - `ucind-cluster`: Create development cluster
- `update-dev`: Deploy to development machines - `update-dev`: Deploy to development machines
- `demo-reset`: Reset demo environment - `demo-reset`: Reset demo environment
- `fmt`: Format code
- `test`: Run all tests - `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 ## CLI Commands Structure
+34 -5
View File
@@ -35,10 +35,11 @@ Build the CLI:
go build -o uc ./cmd/uc go build -o uc ./cmd/uc
``` ```
Or build and run the CLI with a single command: Or build and run the CLI with mise. It forwards arguments, including help flags, directly to the CLI:
```shell ```shell
go run ./cmd/uc --help mise uc --help
mise uc image rm -h
``` ```
The Uncloud daemon (`uncloudd`) only supports Linux, so you need to cross-compile it if you're developing on macOS or The Uncloud daemon (`uncloudd`) only supports Linux, so you need to cross-compile it if you're developing on macOS or
@@ -99,10 +100,16 @@ mise run dev:reset user@host1 user@host2
## Linting and formatting ## Linting and formatting
Lint and format the code: Check the code and formatting without changing files:
```shell ```shell
make lint-and-fix mise run lint
```
Apply formatting and available lint fixes:
```shell
mise run lint:fix
``` ```
## Code generation ## Code generation
@@ -116,9 +123,31 @@ mise run proto
Regenerate mocks: Regenerate mocks:
```shell ```shell
make mocks mise run mocks
``` ```
## Documentation
Regenerate the CLI reference docs after changing commands or flags:
```shell
mise run docs:cli
```
To preview the documentation website, install its dependencies from the project root:
```shell
npm ci --prefix website
```
Then start the local development server:
```shell
mise run docs:dev
```
The server opens the site (http://localhost:3000/) in your browser and reloads it as you edit files in `website/`.
## Uncloud in Docker (UCinD) ## Uncloud in Docker (UCinD)
The [`ucind`](./cmd/ucind) CLI lets you run Uncloud clusters locally using Docker containers instead of real machines. The [`ucind`](./cmd/ucind) CLI lets you run Uncloud clusters locally using Docker containers instead of real machines.
-26
View File
@@ -21,10 +21,6 @@ corrosion-multiarch-image-push:
ucind-multiarch-image-push: ucind-multiarch-image-push:
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind . docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
.PHONY: mocks
mocks:
@mockery
.PHONY: test .PHONY: test
test: test:
ifeq ($(TEST_NAME),) ifeq ($(TEST_NAME),)
@@ -51,25 +47,3 @@ test-clean:
else \ else \
echo "Nothing to clean."; \ echo "Nothing to clean."; \
fi 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
+2
View File
@@ -44,6 +44,8 @@ complexity of Kubernetes.
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service. [Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's * **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
Encrypt. Encrypt.
* **Cluster storage for TLS certificates**: [Cluster storage](https://github.com/unlabs-dev/caddy-uncloud) for Caddy
shares certificates and ACME challenge tokens across machines and coordinates issuance.
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications. * **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster. * **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
+205 -46
View File
@@ -446,6 +446,120 @@ func (x *StatCaddyStorageResponse) GetIsTerminal() bool {
return false return false
} }
type ListCertificatesResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Certificates []*IssuedCertificate `protobuf:"bytes,1,rep,name=certificates,proto3" json:"certificates,omitempty"`
}
func (x *ListCertificatesResponse) Reset() {
*x = ListCertificatesResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_caddy_storage_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ListCertificatesResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListCertificatesResponse) ProtoMessage() {}
func (x *ListCertificatesResponse) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_caddy_storage_proto_msgTypes[8]
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 ListCertificatesResponse.ProtoReflect.Descriptor instead.
func (*ListCertificatesResponse) Descriptor() ([]byte, []int) {
return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{8}
}
func (x *ListCertificatesResponse) GetCertificates() []*IssuedCertificate {
if x != nil {
return x.Certificates
}
return nil
}
type IssuedCertificate struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Subject Alternative Name (SAN) of the certificate. Caddy doesn't issue certificates with multiple SANs.
San string `protobuf:"bytes,1,opt,name=san,proto3" json:"san,omitempty"`
// The PEM-encoding of DER-encoded ASN.1 data for the cert or chain, leaf first.
Chain []byte `protobuf:"bytes,2,opt,name=chain,proto3" json:"chain,omitempty"`
// Any extra information associated with the certificate, usually provided by the issuer implementation.
// JSON-encoded, may be absent or null.
IssuerData []byte `protobuf:"bytes,3,opt,name=issuer_data,json=issuerData,proto3" json:"issuer_data,omitempty"`
}
func (x *IssuedCertificate) Reset() {
*x = IssuedCertificate{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_caddy_storage_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *IssuedCertificate) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*IssuedCertificate) ProtoMessage() {}
func (x *IssuedCertificate) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_caddy_storage_proto_msgTypes[9]
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 IssuedCertificate.ProtoReflect.Descriptor instead.
func (*IssuedCertificate) Descriptor() ([]byte, []int) {
return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{9}
}
func (x *IssuedCertificate) GetSan() string {
if x != nil {
return x.San
}
return ""
}
func (x *IssuedCertificate) GetChain() []byte {
if x != nil {
return x.Chain
}
return nil
}
func (x *IssuedCertificate) GetIssuerData() []byte {
if x != nil {
return x.IssuerData
}
return nil
}
var File_api_pb_caddy_storage_proto protoreflect.FileDescriptor var File_api_pb_caddy_storage_proto protoreflect.FileDescriptor
var file_api_pb_caddy_storage_proto_rawDesc = []byte{ var file_api_pb_caddy_storage_proto_rawDesc = []byte{
@@ -492,32 +606,48 @@ var file_api_pb_caddy_storage_proto_rawDesc = []byte{
0x65, 0x64, 0x41, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x65, 0x64, 0x41, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01,
0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x73, 0x5f, 0x74, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x73, 0x5f, 0x74,
0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x69, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x69,
0x73, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x32, 0xdf, 0x02, 0x0a, 0x0c, 0x43, 0x61, 0x73, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x22, 0x56, 0x0a, 0x18, 0x4c, 0x69, 0x73,
0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x12, 0x3e, 0x0a, 0x05, 0x53, 0x74, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73,
0x6f, 0x72, 0x65, 0x12, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x43, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3a, 0x0a, 0x0c, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69,
0x63, 0x61, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x70,
0x69, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63,
0x61, 0x74, 0x65, 0x52, 0x0c, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65,
0x73, 0x22, 0x5c, 0x0a, 0x11, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x43, 0x65, 0x72, 0x74, 0x69,
0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x61, 0x6e, 0x18, 0x01, 0x20,
0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x61, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x68, 0x61, 0x69,
0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x12, 0x1f,
0x0a, 0x0b, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20,
0x01, 0x28, 0x0c, 0x52, 0x0a, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x32,
0xaa, 0x03, 0x0a, 0x0c, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65,
0x12, 0x3e, 0x0a, 0x05, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e,
0x53, 0x74, 0x6f, 0x72, 0x65, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67,
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, 0x43, 0x0a, 0x04, 0x4c, 0x6f, 0x61, 0x64, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c,
0x6f, 0x61, 0x64, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x61,
0x64, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12,
0x1e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x61, 0x64, 0x64,
0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 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, 0x43, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12,
0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53,
0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f,
0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x04,
0x53, 0x74, 0x61, 0x74, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x43,
0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x43, 0x61, 0x64,
0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x43, 0x0a, 0x04, 0x4c, 0x6f, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x61, 0x64, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x43, 0x61, 0x64, 0x65, 0x12, 0x49, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69,
0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x63, 0x61, 0x74, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x43, 0x61, 0x64, 0x64, 0x79, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e,
0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63,
0x40, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x1e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x26, 0x5a, 0x24,
0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64,
0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x61, 0x70,
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x79, 0x12, 0x43, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e,
0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69,
0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x04, 0x53, 0x74, 0x61, 0x74, 0x12, 0x1c,
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74,
0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61,
0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72,
0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 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 ( var (
@@ -532,7 +662,7 @@ func file_api_pb_caddy_storage_proto_rawDescGZIP() []byte {
return file_api_pb_caddy_storage_proto_rawDescData return file_api_pb_caddy_storage_proto_rawDescData
} }
var file_api_pb_caddy_storage_proto_msgTypes = make([]protoimpl.MessageInfo, 8) var file_api_pb_caddy_storage_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_api_pb_caddy_storage_proto_goTypes = []any{ var file_api_pb_caddy_storage_proto_goTypes = []any{
(*StoreCaddyStorageRequest)(nil), // 0: api.StoreCaddyStorageRequest (*StoreCaddyStorageRequest)(nil), // 0: api.StoreCaddyStorageRequest
(*LoadCaddyStorageRequest)(nil), // 1: api.LoadCaddyStorageRequest (*LoadCaddyStorageRequest)(nil), // 1: api.LoadCaddyStorageRequest
@@ -542,27 +672,32 @@ var file_api_pb_caddy_storage_proto_goTypes = []any{
(*ListCaddyStorageResponse)(nil), // 5: api.ListCaddyStorageResponse (*ListCaddyStorageResponse)(nil), // 5: api.ListCaddyStorageResponse
(*StatCaddyStorageRequest)(nil), // 6: api.StatCaddyStorageRequest (*StatCaddyStorageRequest)(nil), // 6: api.StatCaddyStorageRequest
(*StatCaddyStorageResponse)(nil), // 7: api.StatCaddyStorageResponse (*StatCaddyStorageResponse)(nil), // 7: api.StatCaddyStorageResponse
(*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp (*ListCertificatesResponse)(nil), // 8: api.ListCertificatesResponse
(*emptypb.Empty)(nil), // 9: google.protobuf.Empty (*IssuedCertificate)(nil), // 9: api.IssuedCertificate
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
(*emptypb.Empty)(nil), // 11: google.protobuf.Empty
} }
var file_api_pb_caddy_storage_proto_depIdxs = []int32{ var file_api_pb_caddy_storage_proto_depIdxs = []int32{
8, // 0: api.LoadCaddyStorageResponse.updated_at:type_name -> google.protobuf.Timestamp 10, // 0: api.LoadCaddyStorageResponse.updated_at:type_name -> google.protobuf.Timestamp
8, // 1: api.StatCaddyStorageResponse.updated_at:type_name -> google.protobuf.Timestamp 10, // 1: api.StatCaddyStorageResponse.updated_at:type_name -> google.protobuf.Timestamp
0, // 2: api.CaddyStorage.Store:input_type -> api.StoreCaddyStorageRequest 9, // 2: api.ListCertificatesResponse.certificates:type_name -> api.IssuedCertificate
1, // 3: api.CaddyStorage.Load:input_type -> api.LoadCaddyStorageRequest 0, // 3: api.CaddyStorage.Store:input_type -> api.StoreCaddyStorageRequest
3, // 4: api.CaddyStorage.Delete:input_type -> api.DeleteCaddyStorageRequest 1, // 4: api.CaddyStorage.Load:input_type -> api.LoadCaddyStorageRequest
4, // 5: api.CaddyStorage.List:input_type -> api.ListCaddyStorageRequest 3, // 5: api.CaddyStorage.Delete:input_type -> api.DeleteCaddyStorageRequest
6, // 6: api.CaddyStorage.Stat:input_type -> api.StatCaddyStorageRequest 4, // 6: api.CaddyStorage.List:input_type -> api.ListCaddyStorageRequest
9, // 7: api.CaddyStorage.Store:output_type -> google.protobuf.Empty 6, // 7: api.CaddyStorage.Stat:input_type -> api.StatCaddyStorageRequest
2, // 8: api.CaddyStorage.Load:output_type -> api.LoadCaddyStorageResponse 11, // 8: api.CaddyStorage.ListCertificates:input_type -> google.protobuf.Empty
9, // 9: api.CaddyStorage.Delete:output_type -> google.protobuf.Empty 11, // 9: api.CaddyStorage.Store:output_type -> google.protobuf.Empty
5, // 10: api.CaddyStorage.List:output_type -> api.ListCaddyStorageResponse 2, // 10: api.CaddyStorage.Load:output_type -> api.LoadCaddyStorageResponse
7, // 11: api.CaddyStorage.Stat:output_type -> api.StatCaddyStorageResponse 11, // 11: api.CaddyStorage.Delete:output_type -> google.protobuf.Empty
7, // [7:12] is the sub-list for method output_type 5, // 12: api.CaddyStorage.List:output_type -> api.ListCaddyStorageResponse
2, // [2:7] is the sub-list for method input_type 7, // 13: api.CaddyStorage.Stat:output_type -> api.StatCaddyStorageResponse
2, // [2:2] is the sub-list for extension type_name 8, // 14: api.CaddyStorage.ListCertificates:output_type -> api.ListCertificatesResponse
2, // [2:2] is the sub-list for extension extendee 9, // [9:15] is the sub-list for method output_type
0, // [0:2] is the sub-list for field type_name 3, // [3:9] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
} }
func init() { file_api_pb_caddy_storage_proto_init() } func init() { file_api_pb_caddy_storage_proto_init() }
@@ -667,6 +802,30 @@ func file_api_pb_caddy_storage_proto_init() {
return nil return nil
} }
} }
file_api_pb_caddy_storage_proto_msgTypes[8].Exporter = func(v any, i int) any {
switch v := v.(*ListCertificatesResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_caddy_storage_proto_msgTypes[9].Exporter = func(v any, i int) any {
switch v := v.(*IssuedCertificate); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
} }
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
@@ -674,7 +833,7 @@ func file_api_pb_caddy_storage_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_api_pb_caddy_storage_proto_rawDesc, RawDescriptor: file_api_pb_caddy_storage_proto_rawDesc,
NumEnums: 0, NumEnums: 0,
NumMessages: 8, NumMessages: 10,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },
+18
View File
@@ -18,6 +18,10 @@ service CaddyStorage {
rpc Delete(DeleteCaddyStorageRequest) returns (google.protobuf.Empty); rpc Delete(DeleteCaddyStorageRequest) returns (google.protobuf.Empty);
rpc List(ListCaddyStorageRequest) returns (ListCaddyStorageResponse); rpc List(ListCaddyStorageRequest) returns (ListCaddyStorageResponse);
rpc Stat(StatCaddyStorageRequest) returns (StatCaddyStorageResponse); rpc Stat(StatCaddyStorageRequest) returns (StatCaddyStorageResponse);
// ListCertificates lists certificates issued by Caddy and stored in the cluster store.
// Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored.
rpc ListCertificates(google.protobuf.Empty) returns (ListCertificatesResponse);
} }
message StoreCaddyStorageRequest { message StoreCaddyStorageRequest {
@@ -57,3 +61,17 @@ message StatCaddyStorageResponse {
int64 size = 3; int64 size = 3;
bool is_terminal = 4; bool is_terminal = 4;
} }
message ListCertificatesResponse {
repeated IssuedCertificate certificates = 1;
}
message IssuedCertificate {
// Subject Alternative Name (SAN) of the certificate. Caddy doesn't issue certificates with multiple SANs.
string san = 1;
// The PEM-encoding of DER-encoded ASN.1 data for the cert or chain, leaf first.
bytes chain = 2;
// Any extra information associated with the certificate, usually provided by the issuer implementation.
// JSON-encoded, may be absent or null.
bytes issuer_data = 3;
}
+47 -5
View File
@@ -20,11 +20,12 @@ import (
const _ = grpc.SupportPackageIsVersion9 const _ = grpc.SupportPackageIsVersion9
const ( const (
CaddyStorage_Store_FullMethodName = "/api.CaddyStorage/Store" CaddyStorage_Store_FullMethodName = "/api.CaddyStorage/Store"
CaddyStorage_Load_FullMethodName = "/api.CaddyStorage/Load" CaddyStorage_Load_FullMethodName = "/api.CaddyStorage/Load"
CaddyStorage_Delete_FullMethodName = "/api.CaddyStorage/Delete" CaddyStorage_Delete_FullMethodName = "/api.CaddyStorage/Delete"
CaddyStorage_List_FullMethodName = "/api.CaddyStorage/List" CaddyStorage_List_FullMethodName = "/api.CaddyStorage/List"
CaddyStorage_Stat_FullMethodName = "/api.CaddyStorage/Stat" CaddyStorage_Stat_FullMethodName = "/api.CaddyStorage/Stat"
CaddyStorage_ListCertificates_FullMethodName = "/api.CaddyStorage/ListCertificates"
) )
// CaddyStorageClient is the client API for CaddyStorage service. // CaddyStorageClient is the client API for CaddyStorage service.
@@ -42,6 +43,9 @@ type CaddyStorageClient interface {
Delete(ctx context.Context, in *DeleteCaddyStorageRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) Delete(ctx context.Context, in *DeleteCaddyStorageRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
List(ctx context.Context, in *ListCaddyStorageRequest, opts ...grpc.CallOption) (*ListCaddyStorageResponse, error) List(ctx context.Context, in *ListCaddyStorageRequest, opts ...grpc.CallOption) (*ListCaddyStorageResponse, error)
Stat(ctx context.Context, in *StatCaddyStorageRequest, opts ...grpc.CallOption) (*StatCaddyStorageResponse, error) Stat(ctx context.Context, in *StatCaddyStorageRequest, opts ...grpc.CallOption) (*StatCaddyStorageResponse, error)
// ListCertificates lists certificates issued by Caddy and stored in the cluster store.
// Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored.
ListCertificates(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListCertificatesResponse, error)
} }
type caddyStorageClient struct { type caddyStorageClient struct {
@@ -102,6 +106,16 @@ func (c *caddyStorageClient) Stat(ctx context.Context, in *StatCaddyStorageReque
return out, nil return out, nil
} }
func (c *caddyStorageClient) ListCertificates(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListCertificatesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListCertificatesResponse)
err := c.cc.Invoke(ctx, CaddyStorage_ListCertificates_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// CaddyStorageServer is the server API for CaddyStorage service. // CaddyStorageServer is the server API for CaddyStorage service.
// All implementations must embed UnimplementedCaddyStorageServer // All implementations must embed UnimplementedCaddyStorageServer
// for forward compatibility. // for forward compatibility.
@@ -117,6 +131,9 @@ type CaddyStorageServer interface {
Delete(context.Context, *DeleteCaddyStorageRequest) (*emptypb.Empty, error) Delete(context.Context, *DeleteCaddyStorageRequest) (*emptypb.Empty, error)
List(context.Context, *ListCaddyStorageRequest) (*ListCaddyStorageResponse, error) List(context.Context, *ListCaddyStorageRequest) (*ListCaddyStorageResponse, error)
Stat(context.Context, *StatCaddyStorageRequest) (*StatCaddyStorageResponse, error) Stat(context.Context, *StatCaddyStorageRequest) (*StatCaddyStorageResponse, error)
// ListCertificates lists certificates issued by Caddy and stored in the cluster store.
// Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored.
ListCertificates(context.Context, *emptypb.Empty) (*ListCertificatesResponse, error)
mustEmbedUnimplementedCaddyStorageServer() mustEmbedUnimplementedCaddyStorageServer()
} }
@@ -142,6 +159,9 @@ func (UnimplementedCaddyStorageServer) List(context.Context, *ListCaddyStorageRe
func (UnimplementedCaddyStorageServer) Stat(context.Context, *StatCaddyStorageRequest) (*StatCaddyStorageResponse, error) { func (UnimplementedCaddyStorageServer) Stat(context.Context, *StatCaddyStorageRequest) (*StatCaddyStorageResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Stat not implemented") return nil, status.Errorf(codes.Unimplemented, "method Stat not implemented")
} }
func (UnimplementedCaddyStorageServer) ListCertificates(context.Context, *emptypb.Empty) (*ListCertificatesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListCertificates not implemented")
}
func (UnimplementedCaddyStorageServer) mustEmbedUnimplementedCaddyStorageServer() {} func (UnimplementedCaddyStorageServer) mustEmbedUnimplementedCaddyStorageServer() {}
func (UnimplementedCaddyStorageServer) testEmbeddedByValue() {} func (UnimplementedCaddyStorageServer) testEmbeddedByValue() {}
@@ -253,6 +273,24 @@ func _CaddyStorage_Stat_Handler(srv interface{}, ctx context.Context, dec func(i
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _CaddyStorage_ListCertificates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(emptypb.Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CaddyStorageServer).ListCertificates(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CaddyStorage_ListCertificates_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CaddyStorageServer).ListCertificates(ctx, req.(*emptypb.Empty))
}
return interceptor(ctx, in, info, handler)
}
// CaddyStorage_ServiceDesc is the grpc.ServiceDesc for CaddyStorage service. // CaddyStorage_ServiceDesc is the grpc.ServiceDesc for CaddyStorage service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@@ -280,6 +318,10 @@ var CaddyStorage_ServiceDesc = grpc.ServiceDesc{
MethodName: "Stat", MethodName: "Stat",
Handler: _CaddyStorage_Stat_Handler, Handler: _CaddyStorage_Stat_Handler,
}, },
{
MethodName: "ListCertificates",
Handler: _CaddyStorage_ListCertificates_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "api/pb/caddy_storage.proto", Metadata: "api/pb/caddy_storage.proto",
+8
View File
@@ -4,8 +4,16 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"net/netip" "net/netip"
"google.golang.org/grpc/status"
) )
// Err returns the upstream error, preserving its gRPC status code.
// It returns nil for nil metadata or an absent or successful status.
func (m *Metadata) Err() error {
return status.ErrorProto(m.GetStatus())
}
func NewIP(addr netip.Addr) *IP { func NewIP(addr netip.Addr) *IP {
// MarshalBinary always returns a nil error. // MarshalBinary always returns a nil error.
ip, _ := addr.MarshalBinary() ip, _ := addr.MarshalBinary()
+2 -1
View File
@@ -87,7 +87,8 @@ type Metadata struct {
MachineName string `protobuf:"bytes,5,opt,name=machine_name,json=machineName,proto3" json:"machine_name,omitempty"` MachineName string `protobuf:"bytes,5,opt,name=machine_name,json=machineName,proto3" json:"machine_name,omitempty"`
// Address of the machine the response came from. // Address of the machine the response came from.
MachineAddr string `protobuf:"bytes,1,opt,name=machine_addr,json=machineAddr,proto3" json:"machine_addr,omitempty"` MachineAddr string `protobuf:"bytes,1,opt,name=machine_addr,json=machineAddr,proto3" json:"machine_addr,omitempty"`
// error is set if the request to upstream failed. The rest of the response is undefined. // error is set if the request to upstream failed. The rest of the response apart from this metadata message
// is undefined.
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
// error as a gRPC Status message. // error as a gRPC Status message.
Status *status.Status `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` Status *status.Status `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
+2 -1
View File
@@ -18,7 +18,8 @@ message Metadata {
// Address of the machine the response came from. // Address of the machine the response came from.
string machine_addr = 1; string machine_addr = 1;
// error is set if the request to upstream failed. The rest of the response is undefined. // error is set if the request to upstream failed. The rest of the response apart from this metadata message
// is undefined.
string error = 2; string error = 2;
// error as a gRPC Status message. // error as a gRPC Status message.
google.rpc.Status status = 3; google.rpc.Status status = 3;
+909 -295
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -24,6 +24,7 @@ service Docker {
// Docker auth credentials if necessary. // Docker auth credentials if necessary.
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse); rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
rpc ListImages(ListImagesRequest) returns (ListImagesResponse); rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse);
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse); rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse); rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
@@ -31,6 +32,8 @@ service Docker {
rpc CreateServiceContainer(CreateServiceContainerRequest) returns (CreateContainerResponse); rpc CreateServiceContainer(CreateServiceContainerRequest) returns (CreateContainerResponse);
rpc InspectServiceContainer(InspectContainerRequest) returns (ServiceContainer); 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 ListServiceContainers(ListServiceContainersRequest) returns (ListServiceContainersResponse);
rpc RemoveServiceContainer(RemoveContainerRequest) returns (google.protobuf.Empty); rpc RemoveServiceContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
} }
@@ -179,6 +182,40 @@ message ListImagesRequest {
bytes options = 1; bytes options = 1;
} }
message RemoveImageRequest {
string id = 1;
bool force = 2;
bool prune_children = 3;
repeated ImagePlatform platforms = 4;
}
// ImagePlatform describes an OCI image platform.
message ImagePlatform {
string architecture = 1;
string os = 2;
string os_version = 3;
repeated string os_features = 4;
string variant = 5;
}
message RemoveImageResponse {
// Must contain only one repeated messages field to allow broadcasting RemoveImage requests to multiple machines.
repeated MachineImageRemoval messages = 1;
}
message MachineImageRemoval {
Metadata metadata = 1;
repeated ImageRemoval results = 2;
}
// ImageRemoval reports an image deletion or an untagged image reference.
message ImageRemoval {
// The image ID of an image that was deleted.
string deleted = 1;
// The image reference of an image that was untagged.
string untagged = 2;
}
message ListImagesResponse { message ListImagesResponse {
// Must contain only one repeated messages field to allow broadcasting ListImages requests to multiple machines. // Must contain only one repeated messages field to allow broadcasting ListImages requests to multiple machines.
repeated MachineImages messages = 1; repeated MachineImages messages = 1;
@@ -246,6 +283,23 @@ message ServiceContainer {
bytes service_spec = 2; 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 { message ListServiceContainersRequest {
string service_id = 1; string service_id = 1;
// JSON serialised container.ListOptions. // JSON serialised container.ListOptions.
+78
View File
@@ -32,11 +32,13 @@ const (
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage" Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage" Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
Docker_ListImages_FullMethodName = "/api.Docker/ListImages" Docker_ListImages_FullMethodName = "/api.Docker/ListImages"
Docker_RemoveImage_FullMethodName = "/api.Docker/RemoveImage"
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume" Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes" Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume" Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
Docker_CreateServiceContainer_FullMethodName = "/api.Docker/CreateServiceContainer" Docker_CreateServiceContainer_FullMethodName = "/api.Docker/CreateServiceContainer"
Docker_InspectServiceContainer_FullMethodName = "/api.Docker/InspectServiceContainer" Docker_InspectServiceContainer_FullMethodName = "/api.Docker/InspectServiceContainer"
Docker_UpdateServiceContainer_FullMethodName = "/api.Docker/UpdateServiceContainer"
Docker_ListServiceContainers_FullMethodName = "/api.Docker/ListServiceContainers" Docker_ListServiceContainers_FullMethodName = "/api.Docker/ListServiceContainers"
Docker_RemoveServiceContainer_FullMethodName = "/api.Docker/RemoveServiceContainer" Docker_RemoveServiceContainer_FullMethodName = "/api.Docker/RemoveServiceContainer"
) )
@@ -59,11 +61,14 @@ type DockerClient interface {
// Docker auth credentials if necessary. // Docker auth credentials if necessary.
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error) InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error) ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error)
RemoveImage(ctx context.Context, in *RemoveImageRequest, opts ...grpc.CallOption) (*RemoveImageResponse, error)
CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error)
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error) ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
CreateServiceContainer(ctx context.Context, in *CreateServiceContainerRequest, opts ...grpc.CallOption) (*CreateContainerResponse, error) CreateServiceContainer(ctx context.Context, in *CreateServiceContainerRequest, opts ...grpc.CallOption) (*CreateContainerResponse, error)
InspectServiceContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*ServiceContainer, 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) ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error)
RemoveServiceContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) RemoveServiceContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
} }
@@ -217,6 +222,16 @@ func (c *dockerClient) ListImages(ctx context.Context, in *ListImagesRequest, op
return out, nil return out, nil
} }
func (c *dockerClient) RemoveImage(ctx context.Context, in *RemoveImageRequest, opts ...grpc.CallOption) (*RemoveImageResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RemoveImageResponse)
err := c.cc.Invoke(ctx, Docker_RemoveImage_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) { func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateVolumeResponse) out := new(CreateVolumeResponse)
@@ -267,6 +282,16 @@ func (c *dockerClient) InspectServiceContainer(ctx context.Context, in *InspectC
return out, nil 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) { func (c *dockerClient) ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListServiceContainersResponse) out := new(ListServiceContainersResponse)
@@ -305,11 +330,14 @@ type DockerServer interface {
// Docker auth credentials if necessary. // Docker auth credentials if necessary.
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error) InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error) ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error)
RemoveImage(context.Context, *RemoveImageRequest) (*RemoveImageResponse, error)
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error) ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error) RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error)
CreateServiceContainer(context.Context, *CreateServiceContainerRequest) (*CreateContainerResponse, error) CreateServiceContainer(context.Context, *CreateServiceContainerRequest) (*CreateContainerResponse, error)
InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, 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) ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error)
RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
mustEmbedUnimplementedDockerServer() mustEmbedUnimplementedDockerServer()
@@ -358,6 +386,9 @@ func (UnimplementedDockerServer) InspectRemoteImage(context.Context, *InspectRem
func (UnimplementedDockerServer) ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error) { func (UnimplementedDockerServer) ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListImages not implemented") return nil, status.Errorf(codes.Unimplemented, "method ListImages not implemented")
} }
func (UnimplementedDockerServer) RemoveImage(context.Context, *RemoveImageRequest) (*RemoveImageResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RemoveImage not implemented")
}
func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) { func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented") return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented")
} }
@@ -373,6 +404,9 @@ func (UnimplementedDockerServer) CreateServiceContainer(context.Context, *Create
func (UnimplementedDockerServer) InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error) { func (UnimplementedDockerServer) InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error) {
return nil, status.Errorf(codes.Unimplemented, "method InspectServiceContainer not implemented") 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) { func (UnimplementedDockerServer) ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListServiceContainers not implemented") return nil, status.Errorf(codes.Unimplemented, "method ListServiceContainers not implemented")
} }
@@ -591,6 +625,24 @@ func _Docker_ListImages_Handler(srv interface{}, ctx context.Context, dec func(i
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _Docker_RemoveImage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveImageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DockerServer).RemoveImage(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Docker_RemoveImage_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DockerServer).RemoveImage(ctx, req.(*RemoveImageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateVolumeRequest) in := new(CreateVolumeRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@@ -681,6 +733,24 @@ func _Docker_InspectServiceContainer_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler) 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) { func _Docker_ListServiceContainers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListServiceContainersRequest) in := new(ListServiceContainersRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@@ -760,6 +830,10 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListImages", MethodName: "ListImages",
Handler: _Docker_ListImages_Handler, Handler: _Docker_ListImages_Handler,
}, },
{
MethodName: "RemoveImage",
Handler: _Docker_RemoveImage_Handler,
},
{ {
MethodName: "CreateVolume", MethodName: "CreateVolume",
Handler: _Docker_CreateVolume_Handler, Handler: _Docker_CreateVolume_Handler,
@@ -780,6 +854,10 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
MethodName: "InspectServiceContainer", MethodName: "InspectServiceContainer",
Handler: _Docker_InspectServiceContainer_Handler, Handler: _Docker_InspectServiceContainer_Handler,
}, },
{
MethodName: "UpdateServiceContainer",
Handler: _Docker_UpdateServiceContainer_Handler,
},
{ {
MethodName: "ListServiceContainers", MethodName: "ListServiceContainers",
Handler: _Docker_ListServiceContainers_Handler, Handler: _Docker_ListServiceContainers_Handler,
+1 -1
View File
@@ -20,7 +20,7 @@ service Machine {
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines. // InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
rpc InspectMachine(google.protobuf.Empty) returns (InspectMachineResponse); rpc InspectMachine(google.protobuf.Empty) returns (InspectMachineResponse);
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with // WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
// no known missing or pending transactions through those versions. // no known missing or pending transactions through those versions from active members.
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes // Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
// have been superseded. // have been superseded.
// //
+2 -2
View File
@@ -48,7 +48,7 @@ type MachineClient interface {
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines. // InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
InspectMachine(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectMachineResponse, error) InspectMachine(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectMachineResponse, error)
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with // WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
// no known missing or pending transactions through those versions. // no known missing or pending transactions through those versions from active members.
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes // Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
// have been superseded. // have been superseded.
// //
@@ -224,7 +224,7 @@ type MachineServer interface {
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines. // InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
InspectMachine(context.Context, *emptypb.Empty) (*InspectMachineResponse, error) InspectMachine(context.Context, *emptypb.Empty) (*InspectMachineResponse, error)
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with // WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
// no known missing or pending transactions through those versions. // no known missing or pending transactions through those versions from active members.
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes // Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
// have been superseded. // have been superseded.
// //
-165
View File
@@ -1,165 +0,0 @@
package caddystorage
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"time"
"github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/distlock"
)
const (
lockPrefix = "caddy_storage:"
storeReplicationTimeout = 10 * time.Second
)
// Lock acquires an automatically renewed distributed lock and waits for the local store to catch up with versions
// observed on responding machines. Writers using the same lock can then read locally. Unavailable machines may have
// writes that this wait does not cover, and reads outside a lock remain eventually consistent.
func (s *Storage) Lock(ctx context.Context, name string) (err error) {
if name == "" {
return errors.New("lock name is empty")
}
// Count the call first. If Cleanup has already observed zero, Caddy has cancelled s.ctx and the check below rejects
// this call before it uses the client.
s.lockOps.Add(1)
// A failed Lock owns its lifecycle through any lease rollback. A successful Lock transfers that responsibility to
// Unlock, which keeps the client open until its release attempt finishes.
defer func() {
if err != nil {
s.lockOps.Add(-1)
}
}()
if s.ctx.Err() != nil {
return errors.New("storage is closed")
}
// Stop acquisition retries when Caddy unloads the module (cancels s.ctx), even if the caller's context
// is still active.
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
stopOnCleanup := context.AfterFunc(s.ctx, func() {
cancel(context.Cause(s.ctx))
})
defer stopOnCleanup()
log := s.log.With("lock", name)
started := time.Now()
log.Debug("acquiring lock", "ttl", time.Duration(s.LockTTL))
lease, err := s.locker.Acquire(ctx, lockPrefix+name)
if err != nil {
log.Debug("failed to acquire lock", "duration", time.Since(started), "error", err)
return fmt.Errorf("acquire lock '%s': %w", name, err)
}
log.Debug("lock lease acquired", "duration", time.Since(started))
// Release the lease if the lock acquisition fails after this point.
// Unlock will take care of releasing the lease on success.
defer func() {
if err == nil {
return
}
if releaseErr := s.releaseLock(ctx, name, lease, "cancelled acquisition"); releaseErr != nil {
err = errors.Join(err, releaseErr)
}
}()
// Catch up with the latest store versions observed on responding machines to increase the chance of reading
// the latest writes on them locally.
version, machines, err := s.clusterStoreVersion(ctx, log)
if err != nil {
return err
}
waitStarted := time.Now()
log.Debug("waiting for local store replication", "machine_names", machines, "store_version", version)
waitCtx, cancelWait := context.WithTimeout(ctx, storeReplicationTimeout)
err = s.client.WaitForStoreVersion(waitCtx, version)
cancelWait()
if err != nil {
return fmt.Errorf("wait for local store replication: %w", err)
}
log.Debug("local store replication complete", "duration", time.Since(waitStarted))
s.locksMu.Lock()
defer s.locksMu.Unlock()
if s.ctx.Err() != nil {
return errors.New("storage is closed")
}
if lost := context.Cause(lease.Context()); lost != nil {
return lost
}
if _, exists := s.locks[name]; exists {
return errors.New("lock is already tracked by this storage instance")
}
s.locks[name] = lease
log.Debug("lock acquired", "duration", time.Since(started))
return nil
}
// clusterStoreVersion returns the per-actor maximum store versions from responding machines and their names.
func (s *Storage) clusterStoreVersion(ctx context.Context, log *slog.Logger) (map[string]uint64, []string, error) {
ctx, cancel := context.WithTimeout(ctx, distlock.DefaultMaxNodeCallTimeout)
defer cancel()
resp, err := s.client.MachineClient.InspectMachine(client.ProxyMachinesContext(ctx, nil), nil)
if err != nil {
return nil, nil, fmt.Errorf("inspect machines for store versions: %w", err)
}
maxVersion := make(map[string]uint64)
machines := make([]string, 0, len(resp.Machines))
for _, m := range resp.Machines {
if m.Metadata.Error != "" {
log.Warn("skipping machine when collecting store versions",
"id", m.Metadata.MachineId, "name", m.Metadata.MachineName, "error", m.Metadata.Error)
continue
}
machines = append(machines, m.Metadata.MachineName)
for actor, v := range m.StoreVersion {
maxVersion[actor] = max(maxVersion[actor], v)
}
}
slices.Sort(machines)
return maxVersion, machines, nil
}
// Unlock releases a previously acquired distributed lock.
func (s *Storage) Unlock(ctx context.Context, name string) error {
s.locksMu.Lock()
lease, exists := s.locks[name]
if exists {
delete(s.locks, name)
}
s.locksMu.Unlock()
if !exists {
return fmt.Errorf("lock '%s' is not held by this storage instance", name)
}
defer s.lockOps.Add(-1)
// Release stops renewal even on error. Any nodes that cannot be reached will let the lease expire.
if err := s.releaseLock(ctx, name, lease, "unlock"); err != nil {
return fmt.Errorf("release lock '%s': %w", name, err)
}
return nil
}
// releaseLock releases the lease with a context that ignores cancellation and logs the release attempt.
func (s *Storage) releaseLock(ctx context.Context, name string, lease *distlock.Lease, reason string) error {
// Unlock and rollback must attempt node cleanup even if the caller or module has already been cancelled.
ctx = context.WithoutCancel(ctx)
log := s.log.With("lock", name, "reason", reason)
started := time.Now()
log.Debug("releasing lock")
if err := lease.Release(ctx); err != nil {
log.Debug("failed to release lock", "duration", time.Since(started), "error", err)
return err
}
log.Debug("lock released", "duration", time.Since(started))
return nil
}
-211
View File
@@ -1,211 +0,0 @@
// Package caddystorage provides Caddy storage backed by an Uncloud cluster.
package caddystorage
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/certmagic"
"github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/connector"
"github.com/psviderski/uncloud/pkg/distlock"
)
const (
// ModuleID is the Caddy module ID for Uncloud storage.
ModuleID = "caddy.storage.uncloud"
// DefaultSocketPath is the default path to the Uncloud API socket.
DefaultSocketPath = "/run/uncloud/api/uncloud.sock"
// DefaultLockTTL is the default duration of a distributed lock lease.
DefaultLockTTL = 20 * time.Second
// lockCleanupTimeout bounds how long an unloaded module waits for active lock operations when cleaning up.
lockCleanupTimeout = 5 * time.Minute
)
//nolint:gochecknoinits // Caddy modules must register during package initialisation.
func init() {
caddy.RegisterModule(new(Storage))
}
// Storage implements a Caddy storage backend that uses an Uncloud cluster to store assets such as TLS certificates.
type Storage struct {
// Socket is the path to the Uncloud API socket.
// Defaults to /run/uncloud/api/uncloud.sock when not set.
Socket string `json:"socket,omitempty"`
// LockTTL is the duration of a distributed lock after which it expires if not renewed. Locks renew automatically
// until unlocked. If an instance crashes or cannot renew, expiry allows another instance to acquire the stale lock.
// Longer durations tolerate longer interruptions but delay recovery after a crash. Normal unlocks release the lock
// immediately.
// Defaults to 20 seconds when not set.
LockTTL caddy.Duration `json:"lock_ttl,omitempty"`
client *client.Client
locker *distlock.Locker
log *slog.Logger
// ctx is the module context from Provision. Caddy cancels it when it unloads the module and calls Cleanup.
ctx context.Context
// locksMu protects locks.
locksMu sync.Mutex
// locks maps successfully acquired lock names to held leases.
locks map[string]*distlock.Lease
// lockOps counts Lock calls until they fail or their locks are released.
lockOps atomic.Int64
}
// CaddyModule returns the Caddy module information.
func (*Storage) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: ModuleID,
New: func() caddy.Module { return new(Storage) },
}
}
// Provision connects the storage to the local Uncloud API and initialises the distributed locker.
func (s *Storage) Provision(ctx caddy.Context) error {
s.ctx = ctx
s.log = ctx.Slogger()
if s.Socket == "" {
s.Socket = DefaultSocketPath
}
if s.LockTTL == 0 {
s.LockTTL = caddy.Duration(DefaultLockTTL)
}
if s.LockTTL < 0 {
return errors.New("lock_ttl must be positive")
}
cli, err := client.New(ctx, connector.NewUnixConnector(s.Socket))
if err != nil {
return fmt.Errorf("connect to Uncloud API: %w", err)
}
locker, err := cli.NewLocker(distlock.Config{
LeaseDuration: time.Duration(s.LockTTL),
})
if err != nil {
_ = cli.Close()
return fmt.Errorf("create distributed locker: %w", err)
}
s.client = cli
s.locker = locker
s.locks = make(map[string]*distlock.Lease)
s.log.Info("module provisioned", "socket", s.Socket, "lock_ttl", time.Duration(s.LockTTL))
return nil
}
// Cleanup keeps the Uncloud API connection open so acquired lock leases can renew until Caddy releases them. It closes
// the connection after all lock operations finish or the cleanup timeout expires.
func (s *Storage) Cleanup() error {
if s.client == nil {
// Provision failed before opening the storage.
return nil
}
started := time.Now()
s.log.Debug("cleaning up module", "locks", s.lockOps.Load())
go func() {
timer := time.NewTimer(lockCleanupTimeout)
defer timer.Stop()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
timedOut := false
for !timedOut && s.lockOps.Load() > 0 {
select {
case <-ticker.C:
case <-timer.C:
timedOut = true
}
}
if remaining := s.lockOps.Load(); timedOut && remaining > 0 {
s.log.Warn("timed out waiting for active locks to be unlocked",
"locks", remaining, "timeout", lockCleanupTimeout)
}
if err := s.client.Close(); err != nil {
s.log.Warn("failed to clean up module", "duration", time.Since(started), "error", err)
return
}
s.log.Debug("module cleanup complete", "duration", time.Since(started))
}()
return nil
}
// CertMagicStorage returns the provisioned CertMagic storage implementation.
func (s *Storage) CertMagicStorage() (certmagic.Storage, error) {
return s, nil
}
// UnmarshalCaddyfile configures Uncloud storage from the Caddyfile global storage block.
//
// {
// storage uncloud {
// socket /run/uncloud/api/uncloud.sock
// lock_ttl 20s
// }
// }
func (s *Storage) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
d.Next() // Skip the module name 'uncloud'.
// Reject inline arguments. NextArg leaves an opening brace for NextBlock.
if d.NextArg() {
return d.ArgErr()
}
// Read the optional options block, skipping its surrounding braces.
for d.NextBlock(0) {
switch d.Val() {
case "socket":
// Require a socket path on the same line as 'socket' option.
if !d.NextArg() {
return d.ArgErr()
}
s.Socket = d.Val()
// Reject extra arguments after the socket path.
if d.NextArg() {
return d.ArgErr()
}
case "lock_ttl":
if !d.NextArg() {
return d.ArgErr()
}
ttl, err := caddy.ParseDuration(d.Val())
if err != nil {
return d.Errf("invalid lock_ttl '%s': %w", d.Val(), err)
}
if ttl <= 0 {
return d.Err("lock_ttl must be positive")
}
if d.NextArg() {
return d.ArgErr()
}
s.LockTTL = caddy.Duration(ttl)
default:
return d.Errf("unknown uncloud storage option: '%s'", d.Val())
}
}
return nil
}
var (
_ caddy.Module = (*Storage)(nil)
_ caddy.Provisioner = (*Storage)(nil)
_ caddy.CleanerUpper = (*Storage)(nil)
_ caddy.StorageConverter = (*Storage)(nil)
_ caddyfile.Unmarshaler = (*Storage)(nil)
_ certmagic.Storage = (*Storage)(nil)
)
-83
View File
@@ -1,83 +0,0 @@
package caddystorage
import (
"context"
"fmt"
"io/fs"
"github.com/caddyserver/certmagic"
"github.com/psviderski/uncloud/api/pb"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Store writes a value to the cluster store.
func (s *Storage) Store(ctx context.Context, key string, value []byte) error {
if _, err := s.client.CaddyStorage.Store(ctx, &pb.StoreCaddyStorageRequest{Key: key, Value: value}); err != nil {
return storageError("store", key, err)
}
return nil
}
// Load reads a value from the cluster store.
func (s *Storage) Load(ctx context.Context, key string) ([]byte, error) {
resp, err := s.client.CaddyStorage.Load(ctx, &pb.LoadCaddyStorageRequest{Key: key})
if err != nil {
return nil, storageError("load", key, err)
}
return resp.Value, nil
}
// Delete removes a key and its descendants from the cluster store.
func (s *Storage) Delete(ctx context.Context, key string) error {
if _, err := s.client.CaddyStorage.Delete(ctx, &pb.DeleteCaddyStorageRequest{Key: key}); err != nil {
return storageError("delete", key, err)
}
return nil
}
// Exists reports whether a key exists in the cluster store.
func (s *Storage) Exists(ctx context.Context, key string) bool {
_, err := s.Stat(ctx, key)
return err == nil
}
// List returns keys under prefix from the cluster store.
func (s *Storage) List(ctx context.Context, prefix string, recursive bool) ([]string, error) {
resp, err := s.client.CaddyStorage.List(ctx, &pb.ListCaddyStorageRequest{
Prefix: prefix,
Recursive: recursive,
})
if err != nil {
return nil, storageError("list", prefix, err)
}
return resp.Keys, nil
}
// Stat returns information about a key in the cluster store.
func (s *Storage) Stat(ctx context.Context, key string) (certmagic.KeyInfo, error) {
resp, err := s.client.CaddyStorage.Stat(ctx, &pb.StatCaddyStorageRequest{Key: key})
if err != nil {
return certmagic.KeyInfo{}, storageError("stat", key, err)
}
info := certmagic.KeyInfo{
Key: resp.Key,
Size: resp.Size,
IsTerminal: resp.IsTerminal,
}
if resp.UpdatedAt != nil {
if err := resp.UpdatedAt.CheckValid(); err != nil {
return certmagic.KeyInfo{}, storageError("stat", key, fmt.Errorf("invalid updated_at timestamp: %w", err))
}
info.Modified = resp.UpdatedAt.AsTime()
}
return info, nil
}
func storageError(operation, key string, err error) error {
if status.Code(err) == codes.NotFound {
err = fs.ErrNotExist
}
return fmt.Errorf("uncloud storage: %s key %q: %w", operation, key, err)
}
+171
View File
@@ -0,0 +1,171 @@
package cert
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"slices"
"strings"
"time"
"charm.land/lipgloss/v2"
"github.com/caddyserver/certmagic"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/version"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra"
)
type listOptions struct {
machine string
output string
}
func NewListCommand() *cobra.Command {
opts := listOptions{}
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List certificates in cluster storage for Caddy.",
Long: `List certificates stored in the Uncloud cluster storage for Caddy.
Caddy uses cluster storage by default. Certificates don't appear here if Caddy uses local storage,
for example, if it runs with a custom global config without 'storage uncloud' or was deployed with
uc v0.21.0 or earlier and not upgraded.
This inventory does not check whether Caddy currently serves or trusts a certificate.
See ` + version.DocsURL + `/concepts/ingress/cluster-storage-for-caddy for details.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(cmd.Context(), uncli, opts)
},
}
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
"Name or ID of the machine to read certificate storage from. (default is connected machine)")
cmd.Flags().StringVarP(&opts.output, "output", "o", "",
"Output format: 'json' or empty for a human-readable table.")
completion.MachinesFlag(cmd)
return cmd
}
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
if opts.output != "" && opts.output != "json" {
return fmt.Errorf("unsupported output format '%s' (supported: json)", opts.output)
}
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
certs, listErr := clusterClient.Caddy.ListCertificates(ctx,
client.CaddyListCertificatesOptions{Machine: opts.machine})
if len(certs) > 0 || listErr == nil {
if err = printCertificates(os.Stdout, certs, opts.output); err != nil {
return fmt.Errorf("print Caddy certificates: %w", err)
}
}
if listErr != nil {
if len(certs) > 0 {
return fmt.Errorf("certificate list is partial: %w", listErr)
}
return fmt.Errorf("list Caddy certificates: %w", listErr)
}
return nil
}
func printCertificates(out io.Writer, certs []api.IssuedCertificate, output string) error {
type certificate struct {
api.IssuedCertificate
Fingerprint string
}
items := make([]certificate, 0, len(certs))
for _, cert := range certs {
fingerprint := cert.Fingerprint()
items = append(items, certificate{
IssuedCertificate: cert,
Fingerprint: hex.EncodeToString(fingerprint[:]),
})
}
slices.SortFunc(items, func(a, b certificate) int {
if cmp := strings.Compare(a.SAN, b.SAN); cmp != 0 {
return cmp
}
if cmp := a.Chain[0].NotAfter.Compare(b.Chain[0].NotAfter); cmp != 0 {
return cmp
}
return strings.Compare(a.Fingerprint, b.Fingerprint)
})
if output == "json" {
encoder := json.NewEncoder(out)
encoder.SetIndent("", " ")
if err := encoder.Encode(items); err != nil {
return fmt.Errorf("marshal certificates: %w", err)
}
return nil
}
if len(items) == 0 {
_, err := fmt.Fprintln(out, "No Caddy certificates found in cluster storage.")
return err
}
t := tui.NewTable()
t.Headers("ID", "NAME", "ISSUER", "EXPIRES")
for _, item := range items {
t.Row(
item.Fingerprint[:12],
item.SAN,
formatIssuer(item.IssuerData),
formatExpiry(item.Chain[0].NotAfter),
)
}
_, err := lipgloss.Fprintln(out, t)
return err
}
func formatIssuer(data api.CertificateIssuerData) string {
if data.ACME == nil {
return "unknown"
}
switch data.ACME.CA {
case certmagic.LetsEncryptProductionCA:
return "Let's Encrypt"
case certmagic.LetsEncryptStagingCA:
return "Let's Encrypt (staging)"
case certmagic.ZeroSSLProductionCA:
return "ZeroSSL"
case certmagic.GoogleTrustProductionCA:
return "Google Trust Services"
case certmagic.GoogleTrustStagingCA:
return "Google Trust Services (staging)"
default:
return data.ACME.CA
}
}
func formatExpiry(expires time.Time) string {
delta := expires.Sub(time.Now())
if delta <= 0 {
return fmt.Sprintf("%s (expired %s ago)", expires.UTC().Format(time.DateOnly),
strings.ToLower(units.HumanDuration(-delta)))
}
return fmt.Sprintf("%s (%s)", expires.UTC().Format(time.DateOnly),
strings.ToLower(units.HumanDuration(delta)))
}
+128
View File
@@ -0,0 +1,128 @@
package cert
import (
"bytes"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/json"
"strings"
"testing"
"time"
"github.com/caddyserver/certmagic"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPrintCertificates(t *testing.T) {
now := time.Now().UTC().Truncate(time.Hour)
certs := []api.IssuedCertificate{
testIssuedCertificate("z.example.com", "z", now.Add(48*time.Hour), certmagic.LetsEncryptProductionCA),
testIssuedCertificate("a.example.com", "old", now.Add(-48*time.Hour), certmagic.LetsEncryptStagingCA),
testIssuedCertificate("a.example.com", "new", now.Add(72*time.Hour), "https://ca.example/directory"),
testIssuedCertificate("unknown.example.com", "unknown", now.Add(96*time.Hour), ""),
}
var table bytes.Buffer
require.NoError(t, printCertificates(&table, certs, ""))
output := table.String()
assert.Contains(t, output, "ID")
assert.Contains(t, output, "NAME")
assert.Contains(t, output, "ISSUER")
assert.Contains(t, output, "EXPIRES")
assert.Contains(t, output, now.Add(-48*time.Hour).Format(time.DateOnly)+" (expired ")
assert.Contains(t, output, now.Add(48*time.Hour).Format(time.DateOnly)+" (")
assert.Contains(t, output, "Let's Encrypt (staging)")
assert.Contains(t, output, "https://ca.example/directory")
assert.Less(t, strings.Index(output, now.Add(-48*time.Hour).Format(time.DateOnly)),
strings.Index(output, now.Add(72*time.Hour).Format(time.DateOnly)))
assert.Less(t, strings.Index(output, "a.example.com"), strings.Index(output, "z.example.com"))
assert.Contains(t, output, testFingerprint("old")[:12])
var encoded bytes.Buffer
require.NoError(t, printCertificates(&encoded, certs, "json"))
var items []struct {
api.IssuedCertificate
Fingerprint string
}
require.NoError(t, json.Unmarshal(encoded.Bytes(), &items))
require.Len(t, items, 4)
assert.Equal(t, testFingerprint("old"), items[0].Fingerprint)
assert.Equal(t, "a.example.com", items[0].SAN)
require.Len(t, items[0].Chain, 1)
assert.Equal(t, []byte("old"), items[0].Chain[0].Raw)
assert.Equal(t, now.Add(-48*time.Hour), items[0].Chain[0].NotAfter)
require.NotNil(t, items[0].IssuerData.ACME)
assert.Equal(t, certmagic.LetsEncryptStagingCA, items[0].IssuerData.ACME.CA)
assert.Equal(t, testFingerprint("new"), items[1].Fingerprint)
require.NotNil(t, items[1].IssuerData.ACME)
assert.Equal(t, "https://ca.example/directory", items[1].IssuerData.ACME.CA)
assert.Nil(t, items[2].IssuerData.ACME)
assert.Equal(t, "z.example.com", items[3].SAN)
}
func TestPrintCertificates_Empty(t *testing.T) {
for _, tt := range []struct {
output string
want string
}{
{output: "", want: "No Caddy certificates found in cluster storage.\n"},
{output: "json", want: "[]\n"},
} {
var out bytes.Buffer
require.NoError(t, printCertificates(&out, nil, tt.output))
assert.Equal(t, tt.want, out.String())
}
}
func TestFormatIssuer(t *testing.T) {
for _, tt := range []struct {
name string
ca string
want string
}{
{name: "production", ca: certmagic.LetsEncryptProductionCA, want: "Let's Encrypt"},
{name: "staging", ca: certmagic.LetsEncryptStagingCA, want: "Let's Encrypt (staging)"},
{name: "ZeroSSL", ca: certmagic.ZeroSSLProductionCA, want: "ZeroSSL"},
{name: "Google Trust Services", ca: certmagic.GoogleTrustProductionCA, want: "Google Trust Services"},
{name: "Google Trust Services staging", ca: certmagic.GoogleTrustStagingCA, want: "Google Trust Services (staging)"},
{name: "other", ca: "https://ca.example/directory", want: "https://ca.example/directory"},
} {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, formatIssuer(api.CertificateIssuerData{
ACME: &api.ACMEIssuerData{CA: tt.ca},
}))
})
}
assert.Equal(t, "unknown", formatIssuer(api.CertificateIssuerData{}))
}
func TestFormatExpiry_UTC(t *testing.T) {
now := time.Now().UTC()
brisbane := time.FixedZone("AEST", 10*60*60)
expires := time.Date(now.Year(), now.Month(), now.Day()+3, 3, 0, 0, 0, brisbane)
assert.True(t, strings.HasPrefix(formatExpiry(expires), expires.UTC().Format(time.DateOnly)+" ("))
assert.NotEqual(t, expires.Format(time.DateOnly), expires.UTC().Format(time.DateOnly))
expired := now.Add(-2 * time.Hour)
assert.True(t, strings.HasPrefix(formatExpiry(expired), expired.UTC().Format(time.DateOnly)+" (expired "))
assert.Contains(t, formatExpiry(expired), " ago)")
}
func testIssuedCertificate(name, raw string, expires time.Time, ca string) api.IssuedCertificate {
cert := api.IssuedCertificate{
SAN: name,
Chain: []*x509.Certificate{{Raw: []byte(raw), NotAfter: expires}},
}
if ca != "" {
cert.IssuerData.ACME = &api.ACMEIssuerData{CA: ca}
}
return cert
}
func testFingerprint(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
+12
View File
@@ -0,0 +1,12 @@
package cert
import "github.com/spf13/cobra"
func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cert",
Short: "Inspect certificates in cluster storage for Caddy.",
}
cmd.AddCommand(NewListCommand())
return cmd
}
+6 -17
View File
@@ -3,18 +3,18 @@ package caddy
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"charm.land/lipgloss/v2"
"github.com/alecthomas/chroma/v2/quick" "github.com/alecthomas/chroma/v2/quick"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
type configOptions struct { type configOptions struct {
machine string machine string
noColor bool
} }
func NewConfigCommand() *cobra.Command { func NewConfigCommand() *cobra.Command {
@@ -32,8 +32,6 @@ func NewConfigCommand() *cobra.Command {
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "", cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
"Name or ID of the machine to get the configuration from. (default is connected machine)") "Name or ID of the machine to get the configuration from. (default is connected machine)")
cmd.Flags().BoolVar(&opts.noColor, "no-color", false,
"Disable syntax highlighting for the output.")
completion.MachinesFlag(cmd) completion.MachinesFlag(cmd)
@@ -47,24 +45,15 @@ func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
} }
defer clusterClient.Close() defer clusterClient.Close()
if opts.machine != "" { config, err := clusterClient.Caddy.Config(ctx, client.CaddyConfigOptions{Machine: opts.machine})
// If a specific machine is requested, use it to get the Caddy configuration.
ctx = clusterClient.ProxySingleMachineContext(ctx, opts.machine)
}
config, err := clusterClient.Caddy.GetConfig(ctx, nil)
if err != nil { if err != nil {
return fmt.Errorf("get Caddy config: %w", err) return fmt.Errorf("get Caddy config: %w", err)
} }
// Print the Caddyfile with syntax highlighting. // Print the Caddyfile with syntax highlighting.
if opts.noColor { if err = quick.Highlight(lipgloss.Writer, config.Caddyfile, "caddy", "terminal256", "monokai"); err != nil {
fmt.Print(config.Caddyfile) // If highlighting fails, fall back to plain output.
} else { lipgloss.Print(config.Caddyfile)
if err = quick.Highlight(os.Stdout, config.Caddyfile, "caddy", "terminal256", "monokai"); err != nil {
// If highlighting fails, fall back to plain output.
fmt.Print(config.Caddyfile)
}
} }
if config.LastReconciliationError != "" { if config.LastReconciliationError != "" {
+192 -18
View File
@@ -16,6 +16,7 @@ import (
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/version"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -25,6 +26,10 @@ type deployOptions struct {
caddyfile string caddyfile string
image string image string
machines []string machines []string
caddyfileChanged bool
imageChanged bool
machinesChanged bool
} }
func NewDeployCommand() *cobra.Command { func NewDeployCommand() *cobra.Command {
@@ -33,21 +38,62 @@ func NewDeployCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "deploy", Use: "deploy",
Short: "Deploy or upgrade Caddy reverse proxy across all machines in the cluster.", Short: "Deploy or upgrade Caddy reverse proxy across all machines in the cluster.",
Long: "Deploy or upgrade Caddy reverse proxy across all machines in the cluster.\n" + 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.", A rolling update is performed when updating existing containers to minimise disruption.
By default, this deploys the latest version of the Uncloud Caddy image with a global config that enables cluster
storage for TLS certificates. See https://github.com/unlabs-dev/caddy-uncloud for more details.
Values set with --image, --caddyfile, and --machine are kept for later deployments. Pass an empty value to reset
a flag to its default.
If this command didn't deploy the current caddy service, for example, it was deployed with 'uc deploy'
from a Compose file or an older version of uc, the deployment replaces its configuration.
Requires Uncloud daemon ` + client.MinCaddyDaemonVersion + ` or newer on all machines.`,
Example: ` # Deploy Caddy or upgrade it to the latest version of the Uncloud Caddy image.
uc caddy deploy
# Deploy a custom image and keep it for later deployments.
uc caddy deploy --image ghcr.io/unlabs-dev/caddy-uncloud:0.1.3
# Go back to the latest version of the Uncloud Caddy image.
uc caddy deploy --image ""
# Deploy a custom image build and a custom global Caddy config and keep both for later deployments.
uc caddy deploy --image myorg/caddy-uncloud:latest --caddyfile global.Caddyfile
# Go back to the default global Caddy config but keep the custom image.
uc caddy deploy --caddyfile ""
# Deploy only to specific machines and keep them for later deployments.
uc caddy deploy --machine machine1,machine2
# Go back to deploying to all machines.
uc caddy deploy --machine ""`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI) 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) return runDeploy(cmd.Context(), uncli, opts)
}, },
} }
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "", cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
"Path to a custom global Caddy config (Caddyfile) that will be prepended to the auto-generated Caddy config.") "Path to a custom global Caddy config (Caddyfile) that will be prepended to the auto-generated Caddy config.\n"+
"An empty value resets it to the default config that enables cluster storage.\n"+
"(default is the current custom config or the default config)")
cmd.Flags().StringVar(&opts.image, "image", "", cmd.Flags().StringVar(&opts.image, "image", "",
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)") "Caddy Docker image to deploy. It must include the Uncloud storage module if the Caddy config uses it.\n"+
"An empty value resets it to the latest version of the Uncloud Caddy image.\n"+
"(default is the current custom image or "+client.CaddyImage+":LATEST_VERSION)")
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil, 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 "+ "Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated list.\n"+
"list. (default is all machines)") "An empty value resets it to all machines.\n"+
"(default is the current machines or all machines)")
completion.MachinesFlag(cmd) completion.MachinesFlag(cmd)
@@ -55,13 +101,13 @@ func NewDeployCommand() *cobra.Command {
} }
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
caddyfile := "" config := ""
if opts.caddyfile != "" { if opts.caddyfile != "" {
data, err := os.ReadFile(opts.caddyfile) data, err := os.ReadFile(opts.caddyfile)
if err != nil { if err != nil {
return fmt.Errorf("read Caddyfile: %w", err) return fmt.Errorf("read Caddyfile: %w", err)
} }
caddyfile = strings.TrimSpace(string(data)) config = strings.TrimSpace(string(data))
} }
clusterClient, err := uncli.ConnectCluster(ctx) clusterClient, err := uncli.ConnectCluster(ctx)
@@ -70,6 +116,10 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
} }
defer clusterClient.Close() defer clusterClient.Close()
if err = clusterClient.Caddy.CheckDaemonVersions(ctx); err != nil {
return err
}
svc, err := clusterClient.InspectService(ctx, client.CaddyServiceName) svc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
if err != nil { if err != nil {
if !errors.Is(err, api.ErrNotFound) { if !errors.Is(err, api.ErrNotFound) {
@@ -100,24 +150,48 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
} }
} }
if opts.image != "" { current := svc.Spec()
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(opts.image, tui.Green)) deployOpts := resolveDeploymentOptions(current, opts, config)
configChanged := deployOpts.ConfigOrDefault() != current.CaddyConfig()
if svc.ID != "" && configChanged {
printCaddyConfig("current global Caddy config:", current.CaddyConfig())
} }
fmt.Println() fmt.Println()
fmt.Println("Preparing a deployment plan...") fmt.Println("Preparing a deployment plan...")
placement := api.Placement{ d, err := clusterClient.Caddy.NewDeployment(ctx, deployOpts)
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
}
d, err := clusterClient.NewCaddyDeployment(opts.image, caddyfile, placement)
if err != nil { if err != nil {
return fmt.Errorf("create caddy deployment: %w", err) return fmt.Errorf("create caddy deployment: %w", err)
} }
target := d.Spec
if opts.image == "" { // The Uncloud Caddy image resolves to the latest version allowed by this CLI, which is older than the running image
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(d.Spec.Container.Image, // if a newer CLI deployed it. Continuing would downgrade the image and replace its default config.
tui.Green) + tui.Faint.Render(" (latest stable)")) if deployOpts.Image == "" && client.IsNewerCaddyImage(current.Container.Image, target.Container.Image) {
return fmt.Errorf("caddy service runs image '%s' which is newer than the latest version '%s' supported by "+
"this uc version: upgrade uc or specify the image with --image",
current.Container.Image, target.Container.Image)
}
imageNote := " (latest)"
if deployOpts.Image != "" {
imageNote = " (custom)"
if !opts.imageChanged {
imageNote = " (custom, use --image \"\" for the latest version)"
}
}
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(target.Container.Image, tui.Green) +
tui.Faint.Render(imageNote))
if configChanged {
if deployOpts.Config != nil {
// The user provided this config so there is no need to print its content.
fmt.Println(tui.Faint.Render("target global Caddy config: ") + opts.caddyfile)
} else {
printCaddyConfig("target global Caddy config: (default)", deployOpts.ConfigOrDefault())
}
} }
plan, err := d.Plan(ctx) plan, err := d.Plan(ctx)
@@ -129,7 +203,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName) fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
} else { } else {
if svc.ID == "" { if svc.ID == "" {
if len(opts.machines) > 0 { if len(deployOpts.Placement.Machines) > 0 {
fmt.Println("This will run a Caddy container on each selected machine.") fmt.Println("This will run a Caddy container on each selected machine.")
} else { } else {
fmt.Println("This will run a Caddy container on each machine.") fmt.Println("This will run a Caddy container on each machine.")
@@ -161,6 +235,14 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
fmt.Println(summary) fmt.Println(summary)
fmt.Println() fmt.Println()
warnings := deploymentWarnings(current, target, opts)
for _, warning := range warnings {
tui.PrintWarning(warning)
}
if len(warnings) > 0 {
fmt.Println()
}
title := "Proceed with deployment?" title := "Proceed with deployment?"
// Include the direct connection or context name in the confirmation prompt to avoid accidentally // Include the direct connection or context name in the confirmation prompt to avoid accidentally
// deploying to the wrong cluster. // deploying to the wrong cluster.
@@ -193,6 +275,98 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
return UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut()) 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 { func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, progressOut *streams.Out) error {
domain, err := clusterClient.GetDomain(ctx) domain, err := clusterClient.GetDomain(ctx)
if err != nil { if err != nil {
+263
View File
@@ -0,0 +1,263 @@
package caddy
import (
"strings"
"testing"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
"github.com/stretchr/testify/assert"
)
func TestResolveDeploymentOptions(t *testing.T) {
t.Parallel()
const (
customImage = "example.com/caddy:1.2.3"
customConfig = "{\n\tdebug\n}"
)
machines := api.Placement{Machines: []string{"m1", "m2"}}
// A service with a custom image, config, and machines that has the given labels.
service := func(labels ...string) api.ServiceSpec {
spec := api.ServiceSpec{
Name: client.CaddyServiceName,
Caddy: &api.CaddySpec{Config: customConfig},
Container: api.ContainerSpec{Image: customImage},
Placement: machines,
}
if len(labels) > 0 {
spec.Labels = make(map[string]string, len(labels))
for _, label := range labels {
spec.Labels[label] = ""
}
}
return spec
}
tests := []struct {
name string
current api.ServiceSpec
opts deployOptions
config string
want client.CaddyDeploymentOptions
}{
{
name: "no service gets the defaults",
},
{
name: "service that isn't managed gets the defaults",
current: service(),
},
{
name: "managed service follows the default image and config and keeps machines",
current: service(api.LabelCaddyManaged),
want: client.CaddyDeploymentOptions{Placement: machines},
},
{
name: "custom image is kept and config follows the default",
current: service(api.LabelCaddyManaged, api.LabelCaddyImageCustom),
want: client.CaddyDeploymentOptions{Image: customImage, Placement: machines},
},
{
name: "custom config is kept and image follows the latest version",
current: service(api.LabelCaddyManaged, api.LabelCaddyConfigCustom),
want: client.CaddyDeploymentOptions{Config: new(customConfig), Placement: machines},
},
{
name: "custom image and config are kept",
current: service(api.LabelCaddyManaged, api.LabelCaddyImageCustom, api.LabelCaddyConfigCustom),
want: client.CaddyDeploymentOptions{Image: customImage, Config: new(customConfig), Placement: machines},
},
{
name: "custom empty config is kept",
current: api.ServiceSpec{
Name: client.CaddyServiceName,
Labels: map[string]string{api.LabelCaddyManaged: "", api.LabelCaddyConfigCustom: ""},
},
want: client.CaddyDeploymentOptions{Config: new("")},
},
{
name: "image flag overrides the current image",
current: service(api.LabelCaddyManaged, api.LabelCaddyImageCustom),
opts: deployOptions{image: "caddy:2", imageChanged: true},
want: client.CaddyDeploymentOptions{Image: "caddy:2", Placement: machines},
},
{
name: "empty image flag resets to the latest version",
current: service(api.LabelCaddyManaged, api.LabelCaddyImageCustom),
opts: deployOptions{imageChanged: true},
want: client.CaddyDeploymentOptions{Placement: machines},
},
{
name: "caddyfile flag overrides the current config",
current: service(api.LabelCaddyManaged),
opts: deployOptions{caddyfile: "global.Caddyfile", caddyfileChanged: true},
config: "{\n\tstorage redis\n}",
want: client.CaddyDeploymentOptions{Config: new("{\n\tstorage redis\n}"), Placement: machines},
},
{
name: "caddyfile flag with an empty file sets a custom empty config",
current: service(api.LabelCaddyManaged),
opts: deployOptions{caddyfile: "empty.Caddyfile", caddyfileChanged: true},
want: client.CaddyDeploymentOptions{Config: new(""), Placement: machines},
},
{
name: "empty caddyfile flag resets to the default config",
current: service(api.LabelCaddyManaged, api.LabelCaddyConfigCustom),
opts: deployOptions{caddyfileChanged: true},
want: client.CaddyDeploymentOptions{Placement: machines},
},
{
name: "machine flag overrides the current machines",
current: service(api.LabelCaddyManaged),
opts: deployOptions{machines: []string{"m3,m4", "m5"}, machinesChanged: true},
want: client.CaddyDeploymentOptions{Placement: api.Placement{Machines: []string{"m3", "m4", "m5"}}},
},
{
name: "empty machine flag resets to all machines",
current: service(api.LabelCaddyManaged),
opts: deployOptions{machines: []string{}, machinesChanged: true},
},
{
name: "flags apply to a service that isn't managed",
current: service(),
opts: deployOptions{
image: "caddy:2", imageChanged: true,
caddyfile: "global.Caddyfile", caddyfileChanged: true,
machines: []string{"m3"}, machinesChanged: true,
},
config: customConfig,
want: client.CaddyDeploymentOptions{
Image: "caddy:2",
Config: new(customConfig),
Placement: api.Placement{Machines: []string{"m3"}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, resolveDeploymentOptions(tt.current, tt.opts, tt.config))
})
}
}
func TestDeploymentWarnings(t *testing.T) {
t.Parallel()
const (
defaultImage = client.CaddyImage + ":0.1.3"
customImage = "example.com/caddy:1.2.3"
localConfig = "{\n\tdebug\n}"
override = "doesn't manage this caddy service yet"
storage = "certificate storage changes from"
keepStorage = "To keep the current storage"
module = "must include the Uncloud storage module"
)
managed := map[string]string{api.LabelCaddyManaged: ""}
service := func(image, config string, labels map[string]string) api.ServiceSpec {
spec := api.ServiceSpec{
Name: client.CaddyServiceName,
Container: api.ContainerSpec{Image: image},
Labels: labels,
}
if config != "" {
spec.Caddy = &api.CaddySpec{Config: config}
}
return spec
}
tests := []struct {
name string
current api.ServiceSpec
target api.ServiceSpec
opts deployOptions
want []string
notWant []string
}{
{
name: "new service with defaults has no warnings",
target: service(defaultImage, client.DefaultCaddyConfig, managed),
notWant: []string{override, storage, module},
},
{
name: "new service with a custom image",
target: service(customImage, client.DefaultCaddyConfig, managed),
want: []string{module},
notWant: []string{override, storage},
},
{
name: "service that isn't managed moves to cluster storage",
current: service("caddy:2.11.4", "", nil),
target: service(defaultImage, client.DefaultCaddyConfig, managed),
want: []string{override, "from local to cluster", keepStorage},
notWant: []string{module},
},
{
name: "service that isn't managed already uses cluster storage",
current: service(defaultImage, client.DefaultCaddyConfig, nil),
target: service(defaultImage, client.DefaultCaddyConfig, managed),
want: []string{override},
notWant: []string{storage, module},
},
{
name: "managed service without changes has no warnings",
current: service(defaultImage, client.DefaultCaddyConfig, managed),
target: service(defaultImage, client.DefaultCaddyConfig, managed),
notWant: []string{override, storage, module},
},
{
name: "managed service upgrades the default image",
current: service(client.CaddyImage+":0.1.2", client.DefaultCaddyConfig, managed),
target: service(defaultImage, client.DefaultCaddyConfig, managed),
notWant: []string{override, storage, module},
},
{
name: "custom caddyfile moves to local storage",
current: service(defaultImage, client.DefaultCaddyConfig, managed),
target: service(defaultImage, localConfig, managed),
opts: deployOptions{caddyfile: "global.Caddyfile", caddyfileChanged: true},
want: []string{"from cluster to local"},
notWant: []string{override, keepStorage, module},
},
{
name: "other storage module is named",
current: service(defaultImage, "{\n\tstorage redis\n}", managed),
target: service(defaultImage, client.DefaultCaddyConfig, managed),
want: []string{"from 'redis' to cluster", keepStorage},
},
{
name: "custom image is unchanged with the same config",
current: service(customImage, client.DefaultCaddyConfig, managed),
target: service(customImage, client.DefaultCaddyConfig, managed),
notWant: []string{override, storage, module},
},
{
name: "custom image receives a changed default config",
current: service(customImage, "{\n\tstorage uncloud\n\tdebug\n}", managed),
target: service(customImage, client.DefaultCaddyConfig, managed),
want: []string{module},
notWant: []string{override, storage},
},
{
name: "custom image with local storage",
current: service(defaultImage, localConfig, managed),
target: service(customImage, localConfig, managed),
notWant: []string{override, storage, module},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
warnings := strings.Join(deploymentWarnings(tt.current, tt.target, tt.opts), "\n")
for _, want := range tt.want {
assert.Contains(t, warnings, want)
}
for _, notWant := range tt.notWant {
assert.NotContains(t, warnings, notWant)
}
})
}
}
+2
View File
@@ -1,6 +1,7 @@
package caddy package caddy
import ( import (
"github.com/psviderski/uncloud/cmd/uc/caddy/cert"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -10,6 +11,7 @@ func NewRootCommand() *cobra.Command {
Short: "Manage Caddy reverse proxy service.", Short: "Manage Caddy reverse proxy service.",
} }
cmd.AddCommand( cmd.AddCommand(
cert.NewRootCommand(),
NewConfigCommand(), NewConfigCommand(),
NewDeployCommand(), NewDeployCommand(),
NewLogsCommand(), NewLogsCommand(),
+1 -10
View File
@@ -22,15 +22,6 @@ func NewShowCommand() *cobra.Command {
} }
func show(uncli *cli.CLI) error { func show(uncli *cli.CLI) error {
// discard errors, only show the current context, otherwise nothing fmt.Println(uncli.ContextOverrideOrCurrent())
if uncli.Config == nil {
return nil
}
if len(uncli.Config.Contexts) == 0 {
return nil
}
fmt.Println(uncli.Config.CurrentContext)
return nil return nil
} }
+2 -2
View File
@@ -18,7 +18,7 @@ import (
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose" "github.com/psviderski/uncloud/pkg/client/compose"
"github.com/psviderski/uncloud/pkg/client/deploy" "github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/deploy/operation" "github.com/psviderski/uncloud/pkg/client/operation"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -305,7 +305,7 @@ const defaultFailedContainerLogsTail = 10
// UNCLOUD_FAILED_CONTAINER_LOGS_TAIL environment variable override when set and valid. // UNCLOUD_FAILED_CONTAINER_LOGS_TAIL environment variable override when set and valid.
func failedContainerLogsTail() int { func failedContainerLogsTail() int {
if v := os.Getenv("UNCLOUD_FAILED_CONTAINER_LOGS_TAIL"); v != "" { if v := os.Getenv("UNCLOUD_FAILED_CONTAINER_LOGS_TAIL"); v != "" {
if tail, err := logs.Tail(v); err == nil && (tail == -1 || tail > 0) { if tail, err := logs.ParseTail(v); err == nil && (tail == -1 || tail > 0) {
return tail return tail
} }
} }
+3
View File
@@ -9,8 +9,10 @@ import (
"github.com/psviderski/uncloud/api/pb" "github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/grpcversion"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
) )
@@ -49,6 +51,7 @@ func set(ctx context.Context, uncli *cli.CLI, name string) error {
} }
defer clusterClient.Close() defer clusterClient.Close()
ctx = metadata.AppendToOutgoingContext(ctx, grpcversion.MetadataKeyMinServerVersion, "0.21.0")
_, err = clusterClient.SetDomain(ctx, &pb.SetDomainRequest{Name: name}) _, err = clusterClient.SetDomain(ctx, &pb.SetDomainRequest{Name: name})
if err != nil { if err != nil {
if status.Convert(err).Code() == codes.AlreadyExists { if status.Convert(err).Code() == codes.AlreadyExists {
+54 -48
View File
@@ -13,6 +13,7 @@ import (
"github.com/charmbracelet/colorprofile" "github.com/charmbracelet/colorprofile"
"github.com/containerd/platforms" "github.com/containerd/platforms"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
@@ -90,9 +91,14 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
machines := cli.ExpandCommaSeparatedValues(opts.machines) machines := cli.ExpandCommaSeparatedValues(opts.machines)
clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{ var clusterImages []api.MachineImages
Machines: machines, err = tui.RunSpinner(ctx, "Listing images...", func(ctx context.Context) error {
Name: opts.nameFilter, var err error
clusterImages, err = clusterClient.ListImages(ctx, api.ImageFilter{
Machines: machines,
Name: opts.nameFilter,
})
return err
}) })
if err != nil { if err != nil {
return fmt.Errorf("list images: %w", err) return fmt.Errorf("list images: %w", err)
@@ -102,7 +108,7 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
var rows []imageRow var rows []imageRow
for _, machineImages := range clusterImages { for _, machineImages := range clusterImages {
if err := machineImages.Error(); err != nil { if err := machineImages.Metadata.Err(); err != nil {
tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s", tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s",
machineImages.Metadata.MachineName, err)) machineImages.Metadata.MachineName, err))
continue continue
@@ -116,49 +122,7 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
store = "containerd" store = "containerd"
} }
// Process each image for this machine. rows = append(rows, imageRows(machineImages.Images, machineName, store)...)
for _, img := range machineImages.Images {
// Show the first 12 chars without 'sha256:' as the image ID like Docker does.
id := strings.TrimPrefix(img.ID, "sha256:")[:12]
name := "<none>"
if len(img.RepoTags) > 0 && img.RepoTags[0] != "<none>:<none>" {
name = img.RepoTags[0]
}
imgPlatforms, _ := imagePlatforms(img)
formattedPlatforms := formatPlatforms(imgPlatforms)
created := ""
createdAt := time.Unix(img.Created, 0)
if !createdAt.IsZero() {
created = units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
}
size := units.HumanSizeWithPrecision(float64(img.Size), 3)
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
inUse := "-"
if img.Containers != -1 { // -1 means the info is not available.
if img.Containers > 0 {
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
} else {
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
}
}
rows = append(rows, imageRow{
id: id,
name: name,
platforms: formattedPlatforms,
createdHuman: created,
createdUnix: img.Created,
size: size,
inUse: inUse,
store: store,
machine: machineName,
})
}
} }
if len(rows) == 0 { if len(rows) == 0 {
@@ -184,13 +148,55 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
return nil return nil
} }
// imageRows keeps every stored name visible and retains digest-only and unnamed images.
func imageRows(images []image.Summary, machineName, store string) []imageRow {
var rows []imageRow
for _, img := range images {
// RepoTags includes stored digest names on containerd. RepoDigests can include derived names.
imageNames := img.RepoTags
if len(imageNames) == 0 {
imageNames = []string{"<untagged>"}
}
imgPlatforms, _ := imagePlatforms(img)
created := ""
if img.Created > 0 {
created = units.HumanDuration(time.Since(time.Unix(img.Created, 0))) + " ago"
}
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
inUse := "-"
if img.Containers >= 0 { // -1 means the info is not available.
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
if img.Containers > 0 {
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
}
}
for _, name := range imageNames {
rows = append(rows, imageRow{
id: stringid.TruncateID(img.ID),
name: name,
platforms: formatPlatforms(imgPlatforms),
createdHuman: created,
createdUnix: img.Created,
size: units.HumanSizeWithPrecision(float64(img.Size), 3),
inUse: inUse,
store: store,
machine: machineName,
})
}
}
return rows
}
// imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform. // imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform.
func imagePlatforms(img image.Summary) ([]string, bool) { func imagePlatforms(img image.Summary) ([]string, bool) {
var formattedPlatforms []string var formattedPlatforms []string
multiPlatform := false multiPlatform := false
for _, m := range img.Manifests { for _, m := range img.Manifests {
if m.Kind != image.ManifestKindImage || !m.Available { if m.Kind != image.ManifestKindImage || !m.Available || m.ImageData == nil {
continue continue
} }
+137
View File
@@ -0,0 +1,137 @@
package image
import (
"context"
"errors"
"fmt"
"os"
"charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/spf13/cobra"
)
type removeOptions struct {
machines []string
yes bool
}
func NewRemoveCommand() *cobra.Command {
opts := removeOptions{}
cmd := &cobra.Command{
Use: "rm IMAGE [IMAGE...]",
Aliases: []string{"remove", "delete"},
Short: "Remove images from machines in the cluster.",
Long: `Remove images from all or selected machines in the cluster.
An image name without a tag selects all its tags. Specify a tag, such as myapp:latest, to remove
only that tag.
Wildcard patterns are supported for image names and tags and should be quoted to prevent your
shell from expanding them. Removing by image ID attempts to remove each name for that image
independently.
Images used by running or stopped containers cannot be removed. Unused tags can be removed even
when the same image is used under another tag. Failed removals do not prevent other removals.`,
Example: ` # Remove a tag from all machines after confirming the plan.
uc image rm myapp:old
# Remove all tags for an image name from all machines.
uc image rm myapp
# Remove matching tags from two machines without confirmation.
uc image rm 'myapp:1.*' 'otherapp:*' -m machine1,machine2 --yes
# Remove all names for an image by ID from all machines.
uc image rm ee8fed0e9618`,
Args: cobra.MinimumNArgs(1),
PreRun: func(cmd *cobra.Command, args []string) {
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
},
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return remove(cmd.Context(), uncli, args, opts)
},
}
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
"Machine names or IDs to remove images from. Can be specified multiple times or as a comma-separated list.\n"+
"If not specified, the matching images will be removed from all machines.")
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Auto-confirm the removal plan. Should be explicitly set when running non-interactively,\n"+
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
completion.MachinesFlag(cmd)
return cmd
}
func remove(ctx context.Context, uncli *cli.CLI, images []string, opts removeOptions) error {
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
plan, err := planImageRemoval(ctx, clusterClient, images, cli.ExpandCommaSeparatedValues(opts.machines))
if err != nil {
return fmt.Errorf("plan image removal: %w", err)
}
directConn := uncli.DirectConnection()
contextName := uncli.ContextOverrideOrCurrent()
cluster := ""
if directConn != "" {
cluster = directConn
lipgloss.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
lipgloss.Println()
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
// Only show context if there's more than one to avoid unnecessary clutter.
cluster = contextName
lipgloss.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
lipgloss.Println()
}
lipgloss.Println(plan.Format())
if !opts.yes {
if !tui.IsTerminalAvailable() {
return errors.New("cannot ask to confirm image removal in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
}
title := "Proceed with image removal?"
if cluster != "" {
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
title = "Proceed with image removal on " + tui.NameStyle.Render(cluster) + confirmStyle.Render("?")
}
confirmed, err := tui.Confirm(title)
if err != nil {
return fmt.Errorf("confirm image removal: %w", err)
}
if !confirmed {
return cli.Cancelled("Image removal cancelled. No images were removed.")
}
}
untagged := make(map[string]bool)
var errs []error
for i, group := range plan.GroupPlans {
if i > 0 {
fmt.Println()
}
err := progress.RunWithTitle(ctx, func(ctx context.Context) error {
return group.Execute(ctx, clusterClient, untagged)
}, uncli.ProgressOut(), "Removing image "+group.Image)
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
+426
View File
@@ -0,0 +1,426 @@
package image
import (
"context"
"errors"
"fmt"
"path"
"regexp"
"slices"
"strings"
"charm.land/lipgloss/v2"
"github.com/distribution/reference"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
)
var imageIDRegex = regexp.MustCompile(`^(sha256:)?[a-f0-9]{4,64}$`)
// imageRemovalPlan contains all image removal operations grouped by the user-supplied image name, ID, or pattern.
type imageRemovalPlan struct {
GroupPlans []*removeImageGroupPlan
}
func (p *imageRemovalPlan) IsEmpty() bool { return len(p.GroupPlans) == 0 }
func (p *imageRemovalPlan) Format() string {
var out strings.Builder
machines := make(map[string]struct{})
removeCount := 0
for _, group := range p.GroupPlans {
out.WriteString(group.Format())
for _, op := range group.Operations {
machines[op.MachineName] = struct{}{}
removeCount++
}
}
summary := fmt.Sprintf("%s remove %s across %s %s",
tui.BoldRed.Render(fmt.Sprint(removeCount)),
tui.Faint.Render("·"),
tui.Bold.Render(fmt.Sprint(len(machines))),
pluralMachines(len(machines)),
)
out.WriteString("\n" + tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))) + "\n" + summary + "\n")
return out.String()
}
func pluralMachines(count int) string {
if count == 1 {
return "machine"
}
return "machines"
}
// Execute applies the frozen plan, collecting failures without discovering new targets.
func (p *imageRemovalPlan) Execute(ctx context.Context, cli api.ImageClient) error {
untagged := make(map[string]bool)
var errs []error
for _, group := range p.GroupPlans {
if err := group.Execute(ctx, cli, untagged); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// removeImageGroupPlan groups removals for one image name, ID, or pattern supplied by the user.
type removeImageGroupPlan struct {
// Image is the image name, ID, or pattern supplied by the user.
Image string
Operations []*removeImageOperation
}
func (o *removeImageGroupPlan) Execute(ctx context.Context, cli api.ImageClient, untagged map[string]bool) error {
var errs []error
for _, op := range o.Operations {
if err := ctx.Err(); err != nil {
return errors.Join(append(errs, err)...)
}
if err := op.Execute(ctx, cli, untagged); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (o *removeImageGroupPlan) Format() string {
var out strings.Builder
out.WriteString(tui.BoldRed.Render("-") + " remove image " + tui.NameStyle.Render(o.Image) + "\n")
for i, op := range o.Operations {
branch := " ├── "
if i == len(o.Operations)-1 {
branch = " ╰── "
}
out.WriteString(tui.Faint.Render(branch) + op.Format() + "\n")
}
return out.String()
}
func (o *removeImageGroupPlan) String() string {
return fmt.Sprintf("removeImageGroupPlan[image=%s]", o.Image)
}
// removeImageOperation removes an image name or an unnamed image by ID on a machine.
type removeImageOperation struct {
MachineName string
ImageID string
// Image is the exact image name or ID sent to Docker.
Image string
}
func (o *removeImageOperation) Execute(ctx context.Context, cli api.ImageClient, untagged map[string]bool) error {
if untagged[o.MachineName+"\x00"+o.Image] {
return nil
}
results, err := cli.RemoveImage(ctx, o.Image, api.RemoveImageOptions{Machines: []string{o.MachineName}})
// A removal may also untag other planned names. Track actual results across all groups, per machine.
for _, result := range results {
for _, removal := range result.Results {
if removal.Untagged != "" {
untagged[result.Metadata.MachineName+"\x00"+removal.Untagged] = true
}
}
}
if errors.Is(err, api.ErrNotFound) {
return nil
}
if err != nil {
return fmt.Errorf("remove image '%s': %w", o.Image, err)
}
return nil
}
func (o *removeImageOperation) Format() string {
return tui.BoldRed.Render("-") + " " + tui.FormatImage(o.Image, tui.NoStyle) +
tui.Faint.Render(" ("+stringid.TruncateID(o.ImageID)+") on ") + o.MachineName
}
func (o *removeImageOperation) String() string {
return fmt.Sprintf("removeImageOperation[machine_name=%s image=%s]", o.MachineName, o.Image)
}
type machineImageInventory struct {
machineName string
images []image.Summary
}
// planImageRemoval builds a removal plan grouped by input argument from images on the selected machines.
// An empty machines list selects all machines.
//
// Arguments expand as follows:
// - Name: without a tag or digest, select all stored names in that exact repository across image IDs.
// An explicit tag selects only that tag. Familiar names are normalised.
// - ID: select all RepoTags for the matching image, or its full ID if unnamed. A prefix must identify
// one unique full ID across the selected machines.
// - Pattern: match RepoTags only. A matching digest also selects that image's names in the same repository.
// - Digest: resolve through RepoTags, RepoDigests, or the image ID, then select that image's names in
// the same repository. If none exist but RepoDigests contains the reference, select the digest itself.
//
// Selecting all tags for a repository/image on a machine also adds its stored digest references after
// the tags, even if those digests did not match an argument. Stored names are kept as is, and overlapping
// selections produce only one operation per machine and name.
//
// Failed image listings produce warnings and exclude those machines from the plan. If no machine can
// be queried, planning fails. Every argument must match on a successfully queried machine. Unmatched
// or invalid arguments and ambiguous ID prefixes reject the whole plan before any removal.
//
// Planning does not check container usage. The backend checks each removal during execution, and failures
// do not stop other operations. Tag removal may implicitly untag a used digest. Execution skips names
// already untagged by earlier operations and tolerates names that disappeared after planning.
func planImageRemoval(ctx context.Context, cli api.ImageClient, images, machines []string) (*imageRemovalPlan, error) {
if len(images) == 0 {
return nil, errors.New("at least one image is required")
}
var clusterImages []api.MachineImages
err := tui.RunSpinner(ctx, "Finding images to remove...", func(ctx context.Context) error {
var err error
clusterImages, err = cli.ListImages(ctx, api.ImageFilter{Machines: machines})
return err
})
if err != nil {
return nil, fmt.Errorf("list images: %w", err)
}
var inventory []*machineImageInventory
for _, machineImages := range clusterImages {
if err := machineImages.Metadata.Err(); err != nil {
tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s",
machineImages.Metadata.MachineName, err))
continue
}
machine := &machineImageInventory{
machineName: machineImages.Metadata.MachineName,
images: machineImages.Images,
}
inventory = append(inventory, machine)
}
if len(inventory) == 0 {
return nil, errors.New("failed to list images on any machine")
}
return planImageRemovalFromInventory(images, inventory)
}
func planImageRemovalFromInventory(images []string, inventory []*machineImageInventory) (*imageRemovalPlan, error) {
plan := &imageRemovalPlan{}
seen := make(map[string]bool)
var errs []error
for _, imageArg := range images {
group := &removeImageGroupPlan{Image: imageArg}
isID := imageIDRegex.MatchString(imageArg)
isPattern := strings.ContainsAny(imageArg, "*?[")
var namedRef reference.Named
if isPattern {
if _, err := path.Match(imageArg, ""); err != nil {
return nil, fmt.Errorf("invalid image pattern '%s': %w", imageArg, err)
}
} else if !isID {
var err error
namedRef, err = reference.ParseNormalizedNamed(imageArg)
if err != nil {
return nil, fmt.Errorf("invalid image '%s': %w", imageArg, err)
}
}
if isID {
matches := make(map[string]bool)
for _, machine := range inventory {
for _, img := range machine.images {
if imageIDMatches(img.ID, imageArg) {
matches[img.ID] = true
}
}
}
if len(matches) > 1 {
var candidates []string
for id := range matches {
candidates = append(candidates, id)
}
slices.Sort(candidates)
errs = append(errs,
fmt.Errorf("image ID '%s' is ambiguous: %s", imageArg, strings.Join(candidates, ", ")))
continue
}
}
matched := false
for _, machine := range inventory {
for _, img := range machine.images {
var imagesToRemove []string
switch {
case isID:
if !imageIDMatches(img.ID, imageArg) {
continue
}
matched = true
// Expand only stored names, matching uc image ls. RepoDigests remains available for digest lookup.
imagesToRemove = img.RepoTags
if len(imagesToRemove) == 0 {
imagesToRemove = []string{img.ID}
}
case isPattern:
for _, ref := range img.RepoTags {
if ok, _ := path.Match(imageArg, ref); ok {
named, _ := reference.ParseDockerRef(ref)
if _, digest := named.(reference.Canonical); digest {
imagesToRemove = append(imagesToRemove,
imageRepositoryReferences(img.RepoTags, named.Name())...)
} else {
imagesToRemove = append(imagesToRemove, ref)
}
}
}
case reference.IsNameOnly(namedRef):
imagesToRemove = imageRepositoryReferences(img.RepoTags, namedRef.Name())
default:
if canonical, digest := namedRef.(reference.Canonical); digest {
ref := reference.FamiliarString(namedRef)
if canonical.Digest().String() != img.ID && !slices.Contains(img.RepoTags, ref) &&
!slices.Contains(img.RepoDigests, ref) {
continue
}
imagesToRemove = imageRepositoryReferences(img.RepoTags, namedRef.Name())
if len(imagesToRemove) == 0 && slices.Contains(img.RepoDigests, ref) {
// An explicitly requested digest can resolve even when no stored name is listed.
imagesToRemove = []string{ref}
}
} else if slices.Contains(img.RepoTags, reference.FamiliarString(namedRef)) {
imagesToRemove = []string{reference.FamiliarString(namedRef)}
}
}
for _, ref := range imagesToRemove {
matched = true
key := machine.machineName + "\x00" + ref
if !seen[key] {
seen[key] = true
group.Operations = append(group.Operations, &removeImageOperation{
MachineName: machine.machineName, ImageID: img.ID, Image: ref,
})
}
}
}
}
if !matched {
errs = append(errs, fmt.Errorf("image '%s' not found on any machine", imageArg))
}
slices.SortFunc(group.Operations, compareImageRemovalOperations)
if len(group.Operations) > 0 {
plan.GroupPlans = append(plan.GroupPlans, group)
}
}
planDigestRemovals(plan, inventory)
if err := errors.Join(errs...); err != nil {
return nil, err
}
return plan, nil
}
func imageRepositoryReferences(refs []string, repository string) []string {
var matches []string
for _, ref := range refs {
if named, err := reference.ParseDockerRef(ref); err == nil && named.Name() == repository {
matches = append(matches, ref)
}
}
return matches
}
// planDigestRemovals includes stored digests with the group removing their last repository tag.
// RepoDigests also contains synthesised references, so only digest names stored in RepoTags get operations here.
func planDigestRemovals(plan *imageRemovalPlan, inventory []*machineImageInventory) {
type repository struct {
remainingTags int
digests []string
}
repositories := make(map[string]*repository)
for _, machine := range inventory {
for _, img := range machine.images {
names := slices.Clone(img.RepoTags)
slices.Sort(names)
for _, ref := range slices.Compact(names) {
named, err := reference.ParseDockerRef(ref)
if err != nil {
continue
}
key := machine.machineName + "\x00" + img.ID + "\x00" + named.Name()
repo := repositories[key]
if repo == nil {
repo = &repository{}
repositories[key] = repo
}
if _, tagged := named.(reference.Tagged); tagged {
repo.remainingTags++
} else if _, digest := named.(reference.Canonical); digest {
repo.digests = append(repo.digests, ref)
}
}
}
}
emitted := make(map[string]bool)
var groups []*removeImageGroupPlan
for _, group := range plan.GroupPlans {
var operations []*removeImageOperation
for _, op := range group.Operations {
key := op.MachineName + "\x00" + op.Image
if emitted[key] {
continue
}
emitted[key] = true
operations = append(operations, op)
named, err := reference.ParseDockerRef(op.Image)
if err != nil {
continue
}
if _, tagged := named.(reference.Tagged); !tagged {
continue
}
repo := repositories[op.MachineName+"\x00"+op.ImageID+"\x00"+named.Name()]
if repo == nil {
continue
}
repo.remainingTags--
if repo.remainingTags != 0 {
continue
}
for _, digest := range repo.digests {
key := op.MachineName + "\x00" + digest
if !emitted[key] {
emitted[key] = true
operations = append(operations, &removeImageOperation{
MachineName: op.MachineName, ImageID: op.ImageID, Image: digest,
})
}
}
}
group.Operations = operations
slices.SortFunc(group.Operations, compareImageRemovalOperations)
if len(group.Operations) > 0 {
groups = append(groups, group)
}
}
plan.GroupPlans = groups
}
func compareImageRemovalOperations(a, b *removeImageOperation) int {
aDigest, bDigest := strings.Contains(a.Image, "@"), strings.Contains(b.Image, "@")
if aDigest != bDigest {
if aDigest {
return 1
}
return -1
}
if c := strings.Compare(a.Image, b.Image); c != 0 {
return c
}
return strings.Compare(a.MachineName, b.MachineName)
}
func imageIDMatches(id, prefix string) bool {
return strings.HasPrefix(strings.TrimPrefix(id, "sha256:"), strings.TrimPrefix(prefix, "sha256:"))
}
+405
View File
@@ -0,0 +1,405 @@
package image
import (
"strings"
"testing"
"github.com/docker/docker/api/types/image"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPlanImageRemovalFromInventory_Selectors(t *testing.T) {
idA, idB := testImageID("a"), testImageID("b")
digestRef := "myapp@" + testImageID("c")
tests := []struct {
name string
selector string
inventory []*machineImageInventory
want []removeImageOperation
}{
{
name: "repository selects all stored names across images and machines", selector: "myapp",
inventory: []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "other:keep"),
testImage(idB, "myapp:latest"),
testImage(testImageID("c"), digestRef),
testImage(testImageID("d"), "myapp-extra:latest", "team/myapp:latest", "myapp/worker:latest"),
}),
testMachineInventory("machine2", []image.Summary{testImage(idB, "myapp:test")}),
},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idB, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "machine2", ImageID: idB, Image: "myapp:test"},
{MachineName: "dev1", ImageID: testImageID("c"), Image: digestRef},
},
},
{
name: "repository without latest includes its tags and stored digest", selector: "myapp",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", digestRef, "other:keep"),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "explicit latest selects only that tag", selector: "myapp:latest",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", digestRef),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}},
},
{
name: "qualified repository selects familiar names", selector: "docker.io/library/myapp",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", "other:keep"),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
},
},
{
name: "registry port does not count as an explicit tag", selector: "registry.example.com:5000/team/myapp",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "registry.example.com:5000/team/myapp:old", "registry.example.com:5000/team/myapp:new",
"registry.example.com:5001/team/myapp:keep", "registry.example.com:5000/team/myapp/worker:keep"),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:new"},
{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:old"},
},
},
{
name: "explicit tag with registry port stays exact", selector: "registry.example.com:5000/team/myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "registry.example.com:5000/team/myapp:old", "registry.example.com:5000/team/myapp:new"),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:old"}},
},
{
name: "tag uses each machines local image and allows missing copies", selector: "myapp:old",
inventory: []*machineImageInventory{
testMachineInventory("machine2", []image.Summary{testImage(idB, "myapp:old")}),
testMachineInventory("empty", nil),
testMachineInventory("dev1", []image.Summary{testImage(idA, "myapp:old")}),
},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "machine2", ImageID: idB, Image: "myapp:old"},
},
},
{
name: "qualified names use familiar references", selector: "docker.io/library/myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old"),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "full ID expands all repositories and deduplicates references per machine", selector: idA,
inventory: []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{
{
ID: idA,
RepoTags: []string{"other:keep", "myapp:old", "myapp:old", digestRef, "other@" + testImageID("c")},
RepoDigests: []string{
digestRef, "other@" + testImageID("c"), "other@" + testImageID("c"), testImageID("c"),
},
},
testImage(idB, "unrelated:keep"),
}),
testMachineInventory("machine2", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old", digestRef}, RepoDigests: []string{digestRef},
}}),
},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "machine2", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: "other:keep"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
{MachineName: "machine2", ImageID: idA, Image: digestRef},
{MachineName: "dev1", ImageID: idA, Image: "other@" + testImageID("c")},
},
},
{
name: "same full ID on multiple machines is unambiguous", selector: "aaaaaaaa",
inventory: []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{testImage(idA, "myapp:old")}),
testMachineInventory("dev1", []image.Summary{testImage(idB, "myapp:latest")}),
testMachineInventory("machine2", []image.Summary{testImage(idA, "other:test")}),
},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "machine2", ImageID: idA, Image: "other:test"},
},
},
{
name: "short ID with algorithm prefix", selector: "sha256:aaaaaaaa",
inventory: []*machineImageInventory{testMachineInventory("dev1",
[]image.Summary{testImage(idA, "myapp:old")})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "unnamed image is removed by its full ID", selector: idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{ID: idA}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: idA}},
},
{
name: "ID expands only stored names", selector: idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:latest"},
RepoDigests: []string{"myapp@" + idA, "other@" + idA},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}},
},
{
name: "ID removes an untagged image by ID even if RepoDigests is populated", selector: idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoDigests: []string{digestRef},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: idA}},
},
{
name: "synthesized digest selects repository tags without a digest row", selector: digestRef,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old", "other:keep", "myapp:latest"},
RepoDigests: []string{digestRef, "other@" + testImageID("c")},
}})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
},
},
{
name: "explicit digest lookup works without stored names", selector: digestRef,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoDigests: []string{digestRef},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
},
{
name: "synthesized digest resolves by target and repository even if not listed", selector: "myapp@" + idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old", "other:keep"}, RepoDigests: []string{"other@" + idA},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "stored digest follows its repository tags", selector: digestRef,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", digestRef, "other:keep"),
testImage(idB, "myapp@"+idB),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "digest pattern also expands matching repository tags", selector: "myapp@sha256:c*",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", digestRef, "other:keep"),
testImage(idB, "myapp@"+idB),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "digest pattern matches stored names and ignores derived names", selector: "myapp@sha256:*",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", digestRef),
{ID: idB, RepoTags: []string{"myapp:latest"}, RepoDigests: []string{"myapp@" + idB}},
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "last repository tag includes its stored digest", selector: "myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", digestRef, "other:keep"),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "last tag does not add a synthesized digest row", selector: "myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old", "other:keep"}, RepoDigests: []string{digestRef},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "remaining repository tag preserves its stored digest", selector: "myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", digestRef),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "digest without repository tags remains executable", selector: digestRef,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, digestRef, "other:keep"),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
},
{
name: "digest-only image keeps its named reference", selector: idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{digestRef}, RepoDigests: []string{digestRef, testImageID("c")},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
},
{
name: "tag pattern selects only matching tags", selector: "myapp:1.[12]",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:1.3", "myapp:1.2", "myapp:1.1"}, RepoDigests: []string{digestRef},
}})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:1.1"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:1.2"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plan, err := planImageRemovalFromInventory([]string{tt.selector}, tt.inventory)
require.NoError(t, err)
require.Len(t, plan.GroupPlans, 1)
assert.Equal(t, tt.selector, plan.GroupPlans[0].Image)
var got []removeImageOperation
for _, op := range plan.GroupPlans[0].Operations {
got = append(got, *op)
}
assert.Equal(t, tt.want, got)
})
}
}
func TestPlanImageRemovalFromInventory_OverlappingSelectors(t *testing.T) {
inventory := []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{testImage(testImageID("a"), "myapp:old", "myapp:latest")}),
testMachineInventory("machine2", []image.Summary{testImage(testImageID("b"), "myapp:test", "myapp:old")}),
}
plan, err := planImageRemovalFromInventory([]string{"myapp:old", "myapp", "myapp:*", testImageID("a"), "myapp:old"},
inventory)
require.NoError(t, err)
assert.Equal(t, &imageRemovalPlan{GroupPlans: []*removeImageGroupPlan{
{Image: "myapp:old", Operations: []*removeImageOperation{
{MachineName: "dev1", ImageID: testImageID("a"), Image: "myapp:old"},
{MachineName: "machine2", ImageID: testImageID("b"), Image: "myapp:old"},
}},
{Image: "myapp", Operations: []*removeImageOperation{
{MachineName: "dev1", ImageID: testImageID("a"), Image: "myapp:latest"},
{MachineName: "machine2", ImageID: testImageID("b"), Image: "myapp:test"},
}},
}}, plan, "the first selector owns each removal and empty groups are omitted")
}
func TestPlanImageRemovalFromInventory_DigestFollowsLastTagAcrossSelectors(t *testing.T) {
imageID := testImageID("a")
digestRef := "myapp@" + imageID
inventory := []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(imageID, "myapp:old", "myapp:latest", "myapp:old", digestRef, digestRef, "other:keep"),
})}
plan, err := planImageRemovalFromInventory([]string{"myapp:old", "myapp:latest", digestRef}, inventory)
require.NoError(t, err)
assert.Equal(t, &imageRemovalPlan{GroupPlans: []*removeImageGroupPlan{
{Image: "myapp:old", Operations: []*removeImageOperation{
{MachineName: "dev1", ImageID: imageID, Image: "myapp:old"},
}},
{Image: "myapp:latest", Operations: []*removeImageOperation{
{MachineName: "dev1", ImageID: imageID, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: imageID, Image: digestRef},
}},
}}, plan, "digest cleanup belongs to the last tag removal, so the later digest selector has no extra operation")
}
func TestPlanImageRemovalFromInventory_RejectsInvalidOrMissingSelectors(t *testing.T) {
idA := "sha256:abcdef" + strings.Repeat("a", 58)
idB := "sha256:abcdef" + strings.Repeat("b", 58)
inventory := []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old"}, RepoDigests: []string{"myapp@" + idA, "derived@" + idA},
}}),
testMachineInventory("machine2", []image.Summary{{ID: idB, RepoTags: []string{"myapp:old"}}}),
}
tests := []struct {
name string
images []string
err string
}{
{"missing repository", []string{"missing"}, "not found"},
{"repository only has a derived digest", []string{"derived"}, "not found"},
{"missing tag", []string{"myapp:missing"}, "not found"},
{"missing pattern", []string{"other:*"}, "not found"},
{"pattern only matches a derived digest", []string{"myapp@sha256:*"}, "not found"},
{"missing ID", []string{testImageID("c")}, "not found"},
{"ambiguous ID prefix", []string{"abcdef"}, "ambiguous"},
{"invalid name", []string{"MyApp:old"}, "invalid image"},
{"invalid pattern", []string{"myapp:["}, "invalid image pattern"},
{"one missing selector rejects the whole plan", []string{"myapp:old", "myapp:missing"}, "not found"},
{"one invalid selector rejects the whole plan", []string{"myapp:old", "myapp:["}, "invalid image pattern"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plan, err := planImageRemovalFromInventory(tt.images, inventory)
require.ErrorContains(t, err, tt.err)
assert.Nil(t, plan, "a failed selector must not return a partial plan")
})
}
t.Run("ID prefix resolves within the supplied machines", func(t *testing.T) {
plan, err := planImageRemovalFromInventory([]string{"abcdef"}, inventory[:1])
require.NoError(t, err)
require.Len(t, plan.GroupPlans, 1)
require.Len(t, plan.GroupPlans[0].Operations, 1)
assert.Equal(t, idA, plan.GroupPlans[0].Operations[0].ImageID)
})
}
func TestPlanImageRemovalFromInventory_InUseImagesAreStillPlanned(t *testing.T) {
img := testImage(testImageID("a"), "myapp:old", "myapp:current")
img.Containers = 1
for _, selector := range []string{"myapp", "myapp:current", img.ID, "myapp:*"} {
t.Run(selector, func(t *testing.T) {
plan, err := planImageRemovalFromInventory([]string{selector}, []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{img}),
})
require.NoError(t, err)
assert.False(t, plan.IsEmpty(), "usage protection belongs to the backend at execution time")
})
}
}
func testImageID(char string) string { return "sha256:" + strings.Repeat(char, 64) }
func testImage(imageID string, tags ...string) image.Summary {
return image.Summary{ID: imageID, RepoTags: tags}
}
func testMachineInventory(name string, images []image.Summary) *machineImageInventory {
return &machineImageInventory{machineName: name, images: images}
}
+1
View File
@@ -13,6 +13,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand( cmd.AddCommand(
NewListCommand(), NewListCommand(),
NewPushCommand(), NewPushCommand(),
NewRemoveCommand(),
) )
return cmd return cmd
+4 -22
View File
@@ -187,14 +187,12 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
} }
fmt.Println("Machine joined the cluster.") fmt.Println("Machine joined the cluster.")
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment // Deploy the current caddy service spec to run a container on the added machine. This keeps the service
// that may cause a small downtime. // configuration however it was deployed and shouldn't change the containers on other machines if specs on them
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines, // haven't diverged.
// 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 // 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. // issues accessing the API of existing machines in the cluster.
// See the issue for more details: https://github.com/psviderski/uncloud/issues/65. // See the issue for more details: https://github.com/psviderski/uncloud/issues/65.
caddyImage := ""
caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName) caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
if err != nil { if err != nil {
if errors.Is(err, api.ErrNotFound) { if errors.Is(err, api.ErrNotFound) {
@@ -203,26 +201,10 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
} }
return fmt.Errorf("inspect caddy service: %w", err) 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()
fmt.Println("Preparing Caddy deployment...") fmt.Println("Preparing Caddy deployment...")
d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{}) d := clusterClient.NewDeployment(caddySvc.Spec(), nil)
if err != nil {
return fmt.Errorf("create caddy deployment: %w", err)
}
plan, err := d.Plan(ctx) plan, err := d.Plan(ctx)
if err != nil { if err != nil {
+7 -7
View File
@@ -16,7 +16,7 @@ import (
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/cluster" "github.com/psviderski/uncloud/internal/machine/cluster"
"github.com/psviderski/uncloud/internal/machine/network" "github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -221,17 +221,17 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
initOpts.WireguardEndpoints = endpoints initOpts.WireguardEndpoints = endpoints
} }
client, err := uncli.InitCluster(ctx, initOpts) clusterClient, err := uncli.InitCluster(ctx, initOpts)
if err != nil { if err != nil {
return err return err
} }
defer client.Close() defer clusterClient.Close()
// Since the cluster API needs a few moments to become ready after cluster initialisation, // Since the cluster API needs a few moments to become ready after cluster initialisation,
// we keep the user informed during this wait. We wait here even if no Caddy or DNS is requested // we keep the user informed during this wait. We wait here even if no Caddy or DNS is requested
// as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init. // as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init.
err = tui.RunSpinner(ctx, "Waiting for the cluster to be ready...", func(ctx context.Context) error { err = tui.RunSpinner(ctx, "Waiting for the cluster to be ready...", func(ctx context.Context) error {
return client.WaitClusterReady(ctx, 1*time.Minute) return clusterClient.WaitClusterReady(ctx, 1*time.Minute)
}) })
if err != nil { if err != nil {
return fmt.Errorf("wait for cluster to be ready: %w", err) return fmt.Errorf("wait for cluster to be ready: %w", err)
@@ -245,7 +245,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
fmt.Println() fmt.Println()
if !opts.noDNS { if !opts.noDNS {
domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint}) domain, err := clusterClient.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint})
if err != nil { if err != nil {
return fmt.Errorf("reserve cluster domain in Uncloud DNS: %w", err) return fmt.Errorf("reserve cluster domain in Uncloud DNS: %w", err)
} }
@@ -253,7 +253,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
} }
if !opts.noCaddy { if !opts.noCaddy {
d, err := client.NewCaddyDeployment("", "", api.Placement{}) d, err := clusterClient.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{})
if err != nil { if err != nil {
return fmt.Errorf("create caddy deployment: %w", err) return fmt.Errorf("create caddy deployment: %w", err)
} }
@@ -269,7 +269,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
} }
fmt.Println() fmt.Println()
return caddy.UpdateDomainRecords(ctx, client, uncli.ProgressOut()) return caddy.UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut())
} }
return nil return nil
+12 -7
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
"time"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/completion"
@@ -64,6 +65,15 @@ If no services are specified, streams logs from the uncloud service.`,
} }
func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.Options) error { func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.Options) error {
since, until, err := logs.TimeRange(opts.Since, opts.Until, time.Now())
if err != nil {
return err
}
tail, err := opts.TailLines()
if err != nil {
return err
}
if len(services) == 0 { if len(services) == 0 {
services = []string{api.SystemServiceUncloud} services = []string{api.SystemServiceUncloud}
} }
@@ -74,11 +84,6 @@ func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.O
} }
} }
tail, err := logs.Tail(opts.Tail)
if err != nil {
return err
}
c, err := uncli.ConnectCluster(ctx) c, err := uncli.ConnectCluster(ctx)
if err != nil { if err != nil {
return fmt.Errorf("connect to cluster: %w", err) return fmt.Errorf("connect to cluster: %w", err)
@@ -88,8 +93,8 @@ func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.O
logsOpts := api.ServiceLogsOptions{ logsOpts := api.ServiceLogsOptions{
Follow: opts.Follow, Follow: opts.Follow,
Tail: tail, Tail: tail,
Since: opts.Since, Since: since,
Until: opts.Until, Until: until,
Machines: cli.ExpandCommaSeparatedValues(opts.Machines), Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
} }
+25
View File
@@ -0,0 +1,25 @@
package machine
import (
"context"
"testing"
"github.com/psviderski/uncloud/internal/cli/logs"
"github.com/stretchr/testify/require"
)
func TestRunLogsInvalidTimeFilters(t *testing.T) {
t.Parallel()
// Invalid filters must fail before connecting to the cluster.
for _, flag := range []string{"since", "until"} {
opts := logs.Options{}
if flag == "since" {
opts.Since = "invalid"
} else {
opts.Until = "invalid"
}
err := runLogs(context.Background(), nil, nil, opts)
require.ErrorContains(t, err, "invalid --"+flag+" value")
}
}
+2 -2
View File
@@ -54,8 +54,8 @@ func rtt(ctx context.Context, uncli *cli.CLI) error {
tui.PrintWarning("metadata is missing in response from unknown server") tui.PrintWarning("metadata is missing in response from unknown server")
continue continue
} }
if m.Metadata.Error != "" { if err := m.Metadata.Err(); err != nil {
tui.PrintWarning(fmt.Sprintf("failed to inspect machine '%s': %s", m.Metadata.MachineName, m.Metadata.Error)) tui.PrintWarning(fmt.Sprintf("failed to inspect machine '%s': %s", m.Metadata.MachineName, err))
continue continue
} }
if m.Machine == nil { if m.Machine == nil {
+2 -2
View File
@@ -207,9 +207,9 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo
machineName := msc.Metadata.MachineName machineName := msc.Metadata.MachineName
if msc.Metadata.Error != "" { if err := msc.Metadata.Err(); err != nil {
tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine %s: %s", tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine %s: %s",
machineName, msc.Metadata.Error)) machineName, err))
continue continue
} }
+18
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"maps"
"slices" "slices"
"time" "time"
@@ -58,6 +59,16 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
fmt.Printf("Service ID: %s\n", svc.ID) fmt.Printf("Service ID: %s\n", svc.ID)
fmt.Printf("Name: %s\n", svc.Name) fmt.Printf("Name: %s\n", svc.Name)
fmt.Printf("Mode: %s\n", svc.Mode) 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() fmt.Println()
// Combine regular and hook containers. // Combine regular and hook containers.
@@ -130,3 +141,10 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
lipgloss.Println(t) lipgloss.Println(t)
return nil return nil
} }
// printLabels prints the labels sorted by key followed by a blank line.
func printLabels(labels map[string]string) {
for _, key := range slices.Sorted(maps.Keys(labels)) {
lipgloss.Println(" " + key + tui.Faint.Render("=") + labels[key])
}
}
+12 -8
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
mapset "github.com/deckarep/golang-set/v2" mapset "github.com/deckarep/golang-set/v2"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
@@ -78,6 +79,15 @@ If no services are specified, streams logs from all services defined in the Comp
} }
func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Options) error { func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Options) error {
since, until, err := logs.TimeRange(opts.Since, opts.Until, time.Now())
if err != nil {
return err
}
tail, err := opts.TailLines()
if err != nil {
return err
}
serviceArgs, err := logs.ParseServiceArgs(args) serviceArgs, err := logs.ParseServiceArgs(args)
if err != nil { if err != nil {
return err return err
@@ -106,12 +116,6 @@ func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Optio
} }
} }
// Parse tail option.
tail, err := logs.Tail(opts.Tail)
if err != nil {
return err
}
c, err := uncli.ConnectCluster(ctx) c, err := uncli.ConnectCluster(ctx)
if err != nil { if err != nil {
return fmt.Errorf("connect to cluster: %w", err) return fmt.Errorf("connect to cluster: %w", err)
@@ -121,8 +125,8 @@ func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Optio
baseOpts := api.ServiceLogsOptions{ baseOpts := api.ServiceLogsOptions{
Follow: opts.Follow, Follow: opts.Follow,
Tail: tail, Tail: tail,
Since: opts.Since, Since: since,
Until: opts.Until, Until: until,
Machines: cli.ExpandCommaSeparatedValues(opts.Machines), Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
} }
+25
View File
@@ -0,0 +1,25 @@
package service
import (
"context"
"testing"
"github.com/psviderski/uncloud/internal/cli/logs"
"github.com/stretchr/testify/require"
)
func TestRunLogsInvalidTimeFilters(t *testing.T) {
t.Parallel()
// Invalid filters must fail before loading Compose files or connecting to the cluster.
for _, flag := range []string{"since", "until"} {
opts := logs.Options{}
if flag == "since" {
opts.Since = "invalid"
} else {
opts.Until = "invalid"
}
err := RunLogs(context.Background(), nil, nil, opts)
require.ErrorContains(t, err, "invalid --"+flag+" value")
}
}
+1
View File
@@ -20,6 +20,7 @@ func NewRootCommand() *cobra.Command {
NewScaleCommand(""), NewScaleCommand(""),
NewStartCommand(""), NewStartCommand(""),
NewStopCommand(""), NewStopCommand(""),
NewUpdateCommand(),
) )
return cmd return cmd
} }
+53 -7
View File
@@ -21,13 +21,16 @@ import (
type runOptions struct { type runOptions struct {
caddyfile string caddyfile string
command []string command []string
containerLabels []string
cpu dockeropts.NanoCPUs cpu dockeropts.NanoCPUs
entrypoint string entrypoint string
entrypointChanged bool entrypointChanged bool
env []string env []string
image string image string
labels []string
machines []string machines []string
memory dockeropts.MemBytes memory dockeropts.MemBytes
memoryReservation dockeropts.MemBytes
mode string mode string
shmSize dockeropts.MemBytes shmSize dockeropts.MemBytes
name string name string
@@ -64,6 +67,9 @@ func NewRunCommand(groupID string) *cobra.Command {
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "", cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
"Path to a custom Caddy config (Caddyfile) for the service. "+ "Path to a custom Caddy config (Caddyfile) for the service. "+
"Cannot be used together with non-@host published ports.") "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", "", cmd.Flags().VarP(&opts.cpu, "cpu", "",
"Maximum number of CPU cores a service container can use. Fractional values are allowed: "+ "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.") "0.5 for half a core or 2.25 for two and a quarter cores.")
@@ -72,6 +78,9 @@ func NewRunCommand(groupID string) *cobra.Command {
cmd.Flags().StringSliceVarP(&opts.env, "env", "e", nil, cmd.Flags().StringSliceVarP(&opts.env, "env", "e", nil,
"Set an environment variable for service containers. Can be specified multiple times.\n"+ "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.") "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, cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated,
fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+ fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+
"the machines) or '%s' (one container on every machine).", "the machines) or '%s' (one container on every machine).",
@@ -83,6 +92,9 @@ func NewRunCommand(groupID string) *cobra.Command {
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+ "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"+ "Default unit is bytes if no suffix specified.\n"+
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)") "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", "", 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"+ "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"+ "with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.\n"+
@@ -94,11 +106,12 @@ func NewRunCommand(groupID string) *cobra.Command {
cmd.Flags().StringSliceVarP(&opts.publish, "publish", "p", nil, 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"+ "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"+ "Format: [hostname:]container_port[/protocol] or [host_ip|host_prefix:]host_port:container_port[/protocol]@host\n"+
"Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified\n"+ "Supported protocols: tcp, udp, http, https. The protocol defaults to https if a hostname is specified,\n"+
"and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.\n"+ "otherwise tcp. http(s) ports without a hostname use 'service-name.cluster-domain' as the default hostname\n"+
"if a cluster domain is reserved.\n"+
"Examples:\n"+ "Examples:\n"+
" -p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname\n"+ " -p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname\n"+
" -p app.example.com:8080/https Publish port 8080 as HTTPS via reverse proxy with custom hostname\n"+ " -p app.example.com:8080 Publish port 8080 as HTTPS via reverse proxy with custom hostname\n"+
// TODO: add support for publishing L4 tcp/udp ports. // TODO: add support for publishing L4 tcp/udp ports.
//" -p 9000:8080 Publish port 8080 as TCP port 9000 via reverse proxy\n"+ //" -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"+ " -p 53:5353/udp@host Bind UDP port 5353 to host port 53\n"+
@@ -192,6 +205,18 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
return spec, err 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 { switch opts.mode {
case api.ServiceModeReplicated, api.ServiceModeGlobal: case api.ServiceModeReplicated, api.ServiceModeGlobal:
default: default:
@@ -232,17 +257,20 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
Command: opts.command, Command: opts.command,
Env: env, Env: env,
Image: opts.image, Image: opts.image,
Labels: containerLabels,
Privileged: opts.privileged, Privileged: opts.privileged,
PullPolicy: opts.pull, PullPolicy: opts.pull,
Resources: api.ContainerResources{ Resources: api.ContainerResources{
CPU: opts.cpu.Value(), CPU: opts.cpu.Value(),
Memory: opts.memory.Value(), Memory: opts.memory.Value(),
SharedMemory: opts.shmSize.Value(), MemoryReservation: opts.memoryReservation.Value(),
Ulimits: ulimits, SharedMemory: opts.shmSize.Value(),
Ulimits: ulimits,
}, },
User: opts.user, User: opts.user,
VolumeMounts: mounts, VolumeMounts: mounts,
}, },
Labels: labels,
Mode: opts.mode, Mode: opts.mode,
Name: opts.name, Name: opts.name,
Placement: placement, Placement: placement,
@@ -301,6 +329,24 @@ func parseEnv(env []string) (api.EnvVars, error) {
return envVars, nil 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. // 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]) // It handles both named volumes (volume_name:/container/path[:ro|volume-nocopy])
// and bind mounts (/host/path:/container/path[:ro]). // and bind mounts (/host/path:/container/path[:ro]).
+423
View File
@@ -0,0 +1,423 @@
package service
import (
"os"
"path/filepath"
"testing"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// validRunOptions returns the minimal options that produce a valid service spec.
func validRunOptions() runOptions {
return runOptions{
image: "nginx:1.27",
name: "web",
mode: api.ServiceModeReplicated,
pull: api.PullPolicyMissing,
replicas: 1,
}
}
func TestParseLabels(t *testing.T) {
t.Parallel()
labels, err := parseLabels(nil)
require.NoError(t, err)
assert.Nil(t, labels)
labels, err = parseLabels([]string{"tier=frontend", "empty", "list=a,b", "expr=a=b", "tier=backend"})
require.NoError(t, err)
assert.Equal(t, map[string]string{
"tier": "backend",
"empty": "",
"list": "a,b",
"expr": "a=b",
}, labels)
_, err = parseLabels([]string{"=value"})
require.ErrorContains(t, err, "key must not be empty")
_, err = parseLabels([]string{" \t =value"})
require.ErrorContains(t, err, "key must not be empty")
labels, err = parseLabels([]string{"key=value with spaces"})
require.NoError(t, err)
assert.Equal(t, map[string]string{"key": "value with spaces"}, labels)
}
func TestPrepareServiceSpec(t *testing.T) {
t.Parallel()
t.Run("minimal options", func(t *testing.T) {
t.Parallel()
spec, err := prepareServiceSpec(validRunOptions())
require.NoError(t, err)
assert.Equal(t, "web", spec.Name)
assert.Equal(t, api.ServiceModeReplicated, spec.Mode)
assert.EqualValues(t, 1, spec.Replicas)
assert.Equal(t, "nginx:1.27", spec.Container.Image)
assert.Equal(t, api.PullPolicyMissing, spec.Container.PullPolicy)
assert.Nil(t, spec.Caddy)
assert.Nil(t, spec.Labels)
assert.Nil(t, spec.Container.Labels)
assert.Nil(t, spec.Container.Entrypoint)
assert.Nil(t, spec.Container.Resources.Ulimits)
assert.Nil(t, spec.Placement.Machines)
assert.Empty(t, spec.Container.Env)
assert.Empty(t, spec.Ports)
assert.Empty(t, spec.Volumes)
assert.Empty(t, spec.Container.VolumeMounts)
})
t.Run("all options", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.command = []string{"nginx", "-g", "daemon off;"}
opts.env = []string{"FOO=bar", "EMPTY=", "EXPR=a=b"}
opts.labels = []string{"tier=frontend"}
opts.containerLabels = []string{"logging=enabled"}
opts.machines = []string{"m1,m2", "m3"}
opts.privileged = true
opts.pull = api.PullPolicyAlways
opts.publish = []string{"app.example.com:8080/https", "53:5353/udp@host"}
opts.replicas = 3
opts.ulimits = []string{"nofile=1024:2048", "nproc=65535"}
opts.user = "1000:1000"
opts.volumes = []string{"data:/var/lib/data:ro"}
require.NoError(t, opts.cpu.Set("0.5"))
require.NoError(t, opts.memory.Set("128m"))
require.NoError(t, opts.memoryReservation.Set("32m"))
require.NoError(t, opts.shmSize.Set("64m"))
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Equal(t, api.ServiceSpec{
Container: api.ContainerSpec{
Command: []string{"nginx", "-g", "daemon off;"},
Env: api.EnvVars{"FOO": "bar", "EMPTY": "", "EXPR": "a=b"},
Image: "nginx:1.27",
Labels: map[string]string{"logging": "enabled"},
Privileged: true,
PullPolicy: api.PullPolicyAlways,
Resources: api.ContainerResources{
CPU: 500 * api.MilliCore,
Memory: 128 * units.MiB,
MemoryReservation: 32 * units.MiB,
SharedMemory: 64 * units.MiB,
Ulimits: map[string]api.Ulimit{
"nofile": {Soft: 1024, Hard: 2048},
"nproc": {Soft: 65535, Hard: 65535},
},
},
User: "1000:1000",
VolumeMounts: []api.VolumeMount{
{VolumeName: "data", ContainerPath: "/var/lib/data", ReadOnly: true},
},
},
Labels: map[string]string{"tier": "frontend"},
Mode: api.ServiceModeReplicated,
Name: "web",
Placement: api.Placement{Machines: []string{"m1", "m2", "m3"}},
Ports: []api.PortSpec{
{
Hostname: "app.example.com",
ContainerPort: 8080,
Protocol: api.ProtocolHTTPS,
Mode: api.PortModeIngress,
},
{
PublishedPort: 53,
ContainerPort: 5353,
Protocol: api.ProtocolUDP,
Mode: api.PortModeHost,
},
},
Replicas: 3,
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{Name: "data"},
},
},
}, spec)
})
t.Run("generates a name when not specified", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.name = ""
opts.image = "bitnami/nginx:latest"
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Regexp(t, `^nginx-[a-z0-9]{4}$`, spec.Name)
})
t.Run("keeps the specified name", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.name = "custom"
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Equal(t, "custom", spec.Name)
})
t.Run("global mode", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.mode = api.ServiceModeGlobal
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Equal(t, api.ServiceModeGlobal, spec.Mode)
})
t.Run("entrypoint", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.entrypoint = "/bin/sh"
opts.entrypointChanged = true
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Equal(t, []string{"/bin/sh"}, spec.Container.Entrypoint)
// An explicitly empty entrypoint resets the one from the image.
opts = validRunOptions()
opts.entrypointChanged = true
spec, err = prepareServiceSpec(opts)
require.NoError(t, err)
assert.Equal(t, []string{""}, spec.Container.Entrypoint)
})
t.Run("env without a value is omitted when not set locally", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.env = []string{"UNCLOUD_TEST_ENV_THAT_IS_NOT_SET"}
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.NotContains(t, spec.Container.Env, "UNCLOUD_TEST_ENV_THAT_IS_NOT_SET")
})
t.Run("bind mount", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.volumes = []string{"/data/uploads:/app/uploads"}
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
require.Len(t, spec.Volumes, 1)
assert.Regexp(t, `^bind-[a-z0-9]{4}$`, spec.Volumes[0].Name)
assert.Equal(t, api.VolumeTypeBind, spec.Volumes[0].Type)
require.NotNil(t, spec.Volumes[0].BindOptions)
assert.Equal(t, "/data/uploads", spec.Volumes[0].BindOptions.HostPath)
assert.True(t, spec.Volumes[0].BindOptions.CreateHostPath)
require.Len(t, spec.Container.VolumeMounts, 1)
assert.Equal(t, spec.Volumes[0].Name, spec.Container.VolumeMounts[0].VolumeName)
assert.Equal(t, "/app/uploads", spec.Container.VolumeMounts[0].ContainerPath)
})
t.Run("named volume mounted twice creates one volume", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.volumes = []string{"data:/a", "data:/b"}
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Len(t, spec.Volumes, 1)
assert.Len(t, spec.Container.VolumeMounts, 2)
})
t.Run("caddyfile content is trimmed", func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "Caddyfile")
require.NoError(t, os.WriteFile(path, []byte("\nexample.com {\n}\n\n"), 0o600))
opts := validRunOptions()
opts.caddyfile = path
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
require.NotNil(t, spec.Caddy)
assert.Equal(t, "example.com {\n}", spec.Caddy.Config)
})
t.Run("empty caddyfile is ignored", func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "Caddyfile")
require.NoError(t, os.WriteFile(path, []byte(" \n"), 0o600))
opts := validRunOptions()
opts.caddyfile = path
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Nil(t, spec.Caddy)
})
t.Run("caddyfile can be used with host ports", func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "Caddyfile")
require.NoError(t, os.WriteFile(path, []byte("example.com {\n}"), 0o600))
opts := validRunOptions()
opts.caddyfile = path
opts.publish = []string{"53:5353/udp@host"}
_, err := prepareServiceSpec(opts)
require.NoError(t, err)
})
}
func TestPrepareServiceSpec_LocalEnv(t *testing.T) {
// Not parallel because t.Setenv changes the environment of the whole process.
t.Setenv("UNCLOUD_TEST_LOCAL_ENV", "from-local")
opts := validRunOptions()
opts.env = []string{"UNCLOUD_TEST_LOCAL_ENV"}
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Equal(t, "from-local", spec.Container.Env["UNCLOUD_TEST_LOCAL_ENV"])
}
func TestPrepareServiceSpec_Invalid(t *testing.T) {
t.Parallel()
caddyfile := filepath.Join(t.TempDir(), "Caddyfile")
require.NoError(t, os.WriteFile(caddyfile, []byte("example.com {\n}"), 0o600))
tests := []struct {
name string
modify func(opts *runOptions)
wantErr string
}{
{
name: "invalid image",
modify: func(opts *runOptions) { opts.image = "INVALID IMAGE" },
wantErr: "invalid image",
},
{
name: "invalid name",
modify: func(opts *runOptions) { opts.name = "Not_Valid" },
wantErr: "invalid service name",
},
{
name: "invalid mode",
modify: func(opts *runOptions) { opts.mode = "unknown" },
wantErr: "invalid replication mode: 'unknown'",
},
{
name: "invalid pull policy",
modify: func(opts *runOptions) { opts.pull = "sometimes" },
wantErr: "invalid pull policy: 'sometimes'",
},
{
name: "invalid env",
modify: func(opts *runOptions) { opts.env = []string{"=value"} },
wantErr: "invalid environment variable: '=value'",
},
{
name: "invalid port",
modify: func(opts *runOptions) { opts.publish = []string{"not-a-port"} },
wantErr: "invalid service port 'not-a-port'",
},
{
name: "invalid ulimit",
modify: func(opts *runOptions) { opts.ulimits = []string{"nofile"} },
wantErr: "invalid ulimit 'nofile'",
},
{
name: "volume without a container path",
modify: func(opts *runOptions) { opts.volumes = []string{"data"} },
wantErr: "invalid volume mount 'data'",
},
{
name: "volume with a relative container path",
modify: func(opts *runOptions) { opts.volumes = []string{"data:relative"} },
wantErr: "must be absolute path",
},
{
name: "volume with an unknown option",
modify: func(opts *runOptions) { opts.volumes = []string{"data:/data:unknown"} },
wantErr: "invalid option: 'unknown'",
},
{
name: "missing caddyfile",
modify: func(opts *runOptions) { opts.caddyfile = filepath.Join(os.TempDir(), "uncloud-test-no-such-file") },
wantErr: "read Caddyfile",
},
{
name: "caddyfile with an ingress port",
modify: func(opts *runOptions) {
opts.caddyfile = caddyfile
opts.publish = []string{"app.example.com:8080/https"}
},
wantErr: "cannot be specified simultaneously",
},
{
name: "service label without a key",
modify: func(opts *runOptions) { opts.labels = []string{"=value"} },
wantErr: "key must not be empty",
},
{
name: "container label without a key",
modify: func(opts *runOptions) { opts.containerLabels = []string{"=value"} },
wantErr: "key must not be empty",
},
{
name: "service label with whitespace",
modify: func(opts *runOptions) { opts.labels = []string{"my label=value"} },
wantErr: "invalid service labels: label 'my label' must not contain whitespace",
},
{
name: "container label with whitespace",
modify: func(opts *runOptions) { opts.containerLabels = []string{"my label=value"} },
wantErr: "invalid container labels: label 'my label' must not contain whitespace",
},
{
name: "reserved service label",
modify: func(opts *runOptions) { opts.labels = []string{api.LabelManaged + "=true"} },
wantErr: "invalid service labels",
},
{
name: "reserved container label",
modify: func(opts *runOptions) { opts.containerLabels = []string{api.LabelManaged + "=true"} },
wantErr: "invalid container labels",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
tt.modify(&opts)
_, err := prepareServiceSpec(opts)
require.ErrorContains(t, err, tt.wantErr)
})
}
}
+183
View File
@@ -0,0 +1,183 @@
package service
import (
"context"
"errors"
"fmt"
"maps"
"slices"
dockeropts "github.com/docker/cli/opts"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/spf13/cobra"
)
type updateOptions struct {
service string
cpu dockeropts.NanoCPUs
cpuChanged bool
memory dockeropts.MemBytes
memoryChanged bool
memoryReservation dockeropts.MemBytes
reservationChanged bool
labelAdd []string
labelRm []string
}
func NewUpdateCommand() *cobra.Command {
opts := updateOptions{}
cmd := &cobra.Command{
Use: "update SERVICE",
Short: "Update a service in place without recreating its containers.",
Long: `Update service configuration in place without recreating its containers.
A CPU or memory limit can be changed but not removed. To remove a limit or change anything else,
redeploy the service with 'uc deploy'`,
Example: ` # Change the CPU and memory limits.
uc service update web --cpu 0.5 --memory 512m
# Add or change a service label and remove another one.
uc service update web --label-add tier=frontend --label-rm legacy`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.service = args[0]
opts.cpuChanged = cmd.Flag("cpu").Changed
opts.memoryChanged = cmd.Flag("memory").Changed
opts.reservationChanged = cmd.Flag("memory-reservation").Changed
return update(cmd.Context(), uncli, opts)
},
ValidArgsFunction: func(
cmd *cobra.Command, args []string, toComplete string,
) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
uncli := cmd.Context().Value("cli").(*cli.CLI)
return completion.Services(cmd.Context(), uncli, args, toComplete)
},
}
cmd.Flags().Var(&opts.cpu, "cpu",
"Maximum number of CPU cores a service container can use.\n"+
"Fractional values are allowed: 0.5 for half a core or 2.25 for two and a quarter cores.")
cmd.Flags().Var(&opts.memory, "memory",
"Maximum amount of memory a service container can use. Value is a positive integer with\n"+
"optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.\n"+
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
cmd.Flags().Var(&opts.memoryReservation, "memory-reservation",
"Minimum amount of memory reserved for a service container. Value is a positive integer with\n"+
"optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.")
cmd.Flags().StringArrayVar(&opts.labelAdd, "label-add", nil,
"Add or change a service label. Can be specified multiple times.\n"+
"Format: key=value or just key for an empty value.")
cmd.Flags().StringArrayVar(&opts.labelRm, "label-rm", nil,
"Remove a service label by its key. Can be specified multiple times.")
return cmd
}
func update(ctx context.Context, uncli *cli.CLI, opts updateOptions) error {
if !opts.cpuChanged && !opts.memoryChanged && !opts.reservationChanged &&
len(opts.labelAdd) == 0 && len(opts.labelRm) == 0 {
return errors.New("no changes specified, set at least one of the flags: " +
"--cpu, --memory, --memory-reservation, --label-add, --label-rm")
}
labelAdd, err := parseLabels(opts.labelAdd)
if err != nil {
return err
}
if err = api.ValidateLabels(labelAdd); err != nil {
return fmt.Errorf("invalid service labels: %w", err)
}
for _, key := range opts.labelRm {
if _, ok := labelAdd[key]; ok {
return fmt.Errorf("label '%s' cannot be both added and removed", key)
}
}
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
svc, err := clusterClient.InspectService(ctx, opts.service)
if err != nil {
return fmt.Errorf("inspect service '%s': %w", opts.service, err)
}
// Check all containers before changing any of them to avoid a partially updated service when a limit can't be
// changed in place. Containers may have different specs after a failed deployment so each one is updated from
// its own spec.
updates := make(map[string]api.UpdateContainerOptions, len(svc.Containers))
for _, mc := range svc.Containers {
spec := applyServiceUpdate(mc.Container.ServiceSpec, opts, labelAdd)
resources := mc.Container.AppliedResources()
if err = resources.ValidateUpdateInPlace(spec.Container.Resources); err != nil {
return fmt.Errorf("container '%s' on machine '%s' can't be updated without recreating it: %w",
stringid.TruncateID(mc.Container.ID), mc.MachineName, err)
}
containerOpts := deploy.ContainerUpdateOptions(mc.Container, spec)
if containerOpts != (api.UpdateContainerOptions{}) {
updates[mc.Container.ID] = containerOpts
}
}
if len(updates) == 0 {
fmt.Printf("Service %s is already up to date. No changes required.\n", tui.NameStyle.Render(svc.Name))
return nil
}
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
// Keep updating the remaining containers if one fails, for example, because its machine is down.
// Running the command again only updates the containers that still differ.
var errs []error
for id, containerOpts := range updates {
if err := clusterClient.UpdateContainer(ctx, svc.ID, id, containerOpts); err != nil {
errs = append(errs, fmt.Errorf("update container '%s': %w", stringid.TruncateID(id), err))
}
}
return errors.Join(errs...)
}, uncli.ProgressOut(), "Updating service "+tui.NameStyle.Render(svc.Name))
}
// applyServiceUpdate returns a copy of the spec with the requested changes applied.
func applyServiceUpdate(spec api.ServiceSpec, opts updateOptions, labelAdd map[string]string) api.ServiceSpec {
updated := spec.Clone()
if opts.cpuChanged {
updated.Container.Resources.CPU = opts.cpu.Value()
}
if opts.memoryChanged {
updated.Container.Resources.Memory = opts.memory.Value()
}
if opts.reservationChanged {
updated.Container.Resources.MemoryReservation = opts.memoryReservation.Value()
}
if len(labelAdd) > 0 && updated.Labels == nil {
updated.Labels = make(map[string]string, len(labelAdd))
}
maps.Copy(updated.Labels, labelAdd)
for key := range updated.Labels {
if slices.Contains(opts.labelRm, key) {
delete(updated.Labels, key)
}
}
return updated
}
+106
View File
@@ -0,0 +1,106 @@
package service
import (
"testing"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplyServiceUpdate(t *testing.T) {
t.Parallel()
newSpec := func() api.ServiceSpec {
return api.ServiceSpec{
Name: "web",
Container: api.ContainerSpec{
Image: "nginx",
Labels: map[string]string{"container": "keep"},
Resources: api.ContainerResources{
CPU: 100 * api.MilliCore,
Memory: 1024,
MemoryReservation: 512,
},
},
Labels: map[string]string{"tier": "frontend", "legacy": "true"},
}
}
t.Run("adds, changes and removes labels", func(t *testing.T) {
t.Parallel()
spec := newSpec()
updated := applyServiceUpdate(spec, updateOptions{labelRm: []string{"legacy", "missing"}},
map[string]string{"tier": "backend", "team": "core"})
assert.Equal(t, map[string]string{"tier": "backend", "team": "core"}, updated.Labels)
assert.Equal(t, map[string]string{"container": "keep"}, updated.Container.Labels)
assert.Equal(t, spec.Container.Resources, updated.Container.Resources)
assert.Equal(t, map[string]string{"tier": "frontend", "legacy": "true"}, spec.Labels,
"Expected the original spec to stay unchanged")
})
t.Run("adds labels to a spec without labels", func(t *testing.T) {
t.Parallel()
spec := newSpec()
spec.Labels = nil
updated := applyServiceUpdate(spec, updateOptions{}, map[string]string{"tier": "backend"})
assert.Equal(t, map[string]string{"tier": "backend"}, updated.Labels)
})
t.Run("changes only requested limits", func(t *testing.T) {
t.Parallel()
opts := updateOptions{cpuChanged: true}
require.NoError(t, opts.cpu.Set("0.5"))
updated := applyServiceUpdate(newSpec(), opts, nil)
assert.EqualValues(t, 500*api.MilliCore, updated.Container.Resources.CPU)
assert.EqualValues(t, 1024, updated.Container.Resources.Memory)
assert.EqualValues(t, 512, updated.Container.Resources.MemoryReservation)
})
t.Run("sends only changed fields to the container", func(t *testing.T) {
t.Parallel()
opts := updateOptions{memoryChanged: true}
require.NoError(t, opts.memory.Set("2048"))
ctr := api.ServiceContainer{ServiceSpec: newSpec()}
containerOpts := deploy.ContainerUpdateOptions(ctr, applyServiceUpdate(ctr.ServiceSpec, opts, nil))
require.NotNil(t, containerOpts.Memory)
assert.EqualValues(t, 2048, *containerOpts.Memory)
assert.Nil(t, containerOpts.CPU)
assert.Nil(t, containerOpts.MemoryReservation)
assert.Nil(t, containerOpts.ServiceLabels)
})
t.Run("removing the last label clears service labels", func(t *testing.T) {
t.Parallel()
ctr := api.ServiceContainer{ServiceSpec: newSpec()}
containerOpts := deploy.ContainerUpdateOptions(ctr, applyServiceUpdate(
ctr.ServiceSpec, updateOptions{labelRm: []string{"tier", "legacy"}}, nil,
))
require.NotNil(t, containerOpts.ServiceLabels)
assert.Empty(t, *containerOpts.ServiceLabels)
})
t.Run("no changes produce empty container options", func(t *testing.T) {
t.Parallel()
ctr := api.ServiceContainer{ServiceSpec: newSpec()}
containerOpts := deploy.ContainerUpdateOptions(ctr, applyServiceUpdate(
ctr.ServiceSpec, updateOptions{}, map[string]string{"tier": "frontend"},
))
assert.Equal(t, api.UpdateContainerOptions{}, containerOpts)
})
}
+22 -22
View File
@@ -1,6 +1,6 @@
module github.com/psviderski/uncloud/experiment module github.com/psviderski/uncloud/experiment
go 1.26 go 1.26.0
require ( require (
github.com/dgraph-io/badger/v3 v3.2103.5 github.com/dgraph-io/badger/v3 v3.2103.5
@@ -18,10 +18,10 @@ require (
github.com/psviderski/uncloud v0.0.0 github.com/psviderski/uncloud v0.0.0
github.com/siderolabs/discovery-api v0.1.4 github.com/siderolabs/discovery-api v0.1.4
github.com/siderolabs/discovery-client v0.1.9 github.com/siderolabs/discovery-client v0.1.9
go.uber.org/zap v1.27.0 go.uber.org/zap v1.28.0
golang.org/x/net v0.43.0 golang.org/x/net v0.59.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
google.golang.org/grpc v1.74.2 google.golang.org/grpc v1.81.0
) )
require ( require (
@@ -79,18 +79,18 @@ require (
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 // indirect github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 // indirect
github.com/jbenet/goprocess v0.1.4 // indirect github.com/jbenet/goprocess v0.1.4 // indirect
github.com/josharian/native v1.1.0 // indirect github.com/josharian/native v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.19.1 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/libp2p/go-buffer-pool v0.1.0 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect
github.com/libp2p/go-libp2p v0.35.4 // indirect github.com/libp2p/go-libp2p v0.35.4 // indirect
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
github.com/libp2p/go-msgio v0.3.0 // indirect github.com/libp2p/go-msgio v0.3.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect github.com/mdlayher/socket v0.5.1 // indirect
github.com/miekg/dns v1.1.65 // indirect github.com/miekg/dns v1.1.73 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mitchellh/cli v1.1.5 // indirect github.com/mitchellh/cli v1.1.5 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
@@ -113,30 +113,30 @@ require (
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
github.com/siderolabs/gen v0.4.8 // indirect github.com/siderolabs/gen v0.4.8 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cast v1.7.0 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.41.0 // indirect golang.org/x/crypto v0.57.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/mod v0.27.0 // indirect golang.org/x/mod v0.41.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.11.0 // indirect golang.org/x/time v0.16.0 // indirect
golang.org/x/tools v0.36.0 // indirect golang.org/x/tools v0.50.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 // indirect golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
google.golang.org/protobuf v1.36.9 // indirect google.golang.org/protobuf v1.36.12 // indirect
gotest.tools/v3 v3.5.2 // indirect gotest.tools/v3 v3.5.2 // indirect
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
lukechampine.com/blake3 v1.3.0 // indirect lukechampine.com/blake3 v1.3.0 // indirect
+21
View File
@@ -302,8 +302,10 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0=
github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk=
@@ -349,6 +351,7 @@ github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
@@ -368,6 +371,7 @@ github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKju
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc= github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc=
github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck= github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc= github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc=
@@ -529,6 +533,7 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
@@ -581,16 +586,20 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc= go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc=
go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
@@ -607,6 +616,7 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -622,6 +632,7 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
@@ -635,6 +646,7 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -654,6 +666,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -666,6 +679,7 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -700,6 +714,7 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
@@ -711,8 +726,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -727,6 +744,7 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -745,6 +763,7 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
@@ -753,6 +772,7 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -764,6 +784,7 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+1 -1
View File
@@ -34,6 +34,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/mattn/go-shellwords v1.0.12 github.com/mattn/go-shellwords v1.0.12
github.com/mholt/acmez/v3 v3.1.6
github.com/miekg/dns v1.1.73 github.com/miekg/dns v1.1.73
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/moby/term v0.5.2 github.com/moby/term v0.5.2
@@ -178,7 +179,6 @@ require (
github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect github.com/mdlayher/socket v0.5.1 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/mholt/acmez/v3 v3.1.6 // indirect
github.com/miekg/pkcs11 v1.1.2 // indirect github.com/miekg/pkcs11 v1.1.2 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
+1 -1
View File
@@ -458,7 +458,7 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (_ *clie
} }
// Snapshot the cluster store version so the new machine can catch up before participating. // Snapshot the cluster store version so the new machine can catch up before participating.
var storeVersion map[string]uint64 var storeVersion api.StoreVersion
inspectResp, err = c.MachineClient.InspectMachine(ctx, &emptypb.Empty{}) inspectResp, err = c.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
if err != nil { if err != nil {
// TODO(lhf): remove Unimplemented check when v0.17.0 is released. // TODO(lhf): remove Unimplemented check when v0.17.0 is released.
+66
View File
@@ -0,0 +1,66 @@
package logs
import (
"fmt"
"math/big"
"strings"
"time"
)
// Duration is an extended standard time.Duration that also supports days as a unit.
type Duration = time.Duration
// ParseDuration parses a Go duration with the additional unit d, meaning exactly 24 hours.
// Days can be fractional or combined with other units, such as "1.5d" or "2d3h".
func ParseDuration(value string) (Duration, error) {
if !strings.Contains(value, "d") {
return time.ParseDuration(value)
}
rest := value
var normalised strings.Builder
if len(rest) > 0 && (rest[0] == '-' || rest[0] == '+') {
normalised.WriteByte(rest[0])
rest = rest[1:]
}
for len(rest) > 0 {
// Each component is a decimal number followed by a unit. Only the leading sign is allowed.
numberEnd := 0
for numberEnd < len(rest) && (isDigit(rest[numberEnd]) || rest[numberEnd] == '.') {
numberEnd++
}
unitEnd := numberEnd
for unitEnd < len(rest) && !isDigit(rest[unitEnd]) && rest[unitEnd] != '.' {
unitEnd++
}
if numberEnd == 0 || unitEnd == numberEnd {
return 0, fmt.Errorf("time: invalid duration '%s'", value)
}
number, unit := rest[:numberEnd], rest[numberEnd:unitEnd]
if unit == "d" {
// Use exact decimal arithmetic to avoid float rounding and retain nanosecond precision.
days, ok := new(big.Rat).SetString(number)
if !ok {
return 0, fmt.Errorf("time: invalid duration '%s'", value)
}
days.Mul(days, new(big.Rat).SetInt64(int64(24*time.Hour)))
nanoseconds := new(big.Int).Quo(days.Num(), days.Denom())
normalised.WriteString(nanoseconds.String())
normalised.WriteString("ns")
} else {
normalised.WriteString(rest[:unitEnd])
}
rest = rest[unitEnd:]
}
// The standard parser validates the remaining units and checks the total for overflow.
duration, err := time.ParseDuration(normalised.String())
if err != nil {
return 0, fmt.Errorf("time: invalid duration '%s': %w", value, err)
}
return duration, nil
}
func isDigit(c byte) bool {
return c >= '0' && c <= '9'
}
+62
View File
@@ -0,0 +1,62 @@
package logs
import (
"math"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseDuration(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
input string
want time.Duration
}{
{"0", 0},
{"2m30s", 150 * time.Second},
{"1µs", time.Microsecond},
{"1μs", time.Microsecond},
{"2d", 48 * time.Hour},
{"010d", 240 * time.Hour},
{"08d", 192 * time.Hour},
{"2d3h", 51 * time.Hour},
{"3h2d", 51 * time.Hour},
{"1d1d", 48 * time.Hour},
{"1.5d", 36 * time.Hour},
{".5d", 12 * time.Hour},
{"1.d", 24 * time.Hour},
{"-2d3h", -51 * time.Hour},
{"+2d", 48 * time.Hour},
{"0d", 0},
{"0d1ns", time.Nanosecond},
{"0.000000000001d", 86 * time.Nanosecond},
{"-0.000000000001d", -86 * time.Nanosecond},
{"106751d23h47m16.854775807s", time.Duration(math.MaxInt64)},
{"-106751d23h47m16.854775808s", time.Duration(math.MinInt64)},
} {
t.Run(tt.input, func(t *testing.T) {
actual, err := ParseDuration(tt.input)
require.NoError(t, err)
assert.Equal(t, tt.want, actual)
})
}
}
func TestParseDuration_Invalid(t *testing.T) {
t.Parallel()
for _, input := range []string{
"", "d", "2d3", "1..2d", ".d", "1d-2h", "1d+2h", "1d 2h", "1day", "2D", "1w",
"1e2d", "1d2w", "106752d", "-106752d", "106751d23h47m16.854775808s",
"-106751d23h47m16.854775809s", "999999999999999999999999999d",
} {
t.Run(input, func(t *testing.T) {
_, err := ParseDuration(input)
require.Error(t, err)
})
}
}
+11 -6
View File
@@ -10,6 +10,7 @@ import (
"time" "time"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/charmbracelet/colorprofile"
"github.com/docker/docker/pkg/stringid" "github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
@@ -24,6 +25,10 @@ type Formatter struct {
maxServiceWidth int maxServiceWidth int
utc bool utc bool
// Cache each stream's terminal profile to avoid detection on every log entry.
stdout *colorprofile.Writer
stderr *colorprofile.Writer
} }
func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter { func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter {
@@ -50,6 +55,8 @@ func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter {
maxMachineWidth: maxMachineWidth, maxMachineWidth: maxMachineWidth,
maxServiceWidth: maxServiceWidth, maxServiceWidth: maxServiceWidth,
utc: utc, utc: utc,
stdout: colorprofile.NewWriter(os.Stdout, os.Environ()),
stderr: colorprofile.NewWriter(os.Stderr, os.Environ()),
} }
} }
@@ -138,9 +145,9 @@ func (f *Formatter) PrintEntry(entry api.ServiceLogEntry) {
// Print to appropriate stream. // Print to appropriate stream.
if entry.Stream == api.LogStreamStderr { if entry.Stream == api.LogStreamStderr {
fmt.Fprint(os.Stderr, output.String()) fmt.Fprint(f.stderr, output.String())
} else { } else {
fmt.Print(output.String()) fmt.Fprint(f.stdout, output.String())
} }
} }
@@ -148,8 +155,7 @@ func (f *Formatter) PrintEntry(entry api.ServiceLogEntry) {
func (f *Formatter) printError(entry api.ServiceLogEntry) { func (f *Formatter) printError(entry api.ServiceLogEntry) {
if entry.Metadata.ServiceName == "" { if entry.Metadata.ServiceName == "" {
msg := fmt.Sprintf("ERROR: %v", entry.Err) msg := fmt.Sprintf("ERROR: %v", entry.Err)
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.BrightRed) fmt.Fprintln(f.stderr, tui.BoldRed.Render(msg))
fmt.Fprintln(os.Stderr, style.Render(msg))
return return
} }
@@ -171,8 +177,7 @@ func (f *Formatter) printError(entry api.ServiceLogEntry) {
msg += fmt.Sprintf(": %v", entry.Err) msg += fmt.Sprintf(": %v", entry.Err)
} }
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.BrightYellow) fmt.Fprintln(f.stderr, tui.BoldYellow.Render(msg))
fmt.Fprintln(os.Stderr, style.Render(msg))
} }
// palette is available colors for machine/service differentiation. // palette is available colors for machine/service differentiation.
+22 -7
View File
@@ -19,6 +19,18 @@ type Options struct {
Machines []string Machines []string
} }
// TailLines resolves the default tail limit after parsing flags. An empty opts.Tail means the user
// did not specify a limit, so --since can select all matching logs without overriding an explicit --tail.
func (opts Options) TailLines() (int, error) {
if opts.Tail == "" {
if opts.Since != "" {
return -1, nil
}
return 100, nil
}
return ParseTail(opts.Tail)
}
func Flags(options *Options) *pflag.FlagSet { func Flags(options *Options) *pflag.FlagSet {
set := &pflag.FlagSet{} set := &pflag.FlagSet{}
@@ -29,14 +41,17 @@ func Flags(options *Options) *pflag.FlagSet {
set.StringVar(&options.Since, "since", "", set.StringVar(&options.Since, "since", "",
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+ "Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
"Examples:\n"+ "Examples:\n"+
" --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+ " --since 1h45m Relative duration (1 hour 45 minutes ago)\n"+
" --since 1h Relative duration (1 hour ago)\n"+ " Supported units: d (day = 24h), h (hour), m (minute),\n"+
" --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+ " s (second), ms (millisecond),\n"+
" --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+ " us/µs (microsecond), ns (nanosecond)\n"+
" --since 2025-11-24 RFC 3339 date only (midnight using client local timezone)\n"+
" --since 2024-05-14T22:50:00 RFC 3339 date/time using client local timezone\n"+
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+ " --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)") " --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
set.StringVarP(&options.Tail, "tail", "n", "100", set.StringVarP(&options.Tail, "tail", "n", "",
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.") "Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.\n"+
"Defaults to 100, or 'all' when --since is set.")
set.StringVar(&options.Until, "until", "", set.StringVar(&options.Until, "until", "",
"Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+ "Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
"See --since for examples.") "See --since for examples.")
@@ -46,7 +61,7 @@ func Flags(options *Options) *pflag.FlagSet {
return set return set
} }
func Tail(tail string) (int, error) { func ParseTail(tail string) (int, error) {
if tail == "all" { if tail == "all" {
return -1, nil return -1, nil
} }
+36
View File
@@ -7,6 +7,42 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestOptionsTailLines(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
want int
}{
{"default", nil, 100},
{"follow", []string{"-f"}, 100},
{"since", []string{"--since", "1h"}, -1},
{"since and follow", []string{"--since", "1h", "-f"}, -1},
{"until only", []string{"--until", "1h"}, 100},
{"time range", []string{"--since", "3h", "--until", "1h"}, -1},
{"explicit default with since", []string{"--since", "1h", "--tail", "100"}, 100},
{"explicit limit with since", []string{"--since", "1h", "-n", "20"}, 20},
{"explicit limit before since", []string{"-n", "20", "--since", "1h"}, 20},
{"explicit all", []string{"--tail", "all"}, -1},
{"explicit zero with since", []string{"--since", "1h", "-n", "0", "-f"}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var options Options
require.NoError(t, Flags(&options).Parse(tt.args))
tail, err := options.TailLines()
require.NoError(t, err)
assert.Equal(t, tt.want, tail)
})
}
var options Options
require.NoError(t, Flags(&options).Parse([]string{"--since", "1h", "--tail", "invalid"}))
_, err := options.TailLines()
require.ErrorContains(t, err, "invalid --tail value")
}
func TestParseServiceArgs(t *testing.T) { func TestParseServiceArgs(t *testing.T) {
t.Parallel() t.Parallel()
+72
View File
@@ -0,0 +1,72 @@
package logs
import (
"fmt"
"strconv"
"strings"
"time"
)
// TimeRange resolves log filters that could be RFC 3339, timestamp, or relative duration to UTC timestamps.
// Dates without a timezone use now's location. Relative durations are computed from now.
func TimeRange(since, until string, now time.Time) (string, string, error) {
var err error
since, err = timestamp(since, now)
if err != nil {
return "", "", fmt.Errorf("invalid --since value: %w", err)
}
until, err = timestamp(until, now)
if err != nil {
return "", "", fmt.Errorf("invalid --until value: %w", err)
}
return since, until, nil
}
func timestamp(value string, now time.Time) (string, error) {
if value == "" {
return "", nil
}
// A bare zero is the Unix epoch, matching Docker's log filters.
if duration, err := ParseDuration(value); value != "0" && err == nil {
return now.Add(-duration).UTC().Format(time.RFC3339Nano), nil
}
// Keep Docker's supported date layouts, but use the location's offset at the requested date.
for _, layout := range []string{
time.RFC3339Nano,
"2006-01-02T15:04Z07:00",
"2006-01-02T15Z07:00",
"2006-01-02Z07:00",
"2006-01-02T15:04:05.999999999",
"2006-01-02T15:04",
"2006-01-02T15",
"2006-01-02",
} {
if t, err := time.ParseInLocation(layout, value, now.Location()); err == nil {
return t.UTC().Format(time.RFC3339Nano), nil
}
}
seconds, fraction, hasFraction := strings.Cut(value, ".")
sec, err := strconv.ParseInt(seconds, 10, 64)
if err != nil {
return "", fmt.Errorf("failed to parse '%s' as a time or duration", value)
}
var nsec int64
if hasFraction {
if len(fraction) == 0 || len(fraction) > 9 || strings.ContainsAny(fraction, "+-") {
return "", fmt.Errorf("invalid Unix timestamp fraction in '%s'", value)
}
nsec, err = strconv.ParseInt(fraction+strings.Repeat("0", 9-len(fraction)), 10, 64)
if err != nil {
return "", fmt.Errorf("invalid Unix timestamp fraction in '%s': %w", value, err)
}
}
t := time.Unix(sec, nsec).UTC()
if t.Year() < 0 || t.Year() > 9999 {
return "", fmt.Errorf("Unix timestamp '%s' is outside the RFC 3339 date range", value)
}
return t.Format(time.RFC3339Nano), nil
}
+87
View File
@@ -0,0 +1,87 @@
package logs
import (
"testing"
"time"
timetypes "github.com/docker/docker/api/types/time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTimeRange(t *testing.T) {
t.Parallel()
location, err := time.LoadLocation("Australia/Sydney")
require.NoError(t, err)
// October is daylight-saving time, but July timestamps must use the winter offset.
// Daylight saving starts on 4 October. Day durations must still mean 24 elapsed hours.
now := time.Date(2026, 10, 5, 12, 0, 0, 123456789, location)
tests := []struct {
input string
want string
}{
{"", ""},
{"2026-07-01", "2026-06-30T14:00:00Z"},
{"2026-07-01T10", "2026-07-01T00:00:00Z"},
{"2026-07-01T10:30", "2026-07-01T00:30:00Z"},
{"2026-07-01T10:30:45.123456789", "2026-07-01T00:30:45.123456789Z"},
{"2026-01-01T10:00:00", "2025-12-31T23:00:00Z"},
{"2026-07-01T10:30:45Z", "2026-07-01T10:30:45Z"},
{"2026-07-01T10:30:45+02:00", "2026-07-01T08:30:45Z"},
{"2026-07-01T10:30:45-04:00", "2026-07-01T14:30:45Z"},
{"2026-07-01T10+02:00", "2026-07-01T08:00:00Z"},
{"2026-07-01T10:30+02:00", "2026-07-01T08:30:00Z"},
{"2026-07-01+02:00", "2026-06-30T22:00:00Z"},
{"1763953966", "2025-11-24T03:12:46Z"},
{"1763953966.000000001", "2025-11-24T03:12:46.000000001Z"},
{"0", "1970-01-01T00:00:00Z"},
{"2m30s", "2026-10-05T00:57:30.123456789Z"},
{"1d", "2026-10-04T01:00:00.123456789Z"},
{"2d", "2026-10-03T01:00:00.123456789Z"},
{"2d3h", "2026-10-02T22:00:00.123456789Z"},
{"1.5d", "2026-10-03T13:00:00.123456789Z"},
{"-2d", "2026-10-07T01:00:00.123456789Z"},
{"-1h", "2026-10-05T02:00:00.123456789Z"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
since, until, err := TimeRange(tt.input, tt.input, now)
require.NoError(t, err)
assert.Equal(t, tt.want, since)
assert.Equal(t, tt.want, until)
if since != "" {
// The server-side Docker SDK must preserve the cutoff even in another timezone.
actual, err := timetypes.GetTimestamp(since, now.In(time.UTC))
require.NoError(t, err)
expected, err := time.Parse(time.RFC3339Nano, tt.want)
require.NoError(t, err)
sec, nsec, err := timetypes.ParseTimestamps(actual, 0)
require.NoError(t, err)
assert.True(t, expected.Equal(time.Unix(sec, nsec)))
}
})
}
since, until, err := TimeRange("3h", "1h30m", now)
require.NoError(t, err)
assert.Equal(t, "2026-10-04T22:00:00.123456789Z", since)
assert.Equal(t, "2026-10-04T23:30:00.123456789Z", until)
}
func TestTimeRange_Invalid(t *testing.T) {
t.Parallel()
for _, input := range []string{
"invalid", "2026-02-30", "2026-01-01T25:00:00", "1763953966.xyz",
"1763953966.1234567890", "253402300800",
} {
t.Run(input, func(t *testing.T) {
_, _, err := TimeRange(input, "", time.Now())
require.ErrorContains(t, err, "invalid --since value")
_, _, err = TimeRange("", input, time.Now())
require.ErrorContains(t, err, "invalid --until value")
})
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ import (
type eventIDKey struct{} type eventIDKey struct{}
// WithEventID returns a context that overrides the default event ID used by client methods. // WithEventID returns a context that overrides the event ID already set in the context.
func WithEventID(ctx context.Context, eventID string) context.Context { func WithEventID(ctx context.Context, eventID string) context.Context {
return context.WithValue(ctx, eventIDKey{}, eventID) return context.WithValue(ctx, eventIDKey{}, eventID)
} }
@@ -48,7 +48,7 @@ func OldPreDeployHookEventID(serviceName, containerID, machineName string) strin
tui.Faint.Render(" on ") + machineName tui.Faint.Render(" on ") + machineName
} }
// ImageEventID returns a progress event ID for image pull operations. // ImageEventID returns a progress event ID for image operations on a machine.
func ImageEventID(image, machineName string) string { func ImageEventID(image, machineName string) string {
return tui.Faint.Render("Image ") + image + return tui.Faint.Render("Image ") + image +
tui.Faint.Render(" on ") + machineName tui.Faint.Render(" on ") + machineName
+3 -1
View File
@@ -3,9 +3,11 @@ package tui
import ( import (
"fmt" "fmt"
"os" "os"
"charm.land/lipgloss/v2"
) )
func PrintWarning(msg string) { func PrintWarning(msg string) {
styledMsg := BoldYellow.Render(fmt.Sprintf("WARNING: %s", msg)) styledMsg := BoldYellow.Render(fmt.Sprintf("WARNING: %s", msg))
fmt.Fprintln(os.Stderr, styledMsg) lipgloss.Fprintln(os.Stderr, styledMsg)
} }
+8 -1
View File
@@ -13,6 +13,8 @@ import (
"time" "time"
) )
const adminCommandTimeout = 5 * time.Second
// AdminClient is a client for the Corrosion admin API. // AdminClient is a client for the Corrosion admin API.
type AdminClient struct { type AdminClient struct {
sockPath string sockPath string
@@ -32,10 +34,15 @@ type Response struct {
// The channel will be closed after sending the last or error response. The caller must read from the channel until // The channel will be closed after sending the last or error response. The caller must read from the channel until
// it is closed. // it is closed.
func (c *AdminClient) SendCommand(cmd []byte) (<-chan Response, error) { func (c *AdminClient) SendCommand(cmd []byte) (<-chan Response, error) {
conn, err := net.Dial("unix", c.sockPath) // TODO: Accept contexts in admin methods so callers can cancel requests and set their deadlines.
conn, err := net.DialTimeout("unix", c.sockPath, adminCommandTimeout)
if err != nil { if err != nil {
return nil, fmt.Errorf("connect to admin socket: %w", err) return nil, fmt.Errorf("connect to admin socket: %w", err)
} }
if err = conn.SetDeadline(time.Now().Add(adminCommandTimeout)); err != nil {
conn.Close()
return nil, fmt.Errorf("set admin connection deadline: %w", err)
}
if _, err = conn.Write(encodeFrame(cmd)); err != nil { if _, err = conn.Write(encodeFrame(cmd)); err != nil {
conn.Close() conn.Close()
+15 -2
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"os/exec" "os/exec"
"time"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
) )
@@ -31,11 +32,11 @@ func logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (io.Rea
if opts.Since != "" { if opts.Since != "" {
args = append(args, "-S") args = append(args, "-S")
args = append(args, opts.Since) args = append(args, journalTimestamp(opts.Since))
} }
if opts.Until != "" { if opts.Until != "" {
args = append(args, "-U") args = append(args, "-U")
args = append(args, opts.Until) args = append(args, journalTimestamp(opts.Until))
} }
cmd := commandContext(ctx, journalctl, args...) cmd := commandContext(ctx, journalctl, args...)
@@ -51,6 +52,18 @@ func logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (io.Rea
return p, cmd.Wait, nil return p, cmd.Wait, nil
} }
// journalTimestamp formats normalised client timestamps for journalctl versions that do not
// accept RFC 3339 timezone suffixes. Support for timestamps containing T and Z was added in
// systemd 255 (6 December 2023). Debian 12 ships systemd 252, so it still needs this conversion.
// Journald timestamps have microsecond precision.
func journalTimestamp(value string) string {
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
return t.UTC().Format("2006-01-02 15:04:05.999999") + " UTC"
}
// Preserve raw filters from older clients and SDK callers.
return value
}
// follow synchronously follows the io.Reader, writing each new journal entry to channel. // follow synchronously follows the io.Reader, writing each new journal entry to channel.
// It stops when the reader is exhausted or the context is cancelled. // It stops when the reader is exhausted or the context is cancelled.
func follow(ctx context.Context, reader io.Reader, outCh chan api.LogEntry) { func follow(ctx context.Context, reader io.Reader, outCh chan api.LogEntry) {
+38
View File
@@ -115,6 +115,9 @@ func TestEntry(t *testing.T) {
} }
func TestLogs(t *testing.T) { func TestLogs(t *testing.T) {
originalCommandContext := commandContext
t.Cleanup(func() { commandContext = originalCommandContext })
commandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd { commandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs") return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs")
} }
@@ -150,3 +153,38 @@ func TestLogs(t *testing.T) {
// Still six because heartbeats are not written here and Tail is ignored as the command is overridden. // Still six because heartbeats are not written here and Tail is ignored as the command is overridden.
assert.Equal(t, 6, i) assert.Equal(t, 6, i)
} }
func TestLogs_TimeFilters(t *testing.T) {
originalCommandContext := commandContext
t.Cleanup(func() { commandContext = originalCommandContext })
var args []string
commandContext = func(ctx context.Context, command string, commandArgs ...string) *exec.Cmd {
assert.Equal(t, "journalctl", command)
args = commandArgs
return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs")
}
ch, err := Logs(context.Background(), "uncloud", api.ServiceLogsOptions{
Since: "2026-07-01T10:30:45.123456789+10:00",
Until: "2026-07-01T01:30:45Z",
})
require.NoError(t, err)
for range ch {
}
assert.Equal(t, []string{
"-u", "uncloud", "--no-hostname", "-n", "0", "-o", "short-unix",
"-S", "2026-07-01 00:30:45.123456 UTC",
"-U", "2026-07-01 01:30:45 UTC",
}, args)
// Older clients and SDK callers can still pass journalctl's native filters.
ch, err = Logs(context.Background(), "uncloud", api.ServiceLogsOptions{Since: "1h ago", Until: "today"})
require.NoError(t, err)
for range ch {
}
assert.Equal(t, []string{
"-u", "uncloud", "--no-hostname", "-n", "0", "-o", "short-unix",
"-S", "1h ago", "-U", "today",
}, args)
}
+10 -1
View File
@@ -16,10 +16,19 @@ func NewMockCaddyfileValidator(t interface {
mock.TestingT mock.TestingT
Cleanup(func()) Cleanup(func())
}) *MockCaddyfileValidator { }) *MockCaddyfileValidator {
if helper, ok := t.(interface{ Helper() }); ok {
helper.Helper()
}
mock := &MockCaddyfileValidator{} mock := &MockCaddyfileValidator{}
mock.Mock.Test(t) mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) }) t.Cleanup(func() {
if helper, ok := t.(interface{ Helper() }); ok {
helper.Helper()
}
mock.AssertExpectations(t)
})
return mock return mock
} }
@@ -0,0 +1,79 @@
package caddystorage
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"github.com/caddyserver/certmagic"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/machine/store"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
)
// ListCertificates lists issued certificates from this machine's Caddy storage replica.
// Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored.
func (s *Server) ListCertificates(ctx context.Context, _ *emptypb.Empty) (*pb.ListCertificatesResponse, error) {
records, err := s.store.List(ctx, "certificates/", store.KeyspaceListOptions{KeysOnly: true})
if err != nil {
if ctx.Err() != nil {
return nil, status.FromContextError(ctx.Err()).Err()
}
return nil, status.Errorf(codes.Internal, "list Caddy certificates: %v", err)
}
resp := &pb.ListCertificatesResponse{}
for _, record := range records {
if err = ctx.Err(); err != nil {
return nil, status.FromContextError(err).Err()
}
if !isCertificateKey(record.Key) {
continue
}
cert, err := s.loadIssuedCertificate(ctx, record.Key)
if err != nil {
slog.Warn("Failed to load issued certificate from Caddy storage.", "key", record.Key, "err", err)
continue
}
resp.Certificates = append(resp.Certificates, cert)
}
return resp, nil
}
// isCertificateKey recognizes CertMagic's certificates/<issuer>/<name>/<name>.crt layout.
func isCertificateKey(key string) bool {
parts := strings.Split(key, "/")
return len(parts) == 4 && parts[0] == "certificates" && parts[3] == parts[2]+".crt"
}
func (s *Server) loadIssuedCertificate(ctx context.Context, key string) (*pb.IssuedCertificate, error) {
chain, err := s.store.Get(ctx, key)
if err != nil {
return nil, err
}
metaKey := strings.TrimSuffix(key, ".crt") + ".json"
meta, err := s.store.Get(ctx, metaKey)
if err != nil {
return nil, err
}
var resource certmagic.CertificateResource
if err = json.Unmarshal(meta.Value, &resource); err != nil {
return nil, err
}
if len(resource.SANs) == 0 || strings.TrimSpace(resource.SANs[0]) == "" {
return nil, fmt.Errorf("certificate metadata has no SAN")
}
return &pb.IssuedCertificate{
San: resource.SANs[0],
Chain: chain.Value,
IssuerData: resource.IssuerData,
}, nil
}
@@ -0,0 +1,233 @@
package caddystorage
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"math/big"
"net"
"slices"
"strings"
"testing"
"time"
"github.com/caddyserver/certmagic"
"github.com/psviderski/uncloud/internal/machine/store"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
)
func TestServerListCertificates(t *testing.T) {
for _, sans := range [][]string{
{"app.example.com"}, {"*.example.com"}, {"192.0.2.1"}, {"www.example.com", "example.com"},
} {
t.Run(sans[0], func(t *testing.T) {
bundle, _, _ := certificateFixture(t, &x509.Certificate{
DNSNames: []string{"example.com", "www.example.com"},
IPAddresses: []net.IP{net.ParseIP("192.0.2.1")},
NotAfter: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
})
key := certmagic.StorageKeys.SiteCert("local", sans[0])
storage := newCertificateStorage(t)
raw := json.RawMessage(`{"unknown_issuer_field":true}`)
storage.add(t, key, bundle, sans, raw)
storage.keys = append(storage.keys, "certificates", "certificates/local", "certificates/local/app.crt",
"certificates/local/app/wrong.crt", "certificates/local/app/app.crt/extra.crt",
"pki/authorities/local/root.crt", "ocsp/certificate")
resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{})
require.NoError(t, err)
require.Len(t, resp.Certificates, 1)
cert := resp.Certificates[0]
assert.Equal(t, sans[0], cert.San)
assert.Equal(t, bundle, cert.Chain, "PEM must be returned unchanged")
assert.Equal(t, []byte(raw), cert.IssuerData)
assert.Equal(t, []string{key, strings.TrimSuffix(key, ".crt") + ".json"}, storage.loads)
})
}
}
func TestServerListCertificates_PreservesIssuerData(t *testing.T) {
bundle, _, _ := certificateFixture(t, &x509.Certificate{})
for _, raw := range []string{
"", "null", `{"url":"https://ca/cert/1","ca":"https://ca/directory"}`,
`{"url":"https://ca/cert/1","ca":"https://ca/directory","renewal_info":{"_selectedTime":"invalid"}}`,
`"provider-record"`, "[1,2,3]",
} {
t.Run(raw, func(t *testing.T) {
storage := newCertificateStorage(t)
storage.add(t, "certificates/custom/app/app.crt", bundle, []string{"app"}, json.RawMessage(raw))
resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{})
require.NoError(t, err)
require.Len(t, resp.Certificates, 1)
assert.Equal(t, raw, string(resp.Certificates[0].IssuerData))
})
}
}
func TestServerListCertificates_OrderingAndDuplicates(t *testing.T) {
bundle, _, _ := certificateFixture(t, &x509.Certificate{})
storage := newCertificateStorage(t)
storage.add(t, certmagic.StorageKeys.SiteCert("z-issuer", "a.example.com"), bundle, []string{"a.example.com"}, nil)
storage.add(t, certmagic.StorageKeys.SiteCert("a-issuer", "z.example.com"), bundle, []string{"z.example.com"}, nil)
storage.add(t, certmagic.StorageKeys.SiteCert("b-issuer", "z.example.com"), bundle, []string{"z.example.com"}, nil)
resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{})
require.NoError(t, err)
require.Len(t, resp.Certificates, 3)
certs := resp.Certificates
assert.Equal(t, []string{"z.example.com", "z.example.com", "a.example.com"},
[]string{certs[0].San, certs[1].San, certs[2].San})
for _, cert := range certs {
assert.Equal(t, bundle, cert.Chain)
}
}
func TestServerListCertificates_SkipsUnreadableEntries(t *testing.T) {
bundle, _, _ := certificateFixture(t, &x509.Certificate{})
const badKey = "certificates/local/bad/bad.crt"
const metaKey = "certificates/local/bad/bad.json"
for _, tt := range []struct {
name string
change func(*certificateStorageStub)
}{
{"missing chain", func(s *certificateStorageStub) { delete(s.values, badKey) }},
{"chain read failure", func(s *certificateStorageStub) { s.loadErrors[badKey] = errors.New("offline") }},
{"missing metadata", func(s *certificateStorageStub) { delete(s.values, metaKey) }},
{"metadata read failure", func(s *certificateStorageStub) { s.loadErrors[metaKey] = errors.New("offline") }},
{"invalid JSON", func(s *certificateStorageStub) { s.values[metaKey] = []byte("{") }},
{"missing SAN", func(s *certificateStorageStub) { s.values[metaKey] = []byte(`{}`) }},
{"empty first SAN", func(s *certificateStorageStub) { s.values[metaKey] = []byte(`{"sans":["","app"]}`) }},
{"blank first SAN", func(s *certificateStorageStub) { s.values[metaKey] = []byte(`{"sans":[" "]}`) }},
} {
t.Run(tt.name, func(t *testing.T) {
storage := newCertificateStorage(t)
storage.add(t, badKey, bundle, []string{"bad"}, nil)
storage.add(t, "certificates/local/good/good.crt", bundle, []string{"good"}, nil)
tt.change(storage)
resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{})
require.NoError(t, err)
require.Len(t, resp.Certificates, 1)
assert.Equal(t, "good", resp.Certificates[0].San)
})
}
t.Run("all entries unreadable", func(t *testing.T) {
storage := newCertificateStorage(t)
storage.keys = []string{badKey}
resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{})
require.NoError(t, err)
assert.Empty(t, resp.Certificates)
})
}
func TestServerListCertificates_ListErrors(t *testing.T) {
for _, fail := range []bool{false, true} {
storage := newCertificateStorage(t)
if fail {
storage.listError = errors.New("offline")
}
resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{})
assert.Empty(t, storage.loads)
if fail {
assert.Nil(t, resp)
assert.Equal(t, codes.Internal, status.Code(err))
} else {
require.NoError(t, err)
assert.Empty(t, resp.Certificates)
}
}
}
// Only reads are implemented. Unexpected writes panic through the nil embedded Keyspace.
type certificateStorageStub struct {
*store.Keyspace
t *testing.T
keys []string
values map[string][]byte
loads []string
listCalls int
listError error
loadErrors map[string]error
onLoad func(string)
}
func newCertificateStorage(t *testing.T) *certificateStorageStub {
return &certificateStorageStub{t: t, values: make(map[string][]byte), loadErrors: make(map[string]error)}
}
func (s *certificateStorageStub) add(t *testing.T, key string, bundle []byte, sans []string, issuerData json.RawMessage) {
t.Helper()
metaKey := strings.TrimSuffix(key, ".crt") + ".json"
keyKey := strings.TrimSuffix(key, ".crt") + ".key"
meta, err := json.Marshal(certmagic.CertificateResource{SANs: sans, IssuerData: issuerData})
require.NoError(t, err)
s.keys = append(s.keys, key, metaKey, keyKey)
s.values[key], s.values[metaKey], s.values[keyKey] = bundle, meta, []byte("must not read private keys")
}
func (s *certificateStorageStub) List(ctx context.Context, prefix string, opts store.KeyspaceListOptions) ([]store.Record, error) {
s.listCalls++
assert.Equal(s.t, "certificates/", prefix)
assert.True(s.t, opts.KeysOnly, "listing must not fetch private key values")
var records []store.Record
for _, key := range slices.Sorted(slices.Values(s.keys)) {
records = append(records, store.Record{Key: key})
}
return records, s.listError
}
func (s *certificateStorageStub) Get(ctx context.Context, key string) (store.Record, error) {
require.False(s.t, strings.HasSuffix(key, ".key"), "private keys must never be loaded")
s.loads = append(s.loads, key)
if s.onLoad != nil {
s.onLoad(key)
}
if err := s.loadErrors[key]; err != nil {
return store.Record{}, err
}
value, ok := s.values[key]
if !ok {
return store.Record{}, store.ErrKeyNotFound
}
return store.Record{Key: key, Value: value}, nil
}
func certificateFixture(t *testing.T, template *x509.Certificate) ([]byte, *x509.Certificate, *x509.Certificate) {
t.Helper()
publicKey, key, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
root := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Test CA"}, IsCA: true,
BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
NotBefore: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC),
NotAfter: time.Date(2040, 1, 1, 0, 0, 0, 0, time.UTC),
}
rootDER, err := x509.CreateCertificate(rand.Reader, root, root, publicKey, key)
require.NoError(t, err)
root, err = x509.ParseCertificate(rootDER)
require.NoError(t, err)
template.SerialNumber = big.NewInt(2)
template.NotBefore = root.NotBefore
if template.NotAfter.IsZero() {
template.NotAfter = root.NotAfter
}
leafDER, err := x509.CreateCertificate(rand.Reader, template, root, publicKey, key)
require.NoError(t, err)
leaf, err := x509.ParseCertificate(leafDER)
require.NoError(t, err)
bundle := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}),
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER})...)
return bundle, leaf, root
}
+9 -2
View File
@@ -25,10 +25,17 @@ const Namespace = "caddy_storage"
// Server implements the machine-local CaddyStorage gRPC service. // Server implements the machine-local CaddyStorage gRPC service.
type Server struct { type Server struct {
pb.UnimplementedCaddyStorageServer pb.UnimplementedCaddyStorageServer
store *store.Keyspace store keyspace
} }
func NewServer(store *store.Keyspace) *Server { type keyspace interface {
Get(context.Context, string) (store.Record, error)
Put(context.Context, string, []byte) error
Delete(context.Context, string, store.KeyspaceDeleteOptions) error
List(context.Context, string, store.KeyspaceListOptions) ([]store.Record, error)
}
func NewServer(store keyspace) *Server {
return &Server{store: store} return &Server{store: store}
} }
+6
View File
@@ -14,6 +14,7 @@ import (
"github.com/psviderski/uncloud/internal/machine/network" "github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/internal/machine/store" "github.com/psviderski/uncloud/internal/machine/store"
"github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/emptypb"
@@ -87,6 +88,11 @@ func (c *Cluster) AddMachine(ctx context.Context, req *pb.AddMachineRequest) (*p
func (c *Cluster) AddMachineWithoutReadyCheck( func (c *Cluster) AddMachineWithoutReadyCheck(
ctx context.Context, req *pb.AddMachineRequest, ctx context.Context, req *pb.AddMachineRequest,
) (*pb.AddMachineResponse, error) { ) (*pb.AddMachineResponse, error) {
if req.Name != "" {
if err := api.ValidateMachineName(req.Name); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
}
if req.Network == nil { if req.Network == nil {
return nil, status.Error(codes.InvalidArgument, "network not set") return nil, status.Error(codes.InvalidArgument, "network not set")
} }
+28
View File
@@ -0,0 +1,28 @@
package cluster
import (
"context"
"testing"
"github.com/psviderski/uncloud/api/pb"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestAddMachine_InvalidName(t *testing.T) {
t.Parallel()
ready := make(chan struct{})
close(ready)
// Invalid names must be rejected before any store access.
c := NewCluster(nil, nil, nil, ready)
for _, name := range []string{"VPS1", "worker.example", "rr", "nearest", "c337f00600de51ef4375c9a9a267dba5"} {
t.Run("add", func(t *testing.T) {
resp, err := c.AddMachine(context.Background(), &pb.AddMachineRequest{Name: name})
require.Nil(t, resp)
require.Equal(t, codes.InvalidArgument, status.Code(err))
require.ErrorContains(t, err, "invalid machine name")
})
}
}
+8 -4
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api"
) )
// NewMachineID generates a new unique machine ID. // NewMachineID generates a new unique machine ID.
@@ -27,7 +28,7 @@ func NewRandomMachineName() (string, error) {
// a numeric suffix ("-1", "-2", etc.) if needed. // a numeric suffix ("-1", "-2", etc.) if needed.
func DefaultMachineName(hostname string, existing []string) (string, error) { func DefaultMachineName(hostname string, existing []string) (string, error) {
name := machineNameFromHostname(hostname) name := machineNameFromHostname(hostname)
if name == "" { if api.ValidateMachineName(name) != nil {
var err error var err error
if name, err = NewRandomMachineName(); err != nil { if name, err = NewRandomMachineName(); err != nil {
return "", err return "", err
@@ -38,7 +39,9 @@ func DefaultMachineName(hostname string, existing []string) (string, error) {
return name, nil return name, nil
} }
for i := 1; ; i++ { for i := 1; ; i++ {
candidate := fmt.Sprintf("%s-%d", name, i) suffix := fmt.Sprintf("-%d", i)
base := strings.TrimRight(name[:min(len(name), 63-len(suffix))], "-")
candidate := base + suffix
if !slices.Contains(existing, candidate) { if !slices.Contains(existing, candidate) {
return candidate, nil return candidate, nil
} }
@@ -55,12 +58,13 @@ func machineNameFromHostname(hostname string) string {
var b strings.Builder var b strings.Builder
for _, r := range label { for _, r := range label {
switch { switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_': case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
b.WriteRune(r) b.WriteRune(r)
default: default:
b.WriteRune('-') b.WriteRune('-')
} }
} }
// Trim leading and trailing hyphens that may result from the sanitisation above. // Trim leading and trailing hyphens that may result from the sanitisation above.
return strings.Trim(b.String(), "-") name := strings.Trim(b.String(), "-")
return strings.TrimRight(name[:min(len(name), 63)], "-")
} }
+52 -10
View File
@@ -1,8 +1,10 @@
package cluster package cluster
import ( import (
"strings"
"testing" "testing"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -18,9 +20,11 @@ func TestMachineNameFromHostname(t *testing.T) {
{"simple", "web", "web"}, {"simple", "web", "web"},
{"fqdn uses first label", "web-1.example.com", "web-1"}, {"fqdn uses first label", "web-1.example.com", "web-1"},
{"uppercase lowercased", "Web-Server", "web-server"}, {"uppercase lowercased", "Web-Server", "web-server"},
{"invalid chars replaced", "host_name@1", "host_name-1"}, {"invalid chars replaced", "host_name@1", "host-name-1"},
{"trim surrounding hyphens", "-_host_-", "_host_"}, {"trim surrounding hyphens", "-_host_-", "host"},
{"whitespace trimmed", " myhost ", "myhost"}, {"whitespace trimmed", " myhost ", "myhost"},
{"long hostname truncated", strings.Repeat("a", 64), strings.Repeat("a", 63)},
{"truncation trims trailing hyphen", strings.Repeat("a", 62) + "-b", strings.Repeat("a", 62)},
{"empty", "", ""}, {"empty", "", ""},
{"only invalid chars", "@#", ""}, {"only invalid chars", "@#", ""},
{"dot only", ".example.com", ""}, {"dot only", ".example.com", ""},
@@ -37,10 +41,12 @@ func TestDefaultMachineName(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("falls back to random", func(t *testing.T) { t.Run("falls back to random", func(t *testing.T) {
// An empty or fully invalid hostname falls back to a random "machine-xxxx" name. for _, hostname := range []string{"***", "rr", "nearest", strings.Repeat("a", 32)} {
got, err := DefaultMachineName("***", nil) got, err := DefaultMachineName(hostname, nil)
require.NoError(t, err) require.NoError(t, err)
assert.Regexp(t, `^machine-[a-zA-Z0-9]{4}$`, got) assert.Regexp(t, `^machine-[a-z0-9]{4}$`, got)
require.NoError(t, api.ValidateMachineName(got))
}
}) })
tests := []struct { tests := []struct {
@@ -49,10 +55,45 @@ func TestDefaultMachineName(t *testing.T) {
existing []string existing []string
want string want string
}{ }{
{"from hostname", "web-1.example.com", nil, "web-1"}, {
{"dedup against existing", "web", []string{"web"}, "web-1"}, name: "from hostname",
{"dedup multiple", "web", []string{"web", "web-1"}, "web-2"}, hostname: "web-1.example.com",
{"sanitized", "My_Host", nil, "my_host"}, want: "web-1",
},
{
name: "dedup against existing",
hostname: "web",
existing: []string{"web"},
want: "web-1",
},
{
name: "dedup multiple",
hostname: "web",
existing: []string{"web", "web-1"},
want: "web-2",
},
{
name: "sanitized",
hostname: "My_Host",
want: "my-host",
},
{
name: "long hostname",
hostname: strings.Repeat("a", 64),
want: strings.Repeat("a", 63),
},
{
name: "dedup maximum length",
hostname: strings.Repeat("a", 63),
existing: []string{strings.Repeat("a", 63)},
want: strings.Repeat("a", 61) + "-1",
},
{
name: "dedup trims trailing hyphen",
hostname: strings.Repeat("a", 60) + "-bb",
existing: []string{strings.Repeat("a", 60) + "-bb"},
want: strings.Repeat("a", 60) + "-1",
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -60,6 +101,7 @@ func TestDefaultMachineName(t *testing.T) {
got, err := DefaultMachineName(tt.hostname, tt.existing) got, err := DefaultMachineName(tt.hostname, tt.existing)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, tt.want, got) assert.Equal(t, tt.want, got)
require.NoError(t, api.ValidateMachineName(got))
}) })
} }
} }
+65 -11
View File
@@ -10,6 +10,8 @@ import (
"sync" "sync"
"time" "time"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/internal/machine/store" "github.com/psviderski/uncloud/internal/machine/store"
) )
@@ -19,7 +21,9 @@ type ClusterResolver struct {
store *store.Store store *store.Store
// serviceIPs maps service names to container IPs. // serviceIPs maps service names to container IPs.
serviceIPs map[string][]netip.Addr serviceIPs map[string][]netip.Addr
// mu protects the serviceIPs map. // machineIPs maps machine IDs and names to their IP.
machineIPs map[string]netip.Addr
// mu protects the serviceIPs and machineIPs map.
mu sync.RWMutex mu sync.RWMutex
// lastUpdate tracks when records were last updated. // lastUpdate tracks when records were last updated.
lastUpdate time.Time lastUpdate time.Time
@@ -46,6 +50,13 @@ func (r *ClusterResolver) Run(ctx context.Context) error {
// TODO: implement machine membership check using Corrossion Admin client to filter available containers. // TODO: implement machine membership check using Corrossion Admin client to filter available containers.
r.updateServiceIPs(containers) r.updateServiceIPs(containers)
machines, mchanges, err := r.store.SubscribeMachines(ctx)
if err != nil {
return fmt.Errorf("subscribe to machine changes: %w", err)
}
r.log.Info("Subscribed to machine changes in the clsuter to keep machine DNS records updated.")
r.updateMachineIPs(machines)
for { for {
select { select {
case _, ok := <-changes: case _, ok := <-changes:
@@ -59,9 +70,22 @@ func (r *ClusterResolver) Run(ctx context.Context) error {
r.log.Error("Failed to list containers.", "err", err) r.log.Error("Failed to list containers.", "err", err)
continue continue
} }
// TODO: implement machine membership check using Corrossion Admin client to filter available containers. // TODO: implement machine membership check using Corrossion Admin client to filter available containers.
r.updateServiceIPs(containers) r.updateServiceIPs(containers)
case _, ok := <-mchanges:
if !ok {
return fmt.Errorf("machine subscription failed")
}
r.log.Debug("Cluster machines changed, updating DNS records.")
machines, err := r.store.ListMachines(ctx)
if err != nil {
r.log.Error("Failed to list machines.", "err", err)
continue
}
r.updateMachineIPs(machines)
case <-ctx.Done(): case <-ctx.Done():
return nil return nil
} }
@@ -121,19 +145,49 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
r.log.Info("DNS records updated.", "services", len(newServiceIPs)/3, "containers", containersCount) r.log.Info("DNS records updated.", "services", len(newServiceIPs)/3, "containers", containersCount)
} }
// Resolve returns IP addresses of the service containers. func (r *ClusterResolver) updateMachineIPs(machines []*pb.MachineInfo) {
func (r *ClusterResolver) Resolve(serviceName string) []netip.Addr { newMachineIPs := make(map[string]netip.Addr, len(machines))
for _, machine := range machines {
subnet, err := machine.Network.Subnet.ToPrefix()
if err != nil {
continue
}
addr := network.MachineIP(subnet)
newMachineIPs[machine.Name+".m"] = addr
newMachineIPs[machine.Id+".m"] = addr
}
r.mu.Lock()
r.machineIPs = newMachineIPs
r.mu.Unlock()
r.log.Info("DNS records updated.", "machines", len(machines))
}
// Resolve returns IP addresses of the service containers or machines.
func (r *ClusterResolver) Resolve(name string) []netip.Addr {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
ips, ok := r.serviceIPs[serviceName] // Return a copy of the IPs slice to prevent modification of the original.
if !ok || len(ips) == 0 { ips, ok := r.serviceIPs[name]
return nil if ok && len(ips) > 0 {
return slices.Clone(ips)
} }
// Return a copy of the IPs slice to prevent modification of the original. ip, ok := r.machineIPs[name]
ipsCopy := make([]netip.Addr, len(ips)) if ok {
copy(ipsCopy, ips) return slices.Clone([]netip.Addr{ip})
}
return ipsCopy if name == "m" { // all machines subdomain
// collect all unique IP address
uniq := map[string]netip.Addr{}
for _, addr := range r.machineIPs {
uniq[addr.String()] = addr
}
return slices.Clone(slices.Collect(maps.Values(uniq)))
}
return nil
} }
+31
View File
@@ -1,11 +1,13 @@
package dns package dns
import ( import (
"net/netip"
"reflect" "reflect"
"testing" "testing"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/machine/store" "github.com/psviderski/uncloud/internal/machine/store"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -75,3 +77,32 @@ func newRecord(serviceID, serviceName, ip, machineID string) store.ContainerReco
MachineID: machineID, MachineID: machineID,
} }
} }
func TestClusterResolver_UpdateMachineIPs(t *testing.T) {
t.Parallel()
machines := []*pb.MachineInfo{
newMachineRecord("x0y0z0", "mach-1", "10.210.0.0/24"),
newMachineRecord("x1y1z1", "mach-2", "10.210.1.0/24"),
newMachineRecord("x2y2z2", "mach-3", "10.210.2.0/24"),
}
r := NewClusterResolver(nil)
r.updateMachineIPs(machines)
assert.NotEmpty(t, r.Resolve("mach-1.m"))
assert.NotEmpty(t, r.Resolve("mach-3.m"))
assert.NotEmpty(t, r.Resolve("x0y0z0.m"))
assert.Equal(t, 3, len(r.Resolve("m")))
}
func newMachineRecord(machineID, machineName, prefix string) *pb.MachineInfo {
return &pb.MachineInfo{
Id: machineID,
Name: machineName,
Network: &pb.NetworkConfig{
Subnet: pb.NewIPPrefix(netip.MustParsePrefix(prefix)),
},
}
}
+4 -4
View File
@@ -293,13 +293,13 @@ func (s *Server) forwardRequest(req *dns.Msg, proto string) (*dns.Msg, error) {
// handleAQuery processes an A query for the internal domain and returns A records for the requested name. // handleAQuery processes an A query for the internal domain and returns A records for the requested name.
// The internal domain suffix is already stripped from the name. An empty list is returned if no records are found. // The internal domain suffix is already stripped from the name. An empty list is returned if no records are found.
func (s *Server) handleAQuery(name string) []dns.RR { func (s *Server) handleAQuery(name string) []dns.RR {
serviceName, mode := extractModeFromDomain(trimInternalDomain(name)) unname, mode := extractModeFromDomain(trimInternalDomain(name))
ips := s.resolver.Resolve(serviceName) ips := s.resolver.Resolve(unname)
if len(ips) == 0 { if len(ips) == 0 {
s.log.Debug("Failed to resolve service name.", "service", serviceName) s.log.Debug("Failed to resolve internal name.", "name", name)
return nil return nil
} }
s.log.Debug("Resolved service name.", "service", serviceName, "ips", ips) s.log.Debug("Resolved service internal name.", "name", name, "ips", ips)
if len(ips) > 1 { if len(ips) > 1 {
// Shuffle the IPs to approximate round-robin. // Shuffle the IPs to approximate round-robin.
+6 -6
View File
@@ -167,7 +167,7 @@ func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions)
machineContainers := make([]MachineContainers, len(resp.Messages)) machineContainers := make([]MachineContainers, len(resp.Messages))
for i, msg := range resp.Messages { for i, msg := range resp.Messages {
machineContainers[i].Metadata = msg.Metadata machineContainers[i].Metadata = msg.Metadata
if msg.Metadata != nil && msg.Metadata.Error != "" { if msg.Metadata.Err() != nil {
continue continue
} }
@@ -258,7 +258,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
notFoundCount := 0 notFoundCount := 0
for _, msg := range resp.Messages { for _, msg := range resp.Messages {
if msg.Metadata != nil && msg.Metadata.Status != nil && codes.Code(msg.Metadata.Status.Code) == codes.NotFound { if status.Code(msg.Metadata.Err()) == codes.NotFound {
notFoundCount++ notFoundCount++
} }
} }
@@ -269,7 +269,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
images := make([]api.MachineImage, len(resp.Messages)) images := make([]api.MachineImage, len(resp.Messages))
for i, msg := range resp.Messages { for i, msg := range resp.Messages {
images[i].Metadata = msg.Metadata images[i].Metadata = msg.Metadata
if msg.Metadata != nil && msg.Metadata.Error != "" { if msg.Metadata.Err() != nil {
continue continue
} }
@@ -312,7 +312,7 @@ func parseRemoteImageMessage(msg *pb.RemoteImage) (api.MachineRemoteImage, error
mri := api.MachineRemoteImage{ mri := api.MachineRemoteImage{
Metadata: msg.Metadata, Metadata: msg.Metadata,
} }
if msg.Metadata != nil && msg.Metadata.Error != "" { if msg.Metadata.Err() != nil {
return mri, nil return mri, nil
} }
@@ -393,7 +393,7 @@ func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]Ma
machineVolumes := make([]MachineVolumes, len(resp.Messages)) machineVolumes := make([]MachineVolumes, len(resp.Messages))
for i, msg := range resp.Messages { for i, msg := range resp.Messages {
machineVolumes[i].Metadata = msg.Metadata machineVolumes[i].Metadata = msg.Metadata
if msg.Metadata != nil && msg.Metadata.Error != "" { if msg.Metadata.Err() != nil {
continue continue
} }
@@ -472,7 +472,7 @@ func (c *Client) ListServiceContainers(
machineContainers := make([]MachineServiceContainers, len(resp.Messages)) machineContainers := make([]MachineServiceContainers, len(resp.Messages))
for i, msg := range resp.Messages { for i, msg := range resp.Messages {
machineContainers[i].Metadata = msg.Metadata machineContainers[i].Metadata = msg.Metadata
if msg.Metadata != nil && msg.Metadata.Error != "" { if msg.Metadata.Err() != nil {
continue continue
} }
+6 -1
View File
@@ -27,7 +27,7 @@ const (
type Controller struct { type Controller struct {
// machineID is the ID of the machine where the managed Docker daemon is running. // machineID is the ID of the machine where the managed Docker daemon is running.
machineID string machineID string
client *client.Client client client.APIClient
service *Service service *Service
store *store.Store store *store.Store
} }
@@ -129,6 +129,11 @@ func (c *Controller) WatchAndSyncContainers(ctx context.Context) error {
"container_name", e.Actor.Attributes["name"], "container_name", e.Actor.Attributes["name"],
"action", e.Action) "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 { if err := c.syncContainersToStore(ctx); err != nil {
return fmt.Errorf("sync containers to cluster store: %w", err) return fmt.Errorf("sync containers to cluster store: %w", err)
} }
+79
View File
@@ -0,0 +1,79 @@
package docker
import (
"context"
"fmt"
"strings"
"github.com/containerd/errdefs"
"github.com/distribution/reference"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/pkg/api"
)
// RemoveImage removes an image while protecting names used by containers, even when force is requested.
func (s *Service) RemoveImage(ctx context.Context, id string, opts image.RemoveOptions) ([]image.DeleteResponse, error) {
if err := s.checkImageRemovalConflicts(ctx, id); err != nil {
return nil, err
}
// Docker enforces its own conflicts, including protection of a used image's final reference.
return s.Client.ImageRemove(ctx, id, opts)
}
// checkImageRemovalConflicts rejects an image ID used by a running or stopped container, or a name used by a
// container while it still resolves to that container's image. A digest request also checks the repository tags
// Docker would remove with it. Unused aliases are allowed.
func (s *Service) checkImageRemovalConflicts(ctx context.Context, id string) error {
img, err := s.Client.ImageInspect(ctx, id)
if err != nil {
return err
}
// Match Docker's resolved ID before parsing names, since a short ID also parses as a repository name.
isID := strings.HasPrefix(strings.TrimPrefix(img.ID, "sha256:"), strings.TrimPrefix(id, "sha256:"))
var named reference.Named
if !isID {
named, err = reference.ParseDockerRef(id)
if err != nil {
return errdefs.ErrInvalidArgument.WithMessage(err.Error())
}
}
_, isDigest := named.(reference.Canonical)
containers, err := s.Client.ContainerList(ctx, container.ListOptions{
All: true, Filters: filters.NewArgs(filters.Arg("ancestor", img.ID)),
})
if err != nil {
return fmt.Errorf("list containers using image: %w", err)
}
for _, ctr := range containers {
// The ancestor filter can include descendants. A retagged name must also be checked against its current ID.
if ctr.ImageID != img.ID {
continue
}
usedRef, err := reference.ParseDockerRef(ctr.Image)
// Familiar names make "alpine:latest" and "docker.io/library/alpine:latest" identify the same tag.
removesUsedName := err == nil && named != nil && reference.FamiliarString(usedRef) == reference.FamiliarString(named)
if isDigest && err == nil && usedRef.Name() == named.Name() {
// Removing a digest also removes this repository's tags pointing at the same image.
removesUsedName = true
}
if isID || removesUsedName {
ctrID, state := stringid.TruncateID(ctr.ID), "stopped"
if serviceName := ctr.Labels[api.LabelServiceName]; serviceName != "" {
ctrID = serviceName + "/" + ctrID
}
if ctr.State == container.StateRunning || ctr.State == container.StatePaused {
state = container.StateRunning
}
return errdefs.ErrConflict.WithMessage(
fmt.Sprintf("image '%s' is used by container '%s' (%s)", id, ctrID, state))
}
}
return nil
}
+175
View File
@@ -0,0 +1,175 @@
package docker
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestCheckImageRemovalConflicts(t *testing.T) {
imageID := "sha256:" + strings.Repeat("a", 64)
otherID := "sha256:" + strings.Repeat("b", 64)
digestRef := "myapp@" + imageID
tests := []struct {
name string
requested string
configured string
containerImageID string
serviceName string
blocked bool
}{
{name: "used tag", requested: "myapp:current", configured: "myapp:current", serviceName: "web", blocked: true},
{name: "qualified request", requested: "docker.io/library/myapp:current", configured: "myapp:current", blocked: true},
{name: "qualified container reference", requested: "myapp:current", configured: "docker.io/library/myapp:current", blocked: true},
{name: "implicit latest", requested: "myapp", configured: "myapp:latest", blocked: true},
{name: "unused alias", requested: "myapp:old", configured: "myapp:current"},
{name: "unused alias of ID-used image", requested: "myapp:old", configured: imageID},
{name: "tag may clean up a used digest", requested: "myapp:old", configured: digestRef},
{name: "used digest", requested: digestRef, configured: digestRef, blocked: true},
{name: "digest also removes used tag", requested: digestRef, configured: "myapp:current", blocked: true},
{name: "unused repository digest", requested: digestRef, configured: "other:keep"},
{name: "image ID", requested: imageID, configured: "myapp:current", blocked: true},
{name: "short image ID", requested: "aaaaaaaa", configured: "myapp:current", blocked: true},
{name: "short image ID with algorithm", requested: "sha256:aaaaaaaa", configured: "myapp:current", blocked: true},
{name: "retagged name", requested: "myapp:current", configured: "myapp:current", containerImageID: otherID},
}
for _, tt := range tests {
for _, state := range []string{container.StateExited, container.StateRunning} {
t.Run(fmt.Sprintf("%s/%s", tt.name, state), func(t *testing.T) {
containerImageID := tt.containerImageID
if containerImageID == "" {
containerImageID = imageID
}
cli := NewMockAPIClient(t)
cli.EXPECT().ImageInspect(mock.Anything, tt.requested).
Return(image.InspectResponse{ID: imageID}, nil).Once()
cli.EXPECT().ContainerList(mock.Anything, mock.MatchedBy(func(opts container.ListOptions) bool {
return opts.All // Stopped containers must be checked too.
})).Return([]container.Summary{{
ID: strings.Repeat("c", 64),
Labels: map[string]string{api.LabelServiceName: tt.serviceName},
Names: []string{"/web"},
ImageID: containerImageID,
Image: tt.configured,
State: state,
}}, nil).Once()
service := NewService(cli, nil)
err := service.checkImageRemovalConflicts(context.Background(), tt.requested)
if tt.blocked {
require.True(t, errdefs.IsConflict(err), "%v", err)
assert.ErrorContains(t, err, tt.requested)
if tt.serviceName != "" {
assert.ErrorContains(t, err, "container 'web/cccccccccccc'")
} else {
assert.ErrorContains(t, err, "container 'cccccccccccc'")
}
wantState := "stopped"
if state == container.StateRunning {
wantState = state
}
assert.ErrorContains(t, err, wantState)
} else {
require.NoError(t, err)
}
})
}
}
}
func TestCheckImageRemovalConflicts_PropagatesLookupErrors(t *testing.T) {
imageID := "sha256:" + strings.Repeat("a", 64)
t.Run("image lookup fails", func(t *testing.T) {
cli := NewMockAPIClient(t)
lookupErr := errdefs.ErrNotFound.WithMessage("no such image")
cli.EXPECT().ImageInspect(mock.Anything, "myapp:old").Return(image.InspectResponse{}, lookupErr).Once()
service := NewService(cli, nil)
require.ErrorIs(t, service.checkImageRemovalConflicts(context.Background(), "myapp:old"), lookupErr)
})
t.Run("container lookup fails", func(t *testing.T) {
cli := NewMockAPIClient(t)
lookupErr := errors.New("Docker unavailable")
cli.EXPECT().ImageInspect(mock.Anything, "myapp:old").Return(image.InspectResponse{ID: imageID}, nil).Once()
cli.EXPECT().ContainerList(mock.Anything, mock.Anything).Return(nil, lookupErr).Once()
service := NewService(cli, nil)
err := service.checkImageRemovalConflicts(context.Background(), "myapp:old")
require.ErrorIs(t, err, lookupErr)
assert.ErrorContains(t, err, "list containers using image")
})
}
func TestServiceRemoveImage_RejectsUsedNameBeforeDeletion(t *testing.T) {
imageID := "sha256:" + strings.Repeat("a", 64)
cli := NewMockAPIClient(t)
cli.EXPECT().ImageInspect(mock.Anything, "myapp:current").Return(image.InspectResponse{ID: imageID}, nil).Once()
cli.EXPECT().ContainerList(mock.Anything, mock.Anything).Return([]container.Summary{{
ID: "container-id", ImageID: imageID, Image: "myapp:current", State: container.StateRunning,
}}, nil).Once()
service := NewService(cli, nil)
// ImageRemove has no expectation: any deletion call would fail this test.
results, err := service.RemoveImage(context.Background(), "myapp:current", image.RemoveOptions{Force: true})
require.True(t, errdefs.IsConflict(err), "%v", err)
assert.Nil(t, results)
}
func TestServiceRemoveImage_DelegatesToDocker(t *testing.T) {
imageID := "sha256:" + strings.Repeat("a", 64)
tests := []struct {
name string
opts image.RemoveOptions
results []image.DeleteResponse
err error
}{
{
name: "options and results pass through",
opts: image.RemoveOptions{Force: true, PruneChildren: true, Platforms: []ocispec.Platform{{OS: "linux", Architecture: "amd64"}}},
results: []image.DeleteResponse{{Deleted: imageID}},
},
{
name: "Docker rejects final reference removal",
opts: image.RemoveOptions{PruneChildren: true},
err: errdefs.ErrConflict.WithMessage("container is using its referenced image"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cli := NewMockAPIClient(t)
cli.EXPECT().ImageInspect(mock.Anything, "myapp:old").Return(image.InspectResponse{ID: imageID}, nil).Once()
cli.EXPECT().ContainerList(mock.Anything, mock.Anything).Return([]container.Summary{{
ID: "container-id", ImageID: imageID, Image: "myapp@" + imageID, State: container.StateRunning,
}}, nil).Once()
cli.EXPECT().ImageRemove(mock.Anything, "myapp:old", tt.opts).Return(tt.results, tt.err).Once()
service := NewService(cli, nil)
results, err := service.RemoveImage(context.Background(), "myapp:old", tt.opts)
if tt.err != nil {
require.ErrorIs(t, err, tt.err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.results, results)
})
}
}
File diff suppressed because it is too large Load Diff
+84 -16
View File
@@ -56,7 +56,7 @@ var fullDockerIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
// Server implements the gRPC Docker service that proxies requests to the Docker daemon. // Server implements the gRPC Docker service that proxies requests to the Docker daemon.
type Server struct { type Server struct {
pb.UnimplementedDockerServer pb.UnimplementedDockerServer
client *client.Client client client.APIClient
service *Service service *Service
db *sqlx.DB db *sqlx.DB
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty // internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
@@ -349,6 +349,41 @@ func (s *Server) InspectImage(ctx context.Context, req *pb.InspectImageRequest)
}, nil }, nil
} }
// RemoveImage removes an image while protecting names used by containers, even when force is requested.
func (s *Server) RemoveImage(ctx context.Context, req *pb.RemoveImageRequest) (*pb.RemoveImageResponse, error) {
opts := image.RemoveOptions{Force: req.Force, PruneChildren: req.PruneChildren}
for _, platform := range req.Platforms {
opts.Platforms = append(opts.Platforms, ocispec.Platform{
Architecture: platform.Architecture,
OS: platform.Os,
OSVersion: platform.OsVersion,
OSFeatures: platform.OsFeatures,
Variant: platform.Variant,
})
}
removals, err := s.service.RemoveImage(ctx, req.Id, opts)
if err != nil {
switch {
case errdefs.IsNotFound(err):
return nil, status.Error(codes.NotFound, err.Error())
case errdefs.IsConflict(err):
return nil, status.Error(codes.FailedPrecondition, err.Error())
case errdefs.IsInvalidArgument(err):
return nil, status.Error(codes.InvalidArgument, err.Error())
default:
return nil, status.Error(codes.Internal, err.Error())
}
}
result := &pb.MachineImageRemoval{}
for _, removal := range removals {
result.Results = append(result.Results, &pb.ImageRemoval{Deleted: removal.Deleted, Untagged: removal.Untagged})
}
return &pb.RemoveImageResponse{Messages: []*pb.MachineImageRemoval{result}}, nil
}
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's Docker auth // InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's Docker auth
// credentials if necessary. // credentials if necessary.
func (s *Server) InspectRemoteImage( func (s *Server) InspectRemoteImage(
@@ -559,21 +594,26 @@ func (s *Server) CreateServiceContainer(
} }
} }
// Apply user-defined labels first so that the system labels always take precedence.
labels := maps.Clone(spec.Container.Labels)
if labels == nil {
labels = make(map[string]string)
}
labels[api.LabelServiceID] = req.ServiceId
labels[api.LabelServiceName] = spec.Name
labels[api.LabelServiceMode] = spec.Mode
labels[api.LabelManaged] = ""
config := &container.Config{ config := &container.Config{
Cmd: spec.Container.Command, Cmd: spec.Container.Command,
Env: envVars.ToSlice(), Env: envVars.ToSlice(),
Entrypoint: spec.Container.Entrypoint, Entrypoint: spec.Container.Entrypoint,
Hostname: containerName, Hostname: containerName,
Image: spec.Container.Image, Image: spec.Container.Image,
Labels: map[string]string{ Labels: labels,
api.LabelServiceID: req.ServiceId, User: spec.Container.User,
api.LabelServiceName: spec.Name, Tty: spec.Container.Tty,
api.LabelServiceMode: spec.Mode, OpenStdin: spec.Container.OpenStdin,
api.LabelManaged: "",
},
User: spec.Container.User,
Tty: spec.Container.Tty,
OpenStdin: spec.Container.OpenStdin,
} }
if spec.Mode == "" { if spec.Mode == "" {
config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated
@@ -694,12 +734,10 @@ func (s *Server) CreateServiceContainer(
// Strip the container configuration that doesn't make sense for the pre-deploy hook and apply its overrides. // 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 { if spec.PreDeploy != nil && req.ContainerType == pb.CreateServiceContainerRequest_PRE_DEPLOY {
config.Labels = map[string]string{ // Keep the user-defined labels but replace the service mode and ports labels with the hook label.
api.LabelServiceID: req.ServiceId, delete(config.Labels, api.LabelServiceMode)
api.LabelServiceName: spec.Name, delete(config.Labels, api.LabelServicePorts)
api.LabelHook: api.LabelHookPreDeploy, config.Labels[api.LabelHook] = api.LabelHookPreDeploy
api.LabelManaged: "",
}
config.Healthcheck = &container.HealthConfig{ config.Healthcheck = &container.HealthConfig{
Test: []string{"NONE"}, Test: []string{"NONE"},
} }
@@ -1144,6 +1182,36 @@ func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveConta
return resp, nil 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. // logsHeartbeatInterval is the interval at which heartbeat entries are sent when there are no logs to stream.
const logsHeartbeatInterval = 200 * time.Millisecond const logsHeartbeatInterval = 200 * time.Millisecond
+107 -2
View File
@@ -15,6 +15,7 @@ import (
"time" "time"
"github.com/cenkalti/backoff/v4" "github.com/cenkalti/backoff/v4"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/events" "github.com/docker/docker/api/types/events"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
@@ -31,19 +32,39 @@ import (
// from the machine database. // from the machine database.
type Service struct { type Service struct {
// Client is a Docker client for managing Docker resources. // Client is a Docker client for managing Docker resources.
Client *client.Client Client client.APIClient
// db is a connection to the machine database. // db is a connection to the machine database.
db *sqlx.DB db *sqlx.DB
// 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. // NewService creates a new Docker service instance.
func NewService(client *client.Client, db *sqlx.DB) *Service { func NewService(client client.APIClient, db *sqlx.DB) *Service {
return &Service{ return &Service{
Client: client, Client: client,
db: db, db: db,
// 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 // 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. // becomes reachable again after a disconnect, which typically indicates a restart.
func (s *Service) WatchDaemonRestart(ctx context.Context) <-chan struct{} { func (s *Service) WatchDaemonRestart(ctx context.Context) <-chan struct{} {
@@ -141,6 +162,90 @@ func (s *Service) InspectServiceContainer(ctx context.Context, nameOrID string)
return serviceCtr, nil 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 // ListServiceContainersResult holds the result of listing service containers, split into regular
// service containers and one-off hook containers. // service containers and one-off hook containers.
type ListServiceContainersResult struct { type ListServiceContainersResult struct {
+8 -3
View File
@@ -262,11 +262,11 @@ func NewMachine(config *Config) (*Machine, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("create corrosion API client: %w", err) return nil, fmt.Errorf("create corrosion API client: %w", err)
} }
corroStore := store.New(corro)
corroAdmin, err := corrosion.NewAdminClient(config.CorrosionAdminSockPath) corroAdmin, err := corrosion.NewAdminClient(config.CorrosionAdminSockPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("create corrosion admin client: %w", err) return nil, fmt.Errorf("create corrosion admin client: %w", err)
} }
corroStore := store.New(corro, corroAdmin)
initialised := make(chan struct{}) initialised := make(chan struct{})
clusterReady := make(chan struct{}) clusterReady := make(chan struct{})
@@ -836,6 +836,11 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
if m.Initialised() { if m.Initialised() {
return nil, status.Error(codes.FailedPrecondition, "machine is already configured as a cluster member") return nil, status.Error(codes.FailedPrecondition, "machine is already configured as a cluster member")
} }
if req.MachineName != "" {
if err := api.ValidateMachineName(req.MachineName); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
}
clusterNetwork, err := req.Network.ToPrefix() clusterNetwork, err := req.Network.ToPrefix()
if err != nil { if err != nil {
@@ -1220,8 +1225,8 @@ func (m *Machine) applyMachineUpdate(ctx context.Context, req *pb.UpdateMachineR
defer m.state.mu.Unlock() defer m.state.mu.Unlock()
if req.Name != nil { if req.Name != nil {
if *req.Name == "" { if err := api.ValidateMachineName(*req.Name); err != nil {
return status.Error(codes.InvalidArgument, "machine name cannot be empty") return status.Error(codes.InvalidArgument, err.Error())
} }
// Check for duplicate names across the cluster, excluding this machine. // Check for duplicate names across the cluster, excluding this machine.
if *req.Name != m.state.Name { if *req.Name != m.state.Name {
+38
View File
@@ -0,0 +1,38 @@
package machine
import (
"context"
"testing"
"github.com/psviderski/uncloud/api/pb"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestInitCluster_InvalidMachineName(t *testing.T) {
t.Parallel()
for _, name := range []string{"VPS1", "rr", "nearest", "c337f00600de51ef4375c9a9a267dba5"} {
// Invalid names must be rejected before initializing cluster state.
m := &Machine{state: &State{}}
resp, err := m.InitCluster(context.Background(), &pb.InitClusterRequest{MachineName: name})
require.Nil(t, resp)
require.Equal(t, codes.InvalidArgument, status.Code(err))
require.ErrorContains(t, err, "invalid machine name")
}
}
func TestUpdateMachine_InvalidName(t *testing.T) {
t.Parallel()
for _, name := range []string{"", "VPS1", "worker.example", "rr", "nearest", "c337f00600de51ef4375c9a9a267dba5"} {
m := &Machine{state: &State{ID: "machine-id", Name: "worker"}}
resp, err := m.UpdateMachine(context.Background(), &pb.UpdateMachineRequest{Name: &name})
require.Nil(t, resp)
require.Equal(t, codes.InvalidArgument, status.Code(err))
require.ErrorContains(t, err, "invalid machine name")
require.Equal(t, "worker", m.state.Name)
}
}
+2 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/psviderski/uncloud/internal/machine/network" "github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api"
) )
const ( const (
@@ -31,7 +32,7 @@ type State struct {
// MinStoreVersion is the cluster store version this machine must reach before participating. // MinStoreVersion is the cluster store version this machine must reach before participating.
// Per-actor vector (Corrosion actor UUID → max processed db_version) captured from an existing // Per-actor vector (Corrosion actor UUID → max processed db_version) captured from an existing
// member at join time. Cleared once reached. // member at join time. Cleared once reached.
MinStoreVersion map[string]uint64 `json:",omitempty"` MinStoreVersion api.StoreVersion `json:",omitempty"`
// CorrosionAPIToken authenticates requests to the local Corrosion API. // CorrosionAPIToken authenticates requests to the local Corrosion API.
CorrosionAPIToken secret.Secret `json:",omitempty"` CorrosionAPIToken secret.Secret `json:",omitempty"`
+29 -2
View File
@@ -23,6 +23,10 @@ const (
// SyncStatusOutdated indicates that a container record may be outdated, for example, due to being unable // SyncStatusOutdated indicates that a container record may be outdated, for example, due to being unable
// to retrieve the container's state from the Docker daemon or when the machine is being stopped or restarted. // to retrieve the container's state from the Docker daemon or when the machine is being stopped or restarted.
SyncStatusOutdated = "outdated" SyncStatusOutdated = "outdated"
// containerChangesDebounceInterval defines how long to wait before notifying subscribers about container changes.
// Multiple changes within this window are grouped into a single notification to prevent system overload.
containerChangesDebounceInterval = 250 * time.Millisecond
) )
type ContainerRecord struct { type ContainerRecord struct {
@@ -92,6 +96,9 @@ func normaliseContainerForStore(ctr *api.ServiceContainer) {
// Remove the environment variables to avoid leaking secrets. // Remove the environment variables to avoid leaking secrets.
ctr.Config.Env = nil ctr.Config.Env = nil
ctr.ServiceSpec.Container.Env = nil ctr.ServiceSpec.Container.Env = nil
if ctr.ServiceSpec.PreDeploy != nil {
ctr.ServiceSpec.PreDeploy.Env = nil
}
// Docker returns Mounts in a non-deterministic order so sort them. // Docker returns Mounts in a non-deterministic order so sort them.
slices.SortFunc(ctr.Mounts, func(a, b container.MountPoint) int { slices.SortFunc(ctr.Mounts, func(a, b container.MountPoint) int {
@@ -267,6 +274,13 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-c
changes := make(chan struct{}) changes := make(chan struct{})
go func() { go func() {
defer close(changes) defer close(changes)
// Coalesce bursts of rapid-fire row-level change events (e.g. from frequent health-check updates
// across many services) into a single signal per debounce window, instead of forwarding one signal
// per event. Every subscriber of this channel (e.g. the Caddy and DNS reconcilers) otherwise reruns
// its full reconciliation on every single event, which can burn significant CPU across the cluster
// when there's a lot of container churn.
var debouncer *time.Timer
var debouncerCh <-chan time.Time
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -279,8 +293,21 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-c
} }
return return
} }
// Just signal that there is a change in the containers list. if debouncerCh == nil {
changes <- struct{}{} if debouncer == nil {
debouncer = time.NewTimer(containerChangesDebounceInterval)
} else {
debouncer.Reset(containerChangesDebounceInterval)
}
debouncerCh = debouncer.C
}
case <-debouncerCh:
select {
case changes <- struct{}{}:
case <-ctx.Done():
return
}
debouncerCh = nil
} }
} }
}() }()
+67
View File
@@ -0,0 +1,67 @@
package store
import (
"testing"
"github.com/docker/docker/api/types/container"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestNormaliseContainerForStore_StripsEnv pins the behaviour that all fields carrying environment variables
// are cleared before a container record is written to the replicated store, regardless of which part of the
// spec they live in. See https://github.com/psviderski/uncloud/issues/422.
func TestNormaliseContainerForStore_StripsEnv(t *testing.T) {
t.Parallel()
newContainer := func(preDeploy *api.PreDeployHook) api.ServiceContainer {
return api.ServiceContainer{
Container: api.Container{
InspectResponse: container.InspectResponse{
Config: &container.Config{
Env: []string{"CONTROL_TOKEN=control-value-aaa"},
},
},
},
ServiceSpec: api.ServiceSpec{
Container: api.ContainerSpec{
Env: api.EnvVars{"CONTROL_TOKEN": "control-value-aaa"},
},
PreDeploy: preDeploy,
},
}
}
t.Run("service with pre-deploy hook", func(t *testing.T) {
t.Parallel()
ctr := newContainer(&api.PreDeployHook{
Command: []string{"/bin/true"},
Env: api.EnvVars{"HOOK_TOKEN": "hook-value-bbb"},
})
normaliseContainerForStore(&ctr)
assert.Nil(t, ctr.Config.Env, "Config.Env should be stripped")
assert.Nil(t, ctr.ServiceSpec.Container.Env, "ServiceSpec.Container.Env should be stripped")
require.NotNil(t, ctr.ServiceSpec.PreDeploy, "PreDeploy itself should be left in place")
assert.Nil(t, ctr.ServiceSpec.PreDeploy.Env, "ServiceSpec.PreDeploy.Env should be stripped")
// Non-env fields of the hook must survive the normalisation.
assert.Equal(t, []string{"/bin/true"}, ctr.ServiceSpec.PreDeploy.Command)
})
t.Run("service without pre-deploy hook", func(t *testing.T) {
t.Parallel()
ctr := newContainer(nil)
require.NotPanics(t, func() {
normaliseContainerForStore(&ctr)
})
assert.Nil(t, ctr.Config.Env)
assert.Nil(t, ctr.ServiceSpec.Container.Env)
assert.Nil(t, ctr.ServiceSpec.PreDeploy)
})
}
+61 -18
View File
@@ -8,14 +8,16 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/psviderski/uncloud/internal/corrosion"
"github.com/psviderski/uncloud/pkg/api"
) )
// ErrInvalidStoreVersion indicates an invalid actor UUID in a store version vector. // ErrInvalidStoreVersion indicates an invalid actor UUID in a store version vector.
var ErrInvalidStoreVersion = errors.New("invalid store version") var ErrInvalidStoreVersion = errors.New("invalid store version")
// WaitForVersion waits until the local store has reached each actor's minimum version in minVersion, with no known // WaitForVersion waits until the local store has reached each actor's minimum version in minVersion, with no known
// missing or pending transactions through those versions. Corrosion may satisfy a version by applying its surviving // missing or pending transactions through those versions from active members. Corrosion may satisfy a version
// changes or by marking it complete because its changes have been superseded. // by applying its surviving changes or by marking it complete because its changes have been superseded.
// //
// Waiting normally makes the captured data available locally. However, if another write replaces some of that data // Waiting normally makes the captured data available locally. However, if another write replaces some of that data
// before it arrives, Corrosion can complete the older version without transferring the replaced data. If the // before it arrives, Corrosion can complete the older version without transferring the replaced data. If the
@@ -27,7 +29,7 @@ var ErrInvalidStoreVersion = errors.New("invalid store version")
// //
// The method observes native replication without initiating synchronisation. // The method observes native replication without initiating synchronisation.
// An empty minVersion requires no replication. The context controls cancellation and the deadline. // An empty minVersion requires no replication. The context controls cancellation and the deadline.
func (s *Store) WaitForVersion(ctx context.Context, minVersion map[string]uint64) error { func (s *Store) WaitForVersion(ctx context.Context, minVersion api.StoreVersion) error {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return err return err
} }
@@ -52,28 +54,64 @@ func (s *Store) WaitForVersion(ctx context.Context, minVersion map[string]uint64
return nil return nil
} }
// Check all bookkeeping in one SQLite snapshot. Raw buffered rows can remain after application, queryPrefix := `WITH min_version(actor_id, version) AS (VALUES ` + strings.Join(placeholders, ", ") + `)
// but sequence bookkeeping is removed in the same transaction that applies or clears a version.
query := `WITH min_version(actor_id, version) AS (VALUES ` + strings.Join(placeholders, ", ") + `)
SELECT NOT EXISTS ( SELECT NOT EXISTS (
SELECT 1 FROM min_version SELECT 1 FROM min_version
LEFT JOIN crsql_db_versions AS current ON current.site_id = min_version.actor_id LEFT JOIN crsql_db_versions AS current ON current.site_id = min_version.actor_id
WHERE COALESCE(current.db_version, 0) < min_version.version WHERE COALESCE(current.db_version, 0) < min_version.version`
OR EXISTS (
SELECT 1 FROM __corro_bookkeeping_gaps versionsReached := false
WHERE actor_id = min_version.actor_id AND start <= min_version.version
)
OR EXISTS (
SELECT 1 FROM __corro_seq_bookkeeping
WHERE site_id = min_version.actor_id AND db_version <= min_version.version
)
)`
ticker := time.NewTicker(100 * time.Millisecond) ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop() defer ticker.Stop()
for { for {
rows, err := s.corro.QueryContext(ctx, query, args...) query := queryPrefix
queryArgs := args
// Check for gaps and pending transactions only after the requested versions are reached.
if versionsReached {
states, err := s.corroAdmin.ClusterMembershipStates(true)
if err != nil {
return fmt.Errorf("get cluster membership for store replication: %w", err)
}
activePlaceholders := make([]string, 0, len(states))
queryArgs = make([]any, len(args), len(args)+len(states))
copy(queryArgs, args)
for _, state := range states {
if state.State != corrosion.MembershipStateAlive && state.State != corrosion.MembershipStateSuspect {
continue
}
actorID, err := uuid.Parse(state.ID)
if err != nil {
return fmt.Errorf("parse cluster member actor '%s': %w", state.ID, err)
}
activePlaceholders = append(activePlaceholders, "?")
queryArgs = append(queryArgs, [16]byte(actorID))
}
if len(activePlaceholders) > 0 {
// Check active members' gaps and pending sequences in the same query for simplicity.
// Buffered rows can remain in __corro_buffered_changes after application, but sequence bookkeeping
// is removed when a version completes.
query += `
OR (
min_version.actor_id IN (` + strings.Join(activePlaceholders, ", ") + `)
AND (
EXISTS (
SELECT 1 FROM __corro_bookkeeping_gaps
WHERE actor_id = min_version.actor_id AND start <= min_version.version
)
OR EXISTS (
SELECT 1 FROM __corro_seq_bookkeeping
WHERE site_id = min_version.actor_id AND db_version <= min_version.version
)
)
)`
}
}
query += ")"
rows, err := s.corro.QueryContext(ctx, query, queryArgs...)
if err != nil { if err != nil {
return fmt.Errorf("check store replication: %w", err) return fmt.Errorf("check store replication: %w", err)
} }
@@ -83,6 +121,7 @@ func (s *Store) WaitForVersion(ctx context.Context, minVersion map[string]uint64
} }
return errors.New("check store replication: store version query returned no rows") return errors.New("check store replication: store version query returned no rows")
} }
var reached int var reached int
if err = rows.Scan(&reached); err != nil { if err = rows.Scan(&reached); err != nil {
rows.Close() rows.Close()
@@ -94,7 +133,11 @@ func (s *Store) WaitForVersion(ctx context.Context, minVersion map[string]uint64
return fmt.Errorf("check store replication: %w", err) return fmt.Errorf("check store replication: %w", err)
} }
if reached == 1 { if reached == 1 {
return ctx.Err() if versionsReached {
return nil
}
versionsReached = true
continue
} }
select { select {
+7 -5
View File
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/psviderski/uncloud/api/pb" "github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/corrosion" "github.com/psviderski/uncloud/internal/corrosion"
"github.com/psviderski/uncloud/pkg/api"
"google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protojson"
) )
@@ -23,11 +24,12 @@ var (
// Store is a cluster store backed by a distributed Corrosion database. // Store is a cluster store backed by a distributed Corrosion database.
type Store struct { type Store struct {
corro *corrosion.APIClient corro *corrosion.APIClient
corroAdmin *corrosion.AdminClient
} }
func New(corro *corrosion.APIClient) *Store { func New(corro *corrosion.APIClient, corroAdmin *corrosion.AdminClient) *Store {
return &Store{corro: corro} return &Store{corro: corro, corroAdmin: corroAdmin}
} }
// Get retrieves an unnamespaced legacy value. // Get retrieves an unnamespaced legacy value.
@@ -81,14 +83,14 @@ func (s *Store) Delete(ctx context.Context, key string) error {
// limitations documented there. // limitations documented there.
// //
// Capturing a vector does not wait for replication or prevent further writes. // Capturing a vector does not wait for replication or prevent further writes.
func (s *Store) Version(ctx context.Context) (map[string]uint64, error) { func (s *Store) Version(ctx context.Context) (api.StoreVersion, error) {
rows, err := s.corro.QueryContext(ctx, "SELECT site_id, db_version FROM crsql_db_versions") rows, err := s.corro.QueryContext(ctx, "SELECT site_id, db_version FROM crsql_db_versions")
if err != nil { if err != nil {
return nil, fmt.Errorf("query crsql_db_versions: %w", err) return nil, fmt.Errorf("query crsql_db_versions: %w", err)
} }
defer rows.Close() defer rows.Close()
versions := make(map[string]uint64) versions := make(api.StoreVersion)
for rows.Next() { for rows.Next() {
var ( var (
siteID []byte siteID []byte
+146 -27
View File
@@ -1,40 +1,58 @@
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html # @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"]] [[tools."aqua:vektra/mockery"]]
version = "3.5.3" version = "3.8.1"
backend = "aqua:vektra/mockery" backend = "aqua:vektra/mockery"
specifiers = ["3.8.1"]
[tools."aqua:vektra/mockery"."platforms.linux-arm64"] [tools."aqua:vektra/mockery"."platforms.linux-arm64"]
checksum = "sha256:ec02ca72b7ad1ff5a1de5503a27d3b1b3f230d722eb35c5b92e43e295a8e1290" checksum = "sha256:d1f481f3a6e9868dd3ad65da265238ee109ab3a5dd7508958d5811953696f452"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_arm64.tar.gz" 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"] [tools."aqua:vektra/mockery"."platforms.linux-arm64-musl"]
checksum = "sha256:ec02ca72b7ad1ff5a1de5503a27d3b1b3f230d722eb35c5b92e43e295a8e1290" checksum = "sha256:d1f481f3a6e9868dd3ad65da265238ee109ab3a5dd7508958d5811953696f452"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_arm64.tar.gz" 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"] [tools."aqua:vektra/mockery"."platforms.linux-x64"]
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c" checksum = "sha256:953c7d38b58dd6398b1c595f27caf8bbf27e9b733b49c404d2e124e86d2ae72a"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz" 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"] [tools."aqua:vektra/mockery"."platforms.linux-x64-musl"]
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c" checksum = "sha256:953c7d38b58dd6398b1c595f27caf8bbf27e9b733b49c404d2e124e86d2ae72a"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz" 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"] [tools."aqua:vektra/mockery"."platforms.macos-arm64"]
checksum = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704" checksum = "sha256:303a794484243906b37da0c37056f974bf94278c2e10d7b1372e6bd53a2d5034"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_arm64.tar.gz" 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"] [tools."aqua:vektra/mockery"."platforms.macos-x64"]
checksum = "sha256:8bd2b3e4722e476400395920fb5bbd68be4ea22ec12cddf1e424c40610fda863" checksum = "sha256:6882331344e946268b304dce042b01371a0213a896536413be8abe49d532ca6f"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_x86_64.tar.gz" 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"] [tools."aqua:vektra/mockery"."platforms.windows-x64"]
checksum = "sha256:3b4264066db615a56a8b59abda992f6db82b8cd7666937ed9dd8f4a4bfc424a0" checksum = "sha256:7a7458756e9d7bce5f9936b7a63394000f3affa751b9740e65350c4bdcb627dc"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Windows_x86_64.tar.gz" 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]] [[tools.go]]
version = "1.26.1" version = "1.26.1"
backend = "core:go" backend = "core:go"
specifiers = ["1.26.1"]
[tools.go."platforms.linux-arm64"] [tools.go."platforms.linux-arm64"]
checksum = "sha256:a290581cfe4fe28ddd737dde3095f3dbeb7f2e4065cab4eae44dfc53b760c2f7" checksum = "sha256:a290581cfe4fe28ddd737dde3095f3dbeb7f2e4065cab4eae44dfc53b760c2f7"
@@ -67,95 +85,118 @@ url = "https://dl.google.com/go/go1.26.1.windows-amd64.zip"
[[tools.golangci-lint]] [[tools.golangci-lint]]
version = "2.11.2" version = "2.11.2"
backend = "aqua:golangci/golangci-lint" backend = "aqua:golangci/golangci-lint"
specifiers = ["2.11.2"]
[tools.golangci-lint."platforms.linux-arm64"] [tools.golangci-lint."platforms.linux-arm64"]
checksum = "sha256:c3c83c7ed003e881991af21361c487151abd37969927ed9062cd0b48f3cd1d8d" checksum = "sha256:c3c83c7ed003e881991af21361c487151abd37969927ed9062cd0b48f3cd1d8d"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-arm64.tar.gz" url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099658"
provenance = "github-attestations"
[tools.golangci-lint."platforms.linux-arm64-musl"] [tools.golangci-lint."platforms.linux-arm64-musl"]
checksum = "sha256:c3c83c7ed003e881991af21361c487151abd37969927ed9062cd0b48f3cd1d8d" checksum = "sha256:c3c83c7ed003e881991af21361c487151abd37969927ed9062cd0b48f3cd1d8d"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-arm64.tar.gz" url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099658"
provenance = "github-attestations"
[tools.golangci-lint."platforms.linux-x64"] [tools.golangci-lint."platforms.linux-x64"]
checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9" checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-amd64.tar.gz" url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-amd64.tar.gz"
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099674"
provenance = "github-attestations" provenance = "github-attestations"
[tools.golangci-lint."platforms.linux-x64-musl"] [tools.golangci-lint."platforms.linux-x64-musl"]
checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9" checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-amd64.tar.gz" url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-amd64.tar.gz"
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099674"
provenance = "github-attestations"
[tools.golangci-lint."platforms.macos-arm64"] [tools.golangci-lint."platforms.macos-arm64"]
checksum = "sha256:11e9549b955bae4bfb71aa3b5c2762e446e2d3f975fe963d6d767b8f745d9344" checksum = "sha256:11e9549b955bae4bfb71aa3b5c2762e446e2d3f975fe963d6d767b8f745d9344"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-darwin-arm64.tar.gz" url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099671"
provenance = "github-attestations" provenance = "github-attestations"
[tools.golangci-lint."platforms.macos-x64"] [tools.golangci-lint."platforms.macos-x64"]
checksum = "sha256:1dbbbe745eadba9cf012d49bd0b8c9290cbcb50dbf2cf9611f75a715b46ae3af" checksum = "sha256:1dbbbe745eadba9cf012d49bd0b8c9290cbcb50dbf2cf9611f75a715b46ae3af"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-darwin-amd64.tar.gz" url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-darwin-amd64.tar.gz"
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099653"
provenance = "github-attestations"
[tools.golangci-lint."platforms.windows-x64"] [tools.golangci-lint."platforms.windows-x64"]
checksum = "sha256:60e50178253d276d1c808ef00845eddcabca63b7d69d86a6dbaa41e88bc843f3" checksum = "sha256:60e50178253d276d1c808ef00845eddcabca63b7d69d86a6dbaa41e88bc843f3"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-windows-amd64.zip" url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-windows-amd64.zip"
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099651"
provenance = "github-attestations"
[[tools.goreleaser]] [[tools.goreleaser]]
version = "2.15.2" version = "2.15.2"
backend = "aqua:goreleaser/goreleaser" backend = "aqua:goreleaser/goreleaser"
specifiers = ["2.15.2"]
[tools.goreleaser."platforms.linux-arm64"] [tools.goreleaser."platforms.linux-arm64"]
checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9" checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz" url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz"
provenance = "cosign" url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702105"
provenance = "github-attestations"
[tools.goreleaser."platforms.linux-arm64-musl"] [tools.goreleaser."platforms.linux-arm64-musl"]
checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9" checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz" url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz"
provenance = "cosign" url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702105"
provenance = "github-attestations"
[tools.goreleaser."platforms.linux-x64"] [tools.goreleaser."platforms.linux-x64"]
checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc" checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz" url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz"
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702106"
provenance = "github-attestations" provenance = "github-attestations"
[tools.goreleaser."platforms.linux-x64-musl"] [tools.goreleaser."platforms.linux-x64-musl"]
checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc" checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz" url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz"
provenance = "cosign" url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702106"
provenance = "github-attestations"
[tools.goreleaser."platforms.macos-arm64"] [tools.goreleaser."platforms.macos-arm64"]
checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee" checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz" url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz"
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702148"
provenance = "github-attestations" provenance = "github-attestations"
[tools.goreleaser."platforms.macos-x64"] [tools.goreleaser."platforms.macos-x64"]
checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee" checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz" url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz"
provenance = "cosign" url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702148"
provenance = "github-attestations"
[tools.goreleaser."platforms.windows-x64"] [tools.goreleaser."platforms.windows-x64"]
checksum = "sha256:7459832946dbe122c144f8d7f87484d8572ca005b779310aa6bb03346e8de17a" checksum = "sha256:7459832946dbe122c144f8d7f87484d8572ca005b779310aa6bb03346e8de17a"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Windows_x86_64.zip" url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Windows_x86_64.zip"
provenance = "cosign" url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702135"
provenance = "github-attestations"
[[tools.node]] [[tools.node]]
version = "22.20.0" version = "22.20.0"
backend = "core:node" backend = "core:node"
specifiers = ["22.20.0"]
[tools.node."platforms.linux-arm64"] [tools.node."platforms.linux-arm64"]
checksum = "sha256:4181609e03dcb9880e7e5bf956061ecc0503c77a480c6631d868cb1f65a2c7dd" checksum = "sha256:4181609e03dcb9880e7e5bf956061ecc0503c77a480c6631d868cb1f65a2c7dd"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-arm64.tar.gz" url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-arm64.tar.gz"
[tools.node."platforms.linux-arm64-musl"] [tools.node."platforms.linux-arm64-musl"]
checksum = "sha256:4181609e03dcb9880e7e5bf956061ecc0503c77a480c6631d868cb1f65a2c7dd" checksum = "sha256:79a087c1e23fc97d173ad6676df5b1a4e1a02daf1acca05ee25662747c5d492c"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-arm64.tar.gz" url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0.tar.gz"
install = "source"
[tools.node."platforms.linux-x64"] [tools.node."platforms.linux-x64"]
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29" checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz" url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
[tools.node."platforms.linux-x64-musl"] [tools.node."platforms.linux-x64-musl"]
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29" checksum = "sha256:461aafed5eb607357479c6de5df978515d74daf7e0169e972e61c8390c035f1a"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz" url = "https://unofficial-builds.nodejs.org/download/release/v22.20.0/node-v22.20.0-linux-x64-musl.tar.gz"
[tools.node."platforms.macos-arm64"] [tools.node."platforms.macos-arm64"]
checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949" checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
@@ -172,41 +213,119 @@ url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-win-x64.zip"
[[tools.protoc]] [[tools.protoc]]
version = "27.3" version = "27.3"
backend = "aqua:protocolbuffers/protobuf/protoc" 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"] [tools.protoc."platforms.linux-x64"]
checksum = "blake3:8c2938accf7061164ea3968529a47ced1db4824ca553857f6903c68ae4347739" checksum = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-x86_64.zip" url = "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]] [[tools.protoc-gen-go]]
version = "1.34.2" version = "1.34.2"
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go" backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
specifiers = ["1.34.2"]
[tools.protoc-gen-go."platforms.linux-arm64"] [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 = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.arm64.tar.gz"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109690"
[tools.protoc-gen-go."platforms.linux-arm64-musl"] [tools.protoc-gen-go."platforms.linux-arm64-musl"]
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 = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.arm64.tar.gz"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109690"
[tools.protoc-gen-go."platforms.linux-x64"] [tools.protoc-gen-go."platforms.linux-x64"]
checksum = "blake3:f765e393e0f0b25070c13848d88f5e6303e66090132afa1b753e981507a4174e" checksum = "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 = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109692"
[tools.protoc-gen-go."platforms.linux-x64-musl"] [tools.protoc-gen-go."platforms.linux-x64-musl"]
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 = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109692"
[tools.protoc-gen-go."platforms.macos-arm64"] [tools.protoc-gen-go."platforms.macos-arm64"]
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 = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.arm64.tar.gz"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109697"
[tools.protoc-gen-go."platforms.macos-x64"] [tools.protoc-gen-go."platforms.macos-x64"]
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 = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.amd64.tar.gz"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109698"
[tools.protoc-gen-go."platforms.windows-x64"] [tools.protoc-gen-go."platforms.windows-x64"]
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 = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.windows.amd64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109685"
[[tools.protoc-gen-go-grpc]] [[tools.protoc-gen-go-grpc]]
version = "1.5.1" version = "1.5.1"
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc" backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
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"] [tools.protoc-gen-go-grpc."platforms.linux-x64"]
checksum = "blake3:197739132f0408f017d0c83773ac1ace85c0572473fdd658ec6e4ccbf20e0552" checksum = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz" url = "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"
+35 -4
View File
@@ -1,8 +1,9 @@
[settings] [settings]
experimental = true experimental = true
lockfile = true
[tools] [tools]
"aqua:vektra/mockery" = "3.5.3" "aqua:vektra/mockery" = "3.8.1"
go = "1.26.1" go = "1.26.1"
golangci-lint = "2.11.2" golangci-lint = "2.11.2"
goreleaser = "2.15.2" goreleaser = "2.15.2"
@@ -36,9 +37,37 @@ GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/psviderski/uncloud/inte
""" """
outputs = ["uncloudd-linux-arm64"] outputs = ["uncloudd-linux-arm64"]
[tasks."mise:lock"] [tasks."docs:cli"]
description = "Update mise.lock with hashes for all platforms" description = "Regenerate the CLI reference docs"
run = "mise lock --platform linux-x64,macos-arm64" run = "go run ./cmd/uc docs"
[tasks."docs:dev"]
description = "Run the documentation website locally"
dir = "website"
run = "npm start --"
[tasks.fmt]
description = "Format Go code and imports"
alias = "format"
env.GOOS = "linux"
run = "golangci-lint fmt"
[tasks.lint]
description = "Check Go code and formatting"
# Explicitly set OS to Linux to not skip *_linux.go files when running on macOS.
# Uncloud daemon won't likely support OS other than Linux anytime soon, so for now we can rely on that.
env.GOOS = "linux"
run = "golangci-lint run"
[tasks."lint:fix"]
description = "Format Go code and apply available lint fixes"
alias = "lint-and-fix"
env.GOOS = "linux"
run = "golangci-lint run --fix"
[tasks.mocks]
description = "Regenerate Go test mocks"
run = "mockery"
[tasks.proto] [tasks.proto]
description = "Regenerate gRPC API code from .proto files" description = "Regenerate gRPC API code from .proto files"
@@ -49,10 +78,12 @@ protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=p
[tasks.uc] [tasks.uc]
description = "Run the Uncloud CLI" description = "Run the Uncloud CLI"
raw_args = true
run = "go run ./cmd/uc" run = "go run ./cmd/uc"
[tasks.ucind] [tasks.ucind]
description = "Run ucind CLI for managing local Docker-based clusters" description = "Run ucind CLI for managing local Docker-based clusters"
raw_args = true
run = "go run ./cmd/ucind" run = "go run ./cmd/ucind"
[tasks."ucind:image"] [tasks."ucind:image"]
+22 -1
View File
@@ -1,6 +1,27 @@
package api package api
import "strings" import (
"strings"
"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
// ModifiedAt is zero when the server does not supply a modification timestamp.
ModifiedAt time.Time
// LastReconciliationError describes the latest unsuccessful configuration reconciliation.
LastReconciliationError string
}
// CaddySpec is the Caddy reverse proxy configuration for a service. // CaddySpec is the Caddy reverse proxy configuration for a service.
type CaddySpec struct { type CaddySpec struct {
+111
View File
@@ -0,0 +1,111 @@
package api
import (
"crypto/sha256"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"strings"
"github.com/mholt/acmez/v3/acme"
"github.com/psviderski/uncloud/api/pb"
)
// IssuedCertificate describes a certificate in Caddy's managed certificate storage.
// It does not indicate whether Caddy currently serves the certificate or whether it is trusted.
type IssuedCertificate struct {
// SAN is the Subject Alternative Name (SAN) of the certificate.
// Caddy doesn't issue certificates with multiple SANs.
SAN string
// Chain contains the parsed certificates in stored order, with the leaf first.
Chain []*x509.Certificate
// IssuerData is extra information associated with the certificate, usually provided by the issuer implementation.
IssuerData CertificateIssuerData
}
// Fingerprint returns the SHA-256 fingerprint of the leaf certificate.
func (c IssuedCertificate) Fingerprint() [sha256.Size]byte {
return sha256.Sum256(c.Chain[0].Raw)
}
// CertificateIssuerData preserves issuer-specific metadata and provides a best-effort typed view of ACME records.
type CertificateIssuerData struct {
// Raw is the original issuer_data JSON, including unrecognized formats and fields.
Raw json.RawMessage
// ACME is populated when the metadata can be decoded as an ACME record with CA and certificate URLs.
// It is nil for absent, unrecognized, or malformed metadata.
ACME *ACMEIssuerData
}
// ACMEIssuerData identifies the ACME resources used to issue a certificate.
type ACMEIssuerData struct {
// URL is the certificate resource URL as provisioned by the ACME server.
URL string
// CA is the directory URL of the ACME CA that issued this certificate.
CA string
// Account is the URL of the account that obtained the certificate.
Account string
// RenewalInfo is the stored renewal guidance, not a guarantee of when renewal will run.
RenewalInfo *acme.RenewalInfo
}
// IssuedCertificateFromProto parses a stored certificate chain without verifying trust or expiry.
// Issuer metadata is decoded as ACME on a best-effort basis and is always preserved in its raw form.
func IssuedCertificateFromProto(p *pb.IssuedCertificate) (IssuedCertificate, error) {
if p == nil || strings.TrimSpace(p.San) == "" {
return IssuedCertificate{}, fmt.Errorf("invalid certificate: missing SAN")
}
chain, err := parseCertificateChain(p.Chain)
if err != nil {
return IssuedCertificate{}, fmt.Errorf("parse certificate '%s': %w", p.San, err)
}
return IssuedCertificate{
SAN: p.San,
Chain: chain,
IssuerData: parseCertificateIssuerData(p.IssuerData),
}, nil
}
func parseCertificateChain(data []byte) ([]*x509.Certificate, error) {
var chain []*x509.Certificate
for len(data) > 0 {
var block *pem.Block
block, data = pem.Decode(data)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("unexpected PEM block type '%s'", block.Type)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse X.509 certificate: %w", err)
}
chain = append(chain, cert)
}
if len(chain) == 0 {
return nil, fmt.Errorf("empty certificate chain")
}
return chain, nil
}
func parseCertificateIssuerData(raw json.RawMessage) CertificateIssuerData {
data := CertificateIssuerData{Raw: raw}
// Recognize ACME by its resource URLs and decodable metadata.
// Unknown formats and malformed records remain raw-only.
var cert acme.Certificate
if err := json.Unmarshal(raw, &cert); err != nil || cert.CA == "" || cert.URL == "" {
return data
}
data.ACME = &ACMEIssuerData{
URL: cert.URL,
CA: cert.CA,
Account: cert.Account,
RenewalInfo: cert.RenewalInfo,
}
return data
}
+182
View File
@@ -0,0 +1,182 @@
package api
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"math/big"
"testing"
"time"
"github.com/mholt/acmez/v3/acme"
"github.com/psviderski/uncloud/api/pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssuedCertificateFromProto(t *testing.T) {
leaf := testCertificatePEM(t, 1)
issuer := testCertificatePEM(t, 2)
for _, tt := range []struct {
name string
chain []byte
serials []int64
}{
{name: "single certificate", chain: leaf, serials: []int64{1}},
{name: "certificate chain", chain: append(append([]byte(nil), leaf...), issuer...), serials: []int64{1, 2}},
} {
t.Run(tt.name, func(t *testing.T) {
cert, err := IssuedCertificateFromProto(&pb.IssuedCertificate{
San: "app.example.com", Chain: tt.chain,
})
require.NoError(t, err)
assert.Equal(t, "app.example.com", cert.SAN)
require.Len(t, cert.Chain, len(tt.serials))
for i, serial := range tt.serials {
assert.Equal(t, big.NewInt(serial), cert.Chain[i].SerialNumber, "chain order must be preserved")
}
assert.Equal(t, []string{"app.example.com"}, cert.Chain[0].DNSNames)
// Expired, self-signed certificates are parsed without verifying trust or validity.
assert.Equal(t, time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), cert.Chain[0].NotAfter)
assert.Empty(t, cert.IssuerData)
})
}
}
func TestIssuedCertificateFingerprint(t *testing.T) {
cert := IssuedCertificate{Chain: []*x509.Certificate{
{Raw: []byte("abc")},
{Raw: []byte("issuer")},
}}
want, err := hex.DecodeString("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
require.NoError(t, err)
fingerprint := cert.Fingerprint()
assert.Equal(t, want, fingerprint[:])
}
func TestIssuedCertificateFromProto_InvalidCertificate(t *testing.T) {
valid := testCertificatePEM(t, 1)
invalidDER := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("invalid")})
for _, tt := range []struct {
name string
input *pb.IssuedCertificate
wantErr string
}{
{name: "nil input", wantErr: "missing SAN"},
{name: "missing SAN", input: &pb.IssuedCertificate{Chain: valid}, wantErr: "missing SAN"},
{name: "blank SAN", input: &pb.IssuedCertificate{San: " \t", Chain: valid}, wantErr: "missing SAN"},
{name: "empty chain", input: &pb.IssuedCertificate{San: "app.example.com"}, wantErr: "empty certificate chain"},
{
name: "invalid PEM", input: &pb.IssuedCertificate{San: "app.example.com", Chain: []byte("not PEM")},
wantErr: "empty certificate chain",
},
{
name: "wrong PEM block type", input: &pb.IssuedCertificate{
San: "app.example.com",
Chain: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("key")}),
},
wantErr: "unexpected PEM block type",
},
{
name: "invalid DER", input: &pb.IssuedCertificate{San: "app.example.com", Chain: invalidDER},
wantErr: "parse X.509 certificate",
},
{name: "invalid certificate after valid leaf", input: &pb.IssuedCertificate{
San: "app.example.com",
Chain: append(append([]byte(nil), valid...), invalidDER...),
}, wantErr: "parse X.509 certificate"},
} {
t.Run(tt.name, func(t *testing.T) {
cert, err := IssuedCertificateFromProto(tt.input)
require.ErrorContains(t, err, tt.wantErr)
assert.Empty(t, cert)
})
}
}
func TestIssuedCertificateFromProto_IssuerData(t *testing.T) {
chain := testCertificatePEM(t, 1)
retryAfter := time.Date(2029, 12, 1, 0, 0, 0, 0, time.UTC)
renewalInfo := &acme.RenewalInfo{
ExplanationURL: "https://ca.example/why",
UniqueIdentifier: "aki.serial",
RetryAfter: &retryAfter,
SelectedTime: time.Date(2030, 1, 2, 0, 0, 0, 0, time.UTC),
}
renewalInfo.SuggestedWindow.Start = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
renewalInfo.SuggestedWindow.End = time.Date(2030, 1, 3, 0, 0, 0, 0, time.UTC)
for _, tt := range []struct {
name string
raw string
want *ACMEIssuerData
}{
{
name: "ACME",
raw: `{"url":"https://ca.example/cert/1","ca":"https://ca.example/directory","account":"https://ca.example/acct/1","future_field":true}`,
want: &ACMEIssuerData{
URL: "https://ca.example/cert/1", CA: "https://ca.example/directory", Account: "https://ca.example/acct/1",
},
},
{
name: "ACME with renewal information",
raw: `{
"url": "https://ca.example/cert/1",
"ca": "https://ca.example/directory",
"account": "https://ca.example/acct/1",
"renewal_info": {
"suggestedWindow": {"start": "2030-01-01T00:00:00Z", "end": "2030-01-03T00:00:00Z"},
"explanationURL": "https://ca.example/why",
"_uniqueIdentifier": "aki.serial",
"_retryAfter": "2029-12-01T00:00:00Z",
"_selectedTime": "2030-01-02T00:00:00Z"
}
}`,
want: &ACMEIssuerData{
URL: "https://ca.example/cert/1", CA: "https://ca.example/directory",
Account: "https://ca.example/acct/1", RenewalInfo: renewalInfo,
},
},
{name: "absent"},
{name: "null", raw: "null"},
{name: "unknown issuer", raw: `{"id":"provider-id","status":"issued"}`},
{name: "non-object JSON", raw: `"provider-record"`},
{name: "missing CA", raw: `{"url":"https://ca.example/cert/1"}`},
{name: "missing certificate URL", raw: `{"ca":"https://ca.example/directory"}`},
{name: "invalid JSON", raw: "{"},
{name: "invalid field types", raw: `{"url":123,"ca":false}`},
{
name: "malformed renewal information",
raw: `{"url":"https://ca.example/cert/1","ca":"https://ca.example/directory","renewal_info":{"_selectedTime":"invalid"}}`,
},
} {
t.Run(tt.name, func(t *testing.T) {
cert, err := IssuedCertificateFromProto(&pb.IssuedCertificate{
San: "app.example.com", Chain: chain, IssuerData: []byte(tt.raw),
})
require.NoError(t, err, "issuer metadata must not prevent certificate parsing")
require.Len(t, cert.Chain, 1)
assert.Equal(t, tt.raw, string(cert.IssuerData.Raw), "preserve original JSON including unknown fields")
assert.Equal(t, tt.want, cert.IssuerData.ACME)
})
}
}
// testCertificatePEM creates an expired, self-signed certificate for parsing tests.
func testCertificatePEM(t *testing.T, serial int64) []byte {
t.Helper()
public, private, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(serial),
DNSNames: []string{"app.example.com"},
NotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
NotAfter: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC),
}
der, err := x509.CreateCertificate(rand.Reader, template, template, public, private)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}
+3
View File
@@ -30,6 +30,7 @@ type ContainerClient interface {
StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error
StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error
RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
UpdateContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts UpdateContainerOptions) error
WaitContainerHealthy( WaitContainerHealthy(
ctx context.Context, serviceNameOrID, containerNameOrID string, opts WaitContainerHealthyOptions, ctx context.Context, serviceNameOrID, containerNameOrID string, opts WaitContainerHealthyOptions,
) error ) error
@@ -42,6 +43,8 @@ type DNSClient interface {
type ImageClient interface { type ImageClient interface {
InspectImage(ctx context.Context, id string) ([]MachineImage, error) InspectImage(ctx context.Context, id string) ([]MachineImage, error)
InspectRemoteImage(ctx context.Context, id string) ([]MachineRemoteImage, error) InspectRemoteImage(ctx context.Context, id string) ([]MachineRemoteImage, error)
ListImages(ctx context.Context, filter ImageFilter) ([]MachineImages, error)
RemoveImage(ctx context.Context, image string, opts RemoveImageOptions) ([]MachineImageRemoval, error)
} }
type LogsClient interface { type LogsClient interface {
+33
View File
@@ -19,6 +19,11 @@ const (
// in internal/machine/docker/manager.go. // in internal/machine/docker/manager.go.
DockerNetworkName = "uncloud" 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 marks system containers as managed by the Uncloud daemon.
LabelDaemonManaged = "uncloudd.managed" LabelDaemonManaged = "uncloudd.managed"
@@ -175,6 +180,19 @@ type CreateContainerResponse struct {
Name string 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 { type ServiceContainer struct {
Container Container
ServiceSpec ServiceSpec ServiceSpec ServiceSpec
@@ -195,6 +213,21 @@ func (c *ServiceContainer) ServiceName() string {
return c.Config.Labels[LabelServiceName] 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. // ServiceMode returns the replication mode of the service this container belongs to.
func (c *ServiceContainer) ServiceMode() string { func (c *ServiceContainer) ServiceMode() string {
return c.ServiceSpec.Mode return c.ServiceSpec.Mode

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