Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da09d22b47 |
@@ -1,36 +0,0 @@
|
|||||||
---
|
|
||||||
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. -->
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../AI.md
|
|
||||||
@@ -18,8 +18,6 @@ on:
|
|||||||
- "test/**"
|
- "test/**"
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
env:
|
|
||||||
MISE_VERSION: "2026.3.7"
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -27,12 +25,10 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Install Mise
|
- name: Set up Go
|
||||||
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
|
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.MISE_VERSION }}
|
go-version: "1.23.2"
|
||||||
- name: Reset changes in mise.lock after fresh mise install
|
|
||||||
run: git checkout mise.lock
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -40,17 +36,9 @@ jobs:
|
|||||||
git diff --exit-code ||
|
git diff --exit-code ||
|
||||||
(echo "go.mod or go.sum has changed. Please run 'go mod tidy' and commit the changes." && exit 1)
|
(echo "go.mod or go.sum has changed. Please run 'go mod tidy' and commit the changes." && exit 1)
|
||||||
|
|
||||||
- name: Generate docs
|
|
||||||
run: |
|
|
||||||
make cli-docs
|
|
||||||
git diff --exit-code ||
|
|
||||||
(echo "Documentation is out of date. Please run 'make cli-docs' and commit the changes." && exit 1)
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: make test
|
||||||
mise ucind:image
|
timeout-minutes: 10
|
||||||
make test
|
|
||||||
timeout-minutes: 15
|
|
||||||
|
|
||||||
check-protobuf:
|
check-protobuf:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
@@ -65,18 +53,15 @@ jobs:
|
|||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Install Mise
|
- name: Install Mise
|
||||||
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
|
uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3
|
||||||
with:
|
with:
|
||||||
version: ${{ env.MISE_VERSION }}
|
version: "2025.6.5"
|
||||||
|
env:
|
||||||
# TODO: figure out how to properly lock all platforms in the lockfile and remove this hack.
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
# `mise lock` (mise run mise:lock) doesn't generate checksums for some of the tools, e.g. protoc*
|
|
||||||
- name: Reset changes in mise.lock after fresh mise install
|
|
||||||
run: git checkout mise.lock
|
|
||||||
|
|
||||||
- name: Generate Protobuf files
|
- name: Generate Protobuf files
|
||||||
run: |
|
run: |
|
||||||
mise run proto
|
make proto
|
||||||
# Check if the generated files are up to date.
|
# check if the generated files are up to date
|
||||||
git diff --exit-code ||
|
git diff --exit-code ||
|
||||||
(echo "Generated protobuf files are not up to date. Please run 'mise run proto' and commit the changes." && exit 1)
|
(echo "Generated protobuf files are not up to date. Please run 'make proto' and commit the changes." && exit 1)
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
name: Lint
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- "main"
|
|
||||||
- "test/**"
|
|
||||||
- "release/**"
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "**.go"
|
|
||||||
- "go.*"
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
env:
|
|
||||||
MISE_VERSION: "2026.3.7"
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
|
|
||||||
- name: Install Mise
|
|
||||||
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
|
|
||||||
with:
|
|
||||||
version: ${{ env.MISE_VERSION }}
|
|
||||||
- name: Reset changes in mise.lock after fresh mise install
|
|
||||||
run: git checkout mise.lock
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: make lint
|
|
||||||
|
|
||||||
timeout-minutes: 10
|
|
||||||
@@ -7,10 +7,6 @@
|
|||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
/uncloud
|
|
||||||
/uncloud-*
|
|
||||||
/uncloudd
|
|
||||||
/uncloudd-*
|
|
||||||
|
|
||||||
# OS X
|
# OS X
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -41,6 +37,3 @@ node_modules/
|
|||||||
|
|
||||||
# VS Code
|
# VS Code
|
||||||
.vscode
|
.vscode
|
||||||
.devcontainer/
|
|
||||||
|
|
||||||
.zed/
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
version: "2"
|
|
||||||
|
|
||||||
run:
|
|
||||||
concurrency: 4
|
|
||||||
tests: true
|
|
||||||
timeout: 5m
|
|
||||||
|
|
||||||
linters:
|
|
||||||
default: none
|
|
||||||
enable:
|
|
||||||
- bodyclose
|
|
||||||
# - dogsled
|
|
||||||
- dupl
|
|
||||||
# - errcheck
|
|
||||||
- gochecknoinits
|
|
||||||
- goconst
|
|
||||||
# - gocritic
|
|
||||||
# - gocyclo
|
|
||||||
# - godot
|
|
||||||
# - gosec
|
|
||||||
# - govet
|
|
||||||
- ineffassign
|
|
||||||
- misspell
|
|
||||||
- nakedret
|
|
||||||
# - prealloc
|
|
||||||
# - revive
|
|
||||||
# - staticcheck
|
|
||||||
- unconvert
|
|
||||||
# - unparam
|
|
||||||
# - unused
|
|
||||||
- whitespace
|
|
||||||
exclusions:
|
|
||||||
rules:
|
|
||||||
- path: ^test/e2e
|
|
||||||
linters:
|
|
||||||
- goconst # constants here add no value, so we skip goconst only for test/e2e.
|
|
||||||
|
|
||||||
formatters:
|
|
||||||
enable:
|
|
||||||
- gofumpt
|
|
||||||
- goimports
|
|
||||||
|
|
||||||
@@ -16,8 +16,7 @@ builds:
|
|||||||
main: ./cmd/uncloud
|
main: ./cmd/uncloud
|
||||||
binary: uncloud
|
binary: uncloud
|
||||||
env:
|
env:
|
||||||
# Use CGO to be able to build fsevent required by compose on macOS.
|
- CGO_ENABLED=0
|
||||||
- CGO_ENABLED={{ if eq .Os "darwin" }}1{{ else }}0{{ end }}
|
|
||||||
goos:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
- linux
|
- linux
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[tools.go]
|
||||||
|
version = "1.23.10"
|
||||||
|
backend = "core:go"
|
||||||
|
|
||||||
|
[tools.go.checksums]
|
||||||
|
"go1.23.10.darwin-arm64.tar.gz" = "sha256:25c64bfa8a8fd8e7f62fb54afa4354af8409a4bb2358c2699a1003b733e6fce5"
|
||||||
|
"go1.23.10.linux-amd64.tar.gz" = "sha256:535f9f81802499f2a7dbfa70abb8fda3793725fcc29460f719815f6e10b5fd60"
|
||||||
|
|
||||||
|
[tools.protoc]
|
||||||
|
version = "27.3"
|
||||||
|
backend = "aqua:protocolbuffers/protobuf/protoc"
|
||||||
|
|
||||||
|
[tools.protoc.checksums]
|
||||||
|
"protoc-27.3-linux-x86_64.zip" = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
|
||||||
|
"protoc-27.3-osx-aarch_64.zip" = "sha256:b22116bd97cdbd7ea25346abe635a9df268515fe5ef5afa93cd9a68fc2513f84"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go]
|
||||||
|
version = "1.34.2"
|
||||||
|
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go.checksums]
|
||||||
|
"protoc-gen-go.v1.34.2.darwin.arm64.tar.gz" = "sha256:17aca7f948dbb624049030cf841e35895cf34183ba006e721247fdeb95ff2780"
|
||||||
|
"protoc-gen-go.v1.34.2.linux.amd64.tar.gz" = "sha256:b87bc134dee55576a842141bf0ed27761c635d746780fce5dee038c6dd16554f"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go-grpc]
|
||||||
|
version = "1.5.1"
|
||||||
|
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go-grpc.checksums]
|
||||||
|
"protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz" = "sha256:d6083feb51dcfe59f26793e99ef01ef5eac68b64294ec2546711f614ac5878f3"
|
||||||
|
"protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz" = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[settings]
|
||||||
|
experimental = true
|
||||||
|
|
||||||
|
[tools]
|
||||||
|
go = "1.23"
|
||||||
|
protoc = "27.3"
|
||||||
|
protoc-gen-go = "1.34.2"
|
||||||
|
protoc-gen-go-grpc = "1.5.1"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
_.file = ".env"
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
#MISE description="Build and install uncloud binaries on dev machines, restart daemon if running"
|
|
||||||
#MISE depends=["build:uncloudd-amd64", "build:uncloudd-arm64"]
|
|
||||||
#MISE raw=true
|
|
||||||
#USAGE arg "<hosts>" var=#true var_min=1 help="Hosts in [user@]host[/arch] format, arch defaults to amd64"
|
|
||||||
|
|
||||||
for arg in ${usage_hosts}; do
|
|
||||||
if [[ "$arg" == */* ]]; then
|
|
||||||
host="${arg%/*}"
|
|
||||||
arch="${arg##*/}"
|
|
||||||
else
|
|
||||||
host="$arg"
|
|
||||||
arch="amd64"
|
|
||||||
fi
|
|
||||||
|
|
||||||
binary="uncloudd-linux-${arch}"
|
|
||||||
echo "==> Installing uncloudd on ${host} (${arch})..."
|
|
||||||
scp "$binary" "${host}:/tmp/"
|
|
||||||
ssh "$host" "sudo install /tmp/${binary} /usr/local/bin/uncloudd && rm /tmp/${binary} && sudo systemctl try-restart uncloud"
|
|
||||||
done
|
|
||||||
|
|
||||||
rm -f uncloudd-linux-amd64 uncloudd-linux-arm64
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
#MISE description="Stop uncloud daemon and wipe state on dev machines"
|
|
||||||
#USAGE arg "<hosts>" var=#true var_min=1 help="Hosts in [user@]host format"
|
|
||||||
|
|
||||||
for host in ${usage_hosts}; do
|
|
||||||
# Strip optional /arch suffix since reset doesn't need it.
|
|
||||||
host="${host%/*}"
|
|
||||||
echo "==> Resetting ${host}..."
|
|
||||||
ssh "$host" "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "==> Done"
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
packages:
|
|
||||||
github.com/psviderski/uncloud/internal/machine/caddyconfig:
|
|
||||||
interfaces:
|
|
||||||
CaddyfileValidator:
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
# AGENTS.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 `*.xxxxxx.uncld.dev` subdomains via Uncloud DNS service
|
|
||||||
- **Cross-machine scaling**: Run containers across multiple machines
|
|
||||||
|
|
||||||
### 🔧 Developer Experience
|
|
||||||
|
|
||||||
- **Docker-like CLI**: Familiar commands (`uc` binary)
|
|
||||||
- **Imperative operations**: Direct commands vs. declarative state reconciliation
|
|
||||||
- **Remote management**: Control entire infrastructure via SSH to any machine
|
|
||||||
- **Minimal overhead**: ~150MB RAM footprint per machine
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
|
|
||||||
1. **CLI (`uc`)** - Main user interface for cluster management
|
|
||||||
2. **Daemon (`uncloudd`)** - Machine daemon running on each node
|
|
||||||
3. **Corrosion** - Distributed SQLite database for cluster state (Fly.io project)
|
|
||||||
4. **Caddy** - Reverse proxy for HTTPS termination and routing
|
|
||||||
5. **WireGuard** - Secure mesh networking between machines
|
|
||||||
|
|
||||||
### Network Architecture
|
|
||||||
|
|
||||||
- Each machine gets unique subnet (e.g., `10.210.0.0/24`, `10.210.1.0/24`)
|
|
||||||
- Containers get cluster-unique IPs for direct communication
|
|
||||||
- Automatic peer discovery and key management
|
|
||||||
- NAT traversal for machines behind firewalls
|
|
||||||
|
|
||||||
### State Management
|
|
||||||
|
|
||||||
- **CRDT-based distributed storage** using Corrosion
|
|
||||||
- **Eventually consistent** state across all machines
|
|
||||||
- **Gossip protocol** (Serf) for state propagation
|
|
||||||
- **No quorum requirements** - partial network splits remain functional
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Key Directories
|
|
||||||
|
|
||||||
- **`cmd/`**: Contains main applications
|
|
||||||
|
|
||||||
- `uncloud/`: CLI tool with subcommands for machine, service, volume management
|
|
||||||
- `uncloudd/`: Daemon that runs on each machine
|
|
||||||
- `ucind/`: Development cluster management for testing
|
|
||||||
|
|
||||||
- **`internal/`**: Internal implementation packages
|
|
||||||
|
|
||||||
- `cli/`: Command-line interface logic
|
|
||||||
- `machine/`: Machine lifecycle and state management
|
|
||||||
- `daemon/`: Daemon implementation and gRPC services
|
|
||||||
- `dns/`: Internal DNS server for service discovery
|
|
||||||
|
|
||||||
- **`pkg/`**: Public API packages for external use
|
|
||||||
|
|
||||||
- `api/`: Core API types and definitions
|
|
||||||
- `client/`: Client libraries for interacting with Uncloud
|
|
||||||
|
|
||||||
- **`experiment/`**: Experimental features and prototypes
|
|
||||||
- **`scripts/`**: Installation and utility scripts
|
|
||||||
- **`test/`**: Test suites and test infrastructure
|
|
||||||
- **`website/`**: Documentation website (Docusaurus)
|
|
||||||
|
|
||||||
- `landing/`: Landing page
|
|
||||||
- `docs/`: User documentation
|
|
||||||
|
|
||||||
- **`misc/`**: Design documents and guides
|
|
||||||
|
|
||||||
## Key Technologies
|
|
||||||
|
|
||||||
### Core Dependencies
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Networking and orchestration
|
|
||||||
github.com/docker/docker // Docker API client
|
|
||||||
github.com/docker/compose/v2 // Docker Compose integration
|
|
||||||
golang.zx2c4.com/wireguard // WireGuard implementation
|
|
||||||
github.com/hashicorp/serf // Gossip protocol
|
|
||||||
|
|
||||||
// State management
|
|
||||||
github.com/ipfs/go-ds-crdt // CRDT distributed storage
|
|
||||||
github.com/dgraph-io/badger/v3 // Embedded database
|
|
||||||
|
|
||||||
// Web proxy
|
|
||||||
github.com/caddyserver/caddy/v2 // HTTP server and reverse proxy
|
|
||||||
|
|
||||||
// CLI and UX
|
|
||||||
github.com/spf13/cobra // CLI framework
|
|
||||||
github.com/charmbracelet/huh // Interactive forms
|
|
||||||
|
|
||||||
// gRPC and networking
|
|
||||||
google.golang.org/grpc // gRPC framework
|
|
||||||
github.com/siderolabs/grpc-proxy // gRPC proxy for forwarding
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
### Build and Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build binaries
|
|
||||||
go build -o uncloud ./cmd/uncloud
|
|
||||||
go build -o uncloudd ./cmd/uncloudd
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Make Targets
|
|
||||||
|
|
||||||
- `proto`: Generate protobuf code
|
|
||||||
- `ucind-cluster`: Create development cluster
|
|
||||||
- `update-dev`: Deploy to development machines
|
|
||||||
- `demo-reset`: Reset demo environment
|
|
||||||
- `fmt`: Format code
|
|
||||||
- `test`: Run all tests
|
|
||||||
- `lint`: Lint the code using golangci-lint
|
|
||||||
- `lint-and-fix`: Lint the code and fix issues whenever possible
|
|
||||||
|
|
||||||
## CLI Commands Structure
|
|
||||||
|
|
||||||
The `uc` CLI provides these main command groups:
|
|
||||||
|
|
||||||
### Machine Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uc machine init <user@host> # Initialize new cluster
|
|
||||||
uc machine add <user@host> # Add machine to cluster
|
|
||||||
uc machine ls # List machines
|
|
||||||
uc machine rm <name> # Remove machine
|
|
||||||
```
|
|
||||||
|
|
||||||
### Service Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uc run <image> # Run container from image
|
|
||||||
uc deploy # Deploy from compose.yaml
|
|
||||||
uc scale <service> <count> # Scale service replicas
|
|
||||||
uc ls # List services
|
|
||||||
uc rm <service> # Remove service
|
|
||||||
```
|
|
||||||
|
|
||||||
### Context and Connectivity
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uc context ls # List available contexts
|
|
||||||
uc context use <name> # Switch context
|
|
||||||
```
|
|
||||||
|
|
||||||
### Global Flags
|
|
||||||
|
|
||||||
- `--connect`: Connect to remote machine directly, without a config file
|
|
||||||
- `--uncloud-config`: Override config file path
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
|
|
||||||
### Code Organization
|
|
||||||
|
|
||||||
- **Package naming**: Use clear, descriptive names
|
|
||||||
- **Error handling**: Wrap errors with context using `fmt.Errorf`
|
|
||||||
- **Logging**: Use structured logging with levels
|
|
||||||
- **gRPC**: Services defined in `internal/machine/api/pb/`
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
- Test files and locations
|
|
||||||
- Unit tests alongside source files (`*_test.go`)
|
|
||||||
- Integration tests in `test/e2e/`
|
|
||||||
- Test fixtures in `test/fixtures/`
|
|
||||||
- Use table driven tests whenever possible
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
- Prefer standard library when possible
|
|
||||||
- Pin versions in `go.mod`
|
|
||||||
- Document rationale for external dependencies
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
- Support environment variables for key settings
|
|
||||||
- Validate configuration early
|
|
||||||
- Provide sensible defaults
|
|
||||||
|
|
||||||
## Troubleshooting and Debugging
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
- **Networking**: Check WireGuard status, iptables rules
|
|
||||||
- **DNS**: Verify service discovery resolution
|
|
||||||
- **Containers**: Use standard Docker debugging tools
|
|
||||||
- **State sync**: Check Corrosion logs for replication issues
|
|
||||||
|
|
||||||
### Debugging Tools
|
|
||||||
|
|
||||||
- Standard Linux networking tools (`ping`, `traceroute`, `wireshark`)
|
|
||||||
- Docker commands (`docker ps`, `docker logs`)
|
|
||||||
- SSH access to machines for direct inspection
|
|
||||||
- gRPC debugging tools
|
|
||||||
|
|
||||||
### Logs and Monitoring
|
|
||||||
|
|
||||||
- Systemd services (getting logs via `journalctl -u SERVICE_NAME`)
|
|
||||||
- `uncloud` -- Uncloud daemon
|
|
||||||
- `uncloud-corrosion` -- Corrosion process
|
|
||||||
- Machine daemon logs
|
|
||||||
- Container logs via Docker
|
|
||||||
|
|
||||||
## File Patterns and Conventions
|
|
||||||
|
|
||||||
### Important Files to Understand
|
|
||||||
|
|
||||||
- `cmd/uncloud/main.go`: CLI entry point and command structure
|
|
||||||
- `internal/cli/cli.go`: CLI implementation and configuration
|
|
||||||
- `internal/machine/machine.go`: Core machine management
|
|
||||||
- `pkg/api/`: Public API definitions
|
|
||||||
- `misc/design.md`: Architecture and design philosophy
|
|
||||||
- `README.md`: Repository README
|
|
||||||
|
|
||||||
### Configuration Files
|
|
||||||
|
|
||||||
- `go.mod/go.sum`: Go dependency management
|
|
||||||
- `Makefile`: Build and development tasks
|
|
||||||
- `Dockerfile`: Container build instructions forUncloud-in-Docker (used for testing)
|
|
||||||
|
|
||||||
This document should help AI assistants understand the project structure, make informed suggestions, and contribute
|
|
||||||
effectively to the Uncloud codebase.
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Instructions when generating documentation:
|
|
||||||
|
|
||||||
- Use conversational language – write as if you were speaking to a friend.
|
|
||||||
- Keep sentences simple, optimize for clarity and understanding.
|
|
||||||
- Do not use em dashes (—) or semicolons (;) in sentences. Instead, break complex sentences into simpler ones.
|
|
||||||
- Place the subject before the action whenever possible. Example: prefer "The function loads data" over "Data is loaded
|
|
||||||
by the function."
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
ARG ALPINE_VERSION=3.23.3
|
ARG ALPINE_VERSION=3.20.3
|
||||||
|
|
||||||
FROM golang:1.26.1-alpine AS uncloudd
|
FROM golang:1.23.2-alpine AS uncloudd
|
||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@@ -13,7 +13,7 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download && go mod verify
|
RUN go mod download && go mod verify
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd ./cmd/uncloudd
|
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd cmd/uncloudd/main.go
|
||||||
|
|
||||||
|
|
||||||
FROM alpine:${ALPINE_VERSION} AS corrosion-download
|
FROM alpine:${ALPINE_VERSION} AS corrosion-download
|
||||||
@@ -39,11 +39,10 @@ CMD ["corrosion", "agent"]
|
|||||||
|
|
||||||
FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball
|
FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball
|
||||||
ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest"
|
ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest"
|
||||||
ARG TARGETOS
|
|
||||||
ARG TARGETARCH
|
|
||||||
|
|
||||||
RUN apk --no-cache add crane
|
RUN apk --no-cache add crane
|
||||||
RUN crane pull --platform ${TARGETOS}/${TARGETARCH} "${CORROSION_IMAGE}" /corrosion.tar
|
RUN crane pull "${CORROSION_IMAGE}" /corrosion.tar
|
||||||
|
|
||||||
|
|
||||||
# Uncloud-in-Docker (ucind) image for running Uncloud test clusters using Docker.
|
# Uncloud-in-Docker (ucind) image for running Uncloud test clusters using Docker.
|
||||||
FROM docker:27.3.1-dind AS ucind
|
FROM docker:27.3.1-dind AS ucind
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
# Developing Uncloud
|
|
||||||
|
|
||||||
A short guide on setting up your local development environment.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- [mise](https://mise.jdx.dev/) (development environment setup tool)
|
|
||||||
- [Docker](https://docs.docker.com/get-docker/) (for running end-to-end tests)
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
Uncloud uses `mise` to install and manage development tools and dependencies. It makes it easy to create reproducible
|
|
||||||
development environments on developer machines and CI.
|
|
||||||
|
|
||||||
Follow the [Installing Mise](https://mise.jdx.dev/installing-mise.html) guide to get it set up on your machine.
|
|
||||||
|
|
||||||
Mark the project as trusted and install all required tools (Go, `protoc`, `golangci-lint`, etc.) by running from the
|
|
||||||
project root:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
mise trust
|
|
||||||
mise install
|
|
||||||
```
|
|
||||||
|
|
||||||
This reads [`mise.toml`](mise.toml) and installs the exact versions of tools specified there. They will be available in
|
|
||||||
your `PATH` when you are in the project directory, so you can run `go` or `golangci-lint` directly. The tools won't be
|
|
||||||
available outside the project, so they don't interfere with system packages.
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
Build the CLI:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
go build -o uc ./cmd/uncloud
|
|
||||||
```
|
|
||||||
|
|
||||||
Or build and run the CLI with a single command:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
go run ./cmd/uncloud --help
|
|
||||||
```
|
|
||||||
|
|
||||||
The Uncloud daemon (`uncloudd`) only supports Linux, so you need to cross-compile it if you're developing on macOS or
|
|
||||||
Windows:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
GOOS=linux GOARCH=amd64 go build -o uncloudd ./cmd/uncloudd
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run all tests (unit and e2e):
|
|
||||||
|
|
||||||
```shell
|
|
||||||
make test
|
|
||||||
```
|
|
||||||
|
|
||||||
### End-to-end tests
|
|
||||||
|
|
||||||
E2e tests run inside Docker using the [Uncloud-in-Docker](#uncloud-in-docker-ucind) image. Build it first:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
mise ucind:image
|
|
||||||
```
|
|
||||||
|
|
||||||
⚠️ NOTE: You need to rebuild the `ucind` image every time you make changes to the daemon code if you want to test them
|
|
||||||
in e2e tests.
|
|
||||||
|
|
||||||
Then run the tests:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
make test-e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
If tests leave leftover containers, clean them up with:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
make test-clean
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual testing on dev machines
|
|
||||||
|
|
||||||
After making changes to the daemon code, you may want to test them in your dev cluster. Build and install `uncloudd` on
|
|
||||||
one or multiple dev machines over SSH with:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
mise run dev:install user@host1 user@host2/arm64
|
|
||||||
```
|
|
||||||
|
|
||||||
It will also restart the `uncloud.service` daemon on those machines to pick up the new version. The host format is
|
|
||||||
`[user@]host[/arch]`. The architecture of a machine defaults to `amd64` if not specified.
|
|
||||||
|
|
||||||
To stop the daemon and wipe all Uncloud data on the dev machines, run:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
mise run dev:reset user@host1 user@host2
|
|
||||||
```
|
|
||||||
|
|
||||||
## Linting and formatting
|
|
||||||
|
|
||||||
Lint and format the code:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
make lint-and-fix
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code generation
|
|
||||||
|
|
||||||
Update the generated Go code for the machine gRPC API after modifying `.proto` files:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
mise run proto
|
|
||||||
```
|
|
||||||
|
|
||||||
Regenerate mocks:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
make mocks
|
|
||||||
```
|
|
||||||
|
|
||||||
## Uncloud in Docker (UCinD)
|
|
||||||
|
|
||||||
The [`ucind`](./cmd/ucind) CLI lets you run Uncloud clusters locally using Docker containers instead of real machines.
|
|
||||||
Each cluster machine runs as a Docker container connected to a shared Docker network.
|
|
||||||
|
|
||||||
Cluster machines use the `ghcr.io/psviderski/ucind:latest` Docker image by default. It may not always have the
|
|
||||||
latest changes to the `uncloudd` daemon code as it's not automatically rebuilt on CI yet.
|
|
||||||
|
|
||||||
You can rebuild it locally to pick up the latest changes, including your work in progress, and test them in a UCinD
|
|
||||||
cluster:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
mise ucind:image
|
|
||||||
```
|
|
||||||
|
|
||||||
Create a cluster with 3 machines:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
mise ucind cluster create -m 3
|
|
||||||
```
|
|
||||||
|
|
||||||
This also sets the new cluster as the active context, so you can interact with it using the `uc` CLI right away.
|
|
||||||
|
|
||||||
Remove the cluster when you're done:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
mise ucind cluster rm
|
|
||||||
```
|
|
||||||
|
|
||||||
Sometimes crashed tests or interrupted runs can leave orphaned containers and networks behind. Clean them all up with:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
mise ucind:cleanup
|
|
||||||
```
|
|
||||||
@@ -1,11 +1,50 @@
|
|||||||
# TODO: Makefile is deprecated, add new targets as mise tasks in mise.toml instead.
|
|
||||||
|
|
||||||
CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest
|
CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest
|
||||||
UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest
|
UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest
|
||||||
|
DOCS_IMAGE ?= ghcr.io/psviderski/uncloud-docs:latest
|
||||||
|
|
||||||
|
update-dev:
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o uncloudd-linux-amd64 ./cmd/uncloudd && \
|
||||||
|
scp uncloudd-linux-amd64 spy@192.168.40.243:~/ && \
|
||||||
|
ssh spy@192.168.40.243 sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd
|
||||||
|
scp uncloudd-linux-amd64 spy@192.168.40.176:~/ && \
|
||||||
|
ssh spy@192.168.40.176 sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && \
|
||||||
|
rm uncloudd-linux-amd64
|
||||||
|
# GOOS=linux GOARCH=arm64 go build -o uncloudd-linux-arm64 ./cmd/uncloudd && \
|
||||||
|
# scp uncloudd-linux-arm64 ubuntu@152.67.101.197:~/ && \
|
||||||
|
# ssh ubuntu@152.67.101.197 sudo install ./uncloudd-linux-arm64 /usr/local/bin/uncloudd && \
|
||||||
|
# rm uncloudd-linux-arm64
|
||||||
|
|
||||||
|
update-restart-dev:
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o uncloudd-linux-amd64 ./cmd/uncloudd && \
|
||||||
|
scp uncloudd-linux-amd64 spy@192.168.40.243:~/ && \
|
||||||
|
ssh spy@192.168.40.243 "sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
|
||||||
|
scp uncloudd-linux-amd64 spy@192.168.40.176:~/ && \
|
||||||
|
ssh spy@192.168.40.176 "sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
|
||||||
|
rm uncloudd-linux-amd64
|
||||||
|
# GOOS=linux GOARCH=arm64 go build -o uncloudd-linux-arm64 ./cmd/uncloudd && \
|
||||||
|
# scp uncloudd-linux-arm64 ubuntu@152.67.101.197:~/ && \
|
||||||
|
# ssh ubuntu@152.67.101.197 "sudo install ./uncloudd-linux-arm64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
|
||||||
|
# rm uncloudd-linux-arm64
|
||||||
|
|
||||||
|
reset-dev:
|
||||||
|
ssh spy@192.168.40.243 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
||||||
|
ssh spy@192.168.40.176 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
||||||
|
ssh ubuntu@152.67.101.197 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
||||||
|
|
||||||
|
demo-reset:
|
||||||
|
rm -fv ~/.config/uncloud/config.yaml
|
||||||
|
ssh ubuntu@152.67.101.197 "AUTO_CONFIRM=true sudo -E uncloud-uninstall && docker rmi caddy:2.9.1"
|
||||||
|
ssh root@5.223.45.199 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
|
||||||
|
ssh spy@192.168.40.243 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
|
||||||
|
|
||||||
.PHONY: ucind-cluster
|
.PHONY: ucind-cluster
|
||||||
ucind-cluster:
|
ucind-cluster:
|
||||||
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m $(if $(MACHINES_COUNT),$(MACHINES_COUNT),3)
|
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m 3
|
||||||
|
|
||||||
|
.PHONY: proto
|
||||||
|
proto:
|
||||||
|
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||||
|
--proto_path=. --proto_path=internal/machine/api/vendor internal/machine/api/pb/*.proto
|
||||||
|
|
||||||
.PHONY: corrosion-image
|
.PHONY: corrosion-image
|
||||||
corrosion-image:
|
corrosion-image:
|
||||||
@@ -15,34 +54,30 @@ corrosion-image:
|
|||||||
corrosion-multiarch-image-push:
|
corrosion-multiarch-image-push:
|
||||||
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(CORROSION_IMAGE)" --target corrosion .
|
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(CORROSION_IMAGE)" --target corrosion .
|
||||||
|
|
||||||
|
.PHONY: ucind-image
|
||||||
|
ucind-image:
|
||||||
|
docker build -t "$(UCIND_IMAGE)" --target ucind .
|
||||||
|
|
||||||
.PHONY: ucind-multiarch-image-push
|
.PHONY: ucind-multiarch-image-push
|
||||||
ucind-multiarch-image-push:
|
ucind-multiarch-image-push:
|
||||||
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
|
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
|
||||||
|
|
||||||
.PHONY: mocks
|
|
||||||
mocks:
|
|
||||||
@mockery
|
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test:
|
test:
|
||||||
ifeq ($(TEST_NAME),)
|
ifeq ($(TEST_NAME),)
|
||||||
go test -shuffle=on -count=1 -v ./...
|
go test -count=1 -v ./...
|
||||||
else
|
else
|
||||||
go test -shuffle=on -race -count=1 -v -run ^$(TEST_NAME)$$ ./...
|
go test -count=1 -v -run ^$(TEST_NAME)$$ ./...
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: test-e2e
|
|
||||||
test-e2e:
|
|
||||||
go test -shuffle=on -race -count=1 -v ./test/e2e
|
|
||||||
|
|
||||||
.PHONY: test-clean
|
.PHONY: test-clean
|
||||||
test-clean:
|
test-clean:
|
||||||
@CONTAINERS=$$(docker ps --filter "label=ucind.managed" -q); \
|
@CONTAINERS=$$(docker ps --filter "name=ucind-test" -q); \
|
||||||
if [ -n "$$CONTAINERS" ]; then \
|
if [ -n "$$CONTAINERS" ]; then \
|
||||||
echo "Killing containers..."; \
|
echo "Killing containers..."; \
|
||||||
docker kill $$CONTAINERS; \
|
docker kill $$CONTAINERS; \
|
||||||
fi; \
|
fi; \
|
||||||
CONTAINERS_STOPPED=$$(docker ps -a --filter "label=ucind.managed" -q); \
|
CONTAINERS_STOPPED=$$(docker ps -a --filter "name=ucind-test" -q); \
|
||||||
if [ -n "$$CONTAINERS_STOPPED" ]; then \
|
if [ -n "$$CONTAINERS_STOPPED" ]; then \
|
||||||
echo "Removing stopped containers..."; \
|
echo "Removing stopped containers..."; \
|
||||||
docker rm $$CONTAINERS_STOPPED; \
|
docker rm $$CONTAINERS_STOPPED; \
|
||||||
@@ -54,20 +89,6 @@ test-clean:
|
|||||||
vet:
|
vet:
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
.PHONY: format fmt
|
.PHONY: docs-image-push
|
||||||
format fmt:
|
docs-image:
|
||||||
GOOS=linux golangci-lint fmt
|
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(DOCS_IMAGE)" ./docs
|
||||||
|
|
||||||
LINT_TARGETS := lint lint-and-fix
|
|
||||||
.PHONY: $(LINT_TARGETS) _lint
|
|
||||||
$(LINT_TARGETS): _lint
|
|
||||||
lint: ARGS=
|
|
||||||
lint-and-fix: ARGS=--fix
|
|
||||||
_lint:
|
|
||||||
# Explicitly set OS to Linux to not skip *_linux.go files when running on macOS.
|
|
||||||
# Uncloud daemon won't likely support OS other than Linux anytime soon, so for now we can rely on that.
|
|
||||||
GOOS=linux golangci-lint run $(ARGS)
|
|
||||||
|
|
||||||
.PHONY: cli-docs
|
|
||||||
cli-docs:
|
|
||||||
go run ./cmd/uncloud docs
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="./website/landing/images/logo-title.svg#gh-light-mode-only" alt="Uncloud logo"/>
|
<img src="./website/images/logo.svg" height="100" width="100" alt="Uncloud logo"/>
|
||||||
<img src="./website/landing/images/logo-title-dark.svg#gh-dark-mode-only" alt="Uncloud logo"/>
|
<h1>Uncloud</h1>
|
||||||
<p><strong>▸ Deploy and scale containerised apps across servers without Swarm or Kubernetes overhead ◂</strong></p>
|
<p><strong>Docker simplicity. Multi-machine power.</strong></p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://uncloud.run/docs"><img src="https://img.shields.io/badge/Docs-blue.svg?style=for-the-badge&logo=gitbook&logoColor=white" alt="Documentation"></a>
|
<a href="https://docs.uncloud.run"><img src="https://img.shields.io/badge/Docs-blue.svg?style=for-the-badge&logo=gitbook&logoColor=white" alt="Documentation"></a>
|
||||||
<a href="https://discord.gg/eR35KQJhPu"><img src="https://img.shields.io/badge/discord-5865F2.svg?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord"></a>
|
<a href="https://discord.gg/eR35KQJhPu"><img src="https://img.shields.io/badge/discord-5865F2.svg?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord"></a>
|
||||||
<a href="https://x.com/psviderski"><img src="https://img.shields.io/badge/follow-black?style=for-the-badge&logo=X&logoColor=while" alt="Follow on X"></a>
|
<a href="https://x.com/psviderski"><img src="https://img.shields.io/badge/follow-black?style=for-the-badge&logo=X&logoColor=while" alt="Follow on X"></a>
|
||||||
<a href="https://github.com/sponsors/psviderski"><img src="https://img.shields.io/badge/Donate-EA4AAA.svg?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="Donate"></a>
|
<a href="https://github.com/sponsors/psviderski"><img src="https://img.shields.io/badge/Donate-EA4AAA.svg?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="Donate"></a>
|
||||||
@@ -25,14 +25,12 @@ complexity of Kubernetes.
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment,
|
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment —
|
||||||
regardless of location or provider.
|
regardless of location or provider.
|
||||||
* **Docker Compose**: Familiar [Docker Compose](https://compose-spec.io/) format for defining services and volumes. No
|
* **Docker Compose**: Familiar [Docker Compose](https://compose-spec.io/) format for defining services and volumes. No
|
||||||
need to learn a new bespoke DSL.
|
need to learn a new bespoke DSL.
|
||||||
* **Zero-downtime deployments**: Rolling updates without service interruption. Automatic rollback on failure is coming
|
* **Zero-downtime deployments**: Rolling updates without service interruption. Automatic rollback on failure is coming
|
||||||
soon.
|
soon.
|
||||||
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Build and push your Docker images directly to
|
|
||||||
your machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
|
|
||||||
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
||||||
* **Persistent storage**: Run stateful services with Docker volumes managed across machines.
|
* **Persistent storage**: Run stateful services with Docker volumes managed across machines.
|
||||||
* **Zero-config private network**: Automatic WireGuard mesh with peer discovery and NAT traversal. Containers get unique
|
* **Zero-config private network**: Automatic WireGuard mesh with peer discovery and NAT traversal. Containers get unique
|
||||||
@@ -40,20 +38,25 @@ complexity of Kubernetes.
|
|||||||
* **No control plane**: Fully decentralised design eliminates single points of failure and reduces operational overhead.
|
* **No control plane**: Fully decentralised design eliminates single points of failure and reduces operational overhead.
|
||||||
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
|
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
|
||||||
model and troubleshooting.
|
model and troubleshooting.
|
||||||
* **Managed DNS**: Automatic DNS records `*.xxxxxx.uncld.dev` for services with public access via managed
|
* **Managed DNS**: Automatic DNS records `*.<id>.cluster.uncloud.run` for services with public access via managed
|
||||||
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
||||||
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
||||||
Encrypt.
|
Encrypt.
|
||||||
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
||||||
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
|
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
|
||||||
|
|
||||||
|
### 🚀 Coming soon
|
||||||
|
|
||||||
|
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Push your Docker images directly to your
|
||||||
|
machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
|
||||||
|
|
||||||
## 🎬 Quick demo
|
## 🎬 Quick demo
|
||||||
|
|
||||||
The screenshot below demonstrates how I use Uncloud to deploy https://uncloud.run website to 2 remote machines from
|
The screenshot below demonstrates how I use Uncloud to deploy the [Uncloud Documentation](https://docs.uncloud.run)
|
||||||
the [`compose.yaml`](website/compose.yaml) file on my local machine.
|
website to 2 remote machines (why not?) from the [`compose.yaml`](docs/compose.yaml) file on my local machine.
|
||||||
|
|
||||||
It exposes the container port `8000/tcp` as HTTPS on the domain `uncloud.run`, served by the Caddy reverse proxy on the
|
It exposes the container port `8000/tcp` as HTTPS on the domain `docs.uncloud.run`, served by the Caddy reverse proxy on
|
||||||
remote machines. All managed by Uncloud.
|
the remote machines. All managed by Uncloud.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -61,15 +64,9 @@ Here is a more advanced use case. Deploy a highly available web app with automat
|
|||||||
on-premises in just a couple minutes.
|
on-premises in just a couple minutes.
|
||||||
|
|
||||||
<a href="https://uncloud.wistia.com/medias/k47uwt9uau?wvideo=k47uwt9uau">
|
<a href="https://uncloud.wistia.com/medias/k47uwt9uau?wvideo=k47uwt9uau">
|
||||||
<img src="https://embed-ssl.wistia.com/deliveries/4fb71ea442905b81b1ad4e25fc02132e.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" />
|
<img src="https://embed-ssl.wistia.com/deliveries/3cf7014a48b93afc556444bed3e39a8c.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
> **📚 Want more examples?** Check out the [**uncloud-recipes**](https://github.com/psviderski/uncloud-recipes)
|
|
||||||
> repository for community recipes and templates for deploying popular services on Uncloud.
|
|
||||||
|
|
||||||
## 💫 Why Uncloud?
|
## 💫 Why Uncloud?
|
||||||
|
|
||||||
Modern cloud platforms like Heroku and Render offer amazing developer experiences but at a premium price. Traditional
|
Modern cloud platforms like Heroku and Render offer amazing developer experiences but at a premium price. Traditional
|
||||||
@@ -99,8 +96,8 @@ platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bar
|
|||||||
# or using curl (macOS/Linux)
|
# or using curl (macOS/Linux)
|
||||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
See [Installation](https://uncloud.run/docs/getting-started/install-cli) for more options.
|
See [Installation](https://docs.uncloud.run/getting-started/install-cli) for more options.
|
||||||
|
|
||||||
2. Initialise your first machine:
|
2. Initialise your first machine:
|
||||||
|
|
||||||
@@ -133,15 +130,15 @@ platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bar
|
|||||||
uncloud-uninstall
|
uncloud-uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
View the [Documentation](https://uncloud.run/docs) for more information.
|
View the [Documentation](https://docs.uncloud.run) for more information.
|
||||||
|
|
||||||
## ⚙️ How it works
|
## ⚙️ How it works
|
||||||
|
|
||||||
Check out the [design document](misc/design.md) to understand Uncloud's design philosophy and goals.
|
Check out the [design document](docs/design.md) to understand Uncloud's design philosophy and goals.
|
||||||
|
|
||||||
Here is a diagram of an Uncloud multi-provider cluster of 3 machines:
|
Here is a diagram of an Uncloud multi-provider cluster of 3 machines:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Peek under the hood to see what happens when you run certain commands.</summary>
|
<summary>Peek under the hood to see what happens when you run certain commands.</summary>
|
||||||
@@ -281,16 +278,6 @@ using the CLI or directly over SSH. They all have the complete cluster state and
|
|||||||
machine is a full backup of your control plane.
|
machine is a full backup of your control plane.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## 🧪 Interactive tutorials
|
|
||||||
|
|
||||||
To give you a chance to play with Uncloud without even leaving your browser, we're providing interactive tutorials and playgrounds on the [iximiuz Labs](https://labs.iximiuz.com/) platform.
|
|
||||||
|
|
||||||
Available tutorials:
|
|
||||||
|
|
||||||
1. [Setting up a new Uncloud cluster](https://labs.iximiuz.com/tutorials/uncloud-create-cluster-ebebf72b) - the tutorial walks you through creating a new cluster with two machines and then deploying a simple web service to it.
|
|
||||||
|
|
||||||
You can also launch the [Uncloud playground](https://labs.iximiuz.com/playgrounds/uncloud-cluster-64523f7c) where you can play with an already initialized Uncloud cluster.
|
|
||||||
|
|
||||||
## 🏗 Project status
|
## 🏗 Project status
|
||||||
|
|
||||||
Uncloud is currently in active development and is **not ready for production use**. Features may change significantly
|
Uncloud is currently in active development and is **not ready for production use**. Features may change significantly
|
||||||
@@ -336,21 +323,8 @@ SQLite database used to share Uncloud's cluster state.
|
|||||||
features, and be the first to know when it's ready for production use.
|
features, and be the first to know when it's ready for production use.
|
||||||
* Watch this repository for releases.
|
* Watch this repository for releases.
|
||||||
|
|
||||||
## 💖 Sponsors
|
|
||||||
|
|
||||||
These companies and projects are helping Uncloud with their generous sponsorship and/or services:
|
|
||||||
|
|
||||||
<!-- Sentry -->
|
|
||||||
<a href="https://sentry.io/welcome/">
|
|
||||||
<img height="100" alt="Sentry" src="https://github.com/user-attachments/assets/6c1439c0-d20d-40dc-a669-c9aa94651dfa" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## ❤️ Contributors
|
## ❤️ Contributors
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/14069">
|
|
||||||
<img alt="Trendshift" src="https://trendshift.io/api/badge/repositories/14069" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
Thank you [@cedws](https://github.com/cedws) for being the first contributor to Uncloud! 🎉
|
Thank you [@cedws](https://github.com/cedws) for being the first contributor to Uncloud! 🎉
|
||||||
|
|
||||||
<a href="https://github.com/psviderski/uncloud/graphs/contributors">
|
<a href="https://github.com/psviderski/uncloud/graphs/contributors">
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ package cluster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/ucind"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewCreateCommand() *cobra.Command {
|
func NewCreateCommand() *cobra.Command {
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ package cluster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/ucind"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRemoveCommand() *cobra.Command {
|
func NewRemoveCommand() *cobra.Command {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewCreateCommand(),
|
NewCreateCommand(),
|
||||||
// NewListCommand(),
|
//NewListCommand(),
|
||||||
NewRemoveCommand(),
|
NewRemoveCommand(),
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -10,104 +10,68 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type buildOptions struct {
|
// NewBuildCommand creates a new command to build services from a Compose file.
|
||||||
cli.BuildServicesOptions
|
|
||||||
files []string
|
|
||||||
profiles []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBuildCommand creates a new command to build images for services from a Compose file.
|
|
||||||
func NewBuildCommand() *cobra.Command {
|
func NewBuildCommand() *cobra.Command {
|
||||||
opts := buildOptions{}
|
opts := cli.BuildOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "build [FLAGS] [SERVICE...]",
|
Use: "build [FLAGS] [SERVICE...]",
|
||||||
Short: "Build services from a Compose file.",
|
Short: "Build services from a Compose file.",
|
||||||
Long: `Build images for services from a Compose file using local Docker.
|
|
||||||
|
|
||||||
By default, built images remain on the local Docker host. Use --push to upload them
|
|
||||||
to cluster machines or --push-registry to upload them to external registries.`,
|
|
||||||
Example: ` # Build all services that have a build section in compose.yaml.
|
|
||||||
uc build
|
|
||||||
|
|
||||||
# Build specific services that have a build section.
|
|
||||||
uc build web api
|
|
||||||
|
|
||||||
# Build services and push images to all cluster machines or service x-machines if specified.
|
|
||||||
uc build --push
|
|
||||||
|
|
||||||
# Build services and push images to specific machines.
|
|
||||||
uc build --push -m machine1,machine2
|
|
||||||
|
|
||||||
# Build services and push images to external registries (e.g., Docker Hub).
|
|
||||||
uc build --push-registry
|
|
||||||
|
|
||||||
# Build services with build arguments, pull newer base images before building, and don't use cache.
|
|
||||||
uc build --build-arg NODE_VERSION=24 --build-arg ENV=production --no-cache --pull`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
opts.Services = args
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
opts.Services = args
|
||||||
|
}
|
||||||
|
|
||||||
return runBuild(cmd.Context(), uncli, opts)
|
return runBuild(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
GroupID: "service",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringArrayVar(&opts.BuildArgs, "build-arg", nil,
|
cmd.Flags().StringSliceVarP(&opts.Files, "file", "f", nil,
|
||||||
"Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.\n"+
|
"One or more Compose files to build (default compose.yaml)")
|
||||||
"Can be specified multiple times. Format: --build-arg VAR=VALUE")
|
cmd.Flags().StringSliceVarP(&opts.Profiles, "profile", "p", nil,
|
||||||
cmd.Flags().BoolVar(&opts.Check, "check", false,
|
|
||||||
"Check the build configuration for services without building them.")
|
|
||||||
cmd.Flags().BoolVar(&opts.Deps, "deps", false,
|
|
||||||
"Also build services declared as dependencies of the selected services.")
|
|
||||||
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
|
||||||
"One or more Compose files to build. (default compose.yaml)")
|
|
||||||
cmd.Flags().StringSliceVarP(&opts.Machines, "machine", "m", nil,
|
|
||||||
"Machine names or IDs to push the built images to (requires --push).\n"+
|
|
||||||
"Can be specified multiple times or as a comma-separated list. (default is all machines or x-machines)")
|
|
||||||
cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false,
|
|
||||||
"Do not use cache when building images.")
|
|
||||||
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
|
||||||
"One or more Compose profiles to enable.")
|
"One or more Compose profiles to enable.")
|
||||||
cmd.Flags().BoolVar(&opts.Pull, "pull", false,
|
cmd.Flags().BoolVarP(&opts.Push, "push", "P", false,
|
||||||
"Always attempt to pull newer versions of base images before building.")
|
"Push built images to the registry after building. (default false)")
|
||||||
cmd.Flags().BoolVar(&opts.PushCluster, "push", false,
|
cmd.Flags().BoolVarP(&opts.NoCache, "no-cache", "n", false,
|
||||||
"Upload the built images to cluster machines after building.\n"+
|
"Do not use cache when building images. (default false)")
|
||||||
"Use --machine to specify which machines. (default is all machines)")
|
|
||||||
cmd.Flags().BoolVar(&opts.PushRegistry, "push-registry", false,
|
|
||||||
"Upload the built images to external registries (e.g., Docker Hub) after building.")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// runBuild parses the Compose file(s) and builds the images for selected services.
|
// TODO: deduplicate with a similar functino for deploy options
|
||||||
func runBuild(ctx context.Context, uncli *cli.CLI, opts buildOptions) error {
|
// projectOpts returns the project options for the Compose file(s).
|
||||||
// Validate push flags.
|
func projectOptsFromBuildOpts(opts cli.BuildOptions) []composecli.ProjectOptionsFn {
|
||||||
if opts.PushCluster && opts.PushRegistry {
|
projectOpts := []composecli.ProjectOptionsFn{}
|
||||||
return fmt.Errorf("cannot specify both --push and --push-registry: choose one push target")
|
|
||||||
|
if len(opts.Profiles) > 0 {
|
||||||
|
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.Profiles...))
|
||||||
}
|
}
|
||||||
|
|
||||||
machines := cli.ExpandCommaSeparatedValues(opts.Machines)
|
return projectOpts
|
||||||
// Special handling for an explicit "all" keyword to push to all machines.
|
}
|
||||||
if len(machines) == 1 && machines[0] == "all" {
|
|
||||||
machines = nil
|
|
||||||
}
|
|
||||||
opts.Machines = machines
|
|
||||||
|
|
||||||
project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...))
|
// runBuild parses the Compose file(s), builds the services, and pushes them if requested.
|
||||||
|
func runBuild(ctx context.Context, uncli *cli.CLI, opts cli.BuildOptions) error {
|
||||||
|
projectOpts := projectOptsFromBuildOpts(opts)
|
||||||
|
project, err := compose.LoadProject(ctx, opts.Files, projectOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load compose file(s): %w", err)
|
return fmt.Errorf("load compose file(s): %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
|
if len(opts.Services) > 0 {
|
||||||
|
project, err = project.WithSelectedServices(opts.Services)
|
||||||
servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.Services, opts.Deps)
|
if err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("select services: %w", err)
|
||||||
return fmt.Errorf("determine services to build: %w", err)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
servicesToBuild := cli.GetServicesThatNeedBuild(project)
|
||||||
|
|
||||||
if len(servicesToBuild) == 0 {
|
if len(servicesToBuild) == 0 {
|
||||||
fmt.Println("No services to build.")
|
fmt.Println("No services to build.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return uncli.BuildServices(ctx, project, opts.BuildServicesOptions)
|
return cli.BuildServices(ctx, servicesToBuild, opts)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
package caddy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/alecthomas/chroma/v2/quick"
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
type configOptions struct {
|
|
||||||
machine string
|
|
||||||
noColor bool
|
|
||||||
}
|
|
||||||
|
|
||||||
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.")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
|
||||||
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 = clusterClient.ProxyMachinesContext(ctx, []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,15 +5,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"os"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
"github.com/docker/cli/cli/streams"
|
"github.com/docker/cli/cli/streams"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
@@ -21,9 +18,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type deployOptions struct {
|
type deployOptions struct {
|
||||||
caddyfile string
|
image string
|
||||||
image string
|
machines []string
|
||||||
machines []string
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDeployCommand() *cobra.Command {
|
func NewDeployCommand() *cobra.Command {
|
||||||
@@ -40,28 +37,21 @@ 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,
|
||||||
"Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated "+
|
"Machine names to deploy to. Can be specified multiple times or as a comma-separated "+
|
||||||
"list. (default is all machines)")
|
"list of machine names. (default is all machines)")
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context to deploy to. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||||
caddyfile := ""
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -72,11 +62,9 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
if !errors.Is(err, api.ErrNotFound) {
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
return fmt.Errorf("inspect caddy service: %w", err)
|
return fmt.Errorf("inspect caddy service: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(client.CaddyServiceName) +
|
fmt.Printf("Service: %s (not running)\n", client.CaddyServiceName)
|
||||||
tui.Faint.Render(" (not running)"))
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(svc.Name) +
|
fmt.Printf("Service: %s (%s mode)\n", svc.Name, svc.Mode)
|
||||||
tui.Faint.Render(" ("+svc.Mode+" mode)"))
|
|
||||||
|
|
||||||
// Collect unique images of all containers in the running caddy service.
|
// Collect unique images of all containers in the running caddy service.
|
||||||
images := make(map[string]struct{}, len(svc.Containers))
|
images := make(map[string]struct{}, len(svc.Containers))
|
||||||
@@ -86,19 +74,15 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
currentImages := slices.Collect(maps.Keys(images))
|
currentImages := slices.Collect(maps.Keys(images))
|
||||||
|
|
||||||
if len(currentImages) > 1 {
|
if len(currentImages) > 1 {
|
||||||
formattedImages := make([]string, len(currentImages))
|
commaSeparatedImages := strings.Join(currentImages, ", ")
|
||||||
for i, img := range currentImages {
|
fmt.Printf("Current images (multiple versions detected): %s\n", commaSeparatedImages)
|
||||||
formattedImages[i] = tui.FormatImage(img, lipgloss.NewStyle())
|
|
||||||
}
|
|
||||||
fmt.Println(tui.Faint.Render("current images (multiple versions detected): ") +
|
|
||||||
strings.Join(formattedImages, tui.Faint.Render(", ")))
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Println(tui.Faint.Render("current image: ") + tui.FormatImage(currentImages[0], lipgloss.NewStyle()))
|
fmt.Printf("Current image: %s\n", currentImages[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.image != "" {
|
if opts.image != "" {
|
||||||
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(opts.image, tui.Green))
|
fmt.Printf("Target image: %s\n", opts.image)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
@@ -107,14 +91,13 @@ 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, caddyfile, placement)
|
d, err := clusterClient.NewCaddyDeployment(opts.image, placement)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.image == "" {
|
if opts.image == "" {
|
||||||
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(d.Spec.Container.Image,
|
fmt.Printf("Target image: %s (latest stable)\n", d.Spec.Container.Image)
|
||||||
tui.Green) + tui.Faint.Render(" (latest stable)"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, err := d.Plan(ctx)
|
plan, err := d.Plan(ctx)
|
||||||
@@ -133,41 +116,18 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
// Initialise a machine and container name resolver to properly format the plan output.
|
||||||
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
|
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
|
||||||
fmt.Println()
|
if err != nil {
|
||||||
|
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||||
directConn := uncli.DirectConnection()
|
|
||||||
contextName := uncli.ContextOverrideOrCurrent()
|
|
||||||
deployTarget := ""
|
|
||||||
if directConn != "" {
|
|
||||||
deployTarget = directConn
|
|
||||||
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
|
|
||||||
fmt.Println()
|
|
||||||
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
|
|
||||||
// Only show context if there's more than one to avoid unnecessary clutter.
|
|
||||||
deployTarget = contextName
|
|
||||||
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
|
|
||||||
fmt.Println()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(plan.Format())
|
fmt.Println()
|
||||||
|
fmt.Println("Deployment plan:")
|
||||||
summary := plan.FormatSummary()
|
fmt.Println(plan.Format(resolver))
|
||||||
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
|
|
||||||
fmt.Println(summary)
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
title := "Proceed with deployment?"
|
confirmed, err := cli.Confirm()
|
||||||
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
|
|
||||||
// deploying to the wrong cluster.
|
|
||||||
if deployTarget != "" {
|
|
||||||
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
|
||||||
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
|
|
||||||
title = "Proceed with deployment to " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?")
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmed, err := tui.Confirm(title)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("confirm deployment: %w", err)
|
return fmt.Errorf("confirm deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
package context
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"charm.land/huh/v2"
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
|
||||||
"github.com/psviderski/uncloud/internal/cli/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewConnectionCommand() *cobra.Command {
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "connection",
|
|
||||||
Aliases: []string{"conn"},
|
|
||||||
Short: "Choose a new default connection for the current context.",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
|
||||||
return selectConnection(uncli)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectConnection(uncli *cli.CLI) error {
|
|
||||||
if uncli.Config == nil {
|
|
||||||
return fmt.Errorf("connection management is not available: Uncloud configuration file is not being used")
|
|
||||||
}
|
|
||||||
if len(uncli.Config.Contexts) == 0 {
|
|
||||||
return fmt.Errorf("no contexts found in Uncloud config (%s)", uncli.Config.Path())
|
|
||||||
}
|
|
||||||
|
|
||||||
currentCtxName := uncli.Config.CurrentContext
|
|
||||||
currentCtx, ok := uncli.Config.Contexts[currentCtxName]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("current context '%s' not found", currentCtxName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(currentCtx.Connections) == 0 {
|
|
||||||
return fmt.Errorf("no connections found in context '%s'", currentCtxName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectedConnection int
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[int]().
|
|
||||||
Title("Select a default connection").
|
|
||||||
Options(buildConnectionOptions(currentCtx.Connections)...).
|
|
||||||
Value(&selectedConnection),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return fmt.Errorf("select connection: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentCtx.SetDefaultConnection(selectedConnection)
|
|
||||||
selected := currentCtx.Connections[0]
|
|
||||||
|
|
||||||
if err := uncli.Config.Save(); err != nil {
|
|
||||||
return fmt.Errorf("save config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Default connection for context '%s' is now '%s'.\n", currentCtxName, selected.String())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildConnectionOptions(connections []config.MachineConnection) []huh.Option[int] {
|
|
||||||
options := make([]huh.Option[int], len(connections))
|
|
||||||
for i, conn := range connections {
|
|
||||||
key := conn.String()
|
|
||||||
opt := huh.NewOption(key, i)
|
|
||||||
if i == 0 {
|
|
||||||
opt.Key += " (default)"
|
|
||||||
opt = opt.Selected(true)
|
|
||||||
}
|
|
||||||
options[i] = opt
|
|
||||||
}
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
|
var clusterContext string
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
Aliases: []string{"list"},
|
Aliases: []string{"list"},
|
||||||
@@ -22,6 +24,11 @@ func NewListCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&clusterContext, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +51,7 @@ func list(uncli *cli.CLI) error {
|
|||||||
for _, name := range contextNames {
|
for _, name := range contextNames {
|
||||||
current := ""
|
current := ""
|
||||||
if name == currentContext {
|
if name == currentContext {
|
||||||
current = "✓"
|
current = "*"
|
||||||
}
|
}
|
||||||
connCount := len(uncli.Config.Contexts[name].Connections)
|
connCount := len(uncli.Config.Contexts[name].Connections)
|
||||||
fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
|
fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ func NewRootCommand() *cobra.Command {
|
|||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewListCommand(),
|
NewListCommand(),
|
||||||
NewUseCommand(),
|
NewUseCommand(),
|
||||||
NewConnectionCommand(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"maps"
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"charm.land/huh/v2"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"strings"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
@@ -18,15 +17,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type deployOptions struct {
|
type deployOptions struct {
|
||||||
cli.BuildServicesOptions
|
files []string
|
||||||
|
profiles []string
|
||||||
|
services []string
|
||||||
|
noBuild bool
|
||||||
|
|
||||||
files []string
|
context string
|
||||||
profiles []string
|
|
||||||
services []string
|
|
||||||
noBuild bool
|
|
||||||
recreate bool
|
|
||||||
skipHealth bool
|
|
||||||
yes bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDeployCommand creates a new command to deploy services from a Compose file.
|
// NewDeployCommand creates a new command to deploy services from a Compose file.
|
||||||
@@ -36,38 +32,24 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
Use: "deploy [FLAGS] [SERVICE...]",
|
Use: "deploy [FLAGS] [SERVICE...]",
|
||||||
Short: "Deploy services from a Compose file.",
|
Short: "Deploy services from a Compose file.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
|
||||||
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
opts.services = args
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
opts.services = args
|
||||||
|
}
|
||||||
|
|
||||||
return runDeploy(cmd.Context(), uncli, opts)
|
return runDeploy(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
GroupID: "service",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringArrayVar(&opts.BuildServicesOptions.BuildArgs, "build-arg", nil,
|
|
||||||
"Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.\n"+
|
|
||||||
"Can be specified multiple times. Format: --build-arg VAR=VALUE")
|
|
||||||
cmd.Flags().BoolVar(&opts.BuildServicesOptions.Pull, "build-pull", false,
|
|
||||||
"Always attempt to pull newer versions of base images before building service images.")
|
|
||||||
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||||
"One or more Compose files to deploy services from. (default compose.yaml)")
|
"One or more Compose files to deploy services from. (default compose.yaml)")
|
||||||
cmd.Flags().BoolVar(&opts.noBuild, "no-build", false,
|
|
||||||
"Do not build new images before deploying services.")
|
|
||||||
cmd.Flags().BoolVar(&opts.BuildServicesOptions.NoCache, "no-cache", false,
|
|
||||||
"Do not use cache when building images.")
|
|
||||||
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
||||||
"One or more Compose profiles to enable.")
|
"One or more Compose profiles to enable.")
|
||||||
cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
"Recreate containers even if their configuration and image haven't changed.")
|
"Name of the cluster context to deploy to (default is the current context)")
|
||||||
cmd.Flags().BoolVar(&opts.skipHealth, "skip-health", false,
|
cmd.Flags().BoolVarP(&opts.noBuild, "no-build", "n", false,
|
||||||
"Skip the monitoring period and health checks after starting new containers. Useful for faster emergency "+
|
"Do not build images before deploying services. (default false)")
|
||||||
"deployments.\n"+
|
|
||||||
"Warning: This may cause downtime if new containers fail to start properly.")
|
|
||||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
|
||||||
"Auto-confirm deployment plan. Should be explicitly set when running non-interactively,\n"+
|
|
||||||
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
|
||||||
|
|
||||||
// TODO: Consider adding a filter flag to specify which machines to deploy to but keep the rest running.
|
// TODO: Consider adding a filter flag to specify which machines to deploy to but keep the rest running.
|
||||||
// Could be useful to test a new version on a subset of machines before rolling out to all.
|
// Could be useful to test a new version on a subset of machines before rolling out to all.
|
||||||
@@ -75,15 +57,26 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// projectOpts returns the project options for the Compose file(s).
|
||||||
|
func projectOpts(opts deployOptions) []composecli.ProjectOptionsFn {
|
||||||
|
projectOpts := []composecli.ProjectOptionsFn{}
|
||||||
|
|
||||||
|
if len(opts.profiles) > 0 {
|
||||||
|
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.profiles...))
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectOpts
|
||||||
|
}
|
||||||
|
|
||||||
// runDeploy parses the Compose file(s) and deploys the services.
|
// runDeploy parses the Compose file(s) and deploys the services.
|
||||||
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||||
project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...))
|
projectOpts := projectOpts(opts)
|
||||||
|
|
||||||
|
project, err := compose.LoadProject(ctx, opts.files, projectOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load compose file(s): %w", err)
|
return fmt.Errorf("load compose file(s): %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
|
|
||||||
|
|
||||||
if len(opts.services) > 0 {
|
if len(opts.services) > 0 {
|
||||||
// Includes service dependencies by default. This is the default docker compose behavior.
|
// Includes service dependencies by default. This is the default docker compose behavior.
|
||||||
project, err = project.WithSelectedServices(opts.services)
|
project, err = project.WithSelectedServices(opts.services)
|
||||||
@@ -92,74 +85,30 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.services, false)
|
servicesToBuild := cli.GetServicesThatNeedBuild(project)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("determine services to build: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(servicesToBuild) > 0 {
|
if len(servicesToBuild) > 0 {
|
||||||
if opts.noBuild {
|
if opts.noBuild {
|
||||||
fmt.Println("Not building services as requested.")
|
fmt.Println("Not building services as requested.")
|
||||||
} else {
|
} else {
|
||||||
// Build service images without pushing them to cluster yet to not connect to the cluster twice.
|
buildOpts := cli.BuildOptions{
|
||||||
opts.BuildServicesOptions.Deps = true // build dependencies as deploy includes them by default
|
Push: true,
|
||||||
opts.BuildServicesOptions.Services = opts.services
|
NoCache: false,
|
||||||
|
}
|
||||||
|
|
||||||
if err = uncli.BuildServices(ctx, project, opts.BuildServicesOptions); err != nil {
|
if err := cli.BuildServices(ctx, servicesToBuild, buildOpts); err != nil {
|
||||||
return fmt.Errorf("build services: %w", err)
|
return fmt.Errorf("build services: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
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)
|
||||||
}
|
}
|
||||||
defer clusterClient.Close()
|
defer clusterClient.Close()
|
||||||
|
|
||||||
if len(servicesToBuild) > 0 && !opts.noBuild {
|
composeDeploy, err := compose.NewDeployment(ctx, clusterClient, project)
|
||||||
// Push built service images to cluster machines one at a time.
|
|
||||||
var errs []error
|
|
||||||
for _, s := range servicesToBuild {
|
|
||||||
if s.Image == "" {
|
|
||||||
// Skip services without an image name (shouldn't happen for services with build config).
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push to the specified x-machines or to *all* cluster machines if not specified.
|
|
||||||
var pushOpts client.PushImageOptions
|
|
||||||
if machines, ok := s.Extensions[compose.MachinesExtensionKey].(compose.MachinesSource); ok {
|
|
||||||
pushOpts.Machines = machines
|
|
||||||
}
|
|
||||||
if len(pushOpts.Machines) == 0 {
|
|
||||||
pushOpts.AllMachines = true
|
|
||||||
}
|
|
||||||
|
|
||||||
boldStyle := lipgloss.NewStyle().Bold(true)
|
|
||||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
|
||||||
if err = clusterClient.PushImage(ctx, s.Image, pushOpts); err != nil {
|
|
||||||
return fmt.Errorf("push image '%s' for service '%s': %w", s.Image, s.Name, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", boldStyle.Render(s.Image)))
|
|
||||||
// Collect errors to try pushing all images.
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = errors.Join(errs...); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
strategy := &deploy.RollingStrategy{
|
|
||||||
ForceRecreate: opts.recreate,
|
|
||||||
SkipHealthMonitor: opts.skipHealth,
|
|
||||||
}
|
|
||||||
composeDeploy, err := compose.NewDeploymentWithStrategy(ctx, clusterClient, project, strategy)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create compose deployment: %w", err)
|
return fmt.Errorf("create compose deployment: %w", err)
|
||||||
}
|
}
|
||||||
@@ -169,64 +118,63 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
return fmt.Errorf("plan deployment: %w", err)
|
return fmt.Errorf("plan deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if plan.IsEmpty() {
|
if len(plan.Operations) == 0 {
|
||||||
fmt.Println("Services are up to date.")
|
fmt.Println("Services are up to date.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
|
fmt.Println("Deployment plan:")
|
||||||
|
if err = printPlan(ctx, clusterClient, plan); err != nil {
|
||||||
|
return fmt.Errorf("print deployment plan: %w", err)
|
||||||
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
directConn := uncli.DirectConnection()
|
confirmed, err := cli.Confirm()
|
||||||
contextName := uncli.ContextOverrideOrCurrent()
|
if err != nil {
|
||||||
deployTarget := ""
|
return fmt.Errorf("confirm deployment: %w", err)
|
||||||
if directConn != "" {
|
}
|
||||||
deployTarget = directConn
|
if !confirmed {
|
||||||
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
|
fmt.Println("Cancelled. No changes were made.")
|
||||||
fmt.Println()
|
return nil
|
||||||
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
|
|
||||||
// Only show context if there's more than one to avoid unnecessary clutter.
|
|
||||||
deployTarget = contextName
|
|
||||||
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
|
|
||||||
fmt.Println()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(plan.Format())
|
|
||||||
|
|
||||||
// Ask for plan confirmation before proceeding with the deployment unless auto-confirmed with --yes.
|
|
||||||
if !opts.yes {
|
|
||||||
if !tui.IsStdinTerminal() {
|
|
||||||
return errors.New("cannot ask to confirm deployment plan in non-interactive mode, " +
|
|
||||||
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
|
|
||||||
}
|
|
||||||
|
|
||||||
title := "Proceed with deployment?"
|
|
||||||
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
|
|
||||||
// deploying to the wrong cluster.
|
|
||||||
if deployTarget != "" {
|
|
||||||
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
|
||||||
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
|
|
||||||
title = "Proceed with deployment to " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?")
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmed, err := tui.Confirm(title)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("confirm deployment: %w", err)
|
|
||||||
}
|
|
||||||
if !confirmed {
|
|
||||||
fmt.Println("Cancelled. No changes were made.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
title := "Deploying"
|
|
||||||
if deployTarget != "" {
|
|
||||||
title += " to " + tui.NameStyle.Render(deployTarget)
|
|
||||||
}
|
|
||||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
if err := plan.Execute(ctx, clusterClient); err != nil {
|
if err := plan.Execute(ctx, clusterClient); err != nil {
|
||||||
return fmt.Errorf("deploy services: %w", err)
|
return fmt.Errorf("deploy services: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, uncli.ProgressOut(), title)
|
}, uncli.ProgressOut(), "Deploying services")
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPlan(ctx context.Context, cli *client.Client, plan deploy.SequenceOperation) error {
|
||||||
|
for _, op := range plan.Operations {
|
||||||
|
svcPlan, ok := op.(*deploy.Plan)
|
||||||
|
if !ok {
|
||||||
|
fmt.Println("- " + op.Format(nil))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := cli.InspectService(ctx, svcPlan.ServiceID)
|
||||||
|
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||||
|
return fmt.Errorf("inspect service: %w", err)
|
||||||
|
}
|
||||||
|
// Initialise a machine and container name resolver to properly format the service plan output.
|
||||||
|
resolver, err := cli.ServiceOperationNameResolver(ctx, svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("- Deploy service [name=%s]\n", svcPlan.ServiceName)
|
||||||
|
fmt.Println(indent(svcPlan.Format(resolver), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func indent(text, prefix string) string {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
lines[i] = prefix + line
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,32 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type releaseOptions struct {
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
func NewReleaseCommand() *cobra.Command {
|
func NewReleaseCommand() *cobra.Command {
|
||||||
|
opts := releaseOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "release",
|
Use: "release",
|
||||||
Short: "Release the reserved cluster domain.",
|
Short: "Release the reserved cluster domain.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return release(cmd.Context(), uncli)
|
return release(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func release(ctx context.Context, uncli *cli.CLI) error {
|
func release(ctx context.Context, uncli *cli.CLI, opts releaseOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
client, 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const DefaultUncloudDNSAPIEndpoint = "https://dns.uncloud.run/v1"
|
|||||||
|
|
||||||
type reserveOptions struct {
|
type reserveOptions struct {
|
||||||
endpoint string
|
endpoint string
|
||||||
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReserveCommand() *cobra.Command {
|
func NewReserveCommand() *cobra.Command {
|
||||||
@@ -35,12 +36,16 @@ func NewReserveCommand() *cobra.Command {
|
|||||||
|
|
||||||
cmd.Flags().StringVar(&opts.endpoint, "endpoint", DefaultUncloudDNSAPIEndpoint,
|
cmd.Flags().StringVar(&opts.endpoint, "endpoint", DefaultUncloudDNSAPIEndpoint,
|
||||||
"API endpoint for the Uncloud DNS service.")
|
"API endpoint for the Uncloud DNS service.")
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
Use: "dns",
|
Use: "dns",
|
||||||
Short: "Manage cluster domain in Uncloud DNS.",
|
Short: "Manage cluster domain in Uncloud DNS.",
|
||||||
Long: "Manage cluster domain in Uncloud DNS.\n" +
|
Long: "Manage cluster domain in Uncloud DNS.\n" +
|
||||||
"DNS commands allow you to reserve or release a unique 'xxxxxx.uncld.dev' domain for your " +
|
"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 " +
|
"cluster. When reserved, Caddy service deployments will automatically update DNS records to route " +
|
||||||
"traffic to the services in the cluster.",
|
"traffic to the services in the cluster.",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,21 +10,32 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type showOptions struct {
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
func NewShowCommand() *cobra.Command {
|
func NewShowCommand() *cobra.Command {
|
||||||
|
opts := showOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "show",
|
Use: "show",
|
||||||
Short: "Print the cluster domain name.",
|
Short: "Print the cluster domain name.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return show(cmd.Context(), uncli)
|
return show(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func show(ctx context.Context, uncli *cli.CLI) error {
|
func show(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
package image
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"slices"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
"charm.land/lipgloss/v2/table"
|
|
||||||
"github.com/charmbracelet/colorprofile"
|
|
||||||
"github.com/containerd/platforms"
|
|
||||||
"github.com/docker/docker/api/types/image"
|
|
||||||
"github.com/docker/go-units"
|
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
type listOptions struct {
|
|
||||||
machines []string
|
|
||||||
nameFilter string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
|
||||||
opts := listOptions{}
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "ls [REPO:[TAG]]",
|
|
||||||
Aliases: []string{"list"},
|
|
||||||
Short: "List images on machines in the cluster.",
|
|
||||||
Long: "List images on machines in the cluster. By default, on all machines. Optionally filter by image name.",
|
|
||||||
Example: ` # List all images on all machines.
|
|
||||||
uc image ls
|
|
||||||
|
|
||||||
# List images on specific machine.
|
|
||||||
uc image ls -m machine1
|
|
||||||
|
|
||||||
# List images on multiple machines.
|
|
||||||
uc image ls -m machine1,machine2
|
|
||||||
|
|
||||||
# List images filtered by name (with any tag) on all machines.
|
|
||||||
uc image ls myapp
|
|
||||||
|
|
||||||
# List images filtered by name pattern on specific machine.
|
|
||||||
uc image ls "myapp:1.*" -m machine1`,
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
if len(args) > 0 {
|
|
||||||
opts.nameFilter = args[0]
|
|
||||||
}
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
|
||||||
return list(cmd.Context(), uncli, opts)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
|
||||||
"Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. "+
|
|
||||||
"(default is include all machines)")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// imageRow represents a single image with its metadata for display.
|
|
||||||
type imageRow struct {
|
|
||||||
id string
|
|
||||||
name string
|
|
||||||
platforms string
|
|
||||||
createdHuman string
|
|
||||||
createdUnix int64
|
|
||||||
size string
|
|
||||||
inUse string
|
|
||||||
store string
|
|
||||||
machine string
|
|
||||||
}
|
|
||||||
|
|
||||||
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
|
||||||
}
|
|
||||||
defer clusterClient.Close()
|
|
||||||
|
|
||||||
// Get all machines to create ID to name mapping.
|
|
||||||
allMachines, err := clusterClient.ListMachines(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("list machines: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
machineIDToName := make(map[string]string)
|
|
||||||
for _, machineMember := range allMachines {
|
|
||||||
if machineMember.Machine != nil && machineMember.Machine.Id != "" && machineMember.Machine.Name != "" {
|
|
||||||
machineIDToName[machineMember.Machine.Id] = machineMember.Machine.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
|
||||||
|
|
||||||
clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{
|
|
||||||
Machines: machines,
|
|
||||||
Name: opts.nameFilter,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("list images: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all images from all machines.
|
|
||||||
var rows []imageRow
|
|
||||||
|
|
||||||
for _, machineImages := range clusterImages {
|
|
||||||
// Get machine name for better readability.
|
|
||||||
machineName := machineImages.Metadata.Machine
|
|
||||||
if m := allMachines.FindByNameOrID(machineName); m != nil {
|
|
||||||
machineName = m.Machine.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
store := "docker"
|
|
||||||
if machineImages.ContainerdStore {
|
|
||||||
store = "containerd"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process each image for this machine.
|
|
||||||
for _, img := range machineImages.Images {
|
|
||||||
// Show the first 12 chars without 'sha256:' as the image ID like Docker does.
|
|
||||||
id := strings.TrimPrefix(img.ID, "sha256:")[:12]
|
|
||||||
|
|
||||||
name := "<none>"
|
|
||||||
if len(img.RepoTags) > 0 && img.RepoTags[0] != "<none>:<none>" {
|
|
||||||
name = img.RepoTags[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
imgPlatforms, _ := imagePlatforms(img)
|
|
||||||
formattedPlatforms := formatPlatforms(imgPlatforms)
|
|
||||||
|
|
||||||
created := ""
|
|
||||||
createdAt := time.Unix(img.Created, 0)
|
|
||||||
if !createdAt.IsZero() {
|
|
||||||
created = units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
|
|
||||||
}
|
|
||||||
|
|
||||||
size := units.HumanSizeWithPrecision(float64(img.Size), 3)
|
|
||||||
|
|
||||||
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
|
|
||||||
inUse := "-"
|
|
||||||
if img.Containers != -1 { // -1 means the info is not available.
|
|
||||||
if img.Containers > 0 {
|
|
||||||
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
|
|
||||||
} else {
|
|
||||||
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows = append(rows, imageRow{
|
|
||||||
id: id,
|
|
||||||
name: name,
|
|
||||||
platforms: formattedPlatforms,
|
|
||||||
createdHuman: created,
|
|
||||||
createdUnix: img.Created,
|
|
||||||
size: size,
|
|
||||||
inUse: inUse,
|
|
||||||
store: store,
|
|
||||||
machine: machineName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rows) == 0 {
|
|
||||||
if opts.nameFilter != "" {
|
|
||||||
fmt.Printf("No images matching '%s' found.\n", opts.nameFilter)
|
|
||||||
} else {
|
|
||||||
fmt.Println("No images found.")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort images by name, then by machine name.
|
|
||||||
sort.Slice(rows, func(i, j int) bool {
|
|
||||||
if rows[i].name != rows[j].name {
|
|
||||||
return rows[i].name < rows[j].name
|
|
||||||
}
|
|
||||||
return rows[i].machine < rows[j].machine
|
|
||||||
})
|
|
||||||
|
|
||||||
// Print the images in a table format.
|
|
||||||
fmt.Println(formatImageTable(rows))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform.
|
|
||||||
func imagePlatforms(img image.Summary) ([]string, bool) {
|
|
||||||
var formattedPlatforms []string
|
|
||||||
multiPlatform := false
|
|
||||||
|
|
||||||
for _, m := range img.Manifests {
|
|
||||||
if m.Kind != image.ManifestKindImage || !m.Available {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.ID != img.ID {
|
|
||||||
// There is an image manifest that has digest different from the main image digest.
|
|
||||||
// This means the image manifest is an index or a manifest list (multi-platform image).
|
|
||||||
multiPlatform = true
|
|
||||||
}
|
|
||||||
formattedPlatforms = append(formattedPlatforms, platforms.Format(m.ImageData.Platform))
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.Sort(formattedPlatforms)
|
|
||||||
|
|
||||||
return formattedPlatforms, multiPlatform
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatPlatforms(platforms []string) string {
|
|
||||||
if len(platforms) == 0 {
|
|
||||||
return "-"
|
|
||||||
}
|
|
||||||
|
|
||||||
platformStyle := lipgloss.NewStyle().
|
|
||||||
BorderForeground(lipgloss.Color("152")).
|
|
||||||
Foreground(lipgloss.Color("0")).
|
|
||||||
Background(lipgloss.Color("152"))
|
|
||||||
// Use fancy pill borders only if the output is a terminal with color support.
|
|
||||||
if colorprofile.Detect(os.Stdout, os.Environ()) > colorprofile.ASCII {
|
|
||||||
platformStyle = platformStyle.Border(lipgloss.Border{Left: "", Right: ""}, false, true, false, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
styledPlatforms := make([]string, len(platforms))
|
|
||||||
for i, p := range platforms {
|
|
||||||
styledPlatforms[i] = platformStyle.Render(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(styledPlatforms, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatImageTable(rows []imageRow) string {
|
|
||||||
columns := []struct {
|
|
||||||
name string
|
|
||||||
hide bool
|
|
||||||
}{
|
|
||||||
{name: "IMAGE ID"},
|
|
||||||
{name: "NAME"},
|
|
||||||
{name: "PLATFORMS"},
|
|
||||||
{name: "CREATED"},
|
|
||||||
{name: "SIZE"},
|
|
||||||
{name: "IN USE"},
|
|
||||||
{name: "STORE"},
|
|
||||||
{name: "MACHINE"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the "IN USE" column if none of the images have that info available.
|
|
||||||
inUseInfoAvailable := slices.ContainsFunc(rows, func(r imageRow) bool {
|
|
||||||
return r.inUse != "-"
|
|
||||||
})
|
|
||||||
if !inUseInfoAvailable {
|
|
||||||
// Hide "IN USE" column.
|
|
||||||
columns[5].hide = true
|
|
||||||
}
|
|
||||||
|
|
||||||
t := table.New().
|
|
||||||
// Remove the default border.
|
|
||||||
Border(lipgloss.Border{}).
|
|
||||||
BorderTop(false).
|
|
||||||
BorderBottom(false).
|
|
||||||
BorderLeft(false).
|
|
||||||
BorderRight(false).
|
|
||||||
BorderHeader(false).
|
|
||||||
BorderColumn(false).
|
|
||||||
StyleFunc(func(row, col int) lipgloss.Style {
|
|
||||||
if row == table.HeaderRow {
|
|
||||||
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
|
|
||||||
}
|
|
||||||
// Regular style for data rows with padding.
|
|
||||||
return lipgloss.NewStyle().PaddingRight(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
var headers []string
|
|
||||||
for _, col := range columns {
|
|
||||||
if !col.hide {
|
|
||||||
headers = append(headers, col.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Headers(headers...)
|
|
||||||
|
|
||||||
for _, row := range rows {
|
|
||||||
values := []string{
|
|
||||||
row.id,
|
|
||||||
row.name,
|
|
||||||
row.platforms,
|
|
||||||
row.createdHuman,
|
|
||||||
row.size,
|
|
||||||
row.inUse,
|
|
||||||
row.store,
|
|
||||||
row.machine,
|
|
||||||
}
|
|
||||||
var filteredValues []string
|
|
||||||
for i, v := range values {
|
|
||||||
if !columns[i].hide {
|
|
||||||
filteredValues = append(filteredValues, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Row(filteredValues...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return t.String()
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package image
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/containerd/platforms"
|
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
type pushOptions struct {
|
|
||||||
image string
|
|
||||||
machines []string
|
|
||||||
platform string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPushCommand() *cobra.Command {
|
|
||||||
opts := pushOptions{}
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "push IMAGE",
|
|
||||||
Short: "Upload a local Docker image to the cluster.",
|
|
||||||
Long: `Upload a local Docker image to the cluster transferring only the missing layers.
|
|
||||||
The image is uploaded to all cluster machines (default) or the specified machine(s).`,
|
|
||||||
Example: ` # Push image to all machines in the cluster.
|
|
||||||
uc image push myapp:latest
|
|
||||||
|
|
||||||
# Push image to specific machine.
|
|
||||||
uc image push myapp:latest -m machine1
|
|
||||||
|
|
||||||
# Push image to multiple machines.
|
|
||||||
uc image push myapp:latest -m machine1,machine2,machine3
|
|
||||||
|
|
||||||
# Push a specific platform of a multi-platform image.
|
|
||||||
uc image push myapp:latest --platform linux/amd64`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
|
||||||
|
|
||||||
opts.image = args[0]
|
|
||||||
return push(cmd.Context(), uncli, opts)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
|
||||||
"Machine names or IDs to push the image to. Can be specified multiple times or as a comma-separated list. "+
|
|
||||||
"(default is all machines)")
|
|
||||||
cmd.Flags().StringVar(
|
|
||||||
&opts.platform, "platform", "",
|
|
||||||
"Push a specific platform of a multi-platform image (e.g., linux/amd64, linux/arm64).\n"+
|
|
||||||
"Local Docker must be configured to use containerd image store to support multi-platform images.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func push(ctx context.Context, uncli *cli.CLI, opts pushOptions) error {
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
|
||||||
}
|
|
||||||
defer clusterClient.Close()
|
|
||||||
|
|
||||||
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
|
||||||
pushOpts := client.PushImageOptions{}
|
|
||||||
|
|
||||||
// Special handling for an explicit "all" keyword to push to all machines.
|
|
||||||
if len(machines) == 1 && machines[0] == "all" {
|
|
||||||
pushOpts.AllMachines = true
|
|
||||||
} else if len(machines) > 0 {
|
|
||||||
pushOpts.Machines = machines
|
|
||||||
} else {
|
|
||||||
// Default is to push to all machines in the cluster.
|
|
||||||
pushOpts.AllMachines = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.platform != "" {
|
|
||||||
p, err := platforms.Parse(opts.platform)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid platform '%s': %w", opts.platform, err)
|
|
||||||
}
|
|
||||||
pushOpts.Platform = &p
|
|
||||||
}
|
|
||||||
|
|
||||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
|
||||||
if err = clusterClient.PushImage(ctx, opts.image, pushOpts); err != nil {
|
|
||||||
return fmt.Errorf("push image to cluster: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", opts.image))
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package image
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewRootCommand() *cobra.Command {
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "image",
|
|
||||||
Short: "Manage images on machines in the cluster.",
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.AddCommand(
|
|
||||||
NewListCommand(),
|
|
||||||
NewPushCommand(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/image"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewImagesCommand returns the 'image ls' command modified to work as 'images'.
|
|
||||||
func NewImagesCommand() *cobra.Command {
|
|
||||||
listCmd := image.NewListCommand()
|
|
||||||
listCmd.Use = "images [IMAGE]"
|
|
||||||
// Remove 'list' alias since this command is already an alias.
|
|
||||||
listCmd.Aliases = nil
|
|
||||||
listCmd.Example = strings.ReplaceAll(listCmd.Example, "uc image ls", "uc images")
|
|
||||||
|
|
||||||
return listCmd
|
|
||||||
}
|
|
||||||
@@ -5,31 +5,27 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"charm.land/huh/v2/spinner"
|
"github.com/cenkalti/backoff/v4"
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/config"
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
type addOptions struct {
|
type addOptions struct {
|
||||||
name string
|
name string
|
||||||
noCaddy bool
|
noCaddy bool
|
||||||
noInstall bool
|
publicIP string
|
||||||
publicIP string
|
sshKey string
|
||||||
sshKey string
|
context string
|
||||||
version string
|
version string
|
||||||
wgEndpoints []string
|
|
||||||
yes bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAddCommand() *cobra.Command {
|
func NewAddCommand() *cobra.Command {
|
||||||
@@ -37,33 +33,19 @@ func NewAddCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add [USER@]HOST[:PORT]",
|
Use: "add [USER@]HOST[:PORT]",
|
||||||
Short: "Add a remote machine to a cluster.",
|
Short: "Add a remote machine to a cluster.",
|
||||||
Long: `Add a new machine to an existing Uncloud cluster.
|
Args: cobra.ExactArgs(1),
|
||||||
|
|
||||||
Connection methods:
|
|
||||||
ssh://user@host - Use built-in SSH library (default, no prefix required)
|
|
||||||
ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
|
||||||
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
// Determine if SSH CLI needs to be used and strip scheme
|
user, host, port, err := config.SSHDestination(args[0]).Parse()
|
||||||
destination := args[0]
|
|
||||||
useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
|
|
||||||
destination = strings.TrimPrefix(destination, "ssh+cli://")
|
|
||||||
destination = strings.TrimPrefix(destination, "ssh://")
|
|
||||||
|
|
||||||
user, host, port, err := config.SSHDestination(destination).Parse()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse remote machine: %w", err)
|
return fmt.Errorf("parse remote machine: %w", err)
|
||||||
}
|
}
|
||||||
remoteMachine := &cli.RemoteMachine{
|
remoteMachine := cli.RemoteMachine{
|
||||||
User: user,
|
User: user,
|
||||||
Host: host,
|
Host: host,
|
||||||
Port: port,
|
Port: port,
|
||||||
KeyPath: opts.sshKey,
|
KeyPath: opts.sshKey,
|
||||||
UseSSHCLI: useSSHCLI,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return add(cmd.Context(), uncli, remoteMachine, opts)
|
return add(cmd.Context(), uncli, remoteMachine, opts)
|
||||||
@@ -74,46 +56,33 @@ Connection methods:
|
|||||||
&opts.noCaddy, "no-caddy", false,
|
&opts.noCaddy, "no-caddy", false,
|
||||||
"Don't deploy Caddy reverse proxy service to the machine.",
|
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||||
)
|
)
|
||||||
cmd.Flags().BoolVar(
|
|
||||||
&opts.noInstall, "no-install", false,
|
|
||||||
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
|
|
||||||
"Assumes they're already installed and running.",
|
|
||||||
)
|
|
||||||
cmd.Flags().StringVar(
|
cmd.Flags().StringVar(
|
||||||
&opts.publicIP, "public-ip", "auto",
|
&opts.publicIP, "public-ip", "auto",
|
||||||
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
||||||
fmt.Sprintf("blank '' or '%s' to disable ingress on this machine, or specify an IP address.", PublicIPNone),
|
"blank '' or 'none' to disable ingress on this machine, or specify an IP address.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.sshKey, "ssh-key", "i", "",
|
&opts.sshKey, "ssh-key", "i", "~/.ssh/id_ed25519",
|
||||||
fmt.Sprintf("Path to SSH private key for remote login (if not already added to SSH agent). (default %q)",
|
"Path to SSH private key for remote login (if not already added to SSH agent).",
|
||||||
cli.DefaultSSHKeyPath),
|
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVar(
|
cmd.Flags().StringVar(
|
||||||
&opts.version, "version", "latest",
|
&opts.version, "version", "latest",
|
||||||
"Version of the Uncloud daemon to install on the machine.",
|
"Version of the Uncloud daemon to install on the machine.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringSliceVar(
|
cmd.Flags().StringVarP(
|
||||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
&opts.context, "context", "c", "",
|
||||||
fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
|
"Name of the cluster context to add the machine to. (default is the current context)",
|
||||||
"Default port %d is used if omitted.\n", network.WireGuardPort)+
|
|
||||||
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
|
|
||||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
|
|
||||||
"If not specified, the machine's routable IPs and public IP are auto-detected and used as endpoints.",
|
|
||||||
)
|
)
|
||||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
|
||||||
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+
|
|
||||||
"Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts addOptions) error {
|
func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, opts addOptions) error {
|
||||||
var publicIP *netip.Addr
|
var publicIP *netip.Addr
|
||||||
switch opts.publicIP {
|
switch opts.publicIP {
|
||||||
case "auto":
|
case "auto":
|
||||||
publicIP = &netip.Addr{}
|
publicIP = &netip.Addr{}
|
||||||
case "", PublicIPNone:
|
case "", "none":
|
||||||
publicIP = nil
|
publicIP = nil
|
||||||
default:
|
default:
|
||||||
ip, err := netip.ParseAddr(opts.publicIP)
|
ip, err := netip.ParseAddr(opts.publicIP)
|
||||||
@@ -123,24 +92,13 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
|
|||||||
publicIP = &ip
|
publicIP = &ip
|
||||||
}
|
}
|
||||||
|
|
||||||
addOpts := cli.AddMachineOptions{
|
clusterClient, machineClient, err := uncli.AddMachine(ctx, cli.AddMachineOptions{
|
||||||
|
Context: opts.context,
|
||||||
MachineName: opts.name,
|
MachineName: opts.name,
|
||||||
PublicIP: publicIP,
|
PublicIP: publicIP,
|
||||||
RemoteMachine: remoteMachine,
|
RemoteMachine: remoteMachine,
|
||||||
SkipInstall: opts.noInstall,
|
|
||||||
Version: opts.version,
|
Version: opts.version,
|
||||||
AutoConfirm: opts.yes,
|
})
|
||||||
}
|
|
||||||
if len(opts.wgEndpoints) > 0 {
|
|
||||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
|
||||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
|
|
||||||
}
|
|
||||||
addOpts.WireguardEndpoints = endpoints
|
|
||||||
}
|
|
||||||
|
|
||||||
clusterClient, machineClient, err := uncli.AddMachine(ctx, addOpts)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -151,101 +109,79 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the cluster to be initialised on the machine to be able to deploy the Caddy service.
|
// Wait for the cluster to be initialised to be able to deploy the Caddy service.
|
||||||
err = spinner.New().
|
fmt.Println("Waiting for the machine to be ready...")
|
||||||
Title(" Waiting for the machine to join the cluster...").
|
fmt.Println()
|
||||||
Type(spinner.MiniDot).
|
if err = waitClusterInitialised(ctx, machineClient); err != nil {
|
||||||
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
|
return fmt.Errorf("wait for cluster to be initialised on machine: %w", err)
|
||||||
return &spinner.Styles{
|
|
||||||
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
|
||||||
Title: lipgloss.NewStyle(),
|
|
||||||
}
|
|
||||||
})).
|
|
||||||
ActionWithErr(func(ctx context.Context) error {
|
|
||||||
return machineClient.WaitClusterReady(ctx, 5*time.Minute)
|
|
||||||
}).
|
|
||||||
Run()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("wait for machine to join the cluster: %w", err)
|
|
||||||
}
|
}
|
||||||
fmt.Println("Machine joined the cluster.")
|
|
||||||
|
|
||||||
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
|
||||||
// that may cause a small downtime.
|
|
||||||
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
|
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
|
||||||
// use the deployed image version.
|
// use the deployed image version. Otherwise, use the latest version.
|
||||||
// NOTE: We use the cluster client to inspect and scale the Caddy service because the newly added machine may have
|
// NOTE: We use the cluster client to inspect and scale the Caddy service because the newly added machine may have
|
||||||
// issues accessing the Machine API of existing machines in the cluster.
|
// issues accessing the Machine API of existing machines in the cluster.
|
||||||
// See the issue for more details: https://github.com/psviderski/uncloud/issues/65.
|
// See the issue for more details: https://github.com/psviderski/uncloud/issues/65.
|
||||||
caddyImage := ""
|
caddyImage := ""
|
||||||
caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, api.ErrNotFound) {
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
// Caddy service is not deployed.
|
return fmt.Errorf("inspect caddy service: %w", err)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
return fmt.Errorf("inspect caddy service: %w", err)
|
} else {
|
||||||
}
|
caddyImage = caddySvc.Containers[0].Container.Config.Image
|
||||||
caddyImage = caddySvc.Containers[0].Container.Config.Image
|
// Find the latest created container and use its image.
|
||||||
// Find the latest created container and use its image.
|
var latestCreated time.Time
|
||||||
var latestCreated time.Time
|
for _, c := range caddySvc.Containers[1:] {
|
||||||
for _, c := range caddySvc.Containers[1:] {
|
created, err := time.Parse(time.RFC3339Nano, c.Container.Created)
|
||||||
created, err := time.Parse(time.RFC3339Nano, c.Container.Created)
|
if err != nil {
|
||||||
if err != nil {
|
continue
|
||||||
continue
|
}
|
||||||
}
|
if created.After(latestCreated) {
|
||||||
if created.After(latestCreated) {
|
latestCreated = created
|
||||||
latestCreated = created
|
caddyImage = c.Container.Config.Image
|
||||||
caddyImage = c.Container.Config.Image
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
||||||
fmt.Println("Preparing Caddy deployment...")
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, err := d.Plan(ctx)
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if _, err = d.Run(ctx); err != nil {
|
||||||
|
return fmt.Errorf("deploy caddy: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plan caddy deployment: %w", err)
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
if len(plan.Operations) == 0 {
|
|
||||||
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
|
|
||||||
} else {
|
|
||||||
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Print(plan.Format())
|
|
||||||
|
|
||||||
summary := plan.FormatSummary()
|
|
||||||
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
|
|
||||||
fmt.Println(summary)
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
if !opts.yes {
|
|
||||||
confirmed, err := tui.Confirm("Proceed with deployment?")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("confirm deployment: %w", err)
|
|
||||||
}
|
|
||||||
if !confirmed {
|
|
||||||
fmt.Println("Cancelled. No changes were made.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
|
||||||
if _, err = d.Run(ctx); err != nil {
|
|
||||||
return fmt.Errorf("deploy caddy: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s (%s mode)", d.Spec.Name, d.Spec.Mode))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
return caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut())
|
return caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func waitClusterInitialised(ctx context.Context, client *client.Client) error {
|
||||||
|
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||||
|
backoff.WithMaxInterval(1*time.Second),
|
||||||
|
backoff.WithMaxElapsedTime(5*time.Minute),
|
||||||
|
), ctx)
|
||||||
|
|
||||||
|
check := func() error {
|
||||||
|
_, err := client.ListMachines(ctx, nil)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
statusErr := status.Convert(err)
|
||||||
|
if statusErr.Code() == codes.FailedPrecondition {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return backoff.Permanent(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return backoff.Retry(check, boff)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
package machine
|
|
||||||
|
|
||||||
const (
|
|
||||||
// PublicIPNone is the value used to indicate removal of public IP
|
|
||||||
PublicIPNone = "none"
|
|
||||||
)
|
|
||||||
@@ -4,11 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"charm.land/huh/v2/spinner"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||||
@@ -16,85 +12,50 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/cli/config"
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/machine/cluster"
|
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type initOptions struct {
|
type initOptions struct {
|
||||||
context string
|
|
||||||
dnsEndpoint string
|
dnsEndpoint string
|
||||||
name string
|
name string
|
||||||
network string
|
network string
|
||||||
noCaddy bool
|
noCaddy bool
|
||||||
noDNS bool
|
noDNS bool
|
||||||
noInstall bool
|
|
||||||
publicIP string
|
publicIP string
|
||||||
sshKey string
|
sshKey string
|
||||||
version string
|
version string
|
||||||
wgEndpoints []string
|
context string
|
||||||
yes bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInitCommand() *cobra.Command {
|
func NewInitCommand() *cobra.Command {
|
||||||
opts := initOptions{}
|
opts := initOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "init [schema://]USER@HOST[:PORT]",
|
Use: "init [USER@HOST:PORT]",
|
||||||
Short: "Initialise a new cluster with a remote machine as the first member.",
|
Short: "Initialise a new cluster with a remote machine as the first member.",
|
||||||
Long: `Initialise a new cluster by setting up a remote machine as the first member.
|
// TODO: include usage examples of initialising a remote machine.
|
||||||
This command creates a new context in your Uncloud config to manage the cluster.
|
|
||||||
|
|
||||||
Connection methods:
|
|
||||||
ssh://user@host - Use built-in SSH library (default, no prefix required)
|
|
||||||
ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`,
|
|
||||||
Example: ` # Initialise a new cluster with default settings.
|
|
||||||
uc machine init root@<your-server-ip>
|
|
||||||
|
|
||||||
# Initialise with a context name 'prod' in the Uncloud config (~/.config/uncloud/config.yaml) and machine name 'vps1'.
|
|
||||||
uc machine init root@<your-server-ip> -c prod -n vps1
|
|
||||||
|
|
||||||
# Initialise with a non-root user and custom SSH port and key.
|
|
||||||
uc machine init ubuntu@<your-server-ip>:2222 -i ~/.ssh/mykey
|
|
||||||
|
|
||||||
# Initialise without Caddy (no reverse proxy) and without an automatically managed domain name (xxxxxx.uncld.dev).
|
|
||||||
# You can deploy Caddy with 'uc caddy deploy' and reserve a domain with 'uc dns reserve' later.
|
|
||||||
uc machine init root@<your-server-ip> --no-caddy --no-dns`,
|
|
||||||
// TODO: support initialising a cluster on the local machine.
|
// TODO: support initialising a cluster on the local machine.
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
|
||||||
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
var remoteMachine *cli.RemoteMachine
|
var remoteMachine *cli.RemoteMachine
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
// Determine if SSH CLI is requested and strip scheme
|
user, host, port, err := config.SSHDestination(args[0]).Parse()
|
||||||
destination := args[0]
|
|
||||||
useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
|
|
||||||
destination = strings.TrimPrefix(destination, "ssh+cli://")
|
|
||||||
destination = strings.TrimPrefix(destination, "ssh://")
|
|
||||||
|
|
||||||
user, host, port, err := config.SSHDestination(destination).Parse()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse remote machine: %w", err)
|
return fmt.Errorf("parse remote machine: %w", err)
|
||||||
}
|
}
|
||||||
remoteMachine = &cli.RemoteMachine{
|
remoteMachine = &cli.RemoteMachine{
|
||||||
User: user,
|
User: user,
|
||||||
Host: host,
|
Host: host,
|
||||||
Port: port,
|
Port: port,
|
||||||
KeyPath: opts.sshKey,
|
KeyPath: opts.sshKey,
|
||||||
UseSSHCLI: useSSHCLI,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return initCluster(cmd.Context(), uncli, remoteMachine, opts)
|
return initCluster(cmd.Context(), uncli, remoteMachine, opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", cli.DefaultContextName,
|
|
||||||
"Name of the new context to be created in the Uncloud config to manage the cluster.",
|
|
||||||
)
|
|
||||||
cmd.Flags().StringVar(&opts.dnsEndpoint, "dns-endpoint", dns.DefaultUncloudDNSAPIEndpoint,
|
cmd.Flags().StringVar(&opts.dnsEndpoint, "dns-endpoint", dns.DefaultUncloudDNSAPIEndpoint,
|
||||||
"API endpoint for the Uncloud DNS service.")
|
"API endpoint for the Uncloud DNS service.")
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
@@ -107,55 +68,34 @@ Connection methods:
|
|||||||
)
|
)
|
||||||
cmd.Flags().BoolVar(
|
cmd.Flags().BoolVar(
|
||||||
&opts.noCaddy, "no-caddy", false,
|
&opts.noCaddy, "no-caddy", false,
|
||||||
"Don't deploy Caddy reverse proxy service to the machine. You can deploy it later with 'uc caddy deploy'.",
|
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||||
)
|
)
|
||||||
cmd.Flags().BoolVar(
|
cmd.Flags().BoolVar(
|
||||||
&opts.noDNS, "no-dns", false,
|
&opts.noDNS, "no-dns", false,
|
||||||
"Don't reserve a cluster domain in Uncloud DNS. You can reserve it later with 'uc dns reserve'.",
|
"Don't reserve a cluster domain in Uncloud DNS.",
|
||||||
)
|
|
||||||
cmd.Flags().BoolVar(
|
|
||||||
&opts.noInstall, "no-install", false,
|
|
||||||
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
|
|
||||||
"Assumes they're already installed and running.",
|
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVar(
|
cmd.Flags().StringVar(
|
||||||
&opts.publicIP, "public-ip", "auto",
|
&opts.publicIP, "public-ip", "auto",
|
||||||
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
||||||
fmt.Sprintf("blank '' or '%s' to disable ingress on this machine, or specify an IP address.", PublicIPNone),
|
"blank '' or 'none' to disable ingress on this machine, or specify an IP address.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.sshKey, "ssh-key", "i", "",
|
&opts.sshKey, "ssh-key", "i", "~/.ssh/id_ed25519",
|
||||||
fmt.Sprintf("Path to SSH private key for remote login (if not already added to SSH agent). (default %q)",
|
"Path to SSH private key for remote login (if not already added to SSH agent).",
|
||||||
cli.DefaultSSHKeyPath),
|
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVar(
|
cmd.Flags().StringVar(
|
||||||
&opts.version, "version", "latest",
|
&opts.version, "version", "latest",
|
||||||
"Version of the Uncloud daemon to install on the machine.",
|
"Version of the Uncloud daemon to install on the machine.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringSliceVar(
|
cmd.Flags().StringVarP(
|
||||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
&opts.context, "context", "c", "default",
|
||||||
fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
|
"Name of the created context for the initialised cluster in the Uncloud config.",
|
||||||
"Default port %d is used if omitted.\n", network.WireGuardPort)+
|
|
||||||
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
|
|
||||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
|
|
||||||
"If not specified, the machine's routable IPs and public IP are auto-detected and used as endpoints.",
|
|
||||||
)
|
)
|
||||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
|
||||||
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+
|
|
||||||
"Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts initOptions) error {
|
func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts initOptions) error {
|
||||||
if uncli.Config == nil {
|
|
||||||
// Config is nil when connecting directly to a remote machine (--connect) without using Uncloud config.
|
|
||||||
return fmt.Errorf(
|
|
||||||
"do not use --connect when initialising a new cluster: --connect is for overriding the connection " +
|
|
||||||
"to an existing cluster, but 'machine init' creates a new one and writes the new cluster context " +
|
|
||||||
"to the Uncloud config file (--uncloud-config)")
|
|
||||||
}
|
|
||||||
|
|
||||||
netPrefix, err := netip.ParsePrefix(opts.network)
|
netPrefix, err := netip.ParsePrefix(opts.network)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse network CIDR: %w", err)
|
return fmt.Errorf("parse network CIDR: %w", err)
|
||||||
@@ -165,7 +105,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
switch opts.publicIP {
|
switch opts.publicIP {
|
||||||
case "auto":
|
case "auto":
|
||||||
publicIP = &netip.Addr{}
|
publicIP = &netip.Addr{}
|
||||||
case "", PublicIPNone:
|
case "", "none":
|
||||||
publicIP = nil
|
publicIP = nil
|
||||||
default:
|
default:
|
||||||
ip, err := netip.ParseAddr(opts.publicIP)
|
ip, err := netip.ParseAddr(opts.publicIP)
|
||||||
@@ -174,56 +114,27 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
}
|
}
|
||||||
publicIP = &ip
|
publicIP = &ip
|
||||||
}
|
}
|
||||||
initOpts := cli.InitClusterOptions{
|
client, err := uncli.InitCluster(ctx, cli.InitClusterOptions{
|
||||||
Context: opts.context,
|
Context: opts.context,
|
||||||
MachineName: opts.name,
|
MachineName: opts.name,
|
||||||
Network: netPrefix,
|
Network: netPrefix,
|
||||||
PublicIP: publicIP,
|
PublicIP: publicIP,
|
||||||
RemoteMachine: remoteMachine,
|
RemoteMachine: remoteMachine,
|
||||||
SkipInstall: opts.noInstall,
|
|
||||||
Version: opts.version,
|
Version: opts.version,
|
||||||
AutoConfirm: opts.yes,
|
})
|
||||||
}
|
|
||||||
if len(opts.wgEndpoints) > 0 {
|
|
||||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
|
||||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
|
|
||||||
}
|
|
||||||
initOpts.WireguardEndpoints = endpoints
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := uncli.InitCluster(ctx, initOpts)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
// Since the cluster API needs a few moments to become ready after cluster initialisation,
|
|
||||||
// we keep the user informed during this wait. We wait here even if no Caddy or DNS is requested
|
|
||||||
// as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init.
|
|
||||||
err = spinner.New().
|
|
||||||
Title(" Waiting for the cluster to be ready...").
|
|
||||||
Type(spinner.MiniDot).
|
|
||||||
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
|
|
||||||
return &spinner.Styles{
|
|
||||||
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
|
||||||
Title: lipgloss.NewStyle(),
|
|
||||||
}
|
|
||||||
})).
|
|
||||||
ActionWithErr(func(ctx context.Context) error {
|
|
||||||
return client.WaitClusterReady(ctx, 1*time.Minute)
|
|
||||||
}).
|
|
||||||
Run()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("wait for cluster to be ready: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Println("Cluster is ready.")
|
|
||||||
|
|
||||||
if opts.noCaddy && opts.noDNS {
|
if opts.noCaddy && opts.noDNS {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deploy the Caddy service to the initialised machine.
|
||||||
|
// The creation of a deployment plan talks to cluster API. Since the API needs a few moments to become available
|
||||||
|
// after cluster initialisation, we keep the user informed during this wait.
|
||||||
|
fmt.Println("Waiting for the machine to be ready...")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
if !opts.noDNS {
|
if !opts.noDNS {
|
||||||
@@ -235,7 +146,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,20 +14,25 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
|
var contextName string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
Aliases: []string{"list"},
|
Aliases: []string{"list"},
|
||||||
Short: "List machines in a cluster.",
|
Short: "List machines in a cluster.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return list(cmd.Context(), uncli)
|
return list(cmd.Context(), uncli, contextName)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&contextName, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func list(ctx context.Context, uncli *cli.CLI) error {
|
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
client, err := uncli.ConnectCluster(ctx, clusterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -41,7 +46,7 @@ func list(ctx context.Context, uncli *cli.CLI) error {
|
|||||||
// Print the list of machines in a table format.
|
// Print the list of machines in a table format.
|
||||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
// Print header.
|
// Print header.
|
||||||
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS\tMACHINE ID"); err != nil {
|
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS"); err != nil {
|
||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
// Print rows.
|
// Print rows.
|
||||||
@@ -63,8 +68,8 @@ func list(ctx context.Context, uncli *cli.CLI) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err = fmt.Fprintf(
|
if _, err = fmt.Fprintf(
|
||||||
tw, "%s\t%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
tw, "%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
||||||
strings.Join(endpoints, ", "), member.Machine.Id,
|
strings.Join(endpoints, ", "),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("write row: %w", err)
|
return fmt.Errorf("write row: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
package machine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewRenameCommand() *cobra.Command {
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "rename OLD_NAME NEW_NAME",
|
|
||||||
Short: "Rename a machine in the cluster.",
|
|
||||||
Long: `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.`,
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
|
||||||
return rename(cmd.Context(), uncli, args[0], args[1])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func rename(ctx context.Context, uncli *cli.CLI, oldName, newName string) error {
|
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
machine, err := client.RenameMachine(ctx, oldName, newName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("rename machine: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Machine %q renamed to %q (ID: %s)\n", oldName, machine.Name, machine.Id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -9,20 +9,19 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"charm.land/lipgloss/v2/tree"
|
"github.com/charmbracelet/lipgloss/tree"
|
||||||
"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/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type removeOptions struct {
|
type removeOptions struct {
|
||||||
noReset bool
|
force bool
|
||||||
yes bool
|
yes bool
|
||||||
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRmCommand() *cobra.Command {
|
func NewRmCommand() *cobra.Command {
|
||||||
@@ -31,7 +30,7 @@ func NewRmCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "rm MACHINE",
|
Use: "rm MACHINE",
|
||||||
Aliases: []string{"remove", "delete"},
|
Aliases: []string{"remove", "delete"},
|
||||||
Short: "Remove a machine from a cluster and reset it.",
|
Short: "Remove a machine from a cluster.",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
@@ -39,87 +38,55 @@ func NewRmCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||||
"Do not prompt for confirmation before removing the machine.")
|
"Do not prompt for confirmation before removing the machine.")
|
||||||
cmd.Flags().BoolVar(&opts.noReset, "no-reset", false,
|
|
||||||
"Do not reset the machine after removing it from the cluster. This will leave all containers and data intact.")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOptions) error {
|
func remove(ctx context.Context, uncli *cli.CLI, machineName string, opts removeOptions) error {
|
||||||
// TODO: automatically choose a connection to the machine that is not being removed.
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
// Verify the machine exists and list all service containers on it including stopped ones.
|
// Verify the machine exists and list all service containers on it including stopped ones.
|
||||||
mctx, machines, err := client.ProxyMachinesContext(ctx, []string{nameOrID})
|
listCtx, machines, err := api.ProxyMachinesContext(ctx, client, []string{machineName})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(machines) == 0 {
|
if len(machines) == 0 {
|
||||||
return fmt.Errorf("machine '%s' not found in the cluster", nameOrID)
|
return fmt.Errorf("machine '%s' not found in the cluster", machineName)
|
||||||
}
|
}
|
||||||
m := machines[0].Machine
|
m := machines[0].Machine
|
||||||
|
|
||||||
// Verify if the machine being removed is the proxy machine we're connected to.
|
listOpts := container.ListOptions{All: true}
|
||||||
proxyMachine, err := client.MachineClient.Inspect(ctx, nil)
|
machineContainers, err := client.Docker.ListServiceContainers(listCtx, "", listOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("inspect proxy machine: %w", err)
|
return fmt.Errorf("list containers: %w", err)
|
||||||
}
|
|
||||||
if proxyMachine.Id == m.Id {
|
|
||||||
allMachines, err := client.ListMachines(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("list machines: %w", err)
|
|
||||||
}
|
|
||||||
if len(allMachines) > 1 {
|
|
||||||
return errors.New("cannot remove the machine you are currently connected to. " +
|
|
||||||
"Please connect to another machine in the cluster and try again. " +
|
|
||||||
"Use --connect flag or update 'connections' for the cluster context in your Uncloud config")
|
|
||||||
// It's ok to remove the proxy machine if it's the last one in the cluster.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
containers := machineContainers[0].Containers
|
||||||
|
|
||||||
// TODO: mark the machine as being removed and unschedulable when this is possible to prevent new containers
|
if len(containers) > 0 {
|
||||||
// from being scheduled on it while the removal is in progress.
|
plural := ""
|
||||||
|
if len(containers) > 1 {
|
||||||
reset := !opts.noReset
|
plural = "s"
|
||||||
var containers []api.ServiceContainer
|
|
||||||
reachable := false
|
|
||||||
if reset {
|
|
||||||
// Check if the machine is up and has service containers.
|
|
||||||
listOpts := container.ListOptions{All: true}
|
|
||||||
machineContainers, err := client.Docker.ListServiceContainers(mctx, "", listOpts)
|
|
||||||
if err == nil {
|
|
||||||
reachable = true
|
|
||||||
containers = machineContainers[0].Containers
|
|
||||||
if len(containers) > 0 {
|
|
||||||
plural := ""
|
|
||||||
if len(containers) > 1 {
|
|
||||||
plural = "s"
|
|
||||||
}
|
|
||||||
fmt.Printf("Found %d service container%s on machine '%s':\n", len(containers), plural, m.Name)
|
|
||||||
fmt.Println(formatContainerTree(containers))
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("This will remove all service containers from the machine, remove it from the cluster, " +
|
|
||||||
"and reset it to the uninitialised state.")
|
|
||||||
} else {
|
|
||||||
fmt.Printf("No service containers found on machine '%s'.\n", m.Name)
|
|
||||||
fmt.Println("This will remove the machine from the cluster and reset it to the uninitialised state.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fmt.Printf("This will remove machine '%s' from the cluster without resetting it as it's unreachable.\n",
|
|
||||||
m.Name)
|
|
||||||
}
|
}
|
||||||
|
fmt.Printf("Found %d service container%s on machine '%s':\n", len(containers), plural, m.Name)
|
||||||
|
fmt.Println(formatContainerTree(containers))
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("This will remove all service containers on the machine, reset it to the uninitialised state, " +
|
||||||
|
"and remove it from the cluster.")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("This will remove machine '%s' from the cluster without resetting it.\n", m.Name)
|
fmt.Printf("No service containers found on machine '%s'.\n", m.Name)
|
||||||
|
fmt.Println("This will reset the machine to the uninitialised state and remove it from the cluster.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.yes {
|
if !opts.yes {
|
||||||
confirmed, err := tui.Confirm("")
|
confirmed, err := cli.Confirm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("confirm removal: %w", err)
|
return fmt.Errorf("confirm removal: %w", err)
|
||||||
}
|
}
|
||||||
@@ -129,50 +96,23 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if reset && len(containers) > 0 {
|
if len(containers) > 0 {
|
||||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
return removeContainers(ctx, client, containers)
|
return removeContainers(ctx, client, containers)
|
||||||
}, uncli.ProgressOut(), "Removing containers")
|
}, uncli.ProgressOut(), "Removing containers")
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("remove containers: %w", err)
|
return fmt.Errorf("remove containers: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = client.RemoveMachine(ctx, &pb.RemoveMachineRequest{Id: m.Id}); err != nil {
|
// TODO: 4. Implement and call Reset via Machine API to reset the machine state to uninitialised.
|
||||||
return fmt.Errorf("remove machine from cluster: %w", err)
|
// TODO: 5. Remove the machine from the cluster store.
|
||||||
}
|
|
||||||
fmt.Printf("Machine '%s' removed from the cluster.\n", m.Name)
|
|
||||||
|
|
||||||
if reset && reachable {
|
return fmt.Errorf("resetting machine is not fully implemented yet")
|
||||||
_, err = client.MachineClient.Reset(mctx, &pb.ResetRequest{})
|
//fmt.Printf("Machine '%s' removed from the cluster.\n", m.Name)
|
||||||
if err != nil {
|
//return nil
|
||||||
fmt.Printf("WARNING: Failed to reset machine: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Println("Machine reset initiated and will complete in the background.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the connection to the machine from the uncloud config if it exists.
|
|
||||||
if uncli.Config != nil {
|
|
||||||
contextName := uncli.ContextOverrideOrCurrent()
|
|
||||||
if context, ok := uncli.Config.Contexts[contextName]; ok {
|
|
||||||
for i, c := range context.Connections {
|
|
||||||
if c.MachineID == m.Id {
|
|
||||||
context.Connections = slices.Delete(context.Connections, i, i+1)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := uncli.Config.Save(); err != nil {
|
|
||||||
return fmt.Errorf("save config: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: If Caddy was running on this machine and a cluster domain is reserved,
|
|
||||||
// let the user know that the DNS records should be updated.
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatContainerTree formats a list of containers grouped by service as a tree structure.
|
// formatContainerTree formats a list of containers grouped by service as a tree structure.
|
||||||
|
|||||||
@@ -8,15 +8,14 @@ func NewRootCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "machine",
|
Use: "machine",
|
||||||
Aliases: []string{"m"},
|
Aliases: []string{"m"},
|
||||||
Short: "Manage machines in the cluster.",
|
Short: "Manage machines in an Uncloud cluster.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewAddCommand(),
|
NewAddCommand(),
|
||||||
NewInitCommand(),
|
NewInitCommand(),
|
||||||
NewListCommand(),
|
NewListCommand(),
|
||||||
NewRenameCommand(),
|
|
||||||
NewRmCommand(),
|
NewRmCommand(),
|
||||||
NewUpdateCommand(),
|
NewTokenCommand(),
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/psviderski/uncloud/internal/daemon"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tokenOptions struct {
|
||||||
|
dataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTokenCommand() *cobra.Command {
|
||||||
|
opts := tokenOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "token",
|
||||||
|
Short: "Print the local machine's token for adding it to a cluster.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
token, err := daemon.MachineToken(opts.dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get machine token: %w", err)
|
||||||
|
}
|
||||||
|
tokenStr, err := token.String()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode machine token: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println(tokenStr)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.dataDir, "data-dir", "d", machine.DefaultDataDir,
|
||||||
|
"Directory for storing persistent machine state.")
|
||||||
|
_ = cmd.MarkFlagDirname("data-dir")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
package machine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/netip"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
type updateOptions struct {
|
|
||||||
name string
|
|
||||||
publicIP string
|
|
||||||
wgEndpoints []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUpdateCommand() *cobra.Command {
|
|
||||||
opts := updateOptions{}
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "update MACHINE [flags]",
|
|
||||||
Short: "Update machine configuration in the cluster.",
|
|
||||||
Long: `Update machine configuration in the cluster.
|
|
||||||
|
|
||||||
Change the name, public IP address, or WireGuard endpoints of an existing machine.
|
|
||||||
At least one flag must be specified to perform an update.`,
|
|
||||||
Example: ` # Rename a machine.
|
|
||||||
uc machine update machine1 --name web-server
|
|
||||||
|
|
||||||
# Set the public IP address of a machine.
|
|
||||||
uc machine update machine1 --public-ip 203.0.113.10
|
|
||||||
|
|
||||||
# Remove the public IP address from a machine.
|
|
||||||
uc machine update machine1 --public-ip none
|
|
||||||
|
|
||||||
# Update WireGuard endpoints for a machine.
|
|
||||||
uc machine update machine1 --wg-endpoint 203.0.113.10 --wg-endpoint 192.168.1.5
|
|
||||||
|
|
||||||
# Update multiple properties at once.
|
|
||||||
uc machine update machine1 --name web-server --public-ip 203.0.113.10`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
|
||||||
return update(cmd.Context(), uncli, cmd, opts, args[0])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringVar(
|
|
||||||
&opts.name, "name", "",
|
|
||||||
"New name for the machine",
|
|
||||||
)
|
|
||||||
cmd.Flags().StringVar(
|
|
||||||
&opts.publicIP, "public-ip", "",
|
|
||||||
fmt.Sprintf("Public IP address of the machine for ingress configuration. Use '%s' or '' to remove the public IP.",
|
|
||||||
PublicIPNone),
|
|
||||||
)
|
|
||||||
cmd.Flags().StringSliceVar(
|
|
||||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
|
||||||
fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
|
|
||||||
"Default port %d is used if omitted.\n", network.WireGuardPort)+
|
|
||||||
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
|
|
||||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts updateOptions, machineNameOrID string) error {
|
|
||||||
// Check if at least one flag was explicitly set.
|
|
||||||
if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("public-ip") && !cmd.Flags().Changed("wg-endpoint") {
|
|
||||||
return fmt.Errorf("at least one update flag must be specified (--name, --public-ip, --wg-endpoint)")
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
// First, resolve the machine to get its ID
|
|
||||||
machine, err := client.InspectMachine(ctx, machineNameOrID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("find machine: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the update request
|
|
||||||
req := &pb.UpdateMachineRequest{
|
|
||||||
MachineId: machine.Machine.Id,
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.name != "" {
|
|
||||||
req.Name = &opts.name
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if --public-ip flag was explicitly provided
|
|
||||||
if cmd.Flags().Changed("public-ip") {
|
|
||||||
if opts.publicIP == "" || opts.publicIP == PublicIPNone {
|
|
||||||
req.PublicIp = &pb.IP{} // Empty IP to signal removal
|
|
||||||
} else {
|
|
||||||
// Parse and validate the public IP
|
|
||||||
ip, err := netip.ParseAddr(opts.publicIP)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid public IP address %q: %w", opts.publicIP, err)
|
|
||||||
}
|
|
||||||
req.PublicIp = pb.NewIP(ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and set endpoints if the flag was explicitly provided.
|
|
||||||
if cmd.Flags().Changed("wg-endpoint") {
|
|
||||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
|
||||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(endpoints) == 0 {
|
|
||||||
return fmt.Errorf("at least one endpoint must be specified if --wg-endpoint flag is used")
|
|
||||||
}
|
|
||||||
req.Endpoints = endpoints
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform the update operation
|
|
||||||
updatedMachine, err := client.UpdateMachine(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update machine: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report what was changed
|
|
||||||
changes := make([]string, 0)
|
|
||||||
if opts.name != "" {
|
|
||||||
changes = append(changes, fmt.Sprintf("name: %q -> %q", machine.Machine.Name, updatedMachine.Name))
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("public-ip") {
|
|
||||||
oldIP := PublicIPNone
|
|
||||||
if machine.Machine.PublicIp != nil {
|
|
||||||
if addr, err := machine.Machine.PublicIp.ToAddr(); err == nil {
|
|
||||||
oldIP = addr.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newIP := PublicIPNone
|
|
||||||
if updatedMachine.PublicIp != nil {
|
|
||||||
if addr, err := updatedMachine.PublicIp.ToAddr(); err == nil {
|
|
||||||
newIP = addr.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
changes = append(changes, fmt.Sprintf("public IP: %s -> %s", oldIP, newIP))
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("wg-endpoint") {
|
|
||||||
formatEndpoints := func(eps []*pb.IPPort) string {
|
|
||||||
if len(eps) == 0 {
|
|
||||||
return "none"
|
|
||||||
}
|
|
||||||
parts := make([]string, len(eps))
|
|
||||||
for i, ep := range eps {
|
|
||||||
ap, _ := ep.ToAddrPort()
|
|
||||||
parts[i] = ap.String()
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ", ")
|
|
||||||
}
|
|
||||||
oldEndpoints := formatEndpoints(machine.Machine.Network.Endpoints)
|
|
||||||
newEndpoints := formatEndpoints(updatedMachine.Network.Endpoints)
|
|
||||||
changes = append(changes, fmt.Sprintf("endpoints: %s -> %s", oldEndpoints, newEndpoints))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Machine '%s' (ID: %s) configuration updated:\n", updatedMachine.Name, updatedMachine.Id)
|
|
||||||
for _, change := range changes {
|
|
||||||
fmt.Printf(" %s\n", change)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -6,19 +6,15 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
|
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/image"
|
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/machine"
|
"github.com/psviderski/uncloud/cmd/uncloud/machine"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/service"
|
"github.com/psviderski/uncloud/cmd/uncloud/service"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/volume"
|
"github.com/psviderski/uncloud/cmd/uncloud/volume"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/wg"
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/config"
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
"github.com/psviderski/uncloud/internal/fs"
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
"github.com/psviderski/uncloud/internal/log"
|
|
||||||
"github.com/psviderski/uncloud/internal/version"
|
"github.com/psviderski/uncloud/internal/version"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -26,24 +22,17 @@ import (
|
|||||||
type globalOptions struct {
|
type globalOptions struct {
|
||||||
configPath string
|
configPath string
|
||||||
connect string
|
connect string
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.InitLoggerFromEnv()
|
|
||||||
|
|
||||||
opts := globalOptions{}
|
opts := globalOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "uc",
|
Use: "uncloud",
|
||||||
Short: "A CLI tool for managing Uncloud resources such as machines, services, and volumes.",
|
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
||||||
Version: version.String(),
|
Version: version.String(),
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cli.BindEnvToFlag(cmd, "connect", "UNCLOUD_CONNECT")
|
|
||||||
cli.BindEnvToFlag(cmd, "context", "UNCLOUD_CONTEXT")
|
|
||||||
cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG")
|
|
||||||
|
|
||||||
var conn *config.MachineConnection
|
var conn *config.MachineConnection
|
||||||
if opts.connect != "" {
|
if opts.connect != "" {
|
||||||
if strings.HasPrefix(opts.connect, "tcp://") {
|
if strings.HasPrefix(opts.connect, "tcp://") {
|
||||||
@@ -54,17 +43,11 @@ func main() {
|
|||||||
conn = &config.MachineConnection{
|
conn = &config.MachineConnection{
|
||||||
TCP: &addrPort,
|
TCP: &addrPort,
|
||||||
}
|
}
|
||||||
} else if strings.HasPrefix(opts.connect, "ssh+cli://") {
|
|
||||||
dest := opts.connect[len("ssh+cli://"):]
|
|
||||||
conn = &config.MachineConnection{
|
|
||||||
SSHCLI: config.SSHDestination(dest),
|
|
||||||
}
|
|
||||||
} else if strings.HasPrefix(opts.connect, "unix://") {
|
|
||||||
conn = &config.MachineConnection{
|
|
||||||
Unix: opts.connect[len("unix://"):],
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
dest := strings.TrimPrefix(opts.connect, "ssh://")
|
dest := opts.connect
|
||||||
|
if strings.HasPrefix(dest, "ssh://") {
|
||||||
|
dest = dest[len("ssh://"):]
|
||||||
|
}
|
||||||
conn = &config.MachineConnection{
|
conn = &config.MachineConnection{
|
||||||
SSH: config.SSHDestination(dest),
|
SSH: config.SSHDestination(dest),
|
||||||
}
|
}
|
||||||
@@ -72,7 +55,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configPath := fs.ExpandHomeDir(opts.configPath)
|
configPath := fs.ExpandHomeDir(opts.configPath)
|
||||||
uncli, err := cli.New(configPath, conn, opts.context)
|
uncli, err := cli.New(configPath, conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("initialise CLI: %w", err)
|
return fmt.Errorf("initialise CLI: %w", err)
|
||||||
}
|
}
|
||||||
@@ -82,60 +65,28 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
||||||
"Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+
|
"Connect to a remote cluster machine without using the Uncloud configuration file.\n"+
|
||||||
"Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock")
|
"Format: [ssh://]user@host[:port] or tcp://host:port")
|
||||||
|
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
||||||
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||||
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
|
"Path to the Uncloud configuration file.")
|
||||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||||
cmd.PersistentFlags().StringVarP(&opts.context, "context", "c", "",
|
// TODO: make --context a global flag and pass it as a value of the command context.
|
||||||
"Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]")
|
|
||||||
|
|
||||||
// Set custom help function to show links to docs and Discord only for the root 'uc' command.
|
|
||||||
defaultHelpFunc := cmd.HelpFunc()
|
|
||||||
cmd.SetHelpFunc(func(c *cobra.Command, args []string) {
|
|
||||||
defaultHelpFunc(c, args)
|
|
||||||
// Only show links for the root 'uc' command.
|
|
||||||
if c.Name() == "uc" {
|
|
||||||
urlStyle := lipgloss.NewStyle().
|
|
||||||
Underline(true).
|
|
||||||
Foreground(lipgloss.Color("12")) // light blue
|
|
||||||
|
|
||||||
fmt.Fprintln(c.OutOrStdout())
|
|
||||||
fmt.Fprintf(c.OutOrStdout(), "Learn more about Uncloud: %s\n",
|
|
||||||
urlStyle.Render("https://uncloud.run/docs"))
|
|
||||||
fmt.Fprintf(c.OutOrStdout(), "Join our Discord community: %s\n",
|
|
||||||
urlStyle.Render("https://uncloud.run/discord"))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
cmd.AddGroup(&cobra.Group{
|
|
||||||
ID: "service",
|
|
||||||
Title: "Deploy and manage services:",
|
|
||||||
})
|
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewBuildCommand(),
|
|
||||||
NewDeployCommand(),
|
NewDeployCommand(),
|
||||||
NewDocsCommand(),
|
NewBuildCommand(),
|
||||||
NewImagesCommand(),
|
|
||||||
NewPsCommand(),
|
|
||||||
caddy.NewRootCommand(),
|
caddy.NewRootCommand(),
|
||||||
cmdcontext.NewRootCommand(),
|
cmdcontext.NewRootCommand(),
|
||||||
dns.NewRootCommand(),
|
dns.NewRootCommand(),
|
||||||
image.NewRootCommand(),
|
|
||||||
machine.NewRootCommand(),
|
machine.NewRootCommand(),
|
||||||
service.NewRootCommand(),
|
service.NewRootCommand(),
|
||||||
service.NewExecCommand("service"),
|
service.NewInspectCommand(),
|
||||||
service.NewInspectCommand("service"),
|
service.NewListCommand(),
|
||||||
service.NewListCommand("service"),
|
service.NewRmCommand(),
|
||||||
service.NewLogsCommand("service"),
|
service.NewRunCommand(),
|
||||||
service.NewRmCommand("service"),
|
service.NewScaleCommand(),
|
||||||
service.NewRunCommand("service"),
|
|
||||||
service.NewScaleCommand("service"),
|
|
||||||
service.NewStartCommand("service"),
|
|
||||||
service.NewStopCommand("service"),
|
|
||||||
volume.NewRootCommand(),
|
volume.NewRootCommand(),
|
||||||
wg.NewRootCommand(),
|
|
||||||
)
|
)
|
||||||
cobra.CheckErr(cmd.Execute())
|
cobra.CheckErr(cmd.Execute())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,290 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"charm.land/huh/v2/spinner"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
"charm.land/lipgloss/v2/table"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
|
||||||
"github.com/docker/go-units"
|
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
sortByService = "service"
|
|
||||||
sortByMachine = "machine"
|
|
||||||
sortByHealth = "health"
|
|
||||||
)
|
|
||||||
|
|
||||||
type containerHighlight int
|
|
||||||
|
|
||||||
const (
|
|
||||||
highlightDanger containerHighlight = iota
|
|
||||||
highlightWarning
|
|
||||||
highlightSuccess
|
|
||||||
highlightNormal
|
|
||||||
)
|
|
||||||
|
|
||||||
type psOptions struct {
|
|
||||||
sortBy string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPsCommand() *cobra.Command {
|
|
||||||
opts := psOptions{}
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "ps",
|
|
||||||
Short: "List all service containers.",
|
|
||||||
Long: `List all service containers across all machines in the cluster.
|
|
||||||
|
|
||||||
This command provides a comprehensive overview of all running containers that are part of a service,
|
|
||||||
making it easy to see the distribution and status of containers across the cluster.`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
|
||||||
|
|
||||||
if opts.sortBy != sortByService && opts.sortBy != sortByMachine && opts.sortBy != sortByHealth {
|
|
||||||
return fmt.Errorf("invalid value for --sort: %q, must be one of '%s', '%s' or '%s'", opts.sortBy,
|
|
||||||
sortByService, sortByMachine, sortByHealth)
|
|
||||||
}
|
|
||||||
|
|
||||||
return runPs(cmd.Context(), uncli, opts)
|
|
||||||
},
|
|
||||||
GroupID: "service",
|
|
||||||
}
|
|
||||||
cmd.Flags().StringVarP(&opts.sortBy, "sort", "s", sortByService,
|
|
||||||
"Sort containers by 'service', 'machine', or 'health'.")
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
type containerInfo struct {
|
|
||||||
serviceName string
|
|
||||||
machineName string
|
|
||||||
id string
|
|
||||||
name string
|
|
||||||
image string
|
|
||||||
status string
|
|
||||||
highlight containerHighlight
|
|
||||||
created time.Time
|
|
||||||
ip string
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
|
||||||
}
|
|
||||||
defer clusterClient.Close()
|
|
||||||
|
|
||||||
var containers []containerInfo
|
|
||||||
err = spinner.New().
|
|
||||||
Title(" Collecting container info...").
|
|
||||||
Type(spinner.MiniDot).
|
|
||||||
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
|
|
||||||
return &spinner.Styles{
|
|
||||||
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
|
||||||
Title: lipgloss.NewStyle(),
|
|
||||||
}
|
|
||||||
})).
|
|
||||||
ActionWithErr(func(ctx context.Context) error {
|
|
||||||
containers, err = collectContainers(ctx, clusterClient)
|
|
||||||
return err
|
|
||||||
}).
|
|
||||||
Run()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("collect containers: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort the containers based on the sorting option.
|
|
||||||
sort.SliceStable(containers, func(i, j int) bool {
|
|
||||||
a, b := containers[i], containers[j]
|
|
||||||
switch opts.sortBy {
|
|
||||||
case sortByHealth:
|
|
||||||
if a.highlight != b.highlight {
|
|
||||||
return a.highlight < b.highlight
|
|
||||||
}
|
|
||||||
if a.serviceName != b.serviceName {
|
|
||||||
return a.serviceName < b.serviceName
|
|
||||||
}
|
|
||||||
case sortByMachine:
|
|
||||||
if a.machineName != b.machineName {
|
|
||||||
return a.machineName < b.machineName
|
|
||||||
}
|
|
||||||
if a.serviceName != b.serviceName {
|
|
||||||
return a.serviceName < b.serviceName
|
|
||||||
}
|
|
||||||
default: // sortByService
|
|
||||||
if a.serviceName != b.serviceName {
|
|
||||||
return a.serviceName < b.serviceName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback to creation time (newest first).
|
|
||||||
return a.created.After(b.created)
|
|
||||||
})
|
|
||||||
|
|
||||||
return printContainers(containers)
|
|
||||||
}
|
|
||||||
|
|
||||||
func printContainers(containers []containerInfo) error {
|
|
||||||
t := table.New().
|
|
||||||
// Remove the default border.
|
|
||||||
Border(lipgloss.Border{}).
|
|
||||||
BorderTop(false).
|
|
||||||
BorderBottom(false).
|
|
||||||
BorderLeft(false).
|
|
||||||
BorderRight(false).
|
|
||||||
BorderHeader(false).
|
|
||||||
BorderColumn(false).
|
|
||||||
StyleFunc(func(row, col int) lipgloss.Style {
|
|
||||||
if row == table.HeaderRow {
|
|
||||||
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
|
|
||||||
}
|
|
||||||
// Regular style for data rows with padding.
|
|
||||||
return lipgloss.NewStyle().PaddingRight(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Headers("SERVICE", "CONTAINER ID", "CONTAINER NAME", "IMAGE", "CREATED", "STATUS", "IP ADDRESS", "MACHINE")
|
|
||||||
|
|
||||||
for _, ctr := range containers {
|
|
||||||
id := ctr.id
|
|
||||||
if len(id) > 12 {
|
|
||||||
id = id[:12]
|
|
||||||
}
|
|
||||||
|
|
||||||
created := units.HumanDuration(time.Now().UTC().Sub(ctr.created)) + " ago"
|
|
||||||
|
|
||||||
var statusStyle lipgloss.Style
|
|
||||||
switch ctr.highlight {
|
|
||||||
case highlightSuccess:
|
|
||||||
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // Green
|
|
||||||
case highlightDanger:
|
|
||||||
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // Red
|
|
||||||
case highlightWarning:
|
|
||||||
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // Yellow
|
|
||||||
default:
|
|
||||||
statusStyle = lipgloss.NewStyle() // Default
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Row(
|
|
||||||
ctr.serviceName,
|
|
||||||
id,
|
|
||||||
ctr.name,
|
|
||||||
ctr.image,
|
|
||||||
created,
|
|
||||||
statusStyle.Render(ctr.status),
|
|
||||||
ctr.ip,
|
|
||||||
ctr.machineName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(t)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo, error) {
|
|
||||||
listCtx, machines, err := cli.ProxyMachinesContext(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("proxy machines context: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a map of IP to machine name for resolving response metadata
|
|
||||||
machinesNamesByIP := make(map[string]string)
|
|
||||||
for _, m := range machines {
|
|
||||||
if addr, err := m.Machine.Network.ManagementIp.ToAddr(); err == nil {
|
|
||||||
machinesNamesByIP[addr.String()] = m.Machine.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// List all service containers across all machines in the cluster.
|
|
||||||
machineContainers, err := cli.Docker.ListServiceContainers(
|
|
||||||
listCtx, "", container.ListOptions{All: true},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list service containers: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var containers []containerInfo
|
|
||||||
for _, msc := range machineContainers {
|
|
||||||
// Metadata can be nil if the request was broadcasted to only one machine.
|
|
||||||
if msc.Metadata == nil && len(machineContainers) > 1 {
|
|
||||||
return nil, fmt.Errorf("something went wrong with gRPC proxy: metadata is missing for a machine response")
|
|
||||||
}
|
|
||||||
|
|
||||||
machineName := "unknown"
|
|
||||||
if msc.Metadata != nil {
|
|
||||||
var ok bool
|
|
||||||
machineName, ok = machinesNamesByIP[msc.Metadata.Machine]
|
|
||||||
if !ok {
|
|
||||||
// Fallback to machine's IP as name.
|
|
||||||
machineName = msc.Metadata.Machine
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to the first available machine name.
|
|
||||||
if len(machines) > 0 {
|
|
||||||
machineName = machines[0].Machine.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if msc.Metadata != nil && msc.Metadata.Error != "" {
|
|
||||||
tui.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName,
|
|
||||||
msc.Metadata.Error))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ctr := range msc.Containers {
|
|
||||||
if ctr.Container.State == nil || ctr.Container.Config == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
status, err := ctr.Container.HumanState()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get human state for container %s: %w", ctr.Container.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var highlight containerHighlight
|
|
||||||
healthStatus := ""
|
|
||||||
if ctr.Container.State.Health != nil {
|
|
||||||
healthStatus = ctr.Container.State.Health.Status
|
|
||||||
}
|
|
||||||
|
|
||||||
if healthStatus == container.Unhealthy || ctr.Container.State.Status == "dead" || ctr.Container.State.OOMKilled || ctr.Container.State.Dead {
|
|
||||||
highlight = highlightDanger
|
|
||||||
} else if healthStatus == container.Healthy {
|
|
||||||
highlight = highlightSuccess
|
|
||||||
} else if ctr.Container.State.Status == "running" {
|
|
||||||
highlight = highlightNormal
|
|
||||||
} else { // Other non-critical but noteworthy states
|
|
||||||
highlight = highlightWarning
|
|
||||||
}
|
|
||||||
|
|
||||||
created, _ := time.Parse(time.RFC3339Nano, ctr.Container.Created)
|
|
||||||
|
|
||||||
ip := ctr.Container.UncloudNetworkIP()
|
|
||||||
ipStr := ""
|
|
||||||
// The container might not have an IP if it's not running or uses the host network.
|
|
||||||
if ip.IsValid() {
|
|
||||||
ipStr = ip.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
info := containerInfo{
|
|
||||||
serviceName: ctr.ServiceName(),
|
|
||||||
machineName: machineName,
|
|
||||||
id: ctr.Container.ID,
|
|
||||||
name: ctr.Container.Name,
|
|
||||||
image: ctr.Container.Config.Image,
|
|
||||||
status: status,
|
|
||||||
highlight: highlight,
|
|
||||||
created: created,
|
|
||||||
ip: ipStr,
|
|
||||||
}
|
|
||||||
containers = append(containers, info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return containers, nil
|
|
||||||
}
|
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/netip"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mockDockerClient implements pb.DockerClient
|
|
||||||
type mockDockerClient struct {
|
|
||||||
pb.DockerClient // Embed to avoid implementing all methods
|
|
||||||
listResp *pb.ListServiceContainersResponse
|
|
||||||
listErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockDockerClient) ListServiceContainers(ctx context.Context, in *pb.ListServiceContainersRequest, opts ...grpc.CallOption) (*pb.ListServiceContainersResponse, error) {
|
|
||||||
return m.listResp, m.listErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// mockClusterClient implements pb.ClusterClient
|
|
||||||
type mockClusterClient struct {
|
|
||||||
pb.ClusterClient // Embed to avoid implementing all methods
|
|
||||||
machinesResp *pb.ListMachinesResponse
|
|
||||||
machinesErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockClusterClient) ListMachines(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*pb.ListMachinesResponse, error) {
|
|
||||||
return m.machinesResp, m.machinesErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectContainers_NilMetadata(t *testing.T) {
|
|
||||||
// Setup container data
|
|
||||||
containerData := map[string]any{
|
|
||||||
"Id": "container1",
|
|
||||||
"Name": "test-container",
|
|
||||||
"Config": map[string]any{
|
|
||||||
"Image": "test-image",
|
|
||||||
},
|
|
||||||
"State": map[string]any{
|
|
||||||
"Status": "running",
|
|
||||||
"StartedAt": "2023-01-01T12:00:00Z",
|
|
||||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
"NetworkSettings": map[string]any{
|
|
||||||
"Networks": map[string]any{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
containerJSON, err := json.Marshal(containerData)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
serviceSpecJSON, err := json.Marshal(map[string]any{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Setup mocks
|
|
||||||
mockDocker := &mockDockerClient{
|
|
||||||
listResp: &pb.ListServiceContainersResponse{
|
|
||||||
Messages: []*pb.MachineServiceContainers{
|
|
||||||
{
|
|
||||||
Metadata: nil, // Simulating the issue: nil metadata
|
|
||||||
Containers: []*pb.ServiceContainer{
|
|
||||||
{
|
|
||||||
Container: containerJSON,
|
|
||||||
ServiceSpec: serviceSpecJSON,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
machineIP := "10.0.0.1"
|
|
||||||
mockCluster := &mockClusterClient{
|
|
||||||
machinesResp: &pb.ListMachinesResponse{
|
|
||||||
Machines: []*pb.MachineMember{
|
|
||||||
{
|
|
||||||
Machine: &pb.MachineInfo{
|
|
||||||
Name: "machine-1",
|
|
||||||
Network: &pb.NetworkConfig{
|
|
||||||
ManagementIp: pb.NewIP(netip.MustParseAddr(machineIP)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
State: pb.MachineMember_UP,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct client with mocks
|
|
||||||
cli := &client.Client{
|
|
||||||
Docker: &docker.Client{
|
|
||||||
GRPCClient: mockDocker,
|
|
||||||
},
|
|
||||||
ClusterClient: mockCluster,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute
|
|
||||||
containers, err := collectContainers(context.Background(), cli)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
assert.Len(t, containers, 1)
|
|
||||||
if len(containers) > 0 {
|
|
||||||
c := containers[0]
|
|
||||||
assert.Equal(t, "container1", c.id)
|
|
||||||
assert.Equal(t, "test-container", c.name)
|
|
||||||
assert.Equal(t, "machine-1", c.machineName, "Should fall back to the single machine name when metadata is nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectContainers_NilMetadata_MultipleMachines_Error(t *testing.T) {
|
|
||||||
// If we have multiple machines but receive nil metadata, it should return an error as it is ambiguous
|
|
||||||
|
|
||||||
// Setup container data
|
|
||||||
containerData1 := map[string]any{
|
|
||||||
"Id": "container1",
|
|
||||||
}
|
|
||||||
containerJSON1, _ := json.Marshal(containerData1)
|
|
||||||
|
|
||||||
containerData2 := map[string]any{
|
|
||||||
"Id": "container2",
|
|
||||||
"Config": map[string]any{
|
|
||||||
"Image": "test-image",
|
|
||||||
},
|
|
||||||
"State": map[string]any{
|
|
||||||
"Status": "running",
|
|
||||||
"StartedAt": "2023-01-01T12:00:00Z",
|
|
||||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
"NetworkSettings": map[string]any{
|
|
||||||
"Networks": map[string]any{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
containerJSON2, _ := json.Marshal(containerData2)
|
|
||||||
|
|
||||||
serviceSpecJSON, _ := json.Marshal(map[string]any{})
|
|
||||||
|
|
||||||
// Setup mocks
|
|
||||||
mockDocker := &mockDockerClient{
|
|
||||||
listResp: &pb.ListServiceContainersResponse{
|
|
||||||
Messages: []*pb.MachineServiceContainers{
|
|
||||||
{
|
|
||||||
Metadata: nil, // Nil metadata
|
|
||||||
Containers: []*pb.ServiceContainer{
|
|
||||||
{
|
|
||||||
Container: containerJSON1,
|
|
||||||
ServiceSpec: serviceSpecJSON,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Metadata: &pb.Metadata{Machine: "10.0.0.2"},
|
|
||||||
Containers: []*pb.ServiceContainer{
|
|
||||||
{
|
|
||||||
Container: containerJSON2,
|
|
||||||
ServiceSpec: serviceSpecJSON,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
mockCluster := &mockClusterClient{
|
|
||||||
machinesResp: &pb.ListMachinesResponse{
|
|
||||||
Machines: []*pb.MachineMember{
|
|
||||||
{
|
|
||||||
Machine: &pb.MachineInfo{
|
|
||||||
Name: "machine-1",
|
|
||||||
Network: &pb.NetworkConfig{
|
|
||||||
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Machine: &pb.MachineInfo{
|
|
||||||
Name: "machine-2",
|
|
||||||
Network: &pb.NetworkConfig{
|
|
||||||
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.2")),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct client with mocks
|
|
||||||
cli := &client.Client{
|
|
||||||
Docker: &docker.Client{
|
|
||||||
GRPCClient: mockDocker,
|
|
||||||
},
|
|
||||||
ClusterClient: mockCluster,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute
|
|
||||||
_, err := collectContainers(context.Background(), cli)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "metadata is missing for a machine response")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectContainers_MetadataPresent_MultipleMachines(t *testing.T) {
|
|
||||||
// Verify correct mapping of containers to machines when metadata is present
|
|
||||||
|
|
||||||
// Setup container data
|
|
||||||
containerData1 := map[string]any{
|
|
||||||
"Id": "container1",
|
|
||||||
"Name": "container-1",
|
|
||||||
"Config": map[string]any{
|
|
||||||
"Image": "image-1",
|
|
||||||
},
|
|
||||||
"State": map[string]any{
|
|
||||||
"Status": "running",
|
|
||||||
"StartedAt": "2023-01-01T12:00:00Z",
|
|
||||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
"NetworkSettings": map[string]any{
|
|
||||||
"Networks": map[string]any{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
containerJSON1, _ := json.Marshal(containerData1)
|
|
||||||
|
|
||||||
containerData2 := map[string]any{
|
|
||||||
"Id": "container2",
|
|
||||||
"Name": "container-2",
|
|
||||||
"Config": map[string]any{
|
|
||||||
"Image": "image-2",
|
|
||||||
},
|
|
||||||
"State": map[string]any{
|
|
||||||
"Status": "running",
|
|
||||||
"StartedAt": "2023-01-01T12:00:00Z",
|
|
||||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
"NetworkSettings": map[string]any{
|
|
||||||
"Networks": map[string]any{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
containerJSON2, _ := json.Marshal(containerData2)
|
|
||||||
|
|
||||||
serviceSpecJSON, _ := json.Marshal(map[string]any{})
|
|
||||||
|
|
||||||
// Setup mocks
|
|
||||||
mockDocker := &mockDockerClient{
|
|
||||||
listResp: &pb.ListServiceContainersResponse{
|
|
||||||
Messages: []*pb.MachineServiceContainers{
|
|
||||||
{
|
|
||||||
Metadata: &pb.Metadata{Machine: "10.0.0.1"},
|
|
||||||
Containers: []*pb.ServiceContainer{
|
|
||||||
{
|
|
||||||
Container: containerJSON1,
|
|
||||||
ServiceSpec: serviceSpecJSON,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Metadata: &pb.Metadata{Machine: "10.0.0.2"},
|
|
||||||
Containers: []*pb.ServiceContainer{
|
|
||||||
{
|
|
||||||
Container: containerJSON2,
|
|
||||||
ServiceSpec: serviceSpecJSON,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
mockCluster := &mockClusterClient{
|
|
||||||
machinesResp: &pb.ListMachinesResponse{
|
|
||||||
Machines: []*pb.MachineMember{
|
|
||||||
{
|
|
||||||
Machine: &pb.MachineInfo{
|
|
||||||
Name: "machine-1",
|
|
||||||
Network: &pb.NetworkConfig{
|
|
||||||
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Machine: &pb.MachineInfo{
|
|
||||||
Name: "machine-2",
|
|
||||||
Network: &pb.NetworkConfig{
|
|
||||||
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.2")),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cli := &client.Client{
|
|
||||||
Docker: &docker.Client{GRPCClient: mockDocker},
|
|
||||||
ClusterClient: mockCluster,
|
|
||||||
}
|
|
||||||
|
|
||||||
containers, err := collectContainers(context.Background(), cli)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Len(t, containers, 2)
|
|
||||||
// Order is not guaranteed by the map iteration in logic or parallel fetch (though here it's mocked sequential),
|
|
||||||
// but collectContainers just appends.
|
|
||||||
// We'll find them by ID.
|
|
||||||
for _, c := range containers {
|
|
||||||
if c.id == "container1" {
|
|
||||||
assert.Equal(t, "machine-1", c.machineName)
|
|
||||||
} else if c.id == "container2" {
|
|
||||||
assert.Equal(t, "machine-2", c.machineName)
|
|
||||||
} else {
|
|
||||||
t.Errorf("unexpected container id: %s", c.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectContainers_NilMetadata_NoMachines(t *testing.T) {
|
|
||||||
// Case: 1 msc with nil metadata but no machines at all
|
|
||||||
|
|
||||||
containerData := map[string]any{
|
|
||||||
"Id": "container1",
|
|
||||||
"Config": map[string]any{
|
|
||||||
"Image": "test-image",
|
|
||||||
},
|
|
||||||
"State": map[string]any{
|
|
||||||
"Status": "running",
|
|
||||||
"StartedAt": "2023-01-01T12:00:00Z",
|
|
||||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
"NetworkSettings": map[string]any{
|
|
||||||
"Networks": map[string]any{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
containerJSON, _ := json.Marshal(containerData)
|
|
||||||
serviceSpecJSON, _ := json.Marshal(map[string]any{})
|
|
||||||
|
|
||||||
mockDocker := &mockDockerClient{
|
|
||||||
listResp: &pb.ListServiceContainersResponse{
|
|
||||||
Messages: []*pb.MachineServiceContainers{
|
|
||||||
{
|
|
||||||
Metadata: nil,
|
|
||||||
Containers: []*pb.ServiceContainer{
|
|
||||||
{
|
|
||||||
Container: containerJSON,
|
|
||||||
ServiceSpec: serviceSpecJSON,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// No machines in cluster response
|
|
||||||
mockCluster := &mockClusterClient{
|
|
||||||
machinesResp: &pb.ListMachinesResponse{
|
|
||||||
Machines: []*pb.MachineMember{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cli := &client.Client{
|
|
||||||
Docker: &docker.Client{GRPCClient: mockDocker},
|
|
||||||
ClusterClient: mockCluster,
|
|
||||||
}
|
|
||||||
|
|
||||||
containers, err := collectContainers(context.Background(), cli)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Len(t, containers, 1)
|
|
||||||
if len(containers) > 0 {
|
|
||||||
assert.Equal(t, "unknown", containers[0].machineName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectContainers_MetadataPresent_NotInMapping(t *testing.T) {
|
|
||||||
// Case: msc with metadata that is not in the IP-to-name mapping
|
|
||||||
|
|
||||||
containerData := map[string]any{
|
|
||||||
"Id": "container1",
|
|
||||||
"Config": map[string]any{
|
|
||||||
"Image": "test-image",
|
|
||||||
},
|
|
||||||
"State": map[string]any{
|
|
||||||
"Status": "running",
|
|
||||||
"StartedAt": "2023-01-01T12:00:00Z",
|
|
||||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
"NetworkSettings": map[string]any{
|
|
||||||
"Networks": map[string]any{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
containerJSON, _ := json.Marshal(containerData)
|
|
||||||
serviceSpecJSON, _ := json.Marshal(map[string]any{})
|
|
||||||
|
|
||||||
mockDocker := &mockDockerClient{
|
|
||||||
listResp: &pb.ListServiceContainersResponse{
|
|
||||||
Messages: []*pb.MachineServiceContainers{
|
|
||||||
{
|
|
||||||
Metadata: &pb.Metadata{Machine: "10.0.0.99"}, // Unknown IP
|
|
||||||
Containers: []*pb.ServiceContainer{
|
|
||||||
{
|
|
||||||
Container: containerJSON,
|
|
||||||
ServiceSpec: serviceSpecJSON,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
mockCluster := &mockClusterClient{
|
|
||||||
machinesResp: &pb.ListMachinesResponse{
|
|
||||||
Machines: []*pb.MachineMember{
|
|
||||||
{
|
|
||||||
Machine: &pb.MachineInfo{
|
|
||||||
Name: "machine-1",
|
|
||||||
Network: &pb.NetworkConfig{
|
|
||||||
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cli := &client.Client{
|
|
||||||
Docker: &docker.Client{GRPCClient: mockDocker},
|
|
||||||
ClusterClient: mockCluster,
|
|
||||||
}
|
|
||||||
|
|
||||||
containers, err := collectContainers(context.Background(), cli)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Len(t, containers, 1)
|
|
||||||
if len(containers) > 0 {
|
|
||||||
// Should fallback to the IP/string in metadata
|
|
||||||
assert.Equal(t, "10.0.0.99", containers[0].machineName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/docker/cli/cli/streams"
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
type execCliOptions struct {
|
|
||||||
detach bool
|
|
||||||
interactive bool
|
|
||||||
noTty bool
|
|
||||||
containerId string
|
|
||||||
}
|
|
||||||
|
|
||||||
var DEFAULT_COMMAND = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
|
|
||||||
|
|
||||||
func NewExecCommand(groupID string) *cobra.Command {
|
|
||||||
opts := execCliOptions{}
|
|
||||||
|
|
||||||
execCmd := &cobra.Command{
|
|
||||||
Use: "exec [OPTIONS] SERVICE [COMMAND ARGS...]",
|
|
||||||
Short: "Execute a command in a running service container.",
|
|
||||||
Long: `Execute a command (interactive shell by default) in a running container within a service.
|
|
||||||
If the service has multiple replicas and no container ID is specified, the command will be executed in a random container.
|
|
||||||
`,
|
|
||||||
Example: `
|
|
||||||
# Start an interactive shell ("bash" or "sh" will be tried by default)
|
|
||||||
uc exec web-service
|
|
||||||
|
|
||||||
# Start an interactive shell with explicit command
|
|
||||||
uc exec web-service /bin/zsh
|
|
||||||
|
|
||||||
# List files in the specific container of the service; --container accepts full ID or a (unique) prefix
|
|
||||||
uc exec --container d792e web-service ls -la
|
|
||||||
|
|
||||||
# Pipe input to a command inside the service container
|
|
||||||
cat backup.sql | uc exec -T db-service psql -U postgres mydb
|
|
||||||
|
|
||||||
# Run a task in the background (detached mode)
|
|
||||||
uc exec -d web-service /scripts/cleanup.sh`,
|
|
||||||
Args: cobra.MinimumNArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
|
||||||
serviceName := args[0]
|
|
||||||
command := args[1:]
|
|
||||||
if len(command) == 0 {
|
|
||||||
command = DEFAULT_COMMAND
|
|
||||||
}
|
|
||||||
return runExec(cmd.Context(), uncli, serviceName, command, opts)
|
|
||||||
},
|
|
||||||
GroupID: groupID,
|
|
||||||
}
|
|
||||||
|
|
||||||
execCmd.Flags().BoolVarP(&opts.detach, "detach", "d", false, "Detached mode: run command in the background")
|
|
||||||
|
|
||||||
execCmd.Flags().BoolVarP(&opts.noTty, "no-tty", "T", false,
|
|
||||||
"Disable pseudo-TTY allocation. By default 'uc exec' allocates a TTY when connected to a terminal.")
|
|
||||||
|
|
||||||
// Keep "-i" and "-t" flags hidden for compatibility with docker exec
|
|
||||||
execCmd.Flags().BoolVarP(&opts.interactive, "interactive", "i", true, "Keep STDIN open even if not attached")
|
|
||||||
execCmd.Flags().MarkHidden("interactive")
|
|
||||||
|
|
||||||
execCmd.Flags().BoolP("tty", "t", false, "Allocate a pseudo-TTY")
|
|
||||||
execCmd.Flags().MarkHidden("tty")
|
|
||||||
|
|
||||||
// Common flags
|
|
||||||
execCmd.Flags().StringVar(&opts.containerId, "container", "",
|
|
||||||
"ID of the container to exec into. Accepts full ID or a unique prefix "+
|
|
||||||
"(default is the random container of the service)")
|
|
||||||
|
|
||||||
// This tells Cobra that all flags must come before positional arguments, so that
|
|
||||||
// commands with their own flags can be handled correctly.
|
|
||||||
execCmd.Flags().SetInterspersed(false)
|
|
||||||
|
|
||||||
return execCmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error {
|
|
||||||
// Disable TTY allocation if not connected to a terminal
|
|
||||||
if !tui.IsStdoutTerminal() {
|
|
||||||
opts.noTty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if !opts.detach {
|
|
||||||
// Check if we're trying to attach to a TTY from a non-TTY client, e.g.
|
|
||||||
// when doing an 'cmd | uc exec ...'
|
|
||||||
stdin := streams.NewIn(os.Stdin)
|
|
||||||
// TODO: this logic/behavior mirrors docker-compose, but we can be smarter about it and detect TTY dynamically
|
|
||||||
if err := stdin.CheckTty(opts.interactive, !opts.noTty); err != nil {
|
|
||||||
return fmt.Errorf("check TTY: %w; use -T option to disable TTY allocation", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := uncli.ConnectClusterWithOptions(ctx, cli.ConnectOptions{
|
|
||||||
ShowProgress: false,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
execConfig := api.ExecOptions{
|
|
||||||
Command: command,
|
|
||||||
AttachStdin: opts.interactive,
|
|
||||||
Tty: !opts.noTty,
|
|
||||||
Detach: opts.detach,
|
|
||||||
}
|
|
||||||
|
|
||||||
if !opts.detach {
|
|
||||||
execConfig.AttachStdout = true
|
|
||||||
execConfig.AttachStderr = true
|
|
||||||
}
|
|
||||||
|
|
||||||
exitCode, err := client.ExecContainer(ctx, serviceName, opts.containerId, execConfig)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("exec container: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For non-detached mode, exit with the same code as the executed command
|
|
||||||
if !opts.detach {
|
|
||||||
if exitCode != 0 {
|
|
||||||
os.Exit(exitCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -4,22 +4,22 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/pkg/stringid"
|
"github.com/docker/docker/pkg/stringid"
|
||||||
"github.com/docker/go-units"
|
"github.com/docker/go-units"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type inspectOptions struct {
|
type inspectOptions struct {
|
||||||
service string
|
service string
|
||||||
|
cluster string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInspectCommand(groupID string) *cobra.Command {
|
func NewInspectCommand() *cobra.Command {
|
||||||
opts := inspectOptions{}
|
opts := inspectOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "inspect SERVICE",
|
Use: "inspect SERVICE",
|
||||||
@@ -30,13 +30,16 @@ func NewInspectCommand(groupID string) *cobra.Command {
|
|||||||
opts.service = args[0]
|
opts.service = args[0]
|
||||||
return inspect(cmd.Context(), uncli, opts)
|
return inspect(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
GroupID: groupID,
|
|
||||||
}
|
}
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.cluster, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -56,31 +59,23 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
|||||||
machinesNamesByID[m.Machine.Id] = m.Machine.Name
|
machinesNamesByID[m.Machine.Id] = m.Machine.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Service ID: %s\n", svc.ID)
|
fmt.Printf("ID: %s\n", svc.ID)
|
||||||
fmt.Printf("Name: %s\n", svc.Name)
|
fmt.Printf("Name: %s\n", svc.Name)
|
||||||
fmt.Printf("Mode: %s\n", svc.Mode)
|
fmt.Printf("Mode: %s\n", svc.Mode)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Parse created times for sorting and display.
|
|
||||||
createdTimes := make(map[string]time.Time, len(svc.Containers))
|
|
||||||
for _, ctr := range svc.Containers {
|
|
||||||
createdTimes[ctr.Container.ID], _ = time.Parse(time.RFC3339Nano, ctr.Container.Created)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort containers by created time (newest first).
|
|
||||||
slices.SortFunc(svc.Containers, func(a, b api.MachineServiceContainer) int {
|
|
||||||
return createdTimes[b.Container.ID].Compare(createdTimes[a.Container.ID])
|
|
||||||
})
|
|
||||||
|
|
||||||
// Print the list of containers in a table format.
|
// Print the list of containers in a table format.
|
||||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tIP ADDRESS\tMACHINE"); err != nil {
|
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tMACHINE"); err != nil {
|
||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
for _, ctr := range svc.Containers {
|
for _, ctr := range svc.Containers {
|
||||||
created := units.HumanDuration(now.Sub(createdTimes[ctr.Container.ID])) + " ago"
|
createdAt, err := time.Parse(time.RFC3339Nano, ctr.Container.Created)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse created time: %w", err)
|
||||||
|
}
|
||||||
|
created := units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
|
||||||
|
|
||||||
machine := machinesNamesByID[ctr.MachineID]
|
machine := machinesNamesByID[ctr.MachineID]
|
||||||
if machine == "" {
|
if machine == "" {
|
||||||
@@ -91,21 +86,13 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
|||||||
return fmt.Errorf("get human state: %w", err)
|
return fmt.Errorf("get human state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ip := ctr.Container.UncloudNetworkIP()
|
|
||||||
ipStr := ""
|
|
||||||
// The container might not have an IP if it's not running or uses the host network.
|
|
||||||
if ip.IsValid() {
|
|
||||||
ipStr = ip.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = fmt.Fprintf(
|
_, err = fmt.Fprintf(
|
||||||
tw,
|
tw,
|
||||||
"%s\t%s\t%s\t%s\t%s\t%s\n",
|
"%s\t%s\t%s\t%s\t%s\n",
|
||||||
stringid.TruncateID(ctr.Container.ID),
|
stringid.TruncateID(ctr.Container.ID),
|
||||||
ctr.Container.Config.Image,
|
ctr.Container.Config.Image,
|
||||||
created,
|
created,
|
||||||
state,
|
state,
|
||||||
ipStr,
|
|
||||||
machine,
|
machine,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,363 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"image/color"
|
|
||||||
"os"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
mapset "github.com/deckarep/golang-set/v2"
|
|
||||||
"github.com/docker/docker/pkg/stringid"
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
|
||||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
type logsOptions struct {
|
|
||||||
files []string
|
|
||||||
follow bool
|
|
||||||
tail string
|
|
||||||
since string
|
|
||||||
until string
|
|
||||||
utc bool
|
|
||||||
machines []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewLogsCommand(groupID string) *cobra.Command {
|
|
||||||
var options logsOptions
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "logs [SERVICE...]",
|
|
||||||
Aliases: []string{"log"},
|
|
||||||
Short: "View service logs.",
|
|
||||||
Long: `View logs from all replicas of the specified service(s) across all machines in the cluster.
|
|
||||||
|
|
||||||
If no services are specified, streams logs from all services defined in the Compose file
|
|
||||||
(compose.yaml by default or the file(s) specified with --file).`,
|
|
||||||
Example: ` # View recent logs for a service.
|
|
||||||
uc logs web
|
|
||||||
|
|
||||||
# Stream logs in real-time (follow mode).
|
|
||||||
uc logs -f web
|
|
||||||
|
|
||||||
# View logs from multiple services.
|
|
||||||
uc logs web api db
|
|
||||||
|
|
||||||
# View logs from all services in compose.yaml.
|
|
||||||
uc logs
|
|
||||||
|
|
||||||
# Show last 20 lines per replica (default is 100).
|
|
||||||
uc logs -n 20 web
|
|
||||||
|
|
||||||
# Show all logs without line limit.
|
|
||||||
uc logs -n all web
|
|
||||||
|
|
||||||
# View logs from a specific time range.
|
|
||||||
uc logs --since 3h --until 1h30m web
|
|
||||||
|
|
||||||
# View logs only from replicas running on specific machines.
|
|
||||||
uc logs -m machine1,machine2 web api`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
|
||||||
return runLogs(cmd.Context(), uncli, args, options)
|
|
||||||
},
|
|
||||||
GroupID: groupID,
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringSliceVar(&options.files, "file", nil,
|
|
||||||
"One or more Compose files to load service names from when no services are specified. (default compose.yaml)")
|
|
||||||
cmd.Flags().BoolVarP(&options.follow, "follow", "f", false,
|
|
||||||
"Continually stream new logs.")
|
|
||||||
cmd.Flags().StringSliceVarP(&options.machines, "machine", "m", nil,
|
|
||||||
"Filter logs by machine name or ID. Can be specified multiple times or as a comma-separated list.")
|
|
||||||
cmd.Flags().StringVar(&options.since, "since", "",
|
|
||||||
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
|
||||||
"Examples:\n"+
|
|
||||||
" --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+
|
|
||||||
" --since 1h Relative duration (1 hour ago)\n"+
|
|
||||||
" --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+
|
|
||||||
" --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+
|
|
||||||
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
|
|
||||||
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
|
|
||||||
cmd.Flags().StringVarP(&options.tail, "tail", "n", "100",
|
|
||||||
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.")
|
|
||||||
cmd.Flags().StringVar(&options.until, "until", "",
|
|
||||||
"Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
|
||||||
"See --since for examples.")
|
|
||||||
cmd.Flags().BoolVar(&options.utc, "utc", false,
|
|
||||||
"Print timestamps in UTC instead of local timezone.")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts logsOptions) error {
|
|
||||||
// If no services specified, try to load them from the Compose file(s).
|
|
||||||
fromCompose := false
|
|
||||||
if len(serviceNames) == 0 {
|
|
||||||
fromCompose = true
|
|
||||||
project, err := compose.LoadProject(ctx, opts.files)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("load Compose file(s): %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
|
|
||||||
|
|
||||||
// View logs for all services, including disabled by inactive profiles.
|
|
||||||
serviceNames = append(project.ServiceNames(), project.DisabledServiceNames()...)
|
|
||||||
if len(serviceNames) == 0 {
|
|
||||||
return errors.New("no services found in Compose file(s)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse tail option.
|
|
||||||
tail := -1
|
|
||||||
if opts.tail != "all" {
|
|
||||||
tailInt, err := strconv.Atoi(opts.tail)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid --tail value '%s': %w", opts.tail, err)
|
|
||||||
}
|
|
||||||
tail = tailInt
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := uncli.ConnectCluster(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
|
||||||
}
|
|
||||||
defer c.Close()
|
|
||||||
|
|
||||||
logsOpts := api.ServiceLogsOptions{
|
|
||||||
Follow: opts.follow,
|
|
||||||
Tail: tail,
|
|
||||||
Since: opts.since,
|
|
||||||
Until: opts.until,
|
|
||||||
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect log streams from all services. When service names come from a Compose file,
|
|
||||||
// skip the ones that are not found in the cluster (they may have been removed or not deployed yet).
|
|
||||||
machineIDsSet := mapset.NewSet[string]()
|
|
||||||
svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceNames))
|
|
||||||
var foundServices, notFoundServices []string
|
|
||||||
|
|
||||||
for _, serviceName := range serviceNames {
|
|
||||||
svc, ch, err := c.ServiceLogs(ctx, serviceName, logsOpts)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, api.ErrNotFound) && fromCompose {
|
|
||||||
notFoundServices = append(notFoundServices, serviceName)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return fmt.Errorf("stream logs for service '%s': %w", serviceName, err)
|
|
||||||
}
|
|
||||||
svcStreams = append(svcStreams, ch)
|
|
||||||
foundServices = append(foundServices, serviceName)
|
|
||||||
|
|
||||||
machineIDs := svc.MachineIDs()
|
|
||||||
machineIDsSet.Append(machineIDs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if fromCompose {
|
|
||||||
if len(foundServices) == 0 {
|
|
||||||
return fmt.Errorf("stream logs for services defined in %s: no services found in the cluster",
|
|
||||||
strings.Join(opts.files, ", "))
|
|
||||||
}
|
|
||||||
serviceNames = foundServices
|
|
||||||
|
|
||||||
for _, name := range notFoundServices {
|
|
||||||
tui.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var stream <-chan api.ServiceLogEntry
|
|
||||||
if len(serviceNames) == 1 {
|
|
||||||
stream = svcStreams[0]
|
|
||||||
} else {
|
|
||||||
// Merge all service streams into a single sorted stream without stall detection as its handled per-service.
|
|
||||||
merger := client.NewLogMerger(svcStreams, client.LogMergerOptions{})
|
|
||||||
stream = merger.Stream()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch machine names for all machines (machineIDsSet) service containers are running on.
|
|
||||||
machines, err := c.ListMachines(ctx, &api.MachineFilter{NamesOrIDs: machineIDsSet.ToSlice()})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("list machines: %w", err)
|
|
||||||
}
|
|
||||||
machineNames := make([]string, 0, len(machines))
|
|
||||||
for _, m := range machines {
|
|
||||||
machineNames = append(machineNames, m.Machine.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
formatter := newLogFormatter(machineNames, serviceNames, opts.utc)
|
|
||||||
|
|
||||||
// Print merged logs.
|
|
||||||
for entry := range stream {
|
|
||||||
if entry.Err != nil {
|
|
||||||
formatter.printError(entry)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
formatter.printEntry(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Available colors for machine/service differentiation.
|
|
||||||
var colorPalette = []color.Color{
|
|
||||||
lipgloss.BrightGreen,
|
|
||||||
lipgloss.BrightYellow,
|
|
||||||
lipgloss.BrightBlue,
|
|
||||||
lipgloss.BrightMagenta,
|
|
||||||
lipgloss.BrightCyan,
|
|
||||||
lipgloss.Green,
|
|
||||||
lipgloss.Yellow,
|
|
||||||
lipgloss.Blue,
|
|
||||||
lipgloss.Magenta,
|
|
||||||
lipgloss.Cyan,
|
|
||||||
}
|
|
||||||
|
|
||||||
// logFormatter handles formatting and printing of log entries with dynamic column alignment.
|
|
||||||
type logFormatter struct {
|
|
||||||
machineNames []string
|
|
||||||
serviceNames []string
|
|
||||||
|
|
||||||
maxMachineWidth int
|
|
||||||
maxServiceWidth int
|
|
||||||
|
|
||||||
utc bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newLogFormatter(machineNames, serviceNames []string, utc bool) *logFormatter {
|
|
||||||
slices.Sort(machineNames)
|
|
||||||
slices.Sort(serviceNames)
|
|
||||||
|
|
||||||
maxMachineWidth := 0
|
|
||||||
for _, name := range machineNames {
|
|
||||||
if len(name) > maxMachineWidth {
|
|
||||||
maxMachineWidth = len(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
maxServiceWidth := 0
|
|
||||||
for _, name := range serviceNames {
|
|
||||||
if len(name) > maxServiceWidth {
|
|
||||||
maxServiceWidth = len(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &logFormatter{
|
|
||||||
machineNames: machineNames,
|
|
||||||
serviceNames: serviceNames,
|
|
||||||
maxMachineWidth: maxMachineWidth,
|
|
||||||
maxServiceWidth: maxServiceWidth,
|
|
||||||
utc: utc,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatTimestamp formats timestamp using local timezone or UTC if configured.
|
|
||||||
func (f *logFormatter) formatTimestamp(t time.Time) string {
|
|
||||||
if f.utc {
|
|
||||||
t = t.UTC()
|
|
||||||
} else {
|
|
||||||
t = t.In(time.Local)
|
|
||||||
}
|
|
||||||
dimStyle := lipgloss.NewStyle().Faint(true)
|
|
||||||
|
|
||||||
return dimStyle.Render(t.Format(time.StampMilli))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *logFormatter) formatMachine(name string) string {
|
|
||||||
style := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxMachineWidth - len(name))
|
|
||||||
|
|
||||||
if len(f.serviceNames) == 1 {
|
|
||||||
// Machine name is coloured for single-service logs.
|
|
||||||
i := slices.Index(f.machineNames, name)
|
|
||||||
if i == -1 {
|
|
||||||
f.machineNames = append(f.machineNames, name)
|
|
||||||
i = len(f.machineNames) - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
style = style.Foreground(colorPalette[i%len(colorPalette)])
|
|
||||||
}
|
|
||||||
|
|
||||||
return style.Render(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *logFormatter) formatServiceContainer(serviceName, containerID string) string {
|
|
||||||
styleService := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxServiceWidth - len(serviceName))
|
|
||||||
styleContainer := lipgloss.NewStyle().Faint(true)
|
|
||||||
|
|
||||||
if len(f.serviceNames) > 1 {
|
|
||||||
// Service name is coloured for multi-service logs.
|
|
||||||
i := slices.Index(f.serviceNames, serviceName)
|
|
||||||
if i == -1 {
|
|
||||||
f.serviceNames = append(f.serviceNames, serviceName)
|
|
||||||
i = len(f.serviceNames) - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
styleService = styleService.Foreground(colorPalette[i%len(colorPalette)])
|
|
||||||
}
|
|
||||||
|
|
||||||
return styleService.Render(serviceName) + styleContainer.Render("["+containerID[:5]+"]")
|
|
||||||
}
|
|
||||||
|
|
||||||
// printEntry prints a single log entry with proper formatting.
|
|
||||||
func (f *logFormatter) printEntry(entry api.ServiceLogEntry) {
|
|
||||||
if entry.Stream != api.LogStreamStdout && entry.Stream != api.LogStreamStderr {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var output strings.Builder
|
|
||||||
|
|
||||||
// Timestamp
|
|
||||||
output.WriteString(f.formatTimestamp(entry.Timestamp))
|
|
||||||
output.WriteString(" ")
|
|
||||||
|
|
||||||
// Machine name
|
|
||||||
output.WriteString(f.formatMachine(entry.Metadata.MachineName))
|
|
||||||
output.WriteString(" ")
|
|
||||||
|
|
||||||
// Service[container_id]
|
|
||||||
output.WriteString(f.formatServiceContainer(entry.Metadata.ServiceName, entry.Metadata.ContainerID))
|
|
||||||
output.WriteString(" ")
|
|
||||||
|
|
||||||
// Message
|
|
||||||
output.Write(entry.Message)
|
|
||||||
|
|
||||||
// Print to appropriate stream.
|
|
||||||
if entry.Stream == api.LogStreamStderr {
|
|
||||||
fmt.Fprint(os.Stderr, output.String())
|
|
||||||
} else {
|
|
||||||
fmt.Print(output.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// printError prints an error entry (e.g., stalled stream warning).
|
|
||||||
func (f *logFormatter) printError(entry api.ServiceLogEntry) {
|
|
||||||
if entry.Metadata.ContainerID != "" {
|
|
||||||
msg := fmt.Sprintf("WARNING: log stream from %s[%s] on machine '%s'",
|
|
||||||
entry.Metadata.ServiceName,
|
|
||||||
stringid.TruncateID(entry.Metadata.ContainerID),
|
|
||||||
entry.Metadata.MachineName)
|
|
||||||
|
|
||||||
if errors.Is(entry.Err, api.ErrLogStreamStalled) {
|
|
||||||
msg += " stopped responding"
|
|
||||||
} else {
|
|
||||||
msg += fmt.Sprintf(": %v", entry.Err)
|
|
||||||
}
|
|
||||||
|
|
||||||
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) // Bold bright yellow
|
|
||||||
fmt.Fprintln(os.Stderr, style.Render(msg))
|
|
||||||
} else {
|
|
||||||
msg := fmt.Sprintf("ERROR: %v", entry.Err)
|
|
||||||
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) // Bold bright red
|
|
||||||
fmt.Fprintln(os.Stderr, style.Render(msg))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,31 +4,34 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand(groupID string) *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
|
// TODO(lhf): rename to context
|
||||||
|
var cluster string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
Aliases: []string{"list"},
|
Aliases: []string{"list"},
|
||||||
Short: "List services.",
|
Short: "List services.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return list(cmd.Context(), uncli)
|
return list(cmd.Context(), uncli, cluster)
|
||||||
},
|
},
|
||||||
GroupID: groupID,
|
|
||||||
}
|
}
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&cluster, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func list(ctx context.Context, uncli *cli.CLI) error {
|
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
client, err := uncli.ConnectCluster(ctx, clusterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -39,15 +42,15 @@ func list(ctx context.Context, uncli *cli.CLI) error {
|
|||||||
return fmt.Errorf("list services: %w", err)
|
return fmt.Errorf("list services: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort services by name.
|
serviceNames := make(map[string]struct{}, len(services))
|
||||||
haveDuplicateNames := false
|
haveDuplicateNames := false
|
||||||
slices.SortFunc(services, func(a, b api.Service) int {
|
for _, svc := range services {
|
||||||
if a.Name == b.Name {
|
if _, exists := serviceNames[svc.Name]; exists {
|
||||||
haveDuplicateNames = true
|
haveDuplicateNames = true
|
||||||
return strings.Compare(a.ID, b.ID)
|
break
|
||||||
}
|
}
|
||||||
return strings.Compare(a.Name, b.Name)
|
serviceNames[svc.Name] = struct{}{}
|
||||||
})
|
}
|
||||||
|
|
||||||
// Print the list of services in a table format.
|
// Print the list of services in a table format.
|
||||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
@@ -58,29 +61,19 @@ func list(ctx context.Context, uncli *cli.CLI) error {
|
|||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tIMAGE\tENDPOINTS"); err != nil {
|
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tENDPOINTS"); err != nil {
|
||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
for _, s := range services {
|
for _, s := range services {
|
||||||
images := strings.Join(s.Images(), ", ")
|
endpointsSlice := s.Endpoints()
|
||||||
endpoints := strings.Join(s.Endpoints(), ", ")
|
endpoints := strings.Join(endpointsSlice, ", ")
|
||||||
|
|
||||||
// If no endpoints from ports, check if the service uses custom Caddy config.
|
|
||||||
if endpoints == "" {
|
|
||||||
for _, ctr := range s.Containers {
|
|
||||||
if ctr.Container.ServiceSpec.CaddyConfig() != "" {
|
|
||||||
endpoints = "(custom Caddy config)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if haveDuplicateNames {
|
if haveDuplicateNames {
|
||||||
if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
|
if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
|
||||||
return fmt.Errorf("write row: %w", err)
|
return fmt.Errorf("write row: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n",
|
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", s.Name, s.Mode, len(s.Containers), endpoints); err != nil {
|
||||||
s.Name, s.Mode, len(s.Containers), images, endpoints); err != nil {
|
|
||||||
return fmt.Errorf("write row: %w", err)
|
return fmt.Errorf("write row: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,32 +11,31 @@ import (
|
|||||||
|
|
||||||
type rmOptions struct {
|
type rmOptions struct {
|
||||||
services []string
|
services []string
|
||||||
|
cluster string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRmCommand(groupID string) *cobra.Command {
|
func NewRmCommand() *cobra.Command {
|
||||||
opts := rmOptions{}
|
opts := rmOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "rm SERVICE [SERVICE...]",
|
Use: "rm SERVICE [SERVICE...]",
|
||||||
Aliases: []string{"remove", "delete"},
|
Aliases: []string{"remove", "delete"},
|
||||||
Short: "Remove one or more services.",
|
Short: "Remove one or more services.",
|
||||||
Long: `Remove one or more services.
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
|
||||||
The volumes used by the services are preserved and should be removed separately
|
|
||||||
with 'uc volume rm'. Anonymous Docker volumes (automatically created from VOLUME
|
|
||||||
directives in image Dockerfiles) are automatically removed with their containers.`,
|
|
||||||
Args: cobra.MinimumNArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
opts.services = args
|
opts.services = args
|
||||||
return rm(cmd.Context(), uncli, opts)
|
return rm(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
GroupID: groupID,
|
|
||||||
}
|
}
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.cluster, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -51,5 +50,5 @@ func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
|||||||
}, uncli.ProgressOut(), "Removing service "+s)
|
}, uncli.ProgressOut(), "Removing service "+s)
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,14 @@ func NewRootCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "service",
|
Use: "service",
|
||||||
Aliases: []string{"svc"},
|
Aliases: []string{"svc"},
|
||||||
Short: "Manage services in the cluster.",
|
Short: "Manage services in an Uncloud cluster.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewExecCommand(""),
|
NewInspectCommand(),
|
||||||
NewInspectCommand(""),
|
NewListCommand(),
|
||||||
NewListCommand(""),
|
NewRmCommand(),
|
||||||
NewLogsCommand(""),
|
NewRunCommand(),
|
||||||
NewRmCommand(""),
|
NewScaleCommand(),
|
||||||
NewRunCommand(""),
|
|
||||||
NewScaleCommand(""),
|
|
||||||
NewStartCommand(""),
|
|
||||||
NewStopCommand(""),
|
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
dockeropts "github.com/docker/cli/opts"
|
dockeropts "github.com/docker/cli/opts"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/docker/docker/daemon/names"
|
"github.com/docker/docker/daemon/names"
|
||||||
"github.com/docker/go-units"
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
@@ -18,7 +17,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type runOptions struct {
|
type runOptions struct {
|
||||||
caddyfile string
|
|
||||||
command []string
|
command []string
|
||||||
cpu dockeropts.NanoCPUs
|
cpu dockeropts.NanoCPUs
|
||||||
entrypoint string
|
entrypoint string
|
||||||
@@ -28,18 +26,18 @@ type runOptions struct {
|
|||||||
machines []string
|
machines []string
|
||||||
memory dockeropts.MemBytes
|
memory dockeropts.MemBytes
|
||||||
mode string
|
mode string
|
||||||
shmSize dockeropts.MemBytes
|
|
||||||
name string
|
name string
|
||||||
privileged bool
|
privileged bool
|
||||||
publish []string
|
publish []string
|
||||||
pull string
|
pull string
|
||||||
replicas uint
|
replicas uint
|
||||||
ulimits []string
|
|
||||||
user string
|
user string
|
||||||
volumes []string
|
volumes []string
|
||||||
|
|
||||||
|
cluster string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRunCommand(groupID string) *cobra.Command {
|
func NewRunCommand() *cobra.Command {
|
||||||
opts := runOptions{}
|
opts := runOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -57,12 +55,8 @@ func NewRunCommand(groupID string) *cobra.Command {
|
|||||||
|
|
||||||
return run(cmd.Context(), uncli, opts)
|
return run(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
GroupID: groupID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.")
|
||||||
@@ -79,12 +73,8 @@ func NewRunCommand(groupID string) *cobra.Command {
|
|||||||
"Placement constraint by machine names, limiting which machines the service can run on. Can be specified "+
|
"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)")
|
"multiple times or as a comma-separated list of machine names. (default is any suitable machine)")
|
||||||
cmd.Flags().VarP(&opts.memory, "memory", "",
|
cmd.Flags().VarP(&opts.memory, "memory", "",
|
||||||
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+
|
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix "+
|
||||||
"Default unit is bytes if no suffix specified.\n"+
|
"(b, k, m, g). Default unit is bytes if no suffix specified.\n"+
|
||||||
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
|
|
||||||
cmd.Flags().VarP(&opts.shmSize, "shm-size", "",
|
|
||||||
"Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer\n"+
|
|
||||||
"with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.\n"+
|
|
||||||
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
|
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
|
||||||
cmd.Flags().StringVarP(&opts.name, "name", "n", "",
|
cmd.Flags().StringVarP(&opts.name, "name", "n", "",
|
||||||
"Assign a name to the service. A random name is generated if not specified.")
|
"Assign a name to the service. A random name is generated if not specified.")
|
||||||
@@ -92,7 +82,7 @@ func NewRunCommand(groupID string) *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:]container_port[/protocol] or [host_ip:]host_port:container_port[/protocol]@host\n"+
|
"Format: [hostname:][load_balancer_port:]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"+
|
||||||
@@ -109,12 +99,6 @@ func NewRunCommand(groupID string) *cobra.Command {
|
|||||||
cmd.Flags().StringVarP(&opts.user, "user", "u", "",
|
cmd.Flags().StringVarP(&opts.user, "user", "u", "",
|
||||||
"User name or UID and optionally group name or GID used for running the command inside service containers.\n"+
|
"User name or UID and optionally group name or GID used for running the command inside service containers.\n"+
|
||||||
"Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.")
|
"Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.")
|
||||||
cmd.Flags().StringSliceVar(&opts.ulimits, "ulimit", nil,
|
|
||||||
"Set resource limits for service containers. Can be specified multiple times.\n"+
|
|
||||||
"Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both.\n"+
|
|
||||||
"Examples:\n"+
|
|
||||||
" --ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files\n"+
|
|
||||||
" --ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes")
|
|
||||||
cmd.Flags().StringSliceVarP(&opts.volumes, "volume", "v", nil,
|
cmd.Flags().StringSliceVarP(&opts.volumes, "volume", "v", nil,
|
||||||
"Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where\n"+
|
"Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where\n"+
|
||||||
"the volume is located. Can be specified multiple times.\n"+
|
"the volume is located. Can be specified multiple times.\n"+
|
||||||
@@ -124,6 +108,11 @@ func NewRunCommand(groupID string) *cobra.Command {
|
|||||||
" -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container\n"+
|
" -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container\n"+
|
||||||
" -v /host/path:/container/path:ro Bind mount a host directory or file as read-only")
|
" -v /host/path:/container/path:ro Bind mount a host directory or file as read-only")
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.cluster, "context", "c", "",
|
||||||
|
"Name of the cluster context to run the service in. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +122,7 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -172,15 +161,6 @@ 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
|
||||||
@@ -207,20 +187,15 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
|||||||
ports[i] = port
|
ports[i] = port
|
||||||
}
|
}
|
||||||
|
|
||||||
placement := api.Placement{
|
|
||||||
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
|
||||||
}
|
|
||||||
|
|
||||||
ulimits, err := parseUlimits(opts.ulimits)
|
|
||||||
if err != nil {
|
|
||||||
return spec, err
|
|
||||||
}
|
|
||||||
|
|
||||||
volumes, mounts, err := parseVolumeFlags(opts.volumes)
|
volumes, mounts, err := parseVolumeFlags(opts.volumes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return spec, err
|
return spec, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
placement := api.Placement{
|
||||||
|
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||||
|
}
|
||||||
|
|
||||||
spec = api.ServiceSpec{
|
spec = api.ServiceSpec{
|
||||||
Container: api.ContainerSpec{
|
Container: api.ContainerSpec{
|
||||||
Command: opts.command,
|
Command: opts.command,
|
||||||
@@ -229,10 +204,8 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
|||||||
Privileged: opts.privileged,
|
Privileged: opts.privileged,
|
||||||
PullPolicy: opts.pull,
|
PullPolicy: opts.pull,
|
||||||
Resources: api.ContainerResources{
|
Resources: api.ContainerResources{
|
||||||
CPU: opts.cpu.Value(),
|
CPU: opts.cpu.Value(),
|
||||||
Memory: opts.memory.Value(),
|
Memory: opts.memory.Value(),
|
||||||
SharedMemory: opts.shmSize.Value(),
|
|
||||||
Ulimits: ulimits,
|
|
||||||
},
|
},
|
||||||
User: opts.user,
|
User: opts.user,
|
||||||
VolumeMounts: mounts,
|
VolumeMounts: mounts,
|
||||||
@@ -245,12 +218,6 @@ 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}
|
||||||
@@ -322,24 +289,6 @@ func parseVolumeFlags(volumes []string) ([]api.VolumeSpec, []api.VolumeMount, er
|
|||||||
return specs, mounts, nil
|
return specs, mounts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseUlimits parses ulimit flag values in Docker CLI format (type=soft[:hard]).
|
|
||||||
func parseUlimits(ulimits []string) (map[string]api.Ulimit, error) {
|
|
||||||
if len(ulimits) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make(map[string]api.Ulimit, len(ulimits))
|
|
||||||
for _, u := range ulimits {
|
|
||||||
parsed, err := units.ParseUlimit(u)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid ulimit '%s': %w", u, err)
|
|
||||||
}
|
|
||||||
result[parsed.Name] = api.Ulimit{Soft: parsed.Soft, Hard: parsed.Hard}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseVolumeFlagValue(volume string) (api.VolumeSpec, api.VolumeMount, error) {
|
func parseVolumeFlagValue(volume string) (api.VolumeSpec, api.VolumeMount, error) {
|
||||||
var spec api.VolumeSpec
|
var spec api.VolumeSpec
|
||||||
var mount api.VolumeMount
|
var mount api.VolumeMount
|
||||||
@@ -358,8 +307,8 @@ func parseVolumeFlagValue(volume string) (api.VolumeSpec, api.VolumeMount, error
|
|||||||
volumeNoCopy := false
|
volumeNoCopy := false
|
||||||
|
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
opts := strings.SplitSeq(parts[2], ",")
|
opts := strings.Split(parts[2], ",")
|
||||||
for opt := range opts {
|
for _, opt := range opts {
|
||||||
switch opt {
|
switch opt {
|
||||||
case "ro", "readonly":
|
case "ro", "readonly":
|
||||||
mount.ReadOnly = true
|
mount.ReadOnly = true
|
||||||
|
|||||||
@@ -2,16 +2,12 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -19,19 +15,17 @@ import (
|
|||||||
type scaleOptions struct {
|
type scaleOptions struct {
|
||||||
service string
|
service string
|
||||||
replicas uint
|
replicas uint
|
||||||
yes bool
|
cluster string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewScaleCommand(groupID string) *cobra.Command {
|
func NewScaleCommand() *cobra.Command {
|
||||||
opts := scaleOptions{}
|
opts := scaleOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "scale SERVICE REPLICAS",
|
Use: "scale SERVICE REPLICAS",
|
||||||
Short: "Scale a replicated service by changing the number of replicas.",
|
Short: "Scale a replicated service by changing the number of replicas.",
|
||||||
Long: "Scale a replicated service by changing the number of replicas.",
|
Long: "Scale a replicated service by changing the number of replicas. Scaling down requires confirmation.",
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
|
||||||
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
opts.service = args[0]
|
opts.service = args[0]
|
||||||
@@ -43,12 +37,12 @@ func NewScaleCommand(groupID string) *cobra.Command {
|
|||||||
|
|
||||||
return scale(cmd.Context(), uncli, opts)
|
return scale(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
GroupID: groupID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
cmd.Flags().StringVarP(
|
||||||
"Auto-confirm scaling plan. Should be explicitly set when running non-interactively,\n"+
|
&opts.cluster, "context", "c", "",
|
||||||
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -63,7 +57,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -82,8 +76,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
|||||||
currentReplicas := uint(len(svc.Containers))
|
currentReplicas := uint(len(svc.Containers))
|
||||||
|
|
||||||
if currentReplicas == opts.replicas {
|
if currentReplicas == opts.replicas {
|
||||||
fmt.Printf("Service %s already has %s replicas. No changes required.\n",
|
fmt.Printf("Service '%s' already has %d replicas. No changes required.\n", svc.Name, currentReplicas)
|
||||||
tui.NameStyle.Render(svc.Name), tui.Bold.Render(fmt.Sprintf("%d", currentReplicas)))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,51 +91,23 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(plan.Operations) == 0 {
|
if len(plan.Operations) == 0 {
|
||||||
fmt.Printf("Service %s is already scaled to %d replicas.\n", tui.NameStyle.Render(svc.Name), opts.replicas)
|
fmt.Printf("Service '%s' is already scaled to %d replicas.\n", svc.Name, opts.replicas)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(tui.Bold.Underline(true).Render("Scaling plan"))
|
if opts.replicas < currentReplicas {
|
||||||
fmt.Println()
|
// Initialise a machine and container name resolver to properly format the plan output.
|
||||||
|
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
|
||||||
directConn := uncli.DirectConnection()
|
if err != nil {
|
||||||
contextName := uncli.ContextOverrideOrCurrent()
|
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||||
deployTarget := ""
|
|
||||||
if directConn != "" {
|
|
||||||
deployTarget = directConn
|
|
||||||
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
|
|
||||||
fmt.Println()
|
|
||||||
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
|
|
||||||
// Only show context if there's more than one to avoid unnecessary clutter.
|
|
||||||
deployTarget = contextName
|
|
||||||
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(plan.Format())
|
|
||||||
|
|
||||||
summary := plan.FormatSummary()
|
|
||||||
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
|
|
||||||
fmt.Println(summary)
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Ask for confirmation unless auto-confirmed with --yes.
|
|
||||||
if !opts.yes {
|
|
||||||
if !tui.IsStdinTerminal() {
|
|
||||||
return errors.New("cannot ask to confirm scaling plan in non-interactive mode, " +
|
|
||||||
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
title := "Proceed with scaling?"
|
fmt.Printf("Scaling plan for service %s (%d → %d replicas):\n", svc.Name, currentReplicas, opts.replicas)
|
||||||
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
|
fmt.Println(plan.Format(resolver))
|
||||||
// scaling on the wrong cluster.
|
fmt.Println()
|
||||||
if deployTarget != "" {
|
|
||||||
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
|
||||||
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
|
|
||||||
title = "Proceed with scaling on " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?")
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmed, err := tui.Confirm(title)
|
// Ask for confirmation before scaling down as it may cause data loss.
|
||||||
|
confirmed, err := confirm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("confirm scaling: %w", err)
|
return fmt.Errorf("confirm scaling: %w", err)
|
||||||
}
|
}
|
||||||
@@ -152,8 +117,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
title := fmt.Sprintf("Scaling service %s (%d → %d replicas)",
|
title := fmt.Sprintf("Scaling service %s (%d → %d replicas)", svc.Name, currentReplicas, opts.replicas)
|
||||||
tui.NameStyle.Render(svc.Name), currentReplicas, opts.replicas)
|
|
||||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
if _, err = deployment.Run(ctx); err != nil {
|
if _, err = deployment.Run(ctx); err != nil {
|
||||||
return fmt.Errorf("deploy service: %w", err)
|
return fmt.Errorf("deploy service: %w", err)
|
||||||
@@ -166,3 +130,23 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func confirm() (bool, error) {
|
||||||
|
var confirmed bool
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewConfirm().
|
||||||
|
Title(
|
||||||
|
"Do you want to continue?",
|
||||||
|
).
|
||||||
|
Affirmative("Yes!").
|
||||||
|
Negative("No").
|
||||||
|
Value(&confirmed),
|
||||||
|
),
|
||||||
|
).WithAccessible(true)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return confirmed, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
//nolint:dupl
|
|
||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
type startOptions struct {
|
|
||||||
services []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStartCommand(groupID string) *cobra.Command {
|
|
||||||
opts := startOptions{}
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "start SERVICE [SERVICE...]",
|
|
||||||
Short: "Start one or more services.",
|
|
||||||
Long: `Start one or more previously stopped services.
|
|
||||||
|
|
||||||
Starts all containers of the specified service(s) across all machines in the cluster.
|
|
||||||
Services can be specified by name or ID.`,
|
|
||||||
Args: cobra.MinimumNArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
|
||||||
opts.services = args
|
|
||||||
return start(cmd.Context(), uncli, opts)
|
|
||||||
},
|
|
||||||
GroupID: groupID,
|
|
||||||
}
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func start(ctx context.Context, uncli *cli.CLI, opts startOptions) error {
|
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
for _, s := range opts.services {
|
|
||||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
|
||||||
if err = client.StartService(ctx, s); err != nil {
|
|
||||||
return fmt.Errorf("start service '%s': %w", s, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}, uncli.ProgressOut(), "Starting service "+s)
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
//nolint:dupl
|
|
||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
type stopOptions struct {
|
|
||||||
services []string
|
|
||||||
signal string
|
|
||||||
timeout int
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStopCommand(groupID string) *cobra.Command {
|
|
||||||
opts := stopOptions{}
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "stop SERVICE [SERVICE...]",
|
|
||||||
Short: "Stop one or more services.",
|
|
||||||
Long: `Stop one or more running services.
|
|
||||||
|
|
||||||
Gracefully stops all containers of the specified service(s) across all machines in the cluster.
|
|
||||||
Services can be specified by name or ID. Stopped services can be restarted with 'uc start'.`,
|
|
||||||
Args: cobra.MinimumNArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
|
||||||
opts.services = args
|
|
||||||
return stop(cmd.Context(), uncli, opts)
|
|
||||||
},
|
|
||||||
GroupID: groupID,
|
|
||||||
}
|
|
||||||
cmd.Flags().StringVarP(&opts.signal, "signal", "s", "",
|
|
||||||
"Signal to send to each container's main process.\n"+
|
|
||||||
"Can be a signal name (SIGTERM, SIGINT, SIGHUP, etc.) or a number. (default SIGTERM)")
|
|
||||||
cmd.Flags().IntVarP(&opts.timeout, "timeout", "t", 10,
|
|
||||||
"Seconds to wait for each container to stop gracefully before forcibly killing it with SIGKILL.\n"+
|
|
||||||
"Use -1 to wait indefinitely.")
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop(ctx context.Context, uncli *cli.CLI, opts stopOptions) error {
|
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
stopOpts := container.StopOptions{
|
|
||||||
Signal: opts.signal,
|
|
||||||
Timeout: &opts.timeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, s := range opts.services {
|
|
||||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
|
||||||
if err = client.StopService(ctx, s, stopOpts); err != nil {
|
|
||||||
return fmt.Errorf("stop service '%s': %w", s, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}, uncli.ProgressOut(), "Stopping service "+s)
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/huh/v2"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/docker/docker/api/types/volume"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
@@ -19,6 +19,7 @@ type createOptions struct {
|
|||||||
driverOpts []string
|
driverOpts []string
|
||||||
labels []string
|
labels []string
|
||||||
machine string
|
machine string
|
||||||
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCreateCommand() *cobra.Command {
|
func NewCreateCommand() *cobra.Command {
|
||||||
@@ -49,12 +50,14 @@ func NewCreateCommand() *cobra.Command {
|
|||||||
"Labels to assign to the volume in the form of 'key=value' pairs. Can be specified multiple times.")
|
"Labels to assign to the volume in the form of 'key=value' pairs. Can be specified multiple times.")
|
||||||
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||||
"Name or ID of the machine to create the volume on.")
|
"Name or ID of the machine to create the volume on.")
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func create(ctx context.Context, uncli *cli.CLI, name string, opts createOptions) error {
|
func create(ctx context.Context, uncli *cli.CLI, name string, opts createOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
client, 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
type inspectOptions struct {
|
type inspectOptions struct {
|
||||||
machine string
|
machine string
|
||||||
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInspectCommand() *cobra.Command {
|
func NewInspectCommand() *cobra.Command {
|
||||||
@@ -31,12 +32,14 @@ func NewInspectCommand() *cobra.Command {
|
|||||||
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||||
"Name or ID of the machine where the volume is located. "+
|
"Name or ID of the machine where the volume is located. "+
|
||||||
"If not specified, the volume will be searched across all machines.")
|
"If not specified, the volume will be searched across all machines.")
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func inspect(ctx context.Context, uncli *cli.CLI, name string, opts inspectOptions) error {
|
func inspect(ctx context.Context, uncli *cli.CLI, name string, opts inspectOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
client, 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
type listOptions struct {
|
type listOptions struct {
|
||||||
machines []string
|
machines []string
|
||||||
quiet bool
|
quiet bool
|
||||||
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
@@ -37,11 +38,14 @@ func NewListCommand() *cobra.Command {
|
|||||||
cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false,
|
cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false,
|
||||||
"Only display volume names.")
|
"Only display volume names.")
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
client, 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -15,6 +14,7 @@ type removeOptions struct {
|
|||||||
force bool
|
force bool
|
||||||
machines []string
|
machines []string
|
||||||
yes bool
|
yes bool
|
||||||
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRemoveCommand() *cobra.Command {
|
func NewRemoveCommand() *cobra.Command {
|
||||||
@@ -41,11 +41,14 @@ func NewRemoveCommand() *cobra.Command {
|
|||||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||||
"Do not prompt for confirmation before removing the volume(s).")
|
"Do not prompt for confirmation before removing the volume(s).")
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOptions) error {
|
func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
client, 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)
|
||||||
}
|
}
|
||||||
@@ -80,7 +83,7 @@ func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOpti
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
confirmed, err := tui.Confirm("")
|
confirmed, err := cli.Confirm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("confirm removal: %w", err)
|
return fmt.Errorf("confirm removal: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
func NewRootCommand() *cobra.Command {
|
func NewRootCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "volume",
|
Use: "volume",
|
||||||
Short: "Manage volumes in the cluster.",
|
Short: "Manage volumes in an Uncloud cluster.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewCreateCommand(),
|
NewCreateCommand(),
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
package wg
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"text/tabwriter"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/docker/go-units"
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewRootCommand() *cobra.Command {
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "wg",
|
|
||||||
Short: "Inspect WireGuard network",
|
|
||||||
}
|
|
||||||
cmd.AddCommand(newShowCommand())
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
type showOptions struct {
|
|
||||||
machine string
|
|
||||||
}
|
|
||||||
|
|
||||||
func newShowCommand() *cobra.Command {
|
|
||||||
opts := showOptions{}
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "show",
|
|
||||||
Short: "Show WireGuard network configuration for a machine.",
|
|
||||||
Long: "Show the WireGuard network configuration for the machine currently connected to " +
|
|
||||||
"(or specified by the global --connect flag).",
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
|
||||||
return runShow(cmd.Context(), uncli, opts)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
|
||||||
"Name or ID of the machine to show the configuration for. (default is connected machine)")
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func runShow(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
|
||||||
client, err := uncli.ConnectCluster(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connection failed: %w", err)
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
if opts.machine != "" {
|
|
||||||
// Proxy requests to the specified machine.
|
|
||||||
ctx, _, err = client.ProxyMachinesContext(ctx, []string{opts.machine})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.MachineClient.InspectWireGuardNetwork(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
if status.Code(err) == codes.Unimplemented {
|
|
||||||
return fmt.Errorf("inspect WireGuard network: "+
|
|
||||||
"make sure the target machine is running uncloudd daemon version >= 0.16.0: %w", err)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
machines, err := client.ListMachines(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("list machines: %w", err)
|
|
||||||
}
|
|
||||||
machinesNamesByPublicKey := make(map[string]string)
|
|
||||||
for _, m := range machines {
|
|
||||||
publicKey := wgtypes.Key(m.Machine.Network.PublicKey).String()
|
|
||||||
machinesNamesByPublicKey[publicKey] = m.Machine.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the machine's name for more descriptive output
|
|
||||||
inspectResp, err := client.MachineClient.InspectMachine(ctx, nil)
|
|
||||||
if err == nil {
|
|
||||||
fmt.Printf("Machine name: %s\n", inspectResp.Machines[0].Machine.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("WireGuard interface: %s\n", resp.InterfaceName)
|
|
||||||
fmt.Printf("WireGuard public key: %s\n", wgtypes.Key(resp.PublicKey).String())
|
|
||||||
fmt.Printf("WireGuard port: %d\n", resp.ListenPort)
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
if len(resp.Peers) == 0 {
|
|
||||||
fmt.Println("No WireGuard peers configured.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
|
||||||
if _, err = fmt.Fprintln(tw, "PEER\tPUBLIC KEY\tENDPOINT\tHANDSHAKE\tRECEIVED\tSENT\tALLOWED IPS"); err != nil {
|
|
||||||
return fmt.Errorf("write header: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, peer := range resp.Peers {
|
|
||||||
machineName, ok := machinesNamesByPublicKey[wgtypes.Key(peer.PublicKey).String()]
|
|
||||||
if !ok {
|
|
||||||
machineName = "(unknown)"
|
|
||||||
}
|
|
||||||
|
|
||||||
lastHandshake := ""
|
|
||||||
if peer.LastHandshakeTime != nil {
|
|
||||||
lastHandshake = time.Since(peer.LastHandshakeTime.AsTime()).Round(time.Second).String() + " ago"
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = fmt.Fprintf(
|
|
||||||
tw,
|
|
||||||
"%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
||||||
machineName,
|
|
||||||
wgtypes.Key(peer.PublicKey).String(),
|
|
||||||
peer.Endpoint,
|
|
||||||
lastHandshake,
|
|
||||||
units.HumanSize(float64(peer.ReceiveBytes)),
|
|
||||||
units.HumanSize(float64(peer.TransmitBytes)),
|
|
||||||
strings.Join(peer.AllowedIps, ", "),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("write row: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tw.Flush()
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/machine"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newDialStdioCommand() *cobra.Command {
|
|
||||||
var socketPath string
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "dial-stdio",
|
|
||||||
Short: "Proxy stdin/stdout to the Uncloud API socket",
|
|
||||||
Hidden: true,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return runDialStdio(cmd.Context(), socketPath, os.Stdin, os.Stdout)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringVar(&socketPath, "socket", machine.DefaultUncloudSockPath,
|
|
||||||
"Path to the Uncloud API socket")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// halfReadCloser is the read side of a half-duplex connection.
|
|
||||||
type halfReadCloser interface {
|
|
||||||
io.Reader
|
|
||||||
CloseRead() error
|
|
||||||
}
|
|
||||||
|
|
||||||
// halfWriteCloser is the write side of a half-duplex connection.
|
|
||||||
type halfWriteCloser interface {
|
|
||||||
io.Writer
|
|
||||||
CloseWrite() error
|
|
||||||
}
|
|
||||||
|
|
||||||
// halfReadCloserWrapper wraps an io.ReadCloser to implement halfReadCloser.
|
|
||||||
type halfReadCloserWrapper struct {
|
|
||||||
io.ReadCloser
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *halfReadCloserWrapper) CloseRead() error {
|
|
||||||
return x.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// halfWriteCloserWrapper wraps an io.WriteCloser to implement halfWriteCloser.
|
|
||||||
type halfWriteCloserWrapper struct {
|
|
||||||
io.WriteCloser
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *halfWriteCloserWrapper) CloseWrite() error {
|
|
||||||
return x.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDialStdio(ctx context.Context, socketPath string, stdin io.Reader, stdout io.Writer) error {
|
|
||||||
// Connect to the unix socket.
|
|
||||||
var dialer net.Dialer
|
|
||||||
conn, err := dialer.DialContext(ctx, "unix", socketPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connect to socket %q: %w", socketPath, err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
// Wrap stdin/stdout to support half-closing.
|
|
||||||
var stdinCloser halfReadCloser
|
|
||||||
if c, ok := stdin.(halfReadCloser); ok {
|
|
||||||
stdinCloser = c
|
|
||||||
} else if c, ok := stdin.(io.ReadCloser); ok {
|
|
||||||
stdinCloser = &halfReadCloserWrapper{c}
|
|
||||||
}
|
|
||||||
|
|
||||||
var stdoutCloser halfWriteCloser
|
|
||||||
if c, ok := stdout.(halfWriteCloser); ok {
|
|
||||||
stdoutCloser = c
|
|
||||||
} else if c, ok := stdout.(io.WriteCloser); ok {
|
|
||||||
stdoutCloser = &halfWriteCloserWrapper{c}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy data bidirectionally between stdin/stdout and the socket.
|
|
||||||
stdin2socket := make(chan error, 1)
|
|
||||||
socket2stdout := make(chan error, 1)
|
|
||||||
|
|
||||||
// Copy from stdin to socket.
|
|
||||||
go func() {
|
|
||||||
_, err := io.Copy(conn, stdin)
|
|
||||||
stdin2socket <- err
|
|
||||||
// Close write side of connection after stdin is done.
|
|
||||||
if unixConn, ok := conn.(*net.UnixConn); ok {
|
|
||||||
unixConn.CloseWrite()
|
|
||||||
}
|
|
||||||
if stdinCloser != nil {
|
|
||||||
stdinCloser.CloseRead()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Copy from socket to stdout.
|
|
||||||
go func() {
|
|
||||||
_, err := io.Copy(stdout, conn)
|
|
||||||
socket2stdout <- err
|
|
||||||
// Close read side of connection after socket is done sending.
|
|
||||||
if unixConn, ok := conn.(*net.UnixConn); ok {
|
|
||||||
unixConn.CloseRead()
|
|
||||||
}
|
|
||||||
if stdoutCloser != nil {
|
|
||||||
stdoutCloser.CloseWrite()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err = <-stdin2socket:
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// wait for stdout
|
|
||||||
err = <-socket2stdout
|
|
||||||
case err = <-socket2stdout:
|
|
||||||
// return immediately, matching Docker's approach
|
|
||||||
// (stdin is never closed when TTY)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -42,9 +42,6 @@ func main() {
|
|||||||
"Directory for storing persistent machine state")
|
"Directory for storing persistent machine state")
|
||||||
_ = cmd.MarkFlagDirname("data-dir")
|
_ = cmd.MarkFlagDirname("data-dir")
|
||||||
|
|
||||||
// Add dial-stdio subcommand.
|
|
||||||
cmd.AddCommand(newDialStdioCommand())
|
|
||||||
|
|
||||||
// ctx is canceled when the daemon command is interrupted.
|
// ctx is canceled when the daemon command is interrupted.
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
*
|
*
|
||||||
|
|
||||||
# Allow files and directories.
|
# Allow files and directories.
|
||||||
!blog/
|
|
||||||
!docs/
|
!docs/
|
||||||
!landing/
|
|
||||||
!src/
|
!src/
|
||||||
!static/
|
!static/
|
||||||
!pkg/
|
!pkg/
|
||||||
@@ -5,5 +5,5 @@
|
|||||||
:8000 {
|
:8000 {
|
||||||
root * /usr/share/caddy
|
root * /usr/share/caddy
|
||||||
file_server
|
file_server
|
||||||
log
|
try_files {path} /index.html
|
||||||
}
|
}
|
||||||
@@ -19,12 +19,9 @@ RUN npm ci
|
|||||||
## Build the static site.
|
## Build the static site.
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 3: Serve static Docusaurus site and landing page with Caddy.
|
# Stage 3: Serve static site with Caddy.
|
||||||
FROM caddy:2.10.0-alpine AS caddy
|
FROM caddy:2.10.0-alpine AS caddy
|
||||||
## Copy the Caddyfile.
|
## Copy the Caddyfile.
|
||||||
COPY ./Caddyfile /etc/caddy/Caddyfile
|
COPY ./Caddyfile /etc/caddy/Caddyfile
|
||||||
## Copy the Docusaurus build output.
|
## Copy the Docusaurus build output.
|
||||||
COPY --from=prod /opt/docusaurus/build /usr/share/caddy
|
COPY --from=prod /opt/docusaurus/build /usr/share/caddy
|
||||||
# Copy the landing page assets.
|
|
||||||
COPY landing/images /usr/share/caddy/images
|
|
||||||
COPY landing/index.html landing/style.css /usr/share/caddy/
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Website
|
||||||
|
|
||||||
|
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```
|
||||||
|
$ yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```
|
||||||
|
$ yarn start
|
||||||
|
```
|
||||||
|
|
||||||
|
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```
|
||||||
|
$ yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
Using SSH:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ USE_SSH=true yarn deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Not using SSH:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ GIT_USER=<Your GitHub username> yarn deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||||
@@ -1,31 +1,39 @@
|
|||||||
---
|
# WireGuard overlay network for Docker containers
|
||||||
title: How to connect Docker containers across multiple hosts with WireGuard
|
|
||||||
description: Learn how to configure a WireGuard overlay network that lets Docker containers securely communicate
|
|
||||||
across multiple hosts. No exposed ports needed.
|
|
||||||
slug: connect-docker-containers-across-hosts-wireguard
|
|
||||||
image: ./wireguard-overlay.png
|
|
||||||
authors: psviderski
|
|
||||||
tags: [ docker, wireguard, networking, vpn ]
|
|
||||||
---
|
|
||||||
|
|
||||||
You want your Docker containers to talk to each other, but they're running on different machines. Perhaps across
|
# How to connect Docker containers across multiple hosts using WireGuard
|
||||||
different cloud providers or mixing cloud with on-prem. The usual approach of mapping services to host ports quickly
|
|
||||||
becomes a pain. Worse, if they're on the public internet, you need to secure every exposed endpoint with TLS and auth.
|
|
||||||
|
|
||||||
What if your containers on different machines could communicate directly without exposing any ports? Using their private
|
# Connect Docker containers across multiple hosts with WireGuard
|
||||||
Docker IPs, as if they were on the same machine. Here's how you can use pure WireGuard and some clever networking tricks
|
|
||||||
to make this work.
|
You have Docker containers running on different Linux machines. You want container A on one machine to talk directly to
|
||||||
|
container B on another machine using their private IPs. For example, to run your application and database containers on
|
||||||
|
separate machines without exposing them publicly. Here's how you can use pure WireGuard and some networking tricks to
|
||||||
|
make this work.
|
||||||
|
|
||||||
|
I implemented this technique to enable cross-machine container communication in
|
||||||
|
[Uncloud](https://github.com/psviderski/uncloud), an open source clustering and deployment tool for Docker.
|
||||||
|
|
||||||
|
* [What we're building](#what-were-building)
|
||||||
|
* [Prequisites](#prerequisites)
|
||||||
|
* [Step 1: Configure Docker networks](#step-1-configure-docker-networks)
|
||||||
|
* [Step 2: Connect Docker networks with WireGuard](#step-2-connect-docker-networks-with-wireguard)
|
||||||
|
* [Step 3: Configure IP routing](#step-3-configure-ip-routing)
|
||||||
|
* [Step 4: Testing](#step-4-testing)
|
||||||
|
* [Step 5: Make the configuration persistent](#step-5-make-the-configuration-persistent)
|
||||||
|
* [Scaling beyond two machines and limitations](#scaling-beyond-two-machines-and-limitations)
|
||||||
|
* [Automating with Uncloud](#automating-with-uncloud)
|
||||||
|
* [Alternative solutions](#alternative-solutions)
|
||||||
|
* [Conclusion](#conclusion)
|
||||||
|
|
||||||
## What we're building
|
## What we're building
|
||||||
|
|
||||||
Docker containers are typically connected to a [bridge network](https://docs.docker.com/engine/network/drivers/bridge/)
|
Docker containers are typically connected to a [bridge network](https://docs.docker.com/engine/network/drivers/bridge/)
|
||||||
on their host machine, which allows them to communicate with each other. A bridge network also provides isolation from
|
on their host machine, which allows them to communicate with each other. A bridge network also provides isolation from
|
||||||
containers not connected to it and other networks on the host. What we want to achieve is connecting these bridge
|
containers not connected to it and other networks on the host. What we want to achieve is to connect these bridge
|
||||||
networks across machines so that containers on different machines can communicate as if they were connected to the same
|
networks across machines so that containers on different machines can communicate as if they were connected to the same
|
||||||
local bridge network.
|
local bridge network.
|
||||||
|
|
||||||
The incantation we need is called a site-to-site VPN. Any solution would work. Moreover, if the machines are on the same
|
The incantation we need is called a site-to-site VPN. Any solution would work. Moreover, if the machines are on the same
|
||||||
local network, they're already connected and only lack the appropriate routing configuration. But I'll describe a more
|
local network, they're already connected and only miss the appropriate routing configuration. But I'll describe a more
|
||||||
versatile approach that works even when the machines are on different continents or behind NAT. WireGuard is the ideal
|
versatile approach that works even when the machines are on different continents or behind NAT. WireGuard is the ideal
|
||||||
solution for this use case: it's lightweight, [fast](https://www.wireguard.com/performance/), simple to configure,
|
solution for this use case: it's lightweight, [fast](https://www.wireguard.com/performance/), simple to configure,
|
||||||
provides [strong security](https://www.wireguard.com/protocol/) and NAT traversal.
|
provides [strong security](https://www.wireguard.com/protocol/) and NAT traversal.
|
||||||
@@ -37,21 +45,19 @@ communicate with each other using their private IPs.
|
|||||||
|
|
||||||
I will use these two machines:
|
I will use these two machines:
|
||||||
|
|
||||||
* Machine 1: Debian 12 virtual machine in my homelab network in Australia, which is behind NAT
|
* Machine 1: Debian 12 virtual machine in my homelab network in Australia which is behind NAT
|
||||||
* Machine 2: Ubuntu 24.04 server from Hetzner in Finland that has a public IP
|
* Machine 2: Ubuntu 24.04 server from Hetzner in Finland that has a public IP
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
<!-- truncate -->
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Basic knowledge of [Docker networking](https://docs.docker.com/network/) and [WireGuard](https://www.wireguard.com/).
|
* Basic knowledge of [Docker networking](https://docs.docker.com/network/) and [WireGuard](https://www.wireguard.com/).
|
||||||
If you're new to these topics, you might want to read up on them first.
|
If you're new to these topics, you might want to read up on them first.
|
||||||
- At least two Linux machines with root access and Docker installed. They should be on the same network or be able to
|
* At least two Linux machines with root access and Docker installed. They should be on the same network or reachable
|
||||||
communicate over the internet.
|
over the internet.
|
||||||
|
|
||||||
## Step 1: Configure Docker networks
|
# Step 1: Configure Docker networks
|
||||||
|
|
||||||
Most of the commands in this guide require root privileges. You can run them with `sudo` or log in as root. I'll start
|
Most of the commands in this guide require root privileges. You can run them with `sudo` or log in as root. I'll start
|
||||||
root shells on both machines with `sudo -i` for convenience.
|
root shells on both machines with `sudo -i` for convenience.
|
||||||
@@ -64,7 +70,7 @@ Therefore, let's create new Docker bridge networks on each machine with manually
|
|||||||
choose any subnets from
|
choose any subnets from
|
||||||
the [private IPv4 address ranges](https://en.wikipedia.org/wiki/Private_network#Private_IPv4_addresses)
|
the [private IPv4 address ranges](https://en.wikipedia.org/wiki/Private_network#Private_IPv4_addresses)
|
||||||
that do not overlap with each other or with your existing networks. I'll use `10.200.1.0/24` and `10.200.2.0/24`
|
that do not overlap with each other or with your existing networks. I'll use `10.200.1.0/24` and `10.200.2.0/24`
|
||||||
for Machine 1 and Machine 2, respectively. They don't even need to be sequential or be part of the same larger network.
|
for Machine 1 and Machine 2 respectively. They don't even need to be sequential or be part of the same larger network.
|
||||||
However, using a common parent network (like `10.200.0.0/16` in my case) can simplify firewall rules and make it easier
|
However, using a common parent network (like `10.200.0.0/16` in my case) can simplify firewall rules and make it easier
|
||||||
to manage more machines later.
|
to manage more machines later.
|
||||||
|
|
||||||
@@ -81,16 +87,16 @@ Starting with Docker 28.2.0 ([PR](https://github.com/moby/moby/pull/49832)), you
|
|||||||
host interfaces you
|
host interfaces you
|
||||||
allow [direct routing](https://docs.docker.com/engine/network/packet-filtering-firewalls/#direct-routing) to containers
|
allow [direct routing](https://docs.docker.com/engine/network/packet-filtering-firewalls/#direct-routing) to containers
|
||||||
in bridge networks. This is done by specifying the `com.docker.network.bridge.trusted_host_interfaces` option when
|
in bridge networks. This is done by specifying the `com.docker.network.bridge.trusted_host_interfaces` option when
|
||||||
creating the network. In our case, we want to allow routing via the WireGuard interface `wg0` that will be created in
|
creating the network. In our case, we want to allow routing via the WireGuard interface `wg0` that we be created in the
|
||||||
the next step.
|
next step.
|
||||||
|
|
||||||
Provide this option even if you're using an older Docker version, as it'll be required if you upgrade Docker in the
|
Provide this option even if you're using an older Docker version as it'll be required if you upgrade Docker in the
|
||||||
future.
|
future.
|
||||||
|
|
||||||
## Step 2: Connect Docker networks with WireGuard
|
## Step 2: Connect Docker networks with WireGuard
|
||||||
|
|
||||||
By default, WireGuard uses the UDP port 51820 for communication. To establish a tunnel, at least one of the machines
|
By default, WireGuard uses the UDP port 51280 for communication. To establish a tunnel, at least one of the machines
|
||||||
needs to be able to reach the other's port over the internet or local network. Please make sure it's not blocked by a
|
need to be able to reach the other's port over the internet or local network. Please make sure it's not blocked by a
|
||||||
firewall on both machines.
|
firewall on both machines.
|
||||||
|
|
||||||
For example, when using `iptables`, you can allow incoming UDP traffic on port 51820 with the following command:
|
For example, when using `iptables`, you can allow incoming UDP traffic on port 51820 with the following command:
|
||||||
@@ -122,7 +128,7 @@ PrivateKey = <replace with 'privatekey' file content from Machine 1>
|
|||||||
|
|
||||||
[Peer]
|
[Peer]
|
||||||
PublicKey = <replace with 'publickey' file content from Machine 2>
|
PublicKey = <replace with 'publickey' file content from Machine 2>
|
||||||
# IP ranges for which a peer will route traffic: Docker subnet on Machine 2
|
# IP ranges for which a peer will route traffic - Docker subnet on Machine 2
|
||||||
AllowedIPs = 10.200.2.0/24
|
AllowedIPs = 10.200.2.0/24
|
||||||
# Public IP of Machine 2
|
# Public IP of Machine 2
|
||||||
Endpoint = 157.180.72.195:51820
|
Endpoint = 157.180.72.195:51820
|
||||||
@@ -139,7 +145,7 @@ PrivateKey = <replace with 'privatekey' file content from Machine 2>
|
|||||||
|
|
||||||
[Peer]
|
[Peer]
|
||||||
PublicKey = <replace with 'publickey' file content from Machine 1>
|
PublicKey = <replace with 'publickey' file content from Machine 1>
|
||||||
# IP ranges for which a peer will route traffic: Docker subnet on Machine 1
|
# IP ranges for which a peer will route traffic - Docker subnet on Machine 1
|
||||||
AllowedIPs = 10.200.1.0/24
|
AllowedIPs = 10.200.1.0/24
|
||||||
# Reachable endpoint of Machine 1
|
# Reachable endpoint of Machine 1
|
||||||
# Endpoint =
|
# Endpoint =
|
||||||
@@ -156,14 +162,14 @@ In my case, Machine 1 is behind NAT in my private homelab network which is not r
|
|||||||
server (Machine 2). The bidirectional tunnel can still be established in this case but Machine 1 must initiate the
|
server (Machine 2). The bidirectional tunnel can still be established in this case but Machine 1 must initiate the
|
||||||
connection.
|
connection.
|
||||||
|
|
||||||
If both of your machines are reachable from each other, you should specify the `Endpoint` option in both configs which
|
If both of your machine are reachable from each other, you should specify the `Endpoint` option in both configs which
|
||||||
will allow them to establish the connection without waiting for the other side to initiate it. If both of your machines
|
will allow them to establish the connection without waiting for the other side to initiate it. If both of your machines
|
||||||
are behind NAT, see [NAT to NAT Connections](https://github.com/pirate/wireguard-docs#NAT-to-NAT-Connections) for more
|
are behind NAT, see [NAT to NAT Connections](https://github.com/pirate/wireguard-docs#NAT-to-NAT-Connections) for more
|
||||||
information.
|
information.
|
||||||
|
|
||||||
Note also that we don't set the `Address` option in the configs because we don't want to assign any IP addresses to the
|
Note also that we don't set `Address` option in the configs because we don't want to assign any IP addresses to the
|
||||||
WireGuard interfaces. We want the tunnel to only encapsulate and transfer packets from the `multi-host` bridge networks
|
WireGuard interfaces. We want the tunnel to only encapsulate and transfer packets from the `multi-host` bridge networks
|
||||||
and don't want either end of it to be the destination for the packets.
|
and don't want any end of it to be the destination for the packets.
|
||||||
|
|
||||||
As the key pairs are now specified in the configuration files, you can remove the `privatekey` and `publickey` files on
|
As the key pairs are now specified in the configuration files, you can remove the `privatekey` and `publickey` files on
|
||||||
both machines:
|
both machines:
|
||||||
@@ -199,19 +205,14 @@ If you see the `latest handshake` time updating, it means the tunnel is working
|
|||||||
|
|
||||||
## Step 3: Configure IP routing
|
## Step 3: Configure IP routing
|
||||||
|
|
||||||
You've established the WireGuard tunnel, but packets between containers won't flow yet. You need to configure IP routing
|
|
||||||
between the tunnel and the container networks.
|
|
||||||
|
|
||||||
Docker daemon automatically enables IP forwarding in the kernel when it starts, so you don't need to manually configure
|
Docker daemon automatically enables IP forwarding in the kernel when it starts, so you don't need to manually configure
|
||||||
`net.ipv4.ip_forward` with `sysctl`.
|
`net.ipv4.ip_forward` with `sysctl`.
|
||||||
|
|
||||||
The challenge is that Docker blocks traffic between external interfaces and container networks by default for security
|
However, Docker blocks traffic between external interfaces and container networks by default for security. You need to
|
||||||
reasons. You need to explicitly allow WireGuard traffic from `wg0` interface to reach your containers via the
|
explicitly allow WireGuard traffic from `wg0` interface to reach your containers via the `multi-host` bridge interface.
|
||||||
`multi-host` bridge interface. Docker uses iptables, so you can allow this traffic by adding a rule to the `FORWARD`
|
Docker uses iptables, so you can allow this traffic by adding a rule to the `FORWARD` chain before any other
|
||||||
chain before any other Docker-managed rules that would drop it.
|
Docker-managed rules that would drop it. Luckily, Docker creates a special `DOCKER-USER` chain exactly for this purpose
|
||||||
|
that the `FORWARD` chain jumps to before jumping to any other Docker-managed chains.
|
||||||
Fortunately, Docker creates a special `DOCKER-USER` chain exactly for this purpose. It's processed before other
|
|
||||||
Docker-managed chains, allowing you to add custom rules that won't be overridden by Docker.
|
|
||||||
|
|
||||||
To create the required iptables rule, you need to find the bridge interface name for the `multi-host` network you
|
To create the required iptables rule, you need to find the bridge interface name for the `multi-host` network you
|
||||||
created earlier. It's named `br-<short-network-id>`, where `<short-network-id>` is the first 12 characters of the
|
created earlier. It's named `br-<short-network-id>`, where `<short-network-id>` is the first 12 characters of the
|
||||||
@@ -219,7 +220,7 @@ network ID.
|
|||||||
|
|
||||||
Add the iptables rule to allow traffic from `wg0` to `multi-host` bridge on Machine 1:
|
Add the iptables rule to allow traffic from `wg0` to `multi-host` bridge on Machine 1:
|
||||||
|
|
||||||
```shell
|
```bash
|
||||||
$ docker network ls -f name=multi-host
|
$ docker network ls -f name=multi-host
|
||||||
NETWORK ID NAME DRIVER SCOPE
|
NETWORK ID NAME DRIVER SCOPE
|
||||||
661096b2a5d9 multi-host bridge local
|
661096b2a5d9 multi-host bridge local
|
||||||
@@ -228,14 +229,14 @@ $ iptables -I DOCKER-USER -i wg0 -o br-661096b2a5d9 -j ACCEPT
|
|||||||
|
|
||||||
Add the iptables rule to allow traffic from `wg0` to `multi-host` bridge on Machine 2:
|
Add the iptables rule to allow traffic from `wg0` to `multi-host` bridge on Machine 2:
|
||||||
|
|
||||||
```shell
|
```bash
|
||||||
$ docker network ls -f name=multi-host
|
$ docker network ls -f name=multi-host
|
||||||
NETWORK ID NAME DRIVER SCOPE
|
NETWORK ID NAME DRIVER SCOPE
|
||||||
48f808048e7c multi-host bridge local
|
48f808048e7c multi-host bridge local
|
||||||
$ iptables -I DOCKER-USER -i wg0 -o br-48f808048e7c -j ACCEPT
|
$ iptables -I DOCKER-USER -i wg0 -o br-48f808048e7c -j ACCEPT
|
||||||
```
|
```
|
||||||
|
|
||||||
The traffic in the other direction (from `multi-host` bridge to `wg0`) is not blocked by Docker by default. But it still
|
The traffic the other way around (from `multi-host` bridge to `wg0`) is not blocked by Docker by default. But it still
|
||||||
won't be able to make it through the tunnel. The reason is that Docker creates a `MASQUERADE` rule in the `nat` table
|
won't be able to make it through the tunnel. The reason is that Docker creates a `MASQUERADE` rule in the `nat` table
|
||||||
for every bridge network with option
|
for every bridge network with option
|
||||||
[`com.docker.network.bridge.enable_ip_masquerade`](https://docs.docker.com/engine/network/drivers/bridge/#options) set
|
[`com.docker.network.bridge.enable_ip_masquerade`](https://docs.docker.com/engine/network/drivers/bridge/#options) set
|
||||||
@@ -245,13 +246,13 @@ to `true` (which is the default). In my case, the rule looks like this on Machin
|
|||||||
POSTROUTING -s 10.200.1.0/24 ! -o br-661096b2a5d9 -j MASQUERADE
|
POSTROUTING -s 10.200.1.0/24 ! -o br-661096b2a5d9 -j MASQUERADE
|
||||||
```
|
```
|
||||||
|
|
||||||
This essentially configures NAT for all external traffic coming from containers which is necessary for allowing them to
|
This essentially configures NAT for all external traffic coming from containers which is necessary to allow them to
|
||||||
access the internet and other external networks. However, it equally applies to the traffic going through the `wg0`
|
access the internet and other external networks. However, it equally applies to the traffic going through the `wg0`
|
||||||
interface. It tries to masquerade the source IP address of the packets with the IP address of the `wg0` interface and
|
interface. It tries to masquerade the source IP address of the packets with the IP address of the `wg0` interface and
|
||||||
fails because the `wg0` interface doesn't have an IP. This results in the packets being
|
fails because the `wg0` interface doesn't have an IP. This results in the packets being
|
||||||
[dropped](https://elixir.bootlin.com/linux/v6.15.5/source/net/netfilter/nf_nat_masquerade.c#L54-L58).
|
[dropped](https://elixir.bootlin.com/linux/v6.15.5/source/net/netfilter/nf_nat_masquerade.c#L54-L58).
|
||||||
|
|
||||||
You could assign an IP address to `wg0` but this would cause the following unwanted side effects:
|
You cloud assign an IP address to `wg0` but this would cause the following unwanted side effects:
|
||||||
|
|
||||||
- Containers from other Docker networks on the same machine could route through the tunnel to reach remote `multi-host`
|
- Containers from other Docker networks on the same machine could route through the tunnel to reach remote `multi-host`
|
||||||
containers, violating Docker's network isolation model.
|
containers, violating Docker's network isolation model.
|
||||||
@@ -321,7 +322,7 @@ PING 10.200.2.2 (10.200.2.2): 56 data bytes
|
|||||||
64 bytes from 10.200.2.2: seq=2 ttl=62 time=297.285 ms
|
64 bytes from 10.200.2.2: seq=2 ttl=62 time=297.285 ms
|
||||||
```
|
```
|
||||||
|
|
||||||
Both hosts have IPs assigned to the `multi-host` bridges, `10.200.1.1` and `10.200.2.1` respectively, which should also
|
Both hosts have IPs assigned to the `multi-host` bridges, `10.200.1.1` and `10.200.2.1` respectively which should aslo
|
||||||
be reachable from the containers or hosts on both machines.
|
be reachable from the containers or hosts on both machines.
|
||||||
|
|
||||||
You can see from the `ping` command the latency is quite high (~300 ms) in my case because the packets have to travel
|
You can see from the `ping` command the latency is quite high (~300 ms) in my case because the packets have to travel
|
||||||
@@ -343,12 +344,12 @@ be to use `PostUp` and `PostDown` options in the WireGuard configs to automatica
|
|||||||
starts/stops.
|
starts/stops.
|
||||||
|
|
||||||
Append the following lines to the `[Interface]` section in `/etc/wireguard/wg0.conf`. Make sure to replace
|
Append the following lines to the `[Interface]` section in `/etc/wireguard/wg0.conf`. Make sure to replace
|
||||||
`<network-id>` with your actual Docker network ID from [Step 3](#step-3-configure-ip-routing). The `%i` is replaced by
|
`<network-id>` with your actual Docker network ID from Step 3. The `%i` is replaced by WireGuard with the interface
|
||||||
WireGuard with the interface name (`wg0`).
|
name (`wg0`).
|
||||||
|
|
||||||
On Machine 1:
|
On Machine 1:
|
||||||
|
|
||||||
```ini
|
```shell
|
||||||
[Interface]
|
[Interface]
|
||||||
...
|
...
|
||||||
PostUp = iptables -I DOCKER-USER -i %i -o br-<network-id> -j ACCEPT; iptables -t nat -I POSTROUTING -s 10.200.1.0/24 -o %i -j RETURN
|
PostUp = iptables -I DOCKER-USER -i %i -o br-<network-id> -j ACCEPT; iptables -t nat -I POSTROUTING -s 10.200.1.0/24 -o %i -j RETURN
|
||||||
@@ -357,7 +358,7 @@ PostDown = iptables -D DOCKER-USER -i %i -o br-<network-id> -j ACCEPT; iptables
|
|||||||
|
|
||||||
On Machine 2:
|
On Machine 2:
|
||||||
|
|
||||||
```ini
|
```shell
|
||||||
[Interface]
|
[Interface]
|
||||||
...
|
...
|
||||||
PostUp = iptables -I DOCKER-USER -i %i -o br-<network-id> -j ACCEPT; iptables -t nat -I POSTROUTING -s 10.200.2.0/24 -o %i -j RETURN
|
PostUp = iptables -I DOCKER-USER -i %i -o br-<network-id> -j ACCEPT; iptables -t nat -I POSTROUTING -s 10.200.2.0/24 -o %i -j RETURN
|
||||||
@@ -367,7 +368,7 @@ PostDown = iptables -D DOCKER-USER -i %i -o br-<network-id> -j ACCEPT; iptables
|
|||||||
### Start WireGuard on boot
|
### Start WireGuard on boot
|
||||||
|
|
||||||
The `wireguard-tools` package provides a convenient systemd service to manage WireGuard interfaces. Since our iptables
|
The `wireguard-tools` package provides a convenient systemd service to manage WireGuard interfaces. Since our iptables
|
||||||
rules should have priority over Docker's rules, WireGuard must start after Docker.
|
rules should have a priority over Docker's rules, WireGuard must start after Docker.
|
||||||
|
|
||||||
Create a systemd drop-in configuration for this:
|
Create a systemd drop-in configuration for this:
|
||||||
|
|
||||||
@@ -385,135 +386,54 @@ Then enable the WireGuard service to start on boot:
|
|||||||
```shell
|
```shell
|
||||||
systemctl enable wg-quick@wg0.service
|
systemctl enable wg-quick@wg0.service
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
# Verify the unit includes the drop-in configuration
|
# Verify the unit includes the drop-in configuration.
|
||||||
systemctl cat wg-quick@wg0.service
|
systemctl cat wg-quick@wg0.service
|
||||||
```
|
```
|
||||||
|
|
||||||
## Scaling beyond two machines
|
## Scaling beyond two machines and limitations
|
||||||
|
|
||||||
Adding a third machine means following the same steps as above on it and updating WireGuard configs on *all* existing
|
//Adding a third machine requires updating configs on all existing machines. This gets tedious fast... //WireGuard mesh
|
||||||
machines. Each machine needs a `[Peer]` section for every other machine in the network. With 5 machines, that's 4 peer
|
and challenges to manually manage key pairs and distribute configs //Requirements for NAT traversal: at least one
|
||||||
entries per config file or 20 peer configurations total that establish a full mesh topology.
|
machine in each pair must be reachable by the other. The wireguard will fail to establish a connection if both machines
|
||||||
|
are behind NAT without special tricks that are beyond the scope of this post. DNS resolution for container names across
|
||||||

|
machines is not covered here, but you can use a service discovery tool like Consul.
|
||||||
|
|
||||||
## Limitations
|
|
||||||
|
|
||||||
### DNS resolution
|
|
||||||
|
|
||||||
The main limitation of this setup is that containers can't find each other by name across machines. You need to use
|
|
||||||
their IP addresses directly or implement a service discovery solution like Consul or CoreDNS.
|
|
||||||
|
|
||||||
For small deployments, you can assign static IPs to containers and use those IPs in your app configuration. But service
|
|
||||||
discovery is essential for larger and more dynamic deployments.
|
|
||||||
|
|
||||||
### NAT traversal constraints
|
|
||||||
|
|
||||||
For WireGuard connections to work, at least one machine in each pair must be publicly reachable. The connection fails if
|
|
||||||
both machines are behind NAT. While solutions exist (STUN/TURN servers, UDP hole punching), they're beyond the scope of
|
|
||||||
this guide.
|
|
||||||
|
|
||||||
Common scenarios that work:
|
|
||||||
|
|
||||||
- ✅ Cloud VPS (public or private IP) ↔ Cloud VPS (public or private IP). Both can use private IPs only if they're in the
|
|
||||||
same cloud provider's network
|
|
||||||
- ✅ Homelab (behind NAT) ↔ Cloud VPS (public IP)
|
|
||||||
- ✅ Homelab (private IP) ↔ Homelab (private IP on the same local network)
|
|
||||||
- ❌ Homelab (behind NAT) ↔ Friend's homelab (behind NAT) — requires a relay server
|
|
||||||
|
|
||||||
## Automating with Uncloud
|
## Automating with Uncloud
|
||||||
|
|
||||||
As your setup grows, managing subnet allocation for Docker networks (ensuring each gets a unique range like
|
//I built Uncloud to handle all the heavy lifting automatically.
|
||||||
`10.200.1.0/24`, `10.200.2.0/24`) and updating WireGuard configs manually may become tedious quickly.
|
|
||||||
|
|
||||||
I built [Uncloud](https://github.com/psviderski/uncloud), an open source clustering and deployment tool for Docker, to
|
You can initialise a cluster of machines by running the following commands:
|
||||||
handle all the heavy lifting automatically. You can get the same result and much more with just a few commands.
|
|
||||||
|
|
||||||
Initialise a new cluster on your first machine:
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
uc machine init user@machine1
|
uc machine init user@machine1-ip
|
||||||
|
uc machine add user@machine2-ip
|
||||||
|
...
|
||||||
|
uc machine add user@machineN-ip
|
||||||
```
|
```
|
||||||
|
|
||||||
Add more machines to the cluster:
|
//This will create `uncloud` Docker bridge network on each machine with `10.210.N.0/24` subnet by default and set up
|
||||||
|
//WireGuard mesh network between them and make persistent across reboots.
|
||||||
|
|
||||||
```shell
|
//Mention embedded DNS that resolves container IPs by their service names and multi-machine Docker Compose support.
|
||||||
uc machine add user@machine2
|
|
||||||
uc machine add user@machine3
|
|
||||||
```
|
|
||||||
|
|
||||||
This is what these commands do:
|
|
||||||
|
|
||||||
- Create the `uncloud` Docker network on each machine with unique subnets (`10.210.0.0/24`, `10.210.1.0/24`, etc.).
|
|
||||||
- Generate WireGuard key pairs and distribute public keys across machines.
|
|
||||||
- Start a full mesh WireGuard network.
|
|
||||||
- Configure iptables rules for container communication.
|
|
||||||
- Make everything persistent across reboots.
|
|
||||||
|
|
||||||
Beyond the network setup, you also get:
|
|
||||||
|
|
||||||
- Multi-machine [Docker Compose](https://docs.docker.com/reference/compose-file/) deployments with zero downtime.
|
|
||||||
- Built-in DNS server that resolves container IPs by their service names.
|
|
||||||
- Automatic HTTPS and reverse proxy configuration.
|
|
||||||
|
|
||||||
Check out the [documentation](https://uncloud.run/docs) for more information.
|
|
||||||
|
|
||||||
## Alternative solutions
|
## Alternative solutions
|
||||||
|
|
||||||
Before settling on the WireGuard approach, I evaluated several alternatives. Note that I only considered lightweight
|
//I wanted to explore only lightweight solutions for Docker so not talking about Kubernetes and a numerous CNI
|
||||||
solutions suitable for Docker. Kubernetes and its CNI ecosystem deserve a separate discussion.
|
//drivers. Let's leave this beast for another time.
|
||||||
|
|
||||||
### Docker Swarm overlay network
|
### Docker Swarm overlay network
|
||||||
|
|
||||||
Docker Swarm includes built-in [overlay networking](https://docs.docker.com/engine/network/drivers/overlay/). However,
|
|
||||||
to use an overlay network, you need to run a [Swarm cluster](https://docs.docker.com/engine/swarm/) on all machines.
|
|
||||||
This introduces additional complexity:
|
|
||||||
|
|
||||||
- Cluster nodes must
|
|
||||||
[maintain the quorum](https://docs.docker.com/engine/swarm/admin_guide/#maintain-the-quorum-of-managers). Losing
|
|
||||||
quorum impacts the functionality of overlay networks.
|
|
||||||
- Ports 2377, 7946, and 4789 must be exposed to untrusted networks (if connecting machines over the internet) for
|
|
||||||
cluster management, node communication, and VXLAN overlay traffic.
|
|
||||||
- VXLAN traffic is unencrypted by default, requiring additional
|
|
||||||
[hardening](https://docs.docker.com/engine/swarm/swarm-tutorial/#open-protocols-and-ports-between-the-hosts) with
|
|
||||||
IPSec and firewalls.
|
|
||||||
- Every node must be publicly reachable. VXLAN fails if machines are behind NAT.
|
|
||||||
|
|
||||||
If these limitations are acceptable, an overlay network is a great option. Note that you can use an overlay network with
|
|
||||||
regular containers without using any other Swarm features.
|
|
||||||
|
|
||||||
### Flannel
|
### Flannel
|
||||||
|
|
||||||
[Flannel](https://github.com/flannel-io/flannel) is battle-tested in Kubernetes but can also be used with Docker. It
|
|
||||||
supports multiple [backends](https://github.com/flannel-io/flannel/blob/master/Documentation/backends.md) including
|
|
||||||
VXLAN and WireGuard.
|
|
||||||
|
|
||||||
The main caveat is that Flannel requires running etcd as the datastore for coordination. Depending on your availability
|
|
||||||
requirements, you may need to set up an etcd cluster with multiple nodes. This is not a problem if you're already using
|
|
||||||
Kubernetes. But if you're just running a few Docker hosts, it might seem like overkill.
|
|
||||||
|
|
||||||
### Tailscale
|
### Tailscale
|
||||||
|
|
||||||
[Tailscale](https://tailscale.com/) makes WireGuard easy with automatic NAT traversal and key management, but it's not
|
//Not a generic site-to-site VPN, so the recommended approach is to use Tailscale on the container level. This way a
|
||||||
designed as a generic site-to-site VPN for connecting networks. Instead, it connects individual devices and provides
|
//container that needs to talk across machines is configured as a Tailscale machine so it can connect to other Tailscale
|
||||||
identity-based access controls.
|
//machines. Maybe the subnet router feature can be used to connect Docker networks in a similar I described here, but
|
||||||
|
//I haven't tested it.
|
||||||
The recommended approach for using [Tailscale with Docker](https://tailscale.com/kb/1282/docker) is to connect each
|
|
||||||
individual container to a Tailscale network. This means deploying an additional Tailscale container alongside every
|
|
||||||
application container.
|
|
||||||
|
|
||||||
Tailscale's [subnet router](https://tailscale.com/kb/1019/subnets) feature might work to expose Docker networks similar
|
|
||||||
to our setup, but I haven't tested this approach.
|
|
||||||
|
|
||||||
## Conclusion
|
## Conclusion
|
||||||
|
|
||||||
That's it! Now you know how to securely connect Docker containers across multiple machines using WireGuard. The manual
|
//Summarise what we've done.?
|
||||||
setup works great for a handful of machines that you don't need to change often, but configuration management becomes
|
|
||||||
tedious as you scale.
|
|
||||||
|
|
||||||
If you don't want to mess with manual configuration, consider automation tools like Uncloud or evaluate if you need a
|
If you like this article and my work, you can follow me on X [@psviderski](https://x.com/psviderski).
|
||||||
full orchestration platform.
|
|
||||||
|
|
||||||
Feel free to reach out if you have any questions or suggestions. You can find me on X
|
|
||||||
at [@psviderski](https://x.com/psviderski) or check my GitHub profile [psviderski](https://github.com/psviderski/)
|
|
||||||
for other contacts.
|
|
||||||
|
Before Width: | Height: | Size: 897 KiB After Width: | Height: | Size: 897 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
uncloud-docs:
|
||||||
|
image: ghcr.io/psviderski/uncloud-docs:latest
|
||||||
|
pull_policy: always
|
||||||
|
user: nobody
|
||||||
|
x-ports:
|
||||||
|
- docs.uncloud.run:8000/https
|
||||||
|
scale: 2
|
||||||
@@ -42,7 +42,7 @@ Some of the common use cases Uncloud is a great fit for:
|
|||||||
- **Dev/staging environments**: Spin up additional environments for development and testing that mirror production
|
- **Dev/staging environments**: Spin up additional environments for development and testing that mirror production
|
||||||
reusing the same Compose configuration.
|
reusing the same Compose configuration.
|
||||||
|
|
||||||
## What makes Uncloud special
|
## What makes Uncloud different
|
||||||
|
|
||||||
Here are the design decisions that make Uncloud stand out:
|
Here are the design decisions that make Uncloud stand out:
|
||||||
|
|
||||||
@@ -67,9 +67,9 @@ 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 (optional)
|
### Managed DNS service
|
||||||
|
|
||||||
Uncloud can provide **managed DNS records** like `<service-name>.<cluster-id>.uncld.dev` 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
|
||||||
instantly access it from anywhere with a proper DNS name and HTTPS without any manual DNS configuration. This makes
|
instantly access it from anywhere with a proper DNS name and HTTPS without any manual DNS configuration. This makes
|
||||||
self-hosting much more accessible and simplifies the process of adding your own domain later.
|
self-hosting much more accessible and simplifies the process of adding your own domain later.
|
||||||
@@ -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:
|
Install Uncloud CLI and deploy your first app in minutes:
|
||||||
|
|
||||||
* [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)
|
||||||
@@ -89,23 +89,6 @@ For example, move it to `/usr/local/bin` which is a common location for user-ins
|
|||||||
sudo mv ./uc /usr/local/bin
|
sudo mv ./uc /usr/local/bin
|
||||||
```
|
```
|
||||||
|
|
||||||
Follow the same steps to upgrade to the latest version in the future.
|
|
||||||
|
|
||||||
## Debian
|
|
||||||
|
|
||||||
On a Debian system, you can install Uncloud CLI from an unofficial
|
|
||||||
[repository](https://debian.griffo.io/) maintained by
|
|
||||||
[@dariogriffo](https://github.com/dariogriffo):
|
|
||||||
|
|
||||||
```shell
|
|
||||||
curl -sS https://debian.griffo.io/EA0F721D231FDD3A0A17B9AC7808B4DD62C41256.asc | sudo gpg --dearmor --yes -o /etc/apt/trusted.gpg.d/debian.griffo.io.gpg
|
|
||||||
echo "deb https://debian.griffo.io/apt $(lsb_release -sc 2>/dev/null) main" | sudo tee /etc/apt/sources.list.d/debian.griffo.io.list
|
|
||||||
apt install -y uncloud
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, you can download `.deb` packages directly from the repository
|
|
||||||
[releases](https://github.com/dariogriffo/uncloud-debian/releases) page.
|
|
||||||
|
|
||||||
## Verify installation
|
## Verify installation
|
||||||
|
|
||||||
After installation, verify that `uc` command is working:
|
After installation, verify that `uc` command is working:
|
||||||
@@ -114,6 +97,22 @@ After installation, verify that `uc` command is working:
|
|||||||
uc --version
|
uc --version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Linux (via package managers)
|
||||||
|
|
||||||
|
### Debian
|
||||||
|
|
||||||
|
Via unofficial repository packages created and maintained at [uncloud-debian](https://github.com/dariogriffo/uncloud-debian/) by @dariogriffo
|
||||||
|
|
||||||
|
You can install uncloud the debian way by running:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS https://debian.griffo.io/EA0F721D231FDD3A0A17B9AC7808B4DD62C41256.asc | sudo gpg --dearmor --yes -o /etc/apt/trusted.gpg.d/debian.griffo.io.gpg
|
||||||
|
echo "deb https://debian.griffo.io/apt $(lsb_release -sc 2>/dev/null) main" | sudo tee /etc/apt/sources.list.d/debian.griffo.io.list
|
||||||
|
apt install -y uncloud
|
||||||
|
```
|
||||||
|
|
||||||
|
or in the releases page of the repository [here](https://github.com/dariogriffo/uncloud-debian/releases)
|
||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
|
|
||||||
Now that you have `uc` installed, you're ready to:
|
Now that you have `uc` installed, you're ready to:
|
||||||
@@ -4,21 +4,13 @@ In this guide, we'll deploy [Excalidraw](https://excalidraw.com) — a popular s
|
|||||||
Linux server. You'll learn the **basics of Uncloud** and see how simple it is to **run web apps** on your own
|
Linux server. You'll learn the **basics of Uncloud** and see how simple it is to **run web apps** on your own
|
||||||
infrastructure with secure internet access.
|
infrastructure with secure internet access.
|
||||||
|
|
||||||
:::info NOTE
|
|
||||||
To give you a chance to play with Uncloud without even leaving your browser or needing your own servers, we're providing interactive tutorials and playgrounds on the [iximiuz Labs](https://labs.iximiuz.com/) platform.
|
|
||||||
|
|
||||||
You can follow [this tutorial](https://labs.iximiuz.com/tutorials/uncloud-create-cluster-ebebf72b) which walks you through creating a new cluster with two machines and then deploying a simple web service to it.
|
|
||||||
|
|
||||||
You can also launch the [Uncloud playground](https://labs.iximiuz.com/playgrounds/uncloud-cluster-64523f7c) where you can play with an already initialized Uncloud cluster.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Before you begin, you'll need:
|
Before you begin, you'll need:
|
||||||
|
|
||||||
- **Uncloud CLI** [installed](1-install-cli.md) on your local machine
|
- **Uncloud CLI** [installed](1-install-cli.md) on your local machine
|
||||||
- A **Ubuntu or Debian server** with **public IP address** and **SSH access** using a **private key** (as `root` or a
|
- A **Ubuntu or Debian server** with **public IP address** and **SSH access** (as `root` or a user with `sudo`
|
||||||
user with **passwordless** `sudo` privileges).
|
privileges) using a **private key**.
|
||||||
|
|
||||||
:::tip Need a server?
|
:::tip Need a server?
|
||||||
|
|
||||||
@@ -53,7 +45,7 @@ This command will:
|
|||||||
- Install the Uncloud daemon on your server
|
- Install the Uncloud daemon on your server
|
||||||
- Create a Docker network for Uncloud-managed containers
|
- Create a Docker network for Uncloud-managed containers
|
||||||
- Deploy [Caddy](https://caddyserver.com/) as your reverse proxy listening on host ports 80 and 443
|
- Deploy [Caddy](https://caddyserver.com/) as your reverse proxy listening on host ports 80 and 443
|
||||||
- Reserve a free `xxxxxx.uncld.dev` subdomain via the Uncloud managed DNS service and point it to your
|
- Reserve a free `xxxxxx.cluster.uncloud.run` subdomain via the Uncloud managed DNS service and point it to your
|
||||||
server's IP
|
server's IP
|
||||||
|
|
||||||
All in about a minute!
|
All in about a minute!
|
||||||
@@ -152,7 +144,7 @@ Cluster initialised with machine 'machine-dc3c' and saved as context 'default' i
|
|||||||
Current cluster context is now 'default'.
|
Current cluster context is now 'default'.
|
||||||
Waiting for the machine to be ready...
|
Waiting for the machine to be ready...
|
||||||
|
|
||||||
Reserved cluster domain: 7za6s7.uncld.dev
|
Reserved cluster domain: 7za6s7.cluster.uncloud.run
|
||||||
[+] Deploying service caddy 7/2
|
[+] Deploying service caddy 7/2
|
||||||
✔ Container caddy-d7uk on machine-dc3c Started 6.1s
|
✔ Container caddy-d7uk on machine-dc3c Started 6.1s
|
||||||
✔ Image caddy:2.10.0 on machine-dc3c Pulled 3.7s
|
✔ Image caddy:2.10.0 on machine-dc3c Pulled 3.7s
|
||||||
@@ -162,7 +154,7 @@ Updating cluster domain records in Uncloud DNS to point to machines running cadd
|
|||||||
✔ Machine machine-dc3c (157.180.72.195) Reachable 0.7s
|
✔ Machine machine-dc3c (157.180.72.195) Reachable 0.7s
|
||||||
|
|
||||||
DNS records updated to use only the internet-reachable machines running caddy service:
|
DNS records updated to use only the internet-reachable machines running caddy service:
|
||||||
*.7za6s7.uncld.dev A → 157.180.72.195
|
*.7za6s7.cluster.uncloud.run A → 157.180.72.195
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -185,7 +177,7 @@ You'll see the progress of the deployment and the public URL where you can acces
|
|||||||
✔ Image excalidraw/excalidraw on machine-dc3c Pulled 4.7s
|
✔ Image excalidraw/excalidraw on machine-dc3c Pulled 4.7s
|
||||||
|
|
||||||
excalidraw endpoints:
|
excalidraw endpoints:
|
||||||
• https://excalidraw.7za6s7.uncld.dev → :80
|
• https://excalidraw.7za6s7.cluster.uncloud.run → :80
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verify your deployment
|
## Verify your deployment
|
||||||
@@ -217,7 +209,7 @@ uc ls
|
|||||||
```
|
```
|
||||||
NAME MODE REPLICAS ENDPOINTS
|
NAME MODE REPLICAS ENDPOINTS
|
||||||
caddy global 1
|
caddy global 1
|
||||||
excalidraw replicated 1 https://excalidraw.7za6s7.uncld.dev → :80
|
excalidraw replicated 1 https://excalidraw.7za6s7.cluster.uncloud.run → :80
|
||||||
```
|
```
|
||||||
|
|
||||||
You can see `caddy` service listed here. That's your reverse proxy, running as a regular Uncloud service.
|
You can see `caddy` service listed here. That's your reverse proxy, running as a regular Uncloud service.
|
||||||
@@ -274,20 +266,10 @@ We've successfully converted our deployment created with `uc run` to a Compose f
|
|||||||
|
|
||||||
## Use your own domain
|
## Use your own domain
|
||||||
|
|
||||||
Want to use your own domain, for example, `excalidraw.example.com` instead of `excalidraw.7za6s7.uncld.dev`?
|
Want to use your own domain, for example, `excalidraw.example.com` instead of `excalidraw.7za6s7.cluster.uncloud.run`?
|
||||||
|
|
||||||
Add a CNAME record `excalidraw.example.com` in your DNS provider (Cloudflare, Namecheap, etc.) pointing to
|
Add a CNAME record `excalidraw.example.com` in your DNS provider (Cloudflare, Namecheap, etc.) pointing to
|
||||||
`excalidraw.7za6s7.uncld.dev`. Alternatively, you can add an A record pointing to your server's IP.
|
`excalidraw.7za6s7.cluster.uncloud.run`. Alternatively, you can add an A record pointing to your server's IP.
|
||||||
|
|
||||||
:::info note
|
|
||||||
|
|
||||||
These instructions set up your own domain **in addition to** the Uncloud managed DNS name
|
|
||||||
`excalidraw.7za6s7.uncld.dev`.
|
|
||||||
|
|
||||||
If you want to avoid the managed service altogether, add `--no-dns` to your `uc machine init` command, and point an A
|
|
||||||
DNS record to your server(s)'s IP(s).
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
Then update the published port `80/https` in `compose.yaml` to use your domain:
|
Then update the published port `80/https` in `compose.yaml` to use your domain:
|
||||||
|
|
||||||
@@ -315,8 +297,8 @@ Choose [y/N]: y
|
|||||||
Chose: Yes!
|
Chose: Yes!
|
||||||
|
|
||||||
[+] Deploying services 2/2
|
[+] Deploying services 2/2
|
||||||
✔ Container excalidraw-0z12 on machine-dc3c Started 3.5s
|
✔ Container excalidraw-0z12 on machine-dc3c Started 3.5s
|
||||||
✔ Container excalidraw-azpc on machine-dc3c Removed 3.4s
|
✔ Container excalidraw-azpc on machine-dc3c Removed 3.4s
|
||||||
```
|
```
|
||||||
|
|
||||||
Notice how Uncloud performed a **zero-downtime deployment** — it started the new container with the updated
|
Notice how Uncloud performed a **zero-downtime deployment** — it started the new container with the updated
|
||||||
@@ -326,126 +308,8 @@ Give it a moment for Caddy to obtain a TLS certificate, then visit https://excal
|
|||||||
|
|
||||||
## Clean up
|
## Clean up
|
||||||
|
|
||||||
When you're done experimenting, you can remove the `excalidraw` service or uninstall Uncloud completely.
|
TBD
|
||||||
|
|
||||||
### Remove the service
|
## Next steps
|
||||||
|
|
||||||
Remove the `excalidraw` service while keeping your Uncloud machine running for future deployments:
|
TBD
|
||||||
|
|
||||||
```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
|
|
||||||
|
Before Width: | Height: | Size: 389 KiB After Width: | Height: | Size: 389 KiB |
@@ -10,12 +10,12 @@ import {themes as prismThemes} from 'prism-react-renderer';
|
|||||||
|
|
||||||
/** @type {import('@docusaurus/types').Config} */
|
/** @type {import('@docusaurus/types').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
title: 'Uncloud',
|
title: 'Uncloud Docs',
|
||||||
tagline: 'Self-host and scale web apps without Kubernetes complexity',
|
tagline: 'Dinosaurs are cool',
|
||||||
favicon: 'img/favicon.png',
|
favicon: 'img/favicon.png',
|
||||||
|
|
||||||
// Set the production url of your site here
|
// Set the production url of your site here
|
||||||
url: 'https://uncloud.run',
|
url: 'https://docs.uncloud.run',
|
||||||
// Set the /<baseUrl>/ pathname under which your site is served
|
// Set the /<baseUrl>/ pathname under which your site is served
|
||||||
// For GitHub pages deployment, it is often '/<projectName>/'
|
// For GitHub pages deployment, it is often '/<projectName>/'
|
||||||
baseUrl: '/',
|
baseUrl: '/',
|
||||||
@@ -54,30 +54,27 @@ const config = {
|
|||||||
({
|
({
|
||||||
docs: {
|
docs: {
|
||||||
// Remove this to remove the "edit this page" links.
|
// Remove this to remove the "edit this page" links.
|
||||||
editUrl: 'https://github.com/psviderski/uncloud/edit/main/website/',
|
editUrl: 'https://github.com/psviderski/uncloud/edit/main/docs/',
|
||||||
// Serve the docs at the site's root.
|
// Serve the docs at the site's root.
|
||||||
// routeBasePath: '/',
|
routeBasePath: '/',
|
||||||
showLastUpdateTime: true,
|
showLastUpdateTime: true,
|
||||||
sidebarPath: './sidebars.js',
|
sidebarPath: './sidebars.js',
|
||||||
},
|
},
|
||||||
blog: {
|
// blog: {
|
||||||
blogDescription: 'Blog posts from the Uncloud team and community',
|
// showReadingTime: true,
|
||||||
blogSidebarTitle: 'All posts',
|
// feedOptions: {
|
||||||
blogSidebarCount: 'ALL',
|
// type: ['rss', 'atom'],
|
||||||
showReadingTime: true,
|
// xslt: true,
|
||||||
feedOptions: {
|
// },
|
||||||
type: ['rss', 'atom'],
|
// // Please change this to your repo.
|
||||||
xslt: true,
|
// // Remove this to remove the "edit this page" links.
|
||||||
},
|
// editUrl:
|
||||||
// Please change this to your repo.
|
// 'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
|
||||||
// Remove this to remove the "edit this page" links.
|
// // Useful options to enforce blogging best practices
|
||||||
// editUrl:
|
// onInlineTags: 'warn',
|
||||||
// 'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
|
// onInlineAuthors: 'warn',
|
||||||
// Useful options to enforce blogging best practices
|
// onUntruncatedBlogPosts: 'warn',
|
||||||
onInlineTags: 'warn',
|
// },
|
||||||
onInlineAuthors: 'warn',
|
|
||||||
onUntruncatedBlogPosts: 'warn',
|
|
||||||
},
|
|
||||||
theme: {
|
theme: {
|
||||||
customCss: './src/css/custom.css',
|
customCss: './src/css/custom.css',
|
||||||
},
|
},
|
||||||
@@ -99,24 +96,12 @@ const config = {
|
|||||||
// Relative to your site's "static" directory. Cannot be SVGs. Can be external URLs too.
|
// Relative to your site's "static" directory. Cannot be SVGs. Can be external URLs too.
|
||||||
image: 'img/social-card.png',
|
image: 'img/social-card.png',
|
||||||
navbar: {
|
navbar: {
|
||||||
title: 'Uncloud',
|
title: 'Uncloud Docs',
|
||||||
logo: {
|
logo: {
|
||||||
alt: 'Uncloud Logo',
|
alt: 'Uncloud Logo',
|
||||||
src: 'img/logo.svg',
|
src: 'img/logo.svg',
|
||||||
href: 'https://uncloud.run',
|
|
||||||
},
|
},
|
||||||
items: [
|
items: [
|
||||||
{
|
|
||||||
type: 'doc',
|
|
||||||
docId: 'overview',
|
|
||||||
label: 'Docs',
|
|
||||||
position: 'left',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: 'blog',
|
|
||||||
label: 'Blog',
|
|
||||||
position: 'left',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'search',
|
type: 'search',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "docs",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"docusaurus": "docusaurus",
|
||||||
|
"start": "docusaurus start",
|
||||||
|
"build": "docusaurus build",
|
||||||
|
"swizzle": "docusaurus swizzle",
|
||||||
|
"deploy": "docusaurus deploy",
|
||||||
|
"clear": "docusaurus clear",
|
||||||
|
"serve": "docusaurus serve",
|
||||||
|
"write-translations": "docusaurus write-translations",
|
||||||
|
"write-heading-ids": "docusaurus write-heading-ids"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@docusaurus/core": "3.7.0",
|
||||||
|
"@docusaurus/preset-classic": "3.7.0",
|
||||||
|
"@easyops-cn/docusaurus-search-local": "^0.49.2",
|
||||||
|
"@mdx-js/react": "^3.0.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"prism-react-renderer": "^2.3.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@docusaurus/module-type-aliases": "3.7.0",
|
||||||
|
"@docusaurus/types": "3.7.0"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.5%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 3 chrome version",
|
||||||
|
"last 3 firefox version",
|
||||||
|
"last 5 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80">
|
||||||
|
<g>
|
||||||
|
<rect x="0" y="0" width="80" height="80" rx="10" fill="#18181B"/>
|
||||||
|
<path d="M20 32 L20 48 L36 48 L36 32 M60 32 L45 32 L45 48 L60 48" stroke="white" stroke-width="6" fill="none"
|
||||||
|
stroke-linecap="square" stroke-linejoin="miter"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 341 B |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -20,9 +20,9 @@ Uncloud stores its configuration in `~/.config/uncloud/config.yaml`. If you wish
|
|||||||
|
|
||||||
### Initialisation
|
### Initialisation
|
||||||
|
|
||||||
Begin by initialising the first node in your cluster with `uc machine init [USER@HOST:PORT]`. If you do not have a need for Caddy reverse proxy, you may disable this feature with `--no-caddy`. If you want to avoid using uncloud's managed DNS service, add the `--no-dns` flag.
|
Begin by initialising the first node in your cluster with `uc machine init [USER@HOST:PORT]`. If you do not have a need for Caddy reverse proxy, you may disable this feature with `--no-caddy`.
|
||||||
|
|
||||||
This command will idempotently install Docker, uncloudd, uncloud-corrosion. If Caddy is enabled, it will set up a reverse proxy. If Uncloud DNS is enabled, it will create a DNS A record for the machine's public IP address under `*.[CLUSTER ID].uncld.dev`.
|
This command will idempotently install Docker, uncloudd, uncloud-corrosion. If Caddy is enabled, it will set up a reverse proxy. If Uncloud DNS is enabled, it will create a DNS A record for the machine's public IP address under `*.[CLUSTER ID].cluster.uncloud.run`.
|
||||||
|
|
||||||
If you wish to uninstall Uncloud and its components, run `uncloud-uninstall`.
|
If you wish to uninstall Uncloud and its components, run `uncloud-uninstall`.
|
||||||
|
|
||||||
@@ -37,7 +37,6 @@ Uncloud (uncloud.run) DNS can be managed with the `uc dns` subcommand.
|
|||||||
* To reserve a domain name, run `uc dns reserve`
|
* To reserve a domain name, run `uc dns reserve`
|
||||||
* To release a domain name, run `uc dns release`.
|
* To release a domain name, run `uc dns release`.
|
||||||
* To see the domain name, run `uc dns show`
|
* To see the domain name, run `uc dns show`
|
||||||
* To avoid using the Uncloud managed DNS service, use the `--no-dns` flag on your `uc machine init` command.
|
|
||||||
|
|
||||||
### Running a service
|
### Running a service
|
||||||
|
|
||||||
@@ -3,11 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/hashicorp/serf/serf"
|
"github.com/hashicorp/serf/serf"
|
||||||
crdt "github.com/ipfs/go-ds-crdt"
|
crdt "github.com/ipfs/go-ds-crdt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Implements the Broadcaster interface.
|
// Implements the Broadcaster interface.
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/dgraph-io/badger/v3"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dgraph-io/badger/v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func customTimeEncoder(t time.Time) string {
|
func customTimeEncoder(t time.Time) string {
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/ipfs/go-log/v2"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/ipfs/go-log/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ipfsLogger is an slog logger that implements the IPFS go-log StandardLogger interface.
|
// ipfsLogger is an slog logger that implements the IPFS go-log StandardLogger interface.
|
||||||
@@ -17,57 +16,57 @@ func newIPFSLogger(l *slog.Logger) *ipfsLogger {
|
|||||||
return &ipfsLogger{log: *l}
|
return &ipfsLogger{log: *l}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Debug(args ...any) {
|
func (l *ipfsLogger) Debug(args ...interface{}) {
|
||||||
l.log.Debug(fmt.Sprint(args...))
|
l.log.Debug(fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Debugf(format string, args ...any) {
|
func (l *ipfsLogger) Debugf(format string, args ...interface{}) {
|
||||||
l.log.Debug(fmt.Sprintf(format, args...))
|
l.log.Debug(fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Error(args ...any) {
|
func (l *ipfsLogger) Error(args ...interface{}) {
|
||||||
l.log.Error(fmt.Sprint(args...))
|
l.log.Error(fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Errorf(format string, args ...any) {
|
func (l *ipfsLogger) Errorf(format string, args ...interface{}) {
|
||||||
l.log.Error(fmt.Sprintf(format, args...))
|
l.log.Error(fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Fatal(args ...any) {
|
func (l *ipfsLogger) Fatal(args ...interface{}) {
|
||||||
l.log.Error(fmt.Sprint(args...))
|
l.log.Error(fmt.Sprint(args...))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Fatalf(format string, args ...any) {
|
func (l *ipfsLogger) Fatalf(format string, args ...interface{}) {
|
||||||
l.log.Error(fmt.Sprintf(format, args...))
|
l.log.Error(fmt.Sprintf(format, args...))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Info(args ...any) {
|
func (l *ipfsLogger) Info(args ...interface{}) {
|
||||||
l.log.Info(fmt.Sprint(args...))
|
l.log.Info(fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Infof(format string, args ...any) {
|
func (l *ipfsLogger) Infof(format string, args ...interface{}) {
|
||||||
l.log.Info(fmt.Sprintf(format, args...))
|
l.log.Info(fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Panic(args ...any) {
|
func (l *ipfsLogger) Panic(args ...interface{}) {
|
||||||
msg := fmt.Sprint(args...)
|
msg := fmt.Sprint(args...)
|
||||||
l.log.Error(msg)
|
l.log.Error(msg)
|
||||||
panic(msg)
|
panic(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Panicf(format string, args ...any) {
|
func (l *ipfsLogger) Panicf(format string, args ...interface{}) {
|
||||||
msg := fmt.Sprintf(format, args...)
|
msg := fmt.Sprintf(format, args...)
|
||||||
l.log.Error(msg)
|
l.log.Error(msg)
|
||||||
panic(msg)
|
panic(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Warn(args ...any) {
|
func (l *ipfsLogger) Warn(args ...interface{}) {
|
||||||
l.log.Warn(fmt.Sprint(args...))
|
l.log.Warn(fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Warnf(format string, args ...any) {
|
func (l *ipfsLogger) Warnf(format string, args ...interface{}) {
|
||||||
l.log.Warn(fmt.Sprintf(format, args...))
|
l.log.Warn(fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/docker/docker/libnetwork/networkdb"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/libnetwork/networkdb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -4,13 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/hashicorp/memberlist"
|
"github.com/hashicorp/memberlist"
|
||||||
"github.com/hashicorp/serf/cmd/serf/command/agent"
|
"github.com/hashicorp/serf/cmd/serf/command/agent"
|
||||||
"github.com/hashicorp/serf/serf"
|
"github.com/hashicorp/serf/serf"
|
||||||
@@ -18,6 +11,12 @@ import (
|
|||||||
badger "github.com/ipfs/go-ds-badger3"
|
badger "github.com/ipfs/go-ds-badger3"
|
||||||
crdt "github.com/ipfs/go-ds-crdt"
|
crdt "github.com/ipfs/go-ds-crdt"
|
||||||
"github.com/lmittmann/tint"
|
"github.com/lmittmann/tint"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func createSerfAgentConfig(name, bindAddr, rpcAddr, profile string) *agent.Config {
|
func createSerfAgentConfig(name, bindAddr, rpcAddr, profile string) *agent.Config {
|
||||||
@@ -50,9 +49,9 @@ func createSerfAgent(config *agent.Config) (*agent.Agent, error) {
|
|||||||
|
|
||||||
serfConfig.MemberlistConfig.BindAddr = bindIP
|
serfConfig.MemberlistConfig.BindAddr = bindIP
|
||||||
serfConfig.MemberlistConfig.BindPort = bindPort
|
serfConfig.MemberlistConfig.BindPort = bindPort
|
||||||
// serfConfig.MemberlistConfig.AdvertiseAddr = advertiseIP
|
//serfConfig.MemberlistConfig.AdvertiseAddr = advertiseIP
|
||||||
// serfConfig.MemberlistConfig.AdvertisePort = advertisePort
|
//serfConfig.MemberlistConfig.AdvertisePort = advertisePort
|
||||||
// serfConfig.MemberlistConfig.SecretKey = encryptKey
|
//serfConfig.MemberlistConfig.SecretKey = encryptKey
|
||||||
serfConfig.NodeName = config.NodeName
|
serfConfig.NodeName = config.NodeName
|
||||||
serfConfig.Tags = config.Tags
|
serfConfig.Tags = config.Tags
|
||||||
serfConfig.SnapshotPath = config.SnapshotPath
|
serfConfig.SnapshotPath = config.SnapshotPath
|
||||||
@@ -130,7 +129,7 @@ func main() {
|
|||||||
logger := slog.New(tint.NewHandler(os.Stdout, &tint.Options{
|
logger := slog.New(tint.NewHandler(os.Stdout, &tint.Options{
|
||||||
AddSource: true,
|
AddSource: true,
|
||||||
Level: slog.LevelDebug,
|
Level: slog.LevelDebug,
|
||||||
// Level: slog.LevelInfo,
|
//Level: slog.LevelInfo,
|
||||||
TimeFormat: time.RFC3339Nano,
|
TimeFormat: time.RFC3339Nano,
|
||||||
}))
|
}))
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
@@ -165,7 +164,7 @@ func main() {
|
|||||||
|
|
||||||
opts := crdt.DefaultOptions()
|
opts := crdt.DefaultOptions()
|
||||||
opts.Logger = newIPFSLogger(logger)
|
opts.Logger = newIPFSLogger(logger)
|
||||||
// opts.MultiHeadProcessing = true
|
//opts.MultiHeadProcessing = true
|
||||||
// TODO: debug why the heads count may grow on the receiving side if the event backlog is huge and the processing
|
// TODO: debug why the heads count may grow on the receiving side if the event backlog is huge and the processing
|
||||||
// is slow.
|
// is slow.
|
||||||
store, err := crdt.New(localStore, ds.NewKey("/"), syncer, broadcaster, opts)
|
store, err := crdt.New(localStore, ds.NewKey("/"), syncer, broadcaster, opts)
|
||||||
@@ -173,7 +172,7 @@ func main() {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ticker := time.NewTicker(10 * time.Millisecond)
|
//ticker := time.NewTicker(10 * time.Millisecond)
|
||||||
ticker := time.NewTicker(3 * time.Second)
|
ticker := time.NewTicker(3 * time.Second)
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -4,19 +4,18 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
|
|
||||||
"github.com/hashicorp/serf/serf"
|
"github.com/hashicorp/serf/serf"
|
||||||
"github.com/ipfs/boxo/datastore/dshelp"
|
"github.com/ipfs/boxo/datastore/dshelp"
|
||||||
dag "github.com/ipfs/boxo/ipld/merkledag"
|
dag "github.com/ipfs/boxo/ipld/merkledag"
|
||||||
"github.com/ipfs/go-cid"
|
"github.com/ipfs/go-cid"
|
||||||
ds "github.com/ipfs/go-datastore"
|
ds "github.com/ipfs/go-datastore"
|
||||||
ipld "github.com/ipfs/go-ipld-format"
|
ipld "github.com/ipfs/go-ipld-format"
|
||||||
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Implements the DAGService interface.
|
// Implements the DAGService interface.
|
||||||
// TODO: implement SessionDAGService to optimize node fetching.
|
// TODO: implement SessionDAGService to optimize node fetching.
|
||||||
// TODO: persistentSerfDAG?
|
// TOOD: persistentSerfDAG?
|
||||||
type dagSyncer struct {
|
type dagSyncer struct {
|
||||||
// Persistent storage for the nodes.
|
// Persistent storage for the nodes.
|
||||||
store ds.Datastore
|
store ds.Datastore
|
||||||
@@ -51,7 +50,8 @@ func (d *dagSyncer) Get(ctx context.Context, cid cid.Cid) (ipld.Node, error) {
|
|||||||
}
|
}
|
||||||
slog.Debug("Queried node from peers", "cid", cid, "deadline", query.Deadline())
|
slog.Debug("Queried node from peers", "cid", cid, "deadline", query.Deadline())
|
||||||
|
|
||||||
for {
|
responded := false
|
||||||
|
for !responded {
|
||||||
select {
|
select {
|
||||||
case resp, ok := <-query.ResponseCh():
|
case resp, ok := <-query.ResponseCh():
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -63,6 +63,7 @@ func (d *dagSyncer) Get(ctx context.Context, cid cid.Cid) (ipld.Node, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
slog.Debug("Received node from peer", "cid", cid, "peer", resp.From)
|
slog.Debug("Received node from peer", "cid", cid, "peer", resp.From)
|
||||||
|
responded = true
|
||||||
query.Close()
|
query.Close()
|
||||||
|
|
||||||
node, err = nodeFromBytes(resp.Payload)
|
node, err = nodeFromBytes(resp.Payload)
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ import (
|
|||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
|
||||||
"github.com/siderolabs/discovery-api/api/v1alpha1/client/pb"
|
"github.com/siderolabs/discovery-api/api/v1alpha1/client/pb"
|
||||||
discovery "github.com/siderolabs/discovery-client/pkg/client"
|
discovery "github.com/siderolabs/discovery-client/pkg/client"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||