mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aa85ab501 | ||
|
|
521ccf2026 | ||
|
|
e8b4dd0bd1 | ||
|
|
265042e2c8 | ||
|
|
1c85112d4f | ||
|
|
0fc5032b27 | ||
|
|
893ab1f9f5 | ||
|
|
a30ed600b6 | ||
|
|
a2c6cbe633 | ||
|
|
1f9c2c873d | ||
|
|
509c9eb7d3 | ||
|
|
fba03b3df7 | ||
|
|
c6d18f5d49 | ||
|
|
3e804d9ecc | ||
|
|
784d86d8e8 | ||
|
|
3bac1e61b9 | ||
|
|
6d2e100d63 | ||
|
|
cce81ecaad | ||
|
|
f8bf6bccd3 | ||
|
|
82fd9744bf | ||
|
|
cdff036935 | ||
|
|
458d282357 | ||
|
|
59074a275f | ||
|
|
92975eec51 | ||
|
|
e0d11a8abc | ||
|
|
fa004d63bb | ||
|
|
a217643ac9 | ||
|
|
fc3297c7b7 | ||
|
|
684f0d38ee | ||
|
|
332e7bca90 | ||
|
|
ae458d260f | ||
|
|
6b758c810e | ||
|
|
ffed906cef | ||
|
|
bcc504b8cc | ||
|
|
782f4cb20b | ||
|
|
aebd2ac232 | ||
|
|
2969b40ad4 | ||
|
|
e0045c7570 | ||
|
|
bc6f2fd49d | ||
|
|
cd25b973ea | ||
|
|
27bbbe7186 | ||
|
|
20cf98d42e | ||
|
|
63c4de512e | ||
|
|
337c15de35 | ||
|
|
2d361d3ee6 | ||
|
|
977fdf3cc3 | ||
|
|
9f6880b701 | ||
|
|
1a1afece7d | ||
|
|
08f233c5b1 | ||
|
|
9438654d99 | ||
|
|
60926f64cc | ||
|
|
b6b8286118 | ||
|
|
8f906cfd95 | ||
|
|
da03ed8ece | ||
|
|
7239cd3a6b | ||
|
|
3db07ca2d4 | ||
|
|
f8fe3c7af3 | ||
|
|
b6e9766f40 | ||
|
|
1f62e30a08 | ||
|
|
88f05ffd75 | ||
|
|
375bb385a2 | ||
|
|
51840e0366 | ||
|
|
831c581e0f | ||
|
|
e1734a1aa6 | ||
|
|
84376bb34c | ||
|
|
6d0b1dc1ee | ||
|
|
bc078472d4 | ||
|
|
8a273dbf63 | ||
|
|
001acfc6a5 | ||
|
|
7b88b8b810 | ||
|
|
97bdb8eae1 | ||
|
|
5baa8087e5 | ||
|
|
d25864e52f | ||
|
|
ec2787c99c | ||
|
|
9963f9df2d | ||
|
|
835834322b | ||
|
|
51ba3c7df8 | ||
|
|
047f661462 | ||
|
|
5a1e61ccff | ||
|
|
db60a81b2d | ||
|
|
7c19323ea1 | ||
|
|
290e6db98e | ||
|
|
df29d9ba43 | ||
|
|
64769081d9 | ||
|
|
0107363d41 | ||
|
|
48dc1dd624 | ||
|
|
3a6eef410a | ||
|
|
9f5ca9a33d | ||
|
|
b046b78398 | ||
|
|
ff213e71d3 | ||
|
|
3cda5cc564 | ||
|
|
75fdbaf2f4 | ||
|
|
81f4e3a67a | ||
|
|
e99e769455 | ||
|
|
11949eeb3b | ||
|
|
b437659678 | ||
|
|
c01365b416 | ||
|
|
813c397644 | ||
|
|
5cc005a423 | ||
|
|
066d411367 | ||
|
|
03970862ab | ||
|
|
8bf9fc0c9c | ||
|
|
93fef88fac | ||
|
|
455174ccb0 | ||
|
|
1ce3e62dbb | ||
|
|
4be8339c51 | ||
|
|
8dd69b46da | ||
|
|
4cc1e556dd | ||
|
|
9186d31d12 | ||
|
|
dd7bc6c982 | ||
|
|
12c07812a2 | ||
|
|
ec73f9ecd8 | ||
|
|
879c7c1876 | ||
|
|
c67127f83f | ||
|
|
5d3f1fe225 | ||
|
|
2e585d0183 | ||
|
|
ae9f943404 | ||
|
|
8805178a58 |
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
**How to reproduce**
|
||||
|
||||
<!-- Steps to reproduce the behavior:
|
||||
|
||||
1. Run ...
|
||||
2. Do ...
|
||||
-->
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
**Environment:**
|
||||
|
||||
- Uncloud versions:
|
||||
- Control (client) node (`uc --version`):
|
||||
- Uncloud daemon (from the server) (`uncloudd --version`):
|
||||
- OS version (`uname -a`):
|
||||
- Client (control node):
|
||||
- Server:
|
||||
|
||||
**Additional context**
|
||||
|
||||
<!-- Add any other context about the problem here. -->
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../AI.md
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: "1.23.2"
|
||||
go-version: "1.25.1"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Install Mise
|
||||
uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3
|
||||
with:
|
||||
version: "2025.6.5"
|
||||
version: "2025.9.6"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: "1.23.2"
|
||||
go-version: "1.25.1"
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
||||
with:
|
||||
version: v2.2.2
|
||||
version: v2.4.0
|
||||
|
||||
- name: Format code
|
||||
run: |
|
||||
|
||||
+80
-22
@@ -1,39 +1,97 @@
|
||||
[tools.go]
|
||||
version = "1.23.10"
|
||||
[[tools."aqua:vektra/mockery"]]
|
||||
version = "3.5.3"
|
||||
backend = "aqua:vektra/mockery"
|
||||
|
||||
[tools."aqua:vektra/mockery".platforms.linux-x64]
|
||||
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
|
||||
size = 5498859
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz"
|
||||
|
||||
[tools."aqua:vektra/mockery".platforms.macos-arm64]
|
||||
checksum = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
|
||||
size = 5238877
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_arm64.tar.gz"
|
||||
|
||||
[[tools.go]]
|
||||
version = "1.25.1"
|
||||
backend = "core:go"
|
||||
|
||||
[tools.go.checksums]
|
||||
"go1.23.10.darwin-arm64.tar.gz" = "sha256:25c64bfa8a8fd8e7f62fb54afa4354af8409a4bb2358c2699a1003b733e6fce5"
|
||||
"go1.23.10.linux-amd64.tar.gz" = "sha256:535f9f81802499f2a7dbfa70abb8fda3793725fcc29460f719815f6e10b5fd60"
|
||||
[tools.go.platforms.linux-x64]
|
||||
checksum = "sha256:7716a0d940a0f6ae8e1f3b3f4f36299dc53e31b16840dbd171254312c41ca12e"
|
||||
size = 59660846
|
||||
url = "https://dl.google.com/go/go1.25.1.linux-amd64.tar.gz"
|
||||
|
||||
[tools.golangci-lint]
|
||||
version = "2.2.2"
|
||||
[tools.go.platforms.macos-arm64]
|
||||
checksum = "sha256:68deebb214f39d542e518ebb0598a406ab1b5a22bba8ec9ade9f55fb4dd94a6c"
|
||||
size = 57906702
|
||||
url = "https://dl.google.com/go/go1.25.1.darwin-arm64.tar.gz"
|
||||
|
||||
[[tools.golangci-lint]]
|
||||
version = "2.4.0"
|
||||
backend = "aqua:golangci/golangci-lint"
|
||||
|
||||
[tools.golangci-lint.checksums]
|
||||
"golangci-lint-2.2.2-darwin-arm64.tar.gz" = "sha256:d84d94d042c0d495fd1746f3d18948a75de163b17a14e8de3ef840928dd2df74"
|
||||
"golangci-lint-2.2.2-linux-amd64.tar.gz" = "sha256:c27fbde948a87d326feacd21df2f61a9c54dbd2e3bfa185c0a1cd6917a6f964f"
|
||||
[tools.golangci-lint.platforms.linux-x64]
|
||||
checksum = "sha256:fae792524c04424c0ac369f5b8076f04b45cf29fc945a370e55d369a8dc11840"
|
||||
size = 14126779
|
||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.4.0/golangci-lint-2.4.0-linux-amd64.tar.gz"
|
||||
|
||||
[tools.protoc]
|
||||
[tools.golangci-lint.platforms.macos-arm64]
|
||||
checksum = "sha256:cd4dd53fa09b6646baff5fd22b8c64d91db02c21c7496df27992d75d34feec59"
|
||||
size = 13542526
|
||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.4.0/golangci-lint-2.4.0-darwin-arm64.tar.gz"
|
||||
|
||||
[[tools.node]]
|
||||
version = "22.20.0"
|
||||
backend = "core:node"
|
||||
|
||||
[tools.node.platforms.linux-x64]
|
||||
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
|
||||
size = 56645685
|
||||
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
|
||||
|
||||
[tools.node.platforms.macos-arm64]
|
||||
checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
|
||||
size = 49838299
|
||||
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-darwin-arm64.tar.gz"
|
||||
|
||||
[[tools.protoc]]
|
||||
version = "27.3"
|
||||
backend = "aqua:protocolbuffers/protobuf/protoc"
|
||||
|
||||
[tools.protoc.checksums]
|
||||
"protoc-27.3-linux-x86_64.zip" = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
|
||||
"protoc-27.3-osx-aarch_64.zip" = "sha256:b22116bd97cdbd7ea25346abe635a9df268515fe5ef5afa93cd9a68fc2513f84"
|
||||
[tools.protoc.platforms.linux-x64]
|
||||
checksum = "blake3:8c2938accf7061164ea3968529a47ced1db4824ca553857f6903c68ae4347739"
|
||||
size = 3235873
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-x86_64.zip"
|
||||
|
||||
[tools.protoc-gen-go]
|
||||
[tools.protoc.platforms.macos-arm64]
|
||||
checksum = "blake3:650c2494c003e9fddd6f4164a0a36e31da51ae7e1e5208da8cb560a4fe886fcd"
|
||||
size = 2372763
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-osx-aarch_64.zip"
|
||||
|
||||
[[tools.protoc-gen-go]]
|
||||
version = "1.34.2"
|
||||
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
|
||||
|
||||
[tools.protoc-gen-go.checksums]
|
||||
"protoc-gen-go.v1.34.2.darwin.arm64.tar.gz" = "sha256:17aca7f948dbb624049030cf841e35895cf34183ba006e721247fdeb95ff2780"
|
||||
"protoc-gen-go.v1.34.2.linux.amd64.tar.gz" = "sha256:b87bc134dee55576a842141bf0ed27761c635d746780fce5dee038c6dd16554f"
|
||||
[tools.protoc-gen-go.platforms.linux-x64]
|
||||
checksum = "blake3:f765e393e0f0b25070c13848d88f5e6303e66090132afa1b753e981507a4174e"
|
||||
size = 1848331
|
||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
|
||||
|
||||
[tools.protoc-gen-go-grpc]
|
||||
[tools.protoc-gen-go.platforms.macos-arm64]
|
||||
checksum = "blake3:c237256a7f718ac4ccdc037ee30025a0b7c3f79300d7831ec59d76d782443fd5"
|
||||
size = 1784028
|
||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.arm64.tar.gz"
|
||||
|
||||
[[tools.protoc-gen-go-grpc]]
|
||||
version = "1.5.1"
|
||||
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
|
||||
|
||||
[tools.protoc-gen-go-grpc.checksums]
|
||||
"protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz" = "sha256:d6083feb51dcfe59f26793e99ef01ef5eac68b64294ec2546711f614ac5878f3"
|
||||
"protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz" = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
|
||||
[tools.protoc-gen-go-grpc.platforms.linux-x64]
|
||||
checksum = "blake3:197739132f0408f017d0c83773ac1ace85c0572473fdd658ec6e4ccbf20e0552"
|
||||
size = 4859466
|
||||
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"
|
||||
|
||||
[tools.protoc-gen-go-grpc.platforms.macos-arm64]
|
||||
checksum = "blake3:3c668207565d23ea2cf006d699207e3a804ebb929a534bd38a3b4ff4e941e00c"
|
||||
size = 4565944
|
||||
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"
|
||||
|
||||
+4
-2
@@ -2,8 +2,10 @@
|
||||
experimental = true
|
||||
|
||||
[tools]
|
||||
go = "1.23"
|
||||
golangci-lint = "2.2.2"
|
||||
"aqua:vektra/mockery" = "3.5.3"
|
||||
go = "1.25.1"
|
||||
golangci-lint = "2.4.0"
|
||||
node = "22.20.0"
|
||||
protoc = "27.3"
|
||||
protoc-gen-go = "1.34.2"
|
||||
protoc-gen-go-grpc = "1.5.1"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
github.com/psviderski/uncloud/internal/machine/caddyconfig:
|
||||
interfaces:
|
||||
CaddyfileValidator:
|
||||
@@ -0,0 +1,258 @@
|
||||
# AI.md - Uncloud Project Guide
|
||||
|
||||
This document provides comprehensive information about the Uncloud project for AI assistants to understand the codebase, architecture, and development practices.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Uncloud** is a lightweight clustering and container orchestration tool that enables deployment and management of web applications across cloud VMs and bare metal servers. It creates a secure WireGuard mesh network between Docker hosts and provides automatic service discovery, load balancing, HTTPS ingress, and simple CLI commands for application management.
|
||||
|
||||
### Key Characteristics
|
||||
|
||||
- **Language**: Go
|
||||
- **Architecture**: Decentralized, no control plane
|
||||
- **Target**: Self-hosted infrastructure without Kubernetes complexity
|
||||
- **License**: View LICENSE file for details
|
||||
- **Status**: Active development, not yet ready for production
|
||||
|
||||
## Core Features
|
||||
|
||||
### 🏗️ Infrastructure
|
||||
|
||||
- **Multi-machine deployment**: Combine cloud VMs, dedicated servers, and bare metal
|
||||
- **Zero-config networking**: Automatic WireGuard mesh with NAT traversal
|
||||
- **Decentralized design**: No central control plane, all machines are equal
|
||||
- **Service discovery**: Built-in DNS server resolves service names to container IPs
|
||||
|
||||
### 🚀 Application Management
|
||||
|
||||
- **Docker Compose compatibility**: Uses familiar Docker Compose format
|
||||
- **Zero-downtime deployments**: Rolling updates without service interruption
|
||||
- **Automatic HTTPS**: Caddy reverse proxy with Let's Encrypt integration
|
||||
- **Managed DNS**: Free `*.cluster.uncloud.run` subdomains via Uncloud DNS service
|
||||
- **Cross-machine scaling**: Run containers across multiple machines
|
||||
|
||||
### 🔧 Developer Experience
|
||||
|
||||
- **Docker-like CLI**: Familiar commands (`uc` binary)
|
||||
- **Imperative operations**: Direct commands vs. declarative state reconciliation
|
||||
- **Remote management**: Control entire infrastructure via SSH to any machine
|
||||
- **Minimal overhead**: ~150MB RAM footprint per machine
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **CLI (`uc`)** - Main user interface for cluster management
|
||||
2. **Daemon (`uncloudd`)** - Machine daemon running on each node
|
||||
3. **Corrosion** - Distributed SQLite database for cluster state (Fly.io project)
|
||||
4. **Caddy** - Reverse proxy for HTTPS termination and routing
|
||||
5. **WireGuard** - Secure mesh networking between machines
|
||||
|
||||
### Network Architecture
|
||||
|
||||
- Each machine gets unique subnet (e.g., `10.210.0.0/24`, `10.210.1.0/24`)
|
||||
- Containers get cluster-unique IPs for direct communication
|
||||
- Automatic peer discovery and key management
|
||||
- NAT traversal for machines behind firewalls
|
||||
|
||||
### State Management
|
||||
|
||||
- **CRDT-based distributed storage** using Corrosion
|
||||
- **Eventually consistent** state across all machines
|
||||
- **Gossip protocol** (Serf) for state propagation
|
||||
- **No quorum requirements** - partial network splits remain functional
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Key Directories
|
||||
|
||||
- **`cmd/`**: Contains main applications
|
||||
|
||||
- `uncloud/`: CLI tool with subcommands for machine, service, volume management
|
||||
- `uncloudd/`: Daemon that runs on each machine
|
||||
- `ucind/`: Development cluster management for testing
|
||||
|
||||
- **`internal/`**: Internal implementation packages
|
||||
|
||||
- `cli/`: Command-line interface logic
|
||||
- `machine/`: Machine lifecycle and state management
|
||||
- `daemon/`: Daemon implementation and gRPC services
|
||||
- `dns/`: Internal DNS server for service discovery
|
||||
|
||||
- **`pkg/`**: Public API packages for external use
|
||||
|
||||
- `api/`: Core API types and definitions
|
||||
- `client/`: Client libraries for interacting with Uncloud
|
||||
|
||||
- **`experiment/`**: Experimental features and prototypes
|
||||
- **`scripts/`**: Installation and utility scripts
|
||||
- **`test/`**: Test suites and test infrastructure
|
||||
- **`website/`**: Documentation website (Docusaurus)
|
||||
|
||||
- `landing/`: Landing page
|
||||
- `docs/`: User documentation
|
||||
|
||||
- **`misc/`**: Design documents and guides
|
||||
|
||||
## Key Technologies
|
||||
|
||||
### Core Dependencies
|
||||
|
||||
```go
|
||||
// Networking and orchestration
|
||||
github.com/docker/docker // Docker API client
|
||||
github.com/docker/compose/v2 // Docker Compose integration
|
||||
golang.zx2c4.com/wireguard // WireGuard implementation
|
||||
github.com/hashicorp/serf // Gossip protocol
|
||||
|
||||
// State management
|
||||
github.com/ipfs/go-ds-crdt // CRDT distributed storage
|
||||
github.com/dgraph-io/badger/v3 // Embedded database
|
||||
|
||||
// Web proxy
|
||||
github.com/caddyserver/caddy/v2 // HTTP server and reverse proxy
|
||||
|
||||
// CLI and UX
|
||||
github.com/spf13/cobra // CLI framework
|
||||
github.com/charmbracelet/huh // Interactive forms
|
||||
|
||||
// gRPC and networking
|
||||
google.golang.org/grpc // gRPC framework
|
||||
github.com/siderolabs/grpc-proxy // gRPC proxy for forwarding
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Build and Development
|
||||
|
||||
```bash
|
||||
# Build binaries
|
||||
go build -o uncloud ./cmd/uncloud
|
||||
go build -o uncloudd ./cmd/uncloudd
|
||||
```
|
||||
|
||||
### Key Make Targets
|
||||
|
||||
- `proto`: Generate protobuf code
|
||||
- `ucind-cluster`: Create development cluster
|
||||
- `update-dev`: Deploy to development machines
|
||||
- `demo-reset`: Reset demo environment
|
||||
- `fmt`: Format code
|
||||
- `test`: Run all tests
|
||||
- `lint`: Lint the code using golangci-lint
|
||||
- `lint-and-fix`: Lint the code and fix issues whenever possible
|
||||
|
||||
## CLI Commands Structure
|
||||
|
||||
The `uc` CLI provides these main command groups:
|
||||
|
||||
### Machine Management
|
||||
|
||||
```bash
|
||||
uc machine init <user@host> # Initialize new cluster
|
||||
uc machine add <user@host> # Add machine to cluster
|
||||
uc machine ls # List machines
|
||||
uc machine rm <name> # Remove machine
|
||||
```
|
||||
|
||||
### Service Management
|
||||
|
||||
```bash
|
||||
uc run <image> # Run container from image
|
||||
uc deploy # Deploy from compose.yaml
|
||||
uc scale <service> <count> # Scale service replicas
|
||||
uc ls # List services
|
||||
uc rm <service> # Remove service
|
||||
```
|
||||
|
||||
### Context and Connectivity
|
||||
|
||||
```bash
|
||||
uc context ls # List available contexts
|
||||
uc context use <name> # Switch context
|
||||
```
|
||||
|
||||
### Global Flags
|
||||
|
||||
- `--connect`: Connect to remote machine directly, without a config file
|
||||
- `--uncloud-config`: Override config file path
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Code Organization
|
||||
|
||||
- **Package naming**: Use clear, descriptive names
|
||||
- **Error handling**: Wrap errors with context using `fmt.Errorf`
|
||||
- **Logging**: Use structured logging with levels
|
||||
- **gRPC**: Services defined in `internal/machine/api/pb/`
|
||||
|
||||
### Testing
|
||||
|
||||
- Test files and locations
|
||||
- Unit tests alongside source files (`*_test.go`)
|
||||
- Integration tests in `test/e2e/`
|
||||
- Test fixtures in `test/fixtures/`
|
||||
- Use table driven tests whenever possible
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Prefer standard library when possible
|
||||
- Pin versions in `go.mod`
|
||||
- Document rationale for external dependencies
|
||||
|
||||
### Configuration
|
||||
|
||||
- Support environment variables for key settings
|
||||
- Validate configuration early
|
||||
- Provide sensible defaults
|
||||
|
||||
## Troubleshooting and Debugging
|
||||
|
||||
### Common Issues
|
||||
|
||||
- **Networking**: Check WireGuard status, iptables rules
|
||||
- **DNS**: Verify service discovery resolution
|
||||
- **Containers**: Use standard Docker debugging tools
|
||||
- **State sync**: Check Corrosion logs for replication issues
|
||||
|
||||
### Debugging Tools
|
||||
|
||||
- Standard Linux networking tools (`ping`, `traceroute`, `wireshark`)
|
||||
- Docker commands (`docker ps`, `docker logs`)
|
||||
- SSH access to machines for direct inspection
|
||||
- gRPC debugging tools
|
||||
|
||||
### Logs and Monitoring
|
||||
|
||||
- Systemd services (getting logs via `journalctl -u SERVICE_NAME`)
|
||||
- `uncloud` -- Uncloud daemon
|
||||
- `uncloud-corrosion` -- Corrosion process
|
||||
- Machine daemon logs
|
||||
- Container logs via Docker
|
||||
|
||||
## File Patterns and Conventions
|
||||
|
||||
### Important Files to Understand
|
||||
|
||||
- `cmd/uncloud/main.go`: CLI entry point and command structure
|
||||
- `internal/cli/cli.go`: CLI implementation and configuration
|
||||
- `internal/machine/machine.go`: Core machine management
|
||||
- `pkg/api/`: Public API definitions
|
||||
- `misc/design.md`: Architecture and design philosophy
|
||||
- `README.md`: Repository README
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- `go.mod/go.sum`: Go dependency management
|
||||
- `Makefile`: Build and development tasks
|
||||
- `Dockerfile`: Container build instructions forUncloud-in-Docker (used for testing)
|
||||
|
||||
This document should help AI assistants understand the project structure, make informed suggestions, and contribute effectively to the Uncloud codebase.
|
||||
|
||||
## Documentation
|
||||
|
||||
Instructions when generating documentation:
|
||||
|
||||
- Use conversational language – write as if you were speaking to a friend.
|
||||
- Keep sentences simple, optimize for clarity and understanding.
|
||||
- Place the subject before the action whenever possible. Example: prefer “The function loads data” over “Data is loaded by the function.”
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
ARG ALPINE_VERSION=3.20.3
|
||||
|
||||
FROM golang:1.23.2-alpine AS uncloudd
|
||||
FROM golang:1.25.1-alpine AS uncloudd
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -38,7 +38,7 @@ demo-reset:
|
||||
|
||||
.PHONY: ucind-cluster
|
||||
ucind-cluster:
|
||||
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m 3
|
||||
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m $(if $(MACHINES_COUNT),$(MACHINES_COUNT),3)
|
||||
|
||||
.PHONY: proto
|
||||
proto:
|
||||
@@ -66,6 +66,10 @@ ucind-image:
|
||||
ucind-multiarch-image-push:
|
||||
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
|
||||
|
||||
.PHONY: mocks
|
||||
mocks:
|
||||
@mockery
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
ifeq ($(TEST_NAME),)
|
||||
@@ -111,3 +115,6 @@ _lint:
|
||||
# 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/uncloud docs
|
||||
|
||||
@@ -323,6 +323,15 @@ SQLite database used to share Uncloud's cluster state.
|
||||
features, and be the first to know when it's ready for production use.
|
||||
* Watch this repository for releases.
|
||||
|
||||
## 💖 Sponsors
|
||||
|
||||
These companies and projects are helping Uncloud with their generous sponsorship and/or services:
|
||||
|
||||
<!-- Sentry -->
|
||||
<a href="https://sentry.io/welcome/">
|
||||
<img height="100" alt="Sentry" src="https://github.com/user-attachments/assets/6c1439c0-d20d-40dc-a669-c9aa94651dfa" />
|
||||
</a>
|
||||
|
||||
## ❤️ Contributors
|
||||
|
||||
Thank you [@cedws](https://github.com/cedws) for being the first contributor to Uncloud! 🎉
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/alecthomas/chroma/v2/quick"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type configOptions struct {
|
||||
machine string
|
||||
noColor bool
|
||||
context string
|
||||
}
|
||||
|
||||
func NewConfigCommand() *cobra.Command {
|
||||
opts := configOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Show the current Caddy configuration (Caddyfile).",
|
||||
Long: "Display the current Caddy configuration (Caddyfile) from the connected machine or a specified one.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return runConfig(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||
"Name or ID of the machine to get the configuration from. (default is connected machine)")
|
||||
cmd.Flags().BoolVar(&opts.noColor, "no-color", false,
|
||||
"Disable syntax highlighting for the output.")
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
if opts.machine != "" {
|
||||
// If a specific machine is requested, use it to get the Caddy configuration.
|
||||
ctx, _, err = api.ProxyMachinesContext(ctx, clusterClient, []string{opts.machine})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
config, err := clusterClient.Caddy.GetConfig(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get Caddy config: %w", err)
|
||||
}
|
||||
|
||||
// Print the Caddyfile with syntax highlighting.
|
||||
if opts.noColor {
|
||||
fmt.Print(config.Caddyfile)
|
||||
} else {
|
||||
if err = quick.Highlight(os.Stdout, config.Caddyfile, "caddy", "terminal256", "monokai"); err != nil {
|
||||
// If highlighting fails, fall back to plain output.
|
||||
fmt.Print(config.Caddyfile)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -18,9 +19,10 @@ import (
|
||||
)
|
||||
|
||||
type deployOptions struct {
|
||||
image string
|
||||
machines []string
|
||||
context string
|
||||
caddyfile string
|
||||
image string
|
||||
machines []string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewDeployCommand() *cobra.Command {
|
||||
@@ -37,6 +39,8 @@ func NewDeployCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
|
||||
"Path to a custom global Caddy config (Caddyfile) that will be prepended to the auto-generated Caddy config.")
|
||||
cmd.Flags().StringVar(&opts.image, "image", "",
|
||||
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
|
||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||
@@ -51,6 +55,15 @@ func NewDeployCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
caddyfile := ""
|
||||
if opts.caddyfile != "" {
|
||||
data, err := os.ReadFile(opts.caddyfile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Caddyfile: %w", err)
|
||||
}
|
||||
caddyfile = strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
@@ -91,7 +104,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
placement := api.Placement{
|
||||
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||
}
|
||||
d, err := clusterClient.NewCaddyDeployment(opts.image, placement)
|
||||
d, err := clusterClient.NewCaddyDeployment(opts.image, caddyfile, placement)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create caddy deployment: %w", err)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ func NewRootCommand() *cobra.Command {
|
||||
Short: "Manage Caddy reverse proxy service.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewConfigCommand(),
|
||||
NewDeployCommand(),
|
||||
)
|
||||
return cmd
|
||||
|
||||
@@ -12,8 +12,6 @@ import (
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
var clusterContext string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
@@ -24,11 +22,6 @@ func NewListCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&clusterContext, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -51,7 +44,7 @@ func list(uncli *cli.CLI) error {
|
||||
for _, name := range contextNames {
|
||||
current := ""
|
||||
if name == currentContext {
|
||||
current = "*"
|
||||
current = "✓"
|
||||
}
|
||||
connCount := len(uncli.Config.Contexts[name].Connections)
|
||||
fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
|
||||
|
||||
+33
-12
@@ -21,6 +21,8 @@ type deployOptions struct {
|
||||
profiles []string
|
||||
services []string
|
||||
noBuild bool
|
||||
recreate bool
|
||||
yes bool
|
||||
|
||||
context string
|
||||
}
|
||||
@@ -32,6 +34,8 @@ func NewDeployCommand() *cobra.Command {
|
||||
Use: "deploy [FLAGS] [SERVICE...]",
|
||||
Short: "Deploy services from a Compose file.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
if len(args) > 0 {
|
||||
@@ -42,14 +46,19 @@ func NewDeployCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||
"One or more Compose files to deploy services from. (default compose.yaml)")
|
||||
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
||||
"One or more Compose profiles to enable.")
|
||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||
"Name of the cluster context to deploy to (default is the current context)")
|
||||
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||
"One or more Compose files to deploy services from. (default compose.yaml)")
|
||||
cmd.Flags().BoolVarP(&opts.noBuild, "no-build", "n", false,
|
||||
"Do not build images before deploying services. (default false)")
|
||||
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
||||
"One or more Compose profiles to enable.")
|
||||
cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
|
||||
"Recreate containers even if their configuration and image haven't changed.")
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Auto-confirm deployment plan. Should be explicitly set when running non-interactively,\n"+
|
||||
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||
|
||||
// TODO: Consider adding a filter flag to specify which machines to deploy to but keep the rest running.
|
||||
// Could be useful to test a new version on a subset of machines before rolling out to all.
|
||||
@@ -108,7 +117,11 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
composeDeploy, err := compose.NewDeployment(ctx, clusterClient, project)
|
||||
var strategy deploy.Strategy
|
||||
if opts.recreate {
|
||||
strategy = &deploy.RollingStrategy{ForceRecreate: true}
|
||||
}
|
||||
composeDeploy, err := compose.NewDeploymentWithStrategy(ctx, clusterClient, project, strategy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create compose deployment: %w", err)
|
||||
}
|
||||
@@ -129,13 +142,21 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
confirmed, err := cli.Confirm()
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm deployment: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
fmt.Println("Cancelled. No changes were made.")
|
||||
return nil
|
||||
// Ask for plan confirmation before proceeding with the deployment unless auto-confirmed with --yes.
|
||||
if !opts.yes {
|
||||
if !cli.IsStdinTerminal() {
|
||||
return errors.New("cannot ask to confirm deployment plan in non-interactive mode, " +
|
||||
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
|
||||
}
|
||||
|
||||
confirmed, err := cli.Confirm()
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm deployment: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
fmt.Println("Cancelled. No changes were made.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/cobra/doc"
|
||||
)
|
||||
|
||||
const docsDir = "website/docs/9-cli-reference"
|
||||
|
||||
type cmdWrapper struct {
|
||||
cmd *cobra.Command
|
||||
}
|
||||
|
||||
// NewDocsCommand creates a new hidden command to generate CLI reference docs.
|
||||
func NewDocsCommand() *cobra.Command {
|
||||
wrapper := &cmdWrapper{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "docs",
|
||||
Short: "Generate Uncloud CLI reference docs",
|
||||
SilenceUsage: true,
|
||||
DisableFlagsInUseLine: true,
|
||||
Hidden: true,
|
||||
Args: cobra.NoArgs,
|
||||
ValidArgsFunction: cobra.NoFileCompletions,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
// Remove existing markdown files.
|
||||
mdFiles, err := filepath.Glob(filepath.Join(docsDir, "*.md"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("list existing CLI docs: %w", err)
|
||||
}
|
||||
for _, f := range mdFiles {
|
||||
if err = os.Remove(f); err != nil {
|
||||
return fmt.Errorf("remove '%s': %w", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new CLI reference docs.
|
||||
wrapper.cmd.Root().DisableAutoGenTag = true
|
||||
if err := doc.GenMarkdownTree(cmd.Root(), docsDir); err != nil {
|
||||
return fmt.Errorf("generate CLI docs: %w", err)
|
||||
}
|
||||
|
||||
// Remove *completion*.md files that contain malformatted code blocks that break Docusaurus.
|
||||
mdFiles, err = filepath.Glob(filepath.Join(docsDir, "*completion*.md"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("list generated CLI docs: %w", err)
|
||||
}
|
||||
for _, f := range mdFiles {
|
||||
if err = os.Remove(f); err != nil {
|
||||
return fmt.Errorf("remove '%s': %w", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Post-process generated markdown files.
|
||||
mdFiles, err = filepath.Glob(filepath.Join(docsDir, "*.md"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("list generated CLI docs: %w", err)
|
||||
}
|
||||
|
||||
for _, f := range mdFiles {
|
||||
if err = postProcessMarkdown(f); err != nil {
|
||||
return fmt.Errorf("post-process '%s': %w", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
wrapper.cmd = cmd
|
||||
return cmd
|
||||
}
|
||||
|
||||
// postProcessMarkdown applies transformations to generated markdown files.
|
||||
func postProcessMarkdown(filename string) error {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
// Replace "SEE ALSO" with "See also".
|
||||
content = strings.ReplaceAll(content, "SEE ALSO", "See also")
|
||||
// Escape <id> to avoid Docusaurus treating it as an HTML tag.
|
||||
content = strings.ReplaceAll(content, "<id>", "\\<id>")
|
||||
|
||||
// Remove broken links to completion docs.
|
||||
if strings.Contains(content, "[uc completion") {
|
||||
lines := strings.Split(content, "\n")
|
||||
var filteredLines []string
|
||||
for _, line := range lines {
|
||||
if !strings.Contains(line, "[uc completion") {
|
||||
filteredLines = append(filteredLines, line)
|
||||
}
|
||||
}
|
||||
content = strings.Join(filteredLines, "\n")
|
||||
}
|
||||
|
||||
// Adjust heading levels. Process from shortest to longest to avoid double replacements.
|
||||
replacements := []struct {
|
||||
old, new string
|
||||
}{
|
||||
{`(?m)^## `, `# `},
|
||||
{`(?m)^### `, `## `},
|
||||
{`(?m)^#### `, `### `},
|
||||
{`(?m)^##### `, `#### `},
|
||||
}
|
||||
|
||||
for _, r := range replacements {
|
||||
re := regexp.MustCompile(r.old)
|
||||
content = re.ReplaceAllString(content, r.new)
|
||||
}
|
||||
|
||||
return os.WriteFile(filename, []byte(content), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
"github.com/containerd/platforms"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/muesli/termenv"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type listOptions struct {
|
||||
machines []string
|
||||
nameFilter string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
opts := listOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls [REPO:[TAG]]",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List images on machines in the cluster.",
|
||||
Long: "List images on machines in the cluster. By default, on all machines. Optionally filter by image name.",
|
||||
Example: ` # List all images on all machines.
|
||||
uc image ls
|
||||
|
||||
# List images on specific machine.
|
||||
uc image ls -m machine1
|
||||
|
||||
# List images on multiple machines.
|
||||
uc image ls -m machine1,machine2
|
||||
|
||||
# List images filtered by name (with any tag) on all machines.
|
||||
uc image ls myapp
|
||||
|
||||
# List images filtered by name pattern on specific machine.
|
||||
uc image ls "myapp:1.*" -m machine1`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
opts.nameFilter = args[0]
|
||||
}
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return list(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||
"Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. "+
|
||||
"(default is include all machines)")
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// imageRow represents a single image with its metadata for display.
|
||||
type imageRow struct {
|
||||
id string
|
||||
name string
|
||||
platforms string
|
||||
createdHuman string
|
||||
createdUnix int64
|
||||
size string
|
||||
inUse string
|
||||
store string
|
||||
machine string
|
||||
}
|
||||
|
||||
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
// Get all machines to create ID to name mapping.
|
||||
allMachines, err := clusterClient.ListMachines(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
|
||||
machineIDToName := make(map[string]string)
|
||||
for _, machineMember := range allMachines {
|
||||
if machineMember.Machine != nil && machineMember.Machine.Id != "" && machineMember.Machine.Name != "" {
|
||||
machineIDToName[machineMember.Machine.Id] = machineMember.Machine.Name
|
||||
}
|
||||
}
|
||||
|
||||
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||
|
||||
clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{
|
||||
Machines: machines,
|
||||
Name: opts.nameFilter,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list images: %w", err)
|
||||
}
|
||||
|
||||
// Collect all images from all machines.
|
||||
var rows []imageRow
|
||||
|
||||
for _, machineImages := range clusterImages {
|
||||
// Get machine name for better readability.
|
||||
machineName := machineImages.Metadata.Machine
|
||||
if m := allMachines.FindByNameOrID(machineName); m != nil {
|
||||
machineName = m.Machine.Name
|
||||
}
|
||||
|
||||
store := "docker"
|
||||
if machineImages.ContainerdStore {
|
||||
store = "containerd"
|
||||
}
|
||||
|
||||
// Process each image for this machine.
|
||||
for _, img := range machineImages.Images {
|
||||
// Show the first 12 chars without 'sha256:' as the image ID like Docker does.
|
||||
id := strings.TrimPrefix(img.ID, "sha256:")[:12]
|
||||
|
||||
name := "<none>"
|
||||
if len(img.RepoTags) > 0 && img.RepoTags[0] != "<none>:<none>" {
|
||||
name = img.RepoTags[0]
|
||||
}
|
||||
|
||||
imgPlatforms, _ := imagePlatforms(img)
|
||||
formattedPlatforms := formatPlatforms(imgPlatforms)
|
||||
|
||||
created := ""
|
||||
createdAt := time.Unix(img.Created, 0)
|
||||
if !createdAt.IsZero() {
|
||||
created = units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
|
||||
}
|
||||
|
||||
size := units.HumanSizeWithPrecision(float64(img.Size), 3)
|
||||
|
||||
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
|
||||
inUse := "-"
|
||||
if img.Containers != -1 { // -1 means the info is not available.
|
||||
if img.Containers > 0 {
|
||||
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
|
||||
} else {
|
||||
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, imageRow{
|
||||
id: id,
|
||||
name: name,
|
||||
platforms: formattedPlatforms,
|
||||
createdHuman: created,
|
||||
createdUnix: img.Created,
|
||||
size: size,
|
||||
inUse: inUse,
|
||||
store: store,
|
||||
machine: machineName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
if opts.nameFilter != "" {
|
||||
fmt.Printf("No images matching '%s' found.\n", opts.nameFilter)
|
||||
} else {
|
||||
fmt.Println("No images found.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort images by name, then by machine name.
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].name != rows[j].name {
|
||||
return rows[i].name < rows[j].name
|
||||
}
|
||||
return rows[i].machine < rows[j].machine
|
||||
})
|
||||
|
||||
// Print the images in a table format.
|
||||
fmt.Println(formatImageTable(rows))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform.
|
||||
func imagePlatforms(img image.Summary) ([]string, bool) {
|
||||
var formattedPlatforms []string
|
||||
multiPlatform := false
|
||||
|
||||
for _, m := range img.Manifests {
|
||||
if m.Kind != image.ManifestKindImage || !m.Available {
|
||||
continue
|
||||
}
|
||||
|
||||
if m.ID != img.ID {
|
||||
// There is an image manifest that has digest different from the main image digest.
|
||||
// This means the image manifest is an index or a manifest list (multi-platform image).
|
||||
multiPlatform = true
|
||||
}
|
||||
formattedPlatforms = append(formattedPlatforms, platforms.Format(m.ImageData.Platform))
|
||||
}
|
||||
|
||||
slices.Sort(formattedPlatforms)
|
||||
|
||||
return formattedPlatforms, multiPlatform
|
||||
}
|
||||
|
||||
func formatPlatforms(platforms []string) string {
|
||||
if len(platforms) == 0 {
|
||||
return "-"
|
||||
}
|
||||
|
||||
platformStyle := lipgloss.NewStyle().
|
||||
BorderForeground(lipgloss.Color("152")).
|
||||
Foreground(lipgloss.Color("0")).
|
||||
Background(lipgloss.Color("152"))
|
||||
// Use fancy pill borders only if the output is a terminal with color support.
|
||||
if lipgloss.ColorProfile() != termenv.Ascii {
|
||||
platformStyle = platformStyle.Border(lipgloss.Border{Left: "", Right: ""}, false, true, false, true)
|
||||
}
|
||||
|
||||
styledPlatforms := make([]string, len(platforms))
|
||||
for i, p := range platforms {
|
||||
styledPlatforms[i] = platformStyle.Render(p)
|
||||
}
|
||||
|
||||
return strings.Join(styledPlatforms, " ")
|
||||
}
|
||||
|
||||
func formatImageTable(rows []imageRow) string {
|
||||
columns := []struct {
|
||||
name string
|
||||
hide bool
|
||||
}{
|
||||
{name: "IMAGE ID"},
|
||||
{name: "NAME"},
|
||||
{name: "PLATFORMS"},
|
||||
{name: "CREATED"},
|
||||
{name: "SIZE"},
|
||||
{name: "IN USE"},
|
||||
{name: "STORE"},
|
||||
{name: "MACHINE"},
|
||||
}
|
||||
|
||||
// Hide the "IN USE" column if none of the images have that info available.
|
||||
inUseInfoAvailable := slices.ContainsFunc(rows, func(r imageRow) bool {
|
||||
return r.inUse != "-"
|
||||
})
|
||||
if !inUseInfoAvailable {
|
||||
// Hide "IN USE" column.
|
||||
columns[5].hide = true
|
||||
}
|
||||
|
||||
t := table.New().
|
||||
// Remove the default border.
|
||||
Border(lipgloss.Border{}).
|
||||
BorderTop(false).
|
||||
BorderBottom(false).
|
||||
BorderLeft(false).
|
||||
BorderRight(false).
|
||||
BorderHeader(false).
|
||||
BorderColumn(false).
|
||||
StyleFunc(func(row, col int) lipgloss.Style {
|
||||
if row == table.HeaderRow {
|
||||
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
|
||||
}
|
||||
// Regular style for data rows with padding.
|
||||
return lipgloss.NewStyle().PaddingRight(3)
|
||||
})
|
||||
|
||||
var headers []string
|
||||
for _, col := range columns {
|
||||
if !col.hide {
|
||||
headers = append(headers, col.name)
|
||||
}
|
||||
}
|
||||
t.Headers(headers...)
|
||||
|
||||
for _, row := range rows {
|
||||
values := []string{
|
||||
row.id,
|
||||
row.name,
|
||||
row.platforms,
|
||||
row.createdHuman,
|
||||
row.size,
|
||||
row.inUse,
|
||||
row.store,
|
||||
row.machine,
|
||||
}
|
||||
var filteredValues []string
|
||||
for i, v := range values {
|
||||
if !columns[i].hide {
|
||||
filteredValues = append(filteredValues, v)
|
||||
}
|
||||
}
|
||||
t.Row(filteredValues...)
|
||||
}
|
||||
|
||||
return t.String()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/containerd/platforms"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type pushOptions struct {
|
||||
image string
|
||||
machines []string
|
||||
context string
|
||||
platform string
|
||||
}
|
||||
|
||||
func NewPushCommand() *cobra.Command {
|
||||
opts := pushOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "push IMAGE",
|
||||
Short: "Upload a local Docker image to the cluster.",
|
||||
Long: `Upload a local Docker image to the cluster transferring only the missing layers.
|
||||
The image is uploaded to the machine which CLI is connected to (default) or the specified machine(s).`,
|
||||
Example: ` # Push image to the machine the CLI is connected to.
|
||||
uc image push myapp:latest
|
||||
|
||||
# Push image to specific machine.
|
||||
uc image push myapp:latest -m machine1
|
||||
|
||||
# Push image to multiple machines.
|
||||
uc image push myapp:latest -m machine1,machine2,machine3
|
||||
|
||||
# Push image to all machines in the cluster.
|
||||
uc image push myapp:latest -m all
|
||||
|
||||
# Push a specific platform of a multi-platform image.
|
||||
uc image push myapp:latest --platform linux/amd64`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
opts.image = args[0]
|
||||
return push(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||
"Machine names to push the image to. Can be specified multiple times or as a comma-separated "+
|
||||
"list of machine names.\n"+
|
||||
"Use 'all' to push to all machines. (default is connected machine)")
|
||||
cmd.Flags().StringVar(
|
||||
&opts.platform, "platform", "",
|
||||
"Push a specific platform of a multi-platform image (e.g., linux/amd64, linux/arm64).\n"+
|
||||
"Local Docker must be configured to use containerd image store to support multi-platform images.",
|
||||
)
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func push(ctx context.Context, uncli *cli.CLI, opts pushOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||
pushOpts := client.PushImageOptions{}
|
||||
|
||||
// Special handling for "all" keyword to push to all machines.
|
||||
if len(machines) == 1 && machines[0] == "all" {
|
||||
pushOpts.AllMachines = true
|
||||
} else {
|
||||
pushOpts.Machines = machines
|
||||
}
|
||||
|
||||
if opts.platform != "" {
|
||||
p, err := platforms.Parse(opts.platform)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid platform '%s': %w", opts.platform, err)
|
||||
}
|
||||
pushOpts.Platform = &p
|
||||
}
|
||||
|
||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if err = clusterClient.PushImage(ctx, opts.image, pushOpts); err != nil {
|
||||
return fmt.Errorf("push image to cluster: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", opts.image))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "image",
|
||||
Short: "Manage images on machines in the cluster.",
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
NewListCommand(),
|
||||
NewPushCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/image"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewImagesCommand returns the 'image ls' command modified to work as 'images'.
|
||||
func NewImagesCommand() *cobra.Command {
|
||||
listCmd := image.NewListCommand()
|
||||
listCmd.Use = "images [IMAGE]"
|
||||
// Remove 'list' alias since this command is already an alias.
|
||||
listCmd.Aliases = nil
|
||||
listCmd.Example = strings.ReplaceAll(listCmd.Example, "uc image ls", "uc images")
|
||||
|
||||
return listCmd
|
||||
}
|
||||
@@ -20,12 +20,13 @@ import (
|
||||
)
|
||||
|
||||
type addOptions struct {
|
||||
name string
|
||||
noCaddy bool
|
||||
publicIP string
|
||||
sshKey string
|
||||
context string
|
||||
version string
|
||||
name string
|
||||
noCaddy bool
|
||||
noInstall bool
|
||||
publicIP string
|
||||
sshKey string
|
||||
context string
|
||||
version string
|
||||
}
|
||||
|
||||
func NewAddCommand() *cobra.Command {
|
||||
@@ -56,6 +57,11 @@ func NewAddCommand() *cobra.Command {
|
||||
&opts.noCaddy, "no-caddy", false,
|
||||
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noInstall, "no-install", false,
|
||||
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
|
||||
"Assumes they're already installed and running.",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.publicIP, "public-ip", "auto",
|
||||
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
||||
@@ -98,6 +104,7 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
|
||||
MachineName: opts.name,
|
||||
PublicIP: publicIP,
|
||||
RemoteMachine: remoteMachine,
|
||||
SkipInstall: opts.noInstall,
|
||||
Version: opts.version,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -146,7 +153,7 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
|
||||
|
||||
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
||||
// that may cause a small downtime.
|
||||
d, err := clusterClient.NewCaddyDeployment(caddyImage, api.Placement{})
|
||||
d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create caddy deployment: %w", err)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type initOptions struct {
|
||||
network string
|
||||
noCaddy bool
|
||||
noDNS bool
|
||||
noInstall bool
|
||||
publicIP string
|
||||
sshKey string
|
||||
version string
|
||||
@@ -33,7 +34,20 @@ func NewInitCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "init [USER@HOST:PORT]",
|
||||
Short: "Initialise a new cluster with a remote machine as the first member.",
|
||||
// TODO: include usage examples of initialising a remote machine.
|
||||
Long: "Initialise a new cluster by setting up a remote machine as the first member.\n" +
|
||||
"This command creates a new context in your Uncloud config to manage the cluster.",
|
||||
Example: ` # Initialise a new cluster with default settings.
|
||||
uc machine init root@<your-server-ip>
|
||||
|
||||
# Initialise with a context name 'prod' in the Uncloud config (~/.config/uncloud/config.yaml) and machine name 'vps1'.
|
||||
uc machine init root@<your-server-ip> -c prod -n vps1
|
||||
|
||||
# Initialise with a non-root user and custom SSH port and key.
|
||||
uc machine init ubuntu@<your-server-ip>:2222 -i ~/.ssh/mykey
|
||||
|
||||
# Initialise without Caddy (no reverse proxy) and without an automatically managed domain name (xxxxxx.cluster.uncloud.run).
|
||||
# You can deploy Caddy with 'uc caddy deploy' and reserve a domain with 'uc dns reserve' later.
|
||||
uc machine init root@<your-server-ip> --no-caddy --no-dns`,
|
||||
// TODO: support initialising a cluster on the local machine.
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -68,11 +82,16 @@ func NewInitCommand() *cobra.Command {
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noCaddy, "no-caddy", false,
|
||||
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||
"Don't deploy Caddy reverse proxy service to the machine. You can deploy it later with 'uc caddy deploy'.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noDNS, "no-dns", false,
|
||||
"Don't reserve a cluster domain in Uncloud DNS.",
|
||||
"Don't reserve a cluster domain in Uncloud DNS. You can reserve it later with 'uc dns reserve'.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noInstall, "no-install", false,
|
||||
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
|
||||
"Assumes they're already installed and running.",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.publicIP, "public-ip", "auto",
|
||||
@@ -89,8 +108,8 @@ func NewInitCommand() *cobra.Command {
|
||||
"Version of the Uncloud daemon to install on the machine.",
|
||||
)
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", "default",
|
||||
"Name of the created context for the initialised cluster in the Uncloud config.",
|
||||
&opts.context, "context", "c", cli.DefaultContextName,
|
||||
"Name of the new context to be created in the Uncloud config to manage the cluster.",
|
||||
)
|
||||
|
||||
return cmd
|
||||
@@ -121,6 +140,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
||||
Network: netPrefix,
|
||||
PublicIP: publicIP,
|
||||
RemoteMachine: remoteMachine,
|
||||
SkipInstall: opts.noInstall,
|
||||
Version: opts.version,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -147,7 +167,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
||||
}
|
||||
|
||||
if !opts.noCaddy {
|
||||
d, err := client.NewCaddyDeployment("", api.Placement{})
|
||||
d, err := client.NewCaddyDeployment("", "", api.Placement{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create caddy deployment: %w", err)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||
// Print the list of machines in a table format.
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
// Print header.
|
||||
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS"); err != nil {
|
||||
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS\tMACHINE ID"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
// Print rows.
|
||||
@@ -68,8 +68,8 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||
}
|
||||
|
||||
if _, err = fmt.Fprintf(
|
||||
tw, "%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
||||
strings.Join(endpoints, ", "),
|
||||
tw, "%s\t%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
||||
strings.Join(endpoints, ", "), member.Machine.Id,
|
||||
); err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
|
||||
+11
-5
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/image"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/machine"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/service"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/volume"
|
||||
@@ -27,12 +28,15 @@ type globalOptions struct {
|
||||
func main() {
|
||||
opts := globalOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "uncloud",
|
||||
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
||||
Use: "uc",
|
||||
Short: "A CLI tool for managing Uncloud resources such as machines, services, and volumes.",
|
||||
Version: version.String(),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli.BindEnvToFlag(cmd, "connect", "UNCLOUD_CONNECT")
|
||||
cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG")
|
||||
|
||||
var conn *config.MachineConnection
|
||||
if opts.connect != "" {
|
||||
if strings.HasPrefix(opts.connect, "tcp://") {
|
||||
@@ -65,20 +69,22 @@ func main() {
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
||||
"Connect to a remote cluster machine without using the Uncloud configuration file.\n"+
|
||||
"Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+
|
||||
"Format: [ssh://]user@host[:port] or tcp://host:port")
|
||||
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
||||
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||
"Path to the Uncloud configuration file.")
|
||||
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
|
||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||
// TODO: make --context a global flag and pass it as a value of the command context.
|
||||
|
||||
cmd.AddCommand(
|
||||
NewDeployCommand(),
|
||||
NewDocsCommand(),
|
||||
NewBuildCommand(),
|
||||
NewImagesCommand(),
|
||||
caddy.NewRootCommand(),
|
||||
cmdcontext.NewRootCommand(),
|
||||
dns.NewRootCommand(),
|
||||
image.NewRootCommand(),
|
||||
machine.NewRootCommand(),
|
||||
service.NewRootCommand(),
|
||||
service.NewInspectCommand(),
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
type inspectOptions struct {
|
||||
service string
|
||||
cluster string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewInspectCommand() *cobra.Command {
|
||||
@@ -31,14 +31,14 @@ func NewInspectCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "context", "c", "",
|
||||
&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
|
||||
+10
-10
@@ -12,26 +12,25 @@ import (
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
// TODO(lhf): rename to context
|
||||
var cluster string
|
||||
var contextName string
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List services.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return list(cmd.Context(), uncli, cluster)
|
||||
return list(cmd.Context(), uncli, contextName)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(
|
||||
&cluster, "context", "c", "",
|
||||
&contextName, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||
client, err := uncli.ConnectCluster(ctx, clusterName)
|
||||
func list(ctx context.Context, uncli *cli.CLI, contextName string) error {
|
||||
client, err := uncli.ConnectCluster(ctx, contextName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
@@ -61,19 +60,20 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tENDPOINTS"); err != nil {
|
||||
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tIMAGE\tENDPOINTS"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
for _, s := range services {
|
||||
endpointsSlice := s.Endpoints()
|
||||
endpoints := strings.Join(endpointsSlice, ", ")
|
||||
images := strings.Join(s.Images(), ", ")
|
||||
endpoints := strings.Join(s.Endpoints(), ", ")
|
||||
|
||||
if haveDuplicateNames {
|
||||
if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", s.Name, s.Mode, len(s.Containers), endpoints); err != nil {
|
||||
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n",
|
||||
s.Name, s.Mode, len(s.Containers), images, endpoints); err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
type rmOptions struct {
|
||||
services []string
|
||||
cluster string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewRmCommand() *cobra.Command {
|
||||
@@ -28,14 +28,14 @@ func NewRmCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "context", "c", "",
|
||||
&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
@@ -50,5 +50,5 @@ func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
||||
}, uncli.ProgressOut(), "Removing service "+s)
|
||||
}
|
||||
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
)
|
||||
|
||||
type runOptions struct {
|
||||
caddyfile string
|
||||
command []string
|
||||
cpu dockeropts.NanoCPUs
|
||||
entrypoint string
|
||||
@@ -34,7 +35,7 @@ type runOptions struct {
|
||||
user string
|
||||
volumes []string
|
||||
|
||||
cluster string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewRunCommand() *cobra.Command {
|
||||
@@ -57,6 +58,9 @@ func NewRunCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
|
||||
"Path to a custom Caddy config (Caddyfile) for the service. "+
|
||||
"Cannot be used together with non-@host published ports.")
|
||||
cmd.Flags().VarP(&opts.cpu, "cpu", "",
|
||||
"Maximum number of CPU cores a service container can use. Fractional values are allowed: "+
|
||||
"0.5 for half a core or 2.25 for two and a quarter cores.")
|
||||
@@ -82,7 +86,7 @@ func NewRunCommand() *cobra.Command {
|
||||
"Give extended privileges to service containers. This is a security risk and should be used with caution.")
|
||||
cmd.Flags().StringSliceVarP(&opts.publish, "publish", "p", nil,
|
||||
"Publish a service port to make it accessible outside the cluster. Can be specified multiple times.\n"+
|
||||
"Format: [hostname:][load_balancer_port:]container_port[/protocol] or [host_ip:]:host_port:container_port[/protocol]@host\n"+
|
||||
"Format: [hostname:]container_port[/protocol] or [host_ip:]host_port:container_port[/protocol]@host\n"+
|
||||
"Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified\n"+
|
||||
"and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.\n"+
|
||||
"Examples:\n"+
|
||||
@@ -109,7 +113,7 @@ func NewRunCommand() *cobra.Command {
|
||||
" -v /host/path:/container/path:ro Bind mount a host directory or file as read-only")
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "context", "c", "",
|
||||
&opts.context, "context", "c", "",
|
||||
"Name of the cluster context to run the service in. (default is the current context)",
|
||||
)
|
||||
|
||||
@@ -122,7 +126,7 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
@@ -161,6 +165,15 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
||||
func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
||||
var spec api.ServiceSpec
|
||||
|
||||
caddyfile := ""
|
||||
if opts.caddyfile != "" {
|
||||
data, err := os.ReadFile(opts.caddyfile)
|
||||
if err != nil {
|
||||
return spec, fmt.Errorf("read Caddyfile: %w", err)
|
||||
}
|
||||
caddyfile = strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
env, err := parseEnv(opts.env)
|
||||
if err != nil {
|
||||
return spec, err
|
||||
@@ -218,6 +231,12 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
||||
Volumes: volumes,
|
||||
}
|
||||
|
||||
if caddyfile != "" {
|
||||
spec.Caddy = &api.CaddySpec{
|
||||
Config: caddyfile,
|
||||
}
|
||||
}
|
||||
|
||||
// Overwrite the default ENTRYPOINT of the image or reset it if an empty string is passed.
|
||||
if opts.entrypoint != "" {
|
||||
spec.Container.Entrypoint = []string{opts.entrypoint}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
type scaleOptions struct {
|
||||
service string
|
||||
replicas uint
|
||||
cluster string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewScaleCommand() *cobra.Command {
|
||||
@@ -40,7 +40,7 @@ func NewScaleCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "context", "c", "",
|
||||
&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
||||
)
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
module github.com/psviderski/uncloud
|
||||
|
||||
go 1.23.0
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.0
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/alecthomas/chroma/v2 v2.20.0
|
||||
github.com/caddyserver/caddy/v2 v2.8.4
|
||||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/charmbracelet/bubbles v0.20.0
|
||||
github.com/charmbracelet/bubbletea v1.3.9
|
||||
github.com/charmbracelet/huh v0.6.0
|
||||
github.com/charmbracelet/lipgloss v0.13.0
|
||||
github.com/compose-spec/compose-go/v2 v2.4.5
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/compose-spec/compose-go/v2 v2.9.0
|
||||
github.com/containerd/errdefs v1.0.0
|
||||
github.com/containerd/platforms v1.0.0-rc.1
|
||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||
github.com/deckarep/golang-set/v2 v2.8.0
|
||||
github.com/dgraph-io/badger/v3 v3.2103.5
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/docker/cli v27.5.0+incompatible
|
||||
github.com/docker/compose/v2 v2.31.0
|
||||
github.com/docker/docker v27.4.0-rc.2+incompatible
|
||||
github.com/docker/go-connections v0.5.0
|
||||
github.com/docker/cli v28.5.0+incompatible
|
||||
github.com/docker/compose/v2 v2.40.0
|
||||
github.com/docker/docker v28.5.0+incompatible
|
||||
github.com/docker/go-connections v0.6.0
|
||||
github.com/docker/go-units v0.5.0
|
||||
github.com/goccy/go-yaml v1.17.1
|
||||
github.com/google/go-cmp v0.7.0
|
||||
@@ -35,39 +40,44 @@ require (
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/lmittmann/tint v1.0.5
|
||||
github.com/miekg/dns v1.1.65
|
||||
github.com/moby/term v0.5.0
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/moby/term v0.5.2
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/opencontainers/go-digest v1.0.0
|
||||
github.com/opencontainers/image-spec v1.1.0
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/psviderski/unregistry v0.3.1
|
||||
github.com/siderolabs/discovery-api v0.1.4
|
||||
github.com/siderolabs/discovery-client v0.1.9
|
||||
github.com/siderolabs/grpc-proxy v0.5.1
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/vishvananda/netlink v1.3.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
go.uber.org/zap v1.27.0
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/net v0.35.0
|
||||
golang.org/x/sync v0.11.0
|
||||
golang.org/x/sys v0.31.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/sync v0.17.0
|
||||
golang.org/x/sys v0.36.0
|
||||
golang.org/x/term v0.34.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576
|
||||
google.golang.org/grpc v1.68.1
|
||||
google.golang.org/protobuf v1.36.3
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a
|
||||
google.golang.org/grpc v1.74.2
|
||||
google.golang.org/protobuf v1.36.9
|
||||
modernc.org/sqlite v1.36.3
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/kms v1.19.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.6.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
|
||||
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.2.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/OneOfOne/xxhash v1.2.8 // indirect
|
||||
github.com/Microsoft/hcsshim v0.13.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
|
||||
github.com/armon/go-metrics v0.4.1 // indirect
|
||||
@@ -83,36 +93,40 @@ require (
|
||||
github.com/catppuccin/go v0.2.0 // indirect
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.20.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.1.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.3.2 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
|
||||
github.com/charmbracelet/x/term v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/chzyer/readline v1.5.1 // indirect
|
||||
github.com/cloudflare/cfssl v1.6.4 // indirect
|
||||
github.com/containerd/console v1.0.4 // indirect
|
||||
github.com/containerd/containerd v1.7.24 // indirect
|
||||
github.com/containerd/containerd/api v1.7.19 // indirect
|
||||
github.com/containerd/continuity v0.4.4 // indirect
|
||||
github.com/containerd/errdefs v0.3.0 // indirect
|
||||
github.com/containerd/cgroups/v3 v3.0.5 // indirect
|
||||
github.com/containerd/console v1.0.5 // indirect
|
||||
github.com/containerd/containerd/api v1.9.0 // indirect
|
||||
github.com/containerd/containerd/v2 v2.1.4 // indirect
|
||||
github.com/containerd/continuity v0.4.5 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/fifo v1.1.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/containerd/plugin v1.0.0 // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
||||
github.com/containerd/ttrpc v1.2.5 // indirect
|
||||
github.com/containerd/typeurl/v2 v2.2.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||
github.com/containerd/ttrpc v1.2.7 // indirect
|
||||
github.com/containerd/typeurl/v2 v2.2.3 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
||||
github.com/dgraph-io/badger v1.6.2 // indirect
|
||||
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
|
||||
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
|
||||
github.com/docker/buildx v0.18.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/distribution/v3 v3.0.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/docker/buildx v0.29.1 // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.8.2 // indirect
|
||||
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
|
||||
github.com/docker/go-metrics v0.0.1 // indirect
|
||||
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fatih/color v1.17.0 // indirect
|
||||
@@ -122,28 +136,30 @@ require (
|
||||
github.com/go-kit/kit v0.13.0 // indirect
|
||||
github.com/go-kit/log v0.2.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/gofrs/flock v0.12.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/glog v1.2.2 // indirect
|
||||
github.com/golang/glog v1.2.5 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/btree v1.1.2 // indirect
|
||||
github.com/google/cel-go v0.20.1 // indirect
|
||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/handlers v1.5.2 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
|
||||
github.com/hashicorp/go-msgpack v0.5.5 // indirect
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
@@ -151,12 +167,13 @@ require (
|
||||
github.com/hashicorp/go-syslog v1.0.0 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/arc/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/logutils v1.0.0 // indirect
|
||||
github.com/hashicorp/mdns v1.0.5 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/imdario/mergo v0.3.16 // indirect
|
||||
github.com/in-toto/in-toto-golang v0.5.0 // indirect
|
||||
github.com/in-toto/in-toto-golang v0.9.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/ipfs/bbloom v0.0.4 // indirect
|
||||
github.com/ipfs/go-block-format v0.2.0 // indirect
|
||||
@@ -176,7 +193,7 @@ require (
|
||||
github.com/jackc/pgx/v4 v4.18.3 // indirect
|
||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||
github.com/josharian/native v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
@@ -197,28 +214,28 @@ require (
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/mholt/acmez/v2 v2.0.3 // indirect
|
||||
github.com/miekg/pkcs11 v1.1.1 // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/mitchellh/cli v1.1.5 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/buildkit v0.17.2 // indirect
|
||||
github.com/moby/buildkit v0.25.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.1.0 // indirect
|
||||
github.com/moby/locker v1.0.1 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/sys/mountinfo v0.7.2 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/signal v0.7.1 // indirect
|
||||
github.com/moby/sys/user v0.3.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
|
||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||
github.com/multiformats/go-multiaddr v0.13.0 // indirect
|
||||
@@ -231,25 +248,31 @@ require (
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
||||
github.com/opencontainers/runtime-spec v1.2.1 // indirect
|
||||
github.com/opencontainers/selinux v1.12.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pires/go-proxyproto v0.7.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/polydawn/refmt v0.89.0 // indirect
|
||||
github.com/posener/complete v1.2.3 // indirect
|
||||
github.com/prometheus/client_golang v1.20.5 // indirect
|
||||
github.com/prometheus/client_golang v1.22.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.61.0 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.48.2 // indirect
|
||||
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect
|
||||
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect
|
||||
github.com/redis/go-redis/v9 v9.7.3 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/xid v1.5.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.6.0 // indirect
|
||||
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
@@ -258,59 +281,70 @@ require (
|
||||
github.com/slackhq/nebula v1.6.1 // indirect
|
||||
github.com/smallstep/certificates v0.26.1 // indirect
|
||||
github.com/smallstep/nosql v0.6.1 // indirect
|
||||
github.com/smallstep/pkcs7 v0.0.0-20231024181729-3b98ecc1ca81 // indirect
|
||||
github.com/smallstep/pkcs7 v0.1.1 // indirect
|
||||
github.com/smallstep/scep v0.0.0-20231024192529-aee96d7ad34d // indirect
|
||||
github.com/smallstep/truststore v0.13.0 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/cast v1.7.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stoewer/go-strcase v1.2.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
|
||||
github.com/theupdateframework/notary v0.7.0 // indirect
|
||||
github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205 // indirect
|
||||
github.com/tonistiigi/fsutil v0.0.0-20241028165955-397af5306b5c // indirect
|
||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect
|
||||
github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 // indirect
|
||||
github.com/tonistiigi/fsutil v0.0.0-20250605211040-586307ad452f // indirect
|
||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect
|
||||
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
|
||||
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
|
||||
github.com/urfave/cli v1.22.16 // indirect
|
||||
github.com/vbatts/tar-split v0.11.6 // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||
github.com/urfave/cli v1.22.17 // indirect
|
||||
github.com/vbatts/tar-split v0.12.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
go.etcd.io/bbolt v1.3.11 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
|
||||
go.opentelemetry.io/otel v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.33.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
||||
go.step.sm/cli-utils v0.9.0 // indirect
|
||||
go.step.sm/crypto v0.45.0 // indirect
|
||||
go.step.sm/linkedca v0.20.1 // indirect
|
||||
go.uber.org/automaxprocs v1.5.3 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap/exp v0.2.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
|
||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
|
||||
golang.org/x/mod v0.23.0 // indirect
|
||||
golang.org/x/term v0.29.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/time v0.8.0 // indirect
|
||||
golang.org/x/tools v0.30.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
||||
howett.net/plist v1.0.0 // indirect
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/cli/cli/config"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/build"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/archive"
|
||||
@@ -76,7 +76,8 @@ func buildSingleService(ctx context.Context, dockerCli *dockerclient.Client, ser
|
||||
return "", fmt.Errorf("service %s has no build configuration", service.Name)
|
||||
}
|
||||
if service.Image == "" {
|
||||
return "", fmt.Errorf("service %s has no image specified; building services without image is not supported yet", service.Name)
|
||||
return "", fmt.Errorf("service %s has no image specified; building services without image is not supported yet",
|
||||
service.Name)
|
||||
}
|
||||
|
||||
buildContextPath := service.Build.Context
|
||||
@@ -88,7 +89,7 @@ func buildSingleService(ctx context.Context, dockerCli *dockerclient.Client, ser
|
||||
return "", fmt.Errorf("failed to create build context for service %s: %w", service.Name, err)
|
||||
}
|
||||
|
||||
buildOptions := types.ImageBuildOptions{
|
||||
buildOptions := build.ImageBuildOptions{
|
||||
// TODO: Support Dockerfiles outside the build context
|
||||
// See https://github.com/docker/compose/blob/cf89fd1aa1328d5af77658ccc5a1e1b29981ae80/pkg/compose/build_classic.go#L92
|
||||
Dockerfile: service.Build.Dockerfile,
|
||||
|
||||
+69
-50
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/sshexec"
|
||||
@@ -26,7 +24,7 @@ const (
|
||||
// DefaultSSHKeyPath is the fallback location for the SSH private key when provisioning remote machines.
|
||||
// Used when no key is explicitly provided and SSH agent authentication fails.
|
||||
DefaultSSHKeyPath = "~/.ssh/id_ed25519"
|
||||
defaultContextName = "default"
|
||||
DefaultContextName = "default"
|
||||
)
|
||||
|
||||
type CLI struct {
|
||||
@@ -73,8 +71,18 @@ func (cli *CLI) SetCurrentContext(name string) error {
|
||||
// ConnectCluster connects to a cluster using the given context name or the current context if not specified.
|
||||
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
||||
func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client.Client, error) {
|
||||
return cli.ConnectClusterWithOptions(ctx, contextName, ConnectOptions{
|
||||
// Default to showing progress for CLI usage.
|
||||
ShowProgress: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ConnectClusterWithOptions connects to a cluster using the given context name and options.
|
||||
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
||||
// Options are useful when using the CLI as a library where you may want to disable visual feedback.
|
||||
func (cli *CLI) ConnectClusterWithOptions(ctx context.Context, contextName string, opts ConnectOptions) (*client.Client, error) {
|
||||
if cli.conn != nil {
|
||||
return connectCluster(ctx, *cli.conn)
|
||||
return ConnectCluster(ctx, *cli.conn, opts)
|
||||
}
|
||||
|
||||
if len(cli.Config.Contexts) == 0 {
|
||||
@@ -116,38 +124,20 @@ func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: iterate over all connections and try to connect to the cluster using the first successful connection.
|
||||
conn := cfg.Connections[0]
|
||||
|
||||
c, err := connectCluster(ctx, conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||
if conn.SSH != "" {
|
||||
user, host, port, err := conn.SSH.Parse()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err)
|
||||
// Try each connection in order until one succeeds.
|
||||
var lastErr error
|
||||
for _, conn := range cfg.Connections {
|
||||
c, err := ConnectCluster(ctx, conn, opts)
|
||||
if err == nil {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
|
||||
|
||||
sshConfig := &connector.SSHConnectorConfig{
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: keyPath,
|
||||
}
|
||||
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
||||
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
return nil, errors.New("connection configuration is invalid")
|
||||
return nil, fmt.Errorf("failed to connect to cluster context '%s': "+
|
||||
"all connections (%d) in the Uncloud config (%s) failed; last error: %w",
|
||||
contextName, len(cfg.Connections), cli.Config.Path(), lastErr)
|
||||
}
|
||||
|
||||
type InitClusterOptions struct {
|
||||
@@ -156,6 +146,7 @@ type InitClusterOptions struct {
|
||||
Network netip.Prefix
|
||||
PublicIP *netip.Addr
|
||||
RemoteMachine *RemoteMachine
|
||||
SkipInstall bool
|
||||
Version string
|
||||
}
|
||||
|
||||
@@ -170,15 +161,12 @@ func (cli *CLI) InitCluster(ctx context.Context, opts InitClusterOptions) (*clie
|
||||
}
|
||||
|
||||
func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
||||
contextName := opts.Context
|
||||
if contextName == "" {
|
||||
contextName = defaultContextName
|
||||
}
|
||||
if _, ok := cli.Config.Contexts[contextName]; ok {
|
||||
return nil, fmt.Errorf("cluster context '%s' already exists", contextName)
|
||||
contextName, err := cli.newContextName(opts.Context)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
machineClient, err := provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
||||
machineClient, err := provisionOrConnectRemoteMachine(ctx, opts.RemoteMachine, opts.SkipInstall, opts.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -250,11 +238,38 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
||||
return machineClient, nil
|
||||
}
|
||||
|
||||
// newContextName returns a unique name for a new cluster context. If the provided name is not DefaultContextName,
|
||||
// and it's already taken, an error is returned. If the name is not provided or is DefaultContextName, the first
|
||||
// available name "default[-N]" is returned.
|
||||
func (cli *CLI) newContextName(name string) (string, error) {
|
||||
if name == "" {
|
||||
name = DefaultContextName
|
||||
}
|
||||
|
||||
if _, exists := cli.Config.Contexts[name]; !exists {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// If non-default context already exists, error out.
|
||||
if name != DefaultContextName {
|
||||
return "", fmt.Errorf("cluster context '%s' already exists", name)
|
||||
}
|
||||
|
||||
// The default context already exists, generate a numbered suffix to make it unique.
|
||||
for i := 1; ; i++ {
|
||||
name = fmt.Sprintf("%s-%d", DefaultContextName, i)
|
||||
if _, exists := cli.Config.Contexts[name]; !exists {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type AddMachineOptions struct {
|
||||
Context string
|
||||
MachineName string
|
||||
PublicIP *netip.Addr
|
||||
RemoteMachine *RemoteMachine
|
||||
SkipInstall bool
|
||||
Version string
|
||||
}
|
||||
|
||||
@@ -277,7 +292,7 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
||||
}
|
||||
}()
|
||||
|
||||
machineClient, err := provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
||||
machineClient, err := provisionOrConnectRemoteMachine(ctx, opts.RemoteMachine, opts.SkipInstall, opts.Version)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -395,15 +410,16 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
||||
return c, machineClient, nil
|
||||
}
|
||||
|
||||
// provisionRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and returns
|
||||
// a machine API client to interact with the machine. The client should be closed after use by the caller.
|
||||
// provisionOrConnectRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and
|
||||
// returns a machine API client to interact with the machine. The client should be closed after use by the caller.
|
||||
// The version parameter specifies the version of the Uncloud daemon to install. If empty, the latest version is used.
|
||||
// If skipInstall is true, the installation step is skipped, and it is assumed that the Uncloud daemon and dependencies
|
||||
// are already installed and running.
|
||||
// The remoteMachine.SSHKeyPath could be updated to the default SSH key path if it is not set and the SSH agent
|
||||
// authentication fails.
|
||||
func provisionRemoteMachine(
|
||||
ctx context.Context, remoteMachine *RemoteMachine, version string,
|
||||
func provisionOrConnectRemoteMachine(
|
||||
ctx context.Context, remoteMachine *RemoteMachine, skipInstall bool, version string,
|
||||
) (*client.Client, error) {
|
||||
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
||||
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
||||
// If the SSH connection using SSH agent fails and no key path is provided, try to use the default SSH key.
|
||||
if err != nil && remoteMachine.KeyPath == "" {
|
||||
@@ -418,14 +434,17 @@ func provisionRemoteMachine(
|
||||
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
|
||||
)
|
||||
}
|
||||
exec := sshexec.NewRemote(sshClient)
|
||||
// Install and run the Uncloud daemon and dependencies on the remote machine.
|
||||
if err = provisionMachine(ctx, exec, version); err != nil {
|
||||
return nil, fmt.Errorf("provision machine: %w", err)
|
||||
|
||||
if !skipInstall {
|
||||
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
||||
exec := sshexec.NewRemote(sshClient)
|
||||
if err = provisionMachine(ctx, exec, version); err != nil {
|
||||
return nil, fmt.Errorf("provision machine: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var machineClient *client.Client
|
||||
if remoteMachine.User == "root" {
|
||||
if remoteMachine.User == "root" || skipInstall {
|
||||
// Create a machine API client over the established SSH connection to the remote machine.
|
||||
machineClient, err = client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
|
||||
} else {
|
||||
|
||||
@@ -53,6 +53,10 @@ func (c *Config) Read() error {
|
||||
|
||||
func (c *Config) Save() error {
|
||||
dir, _ := filepath.Split(c.path)
|
||||
// If dir is empty (e.g., when path is just a filename), use current directory
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create config directory '%s': %w", dir, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfig_Save(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a temporary directory for the test
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Change to temp directory so relative paths resolve correctly
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current directory: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Chdir(originalDir); err != nil {
|
||||
t.Logf("Failed to restore original directory: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
configPath string
|
||||
contextName string
|
||||
expectFileAt string // Expected file location for verification
|
||||
useAbsolutePath bool // Whether to use absolute path for expectFileAt
|
||||
}{
|
||||
{
|
||||
name: "relative path without prefix",
|
||||
configPath: "test-config.yaml",
|
||||
contextName: "test",
|
||||
},
|
||||
{
|
||||
name: "relative path with prefix",
|
||||
configPath: "./test-config-2.yaml",
|
||||
contextName: "test2",
|
||||
},
|
||||
{
|
||||
name: "absolute path",
|
||||
configPath: filepath.Join(tmpDir, "absolute-config.yaml"),
|
||||
contextName: "test3",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Failed to change to temp directory: %v", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
CurrentContext: tt.contextName,
|
||||
Contexts: map[string]*Context{
|
||||
tt.contextName: {
|
||||
Name: tt.contextName,
|
||||
},
|
||||
},
|
||||
path: tt.configPath,
|
||||
}
|
||||
|
||||
// This should not fail when saving the config
|
||||
err := cfg.Save()
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error when saving config, got: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file was created
|
||||
if _, err := os.Stat(tt.configPath); os.IsNotExist(err) {
|
||||
t.Errorf("Config file was not created at expected path: %s", tt.configPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
@@ -24,6 +25,15 @@ type MachineConnection struct {
|
||||
PublicKey secret.Secret `yaml:"public_key,omitempty"`
|
||||
}
|
||||
|
||||
func (c MachineConnection) String() string {
|
||||
if c.SSH != "" {
|
||||
return string(c.SSH)
|
||||
} else if c.TCP != nil && c.TCP.IsValid() {
|
||||
return fmt.Sprintf("tcp://%s", c.TCP)
|
||||
}
|
||||
return "unknown connection"
|
||||
}
|
||||
|
||||
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
||||
// The default user "root" and port 22 can be omitted.
|
||||
type SSHDestination string
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/psviderski/uncloud/pkg/client/connector"
|
||||
)
|
||||
|
||||
// ConnectOptions configures the behavior of cluster connection attempts.
|
||||
type ConnectOptions struct {
|
||||
// Whether to show connection progress spinner if stdout is a terminal or progress logs if not.
|
||||
ShowProgress bool
|
||||
}
|
||||
|
||||
func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts ConnectOptions) (*client.Client, error) {
|
||||
if opts.ShowProgress {
|
||||
return connectClusterWithProgress(ctx, conn)
|
||||
}
|
||||
return connectCluster(ctx, conn)
|
||||
}
|
||||
|
||||
// connectClusterWithProgress connects to the cluster while displaying a progress spinner.
|
||||
// If the stdout is not a terminal, it falls back to simple progress logs to stderr.
|
||||
func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||
// If stdout is not a terminal, fall back to simple progress logs.
|
||||
if !IsStdoutTerminal() {
|
||||
fmt.Fprintln(os.Stderr, "Connecting to", conn.String())
|
||||
cli, err := connectCluster(ctx, conn)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Connection failed:", err)
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "Connected to cluster.")
|
||||
}
|
||||
return cli, err
|
||||
}
|
||||
|
||||
// Run the connection TUI model.
|
||||
p := tea.NewProgram(newConnectModel(ctx, conn))
|
||||
model, err := p.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run connection TUI: %w", err)
|
||||
}
|
||||
|
||||
m := model.(connectModel)
|
||||
return m.result.client, m.result.err
|
||||
}
|
||||
|
||||
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||
if conn.SSH != "" {
|
||||
user, host, port, err := conn.SSH.Parse()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err)
|
||||
}
|
||||
|
||||
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
|
||||
|
||||
sshConfig := &connector.SSHConnectorConfig{
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: keyPath,
|
||||
}
|
||||
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
||||
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
||||
}
|
||||
|
||||
return nil, errors.New("connection configuration is invalid")
|
||||
}
|
||||
|
||||
// connectModel is a TUI model for connecting to a cluster with a progress spinner.
|
||||
type connectModel struct {
|
||||
ctx context.Context
|
||||
conn config.MachineConnection
|
||||
spinner spinner.Model
|
||||
// showSpinner controls whether the spinner is visible (delayed to avoid flashing).
|
||||
showSpinner bool
|
||||
// done indicates whether the connection attempt has completed (successfully or with error).
|
||||
done bool
|
||||
// result holds the result of the connection attempt.
|
||||
result connectResultMsg
|
||||
}
|
||||
|
||||
type connectResultMsg struct {
|
||||
client *client.Client
|
||||
err error
|
||||
}
|
||||
|
||||
// showSpinnerMsg is sent after a delay to show the spinner.
|
||||
type showSpinnerMsg struct{}
|
||||
|
||||
func newConnectModel(ctx context.Context, conn config.MachineConnection) connectModel {
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.MiniDot
|
||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // the same yellow as in compose progress
|
||||
|
||||
return connectModel{
|
||||
ctx: ctx,
|
||||
conn: conn,
|
||||
spinner: s,
|
||||
}
|
||||
}
|
||||
|
||||
func (m connectModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.spinner.Tick,
|
||||
m.connect(),
|
||||
m.delayShowSpinner(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m connectModel) connect() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
cli, err := connectCluster(m.ctx, m.conn)
|
||||
return connectResultMsg{
|
||||
client: cli,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delayShowSpinner returns a command that sends a message to show the spinner after a delay.
|
||||
// This avoids flashing the spinner if the connection is fast.
|
||||
func (m connectModel) delayShowSpinner() tea.Cmd {
|
||||
return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return showSpinnerMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
m.result.err = m.ctx.Err()
|
||||
m.done = true
|
||||
return m, tea.Quit
|
||||
default:
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case connectResultMsg:
|
||||
m.result = msg
|
||||
m.done = true
|
||||
return m, tea.Quit
|
||||
|
||||
case showSpinnerMsg:
|
||||
// Only show spinner if connection hasn't completed yet.
|
||||
if !m.done {
|
||||
m.showSpinner = true
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case tea.KeyMsg:
|
||||
if msg.Type == tea.KeyCtrlC {
|
||||
m.result.err = fmt.Errorf("connection cancelled")
|
||||
m.done = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m connectModel) View() string {
|
||||
// Don't show anything if done or spinner not yet visible.
|
||||
if m.done || !m.showSpinner {
|
||||
return ""
|
||||
}
|
||||
|
||||
style := lipgloss.NewStyle().Foreground(lipgloss.Color("153"))
|
||||
return fmt.Sprintf("%s %s\n",
|
||||
m.spinner.View(),
|
||||
fmt.Sprintf("Connecting to %s", style.Render(m.conn.String())),
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ExpandCommaSeparatedValues takes a slice of strings and expands any comma-separated values into individual elements.
|
||||
@@ -21,3 +25,12 @@ func ExpandCommaSeparatedValues(values []string) []string {
|
||||
|
||||
return expanded
|
||||
}
|
||||
|
||||
// BindEnvToFlag assigns the value of an environment variable to the given command flag if the flag has not been set.
|
||||
func BindEnvToFlag(cmd *cobra.Command, flagName, envVar string) {
|
||||
if value := os.Getenv(envVar); value != "" && !cmd.Flags().Changed(flagName) {
|
||||
if err := cmd.Flags().Set(flagName, value); err != nil {
|
||||
log.Fatalf("Failed to bind environment variable '%s' to flag '%s': %v", envVar, flagName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -1,6 +1,11 @@
|
||||
package cli
|
||||
|
||||
import "github.com/charmbracelet/huh"
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func Confirm() (bool, error) {
|
||||
var confirmed bool
|
||||
@@ -21,3 +26,13 @@ func Confirm() (bool, error) {
|
||||
|
||||
return confirmed, nil
|
||||
}
|
||||
|
||||
// IsStdinTerminal checks if the standard input is a terminal (TTY).
|
||||
func IsStdinTerminal() bool {
|
||||
return term.IsTerminal(int(os.Stdin.Fd()))
|
||||
}
|
||||
|
||||
// IsStdoutTerminal checks if the standard output is a terminal (TTY).
|
||||
func IsStdoutTerminal() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
*client.Client
|
||||
}
|
||||
|
||||
// WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests.
|
||||
func WaitDaemonReady(ctx context.Context, cli *client.Client) error {
|
||||
// Retry to ping the Docker daemon until it's ready or the context is canceled.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
// CreateContainerWithImagePull creates a container. If the image is missing, it pulls the image first.
|
||||
func (cli *Client) CreateContainerWithImagePull(
|
||||
ctx context.Context, name string, config *container.Config, hostConfig *container.HostConfig,
|
||||
) (container.CreateResponse, error) {
|
||||
resp, err := cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name)
|
||||
if err == nil || !errdefs.IsNotFound(err) {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
pullCh, err := cli.PullImage(ctx, config.Image, image.PullOptions{})
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("pull image: %w", err)
|
||||
}
|
||||
|
||||
// Drain the pull channel until it's closed (image is fully pulled) or an error occurs.
|
||||
// If the context is canceled during the pull, the channel will receive a context cancellation error.
|
||||
for msg := range pullCh {
|
||||
if msg.Err != nil {
|
||||
return resp, fmt.Errorf("pull image: %w", msg.Err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create container again after image pull.
|
||||
if resp, err = cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name); err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// WaitPortPublished waits for a container port to be published on the host which happens asynchronously.
|
||||
func (cli *Client) WaitPortPublished(ctx context.Context, containerID string, port nat.Port) ([]nat.PortBinding, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
c, err := cli.ContainerInspect(ctx, containerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect container: %w", err)
|
||||
}
|
||||
|
||||
binding, ok := c.NetworkSettings.Ports[port]
|
||||
if ok && len(binding) > 0 {
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return nil, errors.New("timeout")
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
dockercommand "github.com/docker/cli/cli/command"
|
||||
dockerconfig "github.com/docker/cli/cli/config"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
)
|
||||
|
||||
type PullPushImageMessage struct {
|
||||
Message jsonmessage.JSONMessage
|
||||
Err error
|
||||
}
|
||||
|
||||
// PullImage pulls an image and returns a channel to receive progress messages.
|
||||
func (cli *Client) PullImage(
|
||||
ctx context.Context, image string, opts image.PullOptions,
|
||||
) (<-chan PullPushImageMessage, error) {
|
||||
if opts.RegistryAuth == "" {
|
||||
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
||||
if encodedAuth, err := RetrieveLocalDockerRegistryAuth(image); err == nil {
|
||||
opts.RegistryAuth = encodedAuth
|
||||
}
|
||||
}
|
||||
|
||||
respBody, err := cli.ImagePull(ctx, image, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan PullPushImageMessage)
|
||||
go processPullPushImageResp(ctx, respBody, ch)
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// PushImage pushes an image and returns a channel to receive progress messages.
|
||||
func (cli *Client) PushImage(
|
||||
ctx context.Context, image string, opts image.PushOptions,
|
||||
) (<-chan PullPushImageMessage, error) {
|
||||
if opts.RegistryAuth == "" {
|
||||
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
||||
encodedAuth, _ := RetrieveLocalDockerRegistryAuth(image)
|
||||
if encodedAuth == "" {
|
||||
// If no credentials are found, provide an encoded empty auth config to work around the bug in Docker:
|
||||
// https://github.com/moby/moby/issues/50729
|
||||
var err error
|
||||
if encodedAuth, err = registry.EncodeAuthConfig(registry.AuthConfig{}); err != nil {
|
||||
return nil, fmt.Errorf("encode empty auth config: %w", err)
|
||||
}
|
||||
}
|
||||
opts.RegistryAuth = encodedAuth
|
||||
}
|
||||
|
||||
respBody, err := cli.ImagePush(ctx, image, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan PullPushImageMessage)
|
||||
go processPullPushImageResp(ctx, respBody, ch)
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// processPullPushImageResp decodes JSON messages from the image pull/push response body and
|
||||
// sends them to the provided channel.
|
||||
func processPullPushImageResp(ctx context.Context, respBody io.ReadCloser, ch chan<- PullPushImageMessage) {
|
||||
defer respBody.Close()
|
||||
defer close(ch)
|
||||
|
||||
decoder := json.NewDecoder(respBody)
|
||||
for {
|
||||
var jm jsonmessage.JSONMessage
|
||||
if err := decoder.Decode(&jm); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return
|
||||
}
|
||||
ch <- PullPushImageMessage{Err: fmt.Errorf("decode image pull/push message: %w", err)}
|
||||
return
|
||||
}
|
||||
|
||||
msg := PullPushImageMessage{Message: jm}
|
||||
if jm.Error != nil {
|
||||
msg.Err = errors.New(jm.Error.Message)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ch <- PullPushImageMessage{Err: ctx.Err()}
|
||||
return
|
||||
default:
|
||||
ch <- msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RetrieveLocalDockerRegistryAuth retrieves the authentication token for the specified image from the local Docker
|
||||
// config file. It returns the encoded authentication token if it contains any credentials, or an empty string if
|
||||
// no credentials are found.
|
||||
func RetrieveLocalDockerRegistryAuth(image string) (string, error) {
|
||||
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
||||
dockerConfig := dockerconfig.LoadDefaultConfigFile(os.Stderr)
|
||||
encodedAuth, err := dockercommand.RetrieveAuthTokenFromImage(dockerConfig, image)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The encodedAuth can be a base64-encoded "{}" (empty JSON object) or include a server address but no credentials.
|
||||
// Return encodedAuth only if it contains any credentials.
|
||||
auth, err := registry.DecodeAuthConfig(encodedAuth)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode auth config: %w", err)
|
||||
}
|
||||
|
||||
if auth.Username == "" &&
|
||||
auth.Password == "" &&
|
||||
auth.Auth == "" &&
|
||||
auth.IdentityToken == "" &&
|
||||
auth.RegistryToken == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return encodedAuth, nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.34.2
|
||||
// protoc v5.27.3
|
||||
// source: internal/machine/api/pb/caddy.proto
|
||||
|
||||
package pb
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type GetCaddyConfigResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
// The generated Caddyfile content.
|
||||
Caddyfile string `protobuf:"bytes,1,opt,name=caddyfile,proto3" json:"caddyfile,omitempty"`
|
||||
// Timestamp when the config was last modified.
|
||||
ModifiedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=modified_at,json=modifiedAt,proto3" json:"modified_at,omitempty"`
|
||||
}
|
||||
|
||||
func (x *GetCaddyConfigResponse) Reset() {
|
||||
*x = GetCaddyConfigResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_internal_machine_api_pb_caddy_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *GetCaddyConfigResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetCaddyConfigResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetCaddyConfigResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_internal_machine_api_pb_caddy_proto_msgTypes[0]
|
||||
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 GetCaddyConfigResponse.ProtoReflect.Descriptor instead.
|
||||
func (*GetCaddyConfigResponse) Descriptor() ([]byte, []int) {
|
||||
return file_internal_machine_api_pb_caddy_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *GetCaddyConfigResponse) GetCaddyfile() string {
|
||||
if x != nil {
|
||||
return x.Caddyfile
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *GetCaddyConfigResponse) GetModifiedAt() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.ModifiedAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_internal_machine_api_pb_caddy_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_internal_machine_api_pb_caddy_proto_rawDesc = []byte{
|
||||
0x0a, 0x23, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69,
|
||||
0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x2f, 0x63, 0x61, 0x64, 0x64, 0x79, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x69, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67,
|
||||
0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74,
|
||||
0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
|
||||
0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x73, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x43,
|
||||
0x61, 0x64, 0x64, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x64, 0x64, 0x79, 0x66, 0x69, 0x6c, 0x65, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x64, 0x64, 0x79, 0x66, 0x69, 0x6c, 0x65,
|
||||
0x12, 0x3b, 0x0a, 0x0b, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
|
||||
0x70, 0x52, 0x0a, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x41, 0x74, 0x32, 0x49, 0x0a,
|
||||
0x05, 0x43, 0x61, 0x64, 0x64, 0x79, 0x12, 0x40, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e,
|
||||
0x66, 0x69, 0x67, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1b, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35, 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, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
|
||||
0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70,
|
||||
0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_internal_machine_api_pb_caddy_proto_rawDescOnce sync.Once
|
||||
file_internal_machine_api_pb_caddy_proto_rawDescData = file_internal_machine_api_pb_caddy_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_internal_machine_api_pb_caddy_proto_rawDescGZIP() []byte {
|
||||
file_internal_machine_api_pb_caddy_proto_rawDescOnce.Do(func() {
|
||||
file_internal_machine_api_pb_caddy_proto_rawDescData = protoimpl.X.CompressGZIP(file_internal_machine_api_pb_caddy_proto_rawDescData)
|
||||
})
|
||||
return file_internal_machine_api_pb_caddy_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_internal_machine_api_pb_caddy_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
|
||||
var file_internal_machine_api_pb_caddy_proto_goTypes = []any{
|
||||
(*GetCaddyConfigResponse)(nil), // 0: api.GetCaddyConfigResponse
|
||||
(*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp
|
||||
(*emptypb.Empty)(nil), // 2: google.protobuf.Empty
|
||||
}
|
||||
var file_internal_machine_api_pb_caddy_proto_depIdxs = []int32{
|
||||
1, // 0: api.GetCaddyConfigResponse.modified_at:type_name -> google.protobuf.Timestamp
|
||||
2, // 1: api.Caddy.GetConfig:input_type -> google.protobuf.Empty
|
||||
0, // 2: api.Caddy.GetConfig:output_type -> api.GetCaddyConfigResponse
|
||||
2, // [2:3] is the sub-list for method output_type
|
||||
1, // [1:2] is the sub-list for method input_type
|
||||
1, // [1:1] is the sub-list for extension type_name
|
||||
1, // [1:1] is the sub-list for extension extendee
|
||||
0, // [0:1] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_internal_machine_api_pb_caddy_proto_init() }
|
||||
func file_internal_machine_api_pb_caddy_proto_init() {
|
||||
if File_internal_machine_api_pb_caddy_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_internal_machine_api_pb_caddy_proto_msgTypes[0].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*GetCaddyConfigResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_internal_machine_api_pb_caddy_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 1,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_internal_machine_api_pb_caddy_proto_goTypes,
|
||||
DependencyIndexes: file_internal_machine_api_pb_caddy_proto_depIdxs,
|
||||
MessageInfos: file_internal_machine_api_pb_caddy_proto_msgTypes,
|
||||
}.Build()
|
||||
File_internal_machine_api_pb_caddy_proto = out.File
|
||||
file_internal_machine_api_pb_caddy_proto_rawDesc = nil
|
||||
file_internal_machine_api_pb_caddy_proto_goTypes = nil
|
||||
file_internal_machine_api_pb_caddy_proto_depIdxs = nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package api;
|
||||
|
||||
option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
service Caddy {
|
||||
// GetConfig retrieves the current Caddy configuration from the machine.
|
||||
rpc GetConfig(google.protobuf.Empty) returns (GetCaddyConfigResponse);
|
||||
}
|
||||
|
||||
message GetCaddyConfigResponse {
|
||||
// The generated Caddyfile content.
|
||||
string caddyfile = 1;
|
||||
// Timestamp when the config was last modified.
|
||||
google.protobuf.Timestamp modified_at = 2;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.27.3
|
||||
// source: internal/machine/api/pb/caddy.proto
|
||||
|
||||
package pb
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
Caddy_GetConfig_FullMethodName = "/api.Caddy/GetConfig"
|
||||
)
|
||||
|
||||
// CaddyClient is the client API for Caddy service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type CaddyClient interface {
|
||||
// GetConfig retrieves the current Caddy configuration from the machine.
|
||||
GetConfig(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*GetCaddyConfigResponse, error)
|
||||
}
|
||||
|
||||
type caddyClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewCaddyClient(cc grpc.ClientConnInterface) CaddyClient {
|
||||
return &caddyClient{cc}
|
||||
}
|
||||
|
||||
func (c *caddyClient) GetConfig(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*GetCaddyConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetCaddyConfigResponse)
|
||||
err := c.cc.Invoke(ctx, Caddy_GetConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CaddyServer is the server API for Caddy service.
|
||||
// All implementations must embed UnimplementedCaddyServer
|
||||
// for forward compatibility.
|
||||
type CaddyServer interface {
|
||||
// GetConfig retrieves the current Caddy configuration from the machine.
|
||||
GetConfig(context.Context, *emptypb.Empty) (*GetCaddyConfigResponse, error)
|
||||
mustEmbedUnimplementedCaddyServer()
|
||||
}
|
||||
|
||||
// UnimplementedCaddyServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedCaddyServer struct{}
|
||||
|
||||
func (UnimplementedCaddyServer) GetConfig(context.Context, *emptypb.Empty) (*GetCaddyConfigResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetConfig not implemented")
|
||||
}
|
||||
func (UnimplementedCaddyServer) mustEmbedUnimplementedCaddyServer() {}
|
||||
func (UnimplementedCaddyServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeCaddyServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to CaddyServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeCaddyServer interface {
|
||||
mustEmbedUnimplementedCaddyServer()
|
||||
}
|
||||
|
||||
func RegisterCaddyServer(s grpc.ServiceRegistrar, srv CaddyServer) {
|
||||
// If the following call pancis, it indicates UnimplementedCaddyServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&Caddy_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _Caddy_GetConfig_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.(CaddyServer).GetConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Caddy_GetConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CaddyServer).GetConfig(ctx, req.(*emptypb.Empty))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// Caddy_ServiceDesc is the grpc.ServiceDesc for Caddy service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var Caddy_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "api.Caddy",
|
||||
HandlerType: (*CaddyServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "GetConfig",
|
||||
Handler: _Caddy_GetConfig_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "internal/machine/api/pb/caddy.proto",
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ service Docker {
|
||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||
// Docker auth credentials if necessary.
|
||||
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
|
||||
rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
|
||||
|
||||
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
|
||||
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
|
||||
@@ -113,7 +114,7 @@ message InspectImageResponse {
|
||||
|
||||
message Image {
|
||||
Metadata metadata = 1;
|
||||
// JSON serialised types.ImageInspect.
|
||||
// JSON serialised image.InspectResponse.
|
||||
bytes image = 2;
|
||||
}
|
||||
|
||||
@@ -134,6 +135,24 @@ message RemoteImage {
|
||||
bytes manifest = 3;
|
||||
}
|
||||
|
||||
message ListImagesRequest {
|
||||
// JSON serialised image.ListOptions.
|
||||
bytes options = 1;
|
||||
}
|
||||
|
||||
message ListImagesResponse {
|
||||
// Must contain only one repeated messages field to allow broadcasting ListImages requests to multiple machines.
|
||||
repeated MachineImages messages = 1;
|
||||
}
|
||||
|
||||
message MachineImages {
|
||||
Metadata metadata = 1;
|
||||
// JSON serialised []image.Summary.
|
||||
bytes images = 2;
|
||||
// True if Docker uses the containerd image store, false if it uses its internal image store.
|
||||
bool containerd_store = 3;
|
||||
}
|
||||
|
||||
message CreateVolumeRequest {
|
||||
// JSON serialised volume.CreateOptions.
|
||||
bytes options = 1;
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
|
||||
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
|
||||
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
|
||||
Docker_ListImages_FullMethodName = "/api.Docker/ListImages"
|
||||
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
|
||||
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
|
||||
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
|
||||
@@ -53,6 +54,7 @@ type DockerClient interface {
|
||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||
// Docker auth credentials if necessary.
|
||||
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
|
||||
ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error)
|
||||
CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error)
|
||||
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
|
||||
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
@@ -169,6 +171,16 @@ func (c *dockerClient) InspectRemoteImage(ctx context.Context, in *InspectRemote
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListImagesResponse)
|
||||
err := c.cc.Invoke(ctx, Docker_ListImages_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreateVolumeResponse)
|
||||
@@ -254,6 +266,7 @@ type DockerServer interface {
|
||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||
// Docker auth credentials if necessary.
|
||||
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
|
||||
ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error)
|
||||
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
|
||||
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
|
||||
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error)
|
||||
@@ -298,6 +311,9 @@ func (UnimplementedDockerServer) InspectImage(context.Context, *InspectImageRequ
|
||||
func (UnimplementedDockerServer) InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method InspectRemoteImage not implemented")
|
||||
}
|
||||
func (UnimplementedDockerServer) ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListImages not implemented")
|
||||
}
|
||||
func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented")
|
||||
}
|
||||
@@ -495,6 +511,24 @@ func _Docker_InspectRemoteImage_Handler(srv interface{}, ctx context.Context, de
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Docker_ListImages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListImagesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DockerServer).ListImages(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Docker_ListImages_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DockerServer).ListImages(ctx, req.(*ListImagesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateVolumeRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -660,6 +694,10 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "InspectRemoteImage",
|
||||
Handler: _Docker_InspectRemoteImage_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListImages",
|
||||
Handler: _Docker_ListImages_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreateVolume",
|
||||
Handler: _Docker_CreateVolume_Handler,
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
package caddyconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
const (
|
||||
caddyfileHeader = `# This file is autogenerated by Uncloud based on the configuration of running services.
|
||||
# Do not edit manually. Any manual changes will be overwritten on the next update.
|
||||
`
|
||||
caddyfileTemplate = `# Health check endpoint to verify Caddy reachability on this machine.
|
||||
http:// {
|
||||
handle {{.VerifyPath}} {
|
||||
respond "{{.VerifyResponse}}" 200
|
||||
}
|
||||
log
|
||||
}
|
||||
|
||||
(common_proxy) {
|
||||
# Retry failed requests up to lb_retries times against other available upstreams.
|
||||
lb_retries 3
|
||||
# Upstreams are marked unhealthy for fail_duration after a failed request (passive health checking).
|
||||
fail_duration 30s
|
||||
}
|
||||
{{- if or .HTTPHostUpstreams .HTTPSHostUpstreams }}
|
||||
|
||||
# Sites generated from service ports.{{end}}
|
||||
{{- range $hostname, $upstreams := .HTTPHostUpstreams}}
|
||||
|
||||
http://{{$hostname}} {
|
||||
reverse_proxy {{join $upstreams " "}} {
|
||||
import common_proxy
|
||||
}
|
||||
log
|
||||
}{{end}}
|
||||
{{- range $hostname, $upstreams := .HTTPSHostUpstreams}}
|
||||
|
||||
https://{{$hostname}} {
|
||||
reverse_proxy {{join $upstreams " "}} {
|
||||
import common_proxy
|
||||
}
|
||||
log
|
||||
}{{end}}
|
||||
`
|
||||
caddyfileUnavailabeFooter = `# NOTE: User-defined configs for services were skipped because Caddy is not running on this machine
|
||||
# or the latest generated config is invalid. Please check the Caddy logs if it's running.
|
||||
`
|
||||
)
|
||||
|
||||
// CaddyfileGenerator generates a Caddyfile configuration for the Caddy reverse proxy.
|
||||
type CaddyfileGenerator struct {
|
||||
// machineID is the unique identifier of the machine where the controller is running.
|
||||
machineID string
|
||||
validator CaddyfileValidator
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// CaddyfileValidator is an interface for validating Caddyfile configurations.
|
||||
type CaddyfileValidator interface {
|
||||
Validate(ctx context.Context, caddyfile string) error
|
||||
}
|
||||
|
||||
func NewCaddyfileGenerator(machineID string, validator CaddyfileValidator, log *slog.Logger) *CaddyfileGenerator {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &CaddyfileGenerator{
|
||||
machineID: machineID,
|
||||
validator: validator,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Generate creates a Caddyfile configuration based on the provided service containers.
|
||||
// The Caddyfile is generated from the service ports of the healthy containers.
|
||||
// If a 'caddy' service container is running on this machine and defines a custom Caddy config (x-caddy) in its service
|
||||
// spec, it will be validated and prepended to the generated Caddyfile. Custom Caddy configs (x-caddy) defined in other
|
||||
// service specs are validated and appended to the generated Caddyfile. Invalid configs are logged and skipped to ensure
|
||||
// the generated Caddyfile remains valid.
|
||||
//
|
||||
// The final Caddyfile structure includes:
|
||||
//
|
||||
// [caddy x-caddy (global config)]
|
||||
// [generated Caddyfile from all service ports]
|
||||
// [service-a x-caddy]
|
||||
// ...
|
||||
// [service-z x-caddy]
|
||||
//
|
||||
// If includeCustom is false, custom Caddy configs (x-caddy) are not included in the generated Caddyfile.
|
||||
func (g *CaddyfileGenerator) Generate(
|
||||
ctx context.Context, records []store.ContainerRecord, includeCustom bool,
|
||||
) (string, error) {
|
||||
containers := make([]api.ServiceContainer, len(records))
|
||||
for i, cr := range records {
|
||||
containers[i] = cr.Container
|
||||
}
|
||||
// Sort containers by service name and creation time to generate a stable Caddyfile.
|
||||
slices.SortStableFunc(containers, func(a, b api.ServiceContainer) int {
|
||||
return cmp.Or(
|
||||
strings.Compare(a.ServiceName(), b.ServiceName()),
|
||||
a.CreatedTime().Compare(b.CreatedTime()),
|
||||
)
|
||||
})
|
||||
|
||||
caddyfile, err := g.generateBaseFromPorts(containers)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate base Caddyfile from service ports: %w", err)
|
||||
}
|
||||
|
||||
if !includeCustom {
|
||||
return fmt.Sprintf("%s\n%s\n%s", caddyfileHeader, caddyfile, caddyfileUnavailabeFooter), nil
|
||||
}
|
||||
|
||||
upstreams := serviceUpstreams(containers)
|
||||
// Track validation errors for reporting.
|
||||
var configErrors []string
|
||||
|
||||
// Find the 'caddy' service container on this machine. Use the most recent one if multiple exist.
|
||||
var caddyCtr *api.ServiceContainer
|
||||
for _, cr := range records {
|
||||
if cr.MachineID == g.machineID && cr.Container.ServiceName() == CaddyServiceName &&
|
||||
(caddyCtr == nil || cr.Container.CreatedTime().Compare(caddyCtr.CreatedTime()) > 0) {
|
||||
caddyCtr = &cr.Container
|
||||
}
|
||||
}
|
||||
|
||||
// If the caddy container is running on this machine and has a custom Caddy config (global),
|
||||
// prepend it to the generated Caddyfile and validate it.
|
||||
if caddyCtr != nil && caddyCtr.ServiceSpec.CaddyConfig() != "" {
|
||||
// Render the custom global Caddy config as a Go template with the upstreams.
|
||||
tmplCtx := templateContext{
|
||||
Name: caddyCtr.ServiceName(),
|
||||
Upstreams: upstreams,
|
||||
}
|
||||
renderedConfig, err := renderCaddyfile(tmplCtx, caddyCtr.ServiceSpec.CaddyConfig())
|
||||
if err != nil {
|
||||
g.log.Error("Failed to render template directives in user-defined global Caddy config, skipping it.",
|
||||
"service", caddyCtr.ServiceName(), "container", caddyCtr.ID, "err", err)
|
||||
configErrors = append(configErrors,
|
||||
fmt.Sprintf("service '%s': failed to render template: %v", caddyCtr.ServiceName(), err))
|
||||
} else {
|
||||
caddyfileCandidate := fmt.Sprintf("# User-defined global config from service '%s'.\n%s\n\n%s",
|
||||
caddyCtr.ServiceName(), renderedConfig, caddyfile)
|
||||
|
||||
if err = g.validator.Validate(ctx, caddyfileCandidate); err != nil {
|
||||
g.log.Error("User-defined global Caddy config is invalid, skipping it.",
|
||||
"service", caddyCtr.ServiceName(), "container", caddyCtr.ID, "err", err)
|
||||
configErrors = append(configErrors,
|
||||
fmt.Sprintf("service '%s': validation failed: %v", caddyCtr.ServiceName(), err))
|
||||
} else {
|
||||
caddyfile = caddyfileCandidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// There could be multiple service containers for the same service with different custom Caddy configs, for example,
|
||||
// if the service has been partially updated. The most recent container for each service defines the current custom
|
||||
// Caddy config for that service.
|
||||
latestServiceContainers := make(map[string]api.ServiceContainer, len(containers))
|
||||
for _, ctr := range containers {
|
||||
if latest, ok := latestServiceContainers[ctr.ServiceName()]; ok {
|
||||
if ctr.CreatedTime().Compare(latest.CreatedTime()) > 0 {
|
||||
latestServiceContainers[ctr.ServiceName()] = ctr
|
||||
}
|
||||
} else {
|
||||
latestServiceContainers[ctr.ServiceName()] = ctr
|
||||
}
|
||||
}
|
||||
sortedServiceNames := slices.Sorted(maps.Keys(latestServiceContainers))
|
||||
|
||||
// Append a custom Caddy config for each service to the Caddyfile and validate it. If the config for a service
|
||||
// is invalid, skip it but continue processing other services to ensure the Caddyfile remains valid.
|
||||
for _, serviceName := range sortedServiceNames {
|
||||
// Skip the caddy container as we already processed it.
|
||||
if serviceName == CaddyServiceName {
|
||||
continue
|
||||
}
|
||||
|
||||
ctr := latestServiceContainers[serviceName]
|
||||
if ctr.ServiceSpec.CaddyConfig() == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Render the template actions in the service's Caddy config.
|
||||
tmplCtx := templateContext{
|
||||
Name: serviceName,
|
||||
Upstreams: upstreams,
|
||||
}
|
||||
renderedConfig, err := renderCaddyfile(tmplCtx, ctr.ServiceSpec.CaddyConfig())
|
||||
if err != nil {
|
||||
g.log.Error("Failed to render template directives in user-defined Caddy config for service, skipping it.",
|
||||
"service", serviceName, "err", err)
|
||||
configErrors = append(configErrors,
|
||||
fmt.Sprintf("service '%s': failed to render template: %v", serviceName, err))
|
||||
continue
|
||||
}
|
||||
|
||||
caddyfileCandidate := fmt.Sprintf("%s\n# User-defined config for service '%s'.\n%s\n",
|
||||
caddyfile, serviceName, renderedConfig)
|
||||
if err = g.validator.Validate(ctx, caddyfileCandidate); err != nil {
|
||||
g.log.Error("User-defined Caddy config for service is invalid, skipping it.",
|
||||
"service", serviceName, "err", err)
|
||||
configErrors = append(configErrors, fmt.Sprintf("service '%s': validation failed: %v", serviceName, err))
|
||||
} else {
|
||||
caddyfile = caddyfileCandidate
|
||||
}
|
||||
}
|
||||
|
||||
// Append error summary as comment if there were any invalid configs.
|
||||
if len(configErrors) > 0 {
|
||||
errorsComment := "# Skipped invalid user-defined configs:\n"
|
||||
for _, e := range configErrors {
|
||||
errorsComment += fmt.Sprintf("# - %s\n", e)
|
||||
}
|
||||
|
||||
caddyfile += "\n" + errorsComment
|
||||
}
|
||||
|
||||
return caddyfileHeader + "\n" + caddyfile, nil
|
||||
}
|
||||
|
||||
func (g *CaddyfileGenerator) generateBaseFromPorts(containers []api.ServiceContainer) (string, error) {
|
||||
httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromPorts(containers)
|
||||
|
||||
funcs := template.FuncMap{"join": strings.Join}
|
||||
tmpl, err := template.New("Caddyfile").Funcs(funcs).Parse(caddyfileTemplate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse Caddyfile template: %w", err)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
VerifyPath string
|
||||
VerifyResponse string
|
||||
HTTPHostUpstreams map[string][]string
|
||||
HTTPSHostUpstreams map[string][]string
|
||||
}{
|
||||
VerifyPath: VerifyPath,
|
||||
VerifyResponse: g.machineID,
|
||||
HTTPHostUpstreams: httpHostUpstreams,
|
||||
HTTPSHostUpstreams: httpsHostUpstreams,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err = tmpl.Execute(&buf, data); err != nil {
|
||||
return "", fmt.Errorf("execute Caddyfile template: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// httpUpstreamsFromPorts extracts upstreams for HTTP and HTTPS protocols from the published ports of the provided
|
||||
// service containers. It's expected that all containers are healthy.
|
||||
func httpUpstreamsFromPorts(containers []api.ServiceContainer) (map[string][]string, map[string][]string) {
|
||||
// Maps hostnames to lists of upstreams (container IP:port pairs).
|
||||
httpHostUpstreams := make(map[string][]string)
|
||||
httpsHostUpstreams := make(map[string][]string)
|
||||
for _, ctr := range containers {
|
||||
ip := ctr.UncloudNetworkIP()
|
||||
if !ip.IsValid() {
|
||||
// Container is not connected to the uncloud Docker network (could be host network).
|
||||
continue
|
||||
}
|
||||
log := slog.With("container", ctr.ID)
|
||||
|
||||
ports, err := ctr.ServicePorts()
|
||||
if err != nil {
|
||||
log.Error("Failed to parse service ports for container.", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, port := range ports {
|
||||
if port.Mode != api.PortModeIngress {
|
||||
continue
|
||||
}
|
||||
|
||||
switch port.Protocol {
|
||||
case api.ProtocolHTTP:
|
||||
upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort)))
|
||||
httpHostUpstreams[port.Hostname] = append(httpHostUpstreams[port.Hostname], upstream)
|
||||
case api.ProtocolHTTPS:
|
||||
upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort)))
|
||||
httpsHostUpstreams[port.Hostname] = append(httpsHostUpstreams[port.Hostname], upstream)
|
||||
default:
|
||||
// TODO: implement L4 ingress routing for TCP and UDP.
|
||||
log.Error("Unsupported protocol for ingress port.", "port", port)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return httpHostUpstreams, httpsHostUpstreams
|
||||
}
|
||||
|
||||
// serviceUpstreams creates a map of service names to their container IPs.
|
||||
// Only includes containers connected to the uncloud Docker network.
|
||||
func serviceUpstreams(containers []api.ServiceContainer) map[string][]string {
|
||||
upstreams := make(map[string][]string)
|
||||
for _, ctr := range containers {
|
||||
ip := ctr.UncloudNetworkIP()
|
||||
if !ip.IsValid() {
|
||||
// Container is not connected to the uncloud Docker network (could be host network).
|
||||
continue
|
||||
}
|
||||
|
||||
serviceName := ctr.ServiceName()
|
||||
upstreams[serviceName] = append(upstreams[serviceName], ip.String())
|
||||
}
|
||||
|
||||
return upstreams
|
||||
}
|
||||
|
||||
// renderCaddyfile renders a Caddyfile template with the upstreams function and data.
|
||||
func renderCaddyfile(tmplCtx templateContext, caddyfile string) (string, error) {
|
||||
funcs := template.FuncMap{
|
||||
"upstreams": upstreamsTemplateFn(tmplCtx),
|
||||
}
|
||||
|
||||
tmpl, err := template.New("Caddyfile").Funcs(funcs).Parse(caddyfile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse config as Go template: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err = tmpl.Execute(&buf, tmplCtx); err != nil {
|
||||
return "", fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
package caddyconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
)
|
||||
|
||||
// CaddyAdminClient is a client for interacting with the Caddy admin API over a Unix socket.
|
||||
type CaddyAdminClient struct {
|
||||
socketPath string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewCaddyAdminClient(socketPath string) *CaddyAdminClient {
|
||||
return &CaddyAdminClient{
|
||||
socketPath: socketPath,
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
return net.Dial("unix", socketPath)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// IsAvailable checks if the local Caddy instance is running and responding to admin API requests.
|
||||
func (c *CaddyAdminClient) IsAvailable(ctx context.Context) bool {
|
||||
// Caddy doesn't serve a /ping endpoint. It's a random endpoint we can use to check if Caddy is running.
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost/ping", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Any HTTP response means Caddy is running and accessible.
|
||||
return true
|
||||
}
|
||||
|
||||
// Adapt converts a Caddyfile to JSON configuration without loading or running it.
|
||||
func (c *CaddyAdminClient) Adapt(ctx context.Context, caddyfile string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "http://localhost/adapt", strings.NewReader(caddyfile))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create adapt request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/caddyfile")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("send adapt request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
// Parse the response body to extract the result field.
|
||||
var msg struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &msg); err != nil {
|
||||
return "", fmt.Errorf("parse adapt response: %w", err)
|
||||
}
|
||||
return string(msg.Result), nil
|
||||
}
|
||||
|
||||
// If the response is a 400 Bad Request, try to parse the error message from it.
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
var apiError caddy.APIError
|
||||
if err = json.Unmarshal(body, &apiError); err == nil {
|
||||
return "", errors.New(apiError.Message)
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New(string(body))
|
||||
}
|
||||
|
||||
// Load loads a Caddyfile configuration into the Caddy instance running on the machine.
|
||||
// Due to a Caddy bug (https://github.com/caddyserver/caddy/issues/7246), we first adapt the Caddyfile to JSON
|
||||
// and then load the JSON config to get proper error handling.
|
||||
func (c *CaddyAdminClient) Load(ctx context.Context, caddyfile string) error {
|
||||
jsonConfig, err := c.Adapt(ctx, caddyfile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adapt Caddyfile to JSON config: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "http://localhost/load", strings.NewReader(jsonConfig))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create load request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send load request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
// If the response is a 400 Bad Request, try to parse the error message from it.
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
var apiError caddy.APIError
|
||||
if err = json.Unmarshal(body, &apiError); err == nil {
|
||||
return fmt.Errorf("caddy responded with error: %s", apiError.Message)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("caddy responded with error: HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Validate checks if the provided Caddyfile can be adapted to Caddy JSON config using the running Caddy instance via
|
||||
// its admin API. It doesn't guarantee that the Caddyfile is actually valid and can be loaded. For example, a tls
|
||||
// directive with a missing certificate will pass the adaptation but will fail when Caddy tries to load it.
|
||||
// But this is the best we can do over the admin API.
|
||||
// TODO: run 'docker exec caddy-container caddy validate' to do proper validation or implement a Caddy module that
|
||||
// exposes a validation endpoint.
|
||||
func (c *CaddyAdminClient) Validate(ctx context.Context, caddyfile string) error {
|
||||
_, err := c.Adapt(ctx, caddyfile)
|
||||
return err
|
||||
}
|
||||
@@ -14,48 +14,58 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
CaddyGroup = "uncloud"
|
||||
VerifyPath = "/.uncloud-verify"
|
||||
CaddyServiceName = "caddy"
|
||||
CaddyGroup = "uncloud"
|
||||
VerifyPath = "/.uncloud-verify"
|
||||
)
|
||||
|
||||
// Controller monitors container changes in the cluster store and generates a configuration file for Caddy reverse
|
||||
// proxy. The generated configuration allows Caddy to route external traffic to service containers across the internal
|
||||
// network.
|
||||
type Controller struct {
|
||||
store *store.Store
|
||||
path string
|
||||
verifyResponse string
|
||||
machineID string
|
||||
caddyfilePath string
|
||||
generator *CaddyfileGenerator
|
||||
client *CaddyAdminClient
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func NewController(store *store.Store, path string, verifyResponse string) (*Controller, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create parent directory for Caddy configuration '%s': %w", dir, err)
|
||||
func NewController(machineID, configDir, adminSock string, store *store.Store) (*Controller, error) {
|
||||
if err := os.MkdirAll(configDir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create directory for Caddy configuration '%s': %w", configDir, err)
|
||||
}
|
||||
if err := fs.Chown(dir, "", CaddyGroup); err != nil {
|
||||
return nil, fmt.Errorf("change owner of parent directory for Caddy configuration '%s': %w", dir, err)
|
||||
if err := fs.Chown(configDir, "", CaddyGroup); err != nil {
|
||||
return nil, fmt.Errorf("change owner of directory for Caddy configuration '%s': %w", configDir, err)
|
||||
}
|
||||
|
||||
log := slog.With("component", "caddy-controller")
|
||||
client := NewCaddyAdminClient(adminSock)
|
||||
generator := NewCaddyfileGenerator(machineID, client, log)
|
||||
|
||||
return &Controller{
|
||||
store: store,
|
||||
path: path,
|
||||
verifyResponse: verifyResponse,
|
||||
machineID: machineID,
|
||||
caddyfilePath: filepath.Join(configDir, "Caddyfile"),
|
||||
generator: generator,
|
||||
client: client,
|
||||
store: store,
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Controller) Run(ctx context.Context) error {
|
||||
containerRecords, changes, err := c.store.SubscribeContainers(ctx)
|
||||
containers, changes, err := c.store.SubscribeContainers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscribe to container changes: %w", err)
|
||||
}
|
||||
slog.Info("Subscribed to container changes in the cluster to generate Caddy configuration.")
|
||||
c.log.Info("Subscribed to container changes in the cluster to generate Caddy configuration.")
|
||||
|
||||
containers, err := c.filterAvailableContainers(containerRecords)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter available containers: %w", err)
|
||||
}
|
||||
if err = c.generateConfig(containers); err != nil {
|
||||
return fmt.Errorf("generate Caddy configuration: %w", err)
|
||||
containers = filterHealthyContainers(containers)
|
||||
c.generateAndLoadCaddyfile(ctx, containers)
|
||||
|
||||
// TODO: left for backward compatibility, remove later.
|
||||
if err = c.generateJSONConfig(containers); err != nil {
|
||||
c.log.Error("Failed to generate Caddy JSON configuration to disk.", "err", err)
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -64,47 +74,97 @@ func (c *Controller) Run(ctx context.Context) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("containers subscription failed")
|
||||
}
|
||||
slog.Debug("Cluster containers changed, updating Caddy configuration.")
|
||||
c.log.Info("Cluster containers changed, updating Caddy configuration.")
|
||||
|
||||
containerRecords, err = c.store.ListContainers(ctx, store.ListOptions{})
|
||||
containers, err = c.store.ListContainers(ctx, store.ListOptions{})
|
||||
if err != nil {
|
||||
slog.Error("Failed to list containers.", "err", err)
|
||||
c.log.Error("Failed to list containers.", "err", err)
|
||||
continue
|
||||
}
|
||||
containers, err = c.filterAvailableContainers(containerRecords)
|
||||
if err != nil {
|
||||
slog.Error("Failed to filter available containers.", "err", err)
|
||||
continue
|
||||
}
|
||||
if err = c.generateConfig(containers); err != nil {
|
||||
slog.Error("Failed to generate Caddy configuration.", "err", err)
|
||||
}
|
||||
containers = filterHealthyContainers(containers)
|
||||
c.generateAndLoadCaddyfile(ctx, containers)
|
||||
|
||||
slog.Debug("Updated Caddy configuration.", "path", c.path)
|
||||
// TODO: left for backward compatibility, remove later.
|
||||
if err = c.generateJSONConfig(containers); err != nil {
|
||||
c.log.Error("Failed to generate Caddy JSON configuration to disk.", "err", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// filterAvailableContainers filters out containers from this machine that are likely unavailable. The availability
|
||||
// is determined by the cluster membership state of the machine that the container is running on.
|
||||
// TODO: implement machine membership check using Corrossion Admin client.
|
||||
func (c *Controller) filterAvailableContainers(
|
||||
containerRecords []store.ContainerRecord,
|
||||
) ([]api.ServiceContainer, error) {
|
||||
containers := make([]api.ServiceContainer, len(containerRecords))
|
||||
for i, cr := range containerRecords {
|
||||
containers[i] = api.ServiceContainer{
|
||||
Container: cr.Container,
|
||||
// TODO: restore ServiceSpec from the container record once it's saved in the store.
|
||||
// filterHealthyContainers filters out containers that are not healthy.
|
||||
// TODO: Filters out containers from this machine that are likely unavailable. The availability can be determined
|
||||
// by the cluster membership state of the machine that the container is running on. Implement machine membership
|
||||
// check using Corrossion Admin client.
|
||||
func filterHealthyContainers(containers []store.ContainerRecord) []store.ContainerRecord {
|
||||
healthy := make([]store.ContainerRecord, 0, len(containers))
|
||||
for _, cr := range containers {
|
||||
if cr.Container.Healthy() {
|
||||
healthy = append(healthy, cr)
|
||||
}
|
||||
}
|
||||
return containers, nil
|
||||
return healthy
|
||||
}
|
||||
|
||||
func (c *Controller) generateConfig(containers []api.ServiceContainer) error {
|
||||
config, err := GenerateConfig(containers, c.verifyResponse)
|
||||
func (c *Controller) generateAndLoadCaddyfile(ctx context.Context, containers []store.ContainerRecord) {
|
||||
// Check if Caddy is available before attempting to generate and load config.
|
||||
caddyAvailable := c.client.IsAvailable(ctx)
|
||||
caddyfile, err := c.generator.Generate(ctx, containers, caddyAvailable)
|
||||
if err != nil {
|
||||
c.log.Error("Failed to generate Caddyfile configuration.", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !caddyAvailable {
|
||||
// Caddy is not running so the generated Caddyfile should not include user-defined configs thus must be valid.
|
||||
// It's safe to write the config to disk so that when Caddy is deployed on this machine, it can pick it up.
|
||||
if err = c.writeCaddyfile(caddyfile); err != nil {
|
||||
c.log.Error("Failed to write Caddyfile to disk.", "err", err)
|
||||
return
|
||||
}
|
||||
c.log.Debug("Caddy is not running on this machine, skipping configuration load.", "path", c.caddyfilePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Caddy is available, try to load the config which may fail if the config is invalid. Generally, a config can
|
||||
// pass the adaptation/validation step but still fail to load, for example, if it references resources that are
|
||||
// not available.
|
||||
if err = c.client.Load(ctx, caddyfile); err != nil {
|
||||
c.log.Error("Failed to load new Caddy configuration into local Caddy instance.",
|
||||
"err", err, "path", c.caddyfilePath)
|
||||
// Don't write invalid config to disk.
|
||||
return
|
||||
}
|
||||
|
||||
// Config loaded successfully, now write it to disk.
|
||||
if err = c.writeCaddyfile(caddyfile); err != nil {
|
||||
c.log.Error("Failed to write Caddyfile to disk after successful load.", "err", err)
|
||||
// Config is already loaded in Caddy, so this is not critical.
|
||||
}
|
||||
|
||||
c.log.Info("New Caddy configuration loaded into local Caddy instance.", "path", c.caddyfilePath)
|
||||
}
|
||||
|
||||
// writeCaddyfile writes the Caddyfile content to disk with proper permissions.
|
||||
func (c *Controller) writeCaddyfile(caddyfile string) error {
|
||||
if err := os.WriteFile(c.caddyfilePath, []byte(caddyfile), 0o640); err != nil {
|
||||
return fmt.Errorf("write Caddyfile to file '%s': %w", c.caddyfilePath, err)
|
||||
}
|
||||
if err := fs.Chown(c.caddyfilePath, "", CaddyGroup); err != nil {
|
||||
return fmt.Errorf("change owner of Caddyfile '%s': %w", c.caddyfilePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) generateJSONConfig(containers []store.ContainerRecord) error {
|
||||
serviceContainers := make([]api.ServiceContainer, len(containers))
|
||||
for i, cr := range containers {
|
||||
serviceContainers[i] = cr.Container
|
||||
}
|
||||
|
||||
config, err := GenerateJSONConfig(serviceContainers, c.machineID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -113,12 +173,13 @@ func (c *Controller) generateConfig(containers []api.ServiceContainer) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal Caddy configuration: %w", err)
|
||||
}
|
||||
configPath := filepath.Join(filepath.Dir(c.caddyfilePath), "caddy.json")
|
||||
|
||||
if err = os.WriteFile(c.path, configBytes, 0o640); err != nil {
|
||||
return fmt.Errorf("write Caddy configuration to file '%s': %w", c.path, err)
|
||||
if err = os.WriteFile(configPath, configBytes, 0o640); err != nil {
|
||||
return fmt.Errorf("write Caddy configuration to file '%s': %w", configPath, err)
|
||||
}
|
||||
if err = fs.Chown(c.path, "", CaddyGroup); err != nil {
|
||||
return fmt.Errorf("change owner of Caddy configuration file '%s': %w", c.path, err)
|
||||
if err = fs.Chown(configPath, "", CaddyGroup); err != nil {
|
||||
return fmt.Errorf("change owner of Caddy configuration file '%s': %w", configPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+2
-43
@@ -4,9 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -19,47 +17,8 @@ import (
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
func GenerateConfig(containers []api.ServiceContainer, verifyResponse string) (*caddy.Config, error) {
|
||||
// Maps hostnames to lists of upstreams (container IP:port pairs).
|
||||
httpHostUpstreams := make(map[string][]string)
|
||||
httpsHostUpstreams := make(map[string][]string)
|
||||
for _, ctr := range containers {
|
||||
if !ctr.Healthy() {
|
||||
continue
|
||||
}
|
||||
|
||||
ip := ctr.UncloudNetworkIP()
|
||||
if !ip.IsValid() {
|
||||
// Container is not connected to the uncloud Docker network (could be host network).
|
||||
continue
|
||||
}
|
||||
log := slog.With("container", ctr.ID)
|
||||
|
||||
ports, err := ctr.ServicePorts()
|
||||
if err != nil {
|
||||
log.Error("Failed to parse service ports for container.", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, port := range ports {
|
||||
if port.Mode != api.PortModeIngress {
|
||||
continue
|
||||
}
|
||||
|
||||
switch port.Protocol {
|
||||
case api.ProtocolHTTP:
|
||||
upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort)))
|
||||
httpHostUpstreams[port.Hostname] = append(httpHostUpstreams[port.Hostname], upstream)
|
||||
case api.ProtocolHTTPS:
|
||||
upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort)))
|
||||
httpsHostUpstreams[port.Hostname] = append(httpsHostUpstreams[port.Hostname], upstream)
|
||||
default:
|
||||
// TODO: implement L4 ingress routing for TCP and UDP.
|
||||
log.Error("Unsupported protocol for ingress port.", "port", port)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
func GenerateJSONConfig(containers []api.ServiceContainer, verifyResponse string) (*caddy.Config, error) {
|
||||
httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromPorts(containers)
|
||||
|
||||
var warnings []caddyconfig.Warning
|
||||
servers := make(map[string]*caddyhttp.Server)
|
||||
+10
-85
@@ -4,7 +4,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||
@@ -13,7 +12,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateConfig(t *testing.T) {
|
||||
func TestGenerateJSONConfig(t *testing.T) {
|
||||
configWithoutServices := `{
|
||||
"servers": {
|
||||
"http": {
|
||||
@@ -312,73 +311,11 @@ func TestGenerateConfig(t *testing.T) {
|
||||
want: configWithoutServices,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "restarting container ignored",
|
||||
containers: []api.ServiceContainer{
|
||||
newRestartingContainer("10.210.0.2", "app.example.com:8080/http"),
|
||||
},
|
||||
want: configWithoutServices,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "stopped container ignored",
|
||||
containers: []api.ServiceContainer{
|
||||
newStoppedContainer("10.210.0.2", "app.example.com:8080/http"),
|
||||
},
|
||||
want: configWithoutServices,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "mix of running, restarting, and stopped containers",
|
||||
containers: []api.ServiceContainer{
|
||||
newContainer("10.210.0.2", "app.example.com:8080/http"),
|
||||
newRestartingContainer("10.210.0.3", "app.example.com:8080/http"),
|
||||
newStoppedContainer("10.210.0.4", "app.example.com:8080/http"),
|
||||
},
|
||||
want: `{
|
||||
"servers": {
|
||||
"http": {
|
||||
"listen": [":80"],
|
||||
"routes": [
|
||||
{
|
||||
"match": [{"host": ["app.example.com"]}],
|
||||
"handle": [{
|
||||
"handler": "reverse_proxy",
|
||||
"health_checks": {
|
||||
"passive": {
|
||||
"fail_duration": 30000000000
|
||||
}
|
||||
},
|
||||
"load_balancing": {
|
||||
"retries": 3
|
||||
},
|
||||
"upstreams": [{"dial": "10.210.0.2:8080"}]
|
||||
}]
|
||||
},
|
||||
{
|
||||
"match": [{"path": ["/.uncloud-verify"]}],
|
||||
"handle": [{
|
||||
"body": "verification-response-body",
|
||||
"handler": "static_response",
|
||||
"status_code": 200
|
||||
}]
|
||||
}
|
||||
],
|
||||
"logs": {}
|
||||
},
|
||||
"https": {
|
||||
"listen": [":443"],
|
||||
"logs": {}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config, err := GenerateConfig(tt.containers, "verification-response-body")
|
||||
config, err := GenerateJSONConfig(tt.containers, "verification-response-body")
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
@@ -396,13 +333,13 @@ func TestGenerateConfig(t *testing.T) {
|
||||
|
||||
func newContainer(ip string, ports ...string) api.ServiceContainer {
|
||||
portsLabel := strings.Join(ports, ",")
|
||||
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
return api.ServiceContainer{Container: api.Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Running: true,
|
||||
},
|
||||
},
|
||||
NetworkSettings: &types.NetworkSettings{
|
||||
NetworkSettings: &container.NetworkSettings{
|
||||
Networks: map[string]*network.EndpointSettings{
|
||||
docker.NetworkName: {
|
||||
IPAddress: ip,
|
||||
@@ -419,13 +356,13 @@ func newContainer(ip string, ports ...string) api.ServiceContainer {
|
||||
|
||||
func newContainerWithoutNetwork(ports ...string) api.ServiceContainer {
|
||||
portsLabel := strings.Join(ports, ",")
|
||||
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
return api.ServiceContainer{Container: api.Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Running: true,
|
||||
},
|
||||
},
|
||||
NetworkSettings: &types.NetworkSettings{
|
||||
NetworkSettings: &container.NetworkSettings{
|
||||
Networks: map[string]*network.EndpointSettings{
|
||||
"other-network": {
|
||||
IPAddress: "172.17.0.2",
|
||||
@@ -439,15 +376,3 @@ func newContainerWithoutNetwork(ports ...string) api.ServiceContainer {
|
||||
},
|
||||
}}}
|
||||
}
|
||||
|
||||
func newRestartingContainer(ip string, ports ...string) api.ServiceContainer {
|
||||
ctr := newContainer(ip, ports...)
|
||||
ctr.Container.State.Restarting = true
|
||||
return ctr
|
||||
}
|
||||
|
||||
func newStoppedContainer(ip string, ports ...string) api.ServiceContainer {
|
||||
ctr := newContainer(ip, ports...)
|
||||
ctr.Container.State.Running = false
|
||||
return ctr
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package caddyconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewMockCaddyfileValidator creates a new instance of MockCaddyfileValidator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockCaddyfileValidator(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockCaddyfileValidator {
|
||||
mock := &MockCaddyfileValidator{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// MockCaddyfileValidator is an autogenerated mock type for the CaddyfileValidator type
|
||||
type MockCaddyfileValidator struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockCaddyfileValidator_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockCaddyfileValidator) EXPECT() *MockCaddyfileValidator_Expecter {
|
||||
return &MockCaddyfileValidator_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Validate provides a mock function for the type MockCaddyfileValidator
|
||||
func (_mock *MockCaddyfileValidator) Validate(ctx context.Context, caddyfile string) error {
|
||||
ret := _mock.Called(ctx, caddyfile)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Validate")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
|
||||
r0 = returnFunc(ctx, caddyfile)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockCaddyfileValidator_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate'
|
||||
type MockCaddyfileValidator_Validate_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Validate is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - caddyfile string
|
||||
func (_e *MockCaddyfileValidator_Expecter) Validate(ctx interface{}, caddyfile interface{}) *MockCaddyfileValidator_Validate_Call {
|
||||
return &MockCaddyfileValidator_Validate_Call{Call: _e.mock.On("Validate", ctx, caddyfile)}
|
||||
}
|
||||
|
||||
func (_c *MockCaddyfileValidator_Validate_Call) Run(run func(ctx context.Context, caddyfile string)) *MockCaddyfileValidator_Validate_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockCaddyfileValidator_Validate_Call) Return(err error) *MockCaddyfileValidator_Validate_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockCaddyfileValidator_Validate_Call) RunAndReturn(run func(ctx context.Context, caddyfile string) error) *MockCaddyfileValidator_Validate_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package caddyconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
// Server implements the gRPC Caddy service.
|
||||
type Server struct {
|
||||
pb.UnimplementedCaddyServer
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewServer(service *Service) *Server {
|
||||
return &Server{service: service}
|
||||
}
|
||||
|
||||
// GetConfig retrieves the current Caddy configuration from the machine.
|
||||
func (s *Server) GetConfig(ctx context.Context, _ *emptypb.Empty) (*pb.GetCaddyConfigResponse, error) {
|
||||
caddyfile, modifiedAt, err := s.service.Caddyfile()
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
return &pb.GetCaddyConfigResponse{
|
||||
Caddyfile: caddyfile,
|
||||
ModifiedAt: timestamppb.New(modifiedAt),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package caddyconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Service provides methods to interact with the Caddy configuration on the machine.
|
||||
type Service struct {
|
||||
configDir string
|
||||
}
|
||||
|
||||
// NewService creates a new Service instance with the specified Caddy configuration directory.
|
||||
func NewService(configDir string) *Service {
|
||||
return &Service{configDir: configDir}
|
||||
}
|
||||
|
||||
// Caddyfile retrieves the current Caddy configuration (Caddyfile) from the machine's config directory.
|
||||
func (s *Service) Caddyfile() (string, time.Time, error) {
|
||||
path := filepath.Join(s.configDir, "Caddyfile")
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("read Caddyfile from file '%s': %w", path, err)
|
||||
}
|
||||
|
||||
// Get the file modification time.
|
||||
fileInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("get Caddyfile file info '%s': %w", path, err)
|
||||
}
|
||||
|
||||
return string(content), fileInfo.ModTime(), nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package caddyconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// templateContext holds the data available to Caddyfile templates.
|
||||
type templateContext struct {
|
||||
// Name is the current service name.
|
||||
Name string
|
||||
// Upstreams maps service names to their container IPs.
|
||||
Upstreams map[string][]string
|
||||
}
|
||||
|
||||
// upstreamsTemplateFn returns a template function that generates a space separated string of upstreams for the service.
|
||||
// It optionally accepts a service name and a port number: {{upstreams [service-name] [port]}}.
|
||||
func upstreamsTemplateFn(tmplCtx templateContext) func(args ...any) (string, error) {
|
||||
return func(args ...any) (string, error) {
|
||||
var serviceName string
|
||||
var port int
|
||||
|
||||
// Parse arguments.
|
||||
switch len(args) {
|
||||
case 0:
|
||||
// Current service, default port.
|
||||
serviceName = tmplCtx.Name
|
||||
case 1:
|
||||
// Either port (int) for current service or service name (string).
|
||||
switch arg := args[0].(type) {
|
||||
case int:
|
||||
serviceName = tmplCtx.Name
|
||||
port = arg
|
||||
case string:
|
||||
serviceName = arg
|
||||
port = 0
|
||||
default:
|
||||
return "", fmt.Errorf("upstreams function: invalid argument type: %T", arg)
|
||||
}
|
||||
case 2:
|
||||
// Service name and port.
|
||||
name, ok := args[0].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("upstreams function: first argument must be service name (string)")
|
||||
}
|
||||
serviceName = name
|
||||
|
||||
p, ok := args[1].(int)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("upstreams function: second argument must be port (int)")
|
||||
}
|
||||
port = p
|
||||
default:
|
||||
return "", fmt.Errorf("upstreams function: too many arguments; expected 0-2, got %d", len(args))
|
||||
}
|
||||
|
||||
ips, ok := tmplCtx.Upstreams[serviceName]
|
||||
if !ok || len(ips) == 0 {
|
||||
// No upstreams available.
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Build the space separated upstreams string.
|
||||
var upstreams []string
|
||||
for _, ip := range ips {
|
||||
if port > 0 {
|
||||
upstreams = append(upstreams, net.JoinHostPort(ip, strconv.Itoa(port)))
|
||||
} else {
|
||||
upstreams = append(upstreams, ip)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(upstreams, " "), nil
|
||||
}
|
||||
}
|
||||
+40
-14
@@ -12,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/caddyconfig"
|
||||
"github.com/psviderski/uncloud/internal/machine/constants"
|
||||
@@ -22,6 +21,7 @@ import (
|
||||
"github.com/psviderski/uncloud/internal/machine/firewall"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/unregistry"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
@@ -36,10 +36,9 @@ type clusterController struct {
|
||||
wgnet *network.WireGuardNetwork
|
||||
endpointChanges <-chan network.EndpointChangeEvent
|
||||
|
||||
server *grpc.Server
|
||||
corroService corroservice.Service
|
||||
dockerCli *client.Client
|
||||
dockerManager *docker.Manager
|
||||
server *grpc.Server
|
||||
corroService corroservice.Service
|
||||
dockerCtrl *docker.Controller
|
||||
// dockerReady is signalled when Docker is configured and ready for containers.
|
||||
dockerReady chan<- struct{}
|
||||
caddyconfigCtrl *caddyconfig.Controller
|
||||
@@ -47,6 +46,8 @@ type clusterController struct {
|
||||
// dnsServer is the embedded internal DNS server for the cluster listening on the machine IP.
|
||||
dnsServer *dns.Server
|
||||
dnsResolver *dns.ClusterResolver
|
||||
// unregistry is the embedded container registry that uses the local Docker (containerd) image store as its backend.
|
||||
unregistry *unregistry.Registry
|
||||
|
||||
// stopped is a channel that is closed when the controller is stopped.
|
||||
stopped chan struct{}
|
||||
@@ -57,11 +58,12 @@ func newClusterController(
|
||||
store *store.Store,
|
||||
server *grpc.Server,
|
||||
corroService corroservice.Service,
|
||||
dockerCli *client.Client,
|
||||
dockerService *docker.Service,
|
||||
dockerReady chan<- struct{},
|
||||
caddyfileCtrl *caddyconfig.Controller,
|
||||
dnsServer *dns.Server,
|
||||
dnsResolver *dns.ClusterResolver,
|
||||
unregistry *unregistry.Registry,
|
||||
) (*clusterController, error) {
|
||||
slog.Info("Starting WireGuard network.")
|
||||
wgnet, err := network.NewWireGuardNetwork()
|
||||
@@ -77,12 +79,12 @@ func newClusterController(
|
||||
endpointChanges: endpointChanges,
|
||||
server: server,
|
||||
corroService: corroService,
|
||||
dockerCli: dockerCli,
|
||||
dockerManager: docker.NewManager(dockerCli, state.ID, store),
|
||||
dockerCtrl: docker.NewController(state.ID, dockerService, store),
|
||||
dockerReady: dockerReady,
|
||||
caddyconfigCtrl: caddyfileCtrl,
|
||||
dnsServer: dnsServer,
|
||||
dnsResolver: dnsResolver,
|
||||
unregistry: unregistry,
|
||||
stopped: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
@@ -90,7 +92,7 @@ func newClusterController(
|
||||
func (cc *clusterController) Run(ctx context.Context) error {
|
||||
defer close(cc.stopped)
|
||||
|
||||
if err := firewall.ConfigureIptablesChains(); err != nil {
|
||||
if err := firewall.ConfigureIptablesChains(network.MachineIP(cc.state.Network.Subnet)); err != nil {
|
||||
return fmt.Errorf("configure iptables chains: %w", err)
|
||||
}
|
||||
|
||||
@@ -167,7 +169,7 @@ func (cc *clusterController) Run(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
// Watch for endpoint changes and update the machine state accordingly.
|
||||
// Watch for WireGuard peer endpoint changes and update the machine state accordingly.
|
||||
errGroup.Go(func() error {
|
||||
for {
|
||||
select {
|
||||
@@ -213,6 +215,16 @@ func (cc *clusterController) Run(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
if cc.unregistry != nil {
|
||||
errGroup.Go(func() error {
|
||||
slog.Info("Starting unregistry server.")
|
||||
if err := cc.unregistry.ListenAndServe(); err != nil {
|
||||
return fmt.Errorf("unregistry server failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for the context to be done and stop the network API server.
|
||||
<-ctx.Done()
|
||||
slog.Info("Stopping network API server.")
|
||||
@@ -220,6 +232,19 @@ func (cc *clusterController) Run(ctx context.Context) error {
|
||||
cc.server.GracefulStop()
|
||||
slog.Info("Network API server stopped.")
|
||||
|
||||
// Stop the unregistry server with a timeout if it was started.
|
||||
if cc.unregistry != nil {
|
||||
unregTimeout := 30 * time.Second
|
||||
slog.Info("Stopping unregistry server.", "timeout", unregTimeout)
|
||||
unregCtx, cancel := context.WithTimeout(context.Background(), unregTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err = cc.unregistry.Shutdown(unregCtx); err != nil {
|
||||
return fmt.Errorf("unregistry server forced to shutdown: %w", err)
|
||||
}
|
||||
slog.Info("Unregistry server stopped.")
|
||||
}
|
||||
|
||||
// Wait for all controllers to finish.
|
||||
err = errGroup.Wait()
|
||||
|
||||
@@ -238,11 +263,11 @@ func (cc *clusterController) Run(ctx context.Context) error {
|
||||
|
||||
// ensureDockerNetwork ensures that the Docker network is configured and ready for containers.
|
||||
func (cc *clusterController) ensureDockerNetwork(ctx context.Context) error {
|
||||
if err := cc.dockerManager.WaitDaemonReady(ctx); err != nil {
|
||||
if err := cc.dockerCtrl.WaitDaemonReady(ctx); err != nil {
|
||||
return fmt.Errorf("wait for Docker daemon: %w", err)
|
||||
}
|
||||
|
||||
if err := cc.dockerManager.EnsureUncloudNetwork(
|
||||
if err := cc.dockerCtrl.EnsureUncloudNetwork(
|
||||
ctx,
|
||||
cc.state.Network.Subnet,
|
||||
cc.dnsServer.ListenAddr(),
|
||||
@@ -257,6 +282,7 @@ func (cc *clusterController) ensureDockerNetwork(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// syncDockerContainers watches local Docker containers and syncs them to the cluster store.
|
||||
// TODO: move this to the Docker controller.
|
||||
func (cc *clusterController) syncDockerContainers(ctx context.Context) error {
|
||||
// Retry to watch and sync containers until the context is done.
|
||||
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||
@@ -265,7 +291,7 @@ func (cc *clusterController) syncDockerContainers(ctx context.Context) error {
|
||||
backoff.WithMaxElapsedTime(0),
|
||||
), ctx)
|
||||
watchAndSync := func() error {
|
||||
if wErr := cc.dockerManager.WatchAndSyncContainers(ctx); wErr != nil {
|
||||
if wErr := cc.dockerCtrl.WatchAndSyncContainers(ctx); wErr != nil {
|
||||
slog.Error("Failed to watch and sync containers to cluster store, retrying.", "err", wErr)
|
||||
return wErr
|
||||
}
|
||||
@@ -413,7 +439,7 @@ func (cc *clusterController) Cleanup() error {
|
||||
<-cc.stopped
|
||||
|
||||
var errs []error
|
||||
if err := cc.dockerManager.Cleanup(); err != nil {
|
||||
if err := cc.dockerCtrl.Cleanup(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cleanup Docker resources: %w", err))
|
||||
}
|
||||
if err := cc.wgnet.Cleanup(); err != nil {
|
||||
|
||||
@@ -44,7 +44,7 @@ func (c *Cluster) ReserveDomain(ctx context.Context, req *pb.ReserveDomainReques
|
||||
dnsClient := dns.NewClient()
|
||||
name, token, err := dnsClient.ReserveDomain(req.Endpoint)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
domain := uncloudDNSDomain{
|
||||
|
||||
@@ -3,4 +3,6 @@ package constants
|
||||
const (
|
||||
// MachineAPIPort is the port for the Machine API service on the management WireGuard network.
|
||||
MachineAPIPort = 51000
|
||||
// UnregistryPort is the port for the embedded container registry listening on the machine IP.
|
||||
UnregistryPort = 5000
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
@@ -30,7 +31,7 @@ type DockerService struct {
|
||||
func (s *DockerService) Start(ctx context.Context) error {
|
||||
_, err := s.Client.ContainerInspect(ctx, s.Name)
|
||||
if err != nil {
|
||||
if !client.IsErrNotFound(err) {
|
||||
if !errdefs.IsNotFound(err) {
|
||||
return fmt.Errorf("inspect container %q: %w", s.Name, err)
|
||||
}
|
||||
if err = s.startNewContainer(ctx); err != nil {
|
||||
@@ -114,7 +115,7 @@ func (s *DockerService) hostConfig() *container.HostConfig {
|
||||
func (s *DockerService) startNewContainer(ctx context.Context) error {
|
||||
_, err := s.Client.ContainerCreate(ctx, s.containerConfig(), s.hostConfig(), nil, nil, s.Name)
|
||||
if err != nil {
|
||||
if !client.IsErrNotFound(err) {
|
||||
if !errdefs.IsNotFound(err) {
|
||||
return fmt.Errorf("create container: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,10 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
// ClusterResolver implements Resolver by tracking containers in the cluster and resolving service names
|
||||
@@ -84,19 +82,20 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
|
||||
continue
|
||||
}
|
||||
|
||||
ctr := api.ServiceContainer{Container: record.Container}
|
||||
ctr := record.Container
|
||||
if ctr.ServiceID() == "" || ctr.ServiceName() == "" {
|
||||
// Container is not part of a service, skip it.
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO: remove normalisation after implementing service name validation:
|
||||
//.https://github.com/psviderski/uncloud/issues/53
|
||||
serviceName := strings.ToLower(ctr.ServiceName())
|
||||
|
||||
newServiceIPs[serviceName] = append(newServiceIPs[serviceName], ip)
|
||||
newServiceIPs[ctr.ServiceName()] = append(newServiceIPs[ctr.ServiceName()], ip)
|
||||
// Also add the service ID as a valid lookup.
|
||||
newServiceIPs[ctr.ServiceID()] = append(newServiceIPs[ctr.ServiceID()], ip)
|
||||
|
||||
// Add <machine-id>.m.<service-name> as a lookup
|
||||
serviceNameWithMachineID := record.MachineID + ".m." + ctr.ServiceName()
|
||||
newServiceIPs[serviceNameWithMachineID] = append(newServiceIPs[serviceNameWithMachineID], ip)
|
||||
|
||||
containersCount++
|
||||
}
|
||||
|
||||
@@ -105,7 +104,7 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
|
||||
r.serviceIPs = newServiceIPs
|
||||
r.mu.Unlock()
|
||||
|
||||
r.log.Debug("DNS records updated.", "services", len(newServiceIPs)/2, "containers", containersCount)
|
||||
r.log.Debug("DNS records updated.", "services", len(newServiceIPs)/3, "containers", containersCount)
|
||||
}
|
||||
|
||||
// Resolve returns IP addresses of the service containers.
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
@@ -16,6 +15,7 @@ import (
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
regtypes "github.com/google/go-containerregistry/pkg/v1/types"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/psviderski/uncloud/internal/docker"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"google.golang.org/grpc"
|
||||
@@ -24,16 +24,18 @@ import (
|
||||
)
|
||||
|
||||
// Client is a gRPC client for the Docker service that provides a similar interface to the Docker HTTP client.
|
||||
// TODO: it doesn't seem there is much value in having this intermediate Docker client.
|
||||
// Consider merging it into the main pkg/client.
|
||||
type Client struct {
|
||||
conn *grpc.ClientConn
|
||||
grpcClient pb.DockerClient
|
||||
GRPCClient pb.DockerClient
|
||||
}
|
||||
|
||||
// NewClient creates a new Docker gRPC client with the provided gRPC connection.
|
||||
func NewClient(conn *grpc.ClientConn) *Client {
|
||||
return &Client{
|
||||
conn: conn,
|
||||
grpcClient: pb.NewDockerClient(conn),
|
||||
GRPCClient: pb.NewDockerClient(conn),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +72,7 @@ func (c *Client) CreateContainer(
|
||||
return resp, fmt.Errorf("marshal platform: %w", err)
|
||||
}
|
||||
|
||||
grpcResp, err := c.grpcClient.CreateContainer(ctx, &pb.CreateContainerRequest{
|
||||
grpcResp, err := c.GRPCClient.CreateContainer(ctx, &pb.CreateContainerRequest{
|
||||
Config: configBytes,
|
||||
HostConfig: hostConfigBytes,
|
||||
NetworkConfig: networkingConfigBytes,
|
||||
@@ -91,10 +93,10 @@ func (c *Client) CreateContainer(
|
||||
}
|
||||
|
||||
// InspectContainer returns the container information for the given container ID.
|
||||
func (c *Client) InspectContainer(ctx context.Context, id string) (types.ContainerJSON, error) {
|
||||
var resp types.ContainerJSON
|
||||
func (c *Client) InspectContainer(ctx context.Context, id string) (container.InspectResponse, error) {
|
||||
var resp container.InspectResponse
|
||||
|
||||
grpcResp, err := c.grpcClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||
grpcResp, err := c.GRPCClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||
if err != nil {
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return resp, errdefs.NotFound(err)
|
||||
@@ -115,7 +117,7 @@ func (c *Client) StartContainer(ctx context.Context, id string, opts container.S
|
||||
return fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
_, err = c.grpcClient.StartContainer(ctx, &pb.StartContainerRequest{
|
||||
_, err = c.GRPCClient.StartContainer(ctx, &pb.StartContainerRequest{
|
||||
Id: id,
|
||||
Options: optsBytes,
|
||||
})
|
||||
@@ -134,7 +136,7 @@ func (c *Client) StopContainer(ctx context.Context, id string, opts container.St
|
||||
return fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
_, err = c.grpcClient.StopContainer(ctx, &pb.StopContainerRequest{
|
||||
_, err = c.GRPCClient.StopContainer(ctx, &pb.StopContainerRequest{
|
||||
Id: id,
|
||||
Options: optsBytes,
|
||||
})
|
||||
@@ -148,7 +150,7 @@ func (c *Client) StopContainer(ctx context.Context, id string, opts container.St
|
||||
|
||||
type MachineContainers struct {
|
||||
Metadata *pb.Metadata
|
||||
Containers []types.ContainerJSON
|
||||
Containers []container.InspectResponse
|
||||
}
|
||||
|
||||
func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions) ([]MachineContainers, error) {
|
||||
@@ -157,7 +159,7 @@ func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions)
|
||||
return nil, fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.grpcClient.ListContainers(ctx, &pb.ListContainersRequest{Options: optsBytes})
|
||||
resp, err := c.GRPCClient.ListContainers(ctx, &pb.ListContainersRequest{Options: optsBytes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -184,7 +186,7 @@ func (c *Client) RemoveContainer(ctx context.Context, id string, opts container.
|
||||
return fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
_, err = c.grpcClient.RemoveContainer(ctx, &pb.RemoveContainerRequest{
|
||||
_, err = c.GRPCClient.RemoveContainer(ctx, &pb.RemoveContainerRequest{
|
||||
Id: id,
|
||||
Options: optsBytes,
|
||||
})
|
||||
@@ -196,18 +198,27 @@ func (c *Client) RemoveContainer(ctx context.Context, id string, opts container.
|
||||
return err
|
||||
}
|
||||
|
||||
type PullImageMessage struct {
|
||||
Message jsonmessage.JSONMessage
|
||||
Err error
|
||||
// PullOptions defines the options for pulling an image from a remote registry.
|
||||
// This is a copy of image.PullOptions from the Docker API without the PrivilegeFunc field that is non-serialisable.
|
||||
type PullOptions struct {
|
||||
All bool
|
||||
// RegistryAuth is the base64 encoded credentials for the registry.
|
||||
RegistryAuth string
|
||||
Platform string
|
||||
}
|
||||
|
||||
func (c *Client) PullImage(ctx context.Context, image string) (<-chan PullImageMessage, error) {
|
||||
stream, err := c.grpcClient.PullImage(ctx, &pb.PullImageRequest{Image: image})
|
||||
func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions) (<-chan docker.PullPushImageMessage, error) {
|
||||
optsBytes, err := json.Marshal(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
stream, err := c.GRPCClient.PullImage(ctx, &pb.PullImageRequest{Image: image, Options: optsBytes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan PullImageMessage)
|
||||
ch := make(chan docker.PullPushImageMessage)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
@@ -218,16 +229,16 @@ func (c *Client) PullImage(ctx context.Context, image string) (<-chan PullImageM
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ch <- PullImageMessage{Err: err}
|
||||
ch <- docker.PullPushImageMessage{Err: err}
|
||||
return
|
||||
}
|
||||
|
||||
var jm jsonmessage.JSONMessage
|
||||
if err = json.Unmarshal(msg.Message, &jm); err != nil {
|
||||
ch <- PullImageMessage{Err: fmt.Errorf("unmarshal JSON message: %w", err)}
|
||||
ch <- docker.PullPushImageMessage{Err: fmt.Errorf("unmarshal JSON message: %w", err)}
|
||||
return
|
||||
}
|
||||
ch <- PullImageMessage{Message: jm}
|
||||
ch <- docker.PullPushImageMessage{Message: jm}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -236,7 +247,7 @@ func (c *Client) PullImage(ctx context.Context, image string) (<-chan PullImageM
|
||||
|
||||
// InspectImage returns the image information for the given image ID. The request may be sent to multiple machines.
|
||||
func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
||||
resp, err := c.grpcClient.InspectImage(ctx, &pb.InspectImageRequest{Id: id})
|
||||
resp, err := c.GRPCClient.InspectImage(ctx, &pb.InspectImageRequest{Id: id})
|
||||
if err != nil {
|
||||
// If the request was sent to only one machine, err is an actual error from the machine.
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
@@ -274,7 +285,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
|
||||
// credentials if necessary. If the response from a machine doesn't contain an error, the api.RemoteImage will either
|
||||
// contain an IndexManifest or an ImageManifest.
|
||||
func (c *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
|
||||
resp, err := c.grpcClient.InspectRemoteImage(ctx, &pb.InspectRemoteImageRequest{Id: id})
|
||||
resp, err := c.GRPCClient.InspectRemoteImage(ctx, &pb.InspectRemoteImageRequest{Id: id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -351,7 +362,7 @@ func (c *Client) CreateVolume(ctx context.Context, opts volume.CreateOptions) (v
|
||||
return vol, fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.grpcClient.CreateVolume(ctx, &pb.CreateVolumeRequest{Options: optsBytes})
|
||||
resp, err := c.GRPCClient.CreateVolume(ctx, &pb.CreateVolumeRequest{Options: optsBytes})
|
||||
if err != nil {
|
||||
return vol, err
|
||||
}
|
||||
@@ -363,20 +374,18 @@ func (c *Client) CreateVolume(ctx context.Context, opts volume.CreateOptions) (v
|
||||
return vol, nil
|
||||
}
|
||||
|
||||
// MachineVolumes represents a volume list response from a machine.
|
||||
type MachineVolumes struct {
|
||||
Metadata *pb.Metadata
|
||||
Response volume.ListResponse
|
||||
}
|
||||
|
||||
// ListVolumes returns a list of all volumes matching the filter.
|
||||
func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]MachineVolumes, error) {
|
||||
optsBytes, err := json.Marshal(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.grpcClient.ListVolumes(ctx, &pb.ListVolumesRequest{Options: optsBytes})
|
||||
resp, err := c.GRPCClient.ListVolumes(ctx, &pb.ListVolumesRequest{Options: optsBytes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -398,7 +407,7 @@ func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]Ma
|
||||
|
||||
// RemoveVolume removes a volume with the given ID.
|
||||
func (c *Client) RemoveVolume(ctx context.Context, id string, force bool) error {
|
||||
_, err := c.grpcClient.RemoveVolume(ctx, &pb.RemoveVolumeRequest{
|
||||
_, err := c.GRPCClient.RemoveVolume(ctx, &pb.RemoveVolumeRequest{
|
||||
Id: id,
|
||||
Force: force,
|
||||
})
|
||||
@@ -421,7 +430,7 @@ func (c *Client) CreateServiceContainer(
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("marshal service spec: %w", err)
|
||||
}
|
||||
grpcResp, err := c.grpcClient.CreateServiceContainer(ctx, &pb.CreateServiceContainerRequest{
|
||||
grpcResp, err := c.GRPCClient.CreateServiceContainer(ctx, &pb.CreateServiceContainerRequest{
|
||||
ServiceId: serviceID,
|
||||
ServiceSpec: specBytes,
|
||||
ContainerName: containerName,
|
||||
@@ -444,7 +453,7 @@ func (c *Client) CreateServiceContainer(
|
||||
func (c *Client) InspectServiceContainer(ctx context.Context, id string) (api.ServiceContainer, error) {
|
||||
var resp api.ServiceContainer
|
||||
|
||||
grpcResp, err := c.grpcClient.InspectServiceContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||
grpcResp, err := c.GRPCClient.InspectServiceContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||
if err != nil {
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return resp, errdefs.NotFound(err)
|
||||
@@ -477,7 +486,7 @@ func (c *Client) ListServiceContainers(
|
||||
return nil, fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.grpcClient.ListServiceContainers(ctx, &pb.ListServiceContainersRequest{
|
||||
resp, err := c.GRPCClient.ListServiceContainers(ctx, &pb.ListServiceContainersRequest{
|
||||
ServiceId: serviceNameOrID,
|
||||
Options: optsBytes,
|
||||
})
|
||||
@@ -516,7 +525,7 @@ func (c *Client) RemoveServiceContainer(ctx context.Context, id string, opts con
|
||||
return fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
_, err = c.grpcClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
|
||||
_, err = c.GRPCClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
|
||||
Id: id,
|
||||
Options: optsBytes,
|
||||
})
|
||||
|
||||
@@ -7,12 +7,11 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
dockercontainer "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/filters"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -24,23 +23,26 @@ const (
|
||||
SyncInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
client *client.Client
|
||||
// Controller monitors Docker events and synchronises service containers with the cluster store.
|
||||
type Controller struct {
|
||||
// machineID is the ID of the machine where the managed Docker daemon is running.
|
||||
machineID string
|
||||
client *client.Client
|
||||
service *Service
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
func NewManager(client *client.Client, machineID string, store *store.Store) *Manager {
|
||||
return &Manager{
|
||||
client: client,
|
||||
func NewController(machineID string, service *Service, store *store.Store) *Controller {
|
||||
return &Controller{
|
||||
machineID: machineID,
|
||||
client: service.Client,
|
||||
service: service,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests.
|
||||
func (m *Manager) WaitDaemonReady(ctx context.Context) error {
|
||||
func (c *Controller) WaitDaemonReady(ctx context.Context) error {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -50,7 +52,7 @@ func (m *Manager) WaitDaemonReady(ctx context.Context) error {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
_, err := m.client.Ping(ctx)
|
||||
_, err := c.client.Ping(ctx)
|
||||
if err == nil {
|
||||
ready = true
|
||||
break
|
||||
@@ -67,7 +69,7 @@ func (m *Manager) WaitDaemonReady(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) WatchAndSyncContainers(ctx context.Context) error {
|
||||
func (c *Controller) WatchAndSyncContainers(ctx context.Context) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
// Filter only local container events.
|
||||
@@ -79,9 +81,9 @@ func (m *Manager) WatchAndSyncContainers(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Subscribe to Docker events before running the initial sync to avoid missing any events.
|
||||
eventCh, errCh := m.client.Events(ctx, opts)
|
||||
eventCh, errCh := c.service.Client.Events(ctx, opts)
|
||||
slog.Debug("Syncing containers to cluster store before processing Docker events.")
|
||||
if err := m.syncContainersToStore(ctx); err != nil {
|
||||
if err := c.syncContainersToStore(ctx); err != nil {
|
||||
// The deferred cancel will stop the event subscription.
|
||||
return fmt.Errorf("sync containers to cluster store: %w", err)
|
||||
}
|
||||
@@ -126,13 +128,13 @@ func (m *Manager) WatchAndSyncContainers(ctx context.Context) error {
|
||||
"container_name", e.Actor.Attributes["name"],
|
||||
"action", e.Action)
|
||||
|
||||
if err := m.syncContainersToStore(ctx); err != nil {
|
||||
if err := c.syncContainersToStore(ctx); err != nil {
|
||||
return fmt.Errorf("sync containers to cluster store: %w", err)
|
||||
}
|
||||
case <-ticker.C:
|
||||
slog.Debug("Syncing containers to cluster store triggered by a regular interval.",
|
||||
"interval", SyncInterval)
|
||||
if err := m.syncContainersToStore(ctx); err != nil {
|
||||
if err := c.syncContainersToStore(ctx); err != nil {
|
||||
return fmt.Errorf("sync containers to cluster store: %w", err)
|
||||
}
|
||||
case err := <-errCh:
|
||||
@@ -144,33 +146,16 @@ func (m *Manager) WatchAndSyncContainers(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) syncContainersToStore(ctx context.Context) error {
|
||||
storeContainers, err := m.store.ListContainers(ctx, store.ListOptions{MachineIDs: []string{m.machineID}})
|
||||
func (c *Controller) syncContainersToStore(ctx context.Context) error {
|
||||
storeContainers, err := c.store.ListContainers(ctx, store.ListOptions{MachineIDs: []string{c.machineID}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list containers from store: %w", err)
|
||||
}
|
||||
|
||||
// List only Uncloud service containers identified by their labels.
|
||||
containerSummaries, err := m.client.ContainerList(ctx, dockercontainer.ListOptions{
|
||||
Filters: filters.NewArgs(
|
||||
filters.Arg("label", api.LabelServiceID),
|
||||
filters.Arg("label", api.LabelServiceName),
|
||||
filters.Arg("label", api.LabelManaged),
|
||||
),
|
||||
})
|
||||
containers, err := c.service.ListServiceContainers(ctx, "", container.ListOptions{})
|
||||
if err != nil {
|
||||
// TODO: mark all containers as outdated in the store.
|
||||
return fmt.Errorf("list Docker containers: %w", err)
|
||||
}
|
||||
|
||||
// Inspect each container to get the full container details.
|
||||
containers := make([]api.Container, len(containerSummaries))
|
||||
for i, cs := range containerSummaries {
|
||||
ctr, err := m.client.ContainerInspect(ctx, cs.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect container '%s': %w", cs.ID, err)
|
||||
}
|
||||
containers[i] = api.Container{ContainerJSON: ctr}
|
||||
return fmt.Errorf("list service containers: %w", err)
|
||||
}
|
||||
|
||||
// Delete containers from the store that are no longer present in the Docker daemon.
|
||||
@@ -190,15 +175,15 @@ func (m *Manager) syncContainersToStore(ctx context.Context) error {
|
||||
|
||||
var storeErr error
|
||||
if len(deleteIDs) > 0 {
|
||||
if err = m.store.DeleteContainers(ctx, store.DeleteOptions{IDs: deleteIDs}); err != nil {
|
||||
if err = c.store.DeleteContainers(ctx, store.DeleteOptions{IDs: deleteIDs}); err != nil {
|
||||
storeErr = fmt.Errorf("delete containers from store: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create or update the current Docker containers in the store.
|
||||
for _, c := range containers {
|
||||
if err = m.store.CreateOrUpdateContainer(ctx, c, m.machineID); err != nil {
|
||||
storeErr = errors.Join(storeErr, fmt.Errorf("create or update container %q: %w", c.ID, err))
|
||||
for _, ctr := range containers {
|
||||
if err = c.store.CreateOrUpdateContainer(ctx, ctr, c.machineID); err != nil {
|
||||
storeErr = errors.Join(storeErr, fmt.Errorf("create or update container '%s': %w", ctr.ID, err))
|
||||
}
|
||||
}
|
||||
return storeErr
|
||||
+2
-2
@@ -9,11 +9,11 @@ import (
|
||||
)
|
||||
|
||||
// EnsureUncloudNetwork is a stub for Darwin.
|
||||
func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix, dnsServer netip.Addr) error {
|
||||
func (c *Controller) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix, dnsServer netip.Addr) error {
|
||||
return fmt.Errorf("not supported on Darwin")
|
||||
}
|
||||
|
||||
// Cleanup is a stub for Darwin.
|
||||
func (m *Manager) Cleanup() error {
|
||||
func (c *Controller) Cleanup() error {
|
||||
return fmt.Errorf("not supported on Darwin")
|
||||
}
|
||||
+17
-17
@@ -8,10 +8,10 @@ import (
|
||||
"net/netip"
|
||||
"strconv"
|
||||
|
||||
"github.com/containerd/errdefs"
|
||||
dockercontainer "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dnetwork "github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/libnetwork/iptables"
|
||||
"github.com/psviderski/uncloud/internal/machine/dns"
|
||||
"github.com/psviderski/uncloud/internal/machine/firewall"
|
||||
@@ -22,12 +22,12 @@ import (
|
||||
// EnsureUncloudNetwork creates the Docker bridge network NetworkName with the provided machine subnet
|
||||
// if it doesn't exist. If the network exists but has a different subnet, it removes and recreates the network.
|
||||
// It also configures iptables to allow container access from the WireGuard network.
|
||||
func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix, dnsServer netip.Addr) error {
|
||||
func (c *Controller) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix, dnsServer netip.Addr) error {
|
||||
// Ensure the Docker network 'uncloud' is created with the correct subnet.
|
||||
needsCreation := false
|
||||
nw, err := m.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
|
||||
nw, err := c.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
|
||||
if err != nil {
|
||||
if !client.IsErrNotFound(err) {
|
||||
if !errdefs.IsNotFound(err) {
|
||||
return fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err)
|
||||
}
|
||||
needsCreation = true
|
||||
@@ -37,7 +37,7 @@ func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix,
|
||||
slog.Info(
|
||||
"Removing Docker network with old subnet.", "name", NetworkName, "subnet", nw.IPAM.Config[0].Subnet,
|
||||
)
|
||||
if err = m.client.NetworkRemove(ctx, NetworkName); err != nil {
|
||||
if err = c.client.NetworkRemove(ctx, NetworkName); err != nil {
|
||||
// It can still fail if the network is in use by a container. Leave it to the user to resolve the issue.
|
||||
return fmt.Errorf("remove Docker network '%s': %w", NetworkName, err)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix,
|
||||
}
|
||||
|
||||
if needsCreation {
|
||||
if _, err = m.client.NetworkCreate(
|
||||
if _, err = c.client.NetworkCreate(
|
||||
ctx, NetworkName, dnetwork.CreateOptions{
|
||||
Driver: "bridge",
|
||||
Scope: "local",
|
||||
@@ -70,7 +70,7 @@ func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix,
|
||||
}
|
||||
slog.Info("Docker network created.", "name", NetworkName, "subnet", subnet.String())
|
||||
|
||||
if nw, err = m.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{}); err != nil {
|
||||
if nw, err = c.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{}); err != nil {
|
||||
return fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err)
|
||||
}
|
||||
}
|
||||
@@ -168,12 +168,12 @@ func cleanupIptables(bridgeName string, subnet netip.Prefix) error {
|
||||
}
|
||||
|
||||
// Cleanup removes all uncloud-managed containers and the uncloud Docker network.
|
||||
func (m *Manager) Cleanup() error {
|
||||
func (c *Controller) Cleanup() error {
|
||||
ctx := context.Background()
|
||||
var errs []error
|
||||
|
||||
// Remove uncloud-managed Docker containers.
|
||||
containers, err := m.client.ContainerList(ctx, dockercontainer.ListOptions{
|
||||
containers, err := c.client.ContainerList(ctx, dockercontainer.ListOptions{
|
||||
All: true, // Include stopped containers.
|
||||
Filters: filters.NewArgs(
|
||||
filters.Arg("label", api.LabelManaged),
|
||||
@@ -186,18 +186,18 @@ func (m *Manager) Cleanup() error {
|
||||
removed := 0
|
||||
|
||||
for _, ctr := range containers {
|
||||
err = m.client.ContainerStop(ctx, ctr.ID, dockercontainer.StopOptions{})
|
||||
if err != nil && !client.IsErrNotFound(err) {
|
||||
err = c.client.ContainerStop(ctx, ctr.ID, dockercontainer.StopOptions{})
|
||||
if err != nil && !errdefs.IsNotFound(err) {
|
||||
errs = append(errs, fmt.Errorf("stop container '%s': %w", ctr.ID, err))
|
||||
}
|
||||
|
||||
err = m.client.ContainerRemove(ctx, ctr.ID, dockercontainer.RemoveOptions{
|
||||
err = c.client.ContainerRemove(ctx, ctr.ID, dockercontainer.RemoveOptions{
|
||||
// Remove anonymous volumes created by the container.
|
||||
RemoveVolumes: true,
|
||||
})
|
||||
if err == nil {
|
||||
removed++
|
||||
} else if !client.IsErrNotFound(err) {
|
||||
} else if !errdefs.IsNotFound(err) {
|
||||
errs = append(errs, fmt.Errorf("remove container '%s': %w", ctr.ID, err))
|
||||
}
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func (m *Manager) Cleanup() error {
|
||||
}
|
||||
|
||||
// Remove the uncloud Docker network and related iptables rules.
|
||||
nw, err := m.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
|
||||
nw, err := c.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
|
||||
if err == nil {
|
||||
bridgeName := "br-" + nw.ID[:12]
|
||||
var subnet netip.Prefix
|
||||
@@ -221,12 +221,12 @@ func (m *Manager) Cleanup() error {
|
||||
}
|
||||
}
|
||||
|
||||
if err = m.client.NetworkRemove(ctx, NetworkName); err == nil {
|
||||
if err = c.client.NetworkRemove(ctx, NetworkName); err == nil {
|
||||
slog.Info("Docker network removed.", "name", NetworkName)
|
||||
} else if !client.IsErrNotFound(err) {
|
||||
} else if !errdefs.IsNotFound(err) {
|
||||
errs = append(errs, fmt.Errorf("remove Docker network '%s': %w", NetworkName, err))
|
||||
}
|
||||
} else if !client.IsErrNotFound(err) {
|
||||
} else if !errdefs.IsNotFound(err) {
|
||||
errs = append(errs, fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err))
|
||||
}
|
||||
|
||||
+263
-105
@@ -1,21 +1,28 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/docker/api/types"
|
||||
dockercommand "github.com/docker/cli/cli/command"
|
||||
dockerconfig "github.com/docker/cli/cli/config"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
@@ -45,45 +52,41 @@ var fullDockerIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
// Server implements the gRPC Docker service that proxies requests to the Docker daemon.
|
||||
type Server struct {
|
||||
pb.UnimplementedDockerServer
|
||||
client *client.Client
|
||||
db *sqlx.DB
|
||||
client *client.Client
|
||||
service *Service
|
||||
db *sqlx.DB
|
||||
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
|
||||
// address if the address is unknown (e.g. when the machine is not initialised yet).
|
||||
internalDNSIP func() netip.Addr
|
||||
// machineID is a function that returns the machine ID. It may return an empty string if the machine
|
||||
// is not initialised yet.
|
||||
machineID func() string
|
||||
// networkReady is a function that returns true if the Docker network is ready for containers.
|
||||
networkReady func() bool
|
||||
// waitForNetworkReady is a function that waits for the Docker network to be ready for containers.
|
||||
waitForNetworkReady func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// ServerOption configures the Docker server.
|
||||
type ServerOption func(*Server)
|
||||
|
||||
// WithNetworkReady sets the network readiness check function.
|
||||
func WithNetworkReady(networkReady func() bool) ServerOption {
|
||||
return func(s *Server) {
|
||||
s.networkReady = networkReady
|
||||
}
|
||||
type ServerOptions struct {
|
||||
// TODO: verify if we still need the network readiness checks as the cluster controller ensures the network
|
||||
// is ready before starting the network API server. It may still be needed when communicating with the local
|
||||
// API server but in this case we should probably fail until the cluster is initialised.
|
||||
NetworkReady func() bool
|
||||
WaitForNetworkReady func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// WithWaitForNetworkReady sets the network readiness wait function.
|
||||
func WithWaitForNetworkReady(waitForNetworkReady func(ctx context.Context) error) ServerOption {
|
||||
return func(s *Server) {
|
||||
s.waitForNetworkReady = waitForNetworkReady
|
||||
}
|
||||
}
|
||||
|
||||
// NewServer creates a new Docker gRPC server with the provided Docker client.
|
||||
func NewServer(cli *client.Client, db *sqlx.DB, internalDNSIP func() netip.Addr, opts ...ServerOption) *Server {
|
||||
// NewServer creates a new Docker gRPC server with the provided Docker service.
|
||||
func NewServer(service *Service, db *sqlx.DB, internalDNSIP func() netip.Addr, machineID func() string, opts ServerOptions) *Server {
|
||||
s := &Server{
|
||||
client: cli,
|
||||
client: service.Client,
|
||||
service: service,
|
||||
db: db,
|
||||
internalDNSIP: internalDNSIP,
|
||||
machineID: machineID,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
s.networkReady = opts.NetworkReady
|
||||
s.waitForNetworkReady = opts.WaitForNetworkReady
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -111,10 +114,10 @@ func (s *Server) CreateContainer(ctx context.Context, req *pb.CreateContainerReq
|
||||
|
||||
resp, err := s.client.ContainerCreate(ctx, &config, &hostConfig, &networkConfig, &platform, req.Name)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
@@ -129,10 +132,10 @@ func (s *Server) CreateContainer(ctx context.Context, req *pb.CreateContainerReq
|
||||
func (s *Server) InspectContainer(ctx context.Context, req *pb.InspectContainerRequest) (*pb.InspectContainerResponse, error) {
|
||||
resp, err := s.client.ContainerInspect(ctx, req.Id)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
@@ -162,10 +165,10 @@ func (s *Server) StartContainer(ctx context.Context, req *pb.StartContainerReque
|
||||
}
|
||||
|
||||
if err := s.client.ContainerStart(ctx, req.Id, opts); err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
@@ -181,10 +184,10 @@ func (s *Server) StopContainer(ctx context.Context, req *pb.StopContainerRequest
|
||||
}
|
||||
|
||||
if err := s.client.ContainerStop(ctx, req.Id, opts); err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
@@ -214,13 +217,13 @@ func (s *Server) ListContainers(ctx context.Context, req *pb.ListContainersReque
|
||||
|
||||
containerSummaries, err := s.client.ContainerList(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
containers := make([]types.ContainerJSON, 0, len(containerSummaries))
|
||||
containers := make([]container.InspectResponse, 0, len(containerSummaries))
|
||||
for _, cs := range containerSummaries {
|
||||
c, err := s.client.ContainerInspect(ctx, cs.ID)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
if errdefs.IsNotFound(err) {
|
||||
// The listed container may have been removed while we were inspecting other containers.
|
||||
continue
|
||||
}
|
||||
@@ -253,10 +256,10 @@ func (s *Server) RemoveContainer(ctx context.Context, req *pb.RemoveContainerReq
|
||||
}
|
||||
|
||||
if err := s.client.ContainerRemove(ctx, req.Id, opts); err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
@@ -265,7 +268,6 @@ func (s *Server) RemoveContainer(ctx context.Context, req *pb.RemoveContainerReq
|
||||
func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreamingServer[pb.JSONMessage]) error {
|
||||
ctx := stream.Context()
|
||||
|
||||
// TODO: replace with another JSON serializable type (PullOptions.PrivilegeFunc is not serializable).
|
||||
var opts image.PullOptions
|
||||
if len(req.Options) > 0 {
|
||||
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
||||
@@ -273,9 +275,17 @@ func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreaming
|
||||
}
|
||||
}
|
||||
|
||||
if opts.RegistryAuth == "" {
|
||||
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
||||
dockerConfig := dockerconfig.LoadDefaultConfigFile(os.Stderr)
|
||||
if encodedAuth, err := dockercommand.RetrieveAuthTokenFromImage(dockerConfig, req.Image); err == nil {
|
||||
opts.RegistryAuth = encodedAuth
|
||||
}
|
||||
}
|
||||
|
||||
respBody, err := s.client.ImagePull(ctx, req.Image, opts)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, err.Error())
|
||||
return status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
@@ -306,19 +316,19 @@ func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreaming
|
||||
case err = <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return status.Errorf(codes.Canceled, ctx.Err().Error())
|
||||
return status.Error(codes.Canceled, ctx.Err().Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// InspectImage returns the image information for the given image ID.
|
||||
func (s *Server) InspectImage(ctx context.Context, req *pb.InspectImageRequest) (*pb.InspectImageResponse, error) {
|
||||
resp, _, err := s.client.ImageInspectWithRaw(ctx, req.Id)
|
||||
resp, err := s.client.ImageInspect(ctx, req.Id)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
@@ -374,6 +384,49 @@ func (s *Server) InspectRemoteImage(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListImages returns a list of all images matching the filter and indicates whether Docker is using the containerd
|
||||
// image store.
|
||||
func (s *Server) ListImages(ctx context.Context, req *pb.ListImagesRequest) (*pb.ListImagesResponse, error) {
|
||||
var opts image.ListOptions
|
||||
if len(req.Options) > 0 {
|
||||
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
||||
}
|
||||
|
||||
// Handle filters separately because they implement custom JSON unmarshalling.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(req.Options, &raw); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options to raw map: %v", err)
|
||||
}
|
||||
|
||||
if filtersBytes, ok := raw["Filters"]; ok {
|
||||
args, err := filters.FromJSON(string(filtersBytes))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unmarshal filters: %v", err)
|
||||
}
|
||||
opts.Filters = args
|
||||
}
|
||||
}
|
||||
|
||||
images, err := s.service.ListImages(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
machineImages := pb.MachineImages{
|
||||
ContainerdStore: images.ContainerdStore,
|
||||
}
|
||||
if len(images.Images) > 0 {
|
||||
if machineImages.Images, err = json.Marshal(images.Images); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "marshal Docker images: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &pb.ListImagesResponse{
|
||||
Messages: []*pb.MachineImages{&machineImages},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateVolume creates a new volume with the given options.
|
||||
func (s *Server) CreateVolume(ctx context.Context, req *pb.CreateVolumeRequest) (*pb.CreateVolumeResponse, error) {
|
||||
var opts volume.CreateOptions
|
||||
@@ -445,7 +498,7 @@ func (s *Server) ListVolumes(ctx context.Context, req *pb.ListVolumesRequest) (*
|
||||
// RemoveVolume removes a volume with the given ID.
|
||||
func (s *Server) RemoveVolume(ctx context.Context, req *pb.RemoveVolumeRequest) (*emptypb.Empty, error) {
|
||||
if err := s.client.VolumeRemove(ctx, req.Id, req.Force); err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
@@ -455,6 +508,7 @@ func (s *Server) RemoveVolume(ctx context.Context, req *pb.RemoveVolumeRequest)
|
||||
}
|
||||
|
||||
// CreateServiceContainer creates a new container for the service with the given specifications.
|
||||
// TODO: move the main logic to the Docker service and remove db dependency from the server.
|
||||
func (s *Server) CreateServiceContainer(
|
||||
ctx context.Context, req *pb.CreateServiceContainerRequest,
|
||||
) (*pb.CreateContainerResponse, error) {
|
||||
@@ -480,9 +534,21 @@ func (s *Server) CreateServiceContainer(
|
||||
containerName = fmt.Sprintf("%s-%s", spec.Name, suffix)
|
||||
}
|
||||
|
||||
envVars := maps.Clone(spec.Container.Env)
|
||||
if envVars == nil {
|
||||
envVars = make(api.EnvVars)
|
||||
}
|
||||
|
||||
// Inject the machine ID if available
|
||||
if s.machineID != nil {
|
||||
if machineID := s.machineID(); machineID != "" {
|
||||
envVars["UNCLOUD_MACHINE_ID"] = machineID
|
||||
}
|
||||
}
|
||||
|
||||
config := &container.Config{
|
||||
Cmd: spec.Container.Command,
|
||||
Env: spec.Container.Env.ToSlice(),
|
||||
Env: envVars.ToSlice(),
|
||||
Entrypoint: spec.Container.Entrypoint,
|
||||
Hostname: containerName,
|
||||
Image: spec.Container.Image,
|
||||
@@ -546,10 +612,10 @@ func (s *Server) CreateServiceContainer(
|
||||
Memory: spec.Container.Resources.Memory,
|
||||
MemoryReservation: spec.Container.Resources.MemoryReservation,
|
||||
},
|
||||
// Always restart service containers if they exit or a machine restarts.
|
||||
// Restart service containers if they exit or a machine restarts unless they are explicitly stopped.
|
||||
// For one-off containers and batch jobs we plan to use a different service type/mode.
|
||||
RestartPolicy: container.RestartPolicy{
|
||||
Name: container.RestartPolicyAlways,
|
||||
Name: container.RestartPolicyUnlessStopped,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -578,12 +644,19 @@ func (s *Server) CreateServiceContainer(
|
||||
|
||||
resp, err := s.client.ContainerCreate(ctx, config, hostConfig, networkConfig, nil, containerName)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
// Inject configs into the created container
|
||||
if err = s.injectConfigs(ctx, resp.ID, spec.Configs, spec.Container.ConfigMounts); err != nil {
|
||||
// Remove the container if config injection fails
|
||||
_ = s.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{RemoveVolumes: true})
|
||||
return nil, status.Errorf(codes.Internal, "inject configs: %v", err)
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
||||
@@ -660,6 +733,122 @@ func ToDockerMounts(volumes []api.VolumeSpec, mounts []api.VolumeMount) ([]mount
|
||||
return dockerMounts, nil
|
||||
}
|
||||
|
||||
// injectConfigs writes config content directly into the container.
|
||||
// It processes ConfigSpecs and ConfigMounts to mount configuration content into the container filesystem.
|
||||
func (s *Server) injectConfigs(ctx context.Context, containerID string, configs []api.ConfigSpec, mounts []api.ConfigMount) error {
|
||||
if len(configs) == 0 || len(mounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := api.ValidateConfigsAndMounts(configs, mounts); err != nil {
|
||||
return fmt.Errorf("validate configs and mounts: %w", err)
|
||||
}
|
||||
|
||||
// Create a map of config name to config spec for quick lookup
|
||||
configMap := make(map[string]api.ConfigSpec)
|
||||
for _, config := range configs {
|
||||
configMap[config.Name] = config
|
||||
}
|
||||
|
||||
// Process each config mount
|
||||
for _, m := range mounts {
|
||||
config, exists := configMap[m.ConfigName]
|
||||
if !exists {
|
||||
return fmt.Errorf("config mount references a config that doesn't exist: '%s'", m.ConfigName)
|
||||
}
|
||||
|
||||
// Determine target path in container
|
||||
targetPath := m.ContainerPath
|
||||
if targetPath == "" {
|
||||
// This is the default from the Compose spec
|
||||
targetPath = filepath.Join("/", m.ConfigName)
|
||||
}
|
||||
|
||||
// Determine file mode
|
||||
fileMode := os.FileMode(0o444) // Default permissions
|
||||
if m.Mode != nil {
|
||||
fileMode = *m.Mode
|
||||
}
|
||||
|
||||
uid, err := m.GetNumericUid()
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Uid: %w", err)
|
||||
}
|
||||
|
||||
gid, err := m.GetNumericGid()
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Gid: %w", err)
|
||||
}
|
||||
|
||||
// Copy the config content directly into the container
|
||||
if err := s.copyContentToContainer(
|
||||
ctx, containerID, config.Content, targetPath, uid, gid, fileMode,
|
||||
); err != nil {
|
||||
return fmt.Errorf("copy config file '%s' to container: %w", config.Name, err)
|
||||
}
|
||||
|
||||
slog.Debug("Injected config into container",
|
||||
"config", config.Name,
|
||||
"container", containerID[:12],
|
||||
"target", targetPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyContentToContainer copies content directly to a file in the container using Docker's CopyToContainer API.
|
||||
func (s *Server) copyContentToContainer(ctx context.Context, containerID string, content []byte, targetPath string, uid *uint64, gid *uint64, fileMode os.FileMode) error {
|
||||
// Create a tar archive containing the file
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
|
||||
// Create tar header
|
||||
header := &tar.Header{
|
||||
Name: filepath.Base(targetPath),
|
||||
Size: int64(len(content)),
|
||||
Mode: int64(fileMode),
|
||||
ModTime: time.Now(),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
// Set ownership if specified
|
||||
if uid != nil {
|
||||
header.Uid = int(*uid)
|
||||
}
|
||||
if gid != nil {
|
||||
header.Gid = int(*gid)
|
||||
}
|
||||
|
||||
// Write header and content to tar archive
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return fmt.Errorf("write tar header: %w", err)
|
||||
}
|
||||
if _, err := tw.Write(content); err != nil {
|
||||
return fmt.Errorf("write content to tar: %w", err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return fmt.Errorf("close tar writer: %w", err)
|
||||
}
|
||||
|
||||
// Copy the tar archive to the container
|
||||
targetDir := filepath.Dir(targetPath)
|
||||
if targetDir == "." {
|
||||
targetDir = "/"
|
||||
}
|
||||
|
||||
if err := s.client.CopyToContainer(
|
||||
ctx,
|
||||
containerID,
|
||||
targetDir,
|
||||
&buf,
|
||||
container.CopyToContainerOptions{CopyUIDGID: true},
|
||||
); err != nil {
|
||||
return fmt.Errorf("copy to container: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
|
||||
if opts == nil {
|
||||
return nil
|
||||
@@ -691,7 +880,7 @@ func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mo
|
||||
|
||||
// TODO: non-local volume drivers should likely be handled differently (needs proper investigation).
|
||||
if _, err := s.client.VolumeInspect(ctx, m.Source); err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return status.Errorf(codes.NotFound, "volume '%s' not found", m.Source)
|
||||
}
|
||||
return status.Errorf(codes.Internal, "inspect volume '%s': %v", m.Source, err.Error())
|
||||
@@ -709,27 +898,22 @@ func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mo
|
||||
func (s *Server) InspectServiceContainer(
|
||||
ctx context.Context, req *pb.InspectContainerRequest,
|
||||
) (*pb.ServiceContainer, error) {
|
||||
ctr, err := s.client.ContainerInspect(ctx, req.Id)
|
||||
serviceCtr, err := s.service.InspectServiceContainer(ctx, req.Id)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
ctrBytes, err := json.Marshal(ctr)
|
||||
ctrBytes, err := json.Marshal(serviceCtr.Container)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
||||
return nil, status.Errorf(codes.Internal, "marshal container: %v", err)
|
||||
}
|
||||
|
||||
var specBytes []byte
|
||||
err = s.db.QueryRowContext(ctx, `SELECT service_spec FROM containers WHERE id = $1`, ctr.ID).Scan(&specBytes)
|
||||
specBytes, err := json.Marshal(serviceCtr.ServiceSpec)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, status.Errorf(codes.NotFound, "service spec not found for container: '%s'", ctr.ID)
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, "get service spec for container '%s' from machine database: %v",
|
||||
ctr.ID, err)
|
||||
return nil, status.Errorf(codes.Internal, "marshal service spec: %v", err)
|
||||
}
|
||||
|
||||
return &pb.ServiceContainer{
|
||||
@@ -761,54 +945,28 @@ func (s *Server) ListServiceContainers(
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unmarshal filters: %v", err)
|
||||
}
|
||||
opts.Filters = args
|
||||
} else {
|
||||
opts.Filters = filters.NewArgs()
|
||||
}
|
||||
}
|
||||
// Only uncloud-managed containers that belong to some service.
|
||||
opts.Filters.Add("label", api.LabelServiceID)
|
||||
opts.Filters.Add("label", api.LabelManaged)
|
||||
|
||||
containerSummaries, err := s.client.ContainerList(ctx, opts)
|
||||
containers, err := s.service.ListServiceContainers(ctx, req.ServiceId, opts)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
containers := make([]*pb.ServiceContainer, 0, len(containerSummaries))
|
||||
for _, cs := range containerSummaries {
|
||||
if req.ServiceId != "" &&
|
||||
cs.Labels[api.LabelServiceID] != req.ServiceId && cs.Labels[api.LabelServiceName] != req.ServiceId {
|
||||
continue
|
||||
}
|
||||
|
||||
ctr, err := s.client.ContainerInspect(ctx, cs.ID)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
// The listed container may have been removed while we were inspecting other containers.
|
||||
continue
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, "inspect container %s: %v", cs.ID, err)
|
||||
}
|
||||
ctrBytes, err := json.Marshal(ctr)
|
||||
// Convert to protobuf format.
|
||||
pbContainers := make([]*pb.ServiceContainer, 0, len(containers))
|
||||
for _, ctr := range containers {
|
||||
ctrBytes, err := json.Marshal(ctr.Container)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "marshal container: %v", err)
|
||||
}
|
||||
|
||||
var specBytes []byte
|
||||
err = s.db.QueryRowContext(ctx, `SELECT service_spec FROM containers WHERE id = $1`, ctr.ID).Scan(&specBytes)
|
||||
specBytes, err := json.Marshal(ctr.ServiceSpec)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// If this happens, there is a bug in the code, or someone manually removed the container from the DB,
|
||||
// or created a managed container out of band.
|
||||
slog.Error("Service container not found in machine database.", "id", ctr.ID)
|
||||
// Just ignore such a container to not fail the list operation as it's not easily recoverable.
|
||||
continue
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, "get service spec for container '%s' from machine database: %v",
|
||||
ctr.ID, err)
|
||||
return nil, status.Errorf(codes.Internal, "marshal service spec: %v", err)
|
||||
}
|
||||
|
||||
containers = append(containers, &pb.ServiceContainer{
|
||||
pbContainers = append(pbContainers, &pb.ServiceContainer{
|
||||
Container: ctrBytes,
|
||||
ServiceSpec: specBytes,
|
||||
})
|
||||
@@ -817,7 +975,7 @@ func (s *Server) ListServiceContainers(
|
||||
return &pb.ListServiceContainersResponse{
|
||||
Messages: []*pb.MachineServiceContainers{
|
||||
{
|
||||
Containers: containers,
|
||||
Containers: pbContainers,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
@@ -832,7 +990,7 @@ func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveConta
|
||||
if !fullDockerIDRegex.MatchString(req.Id) {
|
||||
ctr, err := s.client.ContainerInspect(ctx, req.Id)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"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/client"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Service provides higher-level Docker operations that extends Docker API with Uncloud-specific data
|
||||
// from the machine database.
|
||||
type Service struct {
|
||||
// Client is a Docker client for managing Docker resources.
|
||||
Client *client.Client
|
||||
// db is a connection to the machine database.
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewService creates a new Docker service instance.
|
||||
func NewService(client *client.Client, db *sqlx.DB) *Service {
|
||||
return &Service{
|
||||
Client: client,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// InspectServiceContainer inspects a Docker container and retrieves its associated ServiceSpec
|
||||
// from the machine database, returning a complete ServiceContainer.
|
||||
func (s *Service) InspectServiceContainer(ctx context.Context, nameOrID string) (api.ServiceContainer, error) {
|
||||
var serviceCtr api.ServiceContainer
|
||||
|
||||
ctr, err := s.Client.ContainerInspect(ctx, nameOrID)
|
||||
if err != nil {
|
||||
return serviceCtr, err
|
||||
}
|
||||
if _, ok := ctr.Config.Labels[api.LabelManaged]; !ok {
|
||||
return serviceCtr, fmt.Errorf("container '%s' is not managed by Uncloud", nameOrID)
|
||||
}
|
||||
|
||||
serviceCtr.Container = api.Container{InspectResponse: ctr}
|
||||
|
||||
// Retrieve ServiceSpec from the machine database.
|
||||
var specBytes []byte
|
||||
err = s.db.QueryRowContext(ctx, `SELECT service_spec FROM containers WHERE id = $1`, ctr.ID).Scan(&specBytes)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// If this happens, there is a bug in the code, or someone manually removed the container from the DB,
|
||||
// or created a managed container out of band or by previous uncloud installation.
|
||||
return serviceCtr, fmt.Errorf("service spec not found for container '%s' in machine DB", ctr.ID)
|
||||
}
|
||||
return serviceCtr, fmt.Errorf("get service spec for container '%s' from machine DB: %w", ctr.ID, err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(specBytes, &serviceCtr.ServiceSpec); err != nil {
|
||||
return serviceCtr, fmt.Errorf("unmarshal service spec for container '%s': %w", ctr.ID, err)
|
||||
}
|
||||
|
||||
return serviceCtr, nil
|
||||
}
|
||||
|
||||
// ListServiceContainers lists Docker containers that belong to the service with the given name or ID.
|
||||
// If serviceIDOrName is empty, all service containers are returned. The opts parameter allows additional filtering.
|
||||
func (s *Service) ListServiceContainers(
|
||||
ctx context.Context, serviceNameOrID string, opts container.ListOptions,
|
||||
) ([]api.ServiceContainer, error) {
|
||||
if opts.Filters.Len() == 0 {
|
||||
opts.Filters = filters.NewArgs()
|
||||
}
|
||||
// Add labels to existing filters to list only Uncloud-managed service containers.
|
||||
opts.Filters.Add("label", api.LabelServiceID)
|
||||
opts.Filters.Add("label", api.LabelManaged)
|
||||
|
||||
containerSummaries, err := s.Client.ContainerList(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var containers []api.ServiceContainer
|
||||
for _, cs := range containerSummaries {
|
||||
// Filter by service name or ID if provided.
|
||||
if serviceNameOrID != "" &&
|
||||
cs.Labels[api.LabelServiceID] != serviceNameOrID &&
|
||||
cs.Labels[api.LabelServiceName] != serviceNameOrID {
|
||||
continue
|
||||
}
|
||||
|
||||
ctr, err := s.InspectServiceContainer(ctx, cs.ID)
|
||||
if err != nil {
|
||||
// Log error but continue with other containers.
|
||||
slog.Error("Failed to inspect service container.", "service", serviceNameOrID, "id", cs.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
containers = append(containers, ctr)
|
||||
}
|
||||
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
// IsContainerdImageStoreEnabled checks if Docker is configured to use the containerd image store:
|
||||
// https://docs.docker.com/engine/storage/containerd/
|
||||
func (s *Service) IsContainerdImageStoreEnabled(ctx context.Context) (bool, error) {
|
||||
info, err := s.Client.Info(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get Docker info: %w", err)
|
||||
}
|
||||
|
||||
return strings.Contains(fmt.Sprintf("%s", info.DriverStatus), "containerd.snapshotter"), nil
|
||||
}
|
||||
|
||||
type Images struct {
|
||||
// Images is a list of images present in the Docker image store (either internal or containerd).
|
||||
Images []image.Summary
|
||||
// ContainerdStore indicates whether Docker is using the containerd image store.
|
||||
ContainerdStore bool
|
||||
}
|
||||
|
||||
// ListImages lists Docker images with the given options and indicates whether Docker is using the containerd
|
||||
// image store. It always includes image manifests in the response if the store is containerd.
|
||||
func (s *Service) ListImages(ctx context.Context, opts image.ListOptions) (Images, error) {
|
||||
var imagesResp Images
|
||||
|
||||
// Always include the image manifests in the response.
|
||||
opts.Manifests = true
|
||||
images, err := s.Client.ImageList(ctx, opts)
|
||||
if err != nil {
|
||||
return imagesResp, status.Errorf(codes.Internal, "list images: %v", err)
|
||||
}
|
||||
|
||||
isContainerdStore, err := s.IsContainerdImageStoreEnabled(ctx)
|
||||
if err != nil {
|
||||
return imagesResp, status.Errorf(codes.Internal, "check if Docker uses containerd image store: %v", err)
|
||||
}
|
||||
|
||||
imagesResp = Images{
|
||||
Images: images,
|
||||
ContainerdStore: isContainerdStore,
|
||||
}
|
||||
|
||||
return imagesResp, nil
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package firewall
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// ConfigureIptablesChains is a stub for Darwin.
|
||||
func ConfigureIptablesChains() error {
|
||||
func ConfigureIptablesChains(machineIP netip.Addr) error {
|
||||
return fmt.Errorf("not supported on Darwin")
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package firewall
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -18,7 +19,7 @@ const (
|
||||
)
|
||||
|
||||
// ConfigureIptablesChains sets up custom iptables chains and initial firewall rules for Uncloud networking.
|
||||
func ConfigureIptablesChains() error {
|
||||
func ConfigureIptablesChains(machineIP netip.Addr) error {
|
||||
if err := createIptablesChains(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -27,10 +28,24 @@ func ConfigureIptablesChains() error {
|
||||
ipt6 := iptables.GetIptable(iptables.IPv6)
|
||||
|
||||
// Allow WireGuard traffic to the machine.
|
||||
acceptWireGuardRule := []string{"-p", "udp", "--dport", strconv.Itoa(network.WireGuardPort), "-j", "ACCEPT"}
|
||||
err := ipt4.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, acceptWireGuardRule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert iptables rule '%s': %w", strings.Join(acceptWireGuardRule, " "), err)
|
||||
acceptWireGuardRule := []string{
|
||||
"-p", "udp",
|
||||
"--dport", strconv.Itoa(network.WireGuardPort),
|
||||
"-j", "ACCEPT",
|
||||
}
|
||||
// Allow cluster machines to access the unregistry (embedded image registry) on the machine to push/pull images.
|
||||
// TODO: allow access only from the machine IPs (10.210.N.1) but not the containers running on them. Use ipset?
|
||||
acceptUnregistryRule := []string{
|
||||
"-i", network.WireGuardInterfaceName,
|
||||
"-d", machineIP.String(),
|
||||
"-p", "tcp",
|
||||
"--dport", strconv.Itoa(constants.UnregistryPort),
|
||||
"-j", "ACCEPT",
|
||||
}
|
||||
for _, rule := range [][]string{acceptUnregistryRule, acceptWireGuardRule} {
|
||||
if err := ipt4.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, rule); err != nil {
|
||||
return fmt.Errorf("insert iptables rule '%s': %w", strings.Join(rule, " "), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow cluster machines to access Machine API via the management IPv6 WireGuard network.
|
||||
@@ -50,7 +65,7 @@ func ConfigureIptablesChains() error {
|
||||
"-j", "ACCEPT",
|
||||
}
|
||||
for _, rule := range [][]string{acceptMachineAPIRule, acceptCorrosionGossipRule} {
|
||||
if err = ipt6.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, rule); err != nil {
|
||||
if err := ipt6.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, rule); err != nil {
|
||||
return fmt.Errorf("insert ip6tables rule '%s': %w", strings.Join(rule, " "), err)
|
||||
}
|
||||
}
|
||||
|
||||
+86
-22
@@ -30,7 +30,7 @@ import (
|
||||
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/unregistry"
|
||||
"github.com/siderolabs/grpc-proxy/proxy"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"google.golang.org/grpc"
|
||||
@@ -43,6 +43,9 @@ const (
|
||||
DefaultMachineSockPath = "/run/uncloud/machine.sock"
|
||||
DefaultUncloudSockPath = "/run/uncloud/uncloud.sock"
|
||||
DefaultSockGroup = "uncloud"
|
||||
// DefaultCaddyAdminSockPath is the default path to the Caddy admin socket for validating the generated Caddy
|
||||
// reverse proxy configuration.
|
||||
DefaultCaddyAdminSockPath = "/run/uncloud/caddy/admin.sock"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -61,10 +64,12 @@ type Config struct {
|
||||
|
||||
// DockerClient manages system and user containers using the local Docker daemon.
|
||||
DockerClient *client.Client
|
||||
// ContainerdSockPath is the path to the containerd.sock used by Docker.
|
||||
ContainerdSockPath string
|
||||
|
||||
// CaddyConfigPath specifies where the machine generates the Caddy reverse proxy configuration file for routing
|
||||
// external traffic to service containers across the internal network. Default is DataDir/caddy/caddy.json.
|
||||
CaddyConfigPath string
|
||||
// CaddyConfigDir specifies the directory where the machine generates the Caddy reverse proxy configuration file
|
||||
// for routing external traffic to service containers across the internal network. Default is DataDir/caddy.
|
||||
CaddyConfigDir string
|
||||
// DNSUpstreams specifies the upstream DNS servers for the embedded internal DNS server.
|
||||
DNSUpstreams []netip.AddrPort
|
||||
}
|
||||
@@ -91,6 +96,26 @@ func (c *Config) SetDefaults() (*Config, error) {
|
||||
}
|
||||
cfg.DockerClient = cli
|
||||
}
|
||||
if cfg.ContainerdSockPath == "" {
|
||||
// Auto-detect the containerd.sock path used by Docker.
|
||||
paths := []string{
|
||||
"/run/containerd/containerd.sock", // Default path on most Linux distributions.
|
||||
"/run/docker/containerd/containerd.sock",
|
||||
"/var/run/containerd/containerd.sock",
|
||||
"/var/run/docker/containerd/containerd.sock",
|
||||
}
|
||||
for _, path := range paths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
cfg.ContainerdSockPath = path
|
||||
slog.Debug("Detected containerd socket used by Docker.", "path", path)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.ContainerdSockPath == "" {
|
||||
slog.Warn("Failed to auto-detect containerd socket used by Docker.")
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.CorrosionDir == "" {
|
||||
cfg.CorrosionDir = filepath.Join(cfg.DataDir, "corrosion")
|
||||
@@ -129,8 +154,8 @@ func (c *Config) SetDefaults() (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.CaddyConfigPath == "" {
|
||||
cfg.CaddyConfigPath = filepath.Join(cfg.DataDir, "caddy", "caddy.json")
|
||||
if cfg.CaddyConfigDir == "" {
|
||||
cfg.CaddyConfigDir = filepath.Join(cfg.DataDir, "caddy")
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
@@ -162,7 +187,9 @@ type Machine struct {
|
||||
// store is the cluster store backed by a distributed Corrosion database.
|
||||
store *store.Store
|
||||
cluster *cluster.Cluster
|
||||
docker *machinedocker.Server
|
||||
// dockerService provides high-level operations for managing Docker containers.
|
||||
dockerService *machinedocker.Service
|
||||
dockerServer *machinedocker.Server
|
||||
// localMachineServer is the gRPC server for the machine API listening on the local Unix socket.
|
||||
localMachineServer *grpc.Server
|
||||
|
||||
@@ -222,16 +249,12 @@ func NewMachine(config *Config) (*Machine, error) {
|
||||
c := cluster.NewCluster(corroStore, corroAdmin)
|
||||
|
||||
// Init dependencies for a gRPC Docker server that proxies requests to the local Docker daemon.
|
||||
dockerCli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create Docker client: %w", err)
|
||||
}
|
||||
|
||||
dbFilePath := filepath.Join(config.DataDir, DBFileName)
|
||||
db, err := NewDB(dbFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init machine database: %w", err)
|
||||
}
|
||||
dockerService := machinedocker.NewService(config.DockerClient, db)
|
||||
|
||||
// Init a local gRPC proxy server that proxies requests to the local or remote machine API servers.
|
||||
proxyDirector := apiproxy.NewDirector(config.MachineSockPath, constants.MachineAPIPort)
|
||||
@@ -250,6 +273,7 @@ func NewMachine(config *Config) (*Machine, error) {
|
||||
networkReady: make(chan struct{}),
|
||||
store: corroStore,
|
||||
cluster: c,
|
||||
dockerService: dockerService,
|
||||
localProxyServer: localProxyServer,
|
||||
proxyDirector: proxyDirector,
|
||||
}
|
||||
@@ -258,10 +282,16 @@ func NewMachine(config *Config) (*Machine, error) {
|
||||
internalDNSIP := func() netip.Addr {
|
||||
return m.IP()
|
||||
}
|
||||
m.docker = machinedocker.NewServer(dockerCli, db, internalDNSIP,
|
||||
machinedocker.WithNetworkReady(m.IsNetworkReady),
|
||||
machinedocker.WithWaitForNetworkReady(m.WaitForNetworkReady))
|
||||
m.localMachineServer = newGRPCServer(m, c, m.docker)
|
||||
// Machine ID will only be available after the machine is initialised as a cluster member so wrap it in a function.
|
||||
machineID := func() string {
|
||||
return m.state.ID
|
||||
}
|
||||
m.dockerServer = machinedocker.NewServer(dockerService, db, internalDNSIP, machineID, machinedocker.ServerOptions{
|
||||
NetworkReady: m.IsNetworkReady,
|
||||
WaitForNetworkReady: m.WaitForNetworkReady,
|
||||
})
|
||||
caddyServer := caddyconfig.NewServer(caddyconfig.NewService(config.CaddyConfigDir))
|
||||
m.localMachineServer = newGRPCServer(m, c, m.dockerServer, caddyServer)
|
||||
|
||||
if m.Initialised() {
|
||||
m.initialised <- struct{}{}
|
||||
@@ -270,11 +300,12 @@ func NewMachine(config *Config) (*Machine, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func newGRPCServer(m pb.MachineServer, c pb.ClusterServer, d pb.DockerServer) *grpc.Server {
|
||||
func newGRPCServer(m pb.MachineServer, c pb.ClusterServer, d pb.DockerServer, caddy pb.CaddyServer) *grpc.Server {
|
||||
s := grpc.NewServer()
|
||||
pb.RegisterMachineServer(s, m)
|
||||
pb.RegisterClusterServer(s, c)
|
||||
pb.RegisterDockerServer(s, d)
|
||||
pb.RegisterCaddyServer(s, caddy)
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -388,7 +419,12 @@ func (m *Machine) Run(ctx context.Context) error {
|
||||
|
||||
// Create a new caddyconfig controller for managing the Caddy reverse proxy configuration.
|
||||
// It will also serve the current machine ID at /.uncloud-verify to verify Caddy reachability.
|
||||
caddyconfigCtrl, err := caddyconfig.NewController(m.store, m.config.CaddyConfigPath, m.state.ID)
|
||||
caddyconfigCtrl, err := caddyconfig.NewController(
|
||||
m.state.ID,
|
||||
m.config.CaddyConfigDir,
|
||||
DefaultCaddyAdminSockPath,
|
||||
m.store,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create caddyconfig controller: %w", err)
|
||||
}
|
||||
@@ -399,17 +435,45 @@ func (m *Machine) Run(ctx context.Context) error {
|
||||
return fmt.Errorf("create embedded DNS server: %w", err)
|
||||
}
|
||||
|
||||
var unreg *unregistry.Registry
|
||||
if m.config.ContainerdSockPath != "" {
|
||||
isContainerdStore, err := m.dockerService.IsContainerdImageStoreEnabled(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check if Docker uses containerd image store: %w", err)
|
||||
}
|
||||
|
||||
if isContainerdStore {
|
||||
// Create an embedded container registry listening on the machine IP address and
|
||||
// using the local Docker (containerd) image store as its backend.
|
||||
unreg, err = unregistry.NewRegistry(unregistry.Config{
|
||||
Addr: net.JoinHostPort(m.IP().String(), strconv.Itoa(constants.UnregistryPort)),
|
||||
ContainerdNamespace: "moby",
|
||||
ContainerdSock: m.config.ContainerdSockPath,
|
||||
LogFormatter: "text",
|
||||
LogLevel: "info",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create embedded registry: %w", err)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("Skipping embedded unregistry setup as Docker is not using the containerd image store.")
|
||||
}
|
||||
} else {
|
||||
slog.Warn("Skipping embedded unregistry setup as the containerd socket path is not configured.")
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.clusterCtrl, err = newClusterController(
|
||||
m.state,
|
||||
m.store,
|
||||
proxyServer,
|
||||
m.config.CorrosionService,
|
||||
m.config.DockerClient,
|
||||
m.dockerService,
|
||||
m.networkReady,
|
||||
caddyconfigCtrl,
|
||||
dnsServer,
|
||||
dnsResolver,
|
||||
unreg,
|
||||
)
|
||||
m.mu.Unlock()
|
||||
if err != nil {
|
||||
@@ -569,7 +633,7 @@ func (m *Machine) cleanup() error {
|
||||
}
|
||||
|
||||
// CheckPrerequisites verifies if the machine meets all necessary system requirements to participate in the cluster.
|
||||
func (m *Machine) CheckPrerequisites(ctx context.Context, _ *emptypb.Empty) (*pb.CheckPrerequisitesResponse, error) {
|
||||
func (m *Machine) CheckPrerequisites(_ context.Context, _ *emptypb.Empty) (*pb.CheckPrerequisitesResponse, error) {
|
||||
// Check DNS port (UDP) availability.
|
||||
if err := checkDNSPortAvailable(); err != nil {
|
||||
return &pb.CheckPrerequisitesResponse{
|
||||
@@ -835,7 +899,7 @@ func (m *Machine) WaitForNetworkReady(ctx context.Context) error {
|
||||
|
||||
// Reset restores the machine to a clean state, scheduling a graceful shutdown and removing all cluster-related
|
||||
// configuration and resource. The uncloud daemon will restart the machine if managed by systemd.
|
||||
func (m *Machine) Reset(ctx context.Context, _ *pb.ResetRequest) (*emptypb.Empty, error) {
|
||||
func (m *Machine) Reset(_ context.Context, _ *pb.ResetRequest) (*emptypb.Empty, error) {
|
||||
if !m.Initialised() {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -890,7 +954,7 @@ func (m *Machine) InspectService(
|
||||
}
|
||||
}
|
||||
|
||||
ctr := api.ServiceContainer{Container: records[0].Container}
|
||||
ctr := records[0].Container
|
||||
svc := &pb.Service{
|
||||
Id: ctr.ServiceID(),
|
||||
Name: ctr.ServiceName(),
|
||||
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
)
|
||||
|
||||
type ContainerRecord struct {
|
||||
Container api.Container
|
||||
Container api.ServiceContainer
|
||||
MachineID string
|
||||
SyncStatus string
|
||||
UpdatedAt time.Time
|
||||
@@ -48,10 +48,12 @@ type DeleteOptions struct {
|
||||
|
||||
// CreateOrUpdateContainer creates a new container record or updates an existing one in the store database.
|
||||
// The container is associated with the given machine ID that indicates which machine the container is running on.
|
||||
func (s *Store) CreateOrUpdateContainer(ctx context.Context, ctr api.Container, machineID string) error {
|
||||
func (s *Store) CreateOrUpdateContainer(ctx context.Context, ctr api.ServiceContainer, machineID string) error {
|
||||
// Remove the environment variables from the container record before storing it in the database
|
||||
// to avoid leaking secrets.
|
||||
ctr.Config.Env = nil
|
||||
ctr.ServiceSpec.Container.Env = nil
|
||||
|
||||
cJSON, err := json.Marshal(ctr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal container: %w", err)
|
||||
@@ -118,7 +120,7 @@ func (s *Store) ListContainers(ctx context.Context, opts ListOptions) ([]Contain
|
||||
return nil, fmt.Errorf("scan container record: %w", err)
|
||||
}
|
||||
|
||||
var c api.Container
|
||||
var c api.ServiceContainer
|
||||
if err = json.Unmarshal([]byte(cJSON), &c); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal container: %w", err)
|
||||
}
|
||||
@@ -163,7 +165,7 @@ func (s *Store) DeleteContainers(ctx context.Context, opts DeleteOptions) error
|
||||
// SubscribeContainers returns a list of containers and a channel that signals changes to the list. The channel doesn't
|
||||
// receive any values, it just signals when a container(s) has been added, updated, or deleted in the database.
|
||||
func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-chan struct{}, error) {
|
||||
// TODO: figure out whether we need sync_status at all.
|
||||
// TODO: figure out whether we need sync_status at all (not used at the moment).
|
||||
q := sq.Select("container", "machine_id", "sync_status", "updated_at").From("containers").
|
||||
Where(sq.Eq{"sync_status": SyncStatusSynced})
|
||||
query, args, err := q.ToSql()
|
||||
|
||||
@@ -19,7 +19,7 @@ CREATE TABLE machines
|
||||
CREATE TABLE containers
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
-- container is a JSON-serialized api.Container struct.
|
||||
-- container is a JSON-serialized api.ServiceContainer struct.
|
||||
container TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(container)),
|
||||
machine_id TEXT NOT NULL DEFAULT '',
|
||||
service_id TEXT AS (json_extract(container, '$.Config.Labels."uncloud.service.id"')),
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Proxy proxies local connections to a remote TCP address optionally using a custom dialer.
|
||||
type Proxy struct {
|
||||
Listener net.Listener
|
||||
RemoteAddr string
|
||||
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
OnError func(error)
|
||||
activeConns sync.WaitGroup
|
||||
}
|
||||
|
||||
// deadliner is an interface for listeners that support setting deadlines.
|
||||
type deadliner interface {
|
||||
SetDeadline(t time.Time) error
|
||||
}
|
||||
|
||||
// halfCloser is an interface for connections that support half-close.
|
||||
type halfCloser interface {
|
||||
CloseWrite() error
|
||||
}
|
||||
|
||||
// Run starts the proxy and runs until the context is canceled.
|
||||
func (p *Proxy) Run(ctx context.Context) {
|
||||
if p.DialContext == nil {
|
||||
p.DialContext = (&net.Dialer{}).DialContext
|
||||
}
|
||||
|
||||
defer p.Listener.Close()
|
||||
|
||||
// Handle incoming connections until context is canceled.
|
||||
Loop:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break Loop
|
||||
default:
|
||||
}
|
||||
|
||||
// Set a deadline on the listener if supported to check context periodically.
|
||||
if dl, ok := p.Listener.(deadliner); ok {
|
||||
dl.SetDeadline(time.Now().Add(1 * time.Second))
|
||||
}
|
||||
|
||||
conn, err := p.Listener.Accept()
|
||||
if err != nil {
|
||||
if os.IsTimeout(err) {
|
||||
// Just a timeout, continue to check context and accept again.
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break Loop
|
||||
default:
|
||||
if p.OnError != nil {
|
||||
p.OnError(fmt.Errorf("accept local connection: %w", err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
p.activeConns.Add(1)
|
||||
go p.handleConnection(ctx, conn)
|
||||
}
|
||||
|
||||
// Wait for all connections to finish.
|
||||
p.activeConns.Wait()
|
||||
}
|
||||
|
||||
func (p *Proxy) handleConnection(ctx context.Context, localConn net.Conn) {
|
||||
defer p.activeConns.Done()
|
||||
defer localConn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
remoteConn, err := p.DialContext(ctx, "tcp", p.RemoteAddr)
|
||||
if err != nil {
|
||||
if p.OnError != nil {
|
||||
p.OnError(fmt.Errorf("connect remote address '%s': %w", p.RemoteAddr, err))
|
||||
}
|
||||
return
|
||||
}
|
||||
defer remoteConn.Close()
|
||||
|
||||
// Bidirectional copy with proper half-close handling.
|
||||
done := make(chan error, 2)
|
||||
|
||||
go func() {
|
||||
_, err := io.Copy(remoteConn, localConn)
|
||||
// Close write half of remote connection if supported.
|
||||
if hc, ok := remoteConn.(halfCloser); ok {
|
||||
hc.CloseWrite()
|
||||
}
|
||||
done <- err
|
||||
}()
|
||||
|
||||
go func() {
|
||||
_, err := io.Copy(localConn, remoteConn)
|
||||
// Close write half of local connection if supported.
|
||||
if hc, ok := localConn.(halfCloser); ok {
|
||||
hc.CloseWrite()
|
||||
}
|
||||
done <- err
|
||||
}()
|
||||
|
||||
// Wait for both copies to complete or context cancel.
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Close connections to abort ongoing copies.
|
||||
localConn.Close()
|
||||
remoteConn.Close()
|
||||
return
|
||||
case err = <-done:
|
||||
if err != nil && p.OnError != nil {
|
||||
p.OnError(fmt.Errorf("data copy: %w", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
@@ -200,7 +200,7 @@ func (p *Provisioner) InspectCluster(ctx context.Context, name string) (Cluster,
|
||||
// Docker network name is the same as the cluster name.
|
||||
net, err := p.dockerCli.NetworkInspect(ctx, name, network.InspectOptions{})
|
||||
if err != nil {
|
||||
if dockerclient.IsErrNotFound(err) {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return c, ErrNotFound
|
||||
}
|
||||
return c, fmt.Errorf("inspect Docker network '%s': %w", name, err)
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
@@ -137,7 +137,7 @@ func (p *Provisioner) createContainerWithImagePull(
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if !dockerclient.IsErrNotFound(err) {
|
||||
if !errdefs.IsNotFound(err) {
|
||||
return resp, fmt.Errorf("create Docker container: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package api
|
||||
|
||||
import "strings"
|
||||
|
||||
// CaddySpec is the Caddy reverse proxy configuration for a service.
|
||||
type CaddySpec struct {
|
||||
// Config contains the Caddy config (Caddyfile) content. It must not conflict with the Caddy configs
|
||||
// of other services.
|
||||
Config string
|
||||
}
|
||||
|
||||
func (c *CaddySpec) Equals(other *CaddySpec) bool {
|
||||
if c == nil {
|
||||
return other == nil || strings.TrimSpace(other.Config) == ""
|
||||
}
|
||||
if other == nil {
|
||||
return strings.TrimSpace(c.Config) == ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(c.Config) == strings.TrimSpace(other.Config)
|
||||
}
|
||||
+21
-7
@@ -3,7 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
@@ -63,21 +63,35 @@ type VolumeClient interface {
|
||||
func ProxyMachinesContext(
|
||||
ctx context.Context, cli MachineClient, namesOrIDs []string,
|
||||
) (context.Context, MachineMembersList, error) {
|
||||
// TODO: move the machine IP resolution to the proxy router to allow setting machine names and IDs in the metadata.
|
||||
machines, err := cli.ListMachines(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
|
||||
var proxiedMachines MachineMembersList
|
||||
md := metadata.New(nil)
|
||||
for _, m := range machines {
|
||||
if len(namesOrIDs) == 0 ||
|
||||
slices.Contains(namesOrIDs, m.Machine.Name) || slices.Contains(namesOrIDs, m.Machine.Id) {
|
||||
var notFound []string
|
||||
for _, nameOrID := range namesOrIDs {
|
||||
if m := machines.FindByNameOrID(nameOrID); m != nil {
|
||||
proxiedMachines = append(proxiedMachines, m)
|
||||
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
|
||||
md.Append("machines", machineIP.String())
|
||||
} else {
|
||||
notFound = append(notFound, nameOrID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(notFound) > 0 {
|
||||
return nil, nil, fmt.Errorf("machines not found: %s", strings.Join(notFound, ", "))
|
||||
}
|
||||
|
||||
if len(namesOrIDs) == 0 {
|
||||
proxiedMachines = machines
|
||||
}
|
||||
|
||||
md := metadata.New(nil)
|
||||
for _, m := range proxiedMachines {
|
||||
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
|
||||
md.Append("machines", machineIP.String())
|
||||
}
|
||||
|
||||
return metadata.NewOutgoingContext(ctx, md), proxiedMachines, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Implementation of Config feature from the Compose spec
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ConfigSpec defines a configuration object that can be mounted into containers
|
||||
type ConfigSpec struct {
|
||||
Name string
|
||||
|
||||
// Content of the config when specified inline
|
||||
Content []byte `json:",omitempty"`
|
||||
|
||||
// Note: NOT IMPLEMENTED
|
||||
// External indicates this config already exists and should not be created
|
||||
// External bool `json:",omitempty"`
|
||||
|
||||
// Note: NOT IMPLEMENTED
|
||||
// Labels for the config
|
||||
// Labels map[string]string `json:",omitempty"`
|
||||
|
||||
// TODO: add support for "environment"
|
||||
}
|
||||
|
||||
func (c *ConfigSpec) Validate() error {
|
||||
if c.Name == "" {
|
||||
return fmt.Errorf("config name is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Equals compares two ConfigSpec instances
|
||||
func (c *ConfigSpec) Equals(other ConfigSpec) bool {
|
||||
return c.Name == other.Name &&
|
||||
bytes.Equal(c.Content, other.Content)
|
||||
}
|
||||
|
||||
// ConfigMount defines how a config is mounted into a container
|
||||
type ConfigMount struct {
|
||||
// ConfigName references a config defined in ServiceSpec.Configs by its Name field
|
||||
ConfigName string
|
||||
// ContainerPath is the absolute path where the config is mounted in the container
|
||||
ContainerPath string `json:",omitempty"`
|
||||
// Uid for the mounted config file
|
||||
Uid string `json:",omitempty"`
|
||||
// Gid for the mounted config file
|
||||
Gid string `json:",omitempty"`
|
||||
// Mode (file permissions) for the mounted config file
|
||||
Mode *os.FileMode `json:",omitempty"`
|
||||
}
|
||||
|
||||
func (c *ConfigMount) GetNumericUid() (*uint64, error) {
|
||||
if c.Uid == "" {
|
||||
return nil, nil
|
||||
}
|
||||
uid, err := strconv.ParseUint(c.Uid, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid Uid '%s': %w", c.Uid, err)
|
||||
}
|
||||
if int(uid) < 0 {
|
||||
return nil, fmt.Errorf("invalid Uid '%s': value too high", c.Uid)
|
||||
}
|
||||
return &uid, nil
|
||||
}
|
||||
|
||||
func (c *ConfigMount) GetNumericGid() (*uint64, error) {
|
||||
if c.Gid == "" {
|
||||
return nil, nil
|
||||
}
|
||||
gid, err := strconv.ParseUint(c.Gid, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid Gid '%s': %w", c.Gid, err)
|
||||
}
|
||||
if int(gid) < 0 {
|
||||
return nil, fmt.Errorf("invalid Gid '%s': value too high", c.Gid)
|
||||
}
|
||||
return &gid, nil
|
||||
}
|
||||
|
||||
func (c *ConfigMount) Validate() error {
|
||||
if c.ConfigName == "" {
|
||||
return fmt.Errorf("config mount source is required")
|
||||
}
|
||||
if _, err := c.GetNumericUid(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.GetNumericGid(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.ContainerPath != "" && !filepath.IsAbs(c.ContainerPath) {
|
||||
return fmt.Errorf("container path must be absolute")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateConfigsAndMounts takes config specs and config mounts and validates that all mounts refer to existing specs
|
||||
func ValidateConfigsAndMounts(configs []ConfigSpec, mounts []ConfigMount) error {
|
||||
configMap := make(map[string]struct{})
|
||||
for _, cfg := range configs {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
if _, ok := configMap[cfg.Name]; ok {
|
||||
return fmt.Errorf("duplicate config name: '%s'", cfg.Name)
|
||||
}
|
||||
|
||||
configMap[cfg.Name] = struct{}{}
|
||||
}
|
||||
|
||||
for _, mount := range mounts {
|
||||
if err := mount.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid config mount: %w", err)
|
||||
}
|
||||
if _, exists := configMap[mount.ConfigName]; !exists {
|
||||
return fmt.Errorf("config mount source '%s' does not refer to any defined config", mount.ConfigName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// uint64Ptr is a convenience function to create a pointer to a uint64 value
|
||||
func uint64Ptr(v uint64) *uint64 {
|
||||
return &v
|
||||
}
|
||||
|
||||
func TestConfigMount_GetNumericUid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uid string
|
||||
expected *uint64
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty uid returns nil",
|
||||
uid: "",
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "valid numeric uid",
|
||||
uid: "1000",
|
||||
expected: uint64Ptr(1000),
|
||||
},
|
||||
{
|
||||
name: "zero uid",
|
||||
uid: "0",
|
||||
expected: uint64Ptr(0),
|
||||
},
|
||||
{
|
||||
name: "invalid non-numeric uid",
|
||||
uid: "root",
|
||||
wantErr: "invalid Uid 'root'",
|
||||
},
|
||||
{
|
||||
name: "negative uid",
|
||||
uid: "-1",
|
||||
wantErr: "invalid Uid",
|
||||
},
|
||||
{
|
||||
name: "very large uid",
|
||||
uid: "18446744073709551615", // max uint64
|
||||
wantErr: "invalid Uid '18446744073709551615': value too high",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mount := &ConfigMount{Uid: tt.uid}
|
||||
uid, err := mount.GetNumericUid()
|
||||
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
assert.Nil(t, uid)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
if tt.expected == nil {
|
||||
assert.Nil(t, uid)
|
||||
} else {
|
||||
require.NotNil(t, uid)
|
||||
assert.Equal(t, *tt.expected, *uid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigMount_GetNumericGid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
gid string
|
||||
expected *uint64
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty gid returns nil",
|
||||
gid: "",
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "valid numeric gid",
|
||||
gid: "1000",
|
||||
expected: uint64Ptr(1000),
|
||||
},
|
||||
{
|
||||
name: "zero gid",
|
||||
gid: "0",
|
||||
expected: uint64Ptr(0),
|
||||
},
|
||||
{
|
||||
name: "invalid non-numeric gid",
|
||||
gid: "wheel",
|
||||
wantErr: "invalid Gid 'wheel'",
|
||||
},
|
||||
{
|
||||
name: "negative gid",
|
||||
gid: "-1",
|
||||
wantErr: "invalid Gid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mount := &ConfigMount{Gid: tt.gid}
|
||||
gid, err := mount.GetNumericGid()
|
||||
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
assert.Nil(t, gid)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
if tt.expected == nil {
|
||||
assert.Nil(t, gid)
|
||||
} else {
|
||||
require.NotNil(t, gid)
|
||||
assert.Equal(t, *tt.expected, *gid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfigsAndMounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mode := os.FileMode(0o644)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
configs []ConfigSpec
|
||||
mounts []ConfigMount
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty configs and mounts",
|
||||
configs: []ConfigSpec{},
|
||||
mounts: []ConfigMount{},
|
||||
},
|
||||
{
|
||||
name: "valid configs without mounts",
|
||||
configs: []ConfigSpec{
|
||||
{Name: "config1", Content: []byte("content1")},
|
||||
{Name: "config2", Content: []byte("content2")},
|
||||
},
|
||||
mounts: []ConfigMount{},
|
||||
},
|
||||
{
|
||||
name: "valid configs with valid mounts",
|
||||
configs: []ConfigSpec{
|
||||
{Name: "config1", Content: []byte("content1")},
|
||||
{Name: "config2", Content: []byte("content2")},
|
||||
},
|
||||
mounts: []ConfigMount{
|
||||
{ConfigName: "config1", ContainerPath: "/etc/config1"},
|
||||
{ConfigName: "config2", ContainerPath: "/etc/config2", Uid: "1000", Gid: "1000"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "config with empty name",
|
||||
configs: []ConfigSpec{
|
||||
{Name: "", Content: []byte("content")},
|
||||
},
|
||||
mounts: []ConfigMount{},
|
||||
wantErr: "config name is required",
|
||||
},
|
||||
{
|
||||
name: "duplicate config names",
|
||||
configs: []ConfigSpec{
|
||||
{Name: "config1", Content: []byte("content1")},
|
||||
{Name: "config1", Content: []byte("content2")},
|
||||
},
|
||||
mounts: []ConfigMount{},
|
||||
wantErr: "duplicate config name: 'config1'",
|
||||
},
|
||||
{
|
||||
name: "mount with empty config name",
|
||||
configs: []ConfigSpec{
|
||||
{Name: "config1", Content: []byte("content1")},
|
||||
},
|
||||
mounts: []ConfigMount{
|
||||
{ConfigName: "", ContainerPath: "/etc/config"},
|
||||
},
|
||||
wantErr: "config mount source is required",
|
||||
},
|
||||
{
|
||||
name: "mount referencing non-existent config",
|
||||
configs: []ConfigSpec{
|
||||
{Name: "config1", Content: []byte("content1")},
|
||||
},
|
||||
mounts: []ConfigMount{
|
||||
{ConfigName: "nonexistent", ContainerPath: "/etc/config"},
|
||||
},
|
||||
wantErr: "config mount source 'nonexistent' does not refer to any defined config",
|
||||
},
|
||||
{
|
||||
name: "mount with invalid uid",
|
||||
configs: []ConfigSpec{
|
||||
{Name: "config1", Content: []byte("content1")},
|
||||
},
|
||||
mounts: []ConfigMount{
|
||||
{ConfigName: "config1", ContainerPath: "/etc/config", Uid: "invalid"},
|
||||
},
|
||||
wantErr: "invalid Uid 'invalid'",
|
||||
},
|
||||
{
|
||||
name: "mount with invalid gid",
|
||||
configs: []ConfigSpec{
|
||||
{Name: "config1", Content: []byte("content1")},
|
||||
},
|
||||
mounts: []ConfigMount{
|
||||
{ConfigName: "config1", ContainerPath: "/etc/config", Gid: "invalid"},
|
||||
},
|
||||
wantErr: "invalid Gid 'invalid'",
|
||||
},
|
||||
{
|
||||
name: "mount with relative container path",
|
||||
configs: []ConfigSpec{
|
||||
{Name: "config1", Content: []byte("content1")},
|
||||
},
|
||||
mounts: []ConfigMount{
|
||||
{ConfigName: "config1", ContainerPath: "relative/path"},
|
||||
},
|
||||
wantErr: "container path must be absolute",
|
||||
},
|
||||
{
|
||||
name: "mount with empty container path",
|
||||
configs: []ConfigSpec{
|
||||
{Name: "config1", Content: []byte("content1")},
|
||||
},
|
||||
mounts: []ConfigMount{
|
||||
{ConfigName: "config1", ContainerPath: ""},
|
||||
},
|
||||
// Empty path is allowed
|
||||
},
|
||||
{
|
||||
name: "mount with absolute container path",
|
||||
configs: []ConfigSpec{
|
||||
{Name: "config1", Content: []byte("content1")},
|
||||
},
|
||||
mounts: []ConfigMount{
|
||||
{ConfigName: "config1", ContainerPath: "/absolute/path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complex valid scenario",
|
||||
configs: []ConfigSpec{
|
||||
{Name: "nginx-conf", Content: []byte("server { listen 80; }")},
|
||||
{Name: "app-config", Content: []byte("debug=true")},
|
||||
{Name: "cert", Content: []byte("-----BEGIN CERTIFICATE-----")},
|
||||
},
|
||||
mounts: []ConfigMount{
|
||||
{ConfigName: "nginx-conf", ContainerPath: "/etc/nginx/nginx.conf", Uid: "0", Gid: "0", Mode: &mode},
|
||||
{ConfigName: "app-config", ContainerPath: "/app/config.env"},
|
||||
{ConfigName: "cert", ContainerPath: "/etc/ssl/cert.pem", Uid: "1000", Gid: "1000"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := ValidateConfigsAndMounts(tt.configs, tt.mounts)
|
||||
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
+39
-4
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/go-units"
|
||||
)
|
||||
|
||||
@@ -23,7 +23,21 @@ const (
|
||||
)
|
||||
|
||||
type Container struct {
|
||||
types.ContainerJSON
|
||||
container.InspectResponse
|
||||
// created caches the parsed creation time by CreatedTime.
|
||||
created time.Time
|
||||
}
|
||||
|
||||
// CreatedTime returns the time when the container was created parsed from the Created field.
|
||||
func (c *Container) CreatedTime() time.Time {
|
||||
if c.created.IsZero() && c.Created != "" {
|
||||
created, err := time.Parse(time.RFC3339Nano, c.Created)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
c.created = created
|
||||
}
|
||||
return c.created
|
||||
}
|
||||
|
||||
// Healthy determines if the container is running and healthy.
|
||||
@@ -38,7 +52,7 @@ func (c *Container) Healthy() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
return c.State.Health.Status == types.Healthy
|
||||
return c.State.Health.Status == container.Healthy
|
||||
}
|
||||
|
||||
// HumanState returns a human-readable description of the container's state. Based on the Docker implementation:
|
||||
@@ -64,7 +78,7 @@ func (c *Container) HumanState() (string, error) {
|
||||
|
||||
if c.State.Health != nil {
|
||||
status := c.State.Health.Status
|
||||
if status == types.Starting {
|
||||
if status == container.Starting {
|
||||
status = "health: " + status
|
||||
}
|
||||
|
||||
@@ -199,3 +213,24 @@ func (c *ServiceContainer) ConflictingServicePorts(ports []PortSpec) ([]PortSpec
|
||||
|
||||
return conflicting, nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom unmarshalling for ServiceContainer to override the custom unmarshaler
|
||||
// of the embedded Container field.
|
||||
func (c *ServiceContainer) UnmarshalJSON(data []byte) error {
|
||||
// Unmarshal everything except Container into a temporary struct. Keep this in sync with ServiceContainer.
|
||||
var temp struct {
|
||||
ServiceSpec ServiceSpec
|
||||
}
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Let Container's UnmarshalJSON handle its part.
|
||||
if err := json.Unmarshal(data, &c.Container); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.ServiceSpec = temp.ServiceSpec
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+30
-31
@@ -4,7 +4,6 @@ import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -15,9 +14,9 @@ func TestContainer_Healthy(t *testing.T) {
|
||||
|
||||
t.Run("exited", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
c := &Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Running: false,
|
||||
Dead: false,
|
||||
ExitCode: 0,
|
||||
@@ -29,9 +28,9 @@ func TestContainer_Healthy(t *testing.T) {
|
||||
|
||||
t.Run("running with no health check", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
c := &Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Running: true,
|
||||
},
|
||||
},
|
||||
@@ -41,12 +40,12 @@ func TestContainer_Healthy(t *testing.T) {
|
||||
|
||||
t.Run("running and healthy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
c := &Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Running: true,
|
||||
Health: &types.Health{
|
||||
Status: types.Healthy,
|
||||
Health: &container.Health{
|
||||
Status: container.Healthy,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -56,12 +55,12 @@ func TestContainer_Healthy(t *testing.T) {
|
||||
|
||||
t.Run("running but unhealthy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
c := &Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Running: true,
|
||||
Health: &types.Health{
|
||||
Status: types.Unhealthy,
|
||||
Health: &container.Health{
|
||||
Status: container.Unhealthy,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -71,11 +70,11 @@ func TestContainer_Healthy(t *testing.T) {
|
||||
|
||||
t.Run("running with health starting", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
c := &Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Running: true,
|
||||
Health: &types.Health{
|
||||
Health: &container.Health{
|
||||
Status: "starting",
|
||||
},
|
||||
},
|
||||
@@ -86,9 +85,9 @@ func TestContainer_Healthy(t *testing.T) {
|
||||
|
||||
t.Run("dead", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
c := &Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Dead: true,
|
||||
Running: false,
|
||||
},
|
||||
@@ -99,9 +98,9 @@ func TestContainer_Healthy(t *testing.T) {
|
||||
|
||||
t.Run("restarting", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
c := &Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Restarting: true,
|
||||
Running: true,
|
||||
ExitCode: 1,
|
||||
@@ -113,9 +112,9 @@ func TestContainer_Healthy(t *testing.T) {
|
||||
|
||||
t.Run("paused", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
c := &Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Paused: true,
|
||||
Running: true,
|
||||
},
|
||||
@@ -280,7 +279,7 @@ func TestContainer_ConflictingServicePorts(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctr := &ServiceContainer{Container: Container{ContainerJSON: types.ContainerJSON{
|
||||
ctr := &ServiceContainer{Container: Container{InspectResponse: container.InspectResponse{
|
||||
Config: &container.Config{
|
||||
Labels: map[string]string{
|
||||
LabelServicePorts: tt.containerPorts,
|
||||
|
||||
+22
-2
@@ -2,14 +2,34 @@ package api
|
||||
|
||||
import (
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
type MachineImage struct {
|
||||
Metadata *pb.Metadata
|
||||
Image types.ImageInspect
|
||||
Image image.InspectResponse
|
||||
}
|
||||
|
||||
// MachineImages represents images present on a particular machine.
|
||||
type MachineImages struct {
|
||||
Metadata *pb.Metadata
|
||||
// Images is a list of images present on the machine.
|
||||
Images []image.Summary
|
||||
// ContainerdStore indicates whether Docker on the machine uses the containerd image store
|
||||
// (containerd-snapshotter feature).
|
||||
ContainerdStore bool
|
||||
}
|
||||
|
||||
// ImageFilter defines criteria to filter images in ListImages.
|
||||
type ImageFilter struct {
|
||||
// Machines filters images to those present on the specified machines (names or IDs).
|
||||
// If empty, it matches images on all machines.
|
||||
Machines []string
|
||||
// Name filters images by name (with or without tag). Accepts a wildcard pattern.
|
||||
// If empty, it matches all image names.
|
||||
Name string
|
||||
}
|
||||
|
||||
// MachineRemoteImage represents an image in a remote registry fetched by a particular machine.
|
||||
|
||||
+68
-2
@@ -6,6 +6,7 @@ import (
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@@ -42,6 +43,10 @@ func ValidateServiceID(id string) bool {
|
||||
// ServiceSpec defines the desired state of a service.
|
||||
// ATTENTION: after changing this struct, verify if deploy.EvalContainerSpecChange needs to be updated.
|
||||
type ServiceSpec struct {
|
||||
// Caddy is the optional Caddy reverse proxy configuration for the service.
|
||||
// Caddy and Ports cannot be specified simultaneously.
|
||||
Caddy *CaddySpec `json:",omitempty"`
|
||||
// Container defines the desired state of each container in the service.
|
||||
Container ContainerSpec
|
||||
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
|
||||
Mode string
|
||||
@@ -49,11 +54,22 @@ type ServiceSpec struct {
|
||||
// Placement defines the placement constraints for the service.
|
||||
Placement Placement
|
||||
// Ports defines what service ports to publish to make the service accessible outside the cluster.
|
||||
// Caddy and Ports cannot be specified simultaneously.
|
||||
Ports []PortSpec
|
||||
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
|
||||
Replicas uint `json:",omitempty"`
|
||||
// Volumes is list of data volumes that can be mounted into the container.
|
||||
Volumes []VolumeSpec
|
||||
// Configs is list of configuration objects that can be mounted into the container.
|
||||
Configs []ConfigSpec
|
||||
}
|
||||
|
||||
// CaddyConfig returns the Caddy reverse proxy configuration for the service or an empty string if it's not defined.
|
||||
func (s *ServiceSpec) CaddyConfig() string {
|
||||
if s.Caddy == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(s.Caddy.Config)
|
||||
}
|
||||
|
||||
func (s *ServiceSpec) Volume(name string) (VolumeSpec, bool) {
|
||||
@@ -65,6 +81,15 @@ func (s *ServiceSpec) Volume(name string) (VolumeSpec, bool) {
|
||||
return VolumeSpec{}, false
|
||||
}
|
||||
|
||||
func (s *ServiceSpec) Config(name string) (ConfigSpec, bool) {
|
||||
for _, c := range s.Configs {
|
||||
if c.Name == name {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return ConfigSpec{}, false
|
||||
}
|
||||
|
||||
// MountedDockerVolumes returns the list of volumes of VolumeTypeVolume type that are mounted into the container.
|
||||
func (s *ServiceSpec) MountedDockerVolumes() []VolumeSpec {
|
||||
volumes := make(map[string]VolumeSpec)
|
||||
@@ -112,7 +137,8 @@ func (s *ServiceSpec) Validate() error {
|
||||
return fmt.Errorf("service name too long (max 63 characters): %q", s.Name)
|
||||
}
|
||||
if !dnsLabelRegexp.MatchString(s.Name) {
|
||||
return fmt.Errorf("invalid service name: %q. must be 1-63 characters, lowercase letters, numbers, and dashes only; must start and end with a letter or number", s.Name)
|
||||
return fmt.Errorf("invalid service name: %q. must be 1-63 characters, lowercase letters, numbers, "+
|
||||
"and dashes only; must start and end with a letter or number", s.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +151,24 @@ func (s *ServiceSpec) Validate() error {
|
||||
|
||||
// TODO: validate there is no conflict between ports.
|
||||
|
||||
// Validate that Caddy and Ports are not used together, unless all ports are host mode.
|
||||
if s.Caddy != nil && strings.TrimSpace(s.Caddy.Config) != "" && len(s.Ports) > 0 {
|
||||
// Check if all ports are in host mode.
|
||||
hasIngressPort := false
|
||||
for _, p := range s.Ports {
|
||||
if p.Mode == "" || p.Mode == PortModeIngress {
|
||||
hasIngressPort = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasIngressPort {
|
||||
return fmt.Errorf("ingress ports and Caddy configuration cannot be specified simultaneously: " +
|
||||
"Caddy config is auto-generated from ingress ports, use only one of them. " +
|
||||
"Host mode ports can be used with Caddy config")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate volumes
|
||||
volumeNames := make(map[string]struct{})
|
||||
for _, v := range s.Volumes {
|
||||
if err := v.Validate(); err != nil {
|
||||
@@ -145,17 +189,27 @@ func (s *ServiceSpec) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate configs
|
||||
if err := ValidateConfigsAndMounts(s.Configs, s.Container.ConfigMounts); err != nil {
|
||||
return fmt.Errorf("validate service configs and mounts: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServiceSpec) Clone() ServiceSpec {
|
||||
spec := *s
|
||||
|
||||
if s.Caddy != nil {
|
||||
caddyCopy := *s.Caddy
|
||||
spec.Caddy = &caddyCopy
|
||||
}
|
||||
spec.Container = s.Container.Clone()
|
||||
|
||||
if s.Ports != nil {
|
||||
spec.Ports = make([]PortSpec, len(s.Ports))
|
||||
copy(spec.Ports, s.Ports)
|
||||
}
|
||||
spec.Container = s.Container.Clone()
|
||||
|
||||
if s.Volumes != nil {
|
||||
spec.Volumes = make([]VolumeSpec, len(s.Volumes))
|
||||
@@ -193,6 +247,9 @@ type ContainerSpec struct {
|
||||
// VolumeMounts specifies how volumes are mounted into the container filesystem.
|
||||
// Each mount references a volume defined in ServiceSpec.Volumes.
|
||||
VolumeMounts []VolumeMount
|
||||
// ConfigMounts specifies how configs are mounted into the container filesystem.
|
||||
// Each mount references a config defined in ServiceSpec.Configs.
|
||||
ConfigMounts []ConfigMount
|
||||
// Volumes is list of data volumes to mount into the container.
|
||||
// TODO(lhf): delete all usage, has been replaced with []VolumeMounts.
|
||||
Volumes []string
|
||||
@@ -309,6 +366,15 @@ type MachineServiceContainer struct {
|
||||
Container ServiceContainer
|
||||
}
|
||||
|
||||
// Images returns a sorted list of unique images used by the service containers.
|
||||
func (s *Service) Images() []string {
|
||||
images := make(map[string]struct{})
|
||||
for _, ctr := range s.Containers {
|
||||
images[ctr.Container.Config.Image] = struct{}{}
|
||||
}
|
||||
return slices.Sorted(maps.Keys(images))
|
||||
}
|
||||
|
||||
// Endpoints returns the exposed HTTP and HTTPS endpoints of the service.
|
||||
func (s *Service) Endpoints() []string {
|
||||
endpoints := make(map[string]struct{})
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
spec ServiceSpec
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid with neither Caddy nor Ports",
|
||||
spec: ServiceSpec{
|
||||
Name: "test",
|
||||
Container: ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "valid with Caddy only",
|
||||
spec: ServiceSpec{
|
||||
Name: "test",
|
||||
Container: ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
Caddy: &CaddySpec{
|
||||
Config: "example.com {\n reverse_proxy :8080\n}",
|
||||
},
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "valid with Ports only",
|
||||
spec: ServiceSpec{
|
||||
Name: "test",
|
||||
Container: ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
Ports: []PortSpec{
|
||||
{
|
||||
ContainerPort: 80,
|
||||
Protocol: ProtocolHTTP,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "valid with empty Caddy config and Ports",
|
||||
spec: ServiceSpec{
|
||||
Name: "test",
|
||||
Container: ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
Caddy: &CaddySpec{
|
||||
Config: "",
|
||||
},
|
||||
Ports: []PortSpec{
|
||||
{
|
||||
ContainerPort: 80,
|
||||
Protocol: ProtocolHTTP,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "invalid with Caddy and Ports (default mode is ingress)",
|
||||
spec: ServiceSpec{
|
||||
Name: "test",
|
||||
Container: ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
Caddy: &CaddySpec{
|
||||
Config: "example.com {\n reverse_proxy :8080\n}",
|
||||
},
|
||||
Ports: []PortSpec{
|
||||
{
|
||||
ContainerPort: 80,
|
||||
Protocol: ProtocolHTTP,
|
||||
// Mode is empty, defaults to ingress
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: "ingress ports and Caddy configuration cannot be specified simultaneously",
|
||||
},
|
||||
{
|
||||
name: "invalid with both Caddy and ingress Ports",
|
||||
spec: ServiceSpec{
|
||||
Name: "test",
|
||||
Container: ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
Caddy: &CaddySpec{
|
||||
Config: "example.com {\n reverse_proxy :8080\n}",
|
||||
},
|
||||
Ports: []PortSpec{
|
||||
{
|
||||
ContainerPort: 80,
|
||||
Protocol: ProtocolHTTP,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: "ingress ports and Caddy configuration cannot be specified simultaneously",
|
||||
},
|
||||
{
|
||||
name: "valid with Caddy and host mode Ports",
|
||||
spec: ServiceSpec{
|
||||
Name: "test",
|
||||
Container: ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
Caddy: &CaddySpec{
|
||||
Config: "example.com {\n reverse_proxy :8080\n}",
|
||||
},
|
||||
Ports: []PortSpec{
|
||||
{
|
||||
ContainerPort: 3306,
|
||||
PublishedPort: 3306,
|
||||
Protocol: ProtocolTCP,
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "invalid with Caddy and mixed mode Ports",
|
||||
spec: ServiceSpec{
|
||||
Name: "test",
|
||||
Container: ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
Caddy: &CaddySpec{
|
||||
Config: "example.com {\n reverse_proxy :8080\n}",
|
||||
},
|
||||
Ports: []PortSpec{
|
||||
{
|
||||
ContainerPort: 3306,
|
||||
PublishedPort: 3306,
|
||||
Protocol: ProtocolTCP,
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
{
|
||||
ContainerPort: 80,
|
||||
Protocol: ProtocolHTTP,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: "ingress ports and Caddy configuration cannot be specified simultaneously",
|
||||
},
|
||||
{
|
||||
name: "valid with Caddy and multiple host mode Ports",
|
||||
spec: ServiceSpec{
|
||||
Name: "test",
|
||||
Container: ContainerSpec{
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
Caddy: &CaddySpec{
|
||||
Config: "example.com {\n reverse_proxy :8080\n}",
|
||||
},
|
||||
Ports: []PortSpec{
|
||||
{
|
||||
ContainerPort: 3306,
|
||||
PublishedPort: 3306,
|
||||
Protocol: ProtocolTCP,
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
{
|
||||
ContainerPort: 5432,
|
||||
PublishedPort: 5432,
|
||||
Protocol: ProtocolTCP,
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.spec.Validate()
|
||||
if tt.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -248,7 +248,7 @@ type VolumeFilter struct {
|
||||
Driver string
|
||||
// Labels filters volumes by label key-value pairs. Volumes must match all labels.
|
||||
Labels map[string]string
|
||||
// MachineIDs filters volumes to those on the specified machines (names or IDs).
|
||||
// Machines filters volumes to those on the specified machines (names or IDs).
|
||||
Machines []string
|
||||
// Names filters volumes by name. Volumes must match one of the names.
|
||||
Names []string
|
||||
|
||||
+30
-5
@@ -23,7 +23,7 @@ var caddyImageTagRegex = regexp.MustCompile(`^2\.\d+\.\d+$`)
|
||||
// NewCaddyDeployment creates a new deployment for a Caddy reverse proxy service.
|
||||
// The service is deployed in global mode to all machines in the cluster. If the image is not provided, the latest
|
||||
// version of the official Caddy Docker image is used.
|
||||
func (cli *Client) NewCaddyDeployment(image string, placement api.Placement) (*deploy.Deployment, error) {
|
||||
func (cli *Client) NewCaddyDeployment(image, config string, placement api.Placement) (*deploy.Deployment, error) {
|
||||
if image == "" {
|
||||
latest, err := LatestCaddyImage()
|
||||
if err != nil {
|
||||
@@ -35,13 +35,24 @@ func (cli *Client) NewCaddyDeployment(image string, placement api.Placement) (*d
|
||||
|
||||
spec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Command: []string{"caddy", "run", "-c", "/config/caddy.json", "--watch"},
|
||||
Image: image,
|
||||
Command: []string{"caddy", "run", "-c", "/config/Caddyfile"},
|
||||
Env: map[string]string{
|
||||
"CADDY_ADMIN": "unix//run/caddy/admin.sock",
|
||||
},
|
||||
Image: image,
|
||||
VolumeMounts: []api.VolumeMount{
|
||||
{
|
||||
VolumeName: "config",
|
||||
VolumeName: "data",
|
||||
ContainerPath: "/config",
|
||||
},
|
||||
{
|
||||
VolumeName: "data",
|
||||
ContainerPath: "/data",
|
||||
},
|
||||
{
|
||||
VolumeName: "run",
|
||||
ContainerPath: "/run/caddy",
|
||||
},
|
||||
},
|
||||
},
|
||||
Mode: api.ServiceModeGlobal,
|
||||
@@ -63,15 +74,29 @@ func (cli *Client) NewCaddyDeployment(image string, placement api.Placement) (*d
|
||||
},
|
||||
Volumes: []api.VolumeSpec{
|
||||
{
|
||||
Name: "config",
|
||||
Name: "data",
|
||||
Type: api.VolumeTypeBind,
|
||||
BindOptions: &api.BindOptions{
|
||||
HostPath: "/var/lib/uncloud/caddy",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "run",
|
||||
Type: api.VolumeTypeBind,
|
||||
BindOptions: &api.BindOptions{
|
||||
HostPath: "/run/uncloud/caddy",
|
||||
CreateHostPath: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if config != "" {
|
||||
spec.Caddy = &api.CaddySpec{
|
||||
Config: config,
|
||||
}
|
||||
}
|
||||
|
||||
return cli.NewDeployment(spec, nil), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"golang.org/x/net/proxy"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
@@ -23,6 +24,7 @@ type Client struct {
|
||||
// Methods such as Reset or Inspect are ambiguous in the context of a machine+cluster client.
|
||||
pb.MachineClient
|
||||
pb.ClusterClient
|
||||
Caddy pb.CaddyClient
|
||||
// Docker is a namespaced client for the Docker service to distinguish Uncloud-specific service container operations
|
||||
// from generic Docker operations.
|
||||
Docker *docker.Client
|
||||
@@ -30,9 +32,12 @@ type Client struct {
|
||||
|
||||
var _ api.Client = (*Client)(nil)
|
||||
|
||||
// Connector is an interface for establishing a connection to the machine API.
|
||||
// Connector is an interface for establishing a connection to the cluster.
|
||||
type Connector interface {
|
||||
// Connect establishes a gRPC client connection to the machine API.
|
||||
Connect(ctx context.Context) (*grpc.ClientConn, error)
|
||||
// Dialer returns a proxy dialer for establishing connections within the cluster if supported by the connector.
|
||||
Dialer() (proxy.ContextDialer, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -50,6 +55,7 @@ func New(ctx context.Context, connector Connector) (*Client, error) {
|
||||
|
||||
c.MachineClient = pb.NewMachineClient(c.conn)
|
||||
c.ClusterClient = pb.NewClusterClient(c.conn)
|
||||
c.Caddy = pb.NewCaddyClient(c.conn)
|
||||
c.Docker = docker.NewClient(c.conn)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
)
|
||||
|
||||
const CaddyExtensionKey = "x-caddy"
|
||||
|
||||
type Caddy struct {
|
||||
Config string `yaml:"config" json:"config"`
|
||||
}
|
||||
|
||||
// DecodeMapstructure decodes x-caddy extension from either a string or an object.
|
||||
// When x-caddy is a string, it's mapped directly to the Config field.
|
||||
func (c *Caddy) DecodeMapstructure(value any) error {
|
||||
switch v := value.(type) {
|
||||
case *Caddy:
|
||||
// Already decoded, happens when mapstructure is called after initial parsing.
|
||||
*c = *v
|
||||
return nil
|
||||
case string:
|
||||
// Handle x-caddy: "Caddyfile config"
|
||||
*c = Caddy{Config: v}
|
||||
case map[string]any:
|
||||
// Use mapstructure to decode the map directly to the struct.
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||
Result: c,
|
||||
ErrorUnused: true, // Error if there are extra keys not in the struct.
|
||||
WeaklyTypedInput: false, // Enforce strict type matching.
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create decoder for x-caddy extension: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(v); err != nil {
|
||||
return fmt.Errorf("decode x-caddy extension: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid type %T for x-caddy extension: expected string or object", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isCaddyfilePath determines if a string is likely a file path rather than inline Caddyfile config.
|
||||
func isCaddyfilePath(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// For simplicity, multi-line string is considered an inline Caddyfile content.
|
||||
return !strings.Contains(s, "\n")
|
||||
}
|
||||
|
||||
// transformServicesCaddyExtension processes Caddy extensions to load configs from files if needed.
|
||||
func transformServicesCaddyExtension(project *types.Project) (*types.Project, error) {
|
||||
return project.WithServicesTransform(func(name string, service types.ServiceConfig) (types.ServiceConfig, error) {
|
||||
ext, ok := service.Extensions[CaddyExtensionKey]
|
||||
if !ok {
|
||||
return service, nil
|
||||
}
|
||||
|
||||
caddy, ok := ext.(Caddy)
|
||||
if !ok {
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// Load the Caddyfile config from file if it's a path and replace the path with its content.
|
||||
if isCaddyfilePath(caddy.Config) {
|
||||
configPath := caddy.Config
|
||||
if !filepath.IsAbs(configPath) {
|
||||
configPath = filepath.Join(project.WorkingDir, configPath)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return service, fmt.Errorf("read Caddy config (Caddyfile) from file '%s' for service '%s': %w",
|
||||
caddy.Config, name, err)
|
||||
}
|
||||
|
||||
caddy.Config = string(content)
|
||||
}
|
||||
|
||||
caddy.Config = strings.TrimSpace(caddy.Config)
|
||||
service.Extensions[CaddyExtensionKey] = caddy
|
||||
|
||||
return service, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCaddyExtension(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
composeYAML string
|
||||
wantConfig string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "x-caddy as string",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy: |
|
||||
example.com {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
`,
|
||||
wantConfig: `example.com {
|
||||
reverse_proxy web:80
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "x-caddy as string with extra spaces",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy: |+
|
||||
|
||||
example.com {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
|
||||
|
||||
`,
|
||||
wantConfig: `example.com {
|
||||
reverse_proxy web:80
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "x-caddy as object with config field",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy:
|
||||
config: |
|
||||
example.com {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
`,
|
||||
wantConfig: `example.com {
|
||||
reverse_proxy web:80
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "x-caddy as object with config field and extra spaces",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy:
|
||||
config: |+
|
||||
|
||||
example.com {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
|
||||
|
||||
`,
|
||||
wantConfig: `example.com {
|
||||
reverse_proxy web:80
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "x-caddy with path to Caddyfile",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy: testdata/Caddyfile
|
||||
`,
|
||||
wantConfig: `test.example.com {
|
||||
reverse_proxy test:8000
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "x-caddy with empty object",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy: {}
|
||||
`,
|
||||
wantConfig: "",
|
||||
},
|
||||
{
|
||||
name: "x-caddy with empty string",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy: ""
|
||||
`,
|
||||
wantConfig: "",
|
||||
},
|
||||
{
|
||||
name: "x-caddy with extra unknown field should fail",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy:
|
||||
config: |
|
||||
example.com {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
unknown_field: "should cause error"
|
||||
`,
|
||||
wantErr: "invalid keys: unknown_field",
|
||||
},
|
||||
{
|
||||
name: "x-caddy with non-string config field should fail",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy:
|
||||
config: 123
|
||||
`,
|
||||
wantErr: "expected type 'string'",
|
||||
},
|
||||
{
|
||||
name: "x-caddy with ingress x-ports conflict",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy: |
|
||||
example.com {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
x-ports:
|
||||
- example.com:80/http
|
||||
`,
|
||||
wantErr: "ingress ports in 'x-ports' and 'x-caddy' cannot be specified simultaneously",
|
||||
},
|
||||
{
|
||||
name: "x-caddy with host-only x-ports allowed",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy: |
|
||||
example.com {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
x-ports:
|
||||
- 8080:80@host
|
||||
- 9090:90/tcp@host
|
||||
`,
|
||||
wantConfig: `example.com {
|
||||
reverse_proxy web:80
|
||||
}`,
|
||||
// Should not error - host ports are allowed with x-caddy
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
project, err := loadProjectFromContent(t, tt.composeYAML)
|
||||
|
||||
if tt.wantErr != "" {
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
service, err := project.GetService("web")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the x-caddy extension was parsed correctly.
|
||||
caddyExt, ok := service.Extensions[CaddyExtensionKey]
|
||||
require.True(t, ok, "x-caddy extension not found")
|
||||
|
||||
caddy, ok := caddyExt.(Caddy)
|
||||
require.True(t, ok, "x-caddy extension is not Caddy type")
|
||||
|
||||
assert.Equal(t, tt.wantConfig, caddy.Config)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCaddyfilePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
// Should be detected as file paths.
|
||||
{"relative path with slash", "./Caddyfile", true},
|
||||
{"relative path parent", "../Caddyfile", true},
|
||||
{"relative path", "relative/path/to/file", true},
|
||||
{"absolute path", "/etc/caddy/Caddyfile", true},
|
||||
{"just Caddyfile", "Caddyfile", true},
|
||||
{"Caddyfile with suffix", "Caddyfile.app", true},
|
||||
{"caddyfile lowercase", "caddyfile", true},
|
||||
{"with .caddyfile extension", "my.caddyfile", true},
|
||||
{"with .Caddyfile extension", "my.Caddyfile", true},
|
||||
{"with .caddy extension", "config.caddy", true},
|
||||
{"with .conf extension", "caddy.conf", true},
|
||||
{"simple filename", "config", true},
|
||||
|
||||
// Should NOT be detected as file paths.
|
||||
{"multiline config", "example.com {\n reverse_proxy :8080\n}", false},
|
||||
{"empty string", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isCaddyfilePath(tt.input)
|
||||
assert.Equal(t, tt.want, result, "isCaddyfilePath(%q) should be %v", tt.input, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
// TODO: add support for short syntax configs
|
||||
func configSpecsFromCompose(
|
||||
configs types.Configs, serviceConfigs []types.ServiceConfigObjConfig, workingDir string,
|
||||
) ([]api.ConfigSpec, []api.ConfigMount, error) {
|
||||
var configSpecs []api.ConfigSpec
|
||||
var configMounts []api.ConfigMount
|
||||
|
||||
for _, serviceConfig := range serviceConfigs {
|
||||
var spec api.ConfigSpec
|
||||
|
||||
if projectConfig, exists := configs[serviceConfig.Source]; exists {
|
||||
if projectConfig.External {
|
||||
return nil, nil, fmt.Errorf("external configs are not supported: %s",
|
||||
serviceConfig.Source)
|
||||
}
|
||||
|
||||
spec = api.ConfigSpec{
|
||||
Name: serviceConfig.Source,
|
||||
Content: []byte(projectConfig.Content),
|
||||
}
|
||||
|
||||
// If File is specified, read the file contents
|
||||
if projectConfig.File != "" {
|
||||
configPath := projectConfig.File
|
||||
// TODO: handle this in a separate function?
|
||||
if !filepath.IsAbs(configPath) {
|
||||
configPath = filepath.Join(workingDir, configPath)
|
||||
}
|
||||
|
||||
fileContent, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read config from file '%s': %w", projectConfig.File, err)
|
||||
}
|
||||
spec.Content = fileContent
|
||||
}
|
||||
} else {
|
||||
return nil, nil, fmt.Errorf("config '%s' not found in project configs", serviceConfig.Source)
|
||||
}
|
||||
|
||||
configSpecs = append(configSpecs, spec)
|
||||
|
||||
// Create config mount
|
||||
target := serviceConfig.Target
|
||||
if target == "" {
|
||||
target = "/" + serviceConfig.Source // Default mount path
|
||||
}
|
||||
|
||||
mount := api.ConfigMount{
|
||||
ConfigName: spec.Name,
|
||||
ContainerPath: target,
|
||||
Uid: serviceConfig.UID,
|
||||
Gid: serviceConfig.GID,
|
||||
}
|
||||
|
||||
if serviceConfig.Mode != nil {
|
||||
mode := os.FileMode(*serviceConfig.Mode)
|
||||
mount.Mode = &mode
|
||||
}
|
||||
|
||||
configMounts = append(configMounts, mount)
|
||||
}
|
||||
|
||||
return configSpecs, configMounts, nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConfigSpecsFromCompose(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configs types.Configs
|
||||
serviceConfigs []types.ServiceConfigObjConfig
|
||||
expectedSpecs []api.ConfigSpec
|
||||
expectedMounts []api.ConfigMount
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "project-level config with file",
|
||||
configs: types.Configs{
|
||||
"app-config": types.ConfigObjConfig{
|
||||
File: "testdata/config1.txt",
|
||||
},
|
||||
},
|
||||
serviceConfigs: []types.ServiceConfigObjConfig{
|
||||
{
|
||||
Source: "app-config",
|
||||
Target: "/app/config.json",
|
||||
UID: "1000",
|
||||
GID: "1000",
|
||||
},
|
||||
},
|
||||
expectedSpecs: []api.ConfigSpec{
|
||||
{
|
||||
Name: "app-config",
|
||||
Content: []byte("test config content\n"),
|
||||
},
|
||||
},
|
||||
expectedMounts: []api.ConfigMount{
|
||||
{
|
||||
ConfigName: "app-config",
|
||||
ContainerPath: "/app/config.json",
|
||||
Uid: "1000",
|
||||
Gid: "1000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "config with mode",
|
||||
configs: types.Configs{
|
||||
"nginx-config": types.ConfigObjConfig{
|
||||
File: "./testdata/nginx.conf",
|
||||
},
|
||||
},
|
||||
serviceConfigs: []types.ServiceConfigObjConfig{
|
||||
{
|
||||
Source: "nginx-config",
|
||||
Target: "/etc/nginx/nginx.conf",
|
||||
Mode: func() *types.FileMode { m := types.FileMode(0o644); return &m }(),
|
||||
},
|
||||
},
|
||||
expectedSpecs: []api.ConfigSpec{
|
||||
{
|
||||
Name: "nginx-config",
|
||||
Content: []byte("user nginx;\nworker_processes auto;\n"),
|
||||
},
|
||||
},
|
||||
expectedMounts: []api.ConfigMount{
|
||||
{
|
||||
ConfigName: "nginx-config",
|
||||
ContainerPath: "/etc/nginx/nginx.conf",
|
||||
Mode: func() *os.FileMode { m := os.FileMode(0o644); return &m }(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
configSpecs, configMounts, err := configSpecsFromCompose(tt.configs, tt.serviceConfigs, ".")
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, tt.expectedSpecs, configSpecs)
|
||||
assert.Equal(t, tt.expectedMounts, configMounts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSpecEquals(t *testing.T) {
|
||||
config1 := api.ConfigSpec{
|
||||
Name: "test-config",
|
||||
}
|
||||
|
||||
config2 := api.ConfigSpec{
|
||||
Name: "test-config",
|
||||
}
|
||||
|
||||
config3 := api.ConfigSpec{
|
||||
Name: "test-config",
|
||||
Content: []byte("some content"),
|
||||
}
|
||||
|
||||
assert.True(t, config1.Equals(config2))
|
||||
assert.False(t, config1.Equals(config3))
|
||||
}
|
||||
@@ -25,11 +25,16 @@ type Deployment struct {
|
||||
Client Client
|
||||
Project *types.Project
|
||||
SpecResolver *deploy.ServiceSpecResolver
|
||||
Strategy deploy.Strategy
|
||||
state *scheduler.ClusterState
|
||||
plan *deploy.SequenceOperation
|
||||
}
|
||||
|
||||
func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*Deployment, error) {
|
||||
return NewDeploymentWithStrategy(ctx, cli, project, nil)
|
||||
}
|
||||
|
||||
func NewDeploymentWithStrategy(ctx context.Context, cli Client, project *types.Project, strategy deploy.Strategy) (*Deployment, error) {
|
||||
state, err := scheduler.InspectClusterState(ctx, cli)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect cluster state: %w", err)
|
||||
@@ -39,16 +44,20 @@ func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*De
|
||||
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||
return nil, fmt.Errorf("get cluster domain: %w", err)
|
||||
}
|
||||
|
||||
resolver := &deploy.ServiceSpecResolver{
|
||||
// If the domain is not found (not reserved), an empty domain is used for the resolver.
|
||||
ClusterDomain: domain,
|
||||
}
|
||||
|
||||
if strategy == nil {
|
||||
strategy = &deploy.RollingStrategy{State: state}
|
||||
}
|
||||
|
||||
return &Deployment{
|
||||
Client: cli,
|
||||
Project: project,
|
||||
SpecResolver: resolver,
|
||||
Strategy: strategy,
|
||||
state: state,
|
||||
}, nil
|
||||
}
|
||||
@@ -90,7 +99,7 @@ func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error)
|
||||
for _, spec := range serviceSpecs {
|
||||
// TODO: properly handle depends_on conditions in the service deployment plan as the first operation.
|
||||
// Pass the update cluster state with scheduled volumes to the deployment.
|
||||
deployment := deploy.NewDeployment(d.Client, spec, &deploy.RollingStrategy{State: d.state})
|
||||
deployment := deploy.NewDeployment(d.Client, spec, d.Strategy)
|
||||
servicePlan, err := deployment.Plan(ctx)
|
||||
if err != nil {
|
||||
return plan, fmt.Errorf("create deployment plan for service '%s': %w", spec.Name, err)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package compose
|
||||
|
||||
// TODO: make compose, cli, and api packages public.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
@@ -24,8 +22,9 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
|
||||
composecli.WithConfigFileEnv,
|
||||
// If none was selected, get default Compose file names from current or parent folders.
|
||||
composecli.WithDefaultConfigPath,
|
||||
composecli.WithExtension(PortsExtensionKey, PortsSource{}),
|
||||
composecli.WithExtension(CaddyExtensionKey, Caddy{}),
|
||||
composecli.WithExtension(MachinesExtensionKey, MachinesSource{}),
|
||||
composecli.WithExtension(PortsExtensionKey, PortsSource{}),
|
||||
}
|
||||
|
||||
options, err := composecli.NewProjectOptions(
|
||||
@@ -41,9 +40,17 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if project, err = transformServicesCaddyExtension(project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if project, err = transformServicesPortsExtension(project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate extension combinations after all transformations.
|
||||
if err = validateServicesExtensions(project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
@@ -57,12 +57,18 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
||||
Mode: api.ServiceModeReplicated,
|
||||
}
|
||||
|
||||
// Map x-caddy extension to spec.Caddy if specified.
|
||||
if caddy, ok := service.Extensions[CaddyExtensionKey].(Caddy); ok && caddy.Config != "" {
|
||||
spec.Caddy = &api.CaddySpec{
|
||||
Config: caddy.Config,
|
||||
}
|
||||
}
|
||||
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok {
|
||||
spec.Ports = ports
|
||||
}
|
||||
|
||||
if machines, ok := service.Extensions[MachinesExtensionKey].(MachinesSource); ok {
|
||||
spec.Placement.Machines = []string(machines)
|
||||
spec.Placement.Machines = machines
|
||||
}
|
||||
|
||||
// Map LogDriver if specified
|
||||
@@ -99,6 +105,15 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
||||
spec.Volumes = volumeSpecs
|
||||
spec.Container.VolumeMounts = volumeMounts
|
||||
|
||||
// Parse configs
|
||||
configSpecs, configMounts, err := configSpecsFromCompose(project.Configs, service.Configs, project.WorkingDir)
|
||||
if err != nil {
|
||||
return spec, err
|
||||
}
|
||||
|
||||
spec.Configs = configSpecs
|
||||
spec.Container.ConfigMounts = configMounts
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
@@ -242,3 +257,32 @@ func tmpfsVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig) api.Vol
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
// validateServicesExtensions validates extension combinations across all services in the project.
|
||||
func validateServicesExtensions(project *types.Project) error {
|
||||
for _, service := range project.Services {
|
||||
// Check for x-caddy and x-ports conflict, unless all ports are host mode.
|
||||
hasCaddy := false
|
||||
if caddy, ok := service.Extensions[CaddyExtensionKey].(Caddy); ok && caddy.Config != "" {
|
||||
hasCaddy = true
|
||||
}
|
||||
|
||||
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok && len(ports) > 0 && hasCaddy {
|
||||
// Check if all ports are in host mode.
|
||||
hasIngressPort := false
|
||||
for _, p := range ports {
|
||||
if p.Mode == "" || p.Mode == api.PortModeIngress {
|
||||
hasIngressPort = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasIngressPort {
|
||||
return fmt.Errorf("service '%s': ingress ports in 'x-ports' and 'x-caddy' cannot be specified "+
|
||||
"simultaneously: Caddy config is auto-generated from ingress ports, use only one of them. "+
|
||||
"Host mode ports in 'x-caddy' can be used with 'x-caddy'", service.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -38,6 +39,7 @@ func loadProjectFromContent(t *testing.T, content string) (*types.Project, error
|
||||
if o.KnownExtensions == nil {
|
||||
o.KnownExtensions = map[string]any{}
|
||||
}
|
||||
o.KnownExtensions[CaddyExtensionKey] = Caddy{}
|
||||
o.KnownExtensions[PortsExtensionKey] = PortsSource{}
|
||||
o.KnownExtensions[MachinesExtensionKey] = MachinesSource{}
|
||||
})
|
||||
@@ -45,11 +47,19 @@ func loadProjectFromContent(t *testing.T, content string) (*types.Project, error
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply ports extension transformation since we're not using LoadProject
|
||||
// Apply extension transformations since we're not using LoadProject.
|
||||
if project, err = transformServicesCaddyExtension(project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if project, err = transformServicesPortsExtension(project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate extension combinations after all transformations.
|
||||
if err = validateServicesExtensions(project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
@@ -182,6 +192,25 @@ func TestServiceSpecFromCompose(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Ports: []api.PortSpec{
|
||||
{
|
||||
Hostname: "test.example.com",
|
||||
ContainerPort: 80,
|
||||
Protocol: api.ProtocolHTTPS,
|
||||
Mode: api.PortModeIngress,
|
||||
},
|
||||
{
|
||||
ContainerPort: 8000,
|
||||
Protocol: api.ProtocolHTTP,
|
||||
Mode: api.PortModeIngress,
|
||||
},
|
||||
{
|
||||
ContainerPort: 3000,
|
||||
PublishedPort: 5000,
|
||||
Protocol: "tcp",
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
},
|
||||
Replicas: 3,
|
||||
Volumes: []api.VolumeSpec{
|
||||
{
|
||||
@@ -225,6 +254,19 @@ func TestServiceSpecFromCompose(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"test-caddy-config": {
|
||||
Name: "test-caddy-config",
|
||||
Mode: api.ServiceModeReplicated,
|
||||
Container: api.ContainerSpec{
|
||||
Image: "myapp:1.2.3",
|
||||
PullPolicy: api.PullPolicyMissing,
|
||||
},
|
||||
Caddy: &api.CaddySpec{
|
||||
Config: `test-caddy-config.example.com {
|
||||
reverse_proxy {{ upstreams 80 }}
|
||||
}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -245,13 +287,153 @@ func TestServiceSpecFromCompose(t *testing.T) {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
|
||||
assert.True(t, cmp.Equal(spec, expectedSpec, cmpopts.EquateEmpty()),
|
||||
cmp.Diff(spec, expectedSpec, cmpopts.EquateEmpty()))
|
||||
cmpOpts := cmp.Options{cmpopts.EquateEmpty(), cmpopts.EquateComparable(netip.Addr{})}
|
||||
assert.True(t, cmp.Equal(spec, expectedSpec, cmpOpts...), cmp.Diff(spec, expectedSpec, cmpOpts...))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSpecFromCompose_Caddy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
composeYAML string
|
||||
want *api.CaddySpec
|
||||
}{
|
||||
{
|
||||
name: "x-caddy as string",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy: |
|
||||
example.com {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
`,
|
||||
want: &api.CaddySpec{
|
||||
Config: `example.com {
|
||||
reverse_proxy web:80
|
||||
}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "x-caddy as string with extra spaces",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy: |+
|
||||
|
||||
example.com {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
|
||||
|
||||
`,
|
||||
want: &api.CaddySpec{
|
||||
Config: `example.com {
|
||||
reverse_proxy web:80
|
||||
}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "x-caddy as object with config field",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy:
|
||||
config: |
|
||||
example.com {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
`,
|
||||
want: &api.CaddySpec{
|
||||
Config: `example.com {
|
||||
reverse_proxy web:80
|
||||
}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "x-caddy as object with config field and extra spaces",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy:
|
||||
config: |+
|
||||
|
||||
example.com {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
|
||||
|
||||
`,
|
||||
want: &api.CaddySpec{
|
||||
Config: `example.com {
|
||||
reverse_proxy web:80
|
||||
}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "x-caddy with path to Caddyfile",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy: testdata/Caddyfile
|
||||
`,
|
||||
want: &api.CaddySpec{
|
||||
Config: `test.example.com {
|
||||
reverse_proxy test:8000
|
||||
}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "x-caddy with empty string",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy: ""
|
||||
`,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "no x-caddy extension",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
`,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "x-caddy with empty object",
|
||||
composeYAML: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-caddy: {}
|
||||
`,
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
project, err := loadProjectFromContent(t, tt.composeYAML)
|
||||
require.NoError(t, err)
|
||||
|
||||
spec, err := ServiceSpecFromCompose(project, "web")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.want, spec.Caddy)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
test.example.com {
|
||||
reverse_proxy test:8000
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user