Compare commits

..
59 Commits
Author SHA1 Message Date
Pavel Sviderski 0e80f2e5b2 chore: replace immutable hash with container spec comparison 2025-03-31 15:58:02 +10:00
Pavel Sviderski 64f6a4f3d3 chore: use original service spec instead of deriving from container, fixes diff for scale 2025-03-30 12:54:36 +10:00
Pavel Sviderski 0975cbab66 chore: handle RemoveContainer server-side that deletes container from machine db 2025-03-29 20:06:49 +10:00
Pavel Sviderski 615022c9ac feat(scale): set file permissions for machine.db database to 600 (may container secrets as env vars) 2025-03-29 15:19:37 +10:00
Pavel Sviderski ecbd4378e4 feat(scale): store container spec in local machine.db sqlite 2025-03-29 15:14:43 +10:00
Pavel Sviderski 1becd033e6 chore: rename CaddyConfigPath field 2025-03-29 11:39:45 +10:00
Pavel Sviderski 1cba1eef0a refactor: handle CreateContainer server-side 2025-03-28 19:01:19 +10:00
Pavel Sviderski 3893005866 fix(compose): remove ServiceSpec validation for 0 replicas, it's handled when resolving defaults 2025-03-27 20:40:11 +10:00
Pavel Sviderski 1911e03c82 feat(compose): handle image pull policy 2025-03-27 15:15:57 +10:00
Pavel Sviderski 641b11a92d feat: implement --pull policy for run command: always, missing, never 2025-03-27 15:06:32 +10:00
Pavel Sviderski 7c7530f977 chore: add comments and TODOs about the disabled image digest resolver 2025-03-27 14:12:20 +10:00
Pavel Sviderski 6e7fe405ef feat(compose): partially implement image resolver (pinner) but disable for now 2025-03-26 20:42:41 +10:00
Pavel Sviderski 03c5c054e1 feat(compose): client methods for inspecting images 2025-03-26 20:38:41 +10:00
Pavel Sviderski c42968c812 feat(compose): add InspectRemoteImage api endpoint to inspect images in registry 2025-03-26 20:37:53 +10:00
Pavel Sviderski ffef41081b feat(compose): add ContainerSpec.PullPolicy field to define when to pull image from registry 2025-03-25 11:03:19 +10:00
Pavel Sviderski 58b51b974c refactor: postpone spec resolver initialisation when creating Deployment 2025-03-24 21:56:30 +10:00
Pavel Sviderski 2d923380a3 chore: compare container ports to spec in assertContainerMatchesSpec 2025-03-24 20:24:40 +10:00
Pavel Sviderski 3910101a88 refactor: embed ServiceSpecResolver into Deployment to handle spec only there 2025-03-24 20:10:28 +10:00
Pavel Sviderski 7a9d0cf9db refactor: move compose and deploy packages under client 2025-03-24 14:41:50 +10:00
Pavel Sviderski 042dd594e0 refactor: decouple deploy and compose packages from client dependency 2025-03-24 14:38:16 +10:00
Pavel Sviderski 1471a94162 refactor(compose): move compose deployment from client to compose pkg 2025-03-24 11:11:36 +10:00
Pavel Sviderski b1abd07dde refactor: move api, client, compose packages to pkg 2025-03-22 18:43:00 +10:00
Pavel Sviderski 3757813b20 refactor: rename main module to github.com/psviderski/uncloud 2025-03-22 18:25:08 +10:00
Pavel Sviderski f1a150821c fix(compose): client.ImageInspect to handle broadcasted responses to multiple machines 2025-03-22 08:44:17 +10:00
Pavel Sviderski 874af1ba10 feat(compose): implement InspectImage rpc with broadcast support 2025-03-21 19:43:58 +10:00
Pavel Sviderski 2ad098c612 feat(deploy): introduce proto for InspectImage and InspectRemoteImage methods 2025-03-20 21:36:41 +10:00
Pavel Sviderski 4172cb826f refactor: compose deployment plan to not use PrepareDeploymentSpec 2025-03-19 16:54:00 +10:00
Pavel Sviderski 041d51b74f refactor: compose deployment with pluggable spec resolver, basic test 2025-03-19 16:28:17 +10:00
Pavel Sviderski fb01604d21 fix: recreate containers only if spec changed by comparing spec hash 2025-03-19 13:11:04 +10:00
Pavel SviderskiandGitHub bd8bcbd2f9 Merge pull request #40 from cedws/userguide 2025-03-19 09:24:50 +10:00
Connor Edwards 07501813c8 docs: s/uncloud/uc 2025-03-18 21:04:43 +09:00
Pavel Sviderski 1bbcfa8519 feat(entrypoint): allow to override service ENTRYPOINT for run command 2025-03-18 16:44:15 +10:00
Pavel Sviderski e8715fce38 fix: list containers if some of them concurrently removed, fixes #33 2025-03-18 15:39:15 +10:00
Pavel Sviderski a0a66f34c4 feat(connect): add --connect flag to connect to remote cluster machine without using config 2025-03-18 14:01:38 +10:00
Pavel Sviderski b799b7518e feat(compose): add TODO to address deployment idempotency 2025-03-17 20:32:14 +10:00
Connor Edwards 2571498ed2 docs: add basic user guide 2025-03-16 14:51:42 +09:00
Pavel Sviderski 5bcbfc332d feat(compose): deploy compose services without waiting dependencies 2025-03-15 20:45:02 +10:00
Pavel Sviderski 34574e41fc feat(compose): plan compose deployment 2025-03-15 20:43:30 +10:00
Pavel Sviderski c7355afa3d refactor: move Confirm logic to common place from caddy deployment 2025-03-15 20:42:44 +10:00
Pavel Sviderski a907fdb1af chore: update todo 2025-03-15 20:41:57 +10:00
Pavel Sviderski c9ba4adccb chore: add todo for ServiceSpec that it may return a spec not equal to original 2025-03-15 20:41:02 +10:00
Pavel Sviderski a0551d2d64 fix: print service endpoints for run service only when non-empty 2025-03-15 19:35:21 +10:00
Pavel Sviderski 76129ea056 refactor: include service name in deployment plan 2025-03-15 19:34:37 +10:00
Pavel Sviderski 6ffd35f596 feat(compose): parse Compose project and transform ports extension 2025-03-15 14:18:06 +10:00
Pavel Sviderski 0ef776e7f1 chore: add link to uncloud-dns repo in README 2025-03-14 22:01:19 +10:00
Pavel Sviderski 0b22b2136a chore: add deployments and managed DNS features to README 2025-03-14 21:59:40 +10:00
Pavel Sviderski e1530bb6f3 feat(deploy): load compose project 2025-03-14 21:41:06 +10:00
Pavel Sviderski 7fc07ce4bd feat(deploy): init a new deploy command to deploy services from Compose file 2025-03-12 20:37:01 +10:00
Pavel Sviderski eb42082955 fix(dns): update DNS records when reserving a new cluster domain, fixes #36 2025-03-11 21:12:00 +10:00
Pavel Sviderski 8719e37856 fix(dns): retry HTTP requests to verify Caddy reachability for up to 5 seconds 2025-03-10 21:26:02 +10:00
Pavel Sviderski e1cc39b0d5 lint: variable collides with imported package name 2025-03-09 19:26:06 +10:00
Pavel Sviderski efe646f54c chore: validate unsupported tcp/udp protocols for ingress port 2025-03-08 19:07:51 +10:00
Pavel Sviderski e130d803e4 fix(website): demo min height to not shrink before video is loaded 2025-03-06 17:30:16 +10:00
Pavel Sviderski 56e7e8baa2 chore(readme): cleanup coming soon features 2025-03-06 17:13:29 +10:00
Pavel Sviderski b963ef39d1 chore(makefile): demo-reset target to reset machines before recording a VHS demo 2025-03-06 16:55:16 +10:00
Pavel Sviderski 1df0fa290f chore(readme): add uc machine ls command 2025-03-06 16:54:35 +10:00
Pavel Sviderski c3bb0c9f49 chore(readme): add Quick demo section with video, update how it works outputs 2025-03-06 16:49:58 +10:00
Pavel Sviderski cf65cecd9e chore: replace terminal example with a demo video on landing 2025-03-06 15:49:06 +10:00
Pavel Sviderski fff17dbb60 add VHS tape script for the terminal demo 2025-03-05 22:17:54 +10:00
104 changed files with 4736 additions and 1612 deletions
+1
View File
@@ -4,6 +4,7 @@
# Allow files and directories. # Allow files and directories.
!cmd/ !cmd/
!internal/ !internal/
!pkg/
!scripts/docker/ !scripts/docker/
!go.* !go.*
+6
View File
@@ -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 \
+107 -11
View File
@@ -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:
@@ -79,24 +89,33 @@ curl -fsS https://get.uncloud.run/install.sh | sh
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:
server's IP address. Allow a few minutes for DNS propagation.
4. Deploy your app from a Docker image:
```bash ```bash
uc run -p app.example.com:8000/https my-app-image 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.
That's it! Your app is now running and accessible at https://app.example.com ✨ 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
Check out the [design document](docs/design.md) to understand Uncloud's design philosophy and goals. Here, let's peek Check out the [design document](docs/design.md) to understand Uncloud's design philosophy and goals. Here, let's peek
@@ -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.
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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() {
+13 -32
View File
@@ -4,16 +4,17 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"github.com/charmbracelet/huh"
"github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/streams"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/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 {
@@ -32,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)
}, },
} }
@@ -48,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)
@@ -57,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)
@@ -86,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 {
@@ -106,7 +107,7 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
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)
@@ -144,7 +145,7 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
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)
} }
@@ -170,7 +171,7 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, progressOut *streams.Out) error { func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, progressOut *streams.Out) error {
if _, err := clusterClient.GetDomain(ctx); err != nil { if _, err := clusterClient.GetDomain(ctx); err != nil {
if errors.Is(err, client.ErrNotFound) { 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
} }
@@ -214,27 +215,7 @@ func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, prog
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
} }
+137
View File
@@ -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")
}
+1 -1
View File
@@ -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 {
+19 -7
View File
@@ -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())
}
+3 -3
View File
@@ -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
+8 -6
View File
@@ -6,17 +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/cmd/uncloud/caddy"
"uncloud/internal/cli"
"uncloud/internal/cli/client"
"uncloud/internal/cli/config"
"uncloud/internal/machine/api/pb"
) )
type addOptions struct { type addOptions struct {
@@ -98,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)
} }
@@ -115,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 {
+8 -7
View File
@@ -4,14 +4,14 @@ 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/cmd/uncloud/caddy"
"uncloud/cmd/uncloud/dns"
"uncloud/internal/cli"
"uncloud/internal/cli/config"
"uncloud/internal/machine/api/pb"
"uncloud/internal/machine/cluster"
) )
type initOptions struct { type initOptions struct {
@@ -77,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", "",
@@ -121,6 +121,7 @@ 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 { if !opts.noDNS {
domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint}) domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint})
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+42 -15
View File
@@ -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(),
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+40 -13
View File
@@ -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
} }
+8 -17
View File
@@ -3,12 +3,13 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"strconv"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"strconv"
"uncloud/internal/api"
"uncloud/internal/cli"
) )
type scaleOptions struct { type scaleOptions struct {
@@ -81,20 +82,10 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
// TODO: Check if all containers have the same spec. If not, prompt user to choose which one to scale. // 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. // This can happen if a service deployment failed midway and some containers were not updated.
// Derive the service spec from the first container. spec := svc.Containers[0].Container.ServiceSpec
spec, err := svc.Containers[0].Container.ServiceSpec()
if err != nil {
return fmt.Errorf("get service spec from container: %w", err)
}
spec.Replicas = opts.replicas spec.Replicas = opts.replicas
deployment := clusterClient.NewDeployment(spec, nil)
deploy, err := clusterClient.NewDeployment(spec, nil) plan, err := deployment.Plan(ctx)
if err != nil {
return fmt.Errorf("create deployment: %w", err)
}
plan, err := deploy.Plan(ctx)
if err != nil { if err != nil {
return fmt.Errorf("plan deployment: %w", err) return fmt.Errorf("plan deployment: %w", err)
} }
@@ -128,7 +119,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
title := fmt.Sprintf("Scaling service %s (%d → %d replicas)", svc.Name, currentReplicas, opts.replicas) title := fmt.Sprintf("Scaling service %s (%d → %d replicas)", svc.Name, currentReplicas, opts.replicas)
err = progress.RunWithTitle(ctx, func(ctx context.Context) error { err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if _, err = deploy.Run(ctx); err != nil { if _, err = deployment.Run(ctx); err != nil {
return fmt.Errorf("deploy service: %w", err) return fmt.Errorf("deploy service: %w", err)
} }
return nil return nil
+3 -3
View File
@@ -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() {
+59
View File
@@ -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
```
+1 -1
View File
@@ -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 (
+17 -6
View File
@@ -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
) )
+49 -9
View File
@@ -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=
-165
View File
@@ -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
View File
@@ -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
}
// 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
} }
func New(configPath string) (*CLI, error) {
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.
-69
View File
@@ -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)
})
}
-116
View File
@@ -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
}
+2 -2
View File
@@ -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".
+1 -1
View File
@@ -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.
+23
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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
+86 -14
View File
@@ -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;
}
+232
View File
@@ -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{
{ {
+1 -1
View File
@@ -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
+8 -7
View File
@@ -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)
+20 -23
View File
@@ -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,
}, },
}, },
}, }}}
}
} }
+13 -7
View File
@@ -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
+5 -5
View File
@@ -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 {
+3 -3
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 (
+56
View File
@@ -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
}
+237 -17
View File
@@ -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
}
+2 -2
View File
@@ -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 (
+1 -1
View File
@@ -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
+367 -7
View File
@@ -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
}
+38 -29
View File
@@ -5,14 +5,6 @@ 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"
@@ -21,17 +13,27 @@ import (
"path/filepath" "path/filepath"
"slices" "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 (
@@ -57,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.
@@ -122,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
@@ -209,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)
@@ -360,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)
} }
@@ -744,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
+6 -6
View File
@@ -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 (
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"net" "net"
"net/netip" "net/netip"
"time" "time"
"uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
) )
const ( const (
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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 (
+2 -2
View File
@@ -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 (
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"net/netip" "net/netip"
"strings" "strings"
"uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
) )
const ( const (
+3 -3
View File
@@ -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 (
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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 (
+67
View File
@@ -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 {
+5
View File
@@ -0,0 +1,5 @@
package api
import "errors"
var ErrNotFound = errors.New("not found")
+28
View File
@@ -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
}
+29
View File
@@ -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)
}
+252
View File
@@ -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)
} }
@@ -55,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)
+17
View File
@@ -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) {
+112
View File
@@ -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)
}
+43
View File
@@ -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
}
+39
View File
@@ -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
}
+53
View File
@@ -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))
+12
View File
@@ -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)
}
+39
View File
@@ -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,49 +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
}
// 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.NameWithoutSlash()
}
return NewNameResolver(machineNames, containerNames), nil
}
+318
View File
@@ -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(
+20
View File
@@ -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)
}
+54
View File
@@ -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 {
+106
View File
@@ -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
}
+3 -3
View File
@@ -4,6 +4,9 @@ import (
"context" "context"
"errors" "errors"
dockerclient "github.com/docker/docker/client" dockerclient "github.com/docker/docker/client"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/ucind"
"github.com/psviderski/uncloud/pkg/client"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
@@ -11,9 +14,6 @@ import (
"os" "os"
"testing" "testing"
"time" "time"
"uncloud/internal/cli/client"
"uncloud/internal/machine/api/pb"
"uncloud/internal/ucind"
) )
func createTestCluster( func createTestCluster(
+70
View File
@@ -0,0 +1,70 @@
package e2e
import (
"context"
"errors"
"github.com/psviderski/uncloud/internal/ucind"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/compose"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestComposeDeployment(t *testing.T) {
t.Parallel()
clusterName := "ucind-test.compose"
ctx := context.Background()
c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 3}, true)
cli, err := c.Machines[0].Connect(ctx)
require.NoError(t, err)
t.Run("basic", func(t *testing.T) {
t.Parallel()
name := "basic"
t.Cleanup(func() {
err := cli.RemoveService(ctx, name)
if !errors.Is(err, api.ErrNotFound) {
require.NoError(t, err)
}
})
project, err := compose.LoadProject(ctx, []string{"fixtures/basic-compose.yaml"})
require.NoError(t, err)
deploy, err := compose.NewDeployment(ctx, cli, project)
require.NoError(t, err)
plan, err := deploy.Plan(ctx)
require.NoError(t, err)
assert.Len(t, plan.Operations, 1, "Expected 1 service to deploy")
err = deploy.Run(ctx)
require.NoError(t, err)
svc, err := cli.InspectService(ctx, name)
require.NoError(t, err)
expectedSpec := api.ServiceSpec{
Name: name,
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
// TODO: resolve image digest and substitute the image with the image@digest.
Image: "portainer/pause:3.9",
},
Ports: []api.PortSpec{
{
Hostname: "basic.example.com",
ContainerPort: 80,
Protocol: api.ProtocolHTTPS,
Mode: api.PortModeIngress,
},
},
Replicas: 1,
}
assertServiceMatchesSpec(t, svc, expectedSpec)
})
}
+5
View File
@@ -0,0 +1,5 @@
services:
basic:
image: portainer/pause:3.9
x-ports:
- basic.example.com:80/https

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