mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e80f2e5b2 | ||
|
|
64f6a4f3d3 | ||
|
|
0975cbab66 | ||
|
|
615022c9ac | ||
|
|
ecbd4378e4 | ||
|
|
1becd033e6 | ||
|
|
1cba1eef0a | ||
|
|
3893005866 | ||
|
|
1911e03c82 | ||
|
|
641b11a92d | ||
|
|
7c7530f977 | ||
|
|
6e7fe405ef | ||
|
|
03c5c054e1 | ||
|
|
c42968c812 | ||
|
|
ffef41081b | ||
|
|
58b51b974c | ||
|
|
2d923380a3 | ||
|
|
3910101a88 | ||
|
|
7a9d0cf9db | ||
|
|
042dd594e0 | ||
|
|
1471a94162 | ||
|
|
b1abd07dde | ||
|
|
3757813b20 | ||
|
|
f1a150821c | ||
|
|
874af1ba10 | ||
|
|
2ad098c612 | ||
|
|
4172cb826f | ||
|
|
041d51b74f | ||
|
|
fb01604d21 | ||
|
|
bd8bcbd2f9 | ||
|
|
07501813c8 | ||
|
|
1bbcfa8519 | ||
|
|
e8715fce38 | ||
|
|
a0a66f34c4 | ||
|
|
b799b7518e | ||
|
|
2571498ed2 | ||
|
|
5bcbfc332d | ||
|
|
34574e41fc | ||
|
|
c7355afa3d | ||
|
|
a907fdb1af | ||
|
|
c9ba4adccb | ||
|
|
a0551d2d64 | ||
|
|
76129ea056 | ||
|
|
6ffd35f596 | ||
|
|
0ef776e7f1 | ||
|
|
0b22b2136a | ||
|
|
e1530bb6f3 | ||
|
|
7fc07ce4bd | ||
|
|
eb42082955 | ||
|
|
8719e37856 | ||
|
|
e1cc39b0d5 | ||
|
|
efe646f54c | ||
|
|
e130d803e4 | ||
|
|
56e7e8baa2 | ||
|
|
b963ef39d1 | ||
|
|
1df0fa290f | ||
|
|
c3bb0c9f49 | ||
|
|
cf65cecd9e | ||
|
|
fff17dbb60 | ||
|
|
586a4fa129 | ||
|
|
2a82f75cad | ||
|
|
805afd0d28 | ||
|
|
11098ea7d1 | ||
|
|
90e357a1dc | ||
|
|
91d58bc701 | ||
|
|
69d3a511d6 | ||
|
|
c3123820b2 | ||
|
|
2e49d94fad | ||
|
|
bb51e01c5c | ||
|
|
cdb2884e4e |
@@ -4,6 +4,7 @@
|
|||||||
# Allow files and directories.
|
# Allow files and directories.
|
||||||
!cmd/
|
!cmd/
|
||||||
!internal/
|
!internal/
|
||||||
|
!pkg/
|
||||||
!scripts/docker/
|
!scripts/docker/
|
||||||
!go.*
|
!go.*
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ reset-dev:
|
|||||||
ssh spy@192.168.40.176 "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"
|
ssh ubuntu@152.67.101.197 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
||||||
|
|
||||||
|
demo-reset:
|
||||||
|
rm -fv ~/.config/uncloud/config.toml
|
||||||
|
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: proto
|
.PHONY: proto
|
||||||
proto:
|
proto:
|
||||||
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ some machines go offline.
|
|||||||
Uncloud aims to be the solution for developers who want the flexibility of self-hosted infrastructure without the
|
Uncloud aims to be the solution for developers who want the flexibility of self-hosted infrastructure without the
|
||||||
operational complexity of Kubernetes.
|
operational complexity of Kubernetes.
|
||||||
|
|
||||||
|
## 🎬 Quick demo
|
||||||
|
|
||||||
|
Deploy a highly available web app with automatic HTTPS across multiple regions and on-premises in just a couple minutes.
|
||||||
|
|
||||||
|
<a href="https://uncloud.wistia.com/medias/k47uwt9uau?wvideo=k47uwt9uau">
|
||||||
|
<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>
|
||||||
|
|
||||||
## ✨ 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 —
|
||||||
@@ -25,7 +33,10 @@ operational complexity of Kubernetes.
|
|||||||
* **No control plane**: Fully decentralized design eliminates single points of failure and reduces operational overhead.
|
* **No control plane**: Fully decentralized 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.
|
||||||
|
* **Zero-downtime deployments**: Rolling updates with health checks and automatic rollback on failure.
|
||||||
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
||||||
|
* **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.
|
||||||
* **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.
|
||||||
@@ -33,12 +44,11 @@ operational complexity of Kubernetes.
|
|||||||
|
|
||||||
Coming soon:
|
Coming soon:
|
||||||
|
|
||||||
* Project isolation through environments/namespaces
|
|
||||||
* Infrastructure as Code using Docker Compose format
|
* Infrastructure as Code using Docker Compose format
|
||||||
|
* Project isolation through environments/namespaces
|
||||||
* Persistent volumes and secrets management
|
* Persistent volumes and secrets management
|
||||||
* Monitoring and log aggregation
|
* Monitoring and log aggregation
|
||||||
* Database deployment and management
|
* Database deployment and management
|
||||||
* Curated application catalog
|
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
@@ -66,36 +76,45 @@ platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bar
|
|||||||
|
|
||||||
1. Install Uncloud CLI:
|
1. Install Uncloud CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install psviderski/tap/uncloud
|
brew install psviderski/tap/uncloud
|
||||||
|
|
||||||
# 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
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Initialize your first machine:
|
2. Initialize your first machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uc machine init root@your-server-ip
|
uc machine init root@your-server-ip
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Create a DNS A record in your DNS provider (Cloudflare, Namecheap, etc.) that points `app.example.com` to your
|
3. Deploy your app from a Docker image and publish its container port 8000 as HTTPS using `app.example.com` domain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uc run -p app.example.com:8000/https image/my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Create a DNS A record in your DNS provider (Cloudflare, Namecheap, etc.) that points `app.example.com` to your
|
||||||
server's IP address. Allow a few minutes for DNS propagation.
|
server's IP address. Allow a few minutes for DNS propagation.
|
||||||
4. Deploy your app from a Docker image:
|
|
||||||
|
|
||||||
```bash
|
That's it! Your app is now running and accessible at https://app.example.com ✨
|
||||||
uc run -p app.example.com:8000/https my-app-image
|
|
||||||
```
|
|
||||||
|
|
||||||
That's it! Your app is now running and accessible at https://app.example.com ✨
|
|
||||||
|
|
||||||
5. Clean up when you're done:
|
5. Clean up when you're done:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uc ls
|
uc ls
|
||||||
# Copy the service ID from the output and remove it:
|
# Copy the service name from the output and run the rm command:
|
||||||
uc rm my-app-id
|
uc rm my-app-name
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you want to fully uninstall Uncloud on a machine, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uncloud-uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
View the [user guide](docs/user_guide.md) for more information.
|
||||||
|
|
||||||
## ⚙️ How it works
|
## ⚙️ How it works
|
||||||
|
|
||||||
@@ -105,7 +124,42 @@ under the hood to see what happens when you run certain commands.
|
|||||||
**When you initialize a new cluster on a machine:**
|
**When you initialize a new cluster on a machine:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uc machine init root@your-server-ip
|
$ uc machine init --name oracle-vm ubuntu@152.67.101.197
|
||||||
|
|
||||||
|
Downloading Uncloud install script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh
|
||||||
|
⏳ Running Uncloud install script...
|
||||||
|
✓ Docker is already installed.
|
||||||
|
⏳ Installing Docker...
|
||||||
|
...
|
||||||
|
✓ Docker installed successfully.
|
||||||
|
✓ Linux user and group 'uncloud' created.
|
||||||
|
✓ Linux user 'ubuntu' added to group 'uncloud'.
|
||||||
|
⏳ Installing Uncloud binaries...
|
||||||
|
⏳ Downloading uncloudd binary: https://github.com/psviderski/uncloud/releases/latest/download/uncloudd_linux_arm64.tar.gz
|
||||||
|
✓ uncloudd binary installed: /usr/local/bin/uncloudd
|
||||||
|
⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh
|
||||||
|
✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall
|
||||||
|
✓ Systemd unit file created: /etc/systemd/system/uncloud.service
|
||||||
|
Created symlink /etc/systemd/system/multi-user.target.wants/uncloud.service → /etc/systemd/system/uncloud.service.
|
||||||
|
⏳ Downloading uncloud-corrosion binary: https://github.com/psviderski/corrosion/releases/latest/download/corrosion-aarch64-unknown-linux-gnu.tar.gz
|
||||||
|
✓ uncloud-corrosion binary installed: /usr/local/bin/uncloud-corrosion
|
||||||
|
✓ Systemd unit file created: /etc/systemd/system/uncloud-corrosion.service
|
||||||
|
⏳ Starting Uncloud machine daemon (uncloud.service)...
|
||||||
|
✓ Uncloud machine daemon started.
|
||||||
|
✓ Uncloud installed on the machine successfully! 🎉
|
||||||
|
Cluster "default" initialised with machine "oracle-vm"
|
||||||
|
Waiting for the machine to be ready...
|
||||||
|
|
||||||
|
Reserved cluster domain: xuw3xd.cluster.uncloud.run
|
||||||
|
[+] Deploying service caddy 1/1
|
||||||
|
✔ Container caddy-c47x on oracle-vm Started 0.9s
|
||||||
|
|
||||||
|
Updating cluster domain records in Uncloud DNS to point to machines running caddy service...
|
||||||
|
[+] Verifying internet access to caddy service 1/1
|
||||||
|
✔ Machine oracle-vm (152.67.101.197) Reachable 0.1s
|
||||||
|
|
||||||
|
DNS records updated to use only the internet-reachable machines running caddy service:
|
||||||
|
*.xuw3xd.cluster.uncloud.run A → 152.67.101.197
|
||||||
```
|
```
|
||||||
|
|
||||||
1. The CLI SSHs into the machine and installs Docker, the `uncloudd` machine daemon and
|
1. The CLI SSHs into the machine and installs Docker, the `uncloudd` machine daemon and
|
||||||
@@ -121,7 +175,42 @@ uc machine init root@your-server-ip
|
|||||||
**When you add another machine:**
|
**When you add another machine:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uc machine add ubuntu@second-server-ip
|
$ uc machine add --name hetzner-server root@5.223.45.199
|
||||||
|
Downloading Uncloud install script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh
|
||||||
|
⏳ Running Uncloud install script...
|
||||||
|
✓ Docker is already installed.
|
||||||
|
✓ Linux user and group 'uncloud' created.
|
||||||
|
⏳ Installing Uncloud binaries...
|
||||||
|
⏳ Downloading uncloudd binary: https://github.com/psviderski/uncloud/releases/latest/download/uncloudd_linux_amd64.tar.gz
|
||||||
|
✓ uncloudd binary installed: /usr/local/bin/uncloudd
|
||||||
|
⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh
|
||||||
|
✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall
|
||||||
|
✓ Systemd unit file created: /etc/systemd/system/uncloud.service
|
||||||
|
Created symlink /etc/systemd/system/multi-user.target.wants/uncloud.service → /etc/systemd/system/uncloud.service.
|
||||||
|
⏳ Downloading uncloud-corrosion binary: https://github.com/psviderski/corrosion/releases/latest/download/corrosion-x86_64-unknown-linux-gnu.tar.gz
|
||||||
|
✓ uncloud-corrosion binary installed: /usr/local/bin/uncloud-corrosion
|
||||||
|
✓ Systemd unit file created: /etc/systemd/system/uncloud-corrosion.service
|
||||||
|
⏳ Starting Uncloud machine daemon (uncloud.service)...
|
||||||
|
✓ Uncloud machine daemon started.
|
||||||
|
✓ Uncloud installed on the machine successfully! 🎉
|
||||||
|
Machine "hetzner-server" added to cluster
|
||||||
|
Waiting for the machine to be ready...
|
||||||
|
|
||||||
|
[+] Deploying service caddy 1/1
|
||||||
|
✔ Container caddy-d36c on hetzner-server Started 1.0s
|
||||||
|
|
||||||
|
Updating cluster domain records in Uncloud DNS to point to machines running caddy service...
|
||||||
|
[+] Verifying internet access to caddy service 2/2
|
||||||
|
✔ Machine hetzner-server (5.223.45.199) Reachable 0.2s
|
||||||
|
✔ Machine oracle-vm (152.67.101.197) Reachable 0.1s
|
||||||
|
|
||||||
|
DNS records updated to use only the internet-reachable machines running caddy service:
|
||||||
|
*.xuw3xd.cluster.uncloud.run A → 152.67.101.197, 5.223.45.199
|
||||||
|
|
||||||
|
$ uc machine ls
|
||||||
|
NAME STATE ADDRESS PUBLIC IP WIREGUARD ENDPOINTS
|
||||||
|
oracle-vm Up 10.210.0.1/24 152.67.101.197 10.0.0.95:51820, 152.67.101.197:51820
|
||||||
|
hetzner-server Up 10.210.1.1/24 5.223.45.199 5.223.45.199:51820, [2a01:4ff:2f0:128b::1]:51820
|
||||||
```
|
```
|
||||||
|
|
||||||
1. The second machine gets provisioned just like the first. A non-root SSH user will need `sudo` access.
|
1. The second machine gets provisioned just like the first. A non-root SSH user will need `sudo` access.
|
||||||
@@ -140,7 +229,14 @@ and automatically establish a WireGuard tunnel with it.
|
|||||||
**When you run a service:**
|
**When you run a service:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uc run -p app.example.com:8000/https my-app-image
|
$ uc run -p app.example.com:8000/https image/my-app
|
||||||
|
|
||||||
|
[+] Running service my-app-1b3b (replicated mode) 1/1
|
||||||
|
✔ Container my-app-1b3b-tcex on oracle-vm Started
|
||||||
|
|
||||||
|
my-app-1b3b endpoints:
|
||||||
|
• https://app.example.com → :8000
|
||||||
|
• https://my-app-1b3b.xuw3xd.cluster.uncloud.run → :8000
|
||||||
```
|
```
|
||||||
|
|
||||||
1. CLI picks a machine to run your container.
|
1. CLI picks a machine to run your container.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package cluster
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"uncloud/internal/ucind"
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewCreateCommand() *cobra.Command {
|
func NewCreateCommand() *cobra.Command {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package cluster
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"uncloud/internal/ucind"
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRemoveCommand() *cobra.Command {
|
func NewRemoveCommand() *cobra.Command {
|
||||||
|
|||||||
+2
-2
@@ -7,8 +7,8 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/cmd/ucind/cluster"
|
"github.com/psviderski/uncloud/cmd/ucind/cluster"
|
||||||
"uncloud/internal/ucind"
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
+30
-52
@@ -4,15 +4,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/charmbracelet/huh"
|
"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/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"maps"
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/internal/cli"
|
|
||||||
"uncloud/internal/cli/client"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type deployOptions struct {
|
type deployOptions struct {
|
||||||
@@ -31,7 +33,7 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
"A rolling update is performed when updating existing containers to minimise disruption.",
|
"A rolling update is performed when updating existing containers to minimise disruption.",
|
||||||
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 deploy(cmd.Context(), uncli, opts)
|
return runDeploy(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
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)
|
||||||
@@ -56,7 +58,7 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
|
|
||||||
svc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
svc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, client.ErrNotFound) {
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
return fmt.Errorf("inspect caddy service: %w", err)
|
return fmt.Errorf("inspect caddy service: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("Service: %s (not running)\n", client.CaddyServiceName)
|
fmt.Printf("Service: %s (not running)\n", client.CaddyServiceName)
|
||||||
@@ -85,7 +87,7 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Preparing a deployment plan...")
|
fmt.Println("Preparing a deployment plan...")
|
||||||
|
|
||||||
var filter client.MachineFilter
|
var filter deploy.MachineFilter
|
||||||
if opts.machine != "" {
|
if opts.machine != "" {
|
||||||
machines := strings.Split(opts.machine, ",")
|
machines := strings.Split(opts.machine, ",")
|
||||||
for i, m := range machines {
|
for i, m := range machines {
|
||||||
@@ -99,34 +101,19 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize a machine and container name resolver to properly format the plan output.
|
|
||||||
machines, err := clusterClient.ListMachines(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("list machines: %w", err)
|
|
||||||
}
|
|
||||||
machineNames := make(map[string]string, len(machines))
|
|
||||||
for _, m := range machines {
|
|
||||||
machineNames[m.Machine.Id] = m.Machine.Name
|
|
||||||
}
|
|
||||||
containerNames := make(map[string]string, len(svc.Containers))
|
|
||||||
for _, c := range svc.Containers {
|
|
||||||
containerNames[c.Container.ID] = c.Container.NameWithoutSlash()
|
|
||||||
}
|
|
||||||
resolver := client.NewNameResolver(machineNames, containerNames)
|
|
||||||
|
|
||||||
if opts.image == "" {
|
if opts.image == "" {
|
||||||
fmt.Printf("Target image: %s (latest stable)\n", d.Spec.Container.Image)
|
fmt.Printf("Target image: %s (latest stable)\n", d.Spec.Container.Image)
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, err := d.Plan(ctx)
|
plan, err := d.Plan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, client.ErrNoMatchingMachines) {
|
if errors.Is(err, deploy.ErrNoMatchingMachines) {
|
||||||
return fmt.Errorf("no machines found matching: %s", opts.machine)
|
return fmt.Errorf("no machines found matching: %s", opts.machine)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("plan caddy deployment: %w", err)
|
return fmt.Errorf("plan caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(plan.SequenceOperation.Operations) == 0 {
|
if len(plan.Operations) == 0 {
|
||||||
if opts.machine != "" {
|
if opts.machine != "" {
|
||||||
fmt.Printf("%s service is up to date on selected machines.\n", client.CaddyServiceName)
|
fmt.Printf("%s service is up to date on selected machines.\n", client.CaddyServiceName)
|
||||||
} else {
|
} else {
|
||||||
@@ -146,13 +133,19 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
fmt.Println("This will perform a rolling update of Caddy containers on each machine.")
|
fmt.Println("This will perform a rolling update of Caddy containers on each machine.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
|
// Initialise a machine and container name resolver to properly format the plan output.
|
||||||
|
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
fmt.Println("Deployment plan:")
|
fmt.Println("Deployment plan:")
|
||||||
fmt.Println(plan.Format(resolver))
|
fmt.Println(plan.Format(resolver))
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
confirmed, err := confirm()
|
confirmed, err := cli.Confirm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("confirm deployment: %w", err)
|
return fmt.Errorf("confirm deployment: %w", err)
|
||||||
}
|
}
|
||||||
@@ -173,8 +166,12 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
if _, err = clusterClient.GetDomain(ctx); err != nil {
|
return UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut())
|
||||||
if errors.Is(err, client.ErrNotFound) {
|
}
|
||||||
|
|
||||||
|
func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, progressOut *streams.Out) error {
|
||||||
|
if _, err := clusterClient.GetDomain(ctx); err != nil {
|
||||||
|
if errors.Is(err, api.ErrNotFound) {
|
||||||
fmt.Println("Skipping DNS records update as no cluster domain is reserved (see 'uc dns').")
|
fmt.Println("Skipping DNS records update as no cluster domain is reserved (see 'uc dns').")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -185,10 +182,11 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
// TODO: split the method into two: one to get the records and one to update them to ask for update confirmation.
|
// TODO: split the method into two: one to get the records and one to update them to ask for update confirmation.
|
||||||
|
|
||||||
var records []*pb.DNSRecord
|
var records []*pb.DNSRecord
|
||||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
err := progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
var err error
|
||||||
records, err = clusterClient.CreateIngressRecords(ctx, client.CaddyServiceName)
|
records, err = clusterClient.CreateIngressRecords(ctx, client.CaddyServiceName)
|
||||||
return err
|
return err
|
||||||
}, uncli.ProgressOut(), "Verifying internet access to caddy service")
|
}, progressOut, "Verifying internet access to caddy service")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, client.ErrNoReachableMachines) {
|
if errors.Is(err, client.ErrNoReachableMachines) {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
@@ -217,27 +215,7 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirm() (bool, error) {
|
func machineFilter(machines []string) deploy.MachineFilter {
|
||||||
var confirmed bool
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title(
|
|
||||||
"Do you want to continue?",
|
|
||||||
).
|
|
||||||
Affirmative("Yes!").
|
|
||||||
Negative("No").
|
|
||||||
Value(&confirmed),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return confirmed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func machineFilter(machines []string) client.MachineFilter {
|
|
||||||
if len(machines) == 0 {
|
if len(machines) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deployOptions struct {
|
||||||
|
files []string
|
||||||
|
services []string
|
||||||
|
|
||||||
|
cluster string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDeployCommand creates a new command to deploy services from a Compose file.
|
||||||
|
func NewDeployCommand() *cobra.Command {
|
||||||
|
opts := deployOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "deploy [FLAGS] [SERVICE...]",
|
||||||
|
// TODO: remove WIP when the command is fully implemented.
|
||||||
|
Short: "WIP: Deploy services from a Compose file.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
opts.services = args
|
||||||
|
}
|
||||||
|
|
||||||
|
return runDeploy(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||||
|
"One or more Compose files to deploy services from. (default compose-ports-long.yaml)")
|
||||||
|
cmd.Flags().StringVarP(&opts.cluster, "cluster", "c", "",
|
||||||
|
"Name of the cluster to deploy to (default is the current cluster)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDeploy parses the Compose file(s) and deploys the services.
|
||||||
|
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||||
|
project, err := compose.LoadProject(ctx, opts.files)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load compose file(s): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.services) > 0 {
|
||||||
|
// TODO: handle dependencies properly.
|
||||||
|
project, err = project.WithSelectedServices(opts.services, types.IgnoreDependencies)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("select services: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
composeDeploy, err := compose.NewDeployment(ctx, clusterClient, project)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create compose deployment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := composeDeploy.Plan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan deployment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(plan.Operations) == 0 {
|
||||||
|
fmt.Println("Services are up to date.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Deployment plan:")
|
||||||
|
|
||||||
|
for _, op := range plan.Operations {
|
||||||
|
svcPlan, ok := op.(*deploy.Plan)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("expected service Plan, got: %T", op)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := clusterClient.InspectService(ctx, svcPlan.ServiceID)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
|
return fmt.Errorf("inspect service: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("- Run service [name=%s]\n", svcPlan.ServiceName)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("- Update service [name=%s]\n", svc.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise a machine and container name resolver to properly format the service plan output.
|
||||||
|
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(indent(svcPlan.Format(resolver), " "))
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
confirmed, err := cli.Confirm()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("confirm deployment: %w", err)
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
fmt.Println("Cancelled. No changes were made.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if err := plan.Execute(ctx, clusterClient); err != nil {
|
||||||
|
return fmt.Errorf("deploy services: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), "Deploying services")
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
"uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
type releaseOptions struct {
|
type releaseOptions struct {
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"uncloud/internal/cli"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultUncloudDNSAPIEndpoint = "https://dns.uncloud.run/v1"
|
const DefaultUncloudDNSAPIEndpoint = "https://dns.uncloud.run/v1"
|
||||||
@@ -41,13 +44,13 @@ func NewReserveCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
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)
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer clusterClient.Close()
|
||||||
|
|
||||||
domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.endpoint})
|
domain, err := clusterClient.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.endpoint})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if status.Convert(err).Code() == codes.AlreadyExists {
|
if status.Convert(err).Code() == codes.AlreadyExists {
|
||||||
return errors.New("domain already reserved")
|
return errors.New("domain already reserved")
|
||||||
@@ -56,7 +59,16 @@ func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Reserved cluster domain: %s\n", domain.Name)
|
fmt.Printf("Reserved cluster domain: %s\n", domain.Name)
|
||||||
fmt.Println("Redeploy the Caddy service ('uc caddy deploy') to configure DNS records for the domain " +
|
|
||||||
"to route traffic to the services in the cluster.")
|
// Update cluster domain records in Uncloud DNS to point to machines running caddy service if it has been deployed.
|
||||||
|
if _, err = clusterClient.InspectService(ctx, client.CaddyServiceName); err != nil {
|
||||||
|
if errors.Is(err, api.ErrNotFound) {
|
||||||
|
fmt.Println("Deploy the Caddy reverse proxy service ('uc caddy deploy') to enable internet access " +
|
||||||
|
"to your services via the reserved or your custom domain.")
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("inspect caddy service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return caddy.UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"uncloud/internal/cli"
|
|
||||||
"uncloud/internal/cli/client"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type showOptions struct {
|
type showOptions struct {
|
||||||
@@ -42,7 +42,7 @@ func show(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
|||||||
|
|
||||||
domain, err := clusterClient.GetDomain(ctx)
|
domain, err := clusterClient.GetDomain(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, client.ErrNotFound) {
|
if errors.Is(err, api.ErrNotFound) {
|
||||||
return errors.New("no domain reserved")
|
return errors.New("no domain reserved")
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -6,16 +6,18 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
"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/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/cli"
|
|
||||||
"uncloud/internal/cli/client"
|
|
||||||
"uncloud/internal/cli/config"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type addOptions struct {
|
type addOptions struct {
|
||||||
@@ -97,6 +99,7 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, o
|
|||||||
|
|
||||||
// Wait for the cluster to be initialised to be able to deploy the Caddy service.
|
// Wait for the cluster to be initialised to be able to deploy the Caddy service.
|
||||||
fmt.Println("Waiting for the machine to be ready...")
|
fmt.Println("Waiting for the machine to be ready...")
|
||||||
|
fmt.Println()
|
||||||
if err = waitClusterInitialised(ctx, machineClient); err != nil {
|
if err = waitClusterInitialised(ctx, machineClient); err != nil {
|
||||||
return fmt.Errorf("wait for cluster to be initialised on machine: %w", err)
|
return fmt.Errorf("wait for cluster to be initialised on machine: %w", err)
|
||||||
}
|
}
|
||||||
@@ -114,7 +117,7 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, o
|
|||||||
caddyImage := ""
|
caddyImage := ""
|
||||||
caddySvc, err := machineClient.InspectService(ctx, client.CaddyServiceName)
|
caddySvc, err := machineClient.InspectService(ctx, client.CaddyServiceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, client.ErrNotFound) {
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
return fmt.Errorf("inspect caddy service: %w", err)
|
return fmt.Errorf("inspect caddy service: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -138,12 +141,18 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, o
|
|||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
if _, err = d.Run(ctx); err != nil {
|
if _, err = d.Run(ctx); err != nil {
|
||||||
return fmt.Errorf("deploy caddy: %w", err)
|
return fmt.Errorf("deploy caddy: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
|
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
return caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut())
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitClusterInitialised(ctx context.Context, client *client.Client) error {
|
func waitClusterInitialised(ctx context.Context, client *client.Client) error {
|
||||||
|
|||||||
@@ -4,17 +4,22 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"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/dns"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"uncloud/internal/cli"
|
|
||||||
"uncloud/internal/cli/config"
|
|
||||||
"uncloud/internal/machine/cluster"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type initOptions struct {
|
type initOptions struct {
|
||||||
|
dnsEndpoint string
|
||||||
name string
|
name string
|
||||||
network string
|
network string
|
||||||
noCaddy bool
|
noCaddy bool
|
||||||
|
noDNS bool
|
||||||
publicIP string
|
publicIP string
|
||||||
sshKey string
|
sshKey string
|
||||||
cluster string
|
cluster string
|
||||||
@@ -47,6 +52,8 @@ func NewInitCommand() *cobra.Command {
|
|||||||
return initCluster(cmd.Context(), uncli, remoteMachine, opts)
|
return initCluster(cmd.Context(), uncli, remoteMachine, opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().StringVar(&opts.dnsEndpoint, "dns-endpoint", dns.DefaultUncloudDNSAPIEndpoint,
|
||||||
|
"API endpoint for the Uncloud DNS service.")
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.name, "name", "n", "",
|
&opts.name, "name", "n", "",
|
||||||
"Assign a name to the machine.",
|
"Assign a name to the machine.",
|
||||||
@@ -59,6 +66,10 @@ func NewInitCommand() *cobra.Command {
|
|||||||
&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.noDNS, "no-dns", false,
|
||||||
|
"Don't reserve a cluster domain in Uncloud DNS.",
|
||||||
|
)
|
||||||
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, "+
|
||||||
@@ -66,7 +77,7 @@ func NewInitCommand() *cobra.Command {
|
|||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.sshKey, "ssh-key", "i", "",
|
&opts.sshKey, "ssh-key", "i", "",
|
||||||
"path to SSH private key for SSH remote login. (default ~/.ssh/id_*)",
|
"Path to SSH private key for SSH remote login. (default ~/.ssh/id_*)",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "cluster", "c", "",
|
&opts.cluster, "cluster", "c", "",
|
||||||
@@ -102,7 +113,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
if opts.noCaddy {
|
if opts.noCaddy && opts.noDNS {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,16 +121,35 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
// The creation of a deployment plan talks to cluster API. Since the API needs a few moments to become available
|
// 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.
|
// after cluster initialisation, we keep the user informed during this wait.
|
||||||
fmt.Println("Waiting for the machine to be ready...")
|
fmt.Println("Waiting for the machine to be ready...")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if !opts.noDNS {
|
||||||
|
domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reserve cluster domain in Uncloud DNS: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Reserved cluster domain: %s\n", domain.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.noCaddy {
|
||||||
d, err := client.NewCaddyDeployment("", nil)
|
d, err := client.NewCaddyDeployment("", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
if _, err = d.Run(ctx); err != nil {
|
if _, err = d.Run(ctx); err != nil {
|
||||||
return fmt.Errorf("deploy caddy: %w", err)
|
return fmt.Errorf("deploy caddy: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
|
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
return caddy.UpdateDomainRecords(ctx, client, uncli.ProgressOut())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package machine
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"uncloud/internal/daemon"
|
"github.com/psviderski/uncloud/internal/daemon"
|
||||||
"uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
)
|
)
|
||||||
|
|
||||||
type tokenOptions struct {
|
type tokenOptions struct {
|
||||||
|
|||||||
+43
-15
@@ -3,33 +3,55 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
"net/netip"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/cmd/uncloud/caddy"
|
|
||||||
"uncloud/cmd/uncloud/dns"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
"uncloud/cmd/uncloud/machine"
|
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||||
"uncloud/cmd/uncloud/service"
|
"github.com/psviderski/uncloud/cmd/uncloud/machine"
|
||||||
"uncloud/internal/cli"
|
"github.com/psviderski/uncloud/cmd/uncloud/service"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type globalOptions struct {
|
||||||
|
configPath string
|
||||||
|
connect string
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var configPath string
|
opts := globalOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "uncloud",
|
Use: "uncloud",
|
||||||
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if strings.HasPrefix(configPath, "~/") {
|
var conn *config.MachineConnection
|
||||||
home, err := os.UserHomeDir()
|
if opts.connect != "" {
|
||||||
|
if strings.HasPrefix(opts.connect, "tcp://") {
|
||||||
|
addrPort, err := netip.ParseAddrPort(opts.connect[len("tcp://"):])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get user home directory to resolve %q: %w", configPath, err)
|
return fmt.Errorf("parse TCP address: %w", err)
|
||||||
|
}
|
||||||
|
conn = &config.MachineConnection{
|
||||||
|
TCP: addrPort,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dest := opts.connect
|
||||||
|
if strings.HasPrefix(dest, "ssh://") {
|
||||||
|
dest = dest[len("ssh://"):]
|
||||||
|
}
|
||||||
|
conn = &config.MachineConnection{
|
||||||
|
SSH: config.SSHDestination(dest),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
configPath = strings.Replace(configPath, "~", home, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uncli, err := cli.New(configPath)
|
configPath := fs.ExpandHomeDir(opts.configPath)
|
||||||
|
uncli, err := cli.New(configPath, conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("initialize CLI: %w", err)
|
return fmt.Errorf("initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
@@ -37,12 +59,17 @@ func main() {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
||||||
|
"Connect to a remote cluster machine without using the Uncloud configuration file.\n"+
|
||||||
|
"Format: [ssh://]user@host[:port] or tcp://host:port")
|
||||||
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
||||||
cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.toml",
|
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.toml",
|
||||||
"path to the Uncloud configuration file.")
|
"Path to the Uncloud configuration file.")
|
||||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "toml")
|
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "toml")
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
|
NewDeployCommand(),
|
||||||
caddy.NewRootCommand(),
|
caddy.NewRootCommand(),
|
||||||
dns.NewRootCommand(),
|
dns.NewRootCommand(),
|
||||||
machine.NewRootCommand(),
|
machine.NewRootCommand(),
|
||||||
@@ -51,6 +78,7 @@ func main() {
|
|||||||
service.NewListCommand(),
|
service.NewListCommand(),
|
||||||
service.NewRmCommand(),
|
service.NewRmCommand(),
|
||||||
service.NewRunCommand(),
|
service.NewRunCommand(),
|
||||||
|
service.NewScaleCommand(),
|
||||||
)
|
)
|
||||||
cobra.CheckErr(cmd.Execute())
|
cobra.CheckErr(cmd.Execute())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
type inspectOptions struct {
|
type inspectOptions struct {
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"uncloud/internal/cli"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"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/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"os"
|
"os"
|
||||||
"uncloud/internal/cli"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type rmOptions struct {
|
type rmOptions struct {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
NewListCommand(),
|
NewListCommand(),
|
||||||
NewRmCommand(),
|
NewRmCommand(),
|
||||||
NewRunCommand(),
|
NewRunCommand(),
|
||||||
|
NewScaleCommand(),
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-13
@@ -3,22 +3,25 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/internal/api"
|
|
||||||
"uncloud/internal/cli"
|
|
||||||
"uncloud/internal/cli/client"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type runOptions struct {
|
type runOptions struct {
|
||||||
command []string
|
command []string
|
||||||
|
entrypoint string
|
||||||
|
entrypointChanged bool
|
||||||
image string
|
image string
|
||||||
machines []string
|
machines []string
|
||||||
mode string
|
mode string
|
||||||
name string
|
name string
|
||||||
publish []string
|
publish []string
|
||||||
|
pull string
|
||||||
replicas uint
|
replicas uint
|
||||||
volumes []string
|
volumes []string
|
||||||
|
|
||||||
@@ -35,6 +38,7 @@ func NewRunCommand() *cobra.Command {
|
|||||||
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.entrypointChanged = cmd.Flag("entrypoint").Changed
|
||||||
opts.image = args[0]
|
opts.image = args[0]
|
||||||
if len(args) > 1 {
|
if len(args) > 1 {
|
||||||
opts.command = args[1:]
|
opts.command = args[1:]
|
||||||
@@ -44,9 +48,11 @@ func NewRunCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&opts.entrypoint, "entrypoint", "",
|
||||||
|
"Overwrite the default ENTRYPOINT of the image. Pass an empty string \"\" to reset it.")
|
||||||
cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated,
|
cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated,
|
||||||
fmt.Sprintf("Replication mode of the service: either %q (a specified number of containers across "+
|
fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+
|
||||||
"the machines) or %q (one container on every machine).",
|
"the machines) or '%s' (one container on every machine).",
|
||||||
api.ServiceModeReplicated, api.ServiceModeGlobal))
|
api.ServiceModeReplicated, api.ServiceModeGlobal))
|
||||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
"Placement constraint by machine name, limiting which machines the service can run on. Can be specified "+
|
"Placement constraint by machine name, limiting which machines the service can run on. Can be specified "+
|
||||||
@@ -63,6 +69,9 @@ func NewRunCommand() *cobra.Command {
|
|||||||
" -p app.example.com:8080/https Publish port 8080 as HTTPS via load balancer with custom hostname\n"+
|
" -p app.example.com:8080/https Publish port 8080 as HTTPS via load balancer with custom hostname\n"+
|
||||||
" -p 9000:8080 Publish port 8080 as TCP port 9000 via load balancer\n"+
|
" -p 9000:8080 Publish port 8080 as TCP port 9000 via load balancer\n"+
|
||||||
" -p 53:5353/udp@host Bind UDP port 5353 to host port 53")
|
" -p 53:5353/udp@host Bind UDP port 5353 to host port 53")
|
||||||
|
cmd.Flags().StringVar(&opts.pull, "pull", api.PullPolicyMissing,
|
||||||
|
fmt.Sprintf("Pull image from the registry before running service containers ('%s', '%s', '%s').",
|
||||||
|
api.PullPolicyAlways, api.PullPolicyMissing, api.PullPolicyNever))
|
||||||
cmd.Flags().UintVar(&opts.replicas, "replicas", 1,
|
cmd.Flags().UintVar(&opts.replicas, "replicas", 1,
|
||||||
"Number of containers to run for the service. Only valid for a replicated service.")
|
"Number of containers to run for the service. Only valid for a replicated service.")
|
||||||
cmd.Flags().StringSliceVarP(&opts.volumes, "volume", "v", nil,
|
cmd.Flags().StringSliceVarP(&opts.volumes, "volume", "v", nil,
|
||||||
@@ -81,10 +90,16 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
|||||||
switch opts.mode {
|
switch opts.mode {
|
||||||
case api.ServiceModeReplicated, api.ServiceModeGlobal:
|
case api.ServiceModeReplicated, api.ServiceModeGlobal:
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid replication mode: %q", opts.mode)
|
return fmt.Errorf("invalid replication mode: '%s'", opts.mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
var machineFilter client.MachineFilter
|
switch opts.pull {
|
||||||
|
case api.PullPolicyAlways, api.PullPolicyMissing, api.PullPolicyNever:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid pull policy: '%s'", opts.pull)
|
||||||
|
}
|
||||||
|
|
||||||
|
var machineFilter deploy.MachineFilter
|
||||||
if len(opts.machines) > 0 {
|
if len(opts.machines) > 0 {
|
||||||
var machines []string
|
var machines []string
|
||||||
for _, value := range opts.machines {
|
for _, value := range opts.machines {
|
||||||
@@ -121,6 +136,7 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
|||||||
Container: api.ContainerSpec{
|
Container: api.ContainerSpec{
|
||||||
Command: opts.command,
|
Command: opts.command,
|
||||||
Image: opts.image,
|
Image: opts.image,
|
||||||
|
PullPolicy: opts.pull,
|
||||||
Volumes: opts.volumes,
|
Volumes: opts.volumes,
|
||||||
},
|
},
|
||||||
Mode: opts.mode,
|
Mode: opts.mode,
|
||||||
@@ -128,31 +144,42 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
|||||||
Ports: ports,
|
Ports: ports,
|
||||||
Replicas: opts.replicas,
|
Replicas: opts.replicas,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overwrite the default ENTRYPOINT of the image or reset it if an empty string is passed.
|
||||||
|
if opts.entrypoint != "" {
|
||||||
|
spec.Container.Entrypoint = []string{opts.entrypoint}
|
||||||
|
} else if opts.entrypointChanged {
|
||||||
|
spec.Container.Entrypoint = []string{""}
|
||||||
|
}
|
||||||
|
|
||||||
if err := spec.Validate(); err != nil {
|
if err := spec.Validate(); err != nil {
|
||||||
return fmt.Errorf("invalid service configuration: %w", err)
|
return fmt.Errorf("invalid service configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
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)
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer clusterClient.Close()
|
||||||
|
|
||||||
resp, err := client.RunService(ctx, spec, machineFilter)
|
resp, err := clusterClient.RunService(ctx, spec, machineFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run service: %w", err)
|
return fmt.Errorf("run service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc, err := client.InspectService(ctx, resp.ID)
|
svc, err := clusterClient.InspectService(ctx, resp.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("inspect service: %w", err)
|
return fmt.Errorf("inspect service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endpoints := svc.Endpoints()
|
||||||
|
if len(endpoints) > 0 {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("%s endpoints:\n", svc.Name)
|
fmt.Printf("%s endpoints:\n", svc.Name)
|
||||||
for _, endpoint := range svc.Endpoints() {
|
for _, endpoint := range endpoints {
|
||||||
fmt.Printf(" • %s\n", endpoint)
|
fmt.Printf(" • %s\n", endpoint)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type scaleOptions struct {
|
||||||
|
service string
|
||||||
|
replicas uint
|
||||||
|
cluster string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScaleCommand() *cobra.Command {
|
||||||
|
opts := scaleOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "scale SERVICE REPLICAS",
|
||||||
|
Short: "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),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
|
opts.service = args[0]
|
||||||
|
replicas, err := strconv.ParseUint(args[1], 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid number of replicas: %w", err)
|
||||||
|
}
|
||||||
|
opts.replicas = uint(replicas)
|
||||||
|
|
||||||
|
return scale(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.cluster, "cluster", "c", "",
|
||||||
|
"Name of the cluster. (default is the current cluster)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
||||||
|
if opts.replicas == 0 {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"scaling to zero replicas is not supported. This would effectively remove the service without preserving "+
|
||||||
|
"its configuration, making it impossible to scale back up. Uncloud derives the service configuration "+
|
||||||
|
"from existing containers. Use 'uc rm %s' instead if you want to remove the service",
|
||||||
|
opts.service,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
svc, err := clusterClient.InspectService(ctx, opts.service)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect service '%s': %w", opts.service, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if svc.Mode != api.ServiceModeReplicated {
|
||||||
|
return fmt.Errorf("scaling is only supported for services in %s mode, service '%s' is in %s mode",
|
||||||
|
api.ServiceModeReplicated, svc.Name, svc.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentReplicas := uint(len(svc.Containers))
|
||||||
|
|
||||||
|
if currentReplicas == opts.replicas {
|
||||||
|
fmt.Printf("Service '%s' already has %d replicas. No changes required.\n", svc.Name, currentReplicas)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Check if all containers have the same spec. If not, prompt user to choose which one to scale.
|
||||||
|
// This can happen if a service deployment failed midway and some containers were not updated.
|
||||||
|
spec := svc.Containers[0].Container.ServiceSpec
|
||||||
|
spec.Replicas = opts.replicas
|
||||||
|
deployment := clusterClient.NewDeployment(spec, nil)
|
||||||
|
plan, err := deployment.Plan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan deployment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(plan.Operations) == 0 {
|
||||||
|
fmt.Printf("Service '%s' is already scaled to %d replicas.\n", svc.Name, opts.replicas)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.replicas < currentReplicas {
|
||||||
|
// Initialise a machine and container name resolver to properly format the plan output.
|
||||||
|
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Scaling plan for service %s (%d → %d replicas):\n", svc.Name, currentReplicas, opts.replicas)
|
||||||
|
fmt.Println(plan.Format(resolver))
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Ask for confirmation before scaling down as it may cause data loss.
|
||||||
|
confirmed, err := confirm()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("confirm scaling: %w", err)
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
fmt.Println("Cancelled. No changes were made.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title := fmt.Sprintf("Scaling service %s (%d → %d replicas)", svc.Name, currentReplicas, opts.replicas)
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if _, err = deployment.Run(ctx); err != nil {
|
||||||
|
return fmt.Errorf("deploy service: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), title)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return confirmed, nil
|
||||||
|
}
|
||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"uncloud/internal/daemon"
|
"github.com/psviderski/uncloud/internal/daemon"
|
||||||
"uncloud/internal/log"
|
"github.com/psviderski/uncloud/internal/log"
|
||||||
"uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# User Guide
|
||||||
|
|
||||||
|
## Initialising a cluster
|
||||||
|
|
||||||
|
To begin setting up a new Uncloud cluster, create the desired nodes. Ensure that their firewall allows the required ports.
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
Nodes running Uncloud with a standard configuration must have the following inbound ports allowed in the firewall:
|
||||||
|
|
||||||
|
* 51820/udp (WireGuard meshnet)
|
||||||
|
* 22/tcp (default SSH management port)
|
||||||
|
* 80/tcp (required for challenge if uncloud.run DNS is enabled, pass --no-dns during init to disable)
|
||||||
|
|
||||||
|
In addition, any ports for any workloads you want to expose will need to be open.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Uncloud stores its configuration in `~/.config/uncloud/config.toml`. If you wish to reinitialise a cluster, simply remove it from this config.
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
### Adding a node
|
||||||
|
|
||||||
|
Just like the initialisation of the first node, a node can be added to the cluster with `uc machine add`.
|
||||||
|
|
||||||
|
### DNS
|
||||||
|
|
||||||
|
Uncloud (uncloud.run) DNS can be managed with the `uc dns` subcommand.
|
||||||
|
|
||||||
|
* To reserve a domain name, run `uc dns reserve`
|
||||||
|
* To release a domain name, run `uc dns release`.
|
||||||
|
* To see the domain name, run `uc dns show`
|
||||||
|
|
||||||
|
### Running a service
|
||||||
|
|
||||||
|
Services on an Uncloud cluster can be managed with `uc service`.
|
||||||
|
|
||||||
|
You can run a service with two replicas that expose port 80 like the following:
|
||||||
|
|
||||||
|
```
|
||||||
|
uc service run -p 80/http --replicas 2 nginxdemos/hello
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires the Caddy reverse proxy to be deployed. If it wasn't previously, it can be deployed with `uc caddy deploy`.
|
||||||
|
|
||||||
|
Since this doesn't specify a service name, a random one will be generated.
|
||||||
|
|
||||||
|
The service can be deleted by its service name:
|
||||||
|
|
||||||
|
```
|
||||||
|
uc service rm hello-gsdo
|
||||||
|
```
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module uncloud
|
module github.com/psviderski/uncloud
|
||||||
|
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
@@ -11,7 +11,9 @@ require (
|
|||||||
github.com/caddyserver/caddy/v2 v2.8.4
|
github.com/caddyserver/caddy/v2 v2.8.4
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0
|
github.com/cenkalti/backoff/v4 v4.3.0
|
||||||
github.com/charmbracelet/huh v0.6.0
|
github.com/charmbracelet/huh v0.6.0
|
||||||
|
github.com/compose-spec/compose-go/v2 v2.4.5
|
||||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||||
|
github.com/deckarep/golang-set/v2 v2.8.0
|
||||||
github.com/dgraph-io/badger/v3 v3.2103.5
|
github.com/dgraph-io/badger/v3 v3.2103.5
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/docker/cli v27.5.0+incompatible
|
github.com/docker/cli v27.5.0+incompatible
|
||||||
@@ -29,7 +31,9 @@ require (
|
|||||||
github.com/ipfs/go-ds-crdt v0.5.2
|
github.com/ipfs/go-ds-crdt v0.5.2
|
||||||
github.com/ipfs/go-ipld-format v0.6.0
|
github.com/ipfs/go-ipld-format v0.6.0
|
||||||
github.com/ipfs/go-log/v2 v2.5.1
|
github.com/ipfs/go-log/v2 v2.5.1
|
||||||
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/lmittmann/tint v1.0.5
|
github.com/lmittmann/tint v1.0.5
|
||||||
|
github.com/opencontainers/go-digest v1.0.0
|
||||||
github.com/opencontainers/image-spec v1.1.0
|
github.com/opencontainers/image-spec v1.1.0
|
||||||
github.com/siderolabs/discovery-api v0.1.4
|
github.com/siderolabs/discovery-api v0.1.4
|
||||||
github.com/siderolabs/discovery-client v0.1.9
|
github.com/siderolabs/discovery-client v0.1.9
|
||||||
@@ -42,12 +46,13 @@ require (
|
|||||||
golang.org/x/crypto v0.32.0
|
golang.org/x/crypto v0.32.0
|
||||||
golang.org/x/net v0.34.0
|
golang.org/x/net v0.34.0
|
||||||
golang.org/x/sync v0.10.0
|
golang.org/x/sync v0.10.0
|
||||||
golang.org/x/sys v0.29.0
|
golang.org/x/sys v0.31.0
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576
|
||||||
google.golang.org/grpc v1.68.1
|
google.golang.org/grpc v1.68.1
|
||||||
google.golang.org/protobuf v1.36.3
|
google.golang.org/protobuf v1.36.3
|
||||||
|
modernc.org/sqlite v1.36.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -83,7 +88,6 @@ require (
|
|||||||
github.com/charmbracelet/x/term v0.2.0 // indirect
|
github.com/charmbracelet/x/term v0.2.0 // indirect
|
||||||
github.com/chzyer/readline v1.5.1 // indirect
|
github.com/chzyer/readline v1.5.1 // indirect
|
||||||
github.com/cloudflare/cfssl v1.6.4 // indirect
|
github.com/cloudflare/cfssl v1.6.4 // indirect
|
||||||
github.com/compose-spec/compose-go/v2 v2.4.5 // indirect
|
|
||||||
github.com/containerd/console v1.0.4 // indirect
|
github.com/containerd/console v1.0.4 // indirect
|
||||||
github.com/containerd/containerd v1.7.24 // indirect
|
github.com/containerd/containerd v1.7.24 // indirect
|
||||||
github.com/containerd/containerd/api v1.7.19 // indirect
|
github.com/containerd/containerd/api v1.7.19 // indirect
|
||||||
@@ -118,7 +122,7 @@ require (
|
|||||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
@@ -132,7 +136,7 @@ require (
|
|||||||
github.com/google/cel-go v0.20.1 // indirect
|
github.com/google/cel-go v0.20.1 // indirect
|
||||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||||
github.com/google/go-cmp v0.6.0 // indirect
|
github.com/google/go-cmp v0.6.0 // indirect
|
||||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/mux v1.8.1 // indirect
|
github.com/gorilla/mux v1.8.1 // indirect
|
||||||
@@ -224,8 +228,8 @@ require (
|
|||||||
github.com/multiformats/go-multistream v0.5.0 // indirect
|
github.com/multiformats/go-multistream v0.5.0 // indirect
|
||||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
|
||||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||||
github.com/pires/go-proxyproto v0.7.0 // indirect
|
github.com/pires/go-proxyproto v0.7.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
@@ -239,6 +243,7 @@ require (
|
|||||||
github.com/prometheus/procfs v0.15.1 // indirect
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.48.2 // indirect
|
github.com/quic-go/quic-go v0.48.2 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rs/xid v1.5.0 // indirect
|
github.com/rs/xid v1.5.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
@@ -269,6 +274,9 @@ require (
|
|||||||
github.com/urfave/cli v1.22.16 // indirect
|
github.com/urfave/cli v1.22.16 // indirect
|
||||||
github.com/vbatts/tar-split v0.11.6 // indirect
|
github.com/vbatts/tar-split v0.11.6 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // indirect
|
github.com/vishvananda/netns v0.0.4 // indirect
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||||
go.etcd.io/bbolt v1.3.11 // indirect
|
go.etcd.io/bbolt v1.3.11 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
@@ -306,4 +314,7 @@ require (
|
|||||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
||||||
howett.net/plist v1.0.0 // indirect
|
howett.net/plist v1.0.0 // indirect
|
||||||
lukechampine.com/blake3 v1.3.0 // indirect
|
lukechampine.com/blake3 v1.3.0 // indirect
|
||||||
|
modernc.org/libc v1.61.13 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.8.2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -233,6 +233,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU=
|
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU=
|
||||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U=
|
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U=
|
||||||
|
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
|
||||||
|
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y=
|
github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
|
||||||
@@ -332,8 +334,8 @@ github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4
|
|||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
github.com/go-stack/stack v1.6.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
github.com/go-stack/stack v1.6.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
@@ -408,8 +410,8 @@ github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5b
|
|||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||||
@@ -597,8 +599,8 @@ github.com/jinzhu/gorm v0.0.0-20170222002820-5409931a1bb8/go.mod h1:Vla75njaFJ8c
|
|||||||
github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d h1:jRQLvyVGL+iVtDElaEIDdKwpPqUIZJfzkNLV34htpEc=
|
github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d h1:jRQLvyVGL+iVtDElaEIDdKwpPqUIZJfzkNLV34htpEc=
|
||||||
github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/jmoiron/sqlx v1.3.3 h1:j82X0bf7oQ27XeqxicSZsTU5suPwKElg3oyxNn43iTk=
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
github.com/jmoiron/sqlx v1.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||||
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||||
@@ -639,8 +641,9 @@ github.com/lib/pq v0.0.0-20150723085316-0dad96c0b94f/go.mod h1:5WUZQaWbwv1U+lTRe
|
|||||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
|
||||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
|
github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
|
||||||
github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||||
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
|
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
|
||||||
@@ -701,6 +704,8 @@ github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh
|
|||||||
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
|
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
|
||||||
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
||||||
github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||||
@@ -809,6 +814,8 @@ github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOEL
|
|||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
|
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
|
||||||
@@ -925,6 +932,8 @@ github.com/quic-go/webtransport-go v0.8.0 h1:HxSrwun11U+LlmwpgM1kEqIqH90IT4N8auv
|
|||||||
github.com/quic-go/webtransport-go v0.8.0/go.mod h1:N99tjprW432Ut5ONql/aUhSLT0YVSlwHohQsuac9WaM=
|
github.com/quic-go/webtransport-go v0.8.0/go.mod h1:N99tjprW432Ut5ONql/aUhSLT0YVSlwHohQsuac9WaM=
|
||||||
github.com/raulk/go-watchdog v1.3.0 h1:oUmdlHxdkXRJlwfG0O9omj8ukerm8MEQavSiDTEtBsk=
|
github.com/raulk/go-watchdog v1.3.0 h1:oUmdlHxdkXRJlwfG0O9omj8ukerm8MEQavSiDTEtBsk=
|
||||||
github.com/raulk/go-watchdog v1.3.0/go.mod h1:fIvOnLbF0b0ZwkB9YU4mOW9Did//4vPZtDqv66NfsMU=
|
github.com/raulk/go-watchdog v1.3.0/go.mod h1:fIvOnLbF0b0ZwkB9YU4mOW9Did//4vPZtDqv66NfsMU=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
@@ -1069,6 +1078,13 @@ github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSD
|
|||||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
|
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
|
||||||
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b h1:FsyNrX12e5BkplJq7wKOLk0+C6LZ+KGXvuEcKUYm5ss=
|
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b h1:FsyNrX12e5BkplJq7wKOLk0+C6LZ+KGXvuEcKUYm5ss=
|
||||||
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
|
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
@@ -1283,8 +1299,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -1420,3 +1436,27 @@ k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
|
|||||||
k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
|
k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
|
||||||
lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
|
lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
|
||||||
lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
|
lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
|
||||||
|
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||||
|
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
|
||||||
|
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
|
||||||
|
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||||
|
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||||
|
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.36.3 h1:qYMYlFR+rtLDUzuXoST1SDIdEPbX8xzuhdF90WsX1ss=
|
||||||
|
modernc.org/sqlite v1.36.3/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"github.com/distribution/reference"
|
|
||||||
"maps"
|
|
||||||
"reflect"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
ServiceModeReplicated = "replicated"
|
|
||||||
ServiceModeGlobal = "global"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ServiceSpec struct {
|
|
||||||
Container ContainerSpec
|
|
||||||
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
|
|
||||||
Mode string
|
|
||||||
Name string
|
|
||||||
// Ports defines what service ports to publish to make the service accessible outside the cluster.
|
|
||||||
Ports []PortSpec
|
|
||||||
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
|
|
||||||
Replicas uint
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServiceSpec) Validate() error {
|
|
||||||
if err := s.Container.Validate(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch s.Mode {
|
|
||||||
case "", ServiceModeGlobal, ServiceModeReplicated:
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("invalid mode: %q", s.Mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: validate there is no conflict between ports.
|
|
||||||
// TODO: return error if there are non-HTTP/HTTPS ingress ports that we don't support yet.
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Equals returns true if the service spec is equal to the given spec ignoring the number of replicas.
|
|
||||||
func (s *ServiceSpec) Equals(spec ServiceSpec) bool {
|
|
||||||
// TODO: ignore order of ports.
|
|
||||||
sCopy := *s
|
|
||||||
// Ignore the number of replicas when comparing.
|
|
||||||
sCopy.Replicas = 0
|
|
||||||
spec.Replicas = 0
|
|
||||||
return reflect.DeepEqual(*s, spec)
|
|
||||||
}
|
|
||||||
|
|
||||||
var serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
|
|
||||||
|
|
||||||
func ValidateServiceID(id string) bool {
|
|
||||||
return serviceIDRegexp.MatchString(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ContainerSpec struct {
|
|
||||||
Command []string
|
|
||||||
Image string
|
|
||||||
// Run a custom init inside the container. If nil, use the daemon's configured settings.
|
|
||||||
Init *bool
|
|
||||||
// List of volumes to bind mount into the container.
|
|
||||||
Volumes []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ContainerSpec) Validate() error {
|
|
||||||
if _, err := reference.ParseDockerRef(s.Image); err != nil {
|
|
||||||
return fmt.Errorf("invalid image: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
Mode string
|
|
||||||
Containers []MachineContainer
|
|
||||||
}
|
|
||||||
|
|
||||||
type MachineContainer struct {
|
|
||||||
MachineID string
|
|
||||||
Container Container
|
|
||||||
}
|
|
||||||
|
|
||||||
// Endpoints returns the exposed HTTP and HTTPS endpoints of the service.
|
|
||||||
func (s *Service) Endpoints() []string {
|
|
||||||
endpoints := make(map[string]struct{})
|
|
||||||
|
|
||||||
// Container specs may differ between containers in the same service, e.g. during a rolling update,
|
|
||||||
// so we need to collect all unique endpoints.
|
|
||||||
for _, ctr := range s.Containers {
|
|
||||||
ports, err := ctr.Container.ServicePorts()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, port := range ports {
|
|
||||||
protocol := ""
|
|
||||||
switch port.Protocol {
|
|
||||||
case ProtocolHTTP:
|
|
||||||
protocol = "http"
|
|
||||||
case ProtocolHTTPS:
|
|
||||||
protocol = "https"
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if port.Hostname == "" {
|
|
||||||
// There shouldn't be http(s) ports without a hostname but just in case ignore them.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint := fmt.Sprintf("%s://%s", protocol, port.Hostname)
|
|
||||||
if port.PublishedPort != 0 {
|
|
||||||
// For non-standard ports (80/443), include the port in the URL.
|
|
||||||
if !(port.Protocol == ProtocolHTTP && port.PublishedPort == 80) &&
|
|
||||||
!(port.Protocol == ProtocolHTTPS && port.PublishedPort == 443) {
|
|
||||||
endpoint += fmt.Sprintf(":%d", port.PublishedPort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint += fmt.Sprintf(" → :%d", port.ContainerPort)
|
|
||||||
endpoints[endpoint] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return slices.Sorted(maps.Keys(endpoints))
|
|
||||||
}
|
|
||||||
|
|
||||||
func ServiceFromProto(s *pb.Service) (Service, error) {
|
|
||||||
var err error
|
|
||||||
containers := make([]MachineContainer, len(s.Containers))
|
|
||||||
for i, sc := range s.Containers {
|
|
||||||
containers[i], err = machineContainerFromProto(sc)
|
|
||||||
if err != nil {
|
|
||||||
return Service{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Service{
|
|
||||||
ID: s.Id,
|
|
||||||
Name: s.Name,
|
|
||||||
Mode: s.Mode,
|
|
||||||
Containers: containers,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func machineContainerFromProto(sc *pb.Service_Container) (MachineContainer, error) {
|
|
||||||
var c Container
|
|
||||||
if err := json.Unmarshal(sc.Container, &c); err != nil {
|
|
||||||
return MachineContainer{}, fmt.Errorf("unmarshal container: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return MachineContainer{
|
|
||||||
MachineID: sc.MachineId,
|
|
||||||
Container: c,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
+37
-10
@@ -5,15 +5,16 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/docker/cli/cli/streams"
|
"github.com/docker/cli/cli/streams"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/sshexec"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/connector"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"uncloud/internal/cli/client"
|
|
||||||
"uncloud/internal/cli/client/connector"
|
|
||||||
"uncloud/internal/cli/config"
|
|
||||||
"uncloud/internal/fs"
|
|
||||||
"uncloud/internal/machine"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
"uncloud/internal/sshexec"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
@@ -23,13 +24,22 @@ const defaultClusterName = "default"
|
|||||||
|
|
||||||
type CLI struct {
|
type CLI struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
|
conn *config.MachineConnection
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(configPath string) (*CLI, error) {
|
// New creates a new CLI instance with the given config path or remote machine connection.
|
||||||
|
// If the connection is provided, the config is ignored for all operations which is useful for interacting with
|
||||||
|
// a cluster without creating a config.
|
||||||
|
func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
|
||||||
|
if conn != nil {
|
||||||
|
return &CLI{conn: conn}, nil
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.NewFromFile(configPath)
|
cfg, err := config.NewFromFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read Uncloud config: %w", err)
|
return nil, fmt.Errorf("read Uncloud config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CLI{
|
return &CLI{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -47,13 +57,19 @@ func (cli *CLI) CreateCluster(name string) error {
|
|||||||
|
|
||||||
func (cli *CLI) SetCurrentCluster(name string) error {
|
func (cli *CLI) SetCurrentCluster(name string) error {
|
||||||
if _, ok := cli.config.Clusters[name]; !ok {
|
if _, ok := cli.config.Clusters[name]; !ok {
|
||||||
return client.ErrNotFound
|
return api.ErrNotFound
|
||||||
}
|
}
|
||||||
cli.config.CurrentCluster = name
|
cli.config.CurrentCluster = name
|
||||||
return cli.config.Save()
|
return cli.config.Save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConnectCluster connects to a cluster using the given cluster name or the current cluster if not specified.
|
||||||
|
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
||||||
func (cli *CLI) ConnectCluster(ctx context.Context, clusterName string) (*client.Client, error) {
|
func (cli *CLI) ConnectCluster(ctx context.Context, clusterName string) (*client.Client, error) {
|
||||||
|
if cli.conn != nil {
|
||||||
|
return connectCluster(ctx, *cli.conn)
|
||||||
|
}
|
||||||
|
|
||||||
if len(cli.config.Clusters) == 0 {
|
if len(cli.config.Clusters) == 0 {
|
||||||
return nil, errors.New(
|
return nil, errors.New(
|
||||||
"no clusters found in the Uncloud config. " +
|
"no clusters found in the Uncloud config. " +
|
||||||
@@ -88,6 +104,16 @@ func (cli *CLI) ConnectCluster(ctx context.Context, clusterName string) (*client
|
|||||||
|
|
||||||
// TODO: iterate over all connections and try to connect to the cluster using the first successful connection.
|
// TODO: iterate over all connections and try to connect to the cluster using the first successful connection.
|
||||||
conn := cfg.Connections[0]
|
conn := cfg.Connections[0]
|
||||||
|
|
||||||
|
c, err := connectCluster(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("no valid connection configuration found for the cluster")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||||
if conn.SSH != "" {
|
if conn.SSH != "" {
|
||||||
user, host, port, err := conn.SSH.Parse()
|
user, host, port, err := conn.SSH.Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -106,7 +132,8 @@ func (cli *CLI) ConnectCluster(ctx context.Context, clusterName string) (*client
|
|||||||
} else if conn.TCP.IsValid() {
|
} else if conn.TCP.IsValid() {
|
||||||
return client.New(ctx, connector.NewTCPConnector(conn.TCP))
|
return client.New(ctx, connector.NewTCPConnector(conn.TCP))
|
||||||
}
|
}
|
||||||
return nil, errors.New("no valid connection configuration found for the cluster")
|
|
||||||
|
return nil, errors.New("connection configuration is invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
|
// InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"testing"
|
|
||||||
"uncloud/internal/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestClient_NewCaddyDeployment(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cli := &Client{}
|
|
||||||
|
|
||||||
t.Run("latest image from Docker Hub", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
deploy, err := cli.NewCaddyDeployment("", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, "caddy", deploy.Spec.Name)
|
|
||||||
assert.Equal(t, api.ServiceModeGlobal, deploy.Spec.Mode)
|
|
||||||
assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, deploy.Spec.Container.Image)
|
|
||||||
expectedPorts := []api.PortSpec{
|
|
||||||
{
|
|
||||||
PublishedPort: 80,
|
|
||||||
ContainerPort: 80,
|
|
||||||
Protocol: api.ProtocolTCP,
|
|
||||||
Mode: api.PortModeHost,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
PublishedPort: 443,
|
|
||||||
ContainerPort: 443,
|
|
||||||
Protocol: api.ProtocolTCP,
|
|
||||||
Mode: api.PortModeHost,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert.Equal(t, expectedPorts, deploy.Spec.Ports)
|
|
||||||
// TODO:
|
|
||||||
//assert.Equal(t, alwaysPullImage, deploy.Spec.Container.PullPolicy)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("custom image", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
image := "my-caddy:1.2.3"
|
|
||||||
deploy, err := cli.NewCaddyDeployment(image, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, "caddy", deploy.Spec.Name)
|
|
||||||
assert.Equal(t, api.ServiceModeGlobal, deploy.Spec.Mode)
|
|
||||||
assert.Equal(t, image, deploy.Spec.Container.Image)
|
|
||||||
expectedPorts := []api.PortSpec{
|
|
||||||
{
|
|
||||||
PublishedPort: 80,
|
|
||||||
ContainerPort: 80,
|
|
||||||
Protocol: api.ProtocolTCP,
|
|
||||||
Mode: api.PortModeHost,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
PublishedPort: 443,
|
|
||||||
ContainerPort: 443,
|
|
||||||
Protocol: api.ProtocolTCP,
|
|
||||||
Mode: api.PortModeHost,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert.Equal(t, expectedPorts, deploy.Spec.Ports)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/distribution/reference"
|
|
||||||
"strings"
|
|
||||||
"uncloud/internal/api"
|
|
||||||
"uncloud/internal/secret"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ServiceSpecResolver transforms user-provided service specs into deployment-ready form.
|
|
||||||
type ServiceSpecResolver struct {
|
|
||||||
ClusterDomain string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewServiceSpecResolver(clusterDomain string) *ServiceSpecResolver {
|
|
||||||
return &ServiceSpecResolver{ClusterDomain: clusterDomain}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve transforms a service spec into its fully resolved form ready for deployment.
|
|
||||||
func (r *ServiceSpecResolver) Resolve(spec *api.ServiceSpec) error {
|
|
||||||
if err := spec.Validate(); err != nil {
|
|
||||||
return fmt.Errorf("invalid service spec: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
steps := []func(*api.ServiceSpec) error{
|
|
||||||
r.applyDefaults,
|
|
||||||
r.resolveServiceName,
|
|
||||||
r.expandIngressPorts,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, step := range steps {
|
|
||||||
if err := step(spec); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ServiceSpecResolver) applyDefaults(spec *api.ServiceSpec) error {
|
|
||||||
if spec.Mode == "" {
|
|
||||||
spec.Mode = api.ServiceModeReplicated
|
|
||||||
}
|
|
||||||
// Ensure the replicated service has at least one replica.
|
|
||||||
if spec.Mode == api.ServiceModeReplicated && spec.Replicas == 0 {
|
|
||||||
spec.Replicas = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ServiceSpecResolver) resolveServiceName(spec *api.ServiceSpec) error {
|
|
||||||
if spec.Name != "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a random service name from the image when not provided.
|
|
||||||
img, err := reference.ParseDockerRef(spec.Container.Image)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid image: %w", err)
|
|
||||||
}
|
|
||||||
// Get the image name without the repository and tag/digest parts.
|
|
||||||
imageName := reference.FamiliarName(img)
|
|
||||||
// Get the last part of the image name (path), e.g. "nginx" from "bitnami/nginx".
|
|
||||||
if i := strings.LastIndex(imageName, "/"); i != -1 {
|
|
||||||
imageName = imageName[i+1:]
|
|
||||||
}
|
|
||||||
// Append a random suffix to the image name to generate an optimistically unique service name.
|
|
||||||
suffix, err := secret.RandomAlphaNumeric(4)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("generate random suffix: %w", err)
|
|
||||||
}
|
|
||||||
spec.Name = fmt.Sprintf("%s-%s", imageName, suffix)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// expandIngressPorts processes HTTP(S) ingress ports in a service spec by:
|
|
||||||
// 1. Setting a default hostname (service-name.cluster-domain) for ports without a hostname.
|
|
||||||
// 2. Duplicating a port with a cluster domain hostname for ports with external domains.
|
|
||||||
// This ensures every ingress port is accessible via the cluster domain, while preserving any custom domains specified
|
|
||||||
// by the user.
|
|
||||||
func (r *ServiceSpecResolver) expandIngressPorts(spec *api.ServiceSpec) error {
|
|
||||||
for i, port := range spec.Ports {
|
|
||||||
if port.Protocol != api.ProtocolHTTP && port.Protocol != api.ProtocolHTTPS {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if port.Hostname == "" {
|
|
||||||
if r.ClusterDomain == "" {
|
|
||||||
return fmt.Errorf("cluster domain must be reserved to generate hostname for ingress port: %d/%s",
|
|
||||||
port.ContainerPort, port.Protocol)
|
|
||||||
}
|
|
||||||
// Assign the default hostname (service-name.cluster-domain).
|
|
||||||
spec.Ports[i].Hostname = fmt.Sprintf("%s.%s", spec.Name, r.ClusterDomain)
|
|
||||||
} else {
|
|
||||||
if r.ClusterDomain == "" {
|
|
||||||
// When no cluster domain is reserved, use only the provided hostname.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasSuffix(port.Hostname, "."+r.ClusterDomain) {
|
|
||||||
// If the hostname is already a cluster subdomain, use as is.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// For external domains, duplicate the port with a service-name.cluster-domain hostname so the service
|
|
||||||
// can be accessed via both hostnames.
|
|
||||||
newPort := port
|
|
||||||
newPort.Hostname = fmt.Sprintf("%s.%s", spec.Name, r.ClusterDomain)
|
|
||||||
spec.Ports = append(spec.Ports, newPort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -15,10 +15,10 @@ const (
|
|||||||
|
|
||||||
type MachineConnection struct {
|
type MachineConnection struct {
|
||||||
SSH SSHDestination `toml:"ssh,omitempty"`
|
SSH SSHDestination `toml:"ssh,omitempty"`
|
||||||
|
SSHKeyFile string `toml:"ssh_key_file,omitempty"`
|
||||||
TCP netip.AddrPort `toml:"tcp,omitempty"`
|
TCP netip.AddrPort `toml:"tcp,omitempty"`
|
||||||
Host string `toml:"host,omitempty"`
|
Host string `toml:"host,omitempty"`
|
||||||
PublicKey secret.Secret `toml:"public_key,omitempty"`
|
PublicKey secret.Secret `toml:"public_key,omitempty"`
|
||||||
SSHKeyFile string `toml:"ssh_key_file,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"uncloud/internal/sshexec"
|
"github.com/psviderski/uncloud/internal/sshexec"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: support pinning the script version to the CLI version.
|
// TODO: support pinning the script version to the CLI version.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/huh"
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return confirmed, nil
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
systemd "github.com/coreos/go-systemd/daemon"
|
systemd "github.com/coreos/go-systemd/daemon"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Daemon struct {
|
type Daemon struct {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package daemon
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"uncloud/internal/machine"
|
|
||||||
"uncloud/internal/machine/network"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MachineToken returns the local machine's token that can be used for adding the machine to a cluster.
|
// MachineToken returns the local machine's token that can be used for adding the machine to a cluster.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,22 +15,31 @@ service Docker {
|
|||||||
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
|
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
|
||||||
rpc RemoveContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
rpc RemoveContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
||||||
rpc PullImage(PullImageRequest) returns (stream JSONMessage);
|
rpc PullImage(PullImageRequest) returns (stream JSONMessage);
|
||||||
|
rpc InspectImage(InspectImageRequest) returns (InspectImageResponse);
|
||||||
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
|
// Docker auth credentials if necessary.
|
||||||
|
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
|
||||||
|
|
||||||
|
rpc CreateServiceContainer(CreateServiceContainerRequest) returns (CreateContainerResponse);
|
||||||
|
rpc InspectServiceContainer(InspectContainerRequest) returns (ServiceContainer);
|
||||||
|
rpc ListServiceContainers(ListServiceContainersRequest) returns (ListServiceContainersResponse);
|
||||||
|
rpc RemoveServiceContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateContainerRequest {
|
message CreateContainerRequest {
|
||||||
// JSON serialized container.Config.
|
// JSON serialised container.Config.
|
||||||
bytes config = 1;
|
bytes config = 1;
|
||||||
// JSON serialized container.HostConfig.
|
// JSON serialised container.HostConfig.
|
||||||
bytes host_config = 2;
|
bytes host_config = 2;
|
||||||
// JSON serialized network.NetworkingConfig.
|
// JSON serialised network.NetworkingConfig.
|
||||||
bytes network_config = 3;
|
bytes network_config = 3;
|
||||||
// JSON serialized ocispec.Platform.
|
// JSON serialised ocispec.Platform.
|
||||||
bytes platform = 4;
|
bytes platform = 4;
|
||||||
string name = 5;
|
string name = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateContainerResponse {
|
message CreateContainerResponse {
|
||||||
// JSON serialized container.CreateResponse.
|
// JSON serialised container.CreateResponse.
|
||||||
bytes response = 1;
|
bytes response = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,51 +48,114 @@ message InspectContainerRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message InspectContainerResponse {
|
message InspectContainerResponse {
|
||||||
// JSON serialized container.InspectResponse.
|
// JSON serialised container.InspectResponse.
|
||||||
bytes response = 1;
|
bytes response = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message StartContainerRequest {
|
message StartContainerRequest {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
// JSON serialized container.StartOptions.
|
// JSON serialised container.StartOptions.
|
||||||
bytes options = 2;
|
bytes options = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message StopContainerRequest {
|
message StopContainerRequest {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
// JSON serialized container.StopOptions.
|
// JSON serialised container.StopOptions.
|
||||||
bytes options = 2;
|
bytes options = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListContainersRequest {
|
message ListContainersRequest {
|
||||||
// JSON serialized container.ListOptions.
|
// JSON serialised container.ListOptions.
|
||||||
bytes options = 1;
|
bytes options = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListContainersResponse structure allows broadcasting ListContainers requests to multiple machines.
|
|
||||||
message ListContainersResponse {
|
message ListContainersResponse {
|
||||||
|
// Must contain only one repeated messages field to allow broadcasting ListContainers requests to multiple machines.
|
||||||
repeated MachineContainers messages = 1;
|
repeated MachineContainers messages = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message MachineContainers {
|
message MachineContainers {
|
||||||
Metadata metadata = 1;
|
Metadata metadata = 1;
|
||||||
// JSON serialized []container.ContainerJSON.
|
// JSON serialised []container.InspectResponse.
|
||||||
bytes containers = 2;
|
bytes containers = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message RemoveContainerRequest {
|
message RemoveContainerRequest {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
// JSON serialized container.RemoveOptions.
|
// JSON serialised container.RemoveOptions.
|
||||||
bytes options = 2;
|
bytes options = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message PullImageRequest {
|
message PullImageRequest {
|
||||||
string image = 1;
|
string image = 1;
|
||||||
// JSON serialized image.PullOptions.
|
// JSON serialised image.PullOptions.
|
||||||
bytes options = 2;
|
bytes options = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message JSONMessage {
|
message JSONMessage {
|
||||||
// JSON serialized jsonmessage.JSONMessage.
|
// JSON serialised jsonmessage.JSONMessage.
|
||||||
bytes message = 1;
|
bytes message = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message InspectImageRequest {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InspectImageResponse {
|
||||||
|
// Must contain only one repeated messages field to allow broadcasting InspectImage requests to multiple machines.
|
||||||
|
repeated Image messages = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Image {
|
||||||
|
Metadata metadata = 1;
|
||||||
|
// JSON serialised types.ImageInspect.
|
||||||
|
bytes image = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InspectRemoteImageRequest {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InspectRemoteImageResponse {
|
||||||
|
// Must contain only one repeated messages field to allow broadcasting InspectRemoteImage requests to multiple machines.
|
||||||
|
repeated RemoteImage messages = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoteImage {
|
||||||
|
Metadata metadata = 1;
|
||||||
|
// Image reference in the canonical form with the digest.
|
||||||
|
string reference = 2;
|
||||||
|
// Raw JSON manifest from the registry.
|
||||||
|
bytes manifest = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateServiceContainerRequest {
|
||||||
|
string service_id = 1;
|
||||||
|
// JSON serialised api.ServiceSpec.
|
||||||
|
bytes service_spec = 2;
|
||||||
|
string container_name = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ServiceContainer {
|
||||||
|
// JSON serialised container.InspectResponse.
|
||||||
|
bytes container = 1;
|
||||||
|
// JSON serialised api.ServiceSpec.
|
||||||
|
bytes service_spec = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListServiceContainersRequest {
|
||||||
|
string service_id = 1;
|
||||||
|
// JSON serialised container.ListOptions.
|
||||||
|
bytes options = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListServiceContainersResponse {
|
||||||
|
// Must contain only one repeated messages field to allow broadcasting ListServiceContainers requests
|
||||||
|
// to multiple machines.
|
||||||
|
repeated MachineServiceContainers messages = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MachineServiceContainers {
|
||||||
|
Metadata metadata = 1;
|
||||||
|
repeated ServiceContainer containers = 2;
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ const (
|
|||||||
Docker_ListContainers_FullMethodName = "/api.Docker/ListContainers"
|
Docker_ListContainers_FullMethodName = "/api.Docker/ListContainers"
|
||||||
Docker_RemoveContainer_FullMethodName = "/api.Docker/RemoveContainer"
|
Docker_RemoveContainer_FullMethodName = "/api.Docker/RemoveContainer"
|
||||||
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
|
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
|
||||||
|
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
|
||||||
|
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
|
||||||
|
Docker_CreateServiceContainer_FullMethodName = "/api.Docker/CreateServiceContainer"
|
||||||
|
Docker_InspectServiceContainer_FullMethodName = "/api.Docker/InspectServiceContainer"
|
||||||
|
Docker_ListServiceContainers_FullMethodName = "/api.Docker/ListServiceContainers"
|
||||||
|
Docker_RemoveServiceContainer_FullMethodName = "/api.Docker/RemoveServiceContainer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DockerClient is the client API for Docker service.
|
// DockerClient is the client API for Docker service.
|
||||||
@@ -40,6 +46,14 @@ type DockerClient interface {
|
|||||||
ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ListContainersResponse, error)
|
ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ListContainersResponse, error)
|
||||||
RemoveContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
RemoveContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], error)
|
PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], error)
|
||||||
|
InspectImage(ctx context.Context, in *InspectImageRequest, opts ...grpc.CallOption) (*InspectImageResponse, error)
|
||||||
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
|
// Docker auth credentials if necessary.
|
||||||
|
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
|
||||||
|
CreateServiceContainer(ctx context.Context, in *CreateServiceContainerRequest, opts ...grpc.CallOption) (*CreateContainerResponse, error)
|
||||||
|
InspectServiceContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*ServiceContainer, error)
|
||||||
|
ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error)
|
||||||
|
RemoveServiceContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type dockerClient struct {
|
type dockerClient struct {
|
||||||
@@ -129,6 +143,66 @@ func (c *dockerClient) PullImage(ctx context.Context, in *PullImageRequest, opts
|
|||||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
type Docker_PullImageClient = grpc.ServerStreamingClient[JSONMessage]
|
type Docker_PullImageClient = grpc.ServerStreamingClient[JSONMessage]
|
||||||
|
|
||||||
|
func (c *dockerClient) InspectImage(ctx context.Context, in *InspectImageRequest, opts ...grpc.CallOption) (*InspectImageResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(InspectImageResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_InspectImage_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(InspectRemoteImageResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_InspectRemoteImage_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) CreateServiceContainer(ctx context.Context, in *CreateServiceContainerRequest, opts ...grpc.CallOption) (*CreateContainerResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CreateContainerResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_CreateServiceContainer_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) InspectServiceContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*ServiceContainer, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ServiceContainer)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_InspectServiceContainer_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ListServiceContainersResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_ListServiceContainers_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) RemoveServiceContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(emptypb.Empty)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_RemoveServiceContainer_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DockerServer is the server API for Docker service.
|
// DockerServer is the server API for Docker service.
|
||||||
// All implementations must embed UnimplementedDockerServer
|
// All implementations must embed UnimplementedDockerServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@@ -140,6 +214,14 @@ type DockerServer interface {
|
|||||||
ListContainers(context.Context, *ListContainersRequest) (*ListContainersResponse, error)
|
ListContainers(context.Context, *ListContainersRequest) (*ListContainersResponse, error)
|
||||||
RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
||||||
PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error
|
PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error
|
||||||
|
InspectImage(context.Context, *InspectImageRequest) (*InspectImageResponse, error)
|
||||||
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
|
// Docker auth credentials if necessary.
|
||||||
|
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
|
||||||
|
CreateServiceContainer(context.Context, *CreateServiceContainerRequest) (*CreateContainerResponse, error)
|
||||||
|
InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error)
|
||||||
|
ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error)
|
||||||
|
RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
||||||
mustEmbedUnimplementedDockerServer()
|
mustEmbedUnimplementedDockerServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +253,24 @@ func (UnimplementedDockerServer) RemoveContainer(context.Context, *RemoveContain
|
|||||||
func (UnimplementedDockerServer) PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error {
|
func (UnimplementedDockerServer) PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error {
|
||||||
return status.Errorf(codes.Unimplemented, "method PullImage not implemented")
|
return status.Errorf(codes.Unimplemented, "method PullImage not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedDockerServer) InspectImage(context.Context, *InspectImageRequest) (*InspectImageResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method InspectImage not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedDockerServer) InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method InspectRemoteImage not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedDockerServer) CreateServiceContainer(context.Context, *CreateServiceContainerRequest) (*CreateContainerResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method CreateServiceContainer not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedDockerServer) InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method InspectServiceContainer not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedDockerServer) ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListServiceContainers not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedDockerServer) RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method RemoveServiceContainer not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedDockerServer) mustEmbedUnimplementedDockerServer() {}
|
func (UnimplementedDockerServer) mustEmbedUnimplementedDockerServer() {}
|
||||||
func (UnimplementedDockerServer) testEmbeddedByValue() {}
|
func (UnimplementedDockerServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -311,6 +411,114 @@ func _Docker_PullImage_Handler(srv interface{}, stream grpc.ServerStream) error
|
|||||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
type Docker_PullImageServer = grpc.ServerStreamingServer[JSONMessage]
|
type Docker_PullImageServer = grpc.ServerStreamingServer[JSONMessage]
|
||||||
|
|
||||||
|
func _Docker_InspectImage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(InspectImageRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).InspectImage(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_InspectImage_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).InspectImage(ctx, req.(*InspectImageRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Docker_InspectRemoteImage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(InspectRemoteImageRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).InspectRemoteImage(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_InspectRemoteImage_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).InspectRemoteImage(ctx, req.(*InspectRemoteImageRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Docker_CreateServiceContainer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(CreateServiceContainerRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).CreateServiceContainer(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_CreateServiceContainer_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).CreateServiceContainer(ctx, req.(*CreateServiceContainerRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Docker_InspectServiceContainer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(InspectContainerRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).InspectServiceContainer(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_InspectServiceContainer_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).InspectServiceContainer(ctx, req.(*InspectContainerRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Docker_ListServiceContainers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ListServiceContainersRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).ListServiceContainers(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_ListServiceContainers_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).ListServiceContainers(ctx, req.(*ListServiceContainersRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Docker_RemoveServiceContainer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(RemoveContainerRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).RemoveServiceContainer(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_RemoveServiceContainer_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).RemoveServiceContainer(ctx, req.(*RemoveContainerRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
// Docker_ServiceDesc is the grpc.ServiceDesc for Docker service.
|
// Docker_ServiceDesc is the grpc.ServiceDesc for Docker service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -342,6 +550,30 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "RemoveContainer",
|
MethodName: "RemoveContainer",
|
||||||
Handler: _Docker_RemoveContainer_Handler,
|
Handler: _Docker_RemoveContainer_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "InspectImage",
|
||||||
|
Handler: _Docker_InspectImage_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "InspectRemoteImage",
|
||||||
|
Handler: _Docker_InspectRemoteImage_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "CreateServiceContainer",
|
||||||
|
Handler: _Docker_CreateServiceContainer_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "InspectServiceContainer",
|
||||||
|
Handler: _Docker_InspectServiceContainer_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListServiceContainers",
|
||||||
|
Handler: _Docker_ListServiceContainers_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "RemoveServiceContainer",
|
||||||
|
Handler: _Docker_RemoveServiceContainer_Handler,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{
|
Streams: []grpc.StreamDesc{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/encoding/protowire"
|
"google.golang.org/protobuf/encoding/protowire"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// One2ManyResponder converts upstream responses into messages from upstreams, so that multiple
|
// One2ManyResponder converts upstream responses into messages from upstreams, so that multiple
|
||||||
|
|||||||
@@ -4,21 +4,22 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/caddyserver/caddy/v2"
|
|
||||||
"github.com/caddyserver/caddy/v2/caddyconfig"
|
|
||||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
|
||||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"uncloud/internal/api"
|
|
||||||
"uncloud/internal/machine/docker"
|
"github.com/caddyserver/caddy/v2"
|
||||||
|
"github.com/caddyserver/caddy/v2/caddyconfig"
|
||||||
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||||
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GenerateConfig(containers []api.Container, verifyResponse string) (*caddy.Config, error) {
|
func GenerateConfig(containers []api.ServiceContainer, verifyResponse string) (*caddy.Config, error) {
|
||||||
// Maps hostnames to lists of upstreams (container IP:port pairs).
|
// Maps hostnames to lists of upstreams (container IP:port pairs).
|
||||||
httpHostUpstreams := make(map[string][]string)
|
httpHostUpstreams := make(map[string][]string)
|
||||||
httpsHostUpstreams := make(map[string][]string)
|
httpsHostUpstreams := make(map[string][]string)
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
package caddyfile
|
package caddyfile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"uncloud/internal/api"
|
|
||||||
"uncloud/internal/machine/docker"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGenerateConfig(t *testing.T) {
|
func TestGenerateConfig(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
containers []api.Container
|
containers []api.ServiceContainer
|
||||||
want string
|
want string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty containers",
|
name: "empty containers",
|
||||||
containers: []api.Container{},
|
containers: []api.ServiceContainer{},
|
||||||
want: `{
|
want: `{
|
||||||
"servers": {
|
"servers": {
|
||||||
"http": {
|
"http": {
|
||||||
@@ -45,7 +46,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
|
|
||||||
{
|
{
|
||||||
name: "HTTP container",
|
name: "HTTP container",
|
||||||
containers: []api.Container{
|
containers: []api.ServiceContainer{
|
||||||
newContainer("10.210.0.2", "app.example.com:8080/http"),
|
newContainer("10.210.0.2", "app.example.com:8080/http"),
|
||||||
},
|
},
|
||||||
want: `{
|
want: `{
|
||||||
@@ -79,7 +80,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "load balancing multiple containers",
|
name: "load balancing multiple containers",
|
||||||
containers: []api.Container{
|
containers: []api.ServiceContainer{
|
||||||
newContainer("10.210.0.2", "app.example.com:8080/http"),
|
newContainer("10.210.0.2", "app.example.com:8080/http"),
|
||||||
newContainer("10.210.0.3", "app.example.com:8080/http"),
|
newContainer("10.210.0.3", "app.example.com:8080/http"),
|
||||||
},
|
},
|
||||||
@@ -117,7 +118,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "HTTPS container",
|
name: "HTTPS container",
|
||||||
containers: []api.Container{
|
containers: []api.ServiceContainer{
|
||||||
newContainer("10.210.0.2", "secure.example.com:8000/https"),
|
newContainer("10.210.0.2", "secure.example.com:8000/https"),
|
||||||
},
|
},
|
||||||
want: `{
|
want: `{
|
||||||
@@ -153,7 +154,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mixed HTTP and HTTPS",
|
name: "mixed HTTP and HTTPS",
|
||||||
containers: []api.Container{
|
containers: []api.ServiceContainer{
|
||||||
newContainer("10.210.0.2",
|
newContainer("10.210.0.2",
|
||||||
"app.example.com:8080/http",
|
"app.example.com:8080/http",
|
||||||
"web.example.com:8000/http"),
|
"web.example.com:8000/http"),
|
||||||
@@ -227,7 +228,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "container without uncloud network ignored",
|
name: "container without uncloud network ignored",
|
||||||
containers: []api.Container{
|
containers: []api.ServiceContainer{
|
||||||
newContainerWithoutNetwork("ignored.example.com:8080/http"),
|
newContainerWithoutNetwork("ignored.example.com:8080/http"),
|
||||||
},
|
},
|
||||||
want: `{
|
want: `{
|
||||||
@@ -252,7 +253,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "container with invalid port ignored",
|
name: "container with invalid port ignored",
|
||||||
containers: []api.Container{
|
containers: []api.ServiceContainer{
|
||||||
newContainer("10.210.0.2", "invalid-port"),
|
newContainer("10.210.0.2", "invalid-port"),
|
||||||
},
|
},
|
||||||
want: `{
|
want: `{
|
||||||
@@ -277,7 +278,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "containers with unsupported protocols ignored",
|
name: "containers with unsupported protocols ignored",
|
||||||
containers: []api.Container{
|
containers: []api.ServiceContainer{
|
||||||
newContainer("10.210.0.2", "5000/tcp"),
|
newContainer("10.210.0.2", "5000/tcp"),
|
||||||
newContainer("10.210.0.3", "5000/udp"),
|
newContainer("10.210.0.3", "5000/udp"),
|
||||||
newContainer("10.210.0.4", "80:8080/tcp@host"),
|
newContainer("10.210.0.4", "80:8080/tcp@host"),
|
||||||
@@ -322,10 +323,9 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newContainer(ip string, ports ...string) api.Container {
|
func newContainer(ip string, ports ...string) api.ServiceContainer {
|
||||||
portsLabel := strings.Join(ports, ",")
|
portsLabel := strings.Join(ports, ",")
|
||||||
return api.Container{
|
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
|
||||||
ContainerJSON: types.ContainerJSON{
|
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{},
|
ContainerJSONBase: &types.ContainerJSONBase{},
|
||||||
NetworkSettings: &types.NetworkSettings{
|
NetworkSettings: &types.NetworkSettings{
|
||||||
Networks: map[string]*network.EndpointSettings{
|
Networks: map[string]*network.EndpointSettings{
|
||||||
@@ -339,14 +339,12 @@ func newContainer(ip string, ports ...string) api.Container {
|
|||||||
api.LabelServicePorts: portsLabel,
|
api.LabelServicePorts: portsLabel,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}}}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newContainerWithoutNetwork(ports ...string) api.Container {
|
func newContainerWithoutNetwork(ports ...string) api.ServiceContainer {
|
||||||
portsLabel := strings.Join(ports, ",")
|
portsLabel := strings.Join(ports, ",")
|
||||||
return api.Container{
|
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
|
||||||
ContainerJSON: types.ContainerJSON{
|
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{},
|
ContainerJSONBase: &types.ContainerJSONBase{},
|
||||||
NetworkSettings: &types.NetworkSettings{
|
NetworkSettings: &types.NetworkSettings{
|
||||||
Networks: map[string]*network.EndpointSettings{
|
Networks: map[string]*network.EndpointSettings{
|
||||||
@@ -360,6 +358,5 @@ func newContainerWithoutNetwork(ports ...string) api.Container {
|
|||||||
api.LabelServicePorts: portsLabel,
|
api.LabelServicePorts: portsLabel,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}}}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"uncloud/internal/api"
|
|
||||||
"uncloud/internal/fs"
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
"uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -89,15 +90,20 @@ func (c *Controller) Run(ctx context.Context) error {
|
|||||||
// filterAvailableContainers filters out containers from this machine that are likely unavailable. The availability
|
// filterAvailableContainers filters out containers from this machine that are likely unavailable. The availability
|
||||||
// is determined by the cluster membership state of the machine that the container is running on.
|
// is determined by the cluster membership state of the machine that the container is running on.
|
||||||
// TODO: implement machine membership check using Corrossion Admin client.
|
// TODO: implement machine membership check using Corrossion Admin client.
|
||||||
func (c *Controller) filterAvailableContainers(containerRecords []store.ContainerRecord) ([]api.Container, error) {
|
func (c *Controller) filterAvailableContainers(
|
||||||
containers := make([]api.Container, len(containerRecords))
|
containerRecords []store.ContainerRecord,
|
||||||
|
) ([]api.ServiceContainer, error) {
|
||||||
|
containers := make([]api.ServiceContainer, len(containerRecords))
|
||||||
for i, cr := range containerRecords {
|
for i, cr := range containerRecords {
|
||||||
containers[i] = cr.Container
|
containers[i] = api.ServiceContainer{
|
||||||
|
Container: cr.Container,
|
||||||
|
// TODO: restore ServiceSpec from the container record once it's saved in the store.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return containers, nil
|
return containers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) generateConfig(containers []api.Container) error {
|
func (c *Controller) generateConfig(containers []api.ServiceContainer) error {
|
||||||
config, err := GenerateConfig(containers, c.verifyResponse)
|
config, err := GenerateConfig(containers, c.verifyResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/corrosion"
|
"github.com/psviderski/uncloud/internal/corrosion"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Cluster struct {
|
type Cluster struct {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"github.com/psviderski/uncloud/internal/dns"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
"uncloud/internal/dns"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
"uncloud/internal/machine/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// uncloudDNSKey is the key used to store the details of the reserved domain in the store.
|
// uncloudDNSKey is the key used to store the details of the reserved domain in the store.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package cluster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewMachineID generates a new unique machine ID.
|
// NewMachineID generates a new unique machine ID.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"uncloud/internal/fs"
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DBFileName = "machine.db"
|
||||||
|
|
||||||
|
// NewDB creates a new connection to machine SQLite database and runs schema migrations if necessary.
|
||||||
|
func NewDB(path string) (*sqlx.DB, error) {
|
||||||
|
// Create the database file with 0600 permissions if it doesn't exist, or update permissions if exists.
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create SQLite database '%s': %w", path, err)
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
} else {
|
||||||
|
if err = os.Chmod(path, 0600); err != nil {
|
||||||
|
return nil, fmt.Errorf("update SQLite database permissions '%s': %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// - Write-Ahead Logging (WAL) mode for better read/write performance.
|
||||||
|
// - Busy timeout (5s) to make concurrent writes wait on each other instead of failing immediately.
|
||||||
|
conn := path + "?_pragma=journal_mode=WAL&_pragma=synchronous=NORMAL&_pragma=busy_timeout=5000&_time_format=sqlite"
|
||||||
|
db, err := sqlx.Connect("sqlite", conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to SQLite database '%s': %w", conn, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS containers (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
service_id TEXT NOT NULL,
|
||||||
|
service_name TEXT AS (json_extract(service_spec, '$.Name')),
|
||||||
|
service_spec TEXT NOT NULL CHECK (json_valid(service_spec)),
|
||||||
|
-- 'subsecond' modifier is used to store timestamps with millisecond precision.
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT (datetime('subsecond')),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('subsecond'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_containers_service_id ON containers (service_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_containers_service_name ON containers (service_name);
|
||||||
|
`
|
||||||
|
|
||||||
|
if _, err = db.Exec(schema); err != nil {
|
||||||
|
return nil, fmt.Errorf("create schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
@@ -3,18 +3,23 @@ package docker
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/errdefs"
|
"github.com/docker/docker/errdefs"
|
||||||
"github.com/docker/docker/pkg/jsonmessage"
|
"github.com/docker/docker/pkg/jsonmessage"
|
||||||
|
regtypes "github.com/google/go-containerregistry/pkg/v1/types"
|
||||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"io"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client is a gRPC client for the Docker service that provides a similar interface to the Docker HTTP client.
|
// Client is a gRPC client for the Docker service that provides a similar interface to the Docker HTTP client.
|
||||||
@@ -72,11 +77,9 @@ func (c *Client) CreateContainer(
|
|||||||
Name: name,
|
Name: name,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s, ok := status.FromError(err); ok {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
if s.Code() == codes.NotFound {
|
|
||||||
return resp, errdefs.NotFound(err)
|
return resp, errdefs.NotFound(err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,11 +95,9 @@ func (c *Client) InspectContainer(ctx context.Context, id string) (types.Contain
|
|||||||
|
|
||||||
grpcResp, err := c.grpcClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
grpcResp, err := c.grpcClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s, ok := status.FromError(err); ok {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
if s.Code() == codes.NotFound {
|
|
||||||
return resp, errdefs.NotFound(err)
|
return resp, errdefs.NotFound(err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,12 +119,10 @@ func (c *Client) StartContainer(ctx context.Context, id string, opts container.S
|
|||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s, ok := status.FromError(err); ok {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
if s.Code() == codes.NotFound {
|
|
||||||
return errdefs.NotFound(err)
|
return errdefs.NotFound(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,12 +138,10 @@ func (c *Client) StopContainer(ctx context.Context, id string, opts container.St
|
|||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s, ok := status.FromError(err); ok {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
if s.Code() == codes.NotFound {
|
|
||||||
return errdefs.NotFound(err)
|
return errdefs.NotFound(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,12 +188,10 @@ func (c *Client) RemoveContainer(ctx context.Context, id string, opts container.
|
|||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s, ok := status.FromError(err); ok {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
if s.Code() == codes.NotFound {
|
|
||||||
return errdefs.NotFound(err)
|
return errdefs.NotFound(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,3 +232,228 @@ func (c *Client) PullImage(ctx context.Context, image string) (<-chan PullImageM
|
|||||||
|
|
||||||
return ch, nil
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InspectImage returns the image information for the given image ID. The request may be sent to multiple machines.
|
||||||
|
func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
||||||
|
resp, err := c.grpcClient.InspectImage(ctx, &pb.InspectImageRequest{Id: id})
|
||||||
|
if err != nil {
|
||||||
|
// If the request was sent to only one machine, err is an actual error from the machine.
|
||||||
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
|
return nil, errdefs.NotFound(err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
notFoundCount := 0
|
||||||
|
for _, msg := range resp.Messages {
|
||||||
|
if msg.Metadata != nil && msg.Metadata.Status != nil && codes.Code(msg.Metadata.Status.Code) == codes.NotFound {
|
||||||
|
notFoundCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(resp.Messages) == notFoundCount {
|
||||||
|
return nil, errdefs.NotFound(fmt.Errorf("image not found: %s", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
images := make([]api.MachineImage, len(resp.Messages))
|
||||||
|
for i, msg := range resp.Messages {
|
||||||
|
images[i].Metadata = msg.Metadata
|
||||||
|
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(msg.Image, &images[i].Image); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal image: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return images, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's Docker auth
|
||||||
|
// credentials if necessary. If the response from a machine doesn't contain an error, the api.RemoteImage will either
|
||||||
|
// contain an IndexManifest or an ImageManifest.
|
||||||
|
func (c *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
|
||||||
|
resp, err := c.grpcClient.InspectRemoteImage(ctx, &pb.InspectRemoteImageRequest{Id: id})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
images := make([]api.MachineRemoteImage, 0, len(resp.Messages))
|
||||||
|
var parseErr error
|
||||||
|
for _, msg := range resp.Messages {
|
||||||
|
mri, err := parseRemoteImageMessage(msg)
|
||||||
|
if err != nil {
|
||||||
|
parseErr = errors.Join(parseErr, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
images = append(images, mri)
|
||||||
|
}
|
||||||
|
|
||||||
|
return images, parseErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestMediaType struct {
|
||||||
|
MediaType regtypes.MediaType `json:"mediaType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRemoteImageMessage(msg *pb.RemoteImage) (api.MachineRemoteImage, error) {
|
||||||
|
mri := api.MachineRemoteImage{
|
||||||
|
Metadata: msg.Metadata,
|
||||||
|
}
|
||||||
|
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||||
|
return mri, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err := reference.ParseNormalizedNamed(msg.Reference)
|
||||||
|
if err != nil {
|
||||||
|
// The reference is the string representation of a valid canonical reference from the server which
|
||||||
|
// is expected to be parseable.
|
||||||
|
return mri, fmt.Errorf("parse reference: %w", err)
|
||||||
|
}
|
||||||
|
if canonicalRef, ok := ref.(reference.Canonical); ok {
|
||||||
|
mri.Image.Reference = canonicalRef
|
||||||
|
} else {
|
||||||
|
return mri, fmt.Errorf("unexpected non-canonical image reference: %s", msg.Reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := manifestMediaType{}
|
||||||
|
if err = json.Unmarshal(msg.Manifest, &manifest); err != nil {
|
||||||
|
return mri, fmt.Errorf("unmarshal manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case manifest.MediaType.IsIndex():
|
||||||
|
index := &ocispec.Index{}
|
||||||
|
if err = json.Unmarshal(msg.Manifest, index); err != nil {
|
||||||
|
return mri, fmt.Errorf("unmarshal index manifest: %w", err)
|
||||||
|
}
|
||||||
|
mri.Image.IndexManifest = index
|
||||||
|
case manifest.MediaType.IsImage():
|
||||||
|
image := &ocispec.Manifest{}
|
||||||
|
if err = json.Unmarshal(msg.Manifest, image); err != nil {
|
||||||
|
return mri, fmt.Errorf("unmarshal image manifest: %w", err)
|
||||||
|
}
|
||||||
|
mri.Image.ImageManifest = image
|
||||||
|
default:
|
||||||
|
return mri, fmt.Errorf("unexpected manifest media type: %s", manifest.MediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mri, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateServiceContainer creates a new container for the service with the given specifications.
|
||||||
|
func (c *Client) CreateServiceContainer(
|
||||||
|
ctx context.Context, serviceID string, spec api.ServiceSpec, containerName string,
|
||||||
|
) (container.CreateResponse, error) {
|
||||||
|
var resp container.CreateResponse
|
||||||
|
|
||||||
|
specBytes, err := json.Marshal(spec)
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("marshal service spec: %w", err)
|
||||||
|
}
|
||||||
|
grpcResp, err := c.grpcClient.CreateServiceContainer(ctx, &pb.CreateServiceContainerRequest{
|
||||||
|
ServiceId: serviceID,
|
||||||
|
ServiceSpec: specBytes,
|
||||||
|
ContainerName: containerName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
|
return resp, errdefs.NotFound(err)
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(grpcResp.Response, &resp); err != nil {
|
||||||
|
return resp, fmt.Errorf("unmarshal gRPC response: %w", err)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectServiceContainer returns the container information and service specification that was used to create the
|
||||||
|
// container with the given ID.
|
||||||
|
func (c *Client) InspectServiceContainer(ctx context.Context, id string) (api.ServiceContainer, error) {
|
||||||
|
var resp api.ServiceContainer
|
||||||
|
|
||||||
|
grpcResp, err := c.grpcClient.InspectServiceContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||||
|
if err != nil {
|
||||||
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
|
return resp, errdefs.NotFound(err)
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(grpcResp.Container, &resp.Container); err != nil {
|
||||||
|
return resp, fmt.Errorf("unmarshal container: %w", err)
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(grpcResp.ServiceSpec, &resp.ServiceSpec); err != nil {
|
||||||
|
return resp, fmt.Errorf("unmarshal service spec: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineServiceContainers struct {
|
||||||
|
Metadata *pb.Metadata
|
||||||
|
Containers []api.ServiceContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListServiceContainers returns all containers on requested machines that belong to the service with the given
|
||||||
|
// name or ID. If serviceNameOrID is empty, all service containers are returned.
|
||||||
|
func (c *Client) ListServiceContainers(
|
||||||
|
ctx context.Context, serviceNameOrID string, opts container.ListOptions,
|
||||||
|
) ([]MachineServiceContainers, error) {
|
||||||
|
optsBytes, err := json.Marshal(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal options: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.grpcClient.ListServiceContainers(ctx, &pb.ListServiceContainersRequest{
|
||||||
|
ServiceId: serviceNameOrID,
|
||||||
|
Options: optsBytes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
machineContainers := make([]MachineServiceContainers, len(resp.Messages))
|
||||||
|
for i, msg := range resp.Messages {
|
||||||
|
machineContainers[i].Metadata = msg.Metadata
|
||||||
|
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
containers := make([]api.ServiceContainer, len(msg.Containers))
|
||||||
|
for j, sc := range msg.Containers {
|
||||||
|
if err = json.Unmarshal(sc.Container, &containers[j].Container); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal container: %w", err)
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(sc.ServiceSpec, &containers[j].ServiceSpec); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal service spec: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
machineContainers[i].Containers = containers
|
||||||
|
}
|
||||||
|
|
||||||
|
return machineContainers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveServiceContainer stops (kills after grace period) and removes a service container with the given ID.
|
||||||
|
// A service container is a container that has been created with CreateServiceContainer.
|
||||||
|
func (c *Client) RemoveServiceContainer(ctx context.Context, id string, opts container.RemoveOptions) error {
|
||||||
|
optsBytes, err := json.Marshal(opts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal options: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.grpcClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
|
||||||
|
Id: id,
|
||||||
|
Options: optsBytes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
|
return errdefs.NotFound(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"github.com/docker/docker/api/types/events"
|
"github.com/docker/docker/api/types/events"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/api"
|
|
||||||
"uncloud/internal/machine/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"github.com/docker/docker/libnetwork/iptables"
|
"github.com/docker/docker/libnetwork/iptables"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnsureUncloudNetwork creates the Docker bridge network NetworkName with the provided machine subnet
|
// EnsureUncloudNetwork creates the Docker bridge network NetworkName with the provided machine subnet
|
||||||
|
|||||||
@@ -2,32 +2,54 @@ package docker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/docker/go-connections/nat"
|
||||||
|
"github.com/google/go-containerregistry/pkg/authn"
|
||||||
|
"github.com/google/go-containerregistry/pkg/name"
|
||||||
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/opencontainers/go-digest"
|
||||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
"io"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var fullDockerIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||||
|
|
||||||
// Server implements the gRPC Docker service that proxies requests to the Docker daemon.
|
// Server implements the gRPC Docker service that proxies requests to the Docker daemon.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
pb.UnimplementedDockerServer
|
pb.UnimplementedDockerServer
|
||||||
client *client.Client
|
client *client.Client
|
||||||
|
db *sqlx.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new Docker gRPC server with the provided Docker client.
|
// NewServer creates a new Docker gRPC server with the provided Docker client.
|
||||||
func NewServer(cli *client.Client) *Server {
|
func NewServer(cli *client.Client, db *sqlx.DB) *Server {
|
||||||
return &Server{client: cli}
|
return &Server{
|
||||||
|
client: cli,
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateContainer creates a new container based on the given configuration.
|
// CreateContainer creates a new container based on the given configuration.
|
||||||
@@ -149,13 +171,17 @@ func (s *Server) ListContainers(ctx context.Context, req *pb.ListContainersReque
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, err.Error())
|
return nil, status.Errorf(codes.Internal, err.Error())
|
||||||
}
|
}
|
||||||
containers := make([]types.ContainerJSON, len(containerSummaries))
|
containers := make([]types.ContainerJSON, 0, len(containerSummaries))
|
||||||
for i, cs := range containerSummaries {
|
for _, cs := range containerSummaries {
|
||||||
c, err := s.client.ContainerInspect(ctx, cs.ID)
|
c, err := s.client.ContainerInspect(ctx, cs.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if client.IsErrNotFound(err) {
|
||||||
|
// The listed container may have been removed while we were inspecting other containers.
|
||||||
|
continue
|
||||||
|
}
|
||||||
return nil, status.Errorf(codes.Internal, "inspect container %s: %v", cs.ID, err)
|
return nil, status.Errorf(codes.Internal, "inspect container %s: %v", cs.ID, err)
|
||||||
}
|
}
|
||||||
containers[i] = c
|
containers = append(containers, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
containersBytes, err := json.Marshal(containers)
|
containersBytes, err := json.Marshal(containers)
|
||||||
@@ -239,3 +265,337 @@ func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreaming
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InspectImage returns the image information for the given image ID.
|
||||||
|
func (s *Server) InspectImage(ctx context.Context, req *pb.InspectImageRequest) (*pb.InspectImageResponse, error) {
|
||||||
|
resp, _, err := s.client.ImageInspectWithRaw(ctx, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
if client.IsErrNotFound(err) {
|
||||||
|
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
respBytes, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.InspectImageResponse{
|
||||||
|
Messages: []*pb.Image{
|
||||||
|
{
|
||||||
|
Image: respBytes,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's Docker auth
|
||||||
|
// credentials if necessary.
|
||||||
|
func (s *Server) InspectRemoteImage(
|
||||||
|
_ context.Context, req *pb.InspectRemoteImageRequest,
|
||||||
|
) (*pb.InspectRemoteImageResponse, error) {
|
||||||
|
ref, err := name.ParseReference(req.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "parse image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
desc, err := remote.Get(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain))
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "fetch image manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
namedRef, err := reference.ParseNormalizedNamed(ref.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "parse image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var canonicalRef reference.Canonical
|
||||||
|
if _, ok := namedRef.(reference.Canonical); ok {
|
||||||
|
canonicalRef = namedRef.(reference.Canonical)
|
||||||
|
} else {
|
||||||
|
if canonicalRef, err = reference.WithDigest(namedRef, digest.Digest(desc.Digest.String())); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "add digest to image: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.InspectRemoteImageResponse{
|
||||||
|
Messages: []*pb.RemoteImage{
|
||||||
|
{
|
||||||
|
Reference: reference.FamiliarString(canonicalRef),
|
||||||
|
Manifest: desc.Manifest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateServiceContainer creates a new container for the service with the given specifications.
|
||||||
|
func (s *Server) CreateServiceContainer(
|
||||||
|
ctx context.Context, req *pb.CreateServiceContainerRequest,
|
||||||
|
) (*pb.CreateContainerResponse, error) {
|
||||||
|
if !api.ValidateServiceID(req.ServiceId) {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "invalid service ID: '%s'", req.ServiceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
var spec api.ServiceSpec
|
||||||
|
if err := json.Unmarshal(req.ServiceSpec, &spec); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal service spec: %v", err)
|
||||||
|
}
|
||||||
|
spec = spec.SetDefaults()
|
||||||
|
if err := spec.Validate(); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "invalid service spec: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
containerName := req.ContainerName
|
||||||
|
if containerName == "" {
|
||||||
|
suffix, err := secret.RandomAlphaNumeric(4)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate random suffix: %w", err)
|
||||||
|
}
|
||||||
|
containerName = fmt.Sprintf("%s-%s", spec.Name, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &container.Config{
|
||||||
|
Cmd: spec.Container.Command,
|
||||||
|
Entrypoint: spec.Container.Entrypoint,
|
||||||
|
Hostname: containerName,
|
||||||
|
Image: spec.Container.Image,
|
||||||
|
Labels: map[string]string{
|
||||||
|
api.LabelServiceID: req.ServiceId,
|
||||||
|
api.LabelServiceName: spec.Name,
|
||||||
|
api.LabelServiceMode: spec.Mode,
|
||||||
|
api.LabelManaged: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if spec.Mode == "" {
|
||||||
|
config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: do not set the ports as container labels once migrated to retrieve them from the spec in DB.
|
||||||
|
var err error
|
||||||
|
if len(spec.Ports) > 0 {
|
||||||
|
encodedPorts := make([]string, len(spec.Ports))
|
||||||
|
for i, p := range spec.Ports {
|
||||||
|
encodedPorts[i], err = p.String()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode service port spec: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Labels[api.LabelServicePorts] = strings.Join(encodedPorts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
portBindings := make(nat.PortMap)
|
||||||
|
for _, p := range spec.Ports {
|
||||||
|
if p.Mode != api.PortModeHost {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
port := nat.Port(fmt.Sprintf("%d/%s", p.ContainerPort, p.Protocol))
|
||||||
|
portBindings[port] = []nat.PortBinding{
|
||||||
|
{
|
||||||
|
HostPort: strconv.Itoa(int(p.PublishedPort)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if p.HostIP.IsValid() {
|
||||||
|
portBindings[port][0].HostIP = p.HostIP.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hostConfig := &container.HostConfig{
|
||||||
|
Binds: spec.Container.Volumes,
|
||||||
|
Init: spec.Container.Init,
|
||||||
|
PortBindings: portBindings,
|
||||||
|
// Always restart service containers if they exit or a machine restarts.
|
||||||
|
// For one-off containers and batch jobs we plan to use a different service type/mode.
|
||||||
|
RestartPolicy: container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyAlways,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
networkConfig := &network.NetworkingConfig{
|
||||||
|
EndpointsConfig: map[string]*network.EndpointSettings{
|
||||||
|
NetworkName: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.client.ContainerCreate(ctx, config, hostConfig, networkConfig, nil, containerName)
|
||||||
|
if err != nil {
|
||||||
|
if client.IsErrNotFound(err) {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
respBytes, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the container spec in the database or remove the container with its anonymous volumes if storing fails.
|
||||||
|
removeContainer := func() {
|
||||||
|
_ = s.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{RemoveVolumes: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
specBytes, err := json.Marshal(spec)
|
||||||
|
if err != nil {
|
||||||
|
removeContainer()
|
||||||
|
return nil, status.Errorf(codes.Internal, "marshal service spec: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO containers (id, service_id, service_spec) VALUES ($1, $2, $3)`,
|
||||||
|
resp.ID, req.ServiceId, string(specBytes)); err != nil {
|
||||||
|
removeContainer()
|
||||||
|
return nil, status.Errorf(codes.Internal, "store container in database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.CreateContainerResponse{Response: respBytes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectServiceContainer returns the container information and service specification that was used to create the
|
||||||
|
// container with the given ID.
|
||||||
|
func (s *Server) InspectServiceContainer(
|
||||||
|
ctx context.Context, req *pb.InspectContainerRequest,
|
||||||
|
) (*pb.ServiceContainer, error) {
|
||||||
|
ctr, err := s.client.ContainerInspect(ctx, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
if client.IsErrNotFound(err) {
|
||||||
|
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
ctrBytes, err := json.Marshal(ctr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var specBytes []byte
|
||||||
|
err = s.db.QueryRowContext(ctx, `SELECT service_spec FROM containers WHERE id = $1`, ctr.ID).Scan(&specBytes)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "service spec not found for container: '%s'", ctr.ID)
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, "get service spec for container '%s' from machine database: %v",
|
||||||
|
ctr.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.ServiceContainer{
|
||||||
|
Container: ctrBytes,
|
||||||
|
ServiceSpec: specBytes,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListServiceContainers returns all containers that belong to the service with the given name or ID.
|
||||||
|
// If req.ServiceId is empty, all service containers are returned.
|
||||||
|
func (s *Server) ListServiceContainers(
|
||||||
|
ctx context.Context, req *pb.ListServiceContainersRequest,
|
||||||
|
) (*pb.ListServiceContainersResponse, error) {
|
||||||
|
var opts container.ListOptions
|
||||||
|
if len(req.Options) > 0 {
|
||||||
|
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle filters separately because they implement custom JSON unmarshalling.
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(req.Options, &raw); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options to raw map: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filtersBytes, ok := raw["Filters"]; ok {
|
||||||
|
args, err := filters.FromJSON(string(filtersBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal filters: %v", err)
|
||||||
|
}
|
||||||
|
opts.Filters = args
|
||||||
|
} else {
|
||||||
|
opts.Filters = filters.NewArgs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only uncloud-managed containers that belong to some service.
|
||||||
|
opts.Filters.Add("label", api.LabelServiceID)
|
||||||
|
opts.Filters.Add("label", api.LabelManaged)
|
||||||
|
|
||||||
|
containerSummaries, err := s.client.ContainerList(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
containers := make([]*pb.ServiceContainer, 0, len(containerSummaries))
|
||||||
|
for _, cs := range containerSummaries {
|
||||||
|
if req.ServiceId != "" &&
|
||||||
|
cs.Labels[api.LabelServiceID] != req.ServiceId && cs.Labels[api.LabelServiceName] != req.ServiceId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ctr, err := s.client.ContainerInspect(ctx, cs.ID)
|
||||||
|
if err != nil {
|
||||||
|
if client.IsErrNotFound(err) {
|
||||||
|
// The listed container may have been removed while we were inspecting other containers.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, "inspect container %s: %v", cs.ID, err)
|
||||||
|
}
|
||||||
|
ctrBytes, err := json.Marshal(ctr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "marshal container: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var specBytes []byte
|
||||||
|
err = s.db.QueryRowContext(ctx, `SELECT service_spec FROM containers WHERE id = $1`, ctr.ID).Scan(&specBytes)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// If this happens, there is a bug in the code, or someone manually removed the container from the DB,
|
||||||
|
// or created a managed container out of band.
|
||||||
|
slog.Error("Service container not found in machine database.", "id", ctr.ID)
|
||||||
|
// Just ignore such a container to not fail the list operation as it's not easily recoverable.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, "get service spec for container '%s' from machine database: %v",
|
||||||
|
ctr.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
containers = append(containers, &pb.ServiceContainer{
|
||||||
|
Container: ctrBytes,
|
||||||
|
ServiceSpec: specBytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.ListServiceContainersResponse{
|
||||||
|
Messages: []*pb.MachineServiceContainers{
|
||||||
|
{
|
||||||
|
Containers: containers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveServiceContainer stops (kills after grace period) and removes a service container with the given ID.
|
||||||
|
// The difference between this method and RemoveContainer is that it also removes the container from the machine
|
||||||
|
// database.
|
||||||
|
func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveContainerRequest) (*emptypb.Empty, error) {
|
||||||
|
ctrID := req.Id
|
||||||
|
// If the ID is not a full Docker ID, inspect the container to get its full ID.
|
||||||
|
if !fullDockerIDRegex.MatchString(req.Id) {
|
||||||
|
ctr, err := s.client.ContainerInspect(ctx, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
if client.IsErrNotFound(err) {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
ctrID = ctr.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.RemoveContainer(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = s.db.ExecContext(ctx, `DELETE FROM containers WHERE id = $1`, ctrID); err != nil {
|
||||||
|
slog.Error("Failed to remove container from machine database.", "err", err, "id", ctrID)
|
||||||
|
// Do not return an error because the container has already been removed from the Docker daemon.
|
||||||
|
// The orphaned db record will be ignored and eventually cleaned up by the garbage collector.
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|||||||
+41
-31
@@ -5,32 +5,35 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/docker/docker/client"
|
|
||||||
"github.com/docker/go-connections/sockets"
|
|
||||||
"github.com/siderolabs/grpc-proxy/proxy"
|
|
||||||
"golang.org/x/sync/errgroup"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"uncloud/internal/corrosion"
|
|
||||||
"uncloud/internal/docker"
|
"github.com/docker/docker/client"
|
||||||
"uncloud/internal/fs"
|
"github.com/docker/go-connections/sockets"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/corrosion"
|
||||||
apiproxy "uncloud/internal/machine/api/proxy"
|
"github.com/psviderski/uncloud/internal/docker"
|
||||||
"uncloud/internal/machine/caddyfile"
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
"uncloud/internal/machine/cluster"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"uncloud/internal/machine/corroservice"
|
apiproxy "github.com/psviderski/uncloud/internal/machine/api/proxy"
|
||||||
machinedocker "uncloud/internal/machine/docker"
|
"github.com/psviderski/uncloud/internal/machine/caddyfile"
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||||
"uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/corroservice"
|
||||||
|
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/siderolabs/grpc-proxy/proxy"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -56,9 +59,9 @@ type Config struct {
|
|||||||
// DockerClient manages system and user containers using the local Docker daemon.
|
// DockerClient manages system and user containers using the local Docker daemon.
|
||||||
DockerClient *client.Client
|
DockerClient *client.Client
|
||||||
|
|
||||||
// CaddyfilePath specifies where the machine generates the Caddy reverse proxy configuration file for routing
|
// CaddyConfigPath specifies where the machine generates the Caddy reverse proxy configuration file for routing
|
||||||
// external traffic to service containers across the internal network. Default is DataDir/caddy/Caddyfile.
|
// external traffic to service containers across the internal network. Default is DataDir/caddy/caddy.json.
|
||||||
CaddyfilePath string
|
CaddyConfigPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDefaults returns a new Config with default values set where not provided.
|
// SetDefaults returns a new Config with default values set where not provided.
|
||||||
@@ -121,8 +124,8 @@ func (c *Config) SetDefaults() (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.CaddyfilePath == "" {
|
if cfg.CaddyConfigPath == "" {
|
||||||
cfg.CaddyfilePath = filepath.Join(cfg.DataDir, "caddy", "caddy.json")
|
cfg.CaddyConfigPath = filepath.Join(cfg.DataDir, "caddy", "caddy.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
@@ -208,7 +211,13 @@ func NewMachine(config *Config) (*Machine, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create Docker client: %w", err)
|
return nil, fmt.Errorf("create Docker client: %w", err)
|
||||||
}
|
}
|
||||||
dockerServer := machinedocker.NewServer(dockerCli)
|
|
||||||
|
dbFilePath := filepath.Join(config.DataDir, DBFileName)
|
||||||
|
db, err := NewDB(dbFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("init machine database: %w", err)
|
||||||
|
}
|
||||||
|
dockerServer := machinedocker.NewServer(dockerCli, db)
|
||||||
|
|
||||||
// Init a local gRPC proxy server that proxies requests to the local or remote machine API servers.
|
// Init a local gRPC proxy server that proxies requests to the local or remote machine API servers.
|
||||||
proxyDirector := apiproxy.NewDirector(config.MachineSockPath, APIPort)
|
proxyDirector := apiproxy.NewDirector(config.MachineSockPath, APIPort)
|
||||||
@@ -359,7 +368,7 @@ func (m *Machine) Run(ctx context.Context) error {
|
|||||||
|
|
||||||
// Create a new Caddyfile controller for managing the Caddy reverse proxy configuration.
|
// Create a new Caddyfile controller for managing the Caddy reverse proxy configuration.
|
||||||
// It will also serve the current machine ID at /.uncloud-verify to verify Caddy reachability.
|
// It will also serve the current machine ID at /.uncloud-verify to verify Caddy reachability.
|
||||||
caddyfileCtrl, err := caddyfile.NewController(m.store, m.config.CaddyfilePath, m.state.ID)
|
caddyfileCtrl, err := caddyfile.NewController(m.store, m.config.CaddyConfigPath, m.state.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create Caddyfile controller: %w", err)
|
return fmt.Errorf("create Caddyfile controller: %w", err)
|
||||||
}
|
}
|
||||||
@@ -540,7 +549,7 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
|
|||||||
}
|
}
|
||||||
publicIP, pubIPErr := network.GetPublicIP()
|
publicIP, pubIPErr := network.GetPublicIP()
|
||||||
// Ignore the error if failed to get the public IP using API services.
|
// Ignore the error if failed to get the public IP using API services.
|
||||||
if pubIPErr == nil {
|
if pubIPErr == nil && !slices.Contains(ips, publicIP) {
|
||||||
ips = append(ips, publicIP)
|
ips = append(ips, publicIP)
|
||||||
}
|
}
|
||||||
endpoints := make([]*pb.IPPort, len(ips))
|
endpoints := make([]*pb.IPPort, len(ips))
|
||||||
@@ -682,7 +691,7 @@ func (m *Machine) Token(_ context.Context, _ *emptypb.Empty) (*pb.TokenResponse,
|
|||||||
}
|
}
|
||||||
publicIP, err := network.GetPublicIP()
|
publicIP, err := network.GetPublicIP()
|
||||||
// Ignore the error if failed to get the public IP using API services.
|
// Ignore the error if failed to get the public IP using API services.
|
||||||
if err == nil {
|
if err == nil && !slices.Contains(ips, publicIP) {
|
||||||
ips = append(ips, publicIP)
|
ips = append(ips, publicIP)
|
||||||
}
|
}
|
||||||
endpoints := make([]netip.AddrPort, len(ips))
|
endpoints := make([]netip.AddrPort, len(ips))
|
||||||
@@ -743,10 +752,11 @@ func (m *Machine) InspectService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctr := api.ServiceContainer{Container: records[0].Container}
|
||||||
svc := &pb.Service{
|
svc := &pb.Service{
|
||||||
Id: records[0].Container.ServiceID(),
|
Id: ctr.ServiceID(),
|
||||||
Name: records[0].Container.ServiceName(),
|
Name: ctr.ServiceName(),
|
||||||
Mode: records[0].Container.ServiceMode(),
|
Mode: ctr.ServiceMode(),
|
||||||
Containers: containers,
|
Containers: containers,
|
||||||
}
|
}
|
||||||
return &pb.InspectServiceResponse{Service: svc}, nil
|
return &pb.InspectServiceResponse{Service: svc}, nil
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"uncloud/internal/machine/caddyfile"
|
"github.com/psviderski/uncloud/internal/machine/caddyfile"
|
||||||
"uncloud/internal/machine/corroservice"
|
"github.com/psviderski/uncloud/internal/machine/corroservice"
|
||||||
"uncloud/internal/machine/docker"
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package network
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"uncloud/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package network
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"uncloud/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MachineIP returns the IP address of the machine which is the first address in the subnet.
|
// MachineIP returns the IP address of the machine which is the first address in the subnet.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WireGuardNetwork struct {
|
type WireGuardNetwork struct {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
sq "github.com/Masterminds/squirrel"
|
sq "github.com/Masterminds/squirrel"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/api"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"google.golang.org/protobuf/encoding/protojson"
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"uncloud/internal/corrosion"
|
"github.com/psviderski/uncloud/internal/corrosion"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"uncloud/internal/machine/cluster"
|
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package ucind
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"uncloud/internal/cli/config"
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConfigUpdater struct {
|
type ConfigUpdater struct {
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import (
|
|||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
dockerclient "github.com/docker/docker/client"
|
dockerclient "github.com/docker/docker/client"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/connector"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/cli/client"
|
|
||||||
"uncloud/internal/cli/client/connector"
|
|
||||||
"uncloud/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client interface {
|
||||||
|
ContainerClient
|
||||||
|
DNSClient
|
||||||
|
ImageClient
|
||||||
|
MachineClient
|
||||||
|
ServiceClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContainerClient interface {
|
||||||
|
CreateContainer(
|
||||||
|
ctx context.Context, serviceID string, spec ServiceSpec, machineID string,
|
||||||
|
) (container.CreateResponse, error)
|
||||||
|
InspectContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) (MachineServiceContainer, error)
|
||||||
|
RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
|
||||||
|
StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error
|
||||||
|
StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSClient interface {
|
||||||
|
GetDomain(ctx context.Context) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageClient interface {
|
||||||
|
InspectImage(ctx context.Context, id string) ([]MachineImage, error)
|
||||||
|
InspectRemoteImage(ctx context.Context, id string) ([]MachineRemoteImage, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineClient interface {
|
||||||
|
InspectMachine(ctx context.Context, id string) (*pb.MachineMember, error)
|
||||||
|
ListMachines(ctx context.Context) ([]*pb.MachineMember, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceClient interface {
|
||||||
|
InspectService(ctx context.Context, id string) (Service, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProxyMachinesContext returns a new context that proxies gRPC requests to the specified machines.
|
||||||
|
// If namesOrIDs is nil, all machines are included.
|
||||||
|
func ProxyMachinesContext(ctx context.Context, cli MachineClient, namesOrIDs []string) (context.Context, error) {
|
||||||
|
machines, err := cli.ListMachines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
md := metadata.New(nil)
|
||||||
|
for _, m := range machines {
|
||||||
|
if namesOrIDs == nil ||
|
||||||
|
slices.Contains(namesOrIDs, m.Machine.Name) || slices.Contains(namesOrIDs, m.Machine.Id) {
|
||||||
|
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
|
||||||
|
md.Append("machines", machineIP.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata.NewOutgoingContext(ctx, md), nil
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/docker/docker/api/types"
|
|
||||||
"github.com/docker/go-units"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/go-units"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -20,70 +22,6 @@ type Container struct {
|
|||||||
types.ContainerJSON
|
types.ContainerJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// NameWithoutSlash returns the container name without the leading slash.
|
|
||||||
// TODO: modify Name in original ContainerJSON structure when inspecting a Docker container and get rid of this method.
|
|
||||||
func (c *Container) NameWithoutSlash() string {
|
|
||||||
return c.Name[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceID returns the ID of the service this container belongs to.
|
|
||||||
func (c *Container) ServiceID() string {
|
|
||||||
return c.Config.Labels[LabelServiceID]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceName returns the name of the service this container belongs to.
|
|
||||||
func (c *Container) ServiceName() string {
|
|
||||||
return c.Config.Labels[LabelServiceName]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceMode returns the replication mode of the service this container belongs to.
|
|
||||||
func (c *Container) ServiceMode() string {
|
|
||||||
return c.Config.Labels[LabelServiceMode]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServicePorts returns the ports this container publishes as part of its service.
|
|
||||||
func (c *Container) ServicePorts() ([]PortSpec, error) {
|
|
||||||
encoded, ok := c.Config.Labels[LabelServicePorts]
|
|
||||||
if !ok {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(encoded) == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
publishPorts := strings.Split(encoded, ",")
|
|
||||||
ports := make([]PortSpec, len(publishPorts))
|
|
||||||
for i, p := range publishPorts {
|
|
||||||
port, err := ParsePortSpec(strings.TrimSpace(p))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ports[i] = port
|
|
||||||
}
|
|
||||||
|
|
||||||
return ports, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceSpec constructs a service spec from the container's configuration.
|
|
||||||
func (c *Container) ServiceSpec() (ServiceSpec, error) {
|
|
||||||
ports, err := c.ServicePorts()
|
|
||||||
if err != nil {
|
|
||||||
return ServiceSpec{}, fmt.Errorf("get service ports: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ServiceSpec{
|
|
||||||
Container: ContainerSpec{
|
|
||||||
Command: c.Config.Cmd,
|
|
||||||
Image: c.Config.Image,
|
|
||||||
Init: c.HostConfig.Init,
|
|
||||||
Volumes: c.HostConfig.Binds,
|
|
||||||
},
|
|
||||||
Mode: c.ServiceMode(),
|
|
||||||
Name: c.ServiceName(),
|
|
||||||
Ports: ports,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Healthy determines if the container is running and healthy.
|
// Healthy determines if the container is running and healthy.
|
||||||
// A running container with no health check configured is considered healthy.
|
// A running container with no health check configured is considered healthy.
|
||||||
func (c *Container) Healthy() bool {
|
func (c *Container) Healthy() bool {
|
||||||
@@ -152,8 +90,71 @@ func (c *Container) HumanState() (string, error) {
|
|||||||
c.State.ExitCode, units.HumanDuration(time.Now().UTC().Sub(finishedAt))), nil
|
c.State.ExitCode, units.HumanDuration(time.Now().UTC().Sub(finishedAt))), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Container) UnmarshalJSON(data []byte) error {
|
||||||
|
// A temporary type that's identical to Container but doesn't have the UnmarshalJSON method.
|
||||||
|
type ContainerAlias Container
|
||||||
|
|
||||||
|
var temp ContainerAlias
|
||||||
|
if err := json.Unmarshal(data, &temp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*c = Container(temp)
|
||||||
|
c.Name = strings.TrimPrefix(c.Name, "/")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceContainer struct {
|
||||||
|
Container
|
||||||
|
ServiceSpec ServiceSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineContainer struct {
|
||||||
|
MachineID string
|
||||||
|
Container Container
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceID returns the ID of the service this container belongs to.
|
||||||
|
func (c *ServiceContainer) ServiceID() string {
|
||||||
|
return c.Config.Labels[LabelServiceID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceName returns the name of the service this container belongs to.
|
||||||
|
func (c *ServiceContainer) ServiceName() string {
|
||||||
|
return c.Config.Labels[LabelServiceName]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceMode returns the replication mode of the service this container belongs to.
|
||||||
|
func (c *ServiceContainer) ServiceMode() string {
|
||||||
|
return c.Config.Labels[LabelServiceMode]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServicePorts returns the ports this container publishes as part of its service.
|
||||||
|
func (c *ServiceContainer) ServicePorts() ([]PortSpec, error) {
|
||||||
|
encoded, ok := c.Config.Labels[LabelServicePorts]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(encoded) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
publishPorts := strings.Split(encoded, ",")
|
||||||
|
ports := make([]PortSpec, len(publishPorts))
|
||||||
|
for i, p := range publishPorts {
|
||||||
|
port, err := ParsePortSpec(strings.TrimSpace(p))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ports[i] = port
|
||||||
|
}
|
||||||
|
|
||||||
|
return ports, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ConflictingServicePorts returns a list of service ports that conflict with the given ports.
|
// ConflictingServicePorts returns a list of service ports that conflict with the given ports.
|
||||||
func (c *Container) ConflictingServicePorts(ports []PortSpec) ([]PortSpec, error) {
|
func (c *ServiceContainer) ConflictingServicePorts(ports []PortSpec) ([]PortSpec, error) {
|
||||||
svcPorts, err := c.ServicePorts()
|
svcPorts, err := c.ServicePorts()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get service ports: %w", err)
|
return nil, fmt.Errorf("get service ports: %w", err)
|
||||||
@@ -1,60 +1,15 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"net/netip"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestContainer_ServiceSpec(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
init := true
|
|
||||||
ctr := &Container{ContainerJSON: types.ContainerJSON{
|
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
|
||||||
HostConfig: &container.HostConfig{
|
|
||||||
Binds: []string{"/host/path:/container/path"},
|
|
||||||
Init: &init,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Config: &container.Config{
|
|
||||||
Cmd: []string{"/app/server"},
|
|
||||||
Image: "app:latest",
|
|
||||||
Labels: map[string]string{
|
|
||||||
LabelServiceID: "test-service-id",
|
|
||||||
LabelServiceName: "test-service-name",
|
|
||||||
LabelServicePorts: "app.example.com:8000/https",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
|
|
||||||
expectedSpec := ServiceSpec{
|
|
||||||
Container: ContainerSpec{
|
|
||||||
Command: []string{"/app/server"},
|
|
||||||
Image: "app:latest",
|
|
||||||
Init: &init,
|
|
||||||
Volumes: []string{"/host/path:/container/path"},
|
|
||||||
},
|
|
||||||
Name: "test-service-name",
|
|
||||||
Ports: []PortSpec{
|
|
||||||
{
|
|
||||||
Hostname: "app.example.com",
|
|
||||||
ContainerPort: 8000,
|
|
||||||
Protocol: ProtocolHTTPS,
|
|
||||||
Mode: PortModeIngress,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
spec, err := ctr.ServiceSpec()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, reflect.DeepEqual(spec, expectedSpec))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContainer_Healthy(t *testing.T) {
|
func TestContainer_Healthy(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -325,13 +280,13 @@ func TestContainer_ConflictingServicePorts(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
ctr := &Container{ContainerJSON: types.ContainerJSON{
|
ctr := &ServiceContainer{Container: Container{ContainerJSON: types.ContainerJSON{
|
||||||
Config: &container.Config{
|
Config: &container.Config{
|
||||||
Labels: map[string]string{
|
Labels: map[string]string{
|
||||||
LabelServicePorts: tt.containerPorts,
|
LabelServicePorts: tt.containerPorts,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}}
|
||||||
|
|
||||||
got, err := ctr.ConflictingServicePorts(tt.checkPorts)
|
got, err := ctr.ConflictingServicePorts(tt.checkPorts)
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MachineImage struct {
|
||||||
|
Metadata *pb.Metadata
|
||||||
|
Image types.ImageInspect
|
||||||
|
}
|
||||||
|
|
||||||
|
// MachineRemoteImage represents an image in a remote registry fetched by a particular machine.
|
||||||
|
type MachineRemoteImage struct {
|
||||||
|
Metadata *pb.Metadata
|
||||||
|
Image RemoteImage
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteImage represents an image in a remote registry. The canonical reference includes the image digest.
|
||||||
|
// Either IndexManifest or ImageManifest must be set depending on whether the reference points to an index
|
||||||
|
// (multi-platform image) or a single-platform image.
|
||||||
|
type RemoteImage struct {
|
||||||
|
Reference reference.Canonical
|
||||||
|
IndexManifest *v1.Index
|
||||||
|
ImageManifest *v1.Manifest
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -261,3 +262,31 @@ func validateHostname(hostname string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PortsEqual returns true if the two port sets are equal. The order of the ports is not important.
|
||||||
|
func PortsEqual(a, b []PortSpec) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
aSerialised := make([]string, len(a))
|
||||||
|
bSerialised := make([]string, len(b))
|
||||||
|
|
||||||
|
for i := range a {
|
||||||
|
aSerialised[i], err = a[i].String()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
bSerialised[i], err = b[i].String()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(aSerialised)
|
||||||
|
slices.Sort(bSerialised)
|
||||||
|
|
||||||
|
return slices.Equal(aSerialised, bSerialised)
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ServiceModeReplicated = "replicated"
|
||||||
|
ServiceModeGlobal = "global"
|
||||||
|
|
||||||
|
// PullPolicyAlways means the image is always pulled from the registry.
|
||||||
|
PullPolicyAlways = "always"
|
||||||
|
// PullPolicyMissing means the image is pulled from the registry only if it's not available on the machine where
|
||||||
|
// a container is started. This is the default pull policy.
|
||||||
|
// TODO: make each machine aware of the images on other machines and it possible to pull from them.
|
||||||
|
// Pull from the registry only if the image is missing on all machines.
|
||||||
|
PullPolicyMissing = "missing"
|
||||||
|
// PullPolicyNever means the image is never pulled from the registry. A service with this pull policy can only be
|
||||||
|
// deployed to machines where the image is already available.
|
||||||
|
// TODO: see the TODO above for PullPolicyMissing. Pull from other machines in the cluster if available.
|
||||||
|
PullPolicyNever = "never"
|
||||||
|
)
|
||||||
|
|
||||||
|
var serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
|
||||||
|
|
||||||
|
func ValidateServiceID(id string) bool {
|
||||||
|
return serviceIDRegexp.MatchString(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceSpec defines the desired state of a service.
|
||||||
|
// ATTENTION: after changing this struct, verify if deploy.EvalContainerSpecChange needs to be updated.
|
||||||
|
type ServiceSpec struct {
|
||||||
|
Container ContainerSpec
|
||||||
|
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
|
||||||
|
Mode string
|
||||||
|
Name string
|
||||||
|
// Ports defines what service ports to publish to make the service accessible outside the cluster.
|
||||||
|
Ports []PortSpec
|
||||||
|
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
|
||||||
|
Replicas uint `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceSpec) SetDefaults() ServiceSpec {
|
||||||
|
spec := s.Clone()
|
||||||
|
|
||||||
|
if spec.Mode == "" {
|
||||||
|
spec.Mode = ServiceModeReplicated
|
||||||
|
}
|
||||||
|
// Ensure the replicated service has at least one replica.
|
||||||
|
if spec.Mode == ServiceModeReplicated && spec.Replicas == 0 {
|
||||||
|
spec.Replicas = 1
|
||||||
|
}
|
||||||
|
spec.Container = spec.Container.SetDefaults()
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceSpec) Validate() error {
|
||||||
|
if err := s.Container.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch s.Mode {
|
||||||
|
case "", ServiceModeGlobal, ServiceModeReplicated:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid mode: %q", s.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: validate the service name is a valid DNS label.
|
||||||
|
|
||||||
|
for _, p := range s.Ports {
|
||||||
|
if (p.Mode == "" || p.Mode == PortModeIngress) &&
|
||||||
|
p.Protocol != ProtocolHTTP && p.Protocol != ProtocolHTTPS {
|
||||||
|
return fmt.Errorf("unsupported protocol for ingress port %d: %s", p.ContainerPort, p.Protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: validate there is no conflict between ports.
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceSpec) Clone() ServiceSpec {
|
||||||
|
spec := *s
|
||||||
|
|
||||||
|
if s.Ports != nil {
|
||||||
|
spec.Ports = make([]PortSpec, len(s.Ports))
|
||||||
|
copy(spec.Ports, s.Ports)
|
||||||
|
}
|
||||||
|
spec.Container = s.Container.Clone()
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainerSpec defines the desired state of a container in a service.
|
||||||
|
// ATTENTION: after changing this struct, verify if deploy.EvalContainerSpecChange needs to be updated.
|
||||||
|
type ContainerSpec struct {
|
||||||
|
// Command overrides the default CMD of the image to be executed when running a container.
|
||||||
|
Command []string
|
||||||
|
// Entrypoint overrides the default ENTRYPOINT of the image.
|
||||||
|
Entrypoint []string
|
||||||
|
Image string
|
||||||
|
// Run a custom init inside the container. If nil, use the daemon's configured settings.
|
||||||
|
Init *bool
|
||||||
|
// PullPolicy determines when to pull the image from the registry or use the image already available in the cluster.
|
||||||
|
// Default is PullPolicyMissing if empty.
|
||||||
|
PullPolicy string
|
||||||
|
// List of volumes to bind mount into the container.
|
||||||
|
Volumes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefaults returns a copy of the container spec with default values set.
|
||||||
|
func (s *ContainerSpec) SetDefaults() ContainerSpec {
|
||||||
|
spec := s.Clone()
|
||||||
|
if spec.PullPolicy == "" {
|
||||||
|
spec.PullPolicy = PullPolicyMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ContainerSpec) Validate() error {
|
||||||
|
if _, err := reference.ParseDockerRef(s.Image); err != nil {
|
||||||
|
return fmt.Errorf("invalid image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ContainerSpec) Equals(spec ContainerSpec) bool {
|
||||||
|
orig := s.SetDefaults()
|
||||||
|
spec = spec.SetDefaults()
|
||||||
|
|
||||||
|
slices.Sort(orig.Volumes)
|
||||||
|
slices.Sort(spec.Volumes)
|
||||||
|
|
||||||
|
return reflect.DeepEqual(orig, spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ContainerSpec) Clone() ContainerSpec {
|
||||||
|
spec := *s
|
||||||
|
|
||||||
|
if s.Command != nil {
|
||||||
|
spec.Command = make([]string, len(s.Command))
|
||||||
|
copy(spec.Command, s.Command)
|
||||||
|
}
|
||||||
|
if s.Entrypoint != nil {
|
||||||
|
spec.Entrypoint = make([]string, len(s.Entrypoint))
|
||||||
|
copy(spec.Entrypoint, s.Entrypoint)
|
||||||
|
}
|
||||||
|
if s.Volumes != nil {
|
||||||
|
spec.Volumes = make([]string, len(s.Volumes))
|
||||||
|
copy(spec.Volumes, s.Volumes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Mode string
|
||||||
|
Containers []MachineServiceContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineServiceContainer struct {
|
||||||
|
MachineID string
|
||||||
|
Container ServiceContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoints returns the exposed HTTP and HTTPS endpoints of the service.
|
||||||
|
func (s *Service) Endpoints() []string {
|
||||||
|
endpoints := make(map[string]struct{})
|
||||||
|
|
||||||
|
// Container specs may differ between containers in the same service, e.g. during a rolling update,
|
||||||
|
// so we need to collect all unique endpoints.
|
||||||
|
for _, ctr := range s.Containers {
|
||||||
|
ports, err := ctr.Container.ServicePorts()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, port := range ports {
|
||||||
|
protocol := ""
|
||||||
|
switch port.Protocol {
|
||||||
|
case ProtocolHTTP:
|
||||||
|
protocol = "http"
|
||||||
|
case ProtocolHTTPS:
|
||||||
|
protocol = "https"
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if port.Hostname == "" {
|
||||||
|
// There shouldn't be http(s) ports without a hostname but just in case ignore them.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s://%s", protocol, port.Hostname)
|
||||||
|
if port.PublishedPort != 0 {
|
||||||
|
// For non-standard ports (80/443), include the port in the URL.
|
||||||
|
if !(port.Protocol == ProtocolHTTP && port.PublishedPort == 80) &&
|
||||||
|
!(port.Protocol == ProtocolHTTPS && port.PublishedPort == 443) {
|
||||||
|
endpoint += fmt.Sprintf(":%d", port.PublishedPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint += fmt.Sprintf(" → :%d", port.ContainerPort)
|
||||||
|
endpoints[endpoint] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slices.Sorted(maps.Keys(endpoints))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ServiceFromProto(s *pb.Service) (Service, error) {
|
||||||
|
var err error
|
||||||
|
containers := make([]MachineServiceContainer, len(s.Containers))
|
||||||
|
for i, sc := range s.Containers {
|
||||||
|
containers[i], err = machineContainerFromProto(sc)
|
||||||
|
if err != nil {
|
||||||
|
return Service{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Service{
|
||||||
|
ID: s.Id,
|
||||||
|
Name: s.Name,
|
||||||
|
Mode: s.Mode,
|
||||||
|
Containers: containers,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func machineContainerFromProto(sc *pb.Service_Container) (MachineServiceContainer, error) {
|
||||||
|
var c Container
|
||||||
|
if err := json.Unmarshal(sc.Container, &c); err != nil {
|
||||||
|
return MachineServiceContainer{}, fmt.Errorf("unmarshal container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MachineServiceContainer{
|
||||||
|
MachineID: sc.MachineId,
|
||||||
|
Container: ServiceContainer{Container: c},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@ import (
|
|||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/google/go-containerregistry/pkg/name"
|
"github.com/google/go-containerregistry/pkg/name"
|
||||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
"regexp"
|
"regexp"
|
||||||
"uncloud/internal/api"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -21,8 +22,8 @@ var caddyImageTagRegex = regexp.MustCompile(`^2\.\d+\.\d+$`)
|
|||||||
// NewCaddyDeployment creates a new deployment for a Caddy reverse proxy service.
|
// NewCaddyDeployment creates a new deployment for a Caddy reverse proxy service.
|
||||||
// The service is deployed in global mode to all machines in the cluster. If the image is not provided, the latest
|
// The service is deployed in global mode to all machines in the cluster. If the image is not provided, the latest
|
||||||
// version of the official Caddy Docker image is used.
|
// version of the official Caddy Docker image is used.
|
||||||
func (cli *Client) NewCaddyDeployment(image string, filter MachineFilter) (*Deployment, error) {
|
func (cli *Client) NewCaddyDeployment(image string, filter deploy.MachineFilter) (*deploy.Deployment, error) {
|
||||||
latest, err := latestCaddyImage()
|
latest, err := LatestCaddyImage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("look up latest Caddy image: %w", err)
|
return nil, fmt.Errorf("look up latest Caddy image: %w", err)
|
||||||
}
|
}
|
||||||
@@ -31,7 +32,6 @@ func (cli *Client) NewCaddyDeployment(image string, filter MachineFilter) (*Depl
|
|||||||
image = reference.FamiliarString(latest)
|
image = reference.FamiliarString(latest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: set restart policy to always. https://github.com/psviderski/uncloud/issues/26
|
|
||||||
spec := api.ServiceSpec{
|
spec := api.ServiceSpec{
|
||||||
Container: api.ContainerSpec{
|
Container: api.ContainerSpec{
|
||||||
Command: []string{"caddy", "run", "-c", "/config/caddy.json", "--watch"},
|
Command: []string{"caddy", "run", "-c", "/config/caddy.json", "--watch"},
|
||||||
@@ -56,12 +56,12 @@ func (cli *Client) NewCaddyDeployment(image string, filter MachineFilter) (*Depl
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return cli.NewDeployment(spec, &RollingStrategy{MachineFilter: filter})
|
return cli.NewDeployment(spec, &deploy.RollingStrategy{MachineFilter: filter}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// latestCaddyImage returns the latest image of the official Caddy Docker image on Docker Hub.
|
// LatestCaddyImage returns the latest image of the official Caddy Docker image on Docker Hub.
|
||||||
// The latest image is determined by the latest version tag 2.x.x.
|
// The latest image is determined by the latest version tag 2.x.x.
|
||||||
func latestCaddyImage() (reference.NamedTagged, error) {
|
func LatestCaddyImage() (reference.NamedTagged, error) {
|
||||||
repo, err := name.NewRepository(CaddyImage)
|
repo, err := name.NewRepository(CaddyImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse image: %w", err)
|
return nil, fmt.Errorf("parse image: %w", err)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLatestCaddyImage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
image, err := LatestCaddyImage()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, reference.FamiliarString(image))
|
||||||
|
}
|
||||||
@@ -4,16 +4,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/docker/cli/cli/streams"
|
"github.com/docker/cli/cli/streams"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
"os"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
"uncloud/internal/machine/docker"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNotFound = errors.New("not found")
|
|
||||||
|
|
||||||
// Client is a client for the machine API.
|
// Client is a client for the machine API.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
connector Connector
|
connector Connector
|
||||||
@@ -26,6 +26,8 @@ type Client struct {
|
|||||||
Docker *docker.Client
|
Docker *docker.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ api.Client = (*Client)(nil)
|
||||||
|
|
||||||
// Connector is an interface for establishing a connection to the machine API.
|
// Connector is an interface for establishing a connection to the machine API.
|
||||||
type Connector interface {
|
type Connector interface {
|
||||||
Connect(ctx context.Context) (*grpc.ClientConn, error)
|
Connect(ctx context.Context) (*grpc.ClientConn, error)
|
||||||
@@ -2,8 +2,9 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (cli *Client) InspectMachine(ctx context.Context, id string) (*pb.MachineMember, error) {
|
func (cli *Client) InspectMachine(ctx context.Context, id string) (*pb.MachineMember, error) {
|
||||||
@@ -18,7 +19,7 @@ func (cli *Client) InspectMachine(ctx context.Context, id string) (*pb.MachineMe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, ErrNotFound
|
return nil, api.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *Client) ListMachines(ctx context.Context) ([]*pb.MachineMember, error) {
|
func (cli *Client) ListMachines(ctx context.Context) ([]*pb.MachineMember, error) {
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/compose-spec/compose-go/v2/graph"
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client interface {
|
||||||
|
api.DNSClient
|
||||||
|
deploy.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type Deployment struct {
|
||||||
|
Client Client
|
||||||
|
Project *types.Project
|
||||||
|
SpecResolver *deploy.ServiceSpecResolver
|
||||||
|
plan *deploy.SequenceOperation
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*Deployment, error) {
|
||||||
|
domain, err := cli.GetDomain(ctx)
|
||||||
|
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("get cluster domain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver := &deploy.ServiceSpecResolver{
|
||||||
|
// If the domain is not found (not reserved), an empty domain is used for the resolver.
|
||||||
|
ClusterDomain: domain,
|
||||||
|
// TODO: provide an image resolver.
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Deployment{
|
||||||
|
Client: cli,
|
||||||
|
Project: project,
|
||||||
|
SpecResolver: resolver,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error) {
|
||||||
|
if d.plan != nil {
|
||||||
|
return *d.plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := deploy.SequenceOperation{}
|
||||||
|
err := graph.InDependencyOrder(ctx, d.Project,
|
||||||
|
func(ctx context.Context, name string, _ types.ServiceConfig) error {
|
||||||
|
spec, err := d.ServiceSpec(name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("convert compose service '%s' to service spec: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: properly handle depends_on conditions in the service deployment plan as the first operation.
|
||||||
|
deployment := deploy.NewDeployment(d.Client, spec, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create deployment for service '%s': %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
servicePlan, err := deployment.Plan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create deployment plan for service '%s': %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip no-op (up-to-date) service plans.
|
||||||
|
if len(servicePlan.Operations) > 0 {
|
||||||
|
plan.Operations = append(plan.Operations, &servicePlan)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
d.plan = &plan
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceSpec returns the service specification for the given compose service that is ready for deployment.
|
||||||
|
func (d *Deployment) ServiceSpec(name string) (api.ServiceSpec, error) {
|
||||||
|
service, err := d.Project.GetService(name)
|
||||||
|
if err != nil {
|
||||||
|
return api.ServiceSpec{}, fmt.Errorf("get config for compose service '%s': %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
spec, err := ServiceSpecFromCompose(name, service)
|
||||||
|
if err != nil {
|
||||||
|
return spec, fmt.Errorf("convert compose service '%s' to service spec: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: resolve the image to a digest and supported platforms using an image resolver that broadcasts requests
|
||||||
|
// to all machines in the cluster. If service.PullPolicy is "missing":
|
||||||
|
// - Broadcast request if any machine contains a particular image and resolve it to image@digest.
|
||||||
|
// - If not found, broadcast request to resolve an image using a registry, and resolve it to image@digest.
|
||||||
|
// TODO: configure placement filter based on the supported platforms of the image.
|
||||||
|
|
||||||
|
// TODO: maybe instantiate ImageResolver here based on PullPolicy of each service?
|
||||||
|
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Deployment) Run(ctx context.Context) error {
|
||||||
|
plan, err := d.Plan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create plan: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan.Execute(ctx, d.Client)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
const PortsExtensionKey = "x-ports"
|
||||||
|
|
||||||
|
type PortsSource []string
|
||||||
|
|
||||||
|
// TransformServicesPortsExtension transforms the ports extension of all services in the project by replacing a string
|
||||||
|
// representation of each port with a parsed PortSpec.
|
||||||
|
func transformServicesPortsExtension(project *types.Project) (*types.Project, error) {
|
||||||
|
return project.WithServicesTransform(func(name string, service types.ServiceConfig) (types.ServiceConfig, error) {
|
||||||
|
ports, ok := service.Extensions[PortsExtensionKey].(PortsSource)
|
||||||
|
if !ok {
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
specs, err := transformPortsExtension(ports)
|
||||||
|
if err != nil {
|
||||||
|
return service, err
|
||||||
|
}
|
||||||
|
|
||||||
|
service.Extensions[PortsExtensionKey] = specs
|
||||||
|
return service, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func transformPortsExtension(ports PortsSource) ([]api.PortSpec, error) {
|
||||||
|
var specs []api.PortSpec
|
||||||
|
for _, port := range ports {
|
||||||
|
spec, err := api.ParsePortSpec(port)
|
||||||
|
if err != nil {
|
||||||
|
return specs, fmt.Errorf("parse port %q: %w", port, err)
|
||||||
|
}
|
||||||
|
specs = append(specs, spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
return specs, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
// TODO: make compose, cli, and api packages public.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LoadProject(ctx context.Context, paths []string) (*types.Project, error) {
|
||||||
|
options, err := composecli.NewProjectOptions(
|
||||||
|
paths,
|
||||||
|
// First apply os.Environment, always wins.
|
||||||
|
composecli.WithOsEnv,
|
||||||
|
// Read dot env file to populate project environment.
|
||||||
|
composecli.WithDotEnv,
|
||||||
|
// Get compose file path set by COMPOSE_FILE.
|
||||||
|
composecli.WithConfigFileEnv,
|
||||||
|
// If none was selected, get default compose-ports-long.yaml file from current dir or parent folders.
|
||||||
|
composecli.WithDefaultConfigPath,
|
||||||
|
composecli.WithExtension(PortsExtensionKey, PortsSource{}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create compose parser options: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := options.LoadProject(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if project, err = transformServicesPortsExtension(project); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return project, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ServiceSpecFromCompose(name string, service types.ServiceConfig) (api.ServiceSpec, error) {
|
||||||
|
pullPolicy := ""
|
||||||
|
switch service.PullPolicy {
|
||||||
|
case types.PullPolicyAlways:
|
||||||
|
pullPolicy = api.PullPolicyAlways
|
||||||
|
case "", types.PullPolicyMissing, types.PullPolicyIfNotPresent:
|
||||||
|
pullPolicy = api.PullPolicyMissing
|
||||||
|
case types.PullPolicyNever:
|
||||||
|
pullPolicy = api.PullPolicyNever
|
||||||
|
default:
|
||||||
|
return api.ServiceSpec{}, fmt.Errorf("unsupported pull policy: '%s'", service.PullPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := api.ServiceSpec{
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Command: service.Command,
|
||||||
|
Image: service.Image,
|
||||||
|
Init: service.Init,
|
||||||
|
PullPolicy: pullPolicy,
|
||||||
|
// TODO: env
|
||||||
|
// TODO: volumes
|
||||||
|
},
|
||||||
|
Name: name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok {
|
||||||
|
spec.Ports = ports
|
||||||
|
}
|
||||||
|
|
||||||
|
if service.Deploy != nil {
|
||||||
|
switch service.Deploy.Mode {
|
||||||
|
case "global":
|
||||||
|
spec.Mode = api.ServiceModeGlobal
|
||||||
|
case "", "replicated":
|
||||||
|
spec.Mode = api.ServiceModeReplicated
|
||||||
|
if service.Deploy.Replicas != nil {
|
||||||
|
spec.Replicas = uint(*service.Deploy.Replicas)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return spec, fmt.Errorf("unsupported deploy mode: %s", service.Deploy.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
@@ -3,13 +3,13 @@ package connector
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
|
"github.com/psviderski/uncloud/internal/sshexec"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/internal/machine"
|
|
||||||
"uncloud/internal/sshexec"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SSHConnectorConfig struct {
|
type SSHConnectorConfig struct {
|
||||||
@@ -3,16 +3,16 @@ package connector
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
|
machine2 "github.com/psviderski/uncloud/internal/machine"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network/tunnel"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"uncloud/internal/cli/client"
|
|
||||||
"uncloud/internal/cli/config"
|
|
||||||
machine2 "uncloud/internal/machine"
|
|
||||||
"uncloud/internal/machine/network"
|
|
||||||
"uncloud/internal/machine/network/tunnel"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// WireGuardConnector establishes a connection to the cluster API through a WireGuard tunnel
|
// WireGuardConnector establishes a connection to the cluster API through a WireGuard tunnel
|
||||||
@@ -4,18 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/network"
|
|
||||||
dockerclient "github.com/docker/docker/client"
|
dockerclient "github.com/docker/docker/client"
|
||||||
"github.com/docker/docker/pkg/jsonmessage"
|
"github.com/docker/docker/pkg/jsonmessage"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"uncloud/internal/api"
|
|
||||||
machinedocker "uncloud/internal/machine/docker"
|
|
||||||
"uncloud/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateContainer creates a new container for the given service on the specified machine.
|
// CreateContainer creates a new container for the given service on the specified machine.
|
||||||
@@ -24,8 +21,9 @@ func (cli *Client) CreateContainer(
|
|||||||
) (container.CreateResponse, error) {
|
) (container.CreateResponse, error) {
|
||||||
var resp container.CreateResponse
|
var resp container.CreateResponse
|
||||||
|
|
||||||
if !api.ValidateServiceID(serviceID) {
|
spec = spec.SetDefaults()
|
||||||
return resp, fmt.Errorf("invalid service ID: '%s'", serviceID)
|
if err := spec.Validate(); err != nil {
|
||||||
|
return resp, fmt.Errorf("invalid service spec: %w", err)
|
||||||
}
|
}
|
||||||
// TODO: validate spec.Name is consistent with serviceID if this is not the first container in the service.
|
// TODO: validate spec.Name is consistent with serviceID if this is not the first container in the service.
|
||||||
|
|
||||||
@@ -40,82 +38,39 @@ func (cli *Client) CreateContainer(
|
|||||||
}
|
}
|
||||||
containerName := fmt.Sprintf("%s-%s", spec.Name, suffix)
|
containerName := fmt.Sprintf("%s-%s", spec.Name, suffix)
|
||||||
|
|
||||||
config := &container.Config{
|
|
||||||
Cmd: spec.Container.Command,
|
|
||||||
Hostname: containerName,
|
|
||||||
Image: spec.Container.Image,
|
|
||||||
Labels: map[string]string{
|
|
||||||
api.LabelServiceID: serviceID,
|
|
||||||
api.LabelServiceName: spec.Name,
|
|
||||||
api.LabelServiceMode: spec.Mode,
|
|
||||||
api.LabelManaged: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if spec.Mode == "" {
|
|
||||||
config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(spec.Ports) > 0 {
|
|
||||||
encodedPorts := make([]string, len(spec.Ports))
|
|
||||||
for i, p := range spec.Ports {
|
|
||||||
encodedPorts[i], err = p.String()
|
|
||||||
if err != nil {
|
|
||||||
return resp, fmt.Errorf("encode service port spec: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
config.Labels[api.LabelServicePorts] = strings.Join(encodedPorts, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
portBindings := make(nat.PortMap)
|
|
||||||
for _, p := range spec.Ports {
|
|
||||||
if p.Mode != api.PortModeHost {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
port := nat.Port(fmt.Sprintf("%d/%s", p.ContainerPort, p.Protocol))
|
|
||||||
portBindings[port] = []nat.PortBinding{
|
|
||||||
{
|
|
||||||
HostPort: strconv.Itoa(int(p.PublishedPort)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if p.HostIP.IsValid() {
|
|
||||||
portBindings[port][0].HostIP = p.HostIP.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hostConfig := &container.HostConfig{
|
|
||||||
Binds: spec.Container.Volumes,
|
|
||||||
Init: spec.Container.Init,
|
|
||||||
PortBindings: portBindings,
|
|
||||||
// Always restart service containers if they exit or a machine restarts.
|
|
||||||
// For one-off containers and batch jobs we plan to use a different service type/mode.
|
|
||||||
RestartPolicy: container.RestartPolicy{
|
|
||||||
Name: container.RestartPolicyAlways,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
netConfig := &network.NetworkingConfig{
|
|
||||||
EndpointsConfig: map[string]*network.EndpointSettings{
|
|
||||||
machinedocker.NetworkName: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxy Docker gRPC requests to the selected machine.
|
// Proxy Docker gRPC requests to the selected machine.
|
||||||
ctx = proxyToMachine(ctx, machine.Machine)
|
ctx = proxyToMachine(ctx, machine.Machine)
|
||||||
|
|
||||||
pw := progress.ContextWriter(ctx)
|
pw := progress.ContextWriter(ctx)
|
||||||
eventID := fmt.Sprintf("Container %s on %s", containerName, machine.Machine.Name)
|
eventID := fmt.Sprintf("Container %s on %s", containerName, machine.Machine.Name)
|
||||||
|
|
||||||
pw.Event(progress.CreatingEvent(eventID))
|
pw.Event(progress.CreatingEvent(eventID))
|
||||||
resp, err = cli.Docker.CreateContainer(ctx, config, hostConfig, netConfig, nil, containerName)
|
|
||||||
|
if spec.Container.PullPolicy == api.PullPolicyAlways {
|
||||||
|
if err = cli.pullImageWithProgress(ctx, spec.Container.Image, machine.Machine.Name, eventID); err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err = cli.Docker.CreateServiceContainer(ctx, serviceID, spec, containerName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
switch spec.Container.PullPolicy {
|
||||||
|
case api.PullPolicyAlways, api.PullPolicyNever:
|
||||||
|
return resp, err
|
||||||
|
case api.PullPolicyMissing:
|
||||||
|
default:
|
||||||
|
return resp, fmt.Errorf("unsupported pull policy: '%s'", spec.Container.PullPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found error is expected if the image is missing.
|
||||||
if !dockerclient.IsErrNotFound(err) {
|
if !dockerclient.IsErrNotFound(err) {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull the missing image and create the container again.
|
// Pull the missing image and create the container again.
|
||||||
if err = cli.pullImageWithProgress(ctx, config.Image, machine.Machine.Name, eventID); err != nil {
|
if err = cli.pullImageWithProgress(ctx, spec.Container.Image, machine.Machine.Name, eventID); err != nil {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
if resp, err = cli.Docker.CreateContainer(ctx, config, hostConfig, netConfig, nil, containerName); err != nil {
|
if resp, err = cli.Docker.CreateServiceContainer(ctx, serviceID, spec, containerName); err != nil {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,29 +190,26 @@ func toPullProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InspectContainer returns the information about the specified container within the service.
|
// InspectContainer returns the information about the specified container within the service.
|
||||||
func (cli *Client) InspectContainer(ctx context.Context, serviceID, containerID string) (api.MachineContainer, error) {
|
func (cli *Client) InspectContainer(
|
||||||
var ctr api.MachineContainer
|
ctx context.Context, serviceNameOrID, containerNameOrID string,
|
||||||
|
) (api.MachineServiceContainer, error) {
|
||||||
svc, err := cli.InspectService(ctx, serviceID)
|
svc, err := cli.InspectService(ctx, serviceNameOrID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ctr, fmt.Errorf("inspect service: %w", err)
|
return api.MachineServiceContainer{}, fmt.Errorf("inspect service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range svc.Containers {
|
for _, c := range svc.Containers {
|
||||||
if c.Container.ID == containerID || c.Container.NameWithoutSlash() == containerID {
|
if c.Container.ID == containerNameOrID || c.Container.Name == containerNameOrID {
|
||||||
ctr = c
|
return c, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ctr.MachineID == "" {
|
|
||||||
return ctr, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctr, nil
|
return api.MachineServiceContainer{}, api.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartContainer starts the specified container within the service.
|
// StartContainer starts the specified container within the service.
|
||||||
func (cli *Client) StartContainer(ctx context.Context, serviceID, containerID string) error {
|
func (cli *Client) StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error {
|
||||||
ctr, err := cli.InspectContainer(ctx, serviceID, containerID)
|
ctr, err := cli.InspectContainer(ctx, serviceNameOrID, containerNameOrID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -269,7 +221,7 @@ func (cli *Client) StartContainer(ctx context.Context, serviceID, containerID st
|
|||||||
ctx = proxyToMachine(ctx, machine.Machine)
|
ctx = proxyToMachine(ctx, machine.Machine)
|
||||||
|
|
||||||
pw := progress.ContextWriter(ctx)
|
pw := progress.ContextWriter(ctx)
|
||||||
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.NameWithoutSlash(), machine.Machine.Name)
|
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name, machine.Machine.Name)
|
||||||
|
|
||||||
pw.Event(progress.StartingEvent(eventID))
|
pw.Event(progress.StartingEvent(eventID))
|
||||||
if err = cli.Docker.StartContainer(ctx, ctr.Container.ID, container.StartOptions{}); err != nil {
|
if err = cli.Docker.StartContainer(ctx, ctr.Container.ID, container.StartOptions{}); err != nil {
|
||||||
@@ -282,9 +234,9 @@ func (cli *Client) StartContainer(ctx context.Context, serviceID, containerID st
|
|||||||
|
|
||||||
// StopContainer stops the specified container within the service.
|
// StopContainer stops the specified container within the service.
|
||||||
func (cli *Client) StopContainer(
|
func (cli *Client) StopContainer(
|
||||||
ctx context.Context, serviceID, containerID string, opts container.StopOptions,
|
ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions,
|
||||||
) error {
|
) error {
|
||||||
ctr, err := cli.InspectContainer(ctx, serviceID, containerID)
|
ctr, err := cli.InspectContainer(ctx, serviceNameOrID, containerNameOrID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -296,7 +248,7 @@ func (cli *Client) StopContainer(
|
|||||||
ctx = proxyToMachine(ctx, machine.Machine)
|
ctx = proxyToMachine(ctx, machine.Machine)
|
||||||
|
|
||||||
pw := progress.ContextWriter(ctx)
|
pw := progress.ContextWriter(ctx)
|
||||||
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.NameWithoutSlash(), machine.Machine.Name)
|
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name, machine.Machine.Name)
|
||||||
|
|
||||||
pw.Event(progress.StoppingEvent(eventID))
|
pw.Event(progress.StoppingEvent(eventID))
|
||||||
if err = cli.Docker.StopContainer(ctx, ctr.Container.ID, opts); err != nil {
|
if err = cli.Docker.StopContainer(ctx, ctr.Container.ID, opts); err != nil {
|
||||||
@@ -309,9 +261,9 @@ func (cli *Client) StopContainer(
|
|||||||
|
|
||||||
// RemoveContainer removes the specified container within the service.
|
// RemoveContainer removes the specified container within the service.
|
||||||
func (cli *Client) RemoveContainer(
|
func (cli *Client) RemoveContainer(
|
||||||
ctx context.Context, serviceID, containerID string, opts container.RemoveOptions,
|
ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions,
|
||||||
) error {
|
) error {
|
||||||
ctr, err := cli.InspectContainer(ctx, serviceID, containerID)
|
ctr, err := cli.InspectContainer(ctx, serviceNameOrID, containerNameOrID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -323,10 +275,10 @@ func (cli *Client) RemoveContainer(
|
|||||||
ctx = proxyToMachine(ctx, machine.Machine)
|
ctx = proxyToMachine(ctx, machine.Machine)
|
||||||
|
|
||||||
pw := progress.ContextWriter(ctx)
|
pw := progress.ContextWriter(ctx)
|
||||||
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.NameWithoutSlash(), machine.Machine.Name)
|
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name, machine.Machine.Name)
|
||||||
|
|
||||||
pw.Event(progress.RemovingEvent(eventID))
|
pw.Event(progress.RemovingEvent(eventID))
|
||||||
if err = cli.Docker.RemoveContainer(ctx, ctr.Container.ID, opts); err != nil {
|
if err = cli.Docker.RemoveServiceContainer(ctx, ctr.Container.ID, opts); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pw.Event(progress.RemovedEvent(eventID))
|
pw.Event(progress.RemovedEvent(eventID))
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewDeployment creates a new deployment for the given service specification.
|
||||||
|
// If strategy is nil, a default deploy.RollingStrategy will be used.
|
||||||
|
func (cli *Client) NewDeployment(spec api.ServiceSpec, strategy deploy.Strategy) *deploy.Deployment {
|
||||||
|
return deploy.NewDeployment(cli, spec, strategy)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package deploy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContainerSpecStatus string
|
||||||
|
|
||||||
|
const ContainerUpToDate ContainerSpecStatus = "up-to-date"
|
||||||
|
const ContainerNeedsUpdate ContainerSpecStatus = "needs-update"
|
||||||
|
const ContainerNeedsRecreate ContainerSpecStatus = "needs-recreate"
|
||||||
|
|
||||||
|
func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) ContainerSpecStatus {
|
||||||
|
current = current.SetDefaults()
|
||||||
|
new = new.SetDefaults()
|
||||||
|
|
||||||
|
// Pull policy doesn't affect the container configuration.
|
||||||
|
new.Container.PullPolicy = current.Container.PullPolicy
|
||||||
|
if !current.Container.Equals(new.Container) {
|
||||||
|
return ContainerNeedsRecreate
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.Mode != new.Mode {
|
||||||
|
return ContainerNeedsRecreate
|
||||||
|
}
|
||||||
|
if current.Name != new.Name {
|
||||||
|
return ContainerNeedsRecreate
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: compare mutable properties such as memory or CPU limits when they are implemented.
|
||||||
|
|
||||||
|
// TODO: change ports check to ContainerNeedsUpdate when ingress ports are stored only the machine DB instead
|
||||||
|
// of as labels ans synced to the cluster store. Host ports changes should be handled as ContainerNeedsRecreate.
|
||||||
|
if !api.PortsEqual(current.Ports, new.Ports) {
|
||||||
|
return ContainerNeedsRecreate
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContainerUpToDate
|
||||||
|
}
|
||||||
@@ -1,25 +1,34 @@
|
|||||||
package client
|
package deploy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"uncloud/internal/api"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Client interface {
|
||||||
|
api.ContainerClient
|
||||||
|
api.DNSClient
|
||||||
|
api.ImageClient
|
||||||
|
api.MachineClient
|
||||||
|
api.ServiceClient
|
||||||
|
}
|
||||||
|
|
||||||
// Deployment manages the process of creating or updating a service to match a desired state.
|
// Deployment manages the process of creating or updating a service to match a desired state.
|
||||||
// It coordinates the validation, planning, and execution of deployment operations.
|
// It coordinates the validation, planning, and execution of deployment operations.
|
||||||
type Deployment struct {
|
type Deployment struct {
|
||||||
Service *api.Service
|
Service *api.Service
|
||||||
Spec api.ServiceSpec
|
Spec api.ServiceSpec
|
||||||
Strategy Strategy
|
Strategy Strategy
|
||||||
cli *Client
|
cli Client
|
||||||
plan *Plan
|
plan *Plan
|
||||||
}
|
}
|
||||||
|
|
||||||
type Plan struct {
|
type Plan struct {
|
||||||
ServiceID string
|
ServiceID string
|
||||||
|
ServiceName string
|
||||||
SequenceOperation
|
SequenceOperation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +40,7 @@ var ErrNoMatchingMachines = errors.New("no machines match the filter")
|
|||||||
|
|
||||||
// NewDeployment creates a new deployment for the given service specification.
|
// NewDeployment creates a new deployment for the given service specification.
|
||||||
// If strategy is nil, a default RollingStrategy will be used.
|
// If strategy is nil, a default RollingStrategy will be used.
|
||||||
func (cli *Client) NewDeployment(spec api.ServiceSpec, strategy Strategy) (*Deployment, error) {
|
func NewDeployment(cli Client, spec api.ServiceSpec, strategy Strategy) *Deployment {
|
||||||
if strategy == nil {
|
if strategy == nil {
|
||||||
strategy = &RollingStrategy{}
|
strategy = &RollingStrategy{}
|
||||||
}
|
}
|
||||||
@@ -40,7 +49,7 @@ func (cli *Client) NewDeployment(spec api.ServiceSpec, strategy Strategy) (*Depl
|
|||||||
Spec: spec,
|
Spec: spec,
|
||||||
Strategy: strategy,
|
Strategy: strategy,
|
||||||
cli: cli,
|
cli: cli,
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plan returns a plan of operations to reconcile the service to the desired state.
|
// Plan returns a plan of operations to reconcile the service to the desired state.
|
||||||
@@ -50,12 +59,32 @@ func (d *Deployment) Plan(ctx context.Context) (Plan, error) {
|
|||||||
return *d.plan, nil
|
return *d.plan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the new spec before planning.
|
// Validate the user-provided spec before resolving it.
|
||||||
if err := d.Validate(ctx); err != nil {
|
if err := d.Validate(ctx); err != nil {
|
||||||
return Plan{}, fmt.Errorf("invalid deployment: %w", err)
|
return Plan{}, fmt.Errorf("invalid deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, err := d.Strategy.Plan(ctx, d.cli, d.Service, d.Spec)
|
clusterDomain, err := d.cli.GetDomain(ctx)
|
||||||
|
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||||
|
return Plan{}, fmt.Errorf("get cluster domain: %w", err)
|
||||||
|
}
|
||||||
|
// TODO: enable image resolver when it's ready to pin digests and look up existing images.
|
||||||
|
//imageResolver := &ImageDigestResolver{
|
||||||
|
// Ctx: ctx,
|
||||||
|
// Client: d.cli,
|
||||||
|
//}
|
||||||
|
specResolver := &ServiceSpecResolver{
|
||||||
|
// If the domain is not found (not reserved), an empty domain is used for the resolver.
|
||||||
|
ClusterDomain: clusterDomain,
|
||||||
|
//ImageResolver: imageResolver,
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedSpec, err := specResolver.Resolve(d.Spec)
|
||||||
|
if err != nil {
|
||||||
|
return Plan{}, fmt.Errorf("resolve service spec: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := d.Strategy.Plan(ctx, d.cli, d.Service, resolvedSpec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Plan{}, fmt.Errorf("create plan using %s strategy: %w", d.Strategy.Type(), err)
|
return Plan{}, fmt.Errorf("create plan using %s strategy: %w", d.Strategy.Type(), err)
|
||||||
}
|
}
|
||||||
@@ -69,15 +98,12 @@ func (d *Deployment) Validate(ctx context.Context) error {
|
|||||||
if err := d.Spec.Validate(); err != nil {
|
if err := d.Spec.Validate(); err != nil {
|
||||||
return fmt.Errorf("invalid service spec: %w", err)
|
return fmt.Errorf("invalid service spec: %w", err)
|
||||||
}
|
}
|
||||||
if d.Spec.Name == "" {
|
|
||||||
return errors.New("service name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if d.Service == nil {
|
if d.Service == nil {
|
||||||
svc, err := d.cli.InspectService(ctx, d.Spec.Name)
|
svc, err := d.cli.InspectService(ctx, d.Spec.Name)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
d.Service = &svc
|
d.Service = &svc
|
||||||
} else if !errors.Is(err, ErrNotFound) {
|
} else if !errors.Is(err, api.ErrNotFound) {
|
||||||
return fmt.Errorf("inspect service: %w", err)
|
return fmt.Errorf("inspect service: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,11 +115,15 @@ func (d *Deployment) Validate(ctx context.Context) error {
|
|||||||
if d.Service.Name != d.Spec.Name {
|
if d.Service.Name != d.Spec.Name {
|
||||||
return errors.New("service name cannot be changed")
|
return errors.New("service name cannot be changed")
|
||||||
}
|
}
|
||||||
if d.Service.Mode != d.Spec.Mode {
|
|
||||||
return errors.New("service mode cannot be changed")
|
// Resolve the default mode if not specified.
|
||||||
|
mode := d.Spec.Mode
|
||||||
|
if mode == "" {
|
||||||
|
mode = api.ServiceModeReplicated
|
||||||
}
|
}
|
||||||
if d.Spec.Mode == api.ServiceModeReplicated && d.Spec.Replicas < 1 {
|
|
||||||
return errors.New("number of replicas must be at least 1")
|
if mode != d.Service.Mode {
|
||||||
|
return errors.New("service mode cannot be changed")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -103,11 +133,11 @@ func (d *Deployment) Validate(ctx context.Context) error {
|
|||||||
// It will create a new plan if one hasn't been created yet. The deployment will either create a new service or update
|
// It will create a new plan if one hasn't been created yet. The deployment will either create a new service or update
|
||||||
// the existing one to match the desired specification.
|
// the existing one to match the desired specification.
|
||||||
// TODO: forbid to run the same deployment more than once.
|
// TODO: forbid to run the same deployment more than once.
|
||||||
func (d *Deployment) Run(ctx context.Context) (string, error) {
|
func (d *Deployment) Run(ctx context.Context) (Plan, error) {
|
||||||
plan, err := d.Plan(ctx)
|
plan, err := d.Plan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("create plan: %w", err)
|
return plan, fmt.Errorf("create plan: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return plan.ServiceID, plan.Execute(ctx, d.cli)
|
return plan, plan.Execute(ctx, d.cli)
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
package client
|
package deploy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/internal/api"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Operation represents a single atomic operation in a deployment process.
|
// Operation represents a single atomic operation in a deployment process.
|
||||||
// Operations can be composed to form complex deployment strategies.
|
// Operations can be composed to form complex deployment strategies.
|
||||||
type Operation interface {
|
type Operation interface {
|
||||||
Execute(ctx context.Context, cli *Client) error
|
// Execute performs the operation using the provided client.
|
||||||
|
// TODO: Encapsulate the client in the operation as otherwise it gives an impression that different clients
|
||||||
|
// can be provided. But in reality, the operation is tightly coupled with the client that was used to create it.
|
||||||
|
Execute(ctx context.Context, cli Client) error
|
||||||
// Format returns a human-readable representation of the operation.
|
// Format returns a human-readable representation of the operation.
|
||||||
Format(resolver NameResolver) string
|
Format(resolver NameResolver) string
|
||||||
String() string
|
String() string
|
||||||
@@ -30,7 +33,7 @@ type RunContainerOperation struct {
|
|||||||
MachineID string
|
MachineID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *RunContainerOperation) Execute(ctx context.Context, cli *Client) error {
|
func (o *RunContainerOperation) Execute(ctx context.Context, cli Client) error {
|
||||||
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
|
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create container: %w", err)
|
return fmt.Errorf("create container: %w", err)
|
||||||
@@ -61,7 +64,7 @@ type StopContainerOperation struct {
|
|||||||
MachineID string
|
MachineID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *StopContainerOperation) Execute(ctx context.Context, cli *Client) error {
|
func (o *StopContainerOperation) Execute(ctx context.Context, cli Client) error {
|
||||||
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil {
|
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil {
|
||||||
return fmt.Errorf("stop container: %w", err)
|
return fmt.Errorf("stop container: %w", err)
|
||||||
}
|
}
|
||||||
@@ -85,7 +88,7 @@ type RemoveContainerOperation struct {
|
|||||||
MachineID string
|
MachineID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *RemoveContainerOperation) Execute(ctx context.Context, cli *Client) error {
|
func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) error {
|
||||||
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil {
|
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil {
|
||||||
return fmt.Errorf("stop container: %w", err)
|
return fmt.Errorf("stop container: %w", err)
|
||||||
}
|
}
|
||||||
@@ -111,7 +114,7 @@ type SequenceOperation struct {
|
|||||||
Operations []Operation
|
Operations []Operation
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *SequenceOperation) Execute(ctx context.Context, cli *Client) error {
|
func (o *SequenceOperation) Execute(ctx context.Context, cli Client) error {
|
||||||
for _, op := range o.Operations {
|
for _, op := range o.Operations {
|
||||||
if err := op.Execute(ctx, cli); err != nil {
|
if err := op.Execute(ctx, cli); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -137,30 +140,3 @@ func (o *SequenceOperation) String() string {
|
|||||||
|
|
||||||
return fmt.Sprintf("SequenceOperation[%s]", strings.Join(ops, ", "))
|
return fmt.Sprintf("SequenceOperation[%s]", strings.Join(ops, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MapNameResolver resolves machine and container IDs to their names using a static map.
|
|
||||||
type MapNameResolver struct {
|
|
||||||
machines map[string]string
|
|
||||||
containers map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewNameResolver(machines, containers map[string]string) *MapNameResolver {
|
|
||||||
return &MapNameResolver{
|
|
||||||
machines: machines,
|
|
||||||
containers: containers,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *MapNameResolver) MachineName(machineID string) string {
|
|
||||||
if name, ok := r.machines[machineID]; ok {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return machineID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *MapNameResolver) ContainerName(containerID string) string {
|
|
||||||
if name, ok := r.containers[containerID]; ok {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return containerID
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package deploy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/opencontainers/go-digest"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceSpecResolver transforms user-provided service specs into deployment-ready form.
|
||||||
|
type ServiceSpecResolver struct {
|
||||||
|
ClusterDomain string
|
||||||
|
ImageResolver *ImageDigestResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve transforms a service spec into its fully resolved form ready for deployment.
|
||||||
|
func (r *ServiceSpecResolver) Resolve(spec api.ServiceSpec) (api.ServiceSpec, error) {
|
||||||
|
if err := spec.Validate(); err != nil {
|
||||||
|
return spec, fmt.Errorf("invalid service spec: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
spec = spec.Clone()
|
||||||
|
|
||||||
|
steps := []func(*api.ServiceSpec) error{
|
||||||
|
r.applyDefaults,
|
||||||
|
r.resolveServiceName,
|
||||||
|
r.expandIngressPorts,
|
||||||
|
r.resolveImageDigest,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, step := range steps {
|
||||||
|
if err := step(&spec); err != nil {
|
||||||
|
return spec, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ServiceSpecResolver) applyDefaults(spec *api.ServiceSpec) error {
|
||||||
|
if spec.Mode == "" {
|
||||||
|
spec.Mode = api.ServiceModeReplicated
|
||||||
|
}
|
||||||
|
// Ensure the replicated service has at least one replica.
|
||||||
|
if spec.Mode == api.ServiceModeReplicated && spec.Replicas == 0 {
|
||||||
|
spec.Replicas = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if spec.Container.PullPolicy == "" {
|
||||||
|
spec.Container.PullPolicy = api.PullPolicyMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveServiceName generates a service name from the image when not provided.
|
||||||
|
func (r *ServiceSpecResolver) resolveServiceName(spec *api.ServiceSpec) error {
|
||||||
|
if spec.Name != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a random service name from the image when not provided.
|
||||||
|
img, err := reference.ParseDockerRef(spec.Container.Image)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid image: %w", err)
|
||||||
|
}
|
||||||
|
// Get the image name without the repository and tag/digest parts.
|
||||||
|
imageName := reference.FamiliarName(img)
|
||||||
|
// Get the last part of the image name (path), e.g. "nginx" from "bitnami/nginx".
|
||||||
|
if i := strings.LastIndex(imageName, "/"); i != -1 {
|
||||||
|
imageName = imageName[i+1:]
|
||||||
|
}
|
||||||
|
// Append a random suffix to the image name to generate an optimistically unique service name.
|
||||||
|
suffix, err := secret.RandomAlphaNumeric(4)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generate random suffix: %w", err)
|
||||||
|
}
|
||||||
|
spec.Name = fmt.Sprintf("%s-%s", imageName, suffix)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandIngressPorts processes HTTP(S) ingress ports in a service spec by:
|
||||||
|
// 1. Setting a default hostname (service-name.cluster-domain) for ports without a hostname.
|
||||||
|
// 2. Duplicating a port with a cluster domain hostname for ports with external domains.
|
||||||
|
// This ensures every ingress port is accessible via the cluster domain, while preserving any custom domains specified
|
||||||
|
// by the user.
|
||||||
|
func (r *ServiceSpecResolver) expandIngressPorts(spec *api.ServiceSpec) error {
|
||||||
|
for i, port := range spec.Ports {
|
||||||
|
if port.Protocol != api.ProtocolHTTP && port.Protocol != api.ProtocolHTTPS {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if port.Hostname == "" {
|
||||||
|
if r.ClusterDomain == "" {
|
||||||
|
return fmt.Errorf("cluster domain must be reserved to generate hostname for ingress port: %d/%s",
|
||||||
|
port.ContainerPort, port.Protocol)
|
||||||
|
}
|
||||||
|
// Assign the default hostname (service-name.cluster-domain).
|
||||||
|
spec.Ports[i].Hostname = fmt.Sprintf("%s.%s", spec.Name, r.ClusterDomain)
|
||||||
|
} else {
|
||||||
|
if r.ClusterDomain == "" {
|
||||||
|
// When no cluster domain is reserved, use only the provided hostname.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(port.Hostname, "."+r.ClusterDomain) {
|
||||||
|
// If the hostname is already a cluster subdomain, use as is.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// For external domains, duplicate the port with a service-name.cluster-domain hostname so the service
|
||||||
|
// can be accessed via both hostnames.
|
||||||
|
newPort := port
|
||||||
|
newPort.Hostname = fmt.Sprintf("%s.%s", spec.Name, r.ClusterDomain)
|
||||||
|
spec.Ports = append(spec.Ports, newPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ServiceSpecResolver) resolveImageDigest(spec *api.ServiceSpec) error {
|
||||||
|
if r.ImageResolver == nil {
|
||||||
|
// Skip digest resolution when no resolver is provided.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
image, err := r.ImageResolver.Resolve(spec.Container.Image, spec.Container.PullPolicy)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve image digest: %w", err)
|
||||||
|
}
|
||||||
|
spec.Container.Image = image
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageResolverClient interface {
|
||||||
|
api.ImageClient
|
||||||
|
api.MachineClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageDigestResolver struct {
|
||||||
|
Ctx context.Context
|
||||||
|
Client ImageResolverClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve resolves the image to the image with the digest according to the pull policy:
|
||||||
|
// - always: Fetch the latest digest for the image tag in the registry.
|
||||||
|
// - missing: Find the latest image matching the tag on any machine and use its digest, if it exists.
|
||||||
|
// When there is no matching image on any machine, it behaves like 'always'.
|
||||||
|
// - never: !Not implemented! Similar to 'missing' but when there is no matching image on any machine,
|
||||||
|
// it returns an error.
|
||||||
|
//
|
||||||
|
// If the image is already pinned to a digest, it is returned as is.
|
||||||
|
func (r *ImageDigestResolver) Resolve(image, policy string) (string, error) {
|
||||||
|
if r.Ctx == nil {
|
||||||
|
r.Ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err := reference.ParseNormalizedNamed(image)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse image: %w", err)
|
||||||
|
}
|
||||||
|
if _, ok := ref.(reference.Canonical); ok {
|
||||||
|
// The image is already pinned to a digest.
|
||||||
|
return image, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch policy {
|
||||||
|
case api.PullPolicyAlways:
|
||||||
|
return r.resolveAlways(image)
|
||||||
|
case api.PullPolicyMissing:
|
||||||
|
return r.resolveMissing(image)
|
||||||
|
case api.PullPolicyNever:
|
||||||
|
return "", fmt.Errorf("pull policy '%s' is not supported yet", policy)
|
||||||
|
}
|
||||||
|
return image, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAlways resolves the image to the image with the digest by querying the registry from all machines.
|
||||||
|
func (r *ImageDigestResolver) resolveAlways(image string) (string, error) {
|
||||||
|
// TODO: broadcast to a subset of machines in large clusters to avoid being rate-limited by the registry.
|
||||||
|
ctx, err := api.ProxyMachinesContext(r.Ctx, r.Client, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request context to broadcast to all machines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteImages, err := r.Client.InspectRemoteImage(ctx, image)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("inspect image '%s' in registry from all machines: %w", image, err)
|
||||||
|
}
|
||||||
|
if len(remoteImages) == 0 {
|
||||||
|
return "", fmt.Errorf("inspect image '%s' in registry from all machines: unexpected empty response", image)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ri := range remoteImages {
|
||||||
|
if ri.Metadata != nil && ri.Metadata.Error != "" {
|
||||||
|
// Save the last error to return it if all machines fail to inspect the image.
|
||||||
|
err = fmt.Errorf("inspect image '%s' in registry on machine '%s': %s",
|
||||||
|
image, ri.Metadata.Machine, ri.Metadata.Error)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return reference.FamiliarString(ri.Image.Reference), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ImageDigestResolver) resolveMissing(image string) (string, error) {
|
||||||
|
ctx, err := api.ProxyMachinesContext(r.Ctx, r.Client, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request context to broadcast to all machines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
machineImages, err := r.Client.InspectImage(ctx, image)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, api.ErrNotFound) {
|
||||||
|
// If the image is missing on all machines, the 'missing' policy is equivalent to 'always'.
|
||||||
|
return r.resolveAlways(image)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("inspect image '%s' on all machines: %w", image, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var availableImages []types.ImageInspect
|
||||||
|
for _, mi := range machineImages {
|
||||||
|
// Metadata can be nil if the request was proxied to only one machine.
|
||||||
|
if mi.Metadata != nil && mi.Metadata.Error != "" {
|
||||||
|
if codes.Code(mi.Metadata.Status.Code) != codes.NotFound {
|
||||||
|
fmt.Printf("WARNING: failed to inspect image '%s' on machine '%s': %s\n",
|
||||||
|
image, mi.Metadata.Machine, mi.Metadata.Error)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
availableImages = append(availableImages, mi.Image)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(availableImages) == 0 {
|
||||||
|
// If the image is missing on all machines, the 'missing' policy is equivalent to 'always'.
|
||||||
|
return r.resolveAlways(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the latest image with a RepoDigest.
|
||||||
|
var latestDigest digest.Digest
|
||||||
|
var latestCreated time.Time
|
||||||
|
for _, img := range availableImages {
|
||||||
|
if len(img.RepoDigests) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: handle multiple RepoDigests. This could happen for example if the same image was pulled twice using
|
||||||
|
// both its index (multi-arch) digest and manifest (platform-specific) digest:
|
||||||
|
// {
|
||||||
|
// "Id": "sha256:6fee7566e4273ee6078f08e167e36434b35f72152232a5e6f1446288817dabe5",
|
||||||
|
// "RepoTags": [
|
||||||
|
// "traefik/whoami:latest"
|
||||||
|
// ],
|
||||||
|
// "RepoDigests": [
|
||||||
|
// "traefik/whoami@sha256:200689790a0a0ea48ca45992e0450bc26ccab5307375b41c84dfc4f2475937ab",
|
||||||
|
// "traefik/whoami@sha256:4f90b33ddca9c4d4f06527070d6e503b16d71016edea036842be2a84e60c91cb"
|
||||||
|
// ],
|
||||||
|
// ...
|
||||||
|
// }
|
||||||
|
// Should the registry be queried to find out which digest to use?
|
||||||
|
repoDigest := img.RepoDigests[0]
|
||||||
|
created, err := time.Parse(time.RFC3339Nano, img.Created)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if created.After(latestCreated) {
|
||||||
|
ref, err := reference.ParseNormalizedNamed(repoDigest)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c, ok := ref.(reference.Canonical); ok {
|
||||||
|
latestDigest = c.Digest()
|
||||||
|
latestCreated = created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if latestDigest != "" {
|
||||||
|
return imageWithDigest(image, latestDigest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't pin the digest if no RepoDigests were found. This means the available images were not pulled from
|
||||||
|
// a registry but built locally or loaded from an archive. In this case, the available images (could be multiple
|
||||||
|
// for different platforms) should be copied to other machines to be able to run service containers on them.
|
||||||
|
return image, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageWithDigest adds a digest to an image string if it doesn't already contain one.
|
||||||
|
func imageWithDigest(image string, dig digest.Digest) (string, error) {
|
||||||
|
ref, err := reference.ParseNormalizedNamed(image)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := ref.(reference.Canonical); !ok {
|
||||||
|
// Preserves the original tag if present.
|
||||||
|
img, err := reference.WithDigest(ref, dig)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("add digest to image: %w", err)
|
||||||
|
}
|
||||||
|
return reference.FamiliarString(img), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return image, nil
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
package client
|
package deploy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"slices"
|
"slices"
|
||||||
"uncloud/internal/api"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Strategy defines how a service should be deployed or updated. Different implementations can provide various
|
// Strategy defines how a service should be deployed or updated. Different implementations can provide various
|
||||||
@@ -17,7 +18,7 @@ type Strategy interface {
|
|||||||
Type() string
|
Type() string
|
||||||
// Plan returns the operation to reconcile the service to the desired state.
|
// Plan returns the operation to reconcile the service to the desired state.
|
||||||
// If the service does not exist (new deployment), svc will be nil.
|
// If the service does not exist (new deployment), svc will be nil.
|
||||||
Plan(ctx context.Context, cli *Client, svc *api.Service, spec api.ServiceSpec) (Plan, error)
|
Plan(ctx context.Context, cli api.MachineClient, svc *api.Service, spec api.ServiceSpec) (Plan, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RollingStrategy implements a rolling update deployment pattern where containers are updated one at a time
|
// RollingStrategy implements a rolling update deployment pattern where containers are updated one at a time
|
||||||
@@ -32,35 +33,29 @@ func (s *RollingStrategy) Type() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *RollingStrategy) Plan(
|
func (s *RollingStrategy) Plan(
|
||||||
ctx context.Context, cli *Client, svc *api.Service, spec api.ServiceSpec,
|
ctx context.Context, cli api.MachineClient, svc *api.Service, spec api.ServiceSpec,
|
||||||
) (Plan, error) {
|
) (Plan, error) {
|
||||||
|
// We can assume that the spec is valid at this point because it has been validated by the deployment.
|
||||||
switch spec.Mode {
|
switch spec.Mode {
|
||||||
case api.ServiceModeReplicated:
|
case api.ServiceModeReplicated:
|
||||||
return s.planReplicated(ctx, cli, svc, spec)
|
return s.planReplicated(ctx, cli, svc, spec)
|
||||||
case api.ServiceModeGlobal:
|
case api.ServiceModeGlobal:
|
||||||
return s.planGlobal(ctx, cli, svc, spec)
|
return s.planGlobal(ctx, cli, svc, spec)
|
||||||
default:
|
default:
|
||||||
return Plan{}, fmt.Errorf("unsupported service mode: %s", spec.Mode)
|
return Plan{}, fmt.Errorf("unsupported service mode: '%s'", spec.Mode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// planReplicated creates a plan for a replicated service deployment.
|
// planReplicated creates a plan for a replicated service deployment.
|
||||||
// For replicated services, we want to maintain a specific number of containers (replicas) across the available machines
|
// For replicated services, we want to maintain a specific number of containers (replicas) across the available machines
|
||||||
// in the cluster.
|
// in the cluster.
|
||||||
|
// TODO: schedule containers only on machines that contain the image if pull policy is set to 'never'.
|
||||||
func (s *RollingStrategy) planReplicated(
|
func (s *RollingStrategy) planReplicated(
|
||||||
ctx context.Context, cli *Client, svc *api.Service, spec api.ServiceSpec,
|
ctx context.Context, cli api.MachineClient, svc *api.Service, spec api.ServiceSpec,
|
||||||
) (Plan, error) {
|
) (Plan, error) {
|
||||||
var plan Plan
|
plan, err := newEmptyPlan(svc, spec)
|
||||||
|
|
||||||
// Generate a new service ID for the first service deployment if it doesn't exist yet.
|
|
||||||
if svc != nil {
|
|
||||||
plan.ServiceID = svc.ID
|
|
||||||
} else {
|
|
||||||
var err error
|
|
||||||
plan.ServiceID, err = secret.NewID()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return plan, fmt.Errorf("generate service ID: %w", err)
|
return plan, err
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
machines, err := cli.ListMachines(ctx)
|
machines, err := cli.ListMachines(ctx)
|
||||||
@@ -95,30 +90,30 @@ func (s *RollingStrategy) planReplicated(
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Organise existing containers by machine.
|
// Organise existing containers by machine.
|
||||||
containersOnMachine := make(map[string][]api.Container)
|
containersOnMachine := make(map[string][]api.ServiceContainer)
|
||||||
upToDateContainersOnMachine := make(map[string]int)
|
upToDateContainersOnMachine := make(map[string]int)
|
||||||
|
containerSpecStatuses := make(map[string]ContainerSpecStatus)
|
||||||
if svc != nil {
|
if svc != nil {
|
||||||
runningSpecs := make(map[string]api.ServiceSpec)
|
|
||||||
for _, c := range svc.Containers {
|
for _, c := range svc.Containers {
|
||||||
if !c.Container.State.Running || c.Container.State.Paused {
|
if !c.Container.State.Running || c.Container.State.Paused {
|
||||||
// Skip containers that are not running.
|
// Skip containers that are not running.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cs, err := c.Container.ServiceSpec()
|
|
||||||
if err == nil {
|
status := EvalContainerSpecChange(c.Container.ServiceSpec, spec)
|
||||||
runningSpecs[c.Container.ID] = cs
|
containerSpecStatuses[c.Container.ID] = status
|
||||||
if cs.Equals(spec) {
|
|
||||||
|
if status == ContainerUpToDate {
|
||||||
upToDateContainersOnMachine[c.MachineID] += 1
|
upToDateContainersOnMachine[c.MachineID] += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Sort containers such that containers with the desired spec are first.
|
// Sort containers such that running containers with the desired spec are first.
|
||||||
slices.SortFunc(svc.Containers, func(c1, c2 api.MachineContainer) int {
|
slices.SortFunc(svc.Containers, func(c1, c2 api.MachineServiceContainer) int {
|
||||||
if spec1, ok := runningSpecs[c1.Container.ID]; ok && spec1.Equals(spec) {
|
if status, ok := containerSpecStatuses[c1.Container.ID]; ok && status == ContainerUpToDate {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
if spec2, ok := runningSpecs[c2.Container.ID]; ok && spec2.Equals(spec) {
|
if status, ok := containerSpecStatuses[c2.Container.ID]; ok && status == ContainerUpToDate {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
@@ -163,14 +158,14 @@ func (s *RollingStrategy) planReplicated(
|
|||||||
ctr := containers[0]
|
ctr := containers[0]
|
||||||
containersOnMachine[m.Id] = containers[1:]
|
containersOnMachine[m.Id] = containers[1:]
|
||||||
|
|
||||||
if ctr.State.Running {
|
if status, ok := containerSpecStatuses[ctr.ID]; ok { // Contains statuses for only running containers.
|
||||||
ctrSpec, specErr := ctr.ServiceSpec()
|
if status == ContainerUpToDate {
|
||||||
if specErr == nil && ctrSpec.Equals(spec) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
|
||||||
|
|
||||||
conflictingPorts, portsErr := ctr.ConflictingServicePorts(spec.Ports)
|
conflictingPorts, portsErr := ctr.ConflictingServicePorts(spec.Ports)
|
||||||
if specErr != nil || portsErr != nil || len(conflictingPorts) > 0 {
|
if portsErr != nil || len(conflictingPorts) > 0 {
|
||||||
// Stop the malformed container or the container with conflicting ports.
|
// Stop the malformed container or the container with conflicting ports.
|
||||||
plan.Operations = append(plan.Operations, &StopContainerOperation{
|
plan.Operations = append(plan.Operations, &StopContainerOperation{
|
||||||
ServiceID: plan.ServiceID,
|
ServiceID: plan.ServiceID,
|
||||||
@@ -215,25 +210,21 @@ func (s *RollingStrategy) planReplicated(
|
|||||||
// It handles multiple containers per machine (though this should not occur in normal operation) and skips machines
|
// It handles multiple containers per machine (though this should not occur in normal operation) and skips machines
|
||||||
// that are down.
|
// that are down.
|
||||||
func (s *RollingStrategy) planGlobal(
|
func (s *RollingStrategy) planGlobal(
|
||||||
ctx context.Context, cli *Client, svc *api.Service, spec api.ServiceSpec,
|
ctx context.Context, cli api.MachineClient, svc *api.Service, spec api.ServiceSpec,
|
||||||
) (Plan, error) {
|
) (Plan, error) {
|
||||||
var plan Plan
|
plan, err := newEmptyPlan(svc, spec)
|
||||||
|
if err != nil {
|
||||||
|
return plan, err
|
||||||
|
}
|
||||||
|
|
||||||
// Map machineID to service containers on that machine. For the global mode, there should be at most one
|
// Map machineID to service containers on that machine. For the global mode, there should be at most one
|
||||||
// container per machine but we use a slice to handle multiple containers that may exist due to a bug
|
// container per machine but we use a slice to handle multiple containers that may exist due to a bug
|
||||||
// or interruption in the previous deployment.
|
// or interruption in the previous deployment.
|
||||||
containersOnMachine := make(map[string][]api.MachineContainer)
|
containersOnMachine := make(map[string][]api.MachineServiceContainer)
|
||||||
if svc != nil {
|
if svc != nil {
|
||||||
plan.ServiceID = svc.ID
|
|
||||||
for _, c := range svc.Containers {
|
for _, c := range svc.Containers {
|
||||||
containersOnMachine[c.MachineID] = append(containersOnMachine[c.MachineID], c)
|
containersOnMachine[c.MachineID] = append(containersOnMachine[c.MachineID], c)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Generate a new service ID for the first service deployment.
|
|
||||||
var err error
|
|
||||||
plan.ServiceID, err = secret.NewID()
|
|
||||||
if err != nil {
|
|
||||||
return plan, fmt.Errorf("generate service ID: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
machines, err := cli.ListMachines(ctx)
|
machines, err := cli.ListMachines(ctx)
|
||||||
@@ -254,12 +245,12 @@ func (s *RollingStrategy) planGlobal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: figure out how to return a warning if there are machines down. Embed the machinesDown in the plan?
|
// TODO: figure out how to return a warning if there are machines down. Embed the machinesDown in the plan?
|
||||||
// WARNING: failed to run a service container on machine '%s' which is Down.
|
|
||||||
var machinesDown []*pb.MachineInfo
|
var machinesDown []*pb.MachineInfo
|
||||||
for _, m := range machines {
|
for _, m := range machines {
|
||||||
// Skip machines that are down but collect them to report a warning later.
|
// Skip machines that are down but collect them to report a warning later.
|
||||||
if m.State == pb.MachineMember_DOWN {
|
if m.State == pb.MachineMember_DOWN {
|
||||||
machinesDown = append(machinesDown, m.Machine)
|
machinesDown = append(machinesDown, m.Machine)
|
||||||
|
fmt.Printf("WARNING: failed to run a service container on machine '%s' which is Down.\n", m.Machine.Id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +269,7 @@ func (s *RollingStrategy) planGlobal(
|
|||||||
// It ensures exactly one container with the desired spec is running on the machine by creating a new container and
|
// It ensures exactly one container with the desired spec is running on the machine by creating a new container and
|
||||||
// removing old ones. If there is a host port conflict, it stops the old container before starting a new one.
|
// removing old ones. If there is a host port conflict, it stops the old container before starting a new one.
|
||||||
func reconcileGlobalContainer(
|
func reconcileGlobalContainer(
|
||||||
containers []api.MachineContainer, spec api.ServiceSpec, serviceID, machineID string,
|
containers []api.MachineServiceContainer, spec api.ServiceSpec, serviceID, machineID string,
|
||||||
) ([]Operation, error) {
|
) ([]Operation, error) {
|
||||||
var ops []Operation
|
var ops []Operation
|
||||||
|
|
||||||
@@ -300,11 +291,8 @@ func reconcileGlobalContainer(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
svcSpec, err := c.Container.ServiceSpec()
|
status := EvalContainerSpecChange(c.Container.ServiceSpec, spec)
|
||||||
if err != nil {
|
if status == ContainerUpToDate {
|
||||||
return nil, fmt.Errorf("get service spec: %w", err)
|
|
||||||
}
|
|
||||||
if svcSpec.Equals(spec) {
|
|
||||||
// The container is already running with the same spec.
|
// The container is already running with the same spec.
|
||||||
upToDate = true
|
upToDate = true
|
||||||
for j, old := range containers {
|
for j, old := range containers {
|
||||||
@@ -319,6 +307,7 @@ func reconcileGlobalContainer(
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
|
||||||
}
|
}
|
||||||
if upToDate {
|
if upToDate {
|
||||||
return ops, nil
|
return ops, nil
|
||||||
@@ -362,3 +351,23 @@ func reconcileGlobalContainer(
|
|||||||
|
|
||||||
return ops, nil
|
return ops, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newEmptyPlan creates a new empty plan for a service deployment with initialised service ID and name.
|
||||||
|
func newEmptyPlan(svc *api.Service, spec api.ServiceSpec) (Plan, error) {
|
||||||
|
var plan Plan
|
||||||
|
|
||||||
|
// Generate a new service ID for the initial service deployment if it doesn't exist yet.
|
||||||
|
if svc != nil {
|
||||||
|
plan.ServiceID = svc.ID
|
||||||
|
plan.ServiceName = svc.Name
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
plan.ServiceID, err = secret.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return plan, fmt.Errorf("generate service ID: %w", err)
|
||||||
|
}
|
||||||
|
plan.ServiceName = spec.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
@@ -4,15 +4,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/caddyfile"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
"uncloud/internal/machine/caddyfile"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetDomain returns the cluster domain name or ErrNotFound if it hasn't been reserved yet.
|
// GetDomain returns the cluster domain name or ErrNotFound if it hasn't been reserved yet.
|
||||||
@@ -20,7 +22,7 @@ func (cli *Client) GetDomain(ctx context.Context) (string, error) {
|
|||||||
domain, err := cli.ClusterClient.GetDomain(ctx, nil)
|
domain, err := cli.ClusterClient.GetDomain(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if status.Convert(err).Code() == codes.NotFound {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
return "", ErrNotFound
|
return "", api.ErrNotFound
|
||||||
}
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -62,59 +64,8 @@ func (cli *Client) CreateIngressRecords(ctx context.Context, serviceID string) (
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
// Verify that the Caddy container is reachable on the machine by its public IP.
|
if err = verifyCaddyReachable(ctx, m.Machine); err == nil {
|
||||||
publicIP, _ := m.Machine.PublicIp.ToAddr()
|
|
||||||
|
|
||||||
pw := progress.ContextWriter(ctx)
|
|
||||||
eventID := fmt.Sprintf("Machine %s (%s)", m.Machine.Name, publicIP)
|
|
||||||
pw.Event(progress.NewEvent(eventID, progress.Working, "Querying"))
|
|
||||||
|
|
||||||
verifyURL := fmt.Sprintf("http://%s%s", publicIP, caddyfile.VerifyPath)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, verifyURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
pw.Event(progress.NewEvent(eventID, progress.Error, err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
e := unreachable(eventID)
|
|
||||||
e.Text = fmt.Sprintf("Failed to send HTTP request: %v", err)
|
|
||||||
pw.Event(e)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
e := unreachable(eventID)
|
|
||||||
e.Text = fmt.Sprintf("Unexpected HTTP response status code: %d", resp.StatusCode)
|
|
||||||
pw.Event(e)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
e := unreachable(eventID)
|
|
||||||
e.Text = fmt.Sprintf("Failed to read HTTP response body: %v", err)
|
|
||||||
pw.Event(e)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the response body is the machine ID to ensure the correct Caddy container is responding.
|
|
||||||
if string(body) == m.Machine.Id {
|
|
||||||
pw.Event(progress.NewEvent(eventID, progress.Done, "Reachable"))
|
|
||||||
reachableMachines <- m.Machine
|
reachableMachines <- m.Machine
|
||||||
} else {
|
|
||||||
bodyStr := string(body)
|
|
||||||
if len(bodyStr) > 50 {
|
|
||||||
bodyStr = bodyStr[:50] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
e := unreachable(eventID)
|
|
||||||
e.Text = fmt.Sprintf("Unexpected HTTP response body: %s", bodyStr)
|
|
||||||
pw.Event(e)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -151,6 +102,76 @@ func (cli *Client) CreateIngressRecords(ctx context.Context, serviceID string) (
|
|||||||
return resp.Records, nil
|
return resp.Records, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verifyCaddyReachable verifies that the Caddy service is reachable on the machine by its public IP.
|
||||||
|
func verifyCaddyReachable(ctx context.Context, m *pb.MachineInfo) error {
|
||||||
|
publicIP, _ := m.PublicIp.ToAddr()
|
||||||
|
|
||||||
|
pw := progress.ContextWriter(ctx)
|
||||||
|
eventID := fmt.Sprintf("Machine %s (%s)", m.Name, publicIP)
|
||||||
|
pw.Event(progress.NewEvent(eventID, progress.Working, "Querying"))
|
||||||
|
|
||||||
|
verifyURL := fmt.Sprintf("http://%s%s", publicIP, caddyfile.VerifyPath)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, verifyURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
pw.Event(progress.NewEvent(eventID, progress.Error, err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||||
|
backoff.WithMaxInterval(1*time.Second),
|
||||||
|
backoff.WithMaxElapsedTime(5*time.Second),
|
||||||
|
), ctx)
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
do := func() (*http.Response, error) {
|
||||||
|
return client.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := backoff.RetryWithData(do, boff)
|
||||||
|
if err != nil {
|
||||||
|
e := unreachable(eventID)
|
||||||
|
e.Text = fmt.Sprintf("Failed to send HTTP request: %v", err)
|
||||||
|
pw.Event(e)
|
||||||
|
|
||||||
|
return fmt.Errorf("send HTTP request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
e := unreachable(eventID)
|
||||||
|
e.Text = fmt.Sprintf("Unexpected HTTP response status code: %d", resp.StatusCode)
|
||||||
|
pw.Event(e)
|
||||||
|
|
||||||
|
return fmt.Errorf("unexpected HTTP response status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
e := unreachable(eventID)
|
||||||
|
e.Text = fmt.Sprintf("Failed to read HTTP response body: %v", err)
|
||||||
|
pw.Event(e)
|
||||||
|
|
||||||
|
return fmt.Errorf("read HTTP response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the response body is the machine ID to ensure the correct Caddy container is responding.
|
||||||
|
if string(body) == m.Id {
|
||||||
|
pw.Event(progress.NewEvent(eventID, progress.Done, "Reachable"))
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
bodyStr := string(body)
|
||||||
|
if len(bodyStr) > 50 {
|
||||||
|
bodyStr = bodyStr[:50] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
e := unreachable(eventID)
|
||||||
|
e.Text = fmt.Sprintf("Unexpected HTTP response body: %s", bodyStr)
|
||||||
|
pw.Event(e)
|
||||||
|
|
||||||
|
return fmt.Errorf("unexpected HTTP response body: %s", bodyStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// unreachable creates a new Unreachable error event.
|
// unreachable creates a new Unreachable error event.
|
||||||
func unreachable(id string) progress.Event {
|
func unreachable(id string) progress.Event {
|
||||||
return progress.NewEvent(
|
return progress.NewEvent(
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
dockerclient "github.com/docker/docker/client"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (cli *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
||||||
|
images, err := cli.Docker.InspectImage(ctx, id)
|
||||||
|
if dockerclient.IsErrNotFound(err) {
|
||||||
|
err = api.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return images, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cli *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
|
||||||
|
return cli.Docker.InspectRemoteImage(ctx, id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MapNameResolver resolves machine and container IDs to their names using a static map.
|
||||||
|
type MapNameResolver struct {
|
||||||
|
machines map[string]string
|
||||||
|
containers map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNameResolver(machines, containers map[string]string) *MapNameResolver {
|
||||||
|
return &MapNameResolver{
|
||||||
|
machines: machines,
|
||||||
|
containers: containers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MapNameResolver) MachineName(machineID string) string {
|
||||||
|
if name, ok := r.machines[machineID]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return machineID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MapNameResolver) ContainerName(containerID string) string {
|
||||||
|
if name, ok := r.containers[containerID]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return containerID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceOperationNameResolver returns a machine and container name resolver for a service that can be used to format
|
||||||
|
// deployment operations.
|
||||||
|
func (cli *Client) ServiceOperationNameResolver(ctx context.Context, svc api.Service) (*MapNameResolver, error) {
|
||||||
|
machines, err := cli.ListMachines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
machineNames := make(map[string]string, len(machines))
|
||||||
|
for _, m := range machines {
|
||||||
|
machineNames[m.Machine.Id] = m.Machine.Name
|
||||||
|
}
|
||||||
|
containerNames := make(map[string]string, len(svc.Containers))
|
||||||
|
for _, c := range svc.Containers {
|
||||||
|
containerNames[c.Container.ID] = c.Container.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewNameResolver(machineNames, containerNames), nil
|
||||||
|
}
|
||||||
@@ -4,41 +4,27 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"slices"
|
|
||||||
"sync"
|
|
||||||
"uncloud/internal/api"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (cli *Client) PrepareDeploymentSpec(ctx context.Context, spec api.ServiceSpec) (api.ServiceSpec, error) {
|
|
||||||
domain, err := cli.GetDomain(ctx)
|
|
||||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
|
||||||
return spec, fmt.Errorf("get domain: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the domain is not found (not reserved), an empty domain is used for the resolver.
|
|
||||||
resolver := NewServiceSpecResolver(domain)
|
|
||||||
|
|
||||||
if err = resolver.Resolve(&spec); err != nil {
|
|
||||||
return spec, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return spec, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type RunServiceResponse struct {
|
type RunServiceResponse struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *Client) RunService(
|
func (cli *Client) RunService(
|
||||||
ctx context.Context, spec api.ServiceSpec, filter MachineFilter,
|
ctx context.Context, spec api.ServiceSpec, filter deploy.MachineFilter,
|
||||||
) (RunServiceResponse, error) {
|
) (RunServiceResponse, error) {
|
||||||
var resp RunServiceResponse
|
var resp RunServiceResponse
|
||||||
|
|
||||||
@@ -52,40 +38,35 @@ func (cli *Client) RunService(
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return resp, fmt.Errorf("service with name '%s' already exists", spec.Name)
|
return resp, fmt.Errorf("service with name '%s' already exists", spec.Name)
|
||||||
}
|
}
|
||||||
if !errors.Is(err, ErrNotFound) {
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
return resp, fmt.Errorf("inspect service: %w", err)
|
return resp, fmt.Errorf("inspect service: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
deployment := cli.NewDeployment(spec, &deploy.RollingStrategy{MachineFilter: filter})
|
||||||
if spec, err = cli.PrepareDeploymentSpec(ctx, spec); err != nil {
|
plan, err := deployment.Plan(ctx)
|
||||||
return resp, fmt.Errorf("prepare service spec ready for deployment: %w", err)
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("plan deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
deploy, err := cli.NewDeployment(spec, &RollingStrategy{MachineFilter: filter})
|
_, err = deployment.Run(ctx)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create deployment: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceID, err := deploy.Run(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.ID = serviceID
|
resp.ID = plan.ServiceID
|
||||||
// TODO: get the service name from the plan when it's available.
|
resp.Name = plan.ServiceName
|
||||||
resp.Name = spec.Name
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}, cli.progressOut(), fmt.Sprintf("Running service %s (%s mode)", spec.Name, spec.Mode))
|
}, cli.progressOut(), fmt.Sprintf("Running service %s (%s mode)", plan.ServiceName, spec.Mode))
|
||||||
|
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// InspectService returns detailed information about a service and its containers.
|
// InspectService returns detailed information about a service and its containers.
|
||||||
// The id parameter can be either a service ID or name.
|
// The nameOrID parameter can be either a service name or ID.
|
||||||
func (cli *Client) InspectService(ctx context.Context, id string) (api.Service, error) {
|
func (cli *Client) InspectService(ctx context.Context, nameOrID string) (api.Service, error) {
|
||||||
var svc api.Service
|
var svc api.Service
|
||||||
|
|
||||||
machines, err := cli.ListMachines(ctx)
|
machines, err := cli.ListMachines(ctx)
|
||||||
@@ -107,22 +88,16 @@ func (cli *Client) InspectService(ctx context.Context, id string) (api.Service,
|
|||||||
}
|
}
|
||||||
listCtx := metadata.NewOutgoingContext(ctx, md)
|
listCtx := metadata.NewOutgoingContext(ctx, md)
|
||||||
|
|
||||||
// List only uncloud-managed containers that belong to some service.
|
// List all service containers including stopped ones.
|
||||||
opts := container.ListOptions{
|
opts := container.ListOptions{All: true}
|
||||||
All: true,
|
machineContainers, err := cli.Docker.ListServiceContainers(listCtx, nameOrID, opts)
|
||||||
Filters: filters.NewArgs(
|
|
||||||
filters.Arg("label", api.LabelServiceID),
|
|
||||||
filters.Arg("label", api.LabelManaged),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
machineContainers, err := cli.Docker.ListContainers(listCtx, opts)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return svc, fmt.Errorf("list containers: %w", err)
|
return svc, fmt.Errorf("list containers: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all containers on all machines that belong to the specified service.
|
// Collect all containers on all machines that belong to the specified service.
|
||||||
foundByID := false
|
foundByID := false
|
||||||
var containers []api.MachineContainer
|
var containers []api.MachineServiceContainer
|
||||||
for _, mc := range machineContainers {
|
for _, mc := range machineContainers {
|
||||||
// Metadata can be nil if the request was broadcasted to only one machine.
|
// Metadata can be nil if the request was broadcasted to only one machine.
|
||||||
if mc.Metadata == nil && len(machineContainers) > 1 {
|
if mc.Metadata == nil && len(machineContainers) > 1 {
|
||||||
@@ -150,15 +125,14 @@ func (cli *Client) InspectService(ctx context.Context, id string) (api.Service,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range mc.Containers {
|
for _, ctr := range mc.Containers {
|
||||||
ctr := api.Container{ContainerJSON: c}
|
if ctr.ServiceID() == nameOrID || ctr.ServiceName() == nameOrID {
|
||||||
if ctr.ServiceID() == id || ctr.ServiceName() == id {
|
containers = append(containers, api.MachineServiceContainer{
|
||||||
containers = append(containers, api.MachineContainer{
|
|
||||||
MachineID: machineID,
|
MachineID: machineID,
|
||||||
Container: ctr,
|
Container: ctr,
|
||||||
})
|
})
|
||||||
|
|
||||||
if ctr.ServiceID() == id {
|
if ctr.ServiceID() == nameOrID {
|
||||||
foundByID = true
|
foundByID = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,22 +140,22 @@ func (cli *Client) InspectService(ctx context.Context, id string) (api.Service,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(containers) == 0 {
|
if len(containers) == 0 {
|
||||||
return svc, ErrNotFound
|
return svc, api.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// Containers from different services may share the same service name (distributed and eventually consistent store
|
// Containers from different services may share the same service name (distributed and eventually consistent store
|
||||||
// may not prevent this), or a service name might match another service's ID. In these cases, matching by ID takes
|
// may not prevent this), or a service name might match another service's ID. In these cases, matching by ID takes
|
||||||
// priority over matching by name.
|
// priority over matching by name.
|
||||||
if foundByID {
|
if foundByID {
|
||||||
containers = slices.DeleteFunc(containers, func(mc api.MachineContainer) bool {
|
containers = slices.DeleteFunc(containers, func(mc api.MachineServiceContainer) bool {
|
||||||
return mc.Container.ServiceID() != id
|
return mc.Container.ServiceID() != nameOrID
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Matched only by name but there could be multiple services with the same name.
|
// Matched only by name but there could be multiple services with the same name.
|
||||||
serviceID := containers[0].Container.ServiceID()
|
serviceID := containers[0].Container.ServiceID()
|
||||||
for _, mc := range containers[1:] {
|
for _, mc := range containers[1:] {
|
||||||
if mc.Container.ServiceID() != serviceID {
|
if mc.Container.ServiceID() != serviceID {
|
||||||
return svc, fmt.Errorf("multiple services found with name '%s', use the service ID instead", id)
|
return svc, fmt.Errorf("multiple services found with name '%s', use the service ID instead", nameOrID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,7 +183,7 @@ func (cli *Client) InspectServiceFromStore(ctx context.Context, id string) (api.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
if s, ok := status.FromError(err); ok {
|
if s, ok := status.FromError(err); ok {
|
||||||
if s.Code() == codes.NotFound {
|
if s.Code() == codes.NotFound {
|
||||||
return svc, ErrNotFound
|
return svc, api.ErrNotFound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return svc, err
|
return svc, err
|
||||||
@@ -257,7 +231,7 @@ func (cli *Client) RemoveService(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = cli.RemoveContainer(ctx, svc.ID, mc.Container.ID, container.RemoveOptions{})
|
err = cli.RemoveContainer(ctx, svc.ID, mc.Container.ID, container.RemoveOptions{})
|
||||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||||
errCh <- fmt.Errorf("remove container '%s': %w", mc.Container.ID, err)
|
errCh <- fmt.Errorf("remove container '%s': %w", mc.Container.ID, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -293,15 +267,9 @@ func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
|
|||||||
}
|
}
|
||||||
listCtx := metadata.NewOutgoingContext(ctx, md)
|
listCtx := metadata.NewOutgoingContext(ctx, md)
|
||||||
|
|
||||||
// List only uncloud-managed containers that belong to some service.
|
// List all containers including stopped ones.
|
||||||
opts := container.ListOptions{
|
opts := container.ListOptions{All: true}
|
||||||
All: true,
|
machineContainers, err := cli.Docker.ListServiceContainers(listCtx, "", opts)
|
||||||
Filters: filters.NewArgs(
|
|
||||||
filters.Arg("label", api.LabelServiceID),
|
|
||||||
filters.Arg("label", api.LabelManaged),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
machineContainers, err := cli.Docker.ListContainers(listCtx, opts)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list containers: %w", err)
|
return nil, fmt.Errorf("list containers: %w", err)
|
||||||
}
|
}
|
||||||
@@ -312,20 +280,19 @@ func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
|
|||||||
for _, mc := range machineContainers {
|
for _, mc := range machineContainers {
|
||||||
if mc.Metadata != nil && mc.Metadata.Error != "" {
|
if mc.Metadata != nil && mc.Metadata.Error != "" {
|
||||||
// TODO: return failed machines in the response.
|
// TODO: return failed machines in the response.
|
||||||
fmt.Printf("WARNING: failed to list containers on machine '%s': %s\n",
|
fmt.Fprintf(os.Stderr, "WARNING: failed to list containers on machine '%s': %s\n",
|
||||||
mc.Metadata.Machine, mc.Metadata.Error)
|
mc.Metadata.Machine, mc.Metadata.Error)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range mc.Containers {
|
for _, ctr := range mc.Containers {
|
||||||
ctr := api.Container{ContainerJSON: c}
|
|
||||||
if _, ok := servicesByID[ctr.ServiceID()]; ok {
|
if _, ok := servicesByID[ctr.ServiceID()]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
svc, err := cli.InspectService(ctx, ctr.ServiceID())
|
svc, err := cli.InspectService(ctx, ctr.ServiceID())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, api.ErrNotFound) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("inspect service: %w", err)
|
return nil, fmt.Errorf("inspect service: %w", err)
|
||||||
@@ -2,10 +2,10 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"uncloud/internal/machine/network"
|
|
||||||
"uncloud/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
+17
-3
@@ -47,7 +47,7 @@ Uncloud supports only systemd-based Linux systems for now."
|
|||||||
}
|
}
|
||||||
|
|
||||||
install_docker() {
|
install_docker() {
|
||||||
if command_exists docker; then
|
if command_exists dockerd; then
|
||||||
log "✓ Docker is already installed."
|
log "✓ Docker is already installed."
|
||||||
docker version
|
docker version
|
||||||
return
|
return
|
||||||
@@ -111,23 +111,37 @@ install_uncloud_binaries() {
|
|||||||
trap "rm -rf '$tmp_dir'" EXIT
|
trap "rm -rf '$tmp_dir'" EXIT
|
||||||
|
|
||||||
local uncloudd_url
|
local uncloudd_url
|
||||||
|
local uninstall_url
|
||||||
if [ "${UNCLOUD_VERSION}" == "latest" ]; then
|
if [ "${UNCLOUD_VERSION}" == "latest" ]; then
|
||||||
uncloudd_url="${UNCLOUD_GITHUB_URL}/releases/latest/download/uncloudd_linux_${file_arch}.tar.gz"
|
uncloudd_url="${UNCLOUD_GITHUB_URL}/releases/latest/download/uncloudd_linux_${file_arch}.tar.gz"
|
||||||
|
uninstall_url="https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh"
|
||||||
else
|
else
|
||||||
uncloudd_url="${UNCLOUD_GITHUB_URL}/releases/download/${UNCLOUD_VERSION}/uncloudd_linux_${file_arch}.tar.gz"
|
uncloudd_url="${UNCLOUD_GITHUB_URL}/releases/download/${UNCLOUD_VERSION}/uncloudd_linux_${file_arch}.tar.gz"
|
||||||
|
uninstall_url="https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/${UNCLOUD_VERSION}/scripts/uninstall.sh"
|
||||||
fi
|
fi
|
||||||
local uncloudd_download_path="${tmp_dir}/uncloudd.tar.gz"
|
local uncloudd_download_path="${tmp_dir}/uncloudd.tar.gz"
|
||||||
|
local uninstall_download_path="${tmp_dir}/uninstall.sh"
|
||||||
|
|
||||||
log "⏳ Downloading uncloudd binary: ${uncloudd_url}"
|
log "⏳ Downloading uncloudd binary: ${uncloudd_url}"
|
||||||
if ! curl -fsSL -o "${uncloudd_download_path}" "${uncloudd_url}"; then
|
if ! curl -fsSL -o "${uncloudd_download_path}" "${uncloudd_url}"; then
|
||||||
error "Failed to download uncloudd binary."
|
error "Failed to download uncloudd binary."
|
||||||
fi
|
fi
|
||||||
tar -xf "${uncloudd_download_path}"
|
tar -xf "${uncloudd_download_path}" --directory "${tmp_dir}"
|
||||||
if ! install ./uncloudd "${uncloudd_install_path}"; then
|
if ! install "${tmp_dir}/uncloudd" "${uncloudd_install_path}"; then
|
||||||
error "Failed to install uncloud binary to ${uncloudd_install_path}"
|
error "Failed to install uncloud binary to ${uncloudd_install_path}"
|
||||||
fi
|
fi
|
||||||
log "✓ uncloudd binary installed: ${uncloudd_install_path}"
|
log "✓ uncloudd binary installed: ${uncloudd_install_path}"
|
||||||
|
|
||||||
|
log "⏳ Downloading uninstall script: ${uninstall_url}"
|
||||||
|
if ! curl -fsSL -o "${uninstall_download_path}" "${uninstall_url}"; then
|
||||||
|
error "Failed to download uninstall script."
|
||||||
|
fi
|
||||||
|
local uninstall_install_path="${INSTALL_BIN_DIR}/uncloud-uninstall"
|
||||||
|
if ! install "${uninstall_download_path}" "${uninstall_install_path}"; then
|
||||||
|
error "Failed to install uninstall.sh script to ${uninstall_install_path}"
|
||||||
|
fi
|
||||||
|
log "✓ uncloud-uninstall script installed: ${uninstall_install_path}"
|
||||||
|
|
||||||
# TODO: install uncloud CLI binary and create a uc alias.
|
# TODO: install uncloud CLI binary and create a uc alias.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
AUTO_CONFIRM=${AUTO_CONFIRM:-false}
|
||||||
|
# Define variables based on the same defaults used in the installation script.
|
||||||
|
INSTALL_BIN_DIR=${INSTALL_BIN_DIR:-/usr/local/bin}
|
||||||
|
INSTALL_SYSTEMD_DIR=${INSTALL_SYSTEMD_DIR:-/etc/systemd/system}
|
||||||
|
UNCLOUD_USER="uncloud"
|
||||||
|
UNCLOUD_DATA_DIR=${UNCLOUD_DATA_DIR:-/var/lib/uncloud}
|
||||||
|
UNCLOUD_RUN_DIR=${UNCLOUD_RUN_DIR:-/run/uncloud}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo -e "\033[1;32m$1\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "\033[1;31mERROR: $1\033[0m" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
if [ "${AUTO_CONFIRM}" = "true" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -r -p "$1 [y/N] " response
|
||||||
|
case "$response" in
|
||||||
|
[yY][eE][sS]|[yY])
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if user is root.
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
error "Please run the uninstall script with sudo or as root."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Confirm before proceeding.
|
||||||
|
log "⚠️This script will uninstall Uncloud and remove ALL Uncloud managed containers on this machine."
|
||||||
|
log "The following actions will be performed:"
|
||||||
|
echo "- Remove Uncloud systemd services"
|
||||||
|
echo "- Remove Uncloud binaries and data"
|
||||||
|
echo "- Remove Uncloud user and group"
|
||||||
|
echo "- Remove all Docker containers managed by Uncloud"
|
||||||
|
echo "- Remove Uncloud Docker network"
|
||||||
|
echo "- Remove Uncloud WireGuard interface"
|
||||||
|
|
||||||
|
if ! confirm "Do you want to proceed with uninstallation?"; then
|
||||||
|
log "Uninstallation cancelled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "⏳ Stopping systemd services..."
|
||||||
|
systemctl stop uncloud.service || log "uncloud.service not running or doesn't exist."
|
||||||
|
systemctl stop uncloud-corrosion.service || log "uncloud-corrosion.service not running or doesn't exist."
|
||||||
|
systemctl disable uncloud.service || log "uncloud.service already disabled or doesn't exist."
|
||||||
|
systemctl disable uncloud-corrosion.service || log "uncloud-corrosion.service already disabled or doesn't exist."
|
||||||
|
log "✓ Systemd services stopped."
|
||||||
|
|
||||||
|
log "⏳ Removing systemd service files..."
|
||||||
|
rm -fv "${INSTALL_SYSTEMD_DIR}/uncloud.service"
|
||||||
|
rm -fv "${INSTALL_SYSTEMD_DIR}/uncloud-corrosion.service"
|
||||||
|
systemctl daemon-reload
|
||||||
|
log "✓ Systemd service files removed."
|
||||||
|
|
||||||
|
log "⏳ Removing binaries..."
|
||||||
|
rm -fv "${INSTALL_BIN_DIR}/uncloudd"
|
||||||
|
rm -fv "${INSTALL_BIN_DIR}/uncloud-corrosion"
|
||||||
|
log "✓ Binaries removed."
|
||||||
|
|
||||||
|
log "⏳ Removing data and run directories..."
|
||||||
|
rm -rfv "${UNCLOUD_DATA_DIR}"
|
||||||
|
rm -rfv "${UNCLOUD_RUN_DIR}"
|
||||||
|
log "✓ Data and run directories removed."
|
||||||
|
|
||||||
|
log "⏳ Removing Linux user and group..."
|
||||||
|
if id "${UNCLOUD_USER}" &> /dev/null; then
|
||||||
|
userdel "${UNCLOUD_USER}" || error "Failed to remove user ${UNCLOUD_USER}, it may still be used by other processes."
|
||||||
|
log "✓ Linux user '${UNCLOUD_USER}' removed."
|
||||||
|
else
|
||||||
|
log "Linux user '${UNCLOUD_USER}' does not exist."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if the group still exists (other users may be using it).
|
||||||
|
if getent group "${UNCLOUD_USER}" &> /dev/null; then
|
||||||
|
groupdel "${UNCLOUD_USER}" || error "Failed to remove group ${UNCLOUD_USER}, it may still be used by other processes or users."
|
||||||
|
log "✓ Linux group '${UNCLOUD_USER}' removed."
|
||||||
|
else
|
||||||
|
log "Linux group '${UNCLOUD_USER}' does not exist or was already removed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "⏳ Looking for Docker containers and network created by Uncloud..."
|
||||||
|
if command -v docker &> /dev/null; then
|
||||||
|
uncloud_containers=$(docker ps -a --filter "label=uncloud.managed" -q)
|
||||||
|
if [ -n "${uncloud_containers}" ]; then
|
||||||
|
log "Found $(echo "${uncloud_containers}" | wc -w) Uncloud managed containers."
|
||||||
|
log "⏳ Stopping Uncloud managed containers..."
|
||||||
|
echo "${uncloud_containers}" | xargs docker stop || error "Failed to stop Uncloud managed containers."
|
||||||
|
log "⏳ Removing Uncloud managed containers..."
|
||||||
|
echo "${uncloud_containers}" | xargs docker rm || error "Failed to remove Uncloud managed containers."
|
||||||
|
log "✓ Uncloud managed containers stopped and removed."
|
||||||
|
else
|
||||||
|
log "No Uncloud managed containers found."
|
||||||
|
fi
|
||||||
|
|
||||||
|
uncloud_network=$(docker network ls --filter "name=uncloud" -q)
|
||||||
|
if [ -n "${uncloud_network}" ]; then
|
||||||
|
log "⏳ Removing Docker network uncloud..."
|
||||||
|
docker network rm uncloud || error "Failed to remove Docker network uncloud."
|
||||||
|
log "✓ Docker network uncloud removed."
|
||||||
|
else
|
||||||
|
log "Docker network uncloud not found."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "Docker CLI not found, skipping Docker container and network cleanup."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "⏳ Removing WireGuard interface uncloud..."
|
||||||
|
if ip link show uncloud &> /dev/null; then
|
||||||
|
ip link delete uncloud
|
||||||
|
log "✓ WireGuard interface uncloud removed."
|
||||||
|
else
|
||||||
|
log "WireGuard interface uncloud not found."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "⏳ Removing uninstall script..."
|
||||||
|
rm -fv "${INSTALL_BIN_DIR}/uncloud-uninstall"
|
||||||
|
log "✓ Uninstall script removed."
|
||||||
|
|
||||||
|
echo
|
||||||
|
log "✅ Uncloud has been uninstalled successfully!"
|
||||||
|
log "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"
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
mapset "github.com/deckarep/golang-set/v2"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/go-connections/nat"
|
||||||
|
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func assertServiceMatchesSpec(t *testing.T, svc api.Service, spec api.ServiceSpec) {
|
||||||
|
assert.Equal(t, spec.Name, svc.Name)
|
||||||
|
|
||||||
|
if svc.Mode == api.ServiceModeReplicated {
|
||||||
|
assert.Contains(t, []string{"", api.ServiceModeReplicated}, spec.Mode)
|
||||||
|
assert.Len(t, svc.Containers, int(spec.Replicas), "Expected %d replicas", spec.Replicas)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, spec.Mode, svc.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mc := range svc.Containers {
|
||||||
|
assertContainerMatchesSpec(t, mc.Container, spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api.ServiceSpec) {
|
||||||
|
status := deploy.EvalContainerSpecChange(ctr.ServiceSpec, spec)
|
||||||
|
assert.Equal(t, deploy.ContainerUpToDate, status)
|
||||||
|
|
||||||
|
spec = spec.SetDefaults()
|
||||||
|
// Verify labels.
|
||||||
|
assert.Equal(t, spec.Name, ctr.Config.Labels[api.LabelServiceName])
|
||||||
|
assert.Equal(t, spec.Mode, ctr.Config.Labels[api.LabelServiceMode])
|
||||||
|
assert.Contains(t, ctr.Config.Labels, api.LabelManaged)
|
||||||
|
|
||||||
|
// Command and Entrypoint can only be compared if they are set in the spec.
|
||||||
|
// Otherwise, the container takes them from the image.
|
||||||
|
if spec.Container.Command != nil {
|
||||||
|
assert.EqualValues(t, spec.Container.Command, ctr.Config.Cmd)
|
||||||
|
}
|
||||||
|
if spec.Container.Entrypoint != nil {
|
||||||
|
assert.EqualValues(t, spec.Container.Entrypoint, ctr.Config.Entrypoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, spec.Container.Image, ctr.Config.Image)
|
||||||
|
assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init)
|
||||||
|
assert.ElementsMatch(t, spec.Container.Volumes, ctr.HostConfig.Binds)
|
||||||
|
|
||||||
|
// Compare host ports.
|
||||||
|
portBindings := make(nat.PortMap)
|
||||||
|
for _, p := range spec.Ports {
|
||||||
|
if p.Mode != api.PortModeHost {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := nat.NewPort(p.Protocol, strconv.Itoa(int(p.ContainerPort)))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
binding := nat.PortBinding{HostPort: strconv.Itoa(int(p.PublishedPort))}
|
||||||
|
if p.HostIP.IsValid() {
|
||||||
|
binding.HostIP = p.HostIP.String()
|
||||||
|
}
|
||||||
|
portBindings[port] = append(portBindings[port], binding)
|
||||||
|
}
|
||||||
|
assert.Equal(t, portBindings, ctr.HostConfig.PortBindings)
|
||||||
|
|
||||||
|
assert.Equal(t, container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyAlways,
|
||||||
|
MaximumRetryCount: 0,
|
||||||
|
}, ctr.HostConfig.RestartPolicy)
|
||||||
|
|
||||||
|
// Verify network settings.
|
||||||
|
assert.Len(t, ctr.NetworkSettings.Networks, 1)
|
||||||
|
assert.Contains(t, ctr.NetworkSettings.Networks, machinedocker.NetworkName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceContainersByMachine returns a map of machine ID to service containers on that machine.
|
||||||
|
func serviceContainersByMachine(t *testing.T, svc api.Service) map[string][]api.ServiceContainer {
|
||||||
|
containers := make(map[string][]api.ServiceContainer)
|
||||||
|
for _, c := range svc.Containers {
|
||||||
|
containers[c.MachineID] = append(containers[c.MachineID], c.Container)
|
||||||
|
}
|
||||||
|
return containers
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceMachines(t *testing.T, svc api.Service) mapset.Set[string] {
|
||||||
|
machines := mapset.NewSet[string]()
|
||||||
|
for _, c := range svc.Containers {
|
||||||
|
machines.Add(c.MachineID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return machines
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceContainerIDs(t *testing.T, svc api.Service) mapset.Set[string] {
|
||||||
|
ids := mapset.NewSet[string]()
|
||||||
|
for _, c := range svc.Containers {
|
||||||
|
ids.Add(c.Container.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user