mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
|
[tools."aqua:vektra/mockery"]
|
||||||
|
version = "3.5.3"
|
||||||
|
backend = "aqua:vektra/mockery"
|
||||||
|
|
||||||
|
[tools."aqua:vektra/mockery".checksums]
|
||||||
|
"mockery_3.5.3_Darwin_arm64.tar.gz" = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
|
||||||
|
"mockery_3.5.3_Linux_x86_64.tar.gz" = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
|
||||||
|
|
||||||
[tools.go]
|
[tools.go]
|
||||||
version = "1.23.10"
|
version = "1.23.10"
|
||||||
backend = "core:go"
|
backend = "core:go"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
experimental = true
|
experimental = true
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
|
"aqua:vektra/mockery" = "3.5.3"
|
||||||
go = "1.23"
|
go = "1.23"
|
||||||
golangci-lint = "2.2.2"
|
golangci-lint = "2.2.2"
|
||||||
protoc = "27.3"
|
protoc = "27.3"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
packages:
|
||||||
|
github.com/psviderski/uncloud/internal/machine/caddyconfig:
|
||||||
|
interfaces:
|
||||||
|
CaddyfileValidator:
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# 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)
|
||||||
|
- **`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
|
||||||
|
|
||||||
|
- Unit tests alongside source files (`*_test.go`)
|
||||||
|
- Integration tests in `test/e2e/`
|
||||||
|
- Test fixtures in `test/fixtures/`
|
||||||
|
|
||||||
|
### 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`: User-facing documentation
|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -66,6 +66,10 @@ ucind-image:
|
|||||||
ucind-multiarch-image-push:
|
ucind-multiarch-image-push:
|
||||||
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
|
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
|
||||||
|
|
||||||
|
.PHONY: mocks
|
||||||
|
mocks:
|
||||||
|
@mockery
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test:
|
test:
|
||||||
ifeq ($(TEST_NAME),)
|
ifeq ($(TEST_NAME),)
|
||||||
@@ -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.
|
# 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)
|
GOOS=linux golangci-lint run $(ARGS)
|
||||||
|
|
||||||
|
.PHONY: cli-docs
|
||||||
|
cli-docs:
|
||||||
|
go run ./cmd/uncloud docs
|
||||||
|
|||||||
@@ -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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -18,9 +19,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type deployOptions struct {
|
type deployOptions struct {
|
||||||
image string
|
caddyfile string
|
||||||
machines []string
|
image string
|
||||||
context string
|
machines []string
|
||||||
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDeployCommand() *cobra.Command {
|
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", "",
|
cmd.Flags().StringVar(&opts.image, "image", "",
|
||||||
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
|
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
|
||||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
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 {
|
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)
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
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{
|
placement := api.Placement{
|
||||||
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||||
}
|
}
|
||||||
d, err := clusterClient.NewCaddyDeployment(opts.image, placement)
|
d, err := clusterClient.NewCaddyDeployment(opts.image, caddyfile, placement)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
Short: "Manage Caddy reverse proxy service.",
|
Short: "Manage Caddy reverse proxy service.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
|
NewConfigCommand(),
|
||||||
NewDeployCommand(),
|
NewDeployCommand(),
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -146,7 +146,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
|
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
||||||
// that may cause a small downtime.
|
// that may cause a small downtime.
|
||||||
d, err := clusterClient.NewCaddyDeployment(caddyImage, api.Placement{})
|
d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func NewInitCommand() *cobra.Command {
|
|||||||
"Version of the Uncloud daemon to install on the machine.",
|
"Version of the Uncloud daemon to install on the machine.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.context, "context", "c", "default",
|
&opts.context, "context", "c", cli.DefaultContextName,
|
||||||
"Name of the created context for the initialised cluster in the Uncloud config.",
|
"Name of the created context for the initialised cluster in the Uncloud config.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !opts.noCaddy {
|
if !opts.noCaddy {
|
||||||
d, err := client.NewCaddyDeployment("", api.Placement{})
|
d, err := client.NewCaddyDeployment("", "", api.Placement{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -27,7 +27,7 @@ type globalOptions struct {
|
|||||||
func main() {
|
func main() {
|
||||||
opts := globalOptions{}
|
opts := globalOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "uncloud",
|
Use: "uc",
|
||||||
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
||||||
Version: version.String(),
|
Version: version.String(),
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
@@ -75,6 +75,7 @@ func main() {
|
|||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewDeployCommand(),
|
NewDeployCommand(),
|
||||||
|
NewDocsCommand(),
|
||||||
NewBuildCommand(),
|
NewBuildCommand(),
|
||||||
caddy.NewRootCommand(),
|
caddy.NewRootCommand(),
|
||||||
cmdcontext.NewRootCommand(),
|
cmdcontext.NewRootCommand(),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type runOptions struct {
|
type runOptions struct {
|
||||||
|
caddyfile string
|
||||||
command []string
|
command []string
|
||||||
cpu dockeropts.NanoCPUs
|
cpu dockeropts.NanoCPUs
|
||||||
entrypoint string
|
entrypoint string
|
||||||
@@ -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", "",
|
cmd.Flags().VarP(&opts.cpu, "cpu", "",
|
||||||
"Maximum number of CPU cores a service container can use. Fractional values are allowed: "+
|
"Maximum number of CPU cores a service container can use. Fractional values are allowed: "+
|
||||||
"0.5 for half a core or 2.25 for two and a quarter cores.")
|
"0.5 for half a core or 2.25 for two and a quarter cores.")
|
||||||
@@ -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.")
|
"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,
|
cmd.Flags().StringSliceVarP(&opts.publish, "publish", "p", nil,
|
||||||
"Publish a service port to make it accessible outside the cluster. Can be specified multiple times.\n"+
|
"Publish a service port to make it accessible outside the cluster. Can be specified multiple times.\n"+
|
||||||
"Format: [hostname:][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"+
|
"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"+
|
"and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.\n"+
|
||||||
"Examples:\n"+
|
"Examples:\n"+
|
||||||
@@ -161,6 +165,15 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
|||||||
func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
||||||
var spec api.ServiceSpec
|
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)
|
env, err := parseEnv(opts.env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return spec, err
|
return spec, err
|
||||||
@@ -218,6 +231,12 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
|||||||
Volumes: volumes,
|
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.
|
// Overwrite the default ENTRYPOINT of the image or reset it if an empty string is passed.
|
||||||
if opts.entrypoint != "" {
|
if opts.entrypoint != "" {
|
||||||
spec.Container.Entrypoint = []string{opts.entrypoint}
|
spec.Container.Entrypoint = []string{opts.entrypoint}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/BurntSushi/toml v1.4.0
|
github.com/BurntSushi/toml v1.4.0
|
||||||
github.com/Masterminds/semver v1.5.0
|
github.com/Masterminds/semver v1.5.0
|
||||||
github.com/Masterminds/squirrel v1.5.4
|
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/caddyserver/caddy/v2 v2.8.4
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0
|
github.com/cenkalti/backoff/v4 v4.3.0
|
||||||
github.com/charmbracelet/huh v0.6.0
|
github.com/charmbracelet/huh v0.6.0
|
||||||
@@ -35,6 +36,7 @@ require (
|
|||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/lmittmann/tint v1.0.5
|
github.com/lmittmann/tint v1.0.5
|
||||||
github.com/miekg/dns v1.1.65
|
github.com/miekg/dns v1.1.65
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
github.com/moby/term v0.5.0
|
github.com/moby/term v0.5.0
|
||||||
github.com/opencontainers/go-digest v1.0.0
|
github.com/opencontainers/go-digest v1.0.0
|
||||||
github.com/opencontainers/image-spec v1.1.0
|
github.com/opencontainers/image-spec v1.1.0
|
||||||
@@ -107,6 +109,7 @@ require (
|
|||||||
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
|
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
|
||||||
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
||||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
|
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/docker/buildx v0.18.0 // indirect
|
github.com/docker/buildx v0.18.0 // indirect
|
||||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||||
github.com/docker/docker-credential-helpers v0.8.2 // indirect
|
github.com/docker/docker-credential-helpers v0.8.2 // indirect
|
||||||
@@ -204,7 +207,6 @@ require (
|
|||||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // 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/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/moby/buildkit v0.17.2 // indirect
|
github.com/moby/buildkit v0.17.2 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
@@ -265,6 +267,7 @@ require (
|
|||||||
github.com/spf13/cast v1.7.0 // indirect
|
github.com/spf13/cast v1.7.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
github.com/stoewer/go-strcase v1.2.0 // 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/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
|
||||||
github.com/theupdateframework/notary v0.7.0 // indirect
|
github.com/theupdateframework/notary v0.7.0 // indirect
|
||||||
github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205 // indirect
|
github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205 // indirect
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdII
|
|||||||
github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
|
github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
|
||||||
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs=
|
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs=
|
||||||
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
|
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||||
|
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
|
||||||
|
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
@@ -259,6 +265,8 @@ github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WA
|
|||||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/docker/buildx v0.18.0 h1:rSauXHeJt90NvtXrLK5J992Eb0UPJZs2vV3u1zTf1nE=
|
github.com/docker/buildx v0.18.0 h1:rSauXHeJt90NvtXrLK5J992Eb0UPJZs2vV3u1zTf1nE=
|
||||||
github.com/docker/buildx v0.18.0/go.mod h1:JGNSshOhHs5FhG3u51jXUf4lLOeD2QBIlJ2vaRB67p4=
|
github.com/docker/buildx v0.18.0/go.mod h1:JGNSshOhHs5FhG3u51jXUf4lLOeD2QBIlJ2vaRB67p4=
|
||||||
github.com/docker/cli v27.5.0+incompatible h1:aMphQkcGtpHixwwhAXJT1rrK/detk2JIvDaFkLctbGM=
|
github.com/docker/cli v27.5.0+incompatible h1:aMphQkcGtpHixwwhAXJT1rrK/detk2JIvDaFkLctbGM=
|
||||||
@@ -487,6 +495,8 @@ github.com/hashicorp/memberlist v0.5.1 h1:mk5dRuzeDNis2bi6LLoQIXfMH7JQvAzt3mQD0v
|
|||||||
github.com/hashicorp/memberlist v0.5.1/go.mod h1:zGDXV6AqbDTKTM6yxW0I4+JtFzZAJVoIPvss4hV8F24=
|
github.com/hashicorp/memberlist v0.5.1/go.mod h1:zGDXV6AqbDTKTM6yxW0I4+JtFzZAJVoIPvss4hV8F24=
|
||||||
github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY=
|
github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY=
|
||||||
github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=
|
github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||||
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||||
@@ -1039,6 +1049,7 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
|||||||
+30
-7
@@ -26,7 +26,7 @@ const (
|
|||||||
// DefaultSSHKeyPath is the fallback location for the SSH private key when provisioning remote machines.
|
// 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.
|
// Used when no key is explicitly provided and SSH agent authentication fails.
|
||||||
DefaultSSHKeyPath = "~/.ssh/id_ed25519"
|
DefaultSSHKeyPath = "~/.ssh/id_ed25519"
|
||||||
defaultContextName = "default"
|
DefaultContextName = "default"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CLI struct {
|
type CLI struct {
|
||||||
@@ -170,12 +170,9 @@ func (cli *CLI) InitCluster(ctx context.Context, opts InitClusterOptions) (*clie
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
||||||
contextName := opts.Context
|
contextName, err := cli.newContextName(opts.Context)
|
||||||
if contextName == "" {
|
if err != nil {
|
||||||
contextName = defaultContextName
|
return nil, err
|
||||||
}
|
|
||||||
if _, ok := cli.Config.Contexts[contextName]; ok {
|
|
||||||
return nil, fmt.Errorf("cluster context '%s' already exists", contextName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
machineClient, err := provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
machineClient, err := provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
||||||
@@ -250,6 +247,32 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
|||||||
return machineClient, nil
|
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 {
|
type AddMachineOptions struct {
|
||||||
Context string
|
Context string
|
||||||
MachineName string
|
MachineName string
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ func (c *Config) Read() error {
|
|||||||
|
|
||||||
func (c *Config) Save() error {
|
func (c *Config) Save() error {
|
||||||
dir, _ := filepath.Split(c.path)
|
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 {
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
return fmt.Errorf("create config directory '%s': %w", dir, err)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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 (
|
const (
|
||||||
CaddyGroup = "uncloud"
|
CaddyServiceName = "caddy"
|
||||||
VerifyPath = "/.uncloud-verify"
|
CaddyGroup = "uncloud"
|
||||||
|
VerifyPath = "/.uncloud-verify"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Controller monitors container changes in the cluster store and generates a configuration file for Caddy reverse
|
// 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
|
// proxy. The generated configuration allows Caddy to route external traffic to service containers across the internal
|
||||||
// network.
|
// network.
|
||||||
type Controller struct {
|
type Controller struct {
|
||||||
store *store.Store
|
machineID string
|
||||||
path string
|
caddyfilePath string
|
||||||
verifyResponse string
|
generator *CaddyfileGenerator
|
||||||
|
client *CaddyAdminClient
|
||||||
|
store *store.Store
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewController(store *store.Store, path string, verifyResponse string) (*Controller, error) {
|
func NewController(machineID, configDir, adminSock string, store *store.Store) (*Controller, error) {
|
||||||
dir := filepath.Dir(path)
|
if err := os.MkdirAll(configDir, 0o750); err != nil {
|
||||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
return nil, fmt.Errorf("create directory for Caddy configuration '%s': %w", configDir, err)
|
||||||
return nil, fmt.Errorf("create parent directory for Caddy configuration '%s': %w", dir, err)
|
|
||||||
}
|
}
|
||||||
if err := fs.Chown(dir, "", CaddyGroup); err != nil {
|
if err := fs.Chown(configDir, "", CaddyGroup); err != nil {
|
||||||
return nil, fmt.Errorf("change owner of parent directory for Caddy configuration '%s': %w", dir, err)
|
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{
|
return &Controller{
|
||||||
store: store,
|
machineID: machineID,
|
||||||
path: path,
|
caddyfilePath: filepath.Join(configDir, "Caddyfile"),
|
||||||
verifyResponse: verifyResponse,
|
generator: generator,
|
||||||
|
client: client,
|
||||||
|
store: store,
|
||||||
|
log: log,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) Run(ctx context.Context) error {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("subscribe to container changes: %w", err)
|
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)
|
containers = filterHealthyContainers(containers)
|
||||||
if err != nil {
|
c.generateAndLoadCaddyfile(ctx, containers)
|
||||||
return fmt.Errorf("filter available containers: %w", err)
|
|
||||||
}
|
// TODO: left for backward compatibility, remove later.
|
||||||
if err = c.generateConfig(containers); err != nil {
|
if err = c.generateJSONConfig(containers); err != nil {
|
||||||
return fmt.Errorf("generate Caddy configuration: %w", err)
|
c.log.Error("Failed to generate Caddy JSON configuration to disk.", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -64,47 +74,97 @@ func (c *Controller) Run(ctx context.Context) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("containers subscription failed")
|
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 {
|
if err != nil {
|
||||||
slog.Error("Failed to list containers.", "err", err)
|
c.log.Error("Failed to list containers.", "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
containers, err = c.filterAvailableContainers(containerRecords)
|
containers = filterHealthyContainers(containers)
|
||||||
if err != nil {
|
c.generateAndLoadCaddyfile(ctx, containers)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterAvailableContainers filters out containers from this machine that are likely unavailable. The availability
|
// filterHealthyContainers filters out containers that are not healthy.
|
||||||
// is determined by the cluster membership state of the machine that the container is running on.
|
// TODO: Filters out containers from this machine that are likely unavailable. The availability can be determined
|
||||||
// TODO: implement machine membership check using Corrossion Admin client.
|
// by the cluster membership state of the machine that the container is running on. Implement machine membership
|
||||||
func (c *Controller) filterAvailableContainers(
|
// check using Corrossion Admin client.
|
||||||
containerRecords []store.ContainerRecord,
|
func filterHealthyContainers(containers []store.ContainerRecord) []store.ContainerRecord {
|
||||||
) ([]api.ServiceContainer, error) {
|
healthy := make([]store.ContainerRecord, 0, len(containers))
|
||||||
containers := make([]api.ServiceContainer, len(containerRecords))
|
for _, cr := range containers {
|
||||||
for i, cr := range containerRecords {
|
if cr.Container.Healthy() {
|
||||||
containers[i] = api.ServiceContainer{
|
healthy = append(healthy, cr)
|
||||||
Container: cr.Container,
|
|
||||||
// TODO: restore ServiceSpec from the container record once it's saved in the store.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return containers, nil
|
return healthy
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) generateConfig(containers []api.ServiceContainer) error {
|
func (c *Controller) generateAndLoadCaddyfile(ctx context.Context, containers []store.ContainerRecord) {
|
||||||
config, err := GenerateConfig(containers, c.verifyResponse)
|
// 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -113,12 +173,13 @@ func (c *Controller) generateConfig(containers []api.ServiceContainer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshal Caddy configuration: %w", err)
|
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 {
|
if err = os.WriteFile(configPath, configBytes, 0o640); err != nil {
|
||||||
return fmt.Errorf("write Caddy configuration to file '%s': %w", c.path, err)
|
return fmt.Errorf("write Caddy configuration to file '%s': %w", configPath, err)
|
||||||
}
|
}
|
||||||
if err = fs.Chown(c.path, "", CaddyGroup); err != nil {
|
if err = fs.Chown(configPath, "", CaddyGroup); err != nil {
|
||||||
return fmt.Errorf("change owner of Caddy configuration file '%s': %w", c.path, err)
|
return fmt.Errorf("change owner of Caddy configuration file '%s': %w", configPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+2
-43
@@ -4,9 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"maps"
|
"maps"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -19,47 +17,8 @@ import (
|
|||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GenerateConfig(containers []api.ServiceContainer, verifyResponse string) (*caddy.Config, error) {
|
func GenerateJSONConfig(containers []api.ServiceContainer, verifyResponse string) (*caddy.Config, error) {
|
||||||
// Maps hostnames to lists of upstreams (container IP:port pairs).
|
httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromPorts(containers)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var warnings []caddyconfig.Warning
|
var warnings []caddyconfig.Warning
|
||||||
servers := make(map[string]*caddyhttp.Server)
|
servers := make(map[string]*caddyhttp.Server)
|
||||||
+2
-76
@@ -13,7 +13,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGenerateConfig(t *testing.T) {
|
func TestGenerateJSONConfig(t *testing.T) {
|
||||||
configWithoutServices := `{
|
configWithoutServices := `{
|
||||||
"servers": {
|
"servers": {
|
||||||
"http": {
|
"http": {
|
||||||
@@ -312,73 +312,11 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
want: configWithoutServices,
|
want: configWithoutServices,
|
||||||
wantErr: false,
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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 {
|
if tt.wantErr {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@@ -439,15 +377,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.Errorf(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(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
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-12
@@ -12,7 +12,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
"github.com/docker/docker/client"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/machine/caddyconfig"
|
"github.com/psviderski/uncloud/internal/machine/caddyconfig"
|
||||||
"github.com/psviderski/uncloud/internal/machine/constants"
|
"github.com/psviderski/uncloud/internal/machine/constants"
|
||||||
@@ -36,10 +35,9 @@ type clusterController struct {
|
|||||||
wgnet *network.WireGuardNetwork
|
wgnet *network.WireGuardNetwork
|
||||||
endpointChanges <-chan network.EndpointChangeEvent
|
endpointChanges <-chan network.EndpointChangeEvent
|
||||||
|
|
||||||
server *grpc.Server
|
server *grpc.Server
|
||||||
corroService corroservice.Service
|
corroService corroservice.Service
|
||||||
dockerCli *client.Client
|
dockerCtrl *docker.Controller
|
||||||
dockerManager *docker.Manager
|
|
||||||
// dockerReady is signalled when Docker is configured and ready for containers.
|
// dockerReady is signalled when Docker is configured and ready for containers.
|
||||||
dockerReady chan<- struct{}
|
dockerReady chan<- struct{}
|
||||||
caddyconfigCtrl *caddyconfig.Controller
|
caddyconfigCtrl *caddyconfig.Controller
|
||||||
@@ -57,7 +55,7 @@ func newClusterController(
|
|||||||
store *store.Store,
|
store *store.Store,
|
||||||
server *grpc.Server,
|
server *grpc.Server,
|
||||||
corroService corroservice.Service,
|
corroService corroservice.Service,
|
||||||
dockerCli *client.Client,
|
dockerService *docker.Service,
|
||||||
dockerReady chan<- struct{},
|
dockerReady chan<- struct{},
|
||||||
caddyfileCtrl *caddyconfig.Controller,
|
caddyfileCtrl *caddyconfig.Controller,
|
||||||
dnsServer *dns.Server,
|
dnsServer *dns.Server,
|
||||||
@@ -77,8 +75,7 @@ func newClusterController(
|
|||||||
endpointChanges: endpointChanges,
|
endpointChanges: endpointChanges,
|
||||||
server: server,
|
server: server,
|
||||||
corroService: corroService,
|
corroService: corroService,
|
||||||
dockerCli: dockerCli,
|
dockerCtrl: docker.NewController(state.ID, dockerService, store),
|
||||||
dockerManager: docker.NewManager(dockerCli, state.ID, store),
|
|
||||||
dockerReady: dockerReady,
|
dockerReady: dockerReady,
|
||||||
caddyconfigCtrl: caddyfileCtrl,
|
caddyconfigCtrl: caddyfileCtrl,
|
||||||
dnsServer: dnsServer,
|
dnsServer: dnsServer,
|
||||||
@@ -238,11 +235,11 @@ func (cc *clusterController) Run(ctx context.Context) error {
|
|||||||
|
|
||||||
// ensureDockerNetwork ensures that the Docker network is configured and ready for containers.
|
// ensureDockerNetwork ensures that the Docker network is configured and ready for containers.
|
||||||
func (cc *clusterController) ensureDockerNetwork(ctx context.Context) error {
|
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)
|
return fmt.Errorf("wait for Docker daemon: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cc.dockerManager.EnsureUncloudNetwork(
|
if err := cc.dockerCtrl.EnsureUncloudNetwork(
|
||||||
ctx,
|
ctx,
|
||||||
cc.state.Network.Subnet,
|
cc.state.Network.Subnet,
|
||||||
cc.dnsServer.ListenAddr(),
|
cc.dnsServer.ListenAddr(),
|
||||||
@@ -257,6 +254,7 @@ func (cc *clusterController) ensureDockerNetwork(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// syncDockerContainers watches local Docker containers and syncs them to the cluster store.
|
// 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 {
|
func (cc *clusterController) syncDockerContainers(ctx context.Context) error {
|
||||||
// Retry to watch and sync containers until the context is done.
|
// Retry to watch and sync containers until the context is done.
|
||||||
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||||
@@ -265,7 +263,7 @@ func (cc *clusterController) syncDockerContainers(ctx context.Context) error {
|
|||||||
backoff.WithMaxElapsedTime(0),
|
backoff.WithMaxElapsedTime(0),
|
||||||
), ctx)
|
), ctx)
|
||||||
watchAndSync := func() error {
|
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)
|
slog.Error("Failed to watch and sync containers to cluster store, retrying.", "err", wErr)
|
||||||
return wErr
|
return wErr
|
||||||
}
|
}
|
||||||
@@ -413,7 +411,7 @@ func (cc *clusterController) Cleanup() error {
|
|||||||
<-cc.stopped
|
<-cc.stopped
|
||||||
|
|
||||||
var errs []error
|
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))
|
errs = append(errs, fmt.Errorf("cleanup Docker resources: %w", err))
|
||||||
}
|
}
|
||||||
if err := cc.wgnet.Cleanup(); err != nil {
|
if err := cc.wgnet.Cleanup(); err != nil {
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/store"
|
"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
|
// ClusterResolver implements Resolver by tracking containers in the cluster and resolving service names
|
||||||
@@ -84,17 +82,13 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
ctr := api.ServiceContainer{Container: record.Container}
|
ctr := record.Container
|
||||||
if ctr.ServiceID() == "" || ctr.ServiceName() == "" {
|
if ctr.ServiceID() == "" || ctr.ServiceName() == "" {
|
||||||
// Container is not part of a service, skip it.
|
// Container is not part of a service, skip it.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: remove normalisation after implementing service name validation:
|
newServiceIPs[ctr.ServiceName()] = append(newServiceIPs[ctr.ServiceName()], ip)
|
||||||
//.https://github.com/psviderski/uncloud/issues/53
|
|
||||||
serviceName := strings.ToLower(ctr.ServiceName())
|
|
||||||
|
|
||||||
newServiceIPs[serviceName] = append(newServiceIPs[serviceName], ip)
|
|
||||||
// Also add the service ID as a valid lookup.
|
// Also add the service ID as a valid lookup.
|
||||||
newServiceIPs[ctr.ServiceID()] = append(newServiceIPs[ctr.ServiceID()], ip)
|
newServiceIPs[ctr.ServiceID()] = append(newServiceIPs[ctr.ServiceID()], ip)
|
||||||
containersCount++
|
containersCount++
|
||||||
|
|||||||
@@ -196,13 +196,27 @@ func (c *Client) RemoveContainer(ctx context.Context, id string, opts container.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
type PullImageMessage struct {
|
type PullImageMessage struct {
|
||||||
Message jsonmessage.JSONMessage
|
Message jsonmessage.JSONMessage
|
||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) PullImage(ctx context.Context, image string) (<-chan PullImageMessage, error) {
|
func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions) (<-chan PullImageMessage, error) {
|
||||||
stream, err := c.grpcClient.PullImage(ctx, &pb.PullImageRequest{Image: image})
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"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/events"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
"github.com/psviderski/uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -24,23 +23,26 @@ const (
|
|||||||
SyncInterval = 30 * time.Second
|
SyncInterval = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type Manager struct {
|
// Controller monitors Docker events and synchronises service containers with the cluster store.
|
||||||
client *client.Client
|
type Controller struct {
|
||||||
// machineID is the ID of the machine where the managed Docker daemon is running.
|
// machineID is the ID of the machine where the managed Docker daemon is running.
|
||||||
machineID string
|
machineID string
|
||||||
|
client *client.Client
|
||||||
|
service *Service
|
||||||
store *store.Store
|
store *store.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(client *client.Client, machineID string, store *store.Store) *Manager {
|
func NewController(machineID string, service *Service, store *store.Store) *Controller {
|
||||||
return &Manager{
|
return &Controller{
|
||||||
client: client,
|
|
||||||
machineID: machineID,
|
machineID: machineID,
|
||||||
|
client: service.Client,
|
||||||
|
service: service,
|
||||||
store: store,
|
store: store,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests.
|
// 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)
|
ticker := time.NewTicker(1 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ func (m *Manager) WaitDaemonReady(ctx context.Context) error {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
_, err := m.client.Ping(ctx)
|
_, err := c.client.Ping(ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
ready = true
|
ready = true
|
||||||
break
|
break
|
||||||
@@ -67,7 +69,7 @@ func (m *Manager) WaitDaemonReady(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) WatchAndSyncContainers(ctx context.Context) error {
|
func (c *Controller) WatchAndSyncContainers(ctx context.Context) error {
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
// Filter only local container events.
|
// 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.
|
// 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.")
|
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.
|
// The deferred cancel will stop the event subscription.
|
||||||
return fmt.Errorf("sync containers to cluster store: %w", err)
|
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"],
|
"container_name", e.Actor.Attributes["name"],
|
||||||
"action", e.Action)
|
"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)
|
return fmt.Errorf("sync containers to cluster store: %w", err)
|
||||||
}
|
}
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
slog.Debug("Syncing containers to cluster store triggered by a regular interval.",
|
slog.Debug("Syncing containers to cluster store triggered by a regular interval.",
|
||||||
"interval", SyncInterval)
|
"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)
|
return fmt.Errorf("sync containers to cluster store: %w", err)
|
||||||
}
|
}
|
||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
@@ -144,33 +146,16 @@ func (m *Manager) WatchAndSyncContainers(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) syncContainersToStore(ctx context.Context) error {
|
func (c *Controller) syncContainersToStore(ctx context.Context) error {
|
||||||
storeContainers, err := m.store.ListContainers(ctx, store.ListOptions{MachineIDs: []string{m.machineID}})
|
storeContainers, err := c.store.ListContainers(ctx, store.ListOptions{MachineIDs: []string{c.machineID}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("list containers from store: %w", err)
|
return fmt.Errorf("list containers from store: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List only Uncloud service containers identified by their labels.
|
containers, err := c.service.ListServiceContainers(ctx, "", container.ListOptions{})
|
||||||
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),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO: mark all containers as outdated in the store.
|
// TODO: mark all containers as outdated in the store.
|
||||||
return fmt.Errorf("list Docker containers: %w", err)
|
return fmt.Errorf("list service 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}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete containers from the store that are no longer present in the Docker daemon.
|
// 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
|
var storeErr error
|
||||||
if len(deleteIDs) > 0 {
|
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)
|
storeErr = fmt.Errorf("delete containers from store: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or update the current Docker containers in the store.
|
// Create or update the current Docker containers in the store.
|
||||||
for _, c := range containers {
|
for _, ctr := range containers {
|
||||||
if err = m.store.CreateOrUpdateContainer(ctx, c, m.machineID); err != nil {
|
if err = c.store.CreateOrUpdateContainer(ctx, ctr, c.machineID); err != nil {
|
||||||
storeErr = errors.Join(storeErr, fmt.Errorf("create or update container %q: %w", c.ID, err))
|
storeErr = errors.Join(storeErr, fmt.Errorf("create or update container '%s': %w", ctr.ID, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return storeErr
|
return storeErr
|
||||||
+2
-2
@@ -9,11 +9,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// EnsureUncloudNetwork is a stub for Darwin.
|
// 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")
|
return fmt.Errorf("not supported on Darwin")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup is a stub for Darwin.
|
// Cleanup is a stub for Darwin.
|
||||||
func (m *Manager) Cleanup() error {
|
func (c *Controller) Cleanup() error {
|
||||||
return fmt.Errorf("not supported on Darwin")
|
return fmt.Errorf("not supported on Darwin")
|
||||||
}
|
}
|
||||||
+11
-11
@@ -22,10 +22,10 @@ import (
|
|||||||
// EnsureUncloudNetwork creates the Docker bridge network NetworkName with the provided machine subnet
|
// 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.
|
// 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.
|
// 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.
|
// Ensure the Docker network 'uncloud' is created with the correct subnet.
|
||||||
needsCreation := false
|
needsCreation := false
|
||||||
nw, err := m.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
|
nw, err := c.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !client.IsErrNotFound(err) {
|
if !client.IsErrNotFound(err) {
|
||||||
return fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err)
|
return fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err)
|
||||||
@@ -37,7 +37,7 @@ func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix,
|
|||||||
slog.Info(
|
slog.Info(
|
||||||
"Removing Docker network with old subnet.", "name", NetworkName, "subnet", nw.IPAM.Config[0].Subnet,
|
"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.
|
// 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)
|
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 needsCreation {
|
||||||
if _, err = m.client.NetworkCreate(
|
if _, err = c.client.NetworkCreate(
|
||||||
ctx, NetworkName, dnetwork.CreateOptions{
|
ctx, NetworkName, dnetwork.CreateOptions{
|
||||||
Driver: "bridge",
|
Driver: "bridge",
|
||||||
Scope: "local",
|
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())
|
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)
|
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.
|
// Cleanup removes all uncloud-managed containers and the uncloud Docker network.
|
||||||
func (m *Manager) Cleanup() error {
|
func (c *Controller) Cleanup() error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
var errs []error
|
var errs []error
|
||||||
|
|
||||||
// Remove uncloud-managed Docker containers.
|
// 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.
|
All: true, // Include stopped containers.
|
||||||
Filters: filters.NewArgs(
|
Filters: filters.NewArgs(
|
||||||
filters.Arg("label", api.LabelManaged),
|
filters.Arg("label", api.LabelManaged),
|
||||||
@@ -186,12 +186,12 @@ func (m *Manager) Cleanup() error {
|
|||||||
removed := 0
|
removed := 0
|
||||||
|
|
||||||
for _, ctr := range containers {
|
for _, ctr := range containers {
|
||||||
err = m.client.ContainerStop(ctx, ctr.ID, dockercontainer.StopOptions{})
|
err = c.client.ContainerStop(ctx, ctr.ID, dockercontainer.StopOptions{})
|
||||||
if err != nil && !client.IsErrNotFound(err) {
|
if err != nil && !client.IsErrNotFound(err) {
|
||||||
errs = append(errs, fmt.Errorf("stop container '%s': %w", ctr.ID, 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.
|
// Remove anonymous volumes created by the container.
|
||||||
RemoveVolumes: true,
|
RemoveVolumes: true,
|
||||||
})
|
})
|
||||||
@@ -205,7 +205,7 @@ func (m *Manager) Cleanup() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove the uncloud Docker network and related iptables rules.
|
// 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 {
|
if err == nil {
|
||||||
bridgeName := "br-" + nw.ID[:12]
|
bridgeName := "br-" + nw.ID[:12]
|
||||||
var subnet netip.Prefix
|
var subnet netip.Prefix
|
||||||
@@ -221,7 +221,7 @@ 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)
|
slog.Info("Docker network removed.", "name", NetworkName)
|
||||||
} else if !client.IsErrNotFound(err) {
|
} else if !client.IsErrNotFound(err) {
|
||||||
errs = append(errs, fmt.Errorf("remove Docker network '%s': %w", NetworkName, err))
|
errs = append(errs, fmt.Errorf("remove Docker network '%s': %w", NetworkName, err))
|
||||||
@@ -2,19 +2,21 @@ package docker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
|
dockercommand "github.com/docker/cli/cli/command"
|
||||||
|
dockerconfig "github.com/docker/cli/cli/config"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
@@ -45,8 +47,9 @@ var fullDockerIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
|||||||
// Server implements the gRPC Docker service that proxies requests to the Docker daemon.
|
// Server implements the gRPC Docker service that proxies requests to the Docker daemon.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
pb.UnimplementedDockerServer
|
pb.UnimplementedDockerServer
|
||||||
client *client.Client
|
client *client.Client
|
||||||
db *sqlx.DB
|
service *Service
|
||||||
|
db *sqlx.DB
|
||||||
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
|
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
|
||||||
// address if the address is unknown (e.g. when the machine is not initialised yet).
|
// address if the address is unknown (e.g. when the machine is not initialised yet).
|
||||||
internalDNSIP func() netip.Addr
|
internalDNSIP func() netip.Addr
|
||||||
@@ -73,10 +76,11 @@ func WithWaitForNetworkReady(waitForNetworkReady func(ctx context.Context) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new Docker gRPC server with the provided Docker client.
|
// NewServer creates a new Docker gRPC server with the provided Docker service.
|
||||||
func NewServer(cli *client.Client, db *sqlx.DB, internalDNSIP func() netip.Addr, opts ...ServerOption) *Server {
|
func NewServer(service *Service, db *sqlx.DB, internalDNSIP func() netip.Addr, opts ...ServerOption) *Server {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
client: cli,
|
client: service.Client,
|
||||||
|
service: service,
|
||||||
db: db,
|
db: db,
|
||||||
internalDNSIP: internalDNSIP,
|
internalDNSIP: internalDNSIP,
|
||||||
}
|
}
|
||||||
@@ -265,7 +269,6 @@ func (s *Server) RemoveContainer(ctx context.Context, req *pb.RemoveContainerReq
|
|||||||
func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreamingServer[pb.JSONMessage]) error {
|
func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreamingServer[pb.JSONMessage]) error {
|
||||||
ctx := stream.Context()
|
ctx := stream.Context()
|
||||||
|
|
||||||
// TODO: replace with another JSON serializable type (PullOptions.PrivilegeFunc is not serializable).
|
|
||||||
var opts image.PullOptions
|
var opts image.PullOptions
|
||||||
if len(req.Options) > 0 {
|
if len(req.Options) > 0 {
|
||||||
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
||||||
@@ -273,6 +276,14 @@ 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)
|
respBody, err := s.client.ImagePull(ctx, req.Image, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status.Errorf(codes.Internal, err.Error())
|
return status.Errorf(codes.Internal, err.Error())
|
||||||
@@ -455,6 +466,7 @@ func (s *Server) RemoveVolume(ctx context.Context, req *pb.RemoveVolumeRequest)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateServiceContainer creates a new container for the service with the given specifications.
|
// 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(
|
func (s *Server) CreateServiceContainer(
|
||||||
ctx context.Context, req *pb.CreateServiceContainerRequest,
|
ctx context.Context, req *pb.CreateServiceContainerRequest,
|
||||||
) (*pb.CreateContainerResponse, error) {
|
) (*pb.CreateContainerResponse, error) {
|
||||||
@@ -709,7 +721,7 @@ func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mo
|
|||||||
func (s *Server) InspectServiceContainer(
|
func (s *Server) InspectServiceContainer(
|
||||||
ctx context.Context, req *pb.InspectContainerRequest,
|
ctx context.Context, req *pb.InspectContainerRequest,
|
||||||
) (*pb.ServiceContainer, error) {
|
) (*pb.ServiceContainer, error) {
|
||||||
ctr, err := s.client.ContainerInspect(ctx, req.Id)
|
serviceCtr, err := s.service.InspectServiceContainer(ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if client.IsErrNotFound(err) {
|
||||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||||
@@ -717,19 +729,14 @@ func (s *Server) InspectServiceContainer(
|
|||||||
return nil, status.Errorf(codes.Internal, err.Error())
|
return nil, status.Errorf(codes.Internal, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
ctrBytes, err := json.Marshal(ctr)
|
ctrBytes, err := json.Marshal(serviceCtr.Container)
|
||||||
if err != nil {
|
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
|
specBytes, err := json.Marshal(serviceCtr.ServiceSpec)
|
||||||
err = s.db.QueryRowContext(ctx, `SELECT service_spec FROM containers WHERE id = $1`, ctr.ID).Scan(&specBytes)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
return nil, status.Errorf(codes.Internal, "marshal service spec: %v", err)
|
||||||
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 &pb.ServiceContainer{
|
return &pb.ServiceContainer{
|
||||||
@@ -761,54 +768,28 @@ func (s *Server) ListServiceContainers(
|
|||||||
return nil, status.Errorf(codes.InvalidArgument, "unmarshal filters: %v", err)
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal filters: %v", err)
|
||||||
}
|
}
|
||||||
opts.Filters = args
|
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 {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
containers := make([]*pb.ServiceContainer, 0, len(containerSummaries))
|
// Convert to protobuf format.
|
||||||
for _, cs := range containerSummaries {
|
pbContainers := make([]*pb.ServiceContainer, 0, len(containers))
|
||||||
if req.ServiceId != "" &&
|
for _, ctr := range containers {
|
||||||
cs.Labels[api.LabelServiceID] != req.ServiceId && cs.Labels[api.LabelServiceName] != req.ServiceId {
|
ctrBytes, err := json.Marshal(ctr.Container)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "marshal container: %v", err)
|
return nil, status.Errorf(codes.Internal, "marshal container: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var specBytes []byte
|
specBytes, err := json.Marshal(ctr.ServiceSpec)
|
||||||
err = s.db.QueryRowContext(ctx, `SELECT service_spec FROM containers WHERE id = $1`, ctr.ID).Scan(&specBytes)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
return nil, status.Errorf(codes.Internal, "marshal service spec: %v", err)
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
containers = append(containers, &pb.ServiceContainer{
|
pbContainers = append(pbContainers, &pb.ServiceContainer{
|
||||||
Container: ctrBytes,
|
Container: ctrBytes,
|
||||||
ServiceSpec: specBytes,
|
ServiceSpec: specBytes,
|
||||||
})
|
})
|
||||||
@@ -817,7 +798,7 @@ func (s *Server) ListServiceContainers(
|
|||||||
return &pb.ListServiceContainersResponse{
|
return &pb.ListServiceContainersResponse{
|
||||||
Messages: []*pb.MachineServiceContainers{
|
Messages: []*pb.MachineServiceContainers{
|
||||||
{
|
{
|
||||||
Containers: containers,
|
Containers: pbContainers,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service provides higher-level Docker operations that extends Docker API with Uncloud-specific data
|
||||||
|
// from the machine database.
|
||||||
|
type Service struct {
|
||||||
|
Client *client.Client
|
||||||
|
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{ContainerJSON: 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
|
||||||
|
}
|
||||||
+29
-20
@@ -30,7 +30,6 @@ import (
|
|||||||
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/psviderski/uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
|
||||||
"github.com/siderolabs/grpc-proxy/proxy"
|
"github.com/siderolabs/grpc-proxy/proxy"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
@@ -43,6 +42,9 @@ const (
|
|||||||
DefaultMachineSockPath = "/run/uncloud/machine.sock"
|
DefaultMachineSockPath = "/run/uncloud/machine.sock"
|
||||||
DefaultUncloudSockPath = "/run/uncloud/uncloud.sock"
|
DefaultUncloudSockPath = "/run/uncloud/uncloud.sock"
|
||||||
DefaultSockGroup = "uncloud"
|
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 {
|
type Config struct {
|
||||||
@@ -62,9 +64,9 @@ type Config struct {
|
|||||||
// DockerClient manages system and user containers using the local Docker daemon.
|
// DockerClient manages system and user containers using the local Docker daemon.
|
||||||
DockerClient *client.Client
|
DockerClient *client.Client
|
||||||
|
|
||||||
// CaddyConfigPath specifies where the machine generates the Caddy reverse proxy configuration file for routing
|
// CaddyConfigDir specifies the directory where the machine generates the Caddy reverse proxy configuration file
|
||||||
// external traffic to service containers across the internal network. Default is DataDir/caddy/caddy.json.
|
// for routing external traffic to service containers across the internal network. Default is DataDir/caddy.
|
||||||
CaddyConfigPath string
|
CaddyConfigDir string
|
||||||
// DNSUpstreams specifies the upstream DNS servers for the embedded internal DNS server.
|
// DNSUpstreams specifies the upstream DNS servers for the embedded internal DNS server.
|
||||||
DNSUpstreams []netip.AddrPort
|
DNSUpstreams []netip.AddrPort
|
||||||
}
|
}
|
||||||
@@ -129,8 +131,8 @@ func (c *Config) SetDefaults() (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.CaddyConfigPath == "" {
|
if cfg.CaddyConfigDir == "" {
|
||||||
cfg.CaddyConfigPath = filepath.Join(cfg.DataDir, "caddy", "caddy.json")
|
cfg.CaddyConfigDir = filepath.Join(cfg.DataDir, "caddy")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
@@ -162,7 +164,9 @@ type Machine struct {
|
|||||||
// store is the cluster store backed by a distributed Corrosion database.
|
// store is the cluster store backed by a distributed Corrosion database.
|
||||||
store *store.Store
|
store *store.Store
|
||||||
cluster *cluster.Cluster
|
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 is the gRPC server for the machine API listening on the local Unix socket.
|
||||||
localMachineServer *grpc.Server
|
localMachineServer *grpc.Server
|
||||||
|
|
||||||
@@ -222,17 +226,14 @@ func NewMachine(config *Config) (*Machine, error) {
|
|||||||
c := cluster.NewCluster(corroStore, corroAdmin)
|
c := cluster.NewCluster(corroStore, corroAdmin)
|
||||||
|
|
||||||
// Init dependencies for a gRPC Docker server that proxies requests to the local Docker daemon.
|
// 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)
|
dbFilePath := filepath.Join(config.DataDir, DBFileName)
|
||||||
db, err := NewDB(dbFilePath)
|
db, err := NewDB(dbFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("init machine database: %w", err)
|
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.
|
// Init a local gRPC proxy server that proxies requests to the local or remote machine API servers.
|
||||||
proxyDirector := apiproxy.NewDirector(config.MachineSockPath, constants.MachineAPIPort)
|
proxyDirector := apiproxy.NewDirector(config.MachineSockPath, constants.MachineAPIPort)
|
||||||
localProxyServer := grpc.NewServer(
|
localProxyServer := grpc.NewServer(
|
||||||
@@ -250,6 +251,7 @@ func NewMachine(config *Config) (*Machine, error) {
|
|||||||
networkReady: make(chan struct{}),
|
networkReady: make(chan struct{}),
|
||||||
store: corroStore,
|
store: corroStore,
|
||||||
cluster: c,
|
cluster: c,
|
||||||
|
dockerService: dockerService,
|
||||||
localProxyServer: localProxyServer,
|
localProxyServer: localProxyServer,
|
||||||
proxyDirector: proxyDirector,
|
proxyDirector: proxyDirector,
|
||||||
}
|
}
|
||||||
@@ -258,10 +260,11 @@ func NewMachine(config *Config) (*Machine, error) {
|
|||||||
internalDNSIP := func() netip.Addr {
|
internalDNSIP := func() netip.Addr {
|
||||||
return m.IP()
|
return m.IP()
|
||||||
}
|
}
|
||||||
m.docker = machinedocker.NewServer(dockerCli, db, internalDNSIP,
|
m.dockerServer = machinedocker.NewServer(dockerService, db, internalDNSIP,
|
||||||
machinedocker.WithNetworkReady(m.IsNetworkReady),
|
machinedocker.WithNetworkReady(m.IsNetworkReady),
|
||||||
machinedocker.WithWaitForNetworkReady(m.WaitForNetworkReady))
|
machinedocker.WithWaitForNetworkReady(m.WaitForNetworkReady))
|
||||||
m.localMachineServer = newGRPCServer(m, c, m.docker)
|
caddyServer := caddyconfig.NewServer(caddyconfig.NewService(config.CaddyConfigDir))
|
||||||
|
m.localMachineServer = newGRPCServer(m, c, m.dockerServer, caddyServer)
|
||||||
|
|
||||||
if m.Initialised() {
|
if m.Initialised() {
|
||||||
m.initialised <- struct{}{}
|
m.initialised <- struct{}{}
|
||||||
@@ -270,11 +273,12 @@ func NewMachine(config *Config) (*Machine, error) {
|
|||||||
return m, nil
|
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()
|
s := grpc.NewServer()
|
||||||
pb.RegisterMachineServer(s, m)
|
pb.RegisterMachineServer(s, m)
|
||||||
pb.RegisterClusterServer(s, c)
|
pb.RegisterClusterServer(s, c)
|
||||||
pb.RegisterDockerServer(s, d)
|
pb.RegisterDockerServer(s, d)
|
||||||
|
pb.RegisterCaddyServer(s, caddy)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +392,12 @@ func (m *Machine) Run(ctx context.Context) error {
|
|||||||
|
|
||||||
// Create a new caddyconfig controller for managing the Caddy reverse proxy configuration.
|
// 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.
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddyconfig controller: %w", err)
|
return fmt.Errorf("create caddyconfig controller: %w", err)
|
||||||
}
|
}
|
||||||
@@ -405,7 +414,7 @@ func (m *Machine) Run(ctx context.Context) error {
|
|||||||
m.store,
|
m.store,
|
||||||
proxyServer,
|
proxyServer,
|
||||||
m.config.CorrosionService,
|
m.config.CorrosionService,
|
||||||
m.config.DockerClient,
|
m.dockerService,
|
||||||
m.networkReady,
|
m.networkReady,
|
||||||
caddyconfigCtrl,
|
caddyconfigCtrl,
|
||||||
dnsServer,
|
dnsServer,
|
||||||
@@ -569,7 +578,7 @@ func (m *Machine) cleanup() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CheckPrerequisites verifies if the machine meets all necessary system requirements to participate in the cluster.
|
// 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.
|
// Check DNS port (UDP) availability.
|
||||||
if err := checkDNSPortAvailable(); err != nil {
|
if err := checkDNSPortAvailable(); err != nil {
|
||||||
return &pb.CheckPrerequisitesResponse{
|
return &pb.CheckPrerequisitesResponse{
|
||||||
@@ -835,7 +844,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
|
// 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.
|
// 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() {
|
if !m.Initialised() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -890,7 +899,7 @@ func (m *Machine) InspectService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctr := api.ServiceContainer{Container: records[0].Container}
|
ctr := records[0].Container
|
||||||
svc := &pb.Service{
|
svc := &pb.Service{
|
||||||
Id: ctr.ServiceID(),
|
Id: ctr.ServiceID(),
|
||||||
Name: ctr.ServiceName(),
|
Name: ctr.ServiceName(),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ContainerRecord struct {
|
type ContainerRecord struct {
|
||||||
Container api.Container
|
Container api.ServiceContainer
|
||||||
MachineID string
|
MachineID string
|
||||||
SyncStatus string
|
SyncStatus string
|
||||||
UpdatedAt time.Time
|
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.
|
// 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.
|
// 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
|
// Remove the environment variables from the container record before storing it in the database
|
||||||
// to avoid leaking secrets.
|
// to avoid leaking secrets.
|
||||||
ctr.Config.Env = nil
|
ctr.Config.Env = nil
|
||||||
|
ctr.ServiceSpec.Container.Env = nil
|
||||||
|
|
||||||
cJSON, err := json.Marshal(ctr)
|
cJSON, err := json.Marshal(ctr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshal container: %w", err)
|
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)
|
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 {
|
if err = json.Unmarshal([]byte(cJSON), &c); err != nil {
|
||||||
return nil, fmt.Errorf("unmarshal container: %w", err)
|
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
|
// 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.
|
// 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) {
|
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").
|
q := sq.Select("container", "machine_id", "sync_status", "updated_at").From("containers").
|
||||||
Where(sq.Eq{"sync_status": SyncStatusSynced})
|
Where(sq.Eq{"sync_status": SyncStatusSynced})
|
||||||
query, args, err := q.ToSql()
|
query, args, err := q.ToSql()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -24,6 +24,20 @@ const (
|
|||||||
|
|
||||||
type Container struct {
|
type Container struct {
|
||||||
types.ContainerJSON
|
types.ContainerJSON
|
||||||
|
// 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.
|
// Healthy determines if the container is running and healthy.
|
||||||
@@ -199,3 +213,24 @@ func (c *ServiceContainer) ConflictingServicePorts(ports []PortSpec) ([]PortSpec
|
|||||||
|
|
||||||
return conflicting, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
+39
-2
@@ -6,6 +6,7 @@ import (
|
|||||||
"maps"
|
"maps"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
@@ -42,6 +43,10 @@ func ValidateServiceID(id string) bool {
|
|||||||
// ServiceSpec defines the desired state of a service.
|
// ServiceSpec defines the desired state of a service.
|
||||||
// ATTENTION: after changing this struct, verify if deploy.EvalContainerSpecChange needs to be updated.
|
// ATTENTION: after changing this struct, verify if deploy.EvalContainerSpecChange needs to be updated.
|
||||||
type ServiceSpec struct {
|
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
|
Container ContainerSpec
|
||||||
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
|
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
|
||||||
Mode string
|
Mode string
|
||||||
@@ -49,6 +54,7 @@ type ServiceSpec struct {
|
|||||||
// Placement defines the placement constraints for the service.
|
// Placement defines the placement constraints for the service.
|
||||||
Placement Placement
|
Placement Placement
|
||||||
// Ports defines what service ports to publish to make the service accessible outside the cluster.
|
// Ports defines what service ports to publish to make the service accessible outside the cluster.
|
||||||
|
// Caddy and Ports cannot be specified simultaneously.
|
||||||
Ports []PortSpec
|
Ports []PortSpec
|
||||||
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
|
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
|
||||||
Replicas uint `json:",omitempty"`
|
Replicas uint `json:",omitempty"`
|
||||||
@@ -56,6 +62,14 @@ type ServiceSpec struct {
|
|||||||
Volumes []VolumeSpec
|
Volumes []VolumeSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func (s *ServiceSpec) Volume(name string) (VolumeSpec, bool) {
|
||||||
for _, v := range s.Volumes {
|
for _, v := range s.Volumes {
|
||||||
if v.Name == name {
|
if v.Name == name {
|
||||||
@@ -112,7 +126,8 @@ func (s *ServiceSpec) Validate() error {
|
|||||||
return fmt.Errorf("service name too long (max 63 characters): %q", s.Name)
|
return fmt.Errorf("service name too long (max 63 characters): %q", s.Name)
|
||||||
}
|
}
|
||||||
if !dnsLabelRegexp.MatchString(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 +140,23 @@ func (s *ServiceSpec) Validate() error {
|
|||||||
|
|
||||||
// TODO: validate there is no conflict between ports.
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
volumeNames := make(map[string]struct{})
|
volumeNames := make(map[string]struct{})
|
||||||
for _, v := range s.Volumes {
|
for _, v := range s.Volumes {
|
||||||
if err := v.Validate(); err != nil {
|
if err := v.Validate(); err != nil {
|
||||||
@@ -151,11 +183,16 @@ func (s *ServiceSpec) Validate() error {
|
|||||||
func (s *ServiceSpec) Clone() ServiceSpec {
|
func (s *ServiceSpec) Clone() ServiceSpec {
|
||||||
spec := *s
|
spec := *s
|
||||||
|
|
||||||
|
if s.Caddy != nil {
|
||||||
|
caddyCopy := *s.Caddy
|
||||||
|
spec.Caddy = &caddyCopy
|
||||||
|
}
|
||||||
|
spec.Container = s.Container.Clone()
|
||||||
|
|
||||||
if s.Ports != nil {
|
if s.Ports != nil {
|
||||||
spec.Ports = make([]PortSpec, len(s.Ports))
|
spec.Ports = make([]PortSpec, len(s.Ports))
|
||||||
copy(spec.Ports, s.Ports)
|
copy(spec.Ports, s.Ports)
|
||||||
}
|
}
|
||||||
spec.Container = s.Container.Clone()
|
|
||||||
|
|
||||||
if s.Volumes != nil {
|
if s.Volumes != nil {
|
||||||
spec.Volumes = make([]VolumeSpec, len(s.Volumes))
|
spec.Volumes = make([]VolumeSpec, len(s.Volumes))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-5
@@ -23,7 +23,7 @@ var caddyImageTagRegex = regexp.MustCompile(`^2\.\d+\.\d+$`)
|
|||||||
// NewCaddyDeployment creates a new deployment for a Caddy reverse proxy service.
|
// 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
|
// 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.
|
// 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 == "" {
|
if image == "" {
|
||||||
latest, err := LatestCaddyImage()
|
latest, err := LatestCaddyImage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,13 +35,24 @@ func (cli *Client) NewCaddyDeployment(image string, placement api.Placement) (*d
|
|||||||
|
|
||||||
spec := api.ServiceSpec{
|
spec := api.ServiceSpec{
|
||||||
Container: api.ContainerSpec{
|
Container: api.ContainerSpec{
|
||||||
Command: []string{"caddy", "run", "-c", "/config/caddy.json", "--watch"},
|
Command: []string{"caddy", "run", "-c", "/config/Caddyfile"},
|
||||||
Image: image,
|
Env: map[string]string{
|
||||||
|
"CADDY_ADMIN": "unix//run/caddy/admin.sock",
|
||||||
|
},
|
||||||
|
Image: image,
|
||||||
VolumeMounts: []api.VolumeMount{
|
VolumeMounts: []api.VolumeMount{
|
||||||
{
|
{
|
||||||
VolumeName: "config",
|
VolumeName: "data",
|
||||||
ContainerPath: "/config",
|
ContainerPath: "/config",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
VolumeName: "data",
|
||||||
|
ContainerPath: "/data",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
VolumeName: "run",
|
||||||
|
ContainerPath: "/run/caddy",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Mode: api.ServiceModeGlobal,
|
Mode: api.ServiceModeGlobal,
|
||||||
@@ -63,15 +74,29 @@ func (cli *Client) NewCaddyDeployment(image string, placement api.Placement) (*d
|
|||||||
},
|
},
|
||||||
Volumes: []api.VolumeSpec{
|
Volumes: []api.VolumeSpec{
|
||||||
{
|
{
|
||||||
Name: "config",
|
Name: "data",
|
||||||
Type: api.VolumeTypeBind,
|
Type: api.VolumeTypeBind,
|
||||||
BindOptions: &api.BindOptions{
|
BindOptions: &api.BindOptions{
|
||||||
HostPath: "/var/lib/uncloud/caddy",
|
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
|
return cli.NewDeployment(spec, nil), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Client struct {
|
|||||||
// Methods such as Reset or Inspect are ambiguous in the context of a machine+cluster client.
|
// Methods such as Reset or Inspect are ambiguous in the context of a machine+cluster client.
|
||||||
pb.MachineClient
|
pb.MachineClient
|
||||||
pb.ClusterClient
|
pb.ClusterClient
|
||||||
|
Caddy pb.CaddyClient
|
||||||
// Docker is a namespaced client for the Docker service to distinguish Uncloud-specific service container operations
|
// Docker is a namespaced client for the Docker service to distinguish Uncloud-specific service container operations
|
||||||
// from generic Docker operations.
|
// from generic Docker operations.
|
||||||
Docker *docker.Client
|
Docker *docker.Client
|
||||||
@@ -50,6 +51,7 @@ func New(ctx context.Context, connector Connector) (*Client, error) {
|
|||||||
|
|
||||||
c.MachineClient = pb.NewMachineClient(c.conn)
|
c.MachineClient = pb.NewMachineClient(c.conn)
|
||||||
c.ClusterClient = pb.NewClusterClient(c.conn)
|
c.ClusterClient = pb.NewClusterClient(c.conn)
|
||||||
|
c.Caddy = pb.NewCaddyClient(c.conn)
|
||||||
c.Docker = docker.NewClient(c.conn)
|
c.Docker = docker.NewClient(c.conn)
|
||||||
return c, nil
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,8 +24,9 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
|
|||||||
composecli.WithConfigFileEnv,
|
composecli.WithConfigFileEnv,
|
||||||
// If none was selected, get default Compose file names from current or parent folders.
|
// If none was selected, get default Compose file names from current or parent folders.
|
||||||
composecli.WithDefaultConfigPath,
|
composecli.WithDefaultConfigPath,
|
||||||
composecli.WithExtension(PortsExtensionKey, PortsSource{}),
|
composecli.WithExtension(CaddyExtensionKey, Caddy{}),
|
||||||
composecli.WithExtension(MachinesExtensionKey, MachinesSource{}),
|
composecli.WithExtension(MachinesExtensionKey, MachinesSource{}),
|
||||||
|
composecli.WithExtension(PortsExtensionKey, PortsSource{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
options, err := composecli.NewProjectOptions(
|
options, err := composecli.NewProjectOptions(
|
||||||
@@ -41,9 +42,17 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if project, err = transformServicesCaddyExtension(project); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if project, err = transformServicesPortsExtension(project); err != nil {
|
if project, err = transformServicesPortsExtension(project); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate extension combinations after all transformations.
|
||||||
|
if err = validateServicesExtensions(project); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return project, nil
|
return project, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
|||||||
Mode: api.ServiceModeReplicated,
|
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 {
|
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok {
|
||||||
spec.Ports = ports
|
spec.Ports = ports
|
||||||
}
|
}
|
||||||
@@ -242,3 +248,32 @@ func tmpfsVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig) api.Vol
|
|||||||
|
|
||||||
return spec
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -38,6 +39,7 @@ func loadProjectFromContent(t *testing.T, content string) (*types.Project, error
|
|||||||
if o.KnownExtensions == nil {
|
if o.KnownExtensions == nil {
|
||||||
o.KnownExtensions = map[string]any{}
|
o.KnownExtensions = map[string]any{}
|
||||||
}
|
}
|
||||||
|
o.KnownExtensions[CaddyExtensionKey] = Caddy{}
|
||||||
o.KnownExtensions[PortsExtensionKey] = PortsSource{}
|
o.KnownExtensions[PortsExtensionKey] = PortsSource{}
|
||||||
o.KnownExtensions[MachinesExtensionKey] = MachinesSource{}
|
o.KnownExtensions[MachinesExtensionKey] = MachinesSource{}
|
||||||
})
|
})
|
||||||
@@ -45,11 +47,19 @@ func loadProjectFromContent(t *testing.T, content string) (*types.Project, error
|
|||||||
return nil, err
|
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 {
|
if project, err = transformServicesPortsExtension(project); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate extension combinations after all transformations.
|
||||||
|
if err = validateServicesExtensions(project); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return project, nil
|
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,
|
Replicas: 3,
|
||||||
Volumes: []api.VolumeSpec{
|
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)
|
return strings.Compare(a.Name, b.Name)
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.True(t, cmp.Equal(spec, expectedSpec, cmpopts.EquateEmpty()),
|
cmpOpts := cmp.Options{cmpopts.EquateEmpty(), cmpopts.EquateComparable(netip.Addr{})}
|
||||||
cmp.Diff(spec, expectedSpec, cmpopts.EquateEmpty()))
|
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) {
|
func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
test.example.com {
|
||||||
|
reverse_proxy test:8000
|
||||||
|
}
|
||||||
@@ -31,6 +31,18 @@ services:
|
|||||||
target: /tmpfs
|
target: /tmpfs
|
||||||
tmpfs:
|
tmpfs:
|
||||||
size: 10485760
|
size: 10485760
|
||||||
|
x-ports:
|
||||||
|
- test.example.com:80/https
|
||||||
|
- 8000/http
|
||||||
|
- 5000:3000@host
|
||||||
|
|
||||||
|
test-caddy-config:
|
||||||
|
image: myapp:1.2.3
|
||||||
|
# x-ports and x-caddy are mutually exclusive.
|
||||||
|
x-caddy: |
|
||||||
|
test-caddy-config.example.com {
|
||||||
|
reverse_proxy {{ upstreams 80 }}
|
||||||
|
}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data1:
|
data1:
|
||||||
|
|||||||
+41
-1
@@ -4,12 +4,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
dockercommand "github.com/docker/cli/cli/command"
|
||||||
|
dockerconfig "github.com/docker/cli/cli/config"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/registry"
|
||||||
dockerclient "github.com/docker/docker/client"
|
dockerclient "github.com/docker/docker/client"
|
||||||
"github.com/docker/docker/pkg/jsonmessage"
|
"github.com/docker/docker/pkg/jsonmessage"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -89,7 +94,14 @@ func (cli *Client) pullImageWithProgress(ctx context.Context, image, machineName
|
|||||||
StatusText: "Pulling",
|
StatusText: "Pulling",
|
||||||
})
|
})
|
||||||
|
|
||||||
pullCh, err := cli.Docker.PullImage(ctx, image)
|
opts := docker.PullOptions{}
|
||||||
|
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
||||||
|
if encodedAuth, err := retrieveRegistryAuthFromDocker(image); err == nil && encodedAuth != "" {
|
||||||
|
// If RegistryAuth is empty, Uncloud daemon will try to retrieve the credentials from its own Docker config.
|
||||||
|
opts.RegistryAuth = encodedAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
pullCh, err := cli.Docker.PullImage(ctx, image, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
statusErr := status.Convert(err)
|
statusErr := status.Convert(err)
|
||||||
pw.Event(progress.Event{
|
pw.Event(progress.Event{
|
||||||
@@ -143,6 +155,34 @@ func (cli *Client) pullImageWithProgress(ctx context.Context, image, machineName
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// retrieveRegistryAuthFromDocker 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 retrieveRegistryAuthFromDocker(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
|
||||||
|
}
|
||||||
|
|
||||||
// toPullProgressEvent converts a JSON progress message from the Docker API to a progress event.
|
// toPullProgressEvent converts a JSON progress message from the Docker API to a progress event.
|
||||||
// It's based on toPullProgressEvent from Docker Compose.
|
// It's based on toPullProgressEvent from Docker Compose.
|
||||||
func toPullProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
|
func toPullProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if any mutable properties changed.
|
// Check if any mutable properties changed.
|
||||||
|
if !current.Caddy.Equals(new.Caddy) {
|
||||||
|
return ContainerNeedsRecreate
|
||||||
|
}
|
||||||
|
|
||||||
if !reflect.DeepEqual(current.Container.Resources, newResources) {
|
if !reflect.DeepEqual(current.Container.Resources, newResources) {
|
||||||
return ContainerNeedsUpdate
|
return ContainerNeedsUpdate
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
package deploy
|
package deploy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/docker/api/types"
|
|
||||||
"github.com/opencontainers/go-digest"
|
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServiceSpecResolver transforms user-provided service specs into deployment-ready form.
|
// ServiceSpecResolver transforms user-provided service specs into deployment-ready form.
|
||||||
type ServiceSpecResolver struct {
|
type ServiceSpecResolver struct {
|
||||||
ClusterDomain string
|
ClusterDomain string
|
||||||
ImageResolver *ImageDigestResolver
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve transforms a service spec into its fully resolved form ready for deployment.
|
// Resolve transforms a service spec into its fully resolved form ready for deployment.
|
||||||
@@ -33,7 +26,6 @@ func (r *ServiceSpecResolver) Resolve(spec api.ServiceSpec) (api.ServiceSpec, er
|
|||||||
r.applyDefaults,
|
r.applyDefaults,
|
||||||
r.resolveServiceName,
|
r.resolveServiceName,
|
||||||
r.expandIngressPorts,
|
r.expandIngressPorts,
|
||||||
r.resolveImageDigest,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, step := range steps {
|
for _, step := range steps {
|
||||||
@@ -111,21 +103,6 @@ func (r *ServiceSpecResolver) expandIngressPorts(spec *api.ServiceSpec) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ServiceSpecResolver) resolveImageDigest(spec *api.ServiceSpec) error {
|
|
||||||
if r.ImageResolver == nil {
|
|
||||||
// Skip digest resolution when no resolver is provided.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
image, err := r.ImageResolver.Resolve(spec.Container.Image, spec.Container.PullPolicy)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("resolve image digest: %w", err)
|
|
||||||
}
|
|
||||||
spec.Container.Image = image
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GenerateServiceName(image string) (string, error) {
|
func GenerateServiceName(image string) (string, error) {
|
||||||
img, err := reference.ParseDockerRef(image)
|
img, err := reference.ParseDockerRef(image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -144,181 +121,3 @@ func GenerateServiceName(image string) (string, error) {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%s-%s", imageName, suffix), nil
|
return fmt.Sprintf("%s-%s", imageName, suffix), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImageResolverClient interface {
|
|
||||||
api.ImageClient
|
|
||||||
api.MachineClient
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(lhf): as of April 2025, ImageDigestResolver is not used in the codebase and considered more harmful
|
|
||||||
// than helpful. It's safe to remove it.
|
|
||||||
type ImageDigestResolver struct {
|
|
||||||
Ctx context.Context
|
|
||||||
Client ImageResolverClient
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve resolves the image to the image with the digest according to the pull policy:
|
|
||||||
// - always: Fetch the latest digest for the image tag in the registry.
|
|
||||||
// - missing: Find the latest image matching the tag on any machine and use its digest, if it exists.
|
|
||||||
// When there is no matching image on any machine, it behaves like 'always'.
|
|
||||||
// - never: !Not implemented! Similar to 'missing' but when there is no matching image on any machine,
|
|
||||||
// it returns an error.
|
|
||||||
//
|
|
||||||
// If the image is already pinned to a digest, it is returned as is.
|
|
||||||
func (r *ImageDigestResolver) Resolve(image, policy string) (string, error) {
|
|
||||||
if r.Ctx == nil {
|
|
||||||
r.Ctx = context.Background()
|
|
||||||
}
|
|
||||||
|
|
||||||
ref, err := reference.ParseNormalizedNamed(image)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("parse image: %w", err)
|
|
||||||
}
|
|
||||||
if _, ok := ref.(reference.Canonical); ok {
|
|
||||||
// The image is already pinned to a digest.
|
|
||||||
return image, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch policy {
|
|
||||||
case api.PullPolicyAlways:
|
|
||||||
return r.resolveAlways(image)
|
|
||||||
case api.PullPolicyMissing:
|
|
||||||
return r.resolveMissing(image)
|
|
||||||
case api.PullPolicyNever:
|
|
||||||
return "", fmt.Errorf("pull policy '%s' is not supported yet", policy)
|
|
||||||
}
|
|
||||||
return image, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveAlways resolves the image to the image with the digest by querying the registry from all machines.
|
|
||||||
func (r *ImageDigestResolver) resolveAlways(image string) (string, error) {
|
|
||||||
// TODO: broadcast to a subset of machines in large clusters to avoid being rate-limited by the registry.
|
|
||||||
ctx, _, err := api.ProxyMachinesContext(r.Ctx, r.Client, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("create request context to broadcast to all machines: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteImages, err := r.Client.InspectRemoteImage(ctx, image)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("inspect image '%s' in registry from all machines: %w", image, err)
|
|
||||||
}
|
|
||||||
if len(remoteImages) == 0 {
|
|
||||||
return "", fmt.Errorf("inspect image '%s' in registry from all machines: unexpected empty response", image)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ri := range remoteImages {
|
|
||||||
if ri.Metadata != nil && ri.Metadata.Error != "" {
|
|
||||||
// Save the last error to return it if all machines fail to inspect the image.
|
|
||||||
err = fmt.Errorf("inspect image '%s' in registry on machine '%s': %s",
|
|
||||||
image, ri.Metadata.Machine, ri.Metadata.Error)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return reference.FamiliarString(ri.Image.Reference), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ImageDigestResolver) resolveMissing(image string) (string, error) {
|
|
||||||
ctx, _, err := api.ProxyMachinesContext(r.Ctx, r.Client, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("create request context to broadcast to all machines: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
machineImages, err := r.Client.InspectImage(ctx, image)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, api.ErrNotFound) {
|
|
||||||
// If the image is missing on all machines, the 'missing' policy is equivalent to 'always'.
|
|
||||||
return r.resolveAlways(image)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("inspect image '%s' on all machines: %w", image, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var availableImages []types.ImageInspect
|
|
||||||
for _, mi := range machineImages {
|
|
||||||
// Metadata can be nil if the request was proxied to only one machine.
|
|
||||||
if mi.Metadata != nil && mi.Metadata.Error != "" {
|
|
||||||
if codes.Code(mi.Metadata.Status.Code) != codes.NotFound {
|
|
||||||
fmt.Printf("WARNING: failed to inspect image '%s' on machine '%s': %s\n",
|
|
||||||
image, mi.Metadata.Machine, mi.Metadata.Error)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
availableImages = append(availableImages, mi.Image)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(availableImages) == 0 {
|
|
||||||
// If the image is missing on all machines, the 'missing' policy is equivalent to 'always'.
|
|
||||||
return r.resolveAlways(image)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the latest image with a RepoDigest.
|
|
||||||
var latestDigest digest.Digest
|
|
||||||
var latestCreated time.Time
|
|
||||||
for _, img := range availableImages {
|
|
||||||
if len(img.RepoDigests) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: handle multiple RepoDigests. This could happen for example if the same image was pulled twice using
|
|
||||||
// both its index (multi-arch) digest and manifest (platform-specific) digest:
|
|
||||||
// {
|
|
||||||
// "Id": "sha256:6fee7566e4273ee6078f08e167e36434b35f72152232a5e6f1446288817dabe5",
|
|
||||||
// "RepoTags": [
|
|
||||||
// "traefik/whoami:latest"
|
|
||||||
// ],
|
|
||||||
// "RepoDigests": [
|
|
||||||
// "traefik/whoami@sha256:200689790a0a0ea48ca45992e0450bc26ccab5307375b41c84dfc4f2475937ab",
|
|
||||||
// "traefik/whoami@sha256:4f90b33ddca9c4d4f06527070d6e503b16d71016edea036842be2a84e60c91cb"
|
|
||||||
// ],
|
|
||||||
// ...
|
|
||||||
// }
|
|
||||||
// Should the registry be queried to find out which digest to use?
|
|
||||||
repoDigest := img.RepoDigests[0]
|
|
||||||
created, err := time.Parse(time.RFC3339Nano, img.Created)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if created.After(latestCreated) {
|
|
||||||
ref, err := reference.ParseNormalizedNamed(repoDigest)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c, ok := ref.(reference.Canonical); ok {
|
|
||||||
latestDigest = c.Digest()
|
|
||||||
latestCreated = created
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if latestDigest != "" {
|
|
||||||
return imageWithDigest(image, latestDigest)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't pin the digest if no RepoDigests were found. This means the available images were not pulled from
|
|
||||||
// a registry but built locally or loaded from an archive. In this case, the available images (could be multiple
|
|
||||||
// for different platforms) should be copied to other machines to be able to run service containers on them.
|
|
||||||
return image, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// imageWithDigest adds a digest to an image string if it doesn't already contain one.
|
|
||||||
func imageWithDigest(image string, dig digest.Digest) (string, error) {
|
|
||||||
ref, err := reference.ParseNormalizedNamed(image)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("parse image: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := ref.(reference.Canonical); !ok {
|
|
||||||
// Preserves the original tag if present.
|
|
||||||
img, err := reference.WithDigest(ref, dig)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("add digest to image: %w", err)
|
|
||||||
}
|
|
||||||
return reference.FamiliarString(img), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return image, nil
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-1
@@ -166,7 +166,7 @@ RestartSec=2
|
|||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
ProtectSystem=full
|
ProtectSystem=full
|
||||||
ProtectControlGroups=true
|
ProtectControlGroups=true
|
||||||
ProtectHome=true
|
ProtectHome=read-only
|
||||||
ProtectKernelTunables=true
|
ProtectKernelTunables=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||||
|
|||||||
+153
-3
@@ -4,12 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/docker/docker/api/types/volume"
|
||||||
"github.com/docker/go-units"
|
"github.com/docker/go-units"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"github.com/psviderski/uncloud/internal/ucind"
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
@@ -271,7 +274,7 @@ func TestDeployment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
deployment, err := cli.NewCaddyDeployment("", api.Placement{})
|
deployment, err := cli.NewCaddyDeployment("", "", api.Placement{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = deployment.Run(ctx)
|
_, err = deployment.Run(ctx)
|
||||||
@@ -284,6 +287,12 @@ func TestDeployment(t *testing.T) {
|
|||||||
|
|
||||||
ctr := svc.Containers[0].Container
|
ctr := svc.Containers[0].Container
|
||||||
assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, ctr.Config.Image)
|
assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, ctr.Config.Image)
|
||||||
|
|
||||||
|
config, err := cli.Caddy.GetConfig(ctx, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Contains(t, config.Caddyfile, "# This file is autogenerated by Uncloud")
|
||||||
|
assert.Contains(t, config.Caddyfile, "handle /.uncloud-verify")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("caddy with machine placement", func(t *testing.T) {
|
t.Run("caddy with machine placement", func(t *testing.T) {
|
||||||
@@ -295,7 +304,7 @@ func TestDeployment(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Deploy to machine #0.
|
// Deploy to machine #0.
|
||||||
deployment, err := cli.NewCaddyDeployment("", api.Placement{
|
deployment, err := cli.NewCaddyDeployment("", "", api.Placement{
|
||||||
Machines: []string{c.Machines[0].Name},
|
Machines: []string{c.Machines[0].Name},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -313,7 +322,7 @@ func TestDeployment(t *testing.T) {
|
|||||||
// initialContainerID := svc.Containers[0].Container.ID
|
// initialContainerID := svc.Containers[0].Container.ID
|
||||||
|
|
||||||
// Deploy to all machines without a placement constraint.
|
// Deploy to all machines without a placement constraint.
|
||||||
deployment, err = cli.NewCaddyDeployment(image, api.Placement{})
|
deployment, err = cli.NewCaddyDeployment(image, "", api.Placement{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = deployment.Run(ctx)
|
_, err = deployment.Run(ctx)
|
||||||
@@ -332,6 +341,147 @@ func TestDeployment(t *testing.T) {
|
|||||||
// assert.True(t, containers.Contains(initialContainerID), "Expected initial container to remain")
|
// assert.True(t, containers.Contains(initialContainerID), "Expected initial container to remain")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("caddy and service with custom configs", func(t *testing.T) {
|
||||||
|
name := "test-custom-caddy-config"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := cli.RemoveService(ctx, name)
|
||||||
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
err = cli.RemoveService(ctx, client.CaddyServiceName)
|
||||||
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// First deploy a service with custom caddy config before caddy is deployed.
|
||||||
|
serviceCaddyfile := `test-custom-caddy-config.example.com {
|
||||||
|
reverse_proxy {{upstreams}} {
|
||||||
|
import common_proxy
|
||||||
|
}
|
||||||
|
log
|
||||||
|
}`
|
||||||
|
spec := api.ServiceSpec{
|
||||||
|
Name: name,
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: "portainer/pause:latest",
|
||||||
|
},
|
||||||
|
Caddy: &api.CaddySpec{
|
||||||
|
Config: serviceCaddyfile,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
deployment := cli.NewDeployment(spec, nil)
|
||||||
|
_, err := deployment.Run(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svc, err := cli.InspectService(ctx, name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertServiceMatchesSpec(t, svc, spec)
|
||||||
|
|
||||||
|
// Check that the generated Caddyfile contains a comment that user-define configs were skipped.
|
||||||
|
var config *pb.GetCaddyConfigResponse
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
config, err = cli.Caddy.GetConfig(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(config.Caddyfile, "# NOTE: User-defined configs for services were skipped")
|
||||||
|
}, 5*time.Second, 100*time.Millisecond)
|
||||||
|
|
||||||
|
assert.NotContains(t, config.Caddyfile, "test-custom-caddy-config.example.com {")
|
||||||
|
|
||||||
|
// Now deploy caddy with custom config.
|
||||||
|
caddyCaddyfile := `{
|
||||||
|
debug
|
||||||
|
}
|
||||||
|
|
||||||
|
myapp.example.com {
|
||||||
|
reverse_proxy 1.2.3.4:8000
|
||||||
|
}`
|
||||||
|
caddyDeployment, err := cli.NewCaddyDeployment("", caddyCaddyfile, api.Placement{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = caddyDeployment.Run(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
caddySvc, err := cli.InspectService(ctx, client.CaddyServiceName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertServiceMatchesSpec(t, caddySvc, caddyDeployment.Spec)
|
||||||
|
|
||||||
|
// Wait for the Caddyfile to be regenerated with both custom configs.
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
config, err = cli.Caddy.GetConfig(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Both configs should be present.
|
||||||
|
return strings.Contains(config.Caddyfile, caddyCaddyfile) &&
|
||||||
|
strings.Contains(config.Caddyfile, "test-custom-caddy-config.example.com")
|
||||||
|
}, 5*time.Second, 100*time.Millisecond,
|
||||||
|
"Expected both custom configs to be included in the Caddyfile")
|
||||||
|
|
||||||
|
assert.Contains(t, config.Caddyfile, "# This file is autogenerated by Uncloud")
|
||||||
|
assert.Contains(t, config.Caddyfile, "handle /.uncloud-verify")
|
||||||
|
assert.Contains(t, config.Caddyfile, caddyCaddyfile,
|
||||||
|
"Expected user-defined global Caddy config to be included in the Caddyfile")
|
||||||
|
|
||||||
|
ctrIP := svc.Containers[0].Container.UncloudNetworkIP().String()
|
||||||
|
renderedServiceCaddyfile := `test-custom-caddy-config.example.com {
|
||||||
|
reverse_proxy ` + ctrIP + ` {
|
||||||
|
import common_proxy
|
||||||
|
}
|
||||||
|
log
|
||||||
|
}`
|
||||||
|
assert.Contains(t, config.Caddyfile, renderedServiceCaddyfile,
|
||||||
|
"Expected rendered user-defined Caddy config for test service to be included in the Caddyfile")
|
||||||
|
|
||||||
|
assert.NotContains(t, config.Caddyfile, "invalid user-defined configs",
|
||||||
|
"Should not have validation failure comments after caddy is deployed")
|
||||||
|
|
||||||
|
// Store the current valid config for later comparison.
|
||||||
|
validConfig := config.Caddyfile
|
||||||
|
|
||||||
|
// Now deploy a service with invalid Caddyfile that references missing cert files and check it isn't included.
|
||||||
|
invalidServiceName := "test-invalid-caddy-config"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := cli.RemoveService(ctx, invalidServiceName)
|
||||||
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
invalidCaddyfile := `test-invalid.example.com {
|
||||||
|
tls cert.pem key.pem
|
||||||
|
}`
|
||||||
|
invalidSpec := api.ServiceSpec{
|
||||||
|
Name: invalidServiceName,
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: "portainer/pause:latest",
|
||||||
|
},
|
||||||
|
Caddy: &api.CaddySpec{
|
||||||
|
Config: invalidCaddyfile,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidDeployment := cli.NewDeployment(invalidSpec, nil)
|
||||||
|
_, err = invalidDeployment.Run(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
invalidSvc, err := cli.InspectService(ctx, invalidServiceName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertServiceMatchesSpec(t, invalidSvc, invalidSpec)
|
||||||
|
|
||||||
|
// Wait a bit for any config updates to potentially happen.
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// Check that the Caddy config hasn't changed.
|
||||||
|
newConfig, err := cli.Caddy.GetConfig(ctx, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, validConfig, newConfig.Caddyfile,
|
||||||
|
"Caddy config should not change when an invalid user-defined Caddy config is deployed")
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("replicated", func(t *testing.T) {
|
t.Run("replicated", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ Docker containers running on different machines get **unique IP addresses** from
|
|||||||
The design and implementation were highly inspired by
|
The design and implementation were highly inspired by
|
||||||
Talos [KubeSpan](https://www.talos.dev/v1.10/talos-guides/network/kubespan/).
|
Talos [KubeSpan](https://www.talos.dev/v1.10/talos-guides/network/kubespan/).
|
||||||
|
|
||||||
### Managed DNS service
|
### Managed DNS service (optional)
|
||||||
|
|
||||||
Uncloud can provide **managed DNS records** like `<service-name>.<cluster-id>.cluster.uncloud.run` for your public
|
Uncloud can provide **managed DNS records** like `<service-name>.<cluster-id>.cluster.uncloud.run` for your public
|
||||||
services through free [Uncloud DNS](https://github.com/psviderski/uncloud-dns) service. You can deploy a service and
|
services through free [Uncloud DNS](https://github.com/psviderski/uncloud-dns) service. You can deploy a service and
|
||||||
@@ -99,7 +99,7 @@ containers by their service names, `curl` service endpoints, or analyse traffic
|
|||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
Install Uncloud CLI and deploy your first app in minutes:
|
Install Uncloud CLI and deploy your first app:
|
||||||
|
|
||||||
* [Install Uncloud CLI](./2-getting-started/1-install-cli.md)
|
* [Install Uncloud CLI](./2-getting-started/1-install-cli.md)
|
||||||
* [Deploy demo app](./2-getting-started/2-deploy-demo-app.md)
|
* [Deploy demo app](./2-getting-started/2-deploy-demo-app.md)
|
||||||
|
|||||||
@@ -318,8 +318,126 @@ Give it a moment for Caddy to obtain a TLS certificate, then visit https://excal
|
|||||||
|
|
||||||
## Clean up
|
## Clean up
|
||||||
|
|
||||||
TBD
|
When you're done experimenting, you can remove the `excalidraw` service or uninstall Uncloud completely.
|
||||||
|
|
||||||
## Next steps
|
### Remove the service
|
||||||
|
|
||||||
TBD
|
Remove the `excalidraw` service while keeping your Uncloud machine running for future deployments:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uc rm excalidraw
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uninstall Uncloud
|
||||||
|
|
||||||
|
If you want to completely uninstall Uncloud from your server and clean up everything it created, SSH into your server
|
||||||
|
and run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
sudo uncloud-uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
This command will:
|
||||||
|
|
||||||
|
- Remove all Uncloud-managed containers (including Caddy)
|
||||||
|
- Remove the Uncloud-managed Docker and WireGuard networks
|
||||||
|
- Uninstall the Uncloud daemon from the server
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>💡 Expand to see example output</summary>
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️This script will uninstall Uncloud and remove ALL Uncloud managed containers on this machine.
|
||||||
|
The following actions will be performed:
|
||||||
|
- Remove Uncloud systemd services
|
||||||
|
- Remove Uncloud binaries and data
|
||||||
|
- Remove Uncloud user and group
|
||||||
|
- Remove all Docker containers managed by Uncloud
|
||||||
|
- Remove Uncloud Docker network
|
||||||
|
- Remove Uncloud WireGuard interface
|
||||||
|
Do you want to proceed with uninstallation? [y/N] y
|
||||||
|
⏳ Stopping systemd services...
|
||||||
|
Removed "/etc/systemd/system/multi-user.target.wants/uncloud.service".
|
||||||
|
The unit files have no installation config (WantedBy=, RequiredBy=, UpheldBy=,
|
||||||
|
Also=, or Alias= settings in the [Install] section, and DefaultInstance= for
|
||||||
|
template units). This means they are not meant to be enabled or disabled using systemctl.
|
||||||
|
|
||||||
|
Possible reasons for having these kinds of units are:
|
||||||
|
• A unit may be statically enabled by being symlinked from another unit's
|
||||||
|
.wants/, .requires/, or .upholds/ directory.
|
||||||
|
• A unit's purpose may be to act as a helper for some other unit which has
|
||||||
|
a requirement dependency on it.
|
||||||
|
• A unit may be started when needed via activation (socket, path, timer,
|
||||||
|
D-Bus, udev, scripted systemctl call, ...).
|
||||||
|
• In case of template units, the unit is meant to be enabled with some
|
||||||
|
instance name specified.
|
||||||
|
✓ Systemd services stopped.
|
||||||
|
⏳ Removing systemd service files...
|
||||||
|
removed '/etc/systemd/system/uncloud.service'
|
||||||
|
removed '/etc/systemd/system/uncloud-corrosion.service'
|
||||||
|
✓ Systemd service files removed.
|
||||||
|
⏳ Removing binaries...
|
||||||
|
removed '/usr/local/bin/uncloudd'
|
||||||
|
removed '/usr/local/bin/uncloud-corrosion'
|
||||||
|
✓ Binaries removed.
|
||||||
|
⏳ Removing data and run directories...
|
||||||
|
removed '/var/lib/uncloud/machine.db-wal'
|
||||||
|
removed '/var/lib/uncloud/caddy/caddy/autosave.json'
|
||||||
|
removed directory '/var/lib/uncloud/caddy/caddy'
|
||||||
|
removed '/var/lib/uncloud/caddy/caddy.json'
|
||||||
|
removed directory '/var/lib/uncloud/caddy'
|
||||||
|
removed '/var/lib/uncloud/machine.json'
|
||||||
|
removed '/var/lib/uncloud/machine.db-shm'
|
||||||
|
removed '/var/lib/uncloud/corrosion/admin.sock'
|
||||||
|
removed '/var/lib/uncloud/corrosion/config.toml'
|
||||||
|
removed '/var/lib/uncloud/corrosion/subscriptions/b4e825113f1143e5b27715b62193a9f8/sub.sqlite-wal'
|
||||||
|
removed '/var/lib/uncloud/corrosion/subscriptions/b4e825113f1143e5b27715b62193a9f8/sub.sqlite-shm'
|
||||||
|
removed '/var/lib/uncloud/corrosion/subscriptions/b4e825113f1143e5b27715b62193a9f8/sub.sqlite'
|
||||||
|
removed directory '/var/lib/uncloud/corrosion/subscriptions/b4e825113f1143e5b27715b62193a9f8'
|
||||||
|
removed '/var/lib/uncloud/corrosion/subscriptions/5e04cbb20a2743c382cfbd4949922351/sub.sqlite'
|
||||||
|
removed directory '/var/lib/uncloud/corrosion/subscriptions/5e04cbb20a2743c382cfbd4949922351'
|
||||||
|
removed directory '/var/lib/uncloud/corrosion/subscriptions'
|
||||||
|
removed '/var/lib/uncloud/corrosion/schema.sql'
|
||||||
|
removed '/var/lib/uncloud/corrosion/store.db'
|
||||||
|
removed directory '/var/lib/uncloud/corrosion'
|
||||||
|
removed '/var/lib/uncloud/machine.db'
|
||||||
|
removed directory '/var/lib/uncloud'
|
||||||
|
removed directory '/run/uncloud'
|
||||||
|
✓ Data and run directories removed.
|
||||||
|
⏳ Removing Linux user and group...
|
||||||
|
✓ Linux user 'uncloud' removed.
|
||||||
|
Linux group 'uncloud' does not exist or was already removed.
|
||||||
|
⏳ Looking for Docker containers and network created by Uncloud...
|
||||||
|
Found 4 Uncloud managed containers.
|
||||||
|
⏳ Stopping Uncloud managed containers...
|
||||||
|
20613f6046d0
|
||||||
|
1f1a65b78e93
|
||||||
|
4300bde4a2b0
|
||||||
|
053fdd57ec56
|
||||||
|
⏳ Removing Uncloud managed containers...
|
||||||
|
20613f6046d0
|
||||||
|
1f1a65b78e93
|
||||||
|
4300bde4a2b0
|
||||||
|
053fdd57ec56
|
||||||
|
✓ Uncloud managed containers stopped and removed.
|
||||||
|
⏳ Removing Docker network uncloud...
|
||||||
|
uncloud
|
||||||
|
✓ Docker network uncloud removed.
|
||||||
|
⏳ Removing WireGuard interface uncloud...
|
||||||
|
✓ WireGuard interface uncloud removed.
|
||||||
|
⏳ Removing uninstall script...
|
||||||
|
removed '/usr/local/bin/uncloud-uninstall'
|
||||||
|
✓ Uninstall script removed.
|
||||||
|
|
||||||
|
✅ Uncloud has been uninstalled successfully!
|
||||||
|
Note: Docker installation was preserved. If you want to completely remove Docker as well, follow https://docs.docker.com/engine/install/ubuntu/#uninstall-docker-engine
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Further reading
|
||||||
|
|
||||||
|
- **[Add more machines](../9-cli-reference/uc_machine_add.md)**: Scale horizontally by creating a cluster of machines
|
||||||
|
- **[Ingress & HTTP](../3-concepts/1-ingress/1-overview.md)**: Learn how Uncloud handles incoming traffic and how to
|
||||||
|
expose your services to the internet
|
||||||
|
- **[CLI reference](../9-cli-reference/uc.md)**: Explore all available commands and options
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
sidebar_label: Overview
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ingress & HTTPS
|
||||||
|
|
||||||
|
Uncloud uses [Caddy](https://caddyserver.com/) as its reverse proxy to handle incoming traffic, provide automatic HTTPS
|
||||||
|
with [Let's Encrypt](https://letsencrypt.org/), and route requests to your services.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Caddy runs as a global service `caddy` on every machine in your cluster, listening on the host ports 80 (HTTP) and 443
|
||||||
|
(HTTPS).
|
||||||
|
|
||||||
|
It's deployed during cluster initialisation (`uc machine init`) unless you use the `--no-caddy` flag.
|
||||||
|
See [Managing Caddy](3-managing-caddy.md) for deployment and customisation instructions.
|
||||||
|
|
||||||
|
When you [publish a service port](2-publishing-services.md), Uncloud automatically configures Caddy to:
|
||||||
|
|
||||||
|
1. Listen for requests on the specified hostname (domain name).
|
||||||
|
2. Automatically obtain and renew a TLS certificate from Let's Encrypt for HTTPS.
|
||||||
|
3. Route traffic to the **healthy** service container(s).
|
||||||
|
4. Load balance across healthy replicas if there are multiple.
|
||||||
|
|
||||||
|
For advanced use cases, Uncloud allows to customise the Caddy config using the `x-caddy` extension in Compose files.
|
||||||
|
See [Custom Caddy configuration](2-publishing-services.md#custom-caddy-configuration) for details.
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
# Publishing services
|
||||||
|
|
||||||
|
Publishing service ports makes your services available outside the cluster. This means your services can be accessed
|
||||||
|
from the internet or local network, depending on your setup.
|
||||||
|
|
||||||
|
You can publish service ports in three ways:
|
||||||
|
|
||||||
|
- Using the `-p/--publish` flag with `uc run`.
|
||||||
|
- Using the `x-ports` extension in a Compose file with `uc deploy`.
|
||||||
|
- Using the `--caddyfile` flag with `uc run` or `x-caddy` extension in a Compose file for custom Caddy configuration.
|
||||||
|
|
||||||
|
For example, run a service with container port 8000 exposed as https://app.example.com via Caddy reverse proxy:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uc run -p app.example.com:8000/https app:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
[+] Running service app-mwng (replicated mode) 1/1
|
||||||
|
✔ Container app-mwng-6lub on machine-fnr9 Started
|
||||||
|
|
||||||
|
app-mwng endpoints:
|
||||||
|
• https://app.example.com → :8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Create an `A` record in your DNS provider (Cloudflare, Namecheap, etc.) pointing `app.example.com` to the public IP
|
||||||
|
address or your machine(s). Once DNS is propagated and Caddy obtains a TLS certificate, you can access your service
|
||||||
|
securely over HTTPS.
|
||||||
|
|
||||||
|
## Ingress vs host mode
|
||||||
|
|
||||||
|
**HTTP/HTTPS** ports are exposed via Caddy using the following format for the `-p/--publish` flag and `x-ports`
|
||||||
|
extension:
|
||||||
|
|
||||||
|
```
|
||||||
|
[hostname:]container_port[/protocol]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `hostname` (optional): The domain name to use for accessing the service. If omitted and a cluster domain is reserved,
|
||||||
|
`<service-name>.<cluster-domain>` is used.
|
||||||
|
- `container_port`: The port number within the container that's listening for traffic.
|
||||||
|
- `protocol` (optional): `http` or `https` (default: `https`)
|
||||||
|
|
||||||
|
**TCP/UDP** ports can only be exposed in host mode, which binds the container port directly to the host machine's
|
||||||
|
network interface(s). This is useful for non-HTTP services that need direct port access (bypasses Caddy):
|
||||||
|
|
||||||
|
```
|
||||||
|
[host_ip:]host_port:container_port[/protocol]@host
|
||||||
|
```
|
||||||
|
|
||||||
|
- `host_ip` (optional): The IP address on the host to bind to. If omitted, binds to all interfaces.
|
||||||
|
- `host_port`: The port number on the host to bind to.
|
||||||
|
- `container_port`: The port number within the container that's listening for traffic.
|
||||||
|
- `protocol` (optional): `tcp` or `udp` (default: `tcp`)
|
||||||
|
|
||||||
|
| Port value | Description |
|
||||||
|
|------------------------------|--------------------------------------------------------------------------------------|
|
||||||
|
| `8000/http` | Publish port 8000 as HTTP via Caddy using hostname `<service-name>.<cluster-domain>` |
|
||||||
|
| `app.example.com:8080/https` | Publish port 8080 as HTTPS via Caddy using hostname `app.example.com` |
|
||||||
|
| `127.0.0.1:5432:5432@host` | Bind TCP port 5432 to host port 5432 on loopback interface only |
|
||||||
|
| `53:5353/udp@host` | Bind UDP port 5353 to host port 53 on all network interfaces |
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
|
||||||
|
Do not publish internal-only services like databases unless absolutely necessary. You only need to publish ports for
|
||||||
|
services that should be accessible from outside the cluster. Services within the cluster can communicate with each other
|
||||||
|
by their DNS names `service-name` or `service-name.internal` without publishing ports.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Using Compose
|
||||||
|
|
||||||
|
Use the `x-ports` extension in a Compose file to publish service ports:
|
||||||
|
|
||||||
|
```yaml title="compose.yaml"
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: app:latest
|
||||||
|
x-ports:
|
||||||
|
- example.com:8000/https
|
||||||
|
- www.example.com:8000/https # The same port can be published with multiple hostnames
|
||||||
|
- api.domain.tld:9000/https # Another port can be published with a different hostname
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Caddy configuration
|
||||||
|
|
||||||
|
For advanced routing and behavior, use `x-caddy` instead of `x-ports`. It allows you to provide custom Caddy
|
||||||
|
configuration for a service in [Caddyfile](https://caddyserver.com/docs/caddyfile) format.
|
||||||
|
|
||||||
|
```yaml title="compose.yaml"
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: app:latest
|
||||||
|
x-caddy: |
|
||||||
|
www.example.com {
|
||||||
|
redir https://example.com{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
example.com {
|
||||||
|
basic_auth /admin/* {
|
||||||
|
admin $2a$14$... # bcrypt hash
|
||||||
|
}
|
||||||
|
|
||||||
|
header /static/* Cache-Control max-age=604800
|
||||||
|
reverse_proxy {{upstreams 8000}} {
|
||||||
|
import common_proxy
|
||||||
|
}
|
||||||
|
log
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can inline the Caddyfile or load it from a file: `x-caddy: ./Caddyfile`. When using a file, the path is relative to
|
||||||
|
the Compose file location. See the [Caddy documentation](https://caddyserver.com/docs/caddyfile) for syntax and
|
||||||
|
features.
|
||||||
|
|
||||||
|
:::info note
|
||||||
|
|
||||||
|
You cannot use `x-caddy` with `http` or `https` ports in `x-ports`. `tcp` and `udp` ports in host mode are allowed
|
||||||
|
though.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
Use it when you need:
|
||||||
|
|
||||||
|
- Custom routing rules (different paths, redirects, rewrites, multiple services on one domain).
|
||||||
|
- Custom headers, authentication, or caching.
|
||||||
|
- Custom load balancing strategies and options.
|
||||||
|
- Request and response manipulation.
|
||||||
|
- Advanced TLS settings.
|
||||||
|
- Other Caddy features and plugins.
|
||||||
|
|
||||||
|
See [Deploying or updating Caddy](3-managing-caddy.md#deploying-or-updating-caddy) for details on deploying Caddy with a
|
||||||
|
custom global configuration.
|
||||||
|
|
||||||
|
### Templates
|
||||||
|
|
||||||
|
`x-caddy` configs are processed as [Go templates](https://pkg.go.dev/text/template), allowing you to use dynamic values.
|
||||||
|
The following functions and variables are available:
|
||||||
|
|
||||||
|
| Template | Description |
|
||||||
|
|---------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||||
|
| `{{upstreams [service-name] [port]}}` | A space-separated list of healthy container IPs for the current or specified service and port |
|
||||||
|
| `{{.Name}}` | The name of the service the config belongs to |
|
||||||
|
| `{{.Upstreams}}` | A map of all service names to their healthy container IPs |
|
||||||
|
|
||||||
|
The templates are automatically re-rendered and Caddy is reloaded when service containers start/stop or health status
|
||||||
|
changes.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
1. Current service upstreams, default port:
|
||||||
|
```caddyfile
|
||||||
|
reverse_proxy {{upstreams}}
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
reverse_proxy 10.210.1.3 10.210.2.5
|
||||||
|
```
|
||||||
|
2. Current service upstreams, port 8000:
|
||||||
|
```caddyfile
|
||||||
|
reverse_proxy {{upstreams 8000}}
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
reverse_proxy 10.210.1.3:8000 10.210.2.5:8000
|
||||||
|
```
|
||||||
|
3. Current service upstreams with `https` scheme:
|
||||||
|
```caddyfile
|
||||||
|
reverse_proxy {{- range $ip := index .Upstreams .Name}} https://{{$ip}}{{end}}
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
reverse_proxy https://10.210.1.3 https://10.210.2.5
|
||||||
|
```
|
||||||
|
4. `api` service upstreams, port 9000:
|
||||||
|
```caddyfile
|
||||||
|
handle_path /api/* {
|
||||||
|
reverse_proxy {{upstreams "api" 9000}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
handle_path /api/* {
|
||||||
|
reverse_proxy 10.210.2.2:9000 10.210.1.7:9000 10.210.2.3:9000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verifying Caddy config
|
||||||
|
|
||||||
|
Use `uc caddy config` to view the complete generated Caddyfile served by the `caddy` service. This is useful for
|
||||||
|
debugging and verifying your `x-caddy` configs.
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# User-defined global config from service 'caddy'.
|
||||||
|
*.example.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
}
|
||||||
|
respond "No host matched" 404
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint to verify Caddy reachability on this machine.
|
||||||
|
http:// {
|
||||||
|
handle /.uncloud-verify {
|
||||||
|
respond "a369b9388812f9557feef6a0f5b46f2e" 200
|
||||||
|
}
|
||||||
|
log
|
||||||
|
}
|
||||||
|
|
||||||
|
(common_proxy) {
|
||||||
|
# Retry failed requests up to lb_retries times against other available upstreams.
|
||||||
|
lb_retries 3
|
||||||
|
# Upstreams are marked unhealthy for fail_duration after a failed request (passive health checking).
|
||||||
|
fail_duration 30s
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sites generated from service ports.
|
||||||
|
|
||||||
|
https://app.example.com {
|
||||||
|
reverse_proxy 10.210.1.3:8000 10.210.2.5:8000 {
|
||||||
|
import common_proxy
|
||||||
|
}
|
||||||
|
log
|
||||||
|
}
|
||||||
|
|
||||||
|
https://api.example.com {
|
||||||
|
reverse_proxy 10.210.2.2:9000 10.210.1.7:9000 10.210.2.3:9000 {
|
||||||
|
import common_proxy
|
||||||
|
}
|
||||||
|
log
|
||||||
|
}
|
||||||
|
|
||||||
|
# User-defined config for service 'web'.
|
||||||
|
www.example.com {
|
||||||
|
redir https://example.com{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
example.com {
|
||||||
|
reverse_proxy 10.210.0.3:8000 {
|
||||||
|
import common_proxy
|
||||||
|
}
|
||||||
|
log
|
||||||
|
}
|
||||||
|
|
||||||
|
# Skipped invalid user-defined configs:
|
||||||
|
# - service 'duplicate-hostname': validation failed: adapting config using caddyfile adapter: ambiguous site definition: example.com
|
||||||
|
# - service 'invalid': validation failed: adapting config using caddyfile adapter: Caddyfile:61: unrecognized directive: invalid_directive
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated config combines:
|
||||||
|
|
||||||
|
- Global Caddy configuration (`x-caddy` from the `caddy` service).
|
||||||
|
See [Deploying or updating Caddy](3-managing-caddy.md#deploying-or-updating-caddy) for details.
|
||||||
|
- Auto-generated configs from published service ports (`x-ports`).
|
||||||
|
- Custom Caddy configs from services (`x-caddy`).
|
||||||
|
- Skipped invalid configs with error messages as comments.
|
||||||
|
|
||||||
|
:::warning important
|
||||||
|
|
||||||
|
Custom Caddy configs from different services must not conflict (all services must use unique hostnames).
|
||||||
|
See [Multiple services on one domain](#multiple-services-on-one-domain) for an example of how to share one hostname
|
||||||
|
between multiple services.
|
||||||
|
|
||||||
|
Conflicting or invalid configs are detected using [caddy adapt](https://caddyserver.com/docs/command-line#caddy-adapt)
|
||||||
|
command and skipped. However, some errors could still break the entire config so Caddy will fail to load it. Check the
|
||||||
|
`caddy` service logs to troubleshoot.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Common use cases
|
||||||
|
|
||||||
|
#### Redirects
|
||||||
|
|
||||||
|
Publish a service on `example.com` and redirect requests from `www.example.com` to `example.com`:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem value="compose.yaml">
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: app:latest
|
||||||
|
x-caddy: ./Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem value="Caddyfile">
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
www.example.com {
|
||||||
|
redir https://example.com{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
example.com {
|
||||||
|
reverse_proxy {{upstreams 8000}} {
|
||||||
|
import common_proxy
|
||||||
|
}
|
||||||
|
log
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
#### Multiple services on one domain
|
||||||
|
|
||||||
|
You can publish multiple services on the same hostname by using different paths for each service. For example, route
|
||||||
|
`/` to the web service and `/api` to the API service:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem value="compose.yaml">
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: api:latest
|
||||||
|
web:
|
||||||
|
image: web:latest
|
||||||
|
# Make sure only one service defines a Caddy config for the hostname.
|
||||||
|
x-caddy: ./Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem value="Caddyfile">
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
example.com {
|
||||||
|
handle_path /api/* {
|
||||||
|
reverse_proxy {{upstreams "api" 9000}} {
|
||||||
|
import common_proxy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy {{upstreams}} {
|
||||||
|
import common_proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
log
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# Managing Caddy
|
||||||
|
|
||||||
|
Caddy is automatically deployed as a global service `caddy` when you initialise a cluster with `uc machine init`. It
|
||||||
|
runs on every machine to handle incoming HTTP/HTTPS traffic and route it to your services.
|
||||||
|
|
||||||
|
## Checking status
|
||||||
|
|
||||||
|
View the `caddy` service status and which machines it's running on:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uc inspect caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
ID: b5b269d5dc5ed4fdae6542894f94de82
|
||||||
|
Name: caddy
|
||||||
|
Mode: global
|
||||||
|
|
||||||
|
CONTAINER ID IMAGE CREATED STATUS MACHINE
|
||||||
|
fb8f390e634d caddy:2.10.0 3 weeks ago Up 3 weeks prod-ap1
|
||||||
|
0182f5d7bd9f caddy:2.10.0 3 months ago Up 3 weeks prod-us1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploying or updating Caddy
|
||||||
|
|
||||||
|
### Using CLI
|
||||||
|
|
||||||
|
Update to the latest stable version using the [caddy](https://hub.docker.com/_/caddy) image from Docker Hub:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uc caddy deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy a specific version or custom image:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uc caddy deploy --image caddybuilds/caddy-cloudflare:2.10.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy with custom global configuration:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uc caddy deploy --caddyfile global.Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
Example global configuration:
|
||||||
|
|
||||||
|
```caddyfile title=global.Caddyfile
|
||||||
|
# Global options.
|
||||||
|
{
|
||||||
|
debug
|
||||||
|
}
|
||||||
|
|
||||||
|
# A snippet that can be reused in custom Caddy configs for services (x-caddy).
|
||||||
|
(my_snippet) {
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
# Expose an internal service that is not managed by Uncloud.
|
||||||
|
internal.example.com {
|
||||||
|
reverse_proxy 192.168.1.100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Compose
|
||||||
|
|
||||||
|
You can manage the Caddy deployment with a Compose file for more control. For example, to deploy a custom global Caddy
|
||||||
|
config that uses the DNS challenge with Cloudflare to obtain a wildcard TLS certificate for `*.example.com`:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem value="compose.yaml">
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
caddy:
|
||||||
|
image: caddybuilds/caddy-cloudflare:2.10.2
|
||||||
|
command: caddy run -c /config/Caddyfile --watch
|
||||||
|
environment:
|
||||||
|
CADDY_ADMIN: unix//run/caddy/admin.sock
|
||||||
|
env_file:
|
||||||
|
# Contains CLOUDFLARE_API_TOKEN=xxxxx
|
||||||
|
- .env.secrets
|
||||||
|
volumes:
|
||||||
|
- /var/lib/uncloud/caddy:/data
|
||||||
|
- /var/lib/uncloud/caddy:/config
|
||||||
|
- /run/uncloud/caddy:/run/caddy
|
||||||
|
x-ports:
|
||||||
|
- 80:80@host
|
||||||
|
- 443:443@host
|
||||||
|
x-caddy: Caddyfile
|
||||||
|
deploy:
|
||||||
|
mode: global
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem value="Caddyfile">
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
# Global options.
|
||||||
|
{
|
||||||
|
debug
|
||||||
|
}
|
||||||
|
|
||||||
|
# A snippet that can be reused in custom Caddy configs for services (x-caddy).
|
||||||
|
(my_snippet) {
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
# Obtain a wildcard TLS certificate for all subdomains of example.name using DNS challenge with Cloudflare.
|
||||||
|
# It will be used for services that publish ports with hostnames under example.name.
|
||||||
|
*.example.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
}
|
||||||
|
respond "No host matched" 404
|
||||||
|
}
|
||||||
|
|
||||||
|
# Expose an internal service that is not managed by Uncloud.
|
||||||
|
internal.example.com {
|
||||||
|
reverse_proxy 192.168.1.100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
:::info note
|
||||||
|
|
||||||
|
The specified `command`, `environment`, `volumes`, and `x-ports` properties are essential for Caddy to function
|
||||||
|
correctly in the Uncloud cluster.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
Deploy or update the `caddy` service from the Compose file:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uc deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verifying config
|
||||||
|
|
||||||
|
View the complete generated Caddyfile served by the `caddy` service. This is useful for debugging and verifying
|
||||||
|
custom global and service-specific Caddy configs.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uc caddy config
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# User-defined global config from service 'caddy'.
|
||||||
|
# Global options.
|
||||||
|
{
|
||||||
|
debug
|
||||||
|
}
|
||||||
|
|
||||||
|
# A snippet that can be reused in custom Caddy configs for services (x-caddy).
|
||||||
|
(my_snippet) {
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
# Obtain a wildcard TLS certificate for all subdomains of example.name using DNS challenge with Cloudflare.
|
||||||
|
# It will be used for services that publish ports with hostnames under example.name.
|
||||||
|
*.example.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
}
|
||||||
|
respond "No host matched" 404
|
||||||
|
}
|
||||||
|
|
||||||
|
# Expose an internal service that is not managed by Uncloud.
|
||||||
|
internal.example.com {
|
||||||
|
reverse_proxy 192.168.1.100
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint to verify Caddy reachability on this machine.
|
||||||
|
http:// {
|
||||||
|
handle /.uncloud-verify {
|
||||||
|
respond "a369b9388812f9557feef6a0f5b46f2e" 200
|
||||||
|
}
|
||||||
|
log
|
||||||
|
}
|
||||||
|
|
||||||
|
(common_proxy) {
|
||||||
|
# Retry failed requests up to lb_retries times against other available upstreams.
|
||||||
|
lb_retries 3
|
||||||
|
# Upstreams are marked unhealthy for fail_duration after a failed request (passive health checking).
|
||||||
|
fail_duration 30s
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sites generated from service ports.
|
||||||
|
|
||||||
|
https://app.example.com {
|
||||||
|
reverse_proxy 10.210.1.3:8000 10.210.2.5:8000 {
|
||||||
|
import common_proxy
|
||||||
|
}
|
||||||
|
log
|
||||||
|
}
|
||||||
|
|
||||||
|
https://api.example.com {
|
||||||
|
reverse_proxy 10.210.2.2:9000 10.210.1.7:9000 10.210.2.3:9000 {
|
||||||
|
import common_proxy
|
||||||
|
}
|
||||||
|
log
|
||||||
|
}
|
||||||
|
|
||||||
|
# User-defined config for service 'web'.
|
||||||
|
www.example.com {
|
||||||
|
redir https://example.com{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
example.com {
|
||||||
|
reverse_proxy 10.210.0.3:8000 {
|
||||||
|
import common_proxy
|
||||||
|
}
|
||||||
|
log
|
||||||
|
}
|
||||||
|
|
||||||
|
# Skipped invalid user-defined configs:
|
||||||
|
# - service 'duplicate-hostname': validation failed: adapting config using caddyfile adapter: ambiguous site definition: example.com
|
||||||
|
# - service 'invalid': validation failed: adapting config using caddyfile adapter: Caddyfile:61: unrecognized directive: invalid_directive
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated config combines:
|
||||||
|
|
||||||
|
- Global Caddy configuration (`x-caddy` from the `caddy` service).
|
||||||
|
- Auto-generated configs from published service ports (`x-ports`).
|
||||||
|
- Custom Caddy configs from services (`x-caddy`).
|
||||||
|
- Skipped invalid configs with error messages as comments.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
label: Ingress & HTTPS
|
||||||
|
collapsed: true # keep the category closed by default
|
||||||
|
link:
|
||||||
|
type: generated-index
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
label: Concepts
|
||||||
|
collapsed: false # keep the category open by default
|
||||||
|
link:
|
||||||
|
type: generated-index
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
label: CLI reference
|
||||||
|
collapsed: true # keep the category closed by default
|
||||||
|
link:
|
||||||
|
type: generated-index
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# uc
|
||||||
|
|
||||||
|
A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
-h, --help help for uc
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc build](uc_build.md) - Build services from a Compose file.
|
||||||
|
* [uc caddy](uc_caddy.md) - Manage Caddy reverse proxy service.
|
||||||
|
* [uc ctx](uc_ctx.md) - Switch between different cluster contexts. Contains subcommands to manage contexts.
|
||||||
|
* [uc deploy](uc_deploy.md) - Deploy services from a Compose file.
|
||||||
|
* [uc dns](uc_dns.md) - Manage cluster domain in Uncloud DNS.
|
||||||
|
* [uc inspect](uc_inspect.md) - Display detailed information on a service.
|
||||||
|
* [uc ls](uc_ls.md) - List services.
|
||||||
|
* [uc machine](uc_machine.md) - Manage machines in an Uncloud cluster.
|
||||||
|
* [uc rm](uc_rm.md) - Remove one or more services.
|
||||||
|
* [uc run](uc_run.md) - Run a service.
|
||||||
|
* [uc scale](uc_scale.md) - Scale a replicated service by changing the number of replicas.
|
||||||
|
* [uc service](uc_service.md) - Manage services in an Uncloud cluster.
|
||||||
|
* [uc volume](uc_volume.md) - Manage volumes in an Uncloud cluster.
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# uc build
|
||||||
|
|
||||||
|
Build services from a Compose file.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc build [FLAGS] [SERVICE...] [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-f, --file strings One or more Compose files to build (default compose.yaml)
|
||||||
|
-h, --help help for build
|
||||||
|
-n, --no-cache Do not use cache when building images. (default false)
|
||||||
|
-p, --profile strings One or more Compose profiles to enable.
|
||||||
|
-P, --push Push built images to the registry after building. (default false)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# uc caddy
|
||||||
|
|
||||||
|
Manage Caddy reverse proxy service.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-h, --help help for caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
||||||
|
* [uc caddy config](uc_caddy_config.md) - Show the current Caddy configuration (Caddyfile).
|
||||||
|
* [uc caddy deploy](uc_caddy_deploy.md) - Deploy or upgrade Caddy reverse proxy across all machines in the cluster.
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# uc caddy config
|
||||||
|
|
||||||
|
Show the current Caddy configuration (Caddyfile).
|
||||||
|
|
||||||
|
## Synopsis
|
||||||
|
|
||||||
|
Display the current Caddy configuration (Caddyfile) from the connected machine or a specified one.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc caddy config [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for config
|
||||||
|
-m, --machine string Name or ID of the machine to get the configuration from. (default is connected machine)
|
||||||
|
--no-color Disable syntax highlighting for the output.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc caddy](uc_caddy.md) - Manage Caddy reverse proxy service.
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# uc caddy deploy
|
||||||
|
|
||||||
|
Deploy or upgrade Caddy reverse proxy across all machines in the cluster.
|
||||||
|
|
||||||
|
## Synopsis
|
||||||
|
|
||||||
|
Deploy or upgrade Caddy reverse proxy across all machines in the cluster.
|
||||||
|
A rolling update is performed when updating existing containers to minimise disruption.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc caddy deploy [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
--caddyfile string Path to a custom global Caddy config (Caddyfile) that will be prepended to the auto-generated Caddy config.
|
||||||
|
-c, --context string Name of the cluster context to deploy to. (default is the current context)
|
||||||
|
-h, --help help for deploy
|
||||||
|
--image string Caddy Docker image to deploy. (default caddy:LATEST_VERSION)
|
||||||
|
-m, --machine strings Machine names to deploy to. Can be specified multiple times or as a comma-separated list of machine names. (default is all machines)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc caddy](uc_caddy.md) - Manage Caddy reverse proxy service.
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# uc ctx
|
||||||
|
|
||||||
|
Switch between different cluster contexts. Contains subcommands to manage contexts.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc ctx [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-h, --help help for ctx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
||||||
|
* [uc ctx ls](uc_ctx_ls.md) - List available cluster contexts.
|
||||||
|
* [uc ctx use](uc_ctx_use.md) - Switch to a different cluster context.
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# uc ctx ls
|
||||||
|
|
||||||
|
List available cluster contexts.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc ctx ls [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for ls
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc ctx](uc_ctx.md) - Switch between different cluster contexts. Contains subcommands to manage contexts.
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# uc ctx use
|
||||||
|
|
||||||
|
Switch to a different cluster context.
|
||||||
|
|
||||||
|
## Synopsis
|
||||||
|
|
||||||
|
Switch to a different cluster context. If no context is provided, a list of available contexts will be displayed for selection.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc ctx use [CONTEXT] [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-h, --help help for use
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc ctx](uc_ctx.md) - Switch between different cluster contexts. Contains subcommands to manage contexts.
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# uc deploy
|
||||||
|
|
||||||
|
Deploy services from a Compose file.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc deploy [FLAGS] [SERVICE...] [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context to deploy to (default is the current context)
|
||||||
|
-f, --file strings One or more Compose files to deploy services from. (default compose.yaml)
|
||||||
|
-h, --help help for deploy
|
||||||
|
-n, --no-build Do not build images before deploying services. (default false)
|
||||||
|
-p, --profile strings One or more Compose profiles to enable.
|
||||||
|
--recreate Recreate containers even if their configuration and image haven't changed.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# uc dns
|
||||||
|
|
||||||
|
Manage cluster domain in Uncloud DNS.
|
||||||
|
|
||||||
|
## Synopsis
|
||||||
|
|
||||||
|
Manage cluster domain in Uncloud DNS.
|
||||||
|
DNS commands allow you to reserve or release a unique '\<id>.cluster.uncloud.run' domain for your cluster. When reserved, Caddy service deployments will automatically update DNS records to route traffic to the services in the cluster.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-h, --help help for dns
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
||||||
|
* [uc dns release](uc_dns_release.md) - Release the reserved cluster domain.
|
||||||
|
* [uc dns reserve](uc_dns_reserve.md) - Reserve a cluster domain in Uncloud DNS.
|
||||||
|
* [uc dns show](uc_dns_show.md) - Print the cluster domain name.
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# uc dns release
|
||||||
|
|
||||||
|
Release the reserved cluster domain.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc dns release [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc dns](uc_dns.md) - Manage cluster domain in Uncloud DNS.
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# uc dns reserve
|
||||||
|
|
||||||
|
Reserve a cluster domain in Uncloud DNS.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc dns reserve [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
--endpoint string API endpoint for the Uncloud DNS service. (default "https://dns.uncloud.run/v1")
|
||||||
|
-h, --help help for reserve
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc dns](uc_dns.md) - Manage cluster domain in Uncloud DNS.
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# uc dns show
|
||||||
|
|
||||||
|
Print the cluster domain name.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc dns show [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for show
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc dns](uc_dns.md) - Manage cluster domain in Uncloud DNS.
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# uc inspect
|
||||||
|
|
||||||
|
Display detailed information on a service.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc inspect SERVICE [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for inspect
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# uc ls
|
||||||
|
|
||||||
|
List services.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc ls [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for ls
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# uc machine
|
||||||
|
|
||||||
|
Manage machines in an Uncloud cluster.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-h, --help help for machine
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
||||||
|
* [uc machine add](uc_machine_add.md) - Add a remote machine to a cluster.
|
||||||
|
* [uc machine init](uc_machine_init.md) - Initialise a new cluster with a remote machine as the first member.
|
||||||
|
* [uc machine ls](uc_machine_ls.md) - List machines in a cluster.
|
||||||
|
* [uc machine rename](uc_machine_rename.md) - Rename a machine in the cluster.
|
||||||
|
* [uc machine rm](uc_machine_rm.md) - Remove a machine from a cluster and reset it.
|
||||||
|
* [uc machine token](uc_machine_token.md) - Print the local machine's token for adding it to a cluster.
|
||||||
|
* [uc machine update](uc_machine_update.md) - Update machine configuration in the cluster.
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# uc machine add
|
||||||
|
|
||||||
|
Add a remote machine to a cluster.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc machine add [USER@]HOST[:PORT] [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context to add the machine to. (default is the current context)
|
||||||
|
-h, --help help for add
|
||||||
|
-n, --name string Assign a name to the machine.
|
||||||
|
--no-caddy Don't deploy Caddy reverse proxy service to the machine.
|
||||||
|
--public-ip string Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, blank '' or 'none' to disable ingress on this machine, or specify an IP address. (default "auto")
|
||||||
|
-i, --ssh-key string Path to SSH private key for remote login (if not already added to SSH agent). (default "~/.ssh/id_ed25519")
|
||||||
|
--version string Version of the Uncloud daemon to install on the machine. (default "latest")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc machine](uc_machine.md) - Manage machines in an Uncloud cluster.
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# uc machine init
|
||||||
|
|
||||||
|
Initialise a new cluster with a remote machine as the first member.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc machine init [USER@HOST:PORT] [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the created context for the initialised cluster in the Uncloud config. (default "default")
|
||||||
|
--dns-endpoint string API endpoint for the Uncloud DNS service. (default "https://dns.uncloud.run/v1")
|
||||||
|
-h, --help help for init
|
||||||
|
-n, --name string Assign a name to the machine.
|
||||||
|
--network string IPv4 network CIDR to use for machines and services. (default "10.210.0.0/16")
|
||||||
|
--no-caddy Don't deploy Caddy reverse proxy service to the machine.
|
||||||
|
--no-dns Don't reserve a cluster domain in Uncloud DNS.
|
||||||
|
--public-ip string Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, blank '' or 'none' to disable ingress on this machine, or specify an IP address. (default "auto")
|
||||||
|
-i, --ssh-key string Path to SSH private key for remote login (if not already added to SSH agent). (default "~/.ssh/id_ed25519")
|
||||||
|
--version string Version of the Uncloud daemon to install on the machine. (default "latest")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc machine](uc_machine.md) - Manage machines in an Uncloud cluster.
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# uc machine ls
|
||||||
|
|
||||||
|
List machines in a cluster.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc machine ls [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for ls
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc machine](uc_machine.md) - Manage machines in an Uncloud cluster.
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# uc machine rename
|
||||||
|
|
||||||
|
Rename a machine in the cluster.
|
||||||
|
|
||||||
|
## Synopsis
|
||||||
|
|
||||||
|
Rename a machine in the cluster.
|
||||||
|
|
||||||
|
This command changes the name of an existing machine while preserving all other
|
||||||
|
configuration including network settings, public IP, and cluster membership.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc machine rename OLD_NAME NEW_NAME [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for rename
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc machine](uc_machine.md) - Manage machines in an Uncloud cluster.
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# uc machine rm
|
||||||
|
|
||||||
|
Remove a machine from a cluster and reset it.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc machine rm MACHINE [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for rm
|
||||||
|
--no-reset Do not reset the machine after removing it from the cluster. This will leave all containers and data intact.
|
||||||
|
-y, --yes Do not prompt for confirmation before removing the machine.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc machine](uc_machine.md) - Manage machines in an Uncloud cluster.
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# uc machine token
|
||||||
|
|
||||||
|
Print the local machine's token for adding it to a cluster.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc machine token [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-d, --data-dir string Directory for storing persistent machine state. (default "/var/lib/uncloud")
|
||||||
|
-h, --help help for token
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc machine](uc_machine.md) - Manage machines in an Uncloud cluster.
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# uc machine update
|
||||||
|
|
||||||
|
Update machine configuration in the cluster.
|
||||||
|
|
||||||
|
## Synopsis
|
||||||
|
|
||||||
|
Update machine configuration in the cluster.
|
||||||
|
|
||||||
|
This command allows setting various machine properties including:
|
||||||
|
- Machine name (--name)
|
||||||
|
- Public IP address (--public-ip)
|
||||||
|
|
||||||
|
At least one flag must be specified to perform an update operation.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc machine update [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for update
|
||||||
|
--name string New name for the machine
|
||||||
|
--public-ip string Public IP address of the machine for ingress configuration. Use 'none' or '' to remove the public IP.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc machine](uc_machine.md) - Manage machines in an Uncloud cluster.
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# uc rm
|
||||||
|
|
||||||
|
Remove one or more services.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc rm SERVICE [SERVICE...] [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for rm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# uc run
|
||||||
|
|
||||||
|
Run a service.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc run IMAGE [COMMAND...] [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
--caddyfile string Path to a custom Caddy config (Caddyfile) for the service. Cannot be used together with non-@host published ports.
|
||||||
|
-c, --context string Name of the cluster context to run the service in. (default is the current context)
|
||||||
|
--cpu decimal Maximum number of CPU cores a service container can use. Fractional values are allowed: 0.5 for half a core or 2.25 for two and a quarter cores.
|
||||||
|
--entrypoint string Overwrite the default ENTRYPOINT of the image. Pass an empty string "" to reset it.
|
||||||
|
-e, --env strings Set an environment variable for service containers. Can be specified multiple times.
|
||||||
|
Format: VAR=value or just VAR to use the value from the local environment.
|
||||||
|
-h, --help help for run
|
||||||
|
-m, --machine strings Placement constraint by machine names, limiting which machines the service can run on. Can be specified multiple times or as a comma-separated list of machine names. (default is any suitable machine)
|
||||||
|
--memory bytes Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.
|
||||||
|
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
|
||||||
|
--mode string Replication mode of the service: either 'replicated' (a specified number of containers across the machines) or 'global' (one container on every machine). (default "replicated")
|
||||||
|
-n, --name string Assign a name to the service. A random name is generated if not specified.
|
||||||
|
--privileged Give extended privileges to service containers. This is a security risk and should be used with caution.
|
||||||
|
-p, --publish strings Publish a service port to make it accessible outside the cluster. Can be specified multiple times.
|
||||||
|
Format: [hostname:]container_port[/protocol] or [host_ip:]host_port:container_port[/protocol]@host
|
||||||
|
Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified
|
||||||
|
and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.
|
||||||
|
Examples:
|
||||||
|
-p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname
|
||||||
|
-p app.example.com:8080/https Publish port 8080 as HTTPS via reverse proxy with custom hostname
|
||||||
|
-p 53:5353/udp@host Bind UDP port 5353 to host port 53
|
||||||
|
--pull string Pull image from the registry before running service containers ('always', 'missing', 'never'). (default "missing")
|
||||||
|
--replicas uint Number of containers to run for the service. Only valid for a replicated service. (default 1)
|
||||||
|
-u, --user string User name or UID and optionally group name or GID used for running the command inside service containers.
|
||||||
|
Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.
|
||||||
|
-v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where
|
||||||
|
the volume is located. Can be specified multiple times.
|
||||||
|
Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]
|
||||||
|
Examples:
|
||||||
|
-v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container
|
||||||
|
-v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container
|
||||||
|
-v /host/path:/container/path:ro Bind mount a host directory or file as read-only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# uc scale
|
||||||
|
|
||||||
|
Scale a replicated service by changing the number of replicas.
|
||||||
|
|
||||||
|
## Synopsis
|
||||||
|
|
||||||
|
Scale a replicated service by changing the number of replicas. Scaling down requires confirmation.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc scale SERVICE REPLICAS [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for scale
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# uc service
|
||||||
|
|
||||||
|
Manage services in an Uncloud cluster.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-h, --help help for service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
||||||
|
* [uc service inspect](uc_service_inspect.md) - Display detailed information on a service.
|
||||||
|
* [uc service ls](uc_service_ls.md) - List services.
|
||||||
|
* [uc service rm](uc_service_rm.md) - Remove one or more services.
|
||||||
|
* [uc service run](uc_service_run.md) - Run a service.
|
||||||
|
* [uc service scale](uc_service_scale.md) - Scale a replicated service by changing the number of replicas.
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# uc service inspect
|
||||||
|
|
||||||
|
Display detailed information on a service.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc service inspect SERVICE [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for inspect
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc service](uc_service.md) - Manage services in an Uncloud cluster.
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# uc service ls
|
||||||
|
|
||||||
|
List services.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc service ls [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for ls
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc service](uc_service.md) - Manage services in an Uncloud cluster.
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# uc service rm
|
||||||
|
|
||||||
|
Remove one or more services.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc service rm SERVICE [SERVICE...] [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-c, --context string Name of the cluster context. (default is the current context)
|
||||||
|
-h, --help help for rm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
||||||
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc service](uc_service.md) - Manage services in an Uncloud cluster.
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user