mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e80f2e5b2 | ||
|
|
64f6a4f3d3 | ||
|
|
0975cbab66 | ||
|
|
615022c9ac | ||
|
|
ecbd4378e4 | ||
|
|
1becd033e6 | ||
|
|
1cba1eef0a | ||
|
|
3893005866 | ||
|
|
1911e03c82 | ||
|
|
641b11a92d | ||
|
|
7c7530f977 | ||
|
|
6e7fe405ef | ||
|
|
03c5c054e1 | ||
|
|
c42968c812 | ||
|
|
ffef41081b | ||
|
|
58b51b974c | ||
|
|
2d923380a3 | ||
|
|
3910101a88 | ||
|
|
7a9d0cf9db | ||
|
|
042dd594e0 | ||
|
|
1471a94162 | ||
|
|
b1abd07dde | ||
|
|
3757813b20 | ||
|
|
f1a150821c | ||
|
|
874af1ba10 | ||
|
|
2ad098c612 | ||
|
|
4172cb826f | ||
|
|
041d51b74f | ||
|
|
fb01604d21 | ||
|
|
bd8bcbd2f9 | ||
|
|
07501813c8 | ||
|
|
1bbcfa8519 | ||
|
|
e8715fce38 | ||
|
|
a0a66f34c4 | ||
|
|
b799b7518e | ||
|
|
2571498ed2 | ||
|
|
5bcbfc332d | ||
|
|
34574e41fc | ||
|
|
c7355afa3d | ||
|
|
a907fdb1af | ||
|
|
c9ba4adccb | ||
|
|
a0551d2d64 | ||
|
|
76129ea056 | ||
|
|
6ffd35f596 | ||
|
|
0ef776e7f1 | ||
|
|
0b22b2136a | ||
|
|
e1530bb6f3 | ||
|
|
7fc07ce4bd | ||
|
|
eb42082955 | ||
|
|
8719e37856 | ||
|
|
e1cc39b0d5 | ||
|
|
efe646f54c | ||
|
|
e130d803e4 | ||
|
|
56e7e8baa2 | ||
|
|
b963ef39d1 | ||
|
|
1df0fa290f | ||
|
|
c3bb0c9f49 | ||
|
|
cf65cecd9e | ||
|
|
fff17dbb60 |
@@ -4,6 +4,7 @@
|
||||
# Allow files and directories.
|
||||
!cmd/
|
||||
!internal/
|
||||
!pkg/
|
||||
!scripts/docker/
|
||||
!go.*
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ reset-dev:
|
||||
ssh spy@192.168.40.176 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
||||
ssh ubuntu@152.67.101.197 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
||||
|
||||
demo-reset:
|
||||
rm -fv ~/.config/uncloud/config.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
|
||||
proto:
|
||||
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||
|
||||
@@ -16,6 +16,14 @@ some machines go offline.
|
||||
Uncloud aims to be the solution for developers who want the flexibility of self-hosted infrastructure without the
|
||||
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
|
||||
|
||||
* **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.
|
||||
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
|
||||
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.
|
||||
* **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
|
||||
Encrypt.
|
||||
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
||||
@@ -33,12 +44,11 @@ operational complexity of Kubernetes.
|
||||
|
||||
Coming soon:
|
||||
|
||||
* Project isolation through environments/namespaces
|
||||
* Infrastructure as Code using Docker Compose format
|
||||
* Project isolation through environments/namespaces
|
||||
* Persistent volumes and secrets management
|
||||
* Monitoring and log aggregation
|
||||
* Database deployment and management
|
||||
* Curated application catalog
|
||||
|
||||
Here is a diagram of an Uncloud multi-provider cluster of 3 machines:
|
||||
|
||||
@@ -66,36 +76,45 @@ platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bar
|
||||
|
||||
1. Install Uncloud CLI:
|
||||
|
||||
```bash
|
||||
brew install psviderski/tap/uncloud
|
||||
```bash
|
||||
brew install psviderski/tap/uncloud
|
||||
|
||||
# or using curl (macOS/Linux)
|
||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||
```
|
||||
# or using curl (macOS/Linux)
|
||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||
```
|
||||
|
||||
2. Initialize your first machine:
|
||||
|
||||
```bash
|
||||
uc machine init root@your-server-ip
|
||||
```
|
||||
```bash
|
||||
uc machine init root@your-server-ip
|
||||
```
|
||||
|
||||
3. Create a DNS A record in your DNS provider (Cloudflare, Namecheap, etc.) that points `app.example.com` to your
|
||||
3. Deploy your app from a Docker image and publish its container port 8000 as HTTPS using `app.example.com` domain:
|
||||
|
||||
```bash
|
||||
uc run -p app.example.com:8000/https image/my-app
|
||||
```
|
||||
|
||||
4. Create a DNS A record in your DNS provider (Cloudflare, Namecheap, etc.) that points `app.example.com` to your
|
||||
server's IP address. Allow a few minutes for DNS propagation.
|
||||
4. Deploy your app from a Docker image:
|
||||
|
||||
```bash
|
||||
uc run -p app.example.com:8000/https my-app-image
|
||||
```
|
||||
|
||||
That's it! Your app is now running and accessible at https://app.example.com ✨
|
||||
That's it! Your app is now running and accessible at https://app.example.com ✨
|
||||
|
||||
5. Clean up when you're done:
|
||||
|
||||
```bash
|
||||
uc ls
|
||||
# Copy the service ID from the output and remove it:
|
||||
uc rm my-app-id
|
||||
```
|
||||
```bash
|
||||
uc ls
|
||||
# Copy the service name from the output and run the rm command:
|
||||
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
|
||||
|
||||
@@ -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:**
|
||||
|
||||
```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
|
||||
@@ -121,7 +175,42 @@ uc machine init root@your-server-ip
|
||||
**When you add another machine:**
|
||||
|
||||
```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.
|
||||
@@ -140,7 +229,14 @@ and automatically establish a WireGuard tunnel with it.
|
||||
**When you run a service:**
|
||||
|
||||
```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.
|
||||
|
||||
@@ -3,7 +3,7 @@ package cluster
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/ucind"
|
||||
"github.com/psviderski/uncloud/internal/ucind"
|
||||
)
|
||||
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
|
||||
@@ -3,7 +3,7 @@ package cluster
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/ucind"
|
||||
"github.com/psviderski/uncloud/internal/ucind"
|
||||
)
|
||||
|
||||
func NewRemoveCommand() *cobra.Command {
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"os"
|
||||
"strings"
|
||||
"uncloud/cmd/ucind/cluster"
|
||||
"uncloud/internal/ucind"
|
||||
"github.com/psviderski/uncloud/cmd/ucind/cluster"
|
||||
"github.com/psviderski/uncloud/internal/ucind"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
+13
-32
@@ -4,16 +4,17 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"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"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"uncloud/internal/cli"
|
||||
"uncloud/internal/cli/client"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
type deployOptions struct {
|
||||
@@ -32,7 +33,7 @@ func NewDeployCommand() *cobra.Command {
|
||||
"A rolling update is performed when updating existing containers to minimise disruption.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
if !errors.Is(err, client.ErrNotFound) {
|
||||
if !errors.Is(err, api.ErrNotFound) {
|
||||
return fmt.Errorf("inspect caddy service: %w", err)
|
||||
}
|
||||
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("Preparing a deployment plan...")
|
||||
|
||||
var filter client.MachineFilter
|
||||
var filter deploy.MachineFilter
|
||||
if opts.machine != "" {
|
||||
machines := strings.Split(opts.machine, ",")
|
||||
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)
|
||||
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("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()
|
||||
|
||||
confirmed, err := confirm()
|
||||
confirmed, err := cli.Confirm()
|
||||
if err != nil {
|
||||
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 {
|
||||
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').")
|
||||
return nil
|
||||
}
|
||||
@@ -214,27 +215,7 @@ func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, prog
|
||||
return nil
|
||||
}
|
||||
|
||||
func confirm() (bool, error) {
|
||||
var confirmed bool
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(
|
||||
"Do you want to continue?",
|
||||
).
|
||||
Affirmative("Yes!").
|
||||
Negative("No").
|
||||
Value(&confirmed),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return confirmed, nil
|
||||
}
|
||||
|
||||
func machineFilter(machines []string) client.MachineFilter {
|
||||
func machineFilter(machines []string) deploy.MachineFilter {
|
||||
if len(machines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
"github.com/spf13/cobra"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type deployOptions struct {
|
||||
files []string
|
||||
services []string
|
||||
|
||||
cluster string
|
||||
}
|
||||
|
||||
// NewDeployCommand creates a new command to deploy services from a Compose file.
|
||||
func NewDeployCommand() *cobra.Command {
|
||||
opts := deployOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "deploy [FLAGS] [SERVICE...]",
|
||||
// TODO: remove WIP when the command is fully implemented.
|
||||
Short: "WIP: Deploy services from a Compose file.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
if len(args) > 0 {
|
||||
opts.services = args
|
||||
}
|
||||
|
||||
return runDeploy(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||
"One or more Compose files to deploy services from. (default compose-ports-long.yaml)")
|
||||
cmd.Flags().StringVarP(&opts.cluster, "cluster", "c", "",
|
||||
"Name of the cluster to deploy to (default is the current cluster)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runDeploy parses the Compose file(s) and deploys the services.
|
||||
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
project, err := compose.LoadProject(ctx, opts.files)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load compose file(s): %w", err)
|
||||
}
|
||||
|
||||
if len(opts.services) > 0 {
|
||||
// TODO: handle dependencies properly.
|
||||
project, err = project.WithSelectedServices(opts.services, types.IgnoreDependencies)
|
||||
if err != nil {
|
||||
return fmt.Errorf("select services: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
composeDeploy, err := compose.NewDeployment(ctx, clusterClient, project)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create compose deployment: %w", err)
|
||||
}
|
||||
|
||||
plan, err := composeDeploy.Plan(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan deployment: %w", err)
|
||||
}
|
||||
|
||||
if len(plan.Operations) == 0 {
|
||||
fmt.Println("Services are up to date.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("Deployment plan:")
|
||||
|
||||
for _, op := range plan.Operations {
|
||||
svcPlan, ok := op.(*deploy.Plan)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected service Plan, got: %T", op)
|
||||
}
|
||||
|
||||
svc, err := clusterClient.InspectService(ctx, svcPlan.ServiceID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, api.ErrNotFound) {
|
||||
return fmt.Errorf("inspect service: %w", err)
|
||||
}
|
||||
fmt.Printf("- Run service [name=%s]\n", svcPlan.ServiceName)
|
||||
} else {
|
||||
fmt.Printf("- Update service [name=%s]\n", svc.Name)
|
||||
}
|
||||
|
||||
// Initialise a machine and container name resolver to properly format the service plan output.
|
||||
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(indent(svcPlan.Format(resolver), " "))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
confirmed, err := cli.Confirm()
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm deployment: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
fmt.Println("Cancelled. No changes were made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if err := plan.Execute(ctx, clusterClient); err != nil {
|
||||
return fmt.Errorf("deploy services: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), "Deploying services")
|
||||
}
|
||||
|
||||
func indent(text, prefix string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = prefix + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
)
|
||||
|
||||
type releaseOptions struct {
|
||||
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"uncloud/internal/cli"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
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 {
|
||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
if err != nil {
|
||||
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 status.Convert(err).Code() == codes.AlreadyExists {
|
||||
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.Println("Redeploy the Caddy service ('uc caddy deploy') to configure DNS records for the domain " +
|
||||
"to route traffic to the services in the cluster.")
|
||||
return nil
|
||||
|
||||
// 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 fmt.Errorf("inspect caddy service: %w", err)
|
||||
}
|
||||
|
||||
return caddy.UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut())
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/cli"
|
||||
"uncloud/internal/cli/client"
|
||||
)
|
||||
|
||||
type showOptions struct {
|
||||
@@ -42,7 +42,7 @@ func show(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
||||
|
||||
domain, err := clusterClient.GetDomain(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, client.ErrNotFound) {
|
||||
if errors.Is(err, api.ErrNotFound) {
|
||||
return errors.New("no domain reserved")
|
||||
}
|
||||
return err
|
||||
|
||||
@@ -6,17 +6,18 @@ import (
|
||||
"fmt"
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"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"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"net/netip"
|
||||
"time"
|
||||
"uncloud/cmd/uncloud/caddy"
|
||||
"uncloud/internal/cli"
|
||||
"uncloud/internal/cli/client"
|
||||
"uncloud/internal/cli/config"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
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.
|
||||
fmt.Println("Waiting for the machine to be ready...")
|
||||
fmt.Println()
|
||||
if err = waitClusterInitialised(ctx, machineClient); err != nil {
|
||||
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 := ""
|
||||
caddySvc, err := machineClient.InspectService(ctx, client.CaddyServiceName)
|
||||
if err != nil {
|
||||
if !errors.Is(err, client.ErrNotFound) {
|
||||
if !errors.Is(err, api.ErrNotFound) {
|
||||
return fmt.Errorf("inspect caddy service: %w", err)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -4,14 +4,14 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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"
|
||||
"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 {
|
||||
@@ -77,7 +77,7 @@ func NewInitCommand() *cobra.Command {
|
||||
)
|
||||
cmd.Flags().StringVarP(
|
||||
&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(
|
||||
&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
|
||||
// after cluster initialisation, we keep the user informed during this wait.
|
||||
fmt.Println("Waiting for the machine to be ready...")
|
||||
fmt.Println()
|
||||
|
||||
if !opts.noDNS {
|
||||
domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint})
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"uncloud/internal/cli"
|
||||
"uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
|
||||
@@ -3,8 +3,8 @@ package machine
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/daemon"
|
||||
"uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/daemon"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
)
|
||||
|
||||
type tokenOptions struct {
|
||||
|
||||
+43
-16
@@ -3,33 +3,55 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"os"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"uncloud/cmd/uncloud/caddy"
|
||||
"uncloud/cmd/uncloud/dns"
|
||||
"uncloud/cmd/uncloud/machine"
|
||||
"uncloud/cmd/uncloud/service"
|
||||
"uncloud/internal/cli"
|
||||
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/machine"
|
||||
"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() {
|
||||
var configPath string
|
||||
opts := globalOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "uncloud",
|
||||
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.HasPrefix(configPath, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get user home directory to resolve %q: %w", configPath, err)
|
||||
var conn *config.MachineConnection
|
||||
if opts.connect != "" {
|
||||
if strings.HasPrefix(opts.connect, "tcp://") {
|
||||
addrPort, err := netip.ParseAddrPort(opts.connect[len("tcp://"):])
|
||||
if err != nil {
|
||||
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 {
|
||||
return fmt.Errorf("initialize CLI: %w", err)
|
||||
}
|
||||
@@ -37,12 +59,17 @@ func main() {
|
||||
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.
|
||||
cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.toml",
|
||||
"path to the Uncloud configuration file.")
|
||||
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.toml",
|
||||
"Path to the Uncloud configuration file.")
|
||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "toml")
|
||||
|
||||
cmd.AddCommand(
|
||||
NewDeployCommand(),
|
||||
caddy.NewRootCommand(),
|
||||
dns.NewRootCommand(),
|
||||
machine.NewRootCommand(),
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
)
|
||||
|
||||
type inspectOptions struct {
|
||||
|
||||
@@ -3,11 +3,11 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"uncloud/internal/cli"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"fmt"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
"os"
|
||||
"uncloud/internal/cli"
|
||||
)
|
||||
|
||||
type rmOptions struct {
|
||||
|
||||
+54
-27
@@ -3,24 +3,27 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
"slices"
|
||||
"strings"
|
||||
"uncloud/internal/api"
|
||||
"uncloud/internal/cli"
|
||||
"uncloud/internal/cli/client"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
type runOptions struct {
|
||||
command []string
|
||||
image string
|
||||
machines []string
|
||||
mode string
|
||||
name string
|
||||
publish []string
|
||||
replicas uint
|
||||
volumes []string
|
||||
command []string
|
||||
entrypoint string
|
||||
entrypointChanged bool
|
||||
image string
|
||||
machines []string
|
||||
mode string
|
||||
name string
|
||||
publish []string
|
||||
pull string
|
||||
replicas uint
|
||||
volumes []string
|
||||
|
||||
cluster string
|
||||
}
|
||||
@@ -35,6 +38,7 @@ func NewRunCommand() *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
opts.entrypointChanged = cmd.Flag("entrypoint").Changed
|
||||
opts.image = args[0]
|
||||
if len(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,
|
||||
fmt.Sprintf("Replication mode of the service: either %q (a specified number of containers across "+
|
||||
"the machines) or %q (one container on every machine).",
|
||||
fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+
|
||||
"the machines) or '%s' (one container on every machine).",
|
||||
api.ServiceModeReplicated, api.ServiceModeGlobal))
|
||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||
"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 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")
|
||||
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,
|
||||
"Number of containers to run for the service. Only valid for a replicated service.")
|
||||
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 {
|
||||
case api.ServiceModeReplicated, api.ServiceModeGlobal:
|
||||
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 {
|
||||
var machines []string
|
||||
for _, value := range opts.machines {
|
||||
@@ -119,39 +134,51 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
||||
|
||||
spec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Command: opts.command,
|
||||
Image: opts.image,
|
||||
Volumes: opts.volumes,
|
||||
Command: opts.command,
|
||||
Image: opts.image,
|
||||
PullPolicy: opts.pull,
|
||||
Volumes: opts.volumes,
|
||||
},
|
||||
Mode: opts.mode,
|
||||
Name: opts.name,
|
||||
Ports: ports,
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return fmt.Errorf("run service: %w", err)
|
||||
}
|
||||
|
||||
svc, err := client.InspectService(ctx, resp.ID)
|
||||
svc, err := clusterClient.InspectService(ctx, resp.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%s endpoints:\n", svc.Name)
|
||||
for _, endpoint := range svc.Endpoints() {
|
||||
fmt.Printf(" • %s\n", endpoint)
|
||||
endpoints := svc.Endpoints()
|
||||
if len(endpoints) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s endpoints:\n", svc.Name)
|
||||
for _, endpoint := range endpoints {
|
||||
fmt.Printf(" • %s\n", endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -3,12 +3,13 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
"strconv"
|
||||
"uncloud/internal/api"
|
||||
"uncloud/internal/cli"
|
||||
)
|
||||
|
||||
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.
|
||||
// This can happen if a service deployment failed midway and some containers were not updated.
|
||||
// Derive the service spec from the first container.
|
||||
spec, err := svc.Containers[0].Container.ServiceSpec()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get service spec from container: %w", err)
|
||||
}
|
||||
|
||||
spec := svc.Containers[0].Container.ServiceSpec
|
||||
spec.Replicas = opts.replicas
|
||||
|
||||
deploy, err := clusterClient.NewDeployment(spec, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create deployment: %w", err)
|
||||
}
|
||||
|
||||
plan, err := deploy.Plan(ctx)
|
||||
deployment := clusterClient.NewDeployment(spec, nil)
|
||||
plan, err := deployment.Plan(ctx)
|
||||
if err != nil {
|
||||
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)
|
||||
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 nil
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"uncloud/internal/daemon"
|
||||
"uncloud/internal/log"
|
||||
"uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/daemon"
|
||||
"github.com/psviderski/uncloud/internal/log"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# User Guide
|
||||
|
||||
## Initialising a cluster
|
||||
|
||||
To begin setting up a new Uncloud cluster, create the desired nodes. Ensure that their firewall allows the required ports.
|
||||
|
||||
### Ports
|
||||
|
||||
Nodes running Uncloud with a standard configuration must have the following inbound ports allowed in the firewall:
|
||||
|
||||
* 51820/udp (WireGuard meshnet)
|
||||
* 22/tcp (default SSH management port)
|
||||
* 80/tcp (required for challenge if uncloud.run DNS is enabled, pass --no-dns during init to disable)
|
||||
|
||||
In addition, any ports for any workloads you want to expose will need to be open.
|
||||
|
||||
### Configuration
|
||||
|
||||
Uncloud stores its configuration in `~/.config/uncloud/config.toml`. If you wish to reinitialise a cluster, simply remove it from this config.
|
||||
|
||||
### Initialisation
|
||||
|
||||
Begin by initialising the first node in your cluster with `uc machine init [USER@HOST:PORT]`. If you do not have a need for Caddy reverse proxy, you may disable this feature with `--no-caddy`.
|
||||
|
||||
This command will idempotently install Docker, uncloudd, uncloud-corrosion. If Caddy is enabled, it will set up a reverse proxy. If Uncloud DNS is enabled, it will create a DNS A record for the machine's public IP address under `*.[CLUSTER ID].cluster.uncloud.run`.
|
||||
|
||||
If you wish to uninstall Uncloud and its components, run `uncloud-uninstall`.
|
||||
|
||||
### Adding a node
|
||||
|
||||
Just like the initialisation of the first node, a node can be added to the cluster with `uc machine add`.
|
||||
|
||||
### DNS
|
||||
|
||||
Uncloud (uncloud.run) DNS can be managed with the `uc dns` subcommand.
|
||||
|
||||
* To reserve a domain name, run `uc dns reserve`
|
||||
* To release a domain name, run `uc dns release`.
|
||||
* To see the domain name, run `uc dns show`
|
||||
|
||||
### Running a service
|
||||
|
||||
Services on an Uncloud cluster can be managed with `uc service`.
|
||||
|
||||
You can run a service with two replicas that expose port 80 like the following:
|
||||
|
||||
```
|
||||
uc service run -p 80/http --replicas 2 nginxdemos/hello
|
||||
```
|
||||
|
||||
This requires the Caddy reverse proxy to be deployed. If it wasn't previously, it can be deployed with `uc caddy deploy`.
|
||||
|
||||
Since this doesn't specify a service name, a random one will be generated.
|
||||
|
||||
The service can be deleted by its service name:
|
||||
|
||||
```
|
||||
uc service rm hello-gsdo
|
||||
```
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
"net/netip"
|
||||
"time"
|
||||
"uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module uncloud
|
||||
module github.com/psviderski/uncloud
|
||||
|
||||
go 1.23.0
|
||||
|
||||
@@ -11,7 +11,9 @@ require (
|
||||
github.com/caddyserver/caddy/v2 v2.8.4
|
||||
github.com/cenkalti/backoff/v4 v4.3.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/deckarep/golang-set/v2 v2.8.0
|
||||
github.com/dgraph-io/badger/v3 v3.2103.5
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/docker/cli v27.5.0+incompatible
|
||||
@@ -29,7 +31,9 @@ require (
|
||||
github.com/ipfs/go-ds-crdt v0.5.2
|
||||
github.com/ipfs/go-ipld-format v0.6.0
|
||||
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/opencontainers/go-digest v1.0.0
|
||||
github.com/opencontainers/image-spec v1.1.0
|
||||
github.com/siderolabs/discovery-api v0.1.4
|
||||
github.com/siderolabs/discovery-client v0.1.9
|
||||
@@ -42,12 +46,13 @@ require (
|
||||
golang.org/x/crypto v0.32.0
|
||||
golang.org/x/net v0.34.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/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576
|
||||
google.golang.org/grpc v1.68.1
|
||||
google.golang.org/protobuf v1.36.3
|
||||
modernc.org/sqlite v1.36.3
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -83,7 +88,6 @@ require (
|
||||
github.com/charmbracelet/x/term v0.2.0 // indirect
|
||||
github.com/chzyer/readline v1.5.1 // 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/containerd v1.7.24 // 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-logr/logr v1.4.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-viper/mapstructure/v2 v2.0.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/flatbuffers v24.3.25+incompatible // 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/uuid v1.6.0 // 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-varint v0.0.7 // 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/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pires/go-proxyproto v0.7.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
@@ -239,6 +243,7 @@ require (
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.48.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/xid v1.5.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
@@ -269,6 +274,9 @@ require (
|
||||
github.com/urfave/cli v1.22.16 // indirect
|
||||
github.com/vbatts/tar-split v0.11.6 // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
go.etcd.io/bbolt v1.3.11 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
@@ -306,4 +314,7 @@ require (
|
||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
||||
howett.net/plist v1.0.0 // indirect
|
||||
lukechampine.com/blake3 v1.3.0 // indirect
|
||||
modernc.org/libc v1.61.13 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.8.2 // indirect
|
||||
)
|
||||
|
||||
@@ -233,6 +233,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/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/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/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
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/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.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
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.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
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/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
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-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
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/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||
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/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
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.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
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/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
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.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.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||
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/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||
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/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
||||
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/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||
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/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
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/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
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/raulk/go-watchdog v1.3.0 h1:oUmdlHxdkXRJlwfG0O9omj8ukerm8MEQavSiDTEtBsk=
|
||||
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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
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/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/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/yuin/goldmark v1.1.27/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.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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
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=
|
||||
lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
|
||||
lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
|
||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
|
||||
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
|
||||
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.36.3 h1:qYMYlFR+rtLDUzuXoST1SDIdEPbX8xzuhdF90WsX1ss=
|
||||
modernc.org/sqlite v1.36.3/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/distribution/reference"
|
||||
"maps"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
const (
|
||||
ServiceModeReplicated = "replicated"
|
||||
ServiceModeGlobal = "global"
|
||||
)
|
||||
|
||||
type ServiceSpec struct {
|
||||
Container ContainerSpec
|
||||
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
|
||||
Mode string
|
||||
Name string
|
||||
// Ports defines what service ports to publish to make the service accessible outside the cluster.
|
||||
Ports []PortSpec
|
||||
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
|
||||
Replicas uint
|
||||
}
|
||||
|
||||
func (s *ServiceSpec) Validate() error {
|
||||
if err := s.Container.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch s.Mode {
|
||||
case "", ServiceModeGlobal, ServiceModeReplicated:
|
||||
default:
|
||||
return fmt.Errorf("invalid mode: %q", s.Mode)
|
||||
}
|
||||
|
||||
// TODO: validate there is no conflict between ports.
|
||||
// TODO: return error if there are non-HTTP/HTTPS ingress ports that we don't support yet.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Equals returns true if the service spec is equal to the given spec ignoring the number of replicas.
|
||||
func (s *ServiceSpec) Equals(spec ServiceSpec) bool {
|
||||
// TODO: ignore order of ports.
|
||||
sCopy := *s
|
||||
// Ignore the number of replicas when comparing.
|
||||
sCopy.Replicas = 0
|
||||
spec.Replicas = 0
|
||||
return reflect.DeepEqual(*s, spec)
|
||||
}
|
||||
|
||||
var serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
|
||||
|
||||
func ValidateServiceID(id string) bool {
|
||||
return serviceIDRegexp.MatchString(id)
|
||||
}
|
||||
|
||||
type ContainerSpec struct {
|
||||
Command []string
|
||||
Image string
|
||||
// Run a custom init inside the container. If nil, use the daemon's configured settings.
|
||||
Init *bool
|
||||
// List of volumes to bind mount into the container.
|
||||
Volumes []string
|
||||
}
|
||||
|
||||
func (s *ContainerSpec) Validate() error {
|
||||
if _, err := reference.ParseDockerRef(s.Image); err != nil {
|
||||
return fmt.Errorf("invalid image: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
ID string
|
||||
Name string
|
||||
Mode string
|
||||
Containers []MachineContainer
|
||||
}
|
||||
|
||||
type MachineContainer struct {
|
||||
MachineID string
|
||||
Container Container
|
||||
}
|
||||
|
||||
// Endpoints returns the exposed HTTP and HTTPS endpoints of the service.
|
||||
func (s *Service) Endpoints() []string {
|
||||
endpoints := make(map[string]struct{})
|
||||
|
||||
// Container specs may differ between containers in the same service, e.g. during a rolling update,
|
||||
// so we need to collect all unique endpoints.
|
||||
for _, ctr := range s.Containers {
|
||||
ports, err := ctr.Container.ServicePorts()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, port := range ports {
|
||||
protocol := ""
|
||||
switch port.Protocol {
|
||||
case ProtocolHTTP:
|
||||
protocol = "http"
|
||||
case ProtocolHTTPS:
|
||||
protocol = "https"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if port.Hostname == "" {
|
||||
// There shouldn't be http(s) ports without a hostname but just in case ignore them.
|
||||
continue
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s://%s", protocol, port.Hostname)
|
||||
if port.PublishedPort != 0 {
|
||||
// For non-standard ports (80/443), include the port in the URL.
|
||||
if !(port.Protocol == ProtocolHTTP && port.PublishedPort == 80) &&
|
||||
!(port.Protocol == ProtocolHTTPS && port.PublishedPort == 443) {
|
||||
endpoint += fmt.Sprintf(":%d", port.PublishedPort)
|
||||
}
|
||||
}
|
||||
|
||||
endpoint += fmt.Sprintf(" → :%d", port.ContainerPort)
|
||||
endpoints[endpoint] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return slices.Sorted(maps.Keys(endpoints))
|
||||
}
|
||||
|
||||
func ServiceFromProto(s *pb.Service) (Service, error) {
|
||||
var err error
|
||||
containers := make([]MachineContainer, len(s.Containers))
|
||||
for i, sc := range s.Containers {
|
||||
containers[i], err = machineContainerFromProto(sc)
|
||||
if err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return Service{
|
||||
ID: s.Id,
|
||||
Name: s.Name,
|
||||
Mode: s.Mode,
|
||||
Containers: containers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func machineContainerFromProto(sc *pb.Service_Container) (MachineContainer, error) {
|
||||
var c Container
|
||||
if err := json.Unmarshal(sc.Container, &c); err != nil {
|
||||
return MachineContainer{}, fmt.Errorf("unmarshal container: %w", err)
|
||||
}
|
||||
|
||||
return MachineContainer{
|
||||
MachineID: sc.MachineId,
|
||||
Container: c,
|
||||
}, nil
|
||||
}
|
||||
+37
-10
@@ -5,15 +5,16 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"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"
|
||||
"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"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
@@ -23,13 +24,22 @@ const defaultClusterName = "default"
|
||||
|
||||
type CLI struct {
|
||||
config *config.Config
|
||||
conn *config.MachineConnection
|
||||
}
|
||||
|
||||
func New(configPath string) (*CLI, error) {
|
||||
// New creates a new CLI instance with the given config path or remote machine connection.
|
||||
// If the connection is provided, the config is ignored for all operations which is useful for interacting with
|
||||
// a cluster without creating a config.
|
||||
func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
|
||||
if conn != nil {
|
||||
return &CLI{conn: conn}, nil
|
||||
}
|
||||
|
||||
cfg, err := config.NewFromFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Uncloud config: %w", err)
|
||||
}
|
||||
|
||||
return &CLI{
|
||||
config: cfg,
|
||||
}, nil
|
||||
@@ -47,13 +57,19 @@ func (cli *CLI) CreateCluster(name string) error {
|
||||
|
||||
func (cli *CLI) SetCurrentCluster(name string) error {
|
||||
if _, ok := cli.config.Clusters[name]; !ok {
|
||||
return client.ErrNotFound
|
||||
return api.ErrNotFound
|
||||
}
|
||||
cli.config.CurrentCluster = name
|
||||
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) {
|
||||
if cli.conn != nil {
|
||||
return connectCluster(ctx, *cli.conn)
|
||||
}
|
||||
|
||||
if len(cli.config.Clusters) == 0 {
|
||||
return nil, errors.New(
|
||||
"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.
|
||||
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 != "" {
|
||||
user, host, port, err := conn.SSH.Parse()
|
||||
if err != nil {
|
||||
@@ -106,7 +132,8 @@ func (cli *CLI) ConnectCluster(ctx context.Context, clusterName string) (*client
|
||||
} else if conn.TCP.IsValid() {
|
||||
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.
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"uncloud/internal/api"
|
||||
)
|
||||
|
||||
func TestClient_NewCaddyDeployment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cli := &Client{}
|
||||
|
||||
t.Run("latest image from Docker Hub", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deploy, err := cli.NewCaddyDeployment("", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "caddy", deploy.Spec.Name)
|
||||
assert.Equal(t, api.ServiceModeGlobal, deploy.Spec.Mode)
|
||||
assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, deploy.Spec.Container.Image)
|
||||
expectedPorts := []api.PortSpec{
|
||||
{
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 80,
|
||||
Protocol: api.ProtocolTCP,
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
{
|
||||
PublishedPort: 443,
|
||||
ContainerPort: 443,
|
||||
Protocol: api.ProtocolTCP,
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expectedPorts, deploy.Spec.Ports)
|
||||
// TODO:
|
||||
//assert.Equal(t, alwaysPullImage, deploy.Spec.Container.PullPolicy)
|
||||
})
|
||||
|
||||
t.Run("custom image", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
image := "my-caddy:1.2.3"
|
||||
deploy, err := cli.NewCaddyDeployment(image, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "caddy", deploy.Spec.Name)
|
||||
assert.Equal(t, api.ServiceModeGlobal, deploy.Spec.Mode)
|
||||
assert.Equal(t, image, deploy.Spec.Container.Image)
|
||||
expectedPorts := []api.PortSpec{
|
||||
{
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 80,
|
||||
Protocol: api.ProtocolTCP,
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
{
|
||||
PublishedPort: 443,
|
||||
ContainerPort: 443,
|
||||
Protocol: api.ProtocolTCP,
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expectedPorts, deploy.Spec.Ports)
|
||||
})
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/distribution/reference"
|
||||
"strings"
|
||||
"uncloud/internal/api"
|
||||
"uncloud/internal/secret"
|
||||
)
|
||||
|
||||
// ServiceSpecResolver transforms user-provided service specs into deployment-ready form.
|
||||
type ServiceSpecResolver struct {
|
||||
ClusterDomain string
|
||||
}
|
||||
|
||||
func NewServiceSpecResolver(clusterDomain string) *ServiceSpecResolver {
|
||||
return &ServiceSpecResolver{ClusterDomain: clusterDomain}
|
||||
}
|
||||
|
||||
// Resolve transforms a service spec into its fully resolved form ready for deployment.
|
||||
func (r *ServiceSpecResolver) Resolve(spec *api.ServiceSpec) error {
|
||||
if err := spec.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid service spec: %w", err)
|
||||
}
|
||||
|
||||
steps := []func(*api.ServiceSpec) error{
|
||||
r.applyDefaults,
|
||||
r.resolveServiceName,
|
||||
r.expandIngressPorts,
|
||||
}
|
||||
|
||||
for _, step := range steps {
|
||||
if err := step(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ServiceSpecResolver) applyDefaults(spec *api.ServiceSpec) error {
|
||||
if spec.Mode == "" {
|
||||
spec.Mode = api.ServiceModeReplicated
|
||||
}
|
||||
// Ensure the replicated service has at least one replica.
|
||||
if spec.Mode == api.ServiceModeReplicated && spec.Replicas == 0 {
|
||||
spec.Replicas = 1
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ServiceSpecResolver) resolveServiceName(spec *api.ServiceSpec) error {
|
||||
if spec.Name != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate a random service name from the image when not provided.
|
||||
img, err := reference.ParseDockerRef(spec.Container.Image)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid image: %w", err)
|
||||
}
|
||||
// Get the image name without the repository and tag/digest parts.
|
||||
imageName := reference.FamiliarName(img)
|
||||
// Get the last part of the image name (path), e.g. "nginx" from "bitnami/nginx".
|
||||
if i := strings.LastIndex(imageName, "/"); i != -1 {
|
||||
imageName = imageName[i+1:]
|
||||
}
|
||||
// Append a random suffix to the image name to generate an optimistically unique service name.
|
||||
suffix, err := secret.RandomAlphaNumeric(4)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate random suffix: %w", err)
|
||||
}
|
||||
spec.Name = fmt.Sprintf("%s-%s", imageName, suffix)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandIngressPorts processes HTTP(S) ingress ports in a service spec by:
|
||||
// 1. Setting a default hostname (service-name.cluster-domain) for ports without a hostname.
|
||||
// 2. Duplicating a port with a cluster domain hostname for ports with external domains.
|
||||
// This ensures every ingress port is accessible via the cluster domain, while preserving any custom domains specified
|
||||
// by the user.
|
||||
func (r *ServiceSpecResolver) expandIngressPorts(spec *api.ServiceSpec) error {
|
||||
for i, port := range spec.Ports {
|
||||
if port.Protocol != api.ProtocolHTTP && port.Protocol != api.ProtocolHTTPS {
|
||||
continue
|
||||
}
|
||||
|
||||
if port.Hostname == "" {
|
||||
if r.ClusterDomain == "" {
|
||||
return fmt.Errorf("cluster domain must be reserved to generate hostname for ingress port: %d/%s",
|
||||
port.ContainerPort, port.Protocol)
|
||||
}
|
||||
// Assign the default hostname (service-name.cluster-domain).
|
||||
spec.Ports[i].Hostname = fmt.Sprintf("%s.%s", spec.Name, r.ClusterDomain)
|
||||
} else {
|
||||
if r.ClusterDomain == "" {
|
||||
// When no cluster domain is reserved, use only the provided hostname.
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasSuffix(port.Hostname, "."+r.ClusterDomain) {
|
||||
// If the hostname is already a cluster subdomain, use as is.
|
||||
continue
|
||||
}
|
||||
// For external domains, duplicate the port with a service-name.cluster-domain hostname so the service
|
||||
// can be accessed via both hostnames.
|
||||
newPort := port
|
||||
newPort.Hostname = fmt.Sprintf("%s.%s", spec.Name, r.ClusterDomain)
|
||||
spec.Ports = append(spec.Ports, newPort)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -15,10 +15,10 @@ const (
|
||||
|
||||
type MachineConnection struct {
|
||||
SSH SSHDestination `toml:"ssh,omitempty"`
|
||||
SSHKeyFile string `toml:"ssh_key_file,omitempty"`
|
||||
TCP netip.AddrPort `toml:"tcp,omitempty"`
|
||||
Host string `toml:"host,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".
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"uncloud/internal/sshexec"
|
||||
"github.com/psviderski/uncloud/internal/sshexec"
|
||||
)
|
||||
|
||||
// TODO: support pinning the script version to the CLI version.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package cli
|
||||
|
||||
import "github.com/charmbracelet/huh"
|
||||
|
||||
func Confirm() (bool, error) {
|
||||
var confirmed bool
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(
|
||||
"Do you want to continue?",
|
||||
).
|
||||
Affirmative("Yes!").
|
||||
Negative("No").
|
||||
Value(&confirmed),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return confirmed, nil
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
systemd "github.com/coreos/go-systemd/daemon"
|
||||
"log/slog"
|
||||
"uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
)
|
||||
|
||||
type Daemon struct {
|
||||
|
||||
@@ -3,10 +3,10 @@ package daemon
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"net/netip"
|
||||
"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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,22 +15,31 @@ service Docker {
|
||||
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
|
||||
rpc RemoveContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
||||
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 {
|
||||
// JSON serialized container.Config.
|
||||
// JSON serialised container.Config.
|
||||
bytes config = 1;
|
||||
// JSON serialized container.HostConfig.
|
||||
// JSON serialised container.HostConfig.
|
||||
bytes host_config = 2;
|
||||
// JSON serialized network.NetworkingConfig.
|
||||
// JSON serialised network.NetworkingConfig.
|
||||
bytes network_config = 3;
|
||||
// JSON serialized ocispec.Platform.
|
||||
// JSON serialised ocispec.Platform.
|
||||
bytes platform = 4;
|
||||
string name = 5;
|
||||
}
|
||||
|
||||
message CreateContainerResponse {
|
||||
// JSON serialized container.CreateResponse.
|
||||
// JSON serialised container.CreateResponse.
|
||||
bytes response = 1;
|
||||
}
|
||||
|
||||
@@ -39,51 +48,114 @@ message InspectContainerRequest {
|
||||
}
|
||||
|
||||
message InspectContainerResponse {
|
||||
// JSON serialized container.InspectResponse.
|
||||
// JSON serialised container.InspectResponse.
|
||||
bytes response = 1;
|
||||
}
|
||||
|
||||
message StartContainerRequest {
|
||||
string id = 1;
|
||||
// JSON serialized container.StartOptions.
|
||||
// JSON serialised container.StartOptions.
|
||||
bytes options = 2;
|
||||
}
|
||||
|
||||
message StopContainerRequest {
|
||||
string id = 1;
|
||||
// JSON serialized container.StopOptions.
|
||||
// JSON serialised container.StopOptions.
|
||||
bytes options = 2;
|
||||
}
|
||||
|
||||
message ListContainersRequest {
|
||||
// JSON serialized container.ListOptions.
|
||||
// JSON serialised container.ListOptions.
|
||||
bytes options = 1;
|
||||
}
|
||||
|
||||
// ListContainersResponse structure allows broadcasting ListContainers requests to multiple machines.
|
||||
message ListContainersResponse {
|
||||
// Must contain only one repeated messages field to allow broadcasting ListContainers requests to multiple machines.
|
||||
repeated MachineContainers messages = 1;
|
||||
}
|
||||
|
||||
message MachineContainers {
|
||||
Metadata metadata = 1;
|
||||
// JSON serialized []container.ContainerJSON.
|
||||
// JSON serialised []container.InspectResponse.
|
||||
bytes containers = 2;
|
||||
}
|
||||
|
||||
message RemoveContainerRequest {
|
||||
string id = 1;
|
||||
// JSON serialized container.RemoveOptions.
|
||||
// JSON serialised container.RemoveOptions.
|
||||
bytes options = 2;
|
||||
}
|
||||
|
||||
message PullImageRequest {
|
||||
string image = 1;
|
||||
// JSON serialized image.PullOptions.
|
||||
// JSON serialised image.PullOptions.
|
||||
bytes options = 2;
|
||||
}
|
||||
|
||||
message JSONMessage {
|
||||
// JSON serialized jsonmessage.JSONMessage.
|
||||
// JSON serialised jsonmessage.JSONMessage.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -20,13 +20,19 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
Docker_CreateContainer_FullMethodName = "/api.Docker/CreateContainer"
|
||||
Docker_InspectContainer_FullMethodName = "/api.Docker/InspectContainer"
|
||||
Docker_StartContainer_FullMethodName = "/api.Docker/StartContainer"
|
||||
Docker_StopContainer_FullMethodName = "/api.Docker/StopContainer"
|
||||
Docker_ListContainers_FullMethodName = "/api.Docker/ListContainers"
|
||||
Docker_RemoveContainer_FullMethodName = "/api.Docker/RemoveContainer"
|
||||
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
|
||||
Docker_CreateContainer_FullMethodName = "/api.Docker/CreateContainer"
|
||||
Docker_InspectContainer_FullMethodName = "/api.Docker/InspectContainer"
|
||||
Docker_StartContainer_FullMethodName = "/api.Docker/StartContainer"
|
||||
Docker_StopContainer_FullMethodName = "/api.Docker/StopContainer"
|
||||
Docker_ListContainers_FullMethodName = "/api.Docker/ListContainers"
|
||||
Docker_RemoveContainer_FullMethodName = "/api.Docker/RemoveContainer"
|
||||
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.
|
||||
@@ -40,6 +46,14 @@ type DockerClient interface {
|
||||
ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ListContainersResponse, 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)
|
||||
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 {
|
||||
@@ -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.
|
||||
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.
|
||||
// All implementations must embed UnimplementedDockerServer
|
||||
// for forward compatibility.
|
||||
@@ -140,6 +214,14 @@ type DockerServer interface {
|
||||
ListContainers(context.Context, *ListContainersRequest) (*ListContainersResponse, error)
|
||||
RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, 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()
|
||||
}
|
||||
|
||||
@@ -171,6 +253,24 @@ func (UnimplementedDockerServer) RemoveContainer(context.Context, *RemoveContain
|
||||
func (UnimplementedDockerServer) PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error {
|
||||
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) 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.
|
||||
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.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -342,6 +550,30 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "RemoveContainer",
|
||||
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{
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
"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
|
||||
|
||||
@@ -4,21 +4,22 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"slices"
|
||||
"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).
|
||||
httpHostUpstreams := make(map[string][]string)
|
||||
httpsHostUpstreams := make(map[string][]string)
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
package caddyfile
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"strings"
|
||||
"testing"
|
||||
"uncloud/internal/api"
|
||||
"uncloud/internal/machine/docker"
|
||||
)
|
||||
|
||||
func TestGenerateConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
containers []api.Container
|
||||
containers []api.ServiceContainer
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty containers",
|
||||
containers: []api.Container{},
|
||||
containers: []api.ServiceContainer{},
|
||||
want: `{
|
||||
"servers": {
|
||||
"http": {
|
||||
@@ -45,7 +46,7 @@ func TestGenerateConfig(t *testing.T) {
|
||||
|
||||
{
|
||||
name: "HTTP container",
|
||||
containers: []api.Container{
|
||||
containers: []api.ServiceContainer{
|
||||
newContainer("10.210.0.2", "app.example.com:8080/http"),
|
||||
},
|
||||
want: `{
|
||||
@@ -79,7 +80,7 @@ func TestGenerateConfig(t *testing.T) {
|
||||
},
|
||||
{
|
||||
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.3", "app.example.com:8080/http"),
|
||||
},
|
||||
@@ -117,7 +118,7 @@ func TestGenerateConfig(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "HTTPS container",
|
||||
containers: []api.Container{
|
||||
containers: []api.ServiceContainer{
|
||||
newContainer("10.210.0.2", "secure.example.com:8000/https"),
|
||||
},
|
||||
want: `{
|
||||
@@ -153,7 +154,7 @@ func TestGenerateConfig(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "mixed HTTP and HTTPS",
|
||||
containers: []api.Container{
|
||||
containers: []api.ServiceContainer{
|
||||
newContainer("10.210.0.2",
|
||||
"app.example.com:8080/http",
|
||||
"web.example.com:8000/http"),
|
||||
@@ -227,7 +228,7 @@ func TestGenerateConfig(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "container without uncloud network ignored",
|
||||
containers: []api.Container{
|
||||
containers: []api.ServiceContainer{
|
||||
newContainerWithoutNetwork("ignored.example.com:8080/http"),
|
||||
},
|
||||
want: `{
|
||||
@@ -252,7 +253,7 @@ func TestGenerateConfig(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "container with invalid port ignored",
|
||||
containers: []api.Container{
|
||||
containers: []api.ServiceContainer{
|
||||
newContainer("10.210.0.2", "invalid-port"),
|
||||
},
|
||||
want: `{
|
||||
@@ -277,7 +278,7 @@ func TestGenerateConfig(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "containers with unsupported protocols ignored",
|
||||
containers: []api.Container{
|
||||
containers: []api.ServiceContainer{
|
||||
newContainer("10.210.0.2", "5000/tcp"),
|
||||
newContainer("10.210.0.3", "5000/udp"),
|
||||
newContainer("10.210.0.4", "80:8080/tcp@host"),
|
||||
@@ -322,44 +323,40 @@ 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, ",")
|
||||
return api.Container{
|
||||
ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{},
|
||||
NetworkSettings: &types.NetworkSettings{
|
||||
Networks: map[string]*network.EndpointSettings{
|
||||
docker.NetworkName: {
|
||||
IPAddress: ip,
|
||||
},
|
||||
},
|
||||
},
|
||||
Config: &container.Config{
|
||||
Labels: map[string]string{
|
||||
api.LabelServicePorts: portsLabel,
|
||||
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{},
|
||||
NetworkSettings: &types.NetworkSettings{
|
||||
Networks: map[string]*network.EndpointSettings{
|
||||
docker.NetworkName: {
|
||||
IPAddress: ip,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
Config: &container.Config{
|
||||
Labels: map[string]string{
|
||||
api.LabelServicePorts: portsLabel,
|
||||
},
|
||||
},
|
||||
}}}
|
||||
}
|
||||
|
||||
func newContainerWithoutNetwork(ports ...string) api.Container {
|
||||
func newContainerWithoutNetwork(ports ...string) api.ServiceContainer {
|
||||
portsLabel := strings.Join(ports, ",")
|
||||
return api.Container{
|
||||
ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{},
|
||||
NetworkSettings: &types.NetworkSettings{
|
||||
Networks: map[string]*network.EndpointSettings{
|
||||
"other-network": {
|
||||
IPAddress: "172.17.0.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
Config: &container.Config{
|
||||
Labels: map[string]string{
|
||||
api.LabelServicePorts: portsLabel,
|
||||
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{},
|
||||
NetworkSettings: &types.NetworkSettings{
|
||||
Networks: map[string]*network.EndpointSettings{
|
||||
"other-network": {
|
||||
IPAddress: "172.17.0.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
Config: &container.Config{
|
||||
Labels: map[string]string{
|
||||
api.LabelServicePorts: portsLabel,
|
||||
},
|
||||
},
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"uncloud/internal/api"
|
||||
"uncloud/internal/fs"
|
||||
"uncloud/internal/machine/store"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
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
|
||||
// is determined by the cluster membership state of the machine that the container is running on.
|
||||
// TODO: implement machine membership check using Corrossion Admin client.
|
||||
func (c *Controller) filterAvailableContainers(containerRecords []store.ContainerRecord) ([]api.Container, error) {
|
||||
containers := make([]api.Container, len(containerRecords))
|
||||
func (c *Controller) filterAvailableContainers(
|
||||
containerRecords []store.ContainerRecord,
|
||||
) ([]api.ServiceContainer, error) {
|
||||
containers := make([]api.ServiceContainer, len(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
|
||||
}
|
||||
|
||||
func (c *Controller) generateConfig(containers []api.Container) error {
|
||||
func (c *Controller) generateConfig(containers []api.ServiceContainer) error {
|
||||
config, err := GenerateConfig(containers, c.verifyResponse)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"time"
|
||||
"uncloud/internal/corrosion"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
"uncloud/internal/machine/network"
|
||||
"uncloud/internal/machine/store"
|
||||
"uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/internal/corrosion"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
)
|
||||
|
||||
type Cluster struct {
|
||||
|
||||
@@ -4,12 +4,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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/status"
|
||||
"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.
|
||||
|
||||
@@ -2,7 +2,7 @@ package cluster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
)
|
||||
|
||||
// NewMachineID generates a new unique machine ID.
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const DBFileName = "machine.db"
|
||||
|
||||
// NewDB creates a new connection to machine SQLite database and runs schema migrations if necessary.
|
||||
func NewDB(path string) (*sqlx.DB, error) {
|
||||
// Create the database file with 0600 permissions if it doesn't exist, or update permissions if exists.
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create SQLite database '%s': %w", path, err)
|
||||
}
|
||||
file.Close()
|
||||
} else {
|
||||
if err = os.Chmod(path, 0600); err != nil {
|
||||
return nil, fmt.Errorf("update SQLite database permissions '%s': %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// - Write-Ahead Logging (WAL) mode for better read/write performance.
|
||||
// - Busy timeout (5s) to make concurrent writes wait on each other instead of failing immediately.
|
||||
conn := path + "?_pragma=journal_mode=WAL&_pragma=synchronous=NORMAL&_pragma=busy_timeout=5000&_time_format=sqlite"
|
||||
db, err := sqlx.Connect("sqlite", conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to SQLite database '%s': %w", conn, err)
|
||||
}
|
||||
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS containers (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
service_id TEXT NOT NULL,
|
||||
service_name TEXT AS (json_extract(service_spec, '$.Name')),
|
||||
service_spec TEXT NOT NULL CHECK (json_valid(service_spec)),
|
||||
-- 'subsecond' modifier is used to store timestamps with millisecond precision.
|
||||
created_at TIMESTAMP NOT NULL DEFAULT (datetime('subsecond')),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('subsecond'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_containers_service_id ON containers (service_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_containers_service_name ON containers (service_name);
|
||||
`
|
||||
|
||||
if _, err = db.Exec(schema); err != nil {
|
||||
return nil, fmt.Errorf("create schema: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -3,18 +3,23 @@ package docker
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/errdefs"
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
regtypes "github.com/google/go-containerregistry/pkg/v1/types"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"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.
|
||||
@@ -72,10 +77,8 @@ func (c *Client) CreateContainer(
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
if s, ok := status.FromError(err); ok {
|
||||
if s.Code() == codes.NotFound {
|
||||
return resp, errdefs.NotFound(err)
|
||||
}
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return resp, errdefs.NotFound(err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
@@ -92,10 +95,8 @@ func (c *Client) InspectContainer(ctx context.Context, id string) (types.Contain
|
||||
|
||||
grpcResp, err := c.grpcClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||
if err != nil {
|
||||
if s, ok := status.FromError(err); ok {
|
||||
if s.Code() == codes.NotFound {
|
||||
return resp, errdefs.NotFound(err)
|
||||
}
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return resp, errdefs.NotFound(err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
@@ -118,10 +119,8 @@ func (c *Client) StartContainer(ctx context.Context, id string, opts container.S
|
||||
Options: optsBytes,
|
||||
})
|
||||
if err != nil {
|
||||
if s, ok := status.FromError(err); ok {
|
||||
if s.Code() == codes.NotFound {
|
||||
return errdefs.NotFound(err)
|
||||
}
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return errdefs.NotFound(err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
@@ -139,10 +138,8 @@ func (c *Client) StopContainer(ctx context.Context, id string, opts container.St
|
||||
Options: optsBytes,
|
||||
})
|
||||
if err != nil {
|
||||
if s, ok := status.FromError(err); ok {
|
||||
if s.Code() == codes.NotFound {
|
||||
return errdefs.NotFound(err)
|
||||
}
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return errdefs.NotFound(err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
@@ -191,10 +188,8 @@ func (c *Client) RemoveContainer(ctx context.Context, id string, opts container.
|
||||
Options: optsBytes,
|
||||
})
|
||||
if err != nil {
|
||||
if s, ok := status.FromError(err); ok {
|
||||
if s.Code() == codes.NotFound {
|
||||
return errdefs.NotFound(err)
|
||||
}
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return errdefs.NotFound(err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
@@ -237,3 +232,228 @@ func (c *Client) PullImage(ctx context.Context, image string) (<-chan PullImageM
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// InspectImage returns the image information for the given image ID. The request may be sent to multiple machines.
|
||||
func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
||||
resp, err := c.grpcClient.InspectImage(ctx, &pb.InspectImageRequest{Id: id})
|
||||
if err != nil {
|
||||
// If the request was sent to only one machine, err is an actual error from the machine.
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return nil, errdefs.NotFound(err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notFoundCount := 0
|
||||
for _, msg := range resp.Messages {
|
||||
if msg.Metadata != nil && msg.Metadata.Status != nil && codes.Code(msg.Metadata.Status.Code) == codes.NotFound {
|
||||
notFoundCount++
|
||||
}
|
||||
}
|
||||
if len(resp.Messages) == notFoundCount {
|
||||
return nil, errdefs.NotFound(fmt.Errorf("image not found: %s", id))
|
||||
}
|
||||
|
||||
images := make([]api.MachineImage, len(resp.Messages))
|
||||
for i, msg := range resp.Messages {
|
||||
images[i].Metadata = msg.Metadata
|
||||
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(msg.Image, &images[i].Image); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal image: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return images, nil
|
||||
}
|
||||
|
||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's Docker auth
|
||||
// credentials if necessary. If the response from a machine doesn't contain an error, the api.RemoteImage will either
|
||||
// contain an IndexManifest or an ImageManifest.
|
||||
func (c *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
|
||||
resp, err := c.grpcClient.InspectRemoteImage(ctx, &pb.InspectRemoteImageRequest{Id: id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
images := make([]api.MachineRemoteImage, 0, len(resp.Messages))
|
||||
var parseErr error
|
||||
for _, msg := range resp.Messages {
|
||||
mri, err := parseRemoteImageMessage(msg)
|
||||
if err != nil {
|
||||
parseErr = errors.Join(parseErr, err)
|
||||
continue
|
||||
}
|
||||
images = append(images, mri)
|
||||
}
|
||||
|
||||
return images, parseErr
|
||||
}
|
||||
|
||||
type manifestMediaType struct {
|
||||
MediaType regtypes.MediaType `json:"mediaType"`
|
||||
}
|
||||
|
||||
func parseRemoteImageMessage(msg *pb.RemoteImage) (api.MachineRemoteImage, error) {
|
||||
mri := api.MachineRemoteImage{
|
||||
Metadata: msg.Metadata,
|
||||
}
|
||||
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||
return mri, nil
|
||||
}
|
||||
|
||||
ref, err := reference.ParseNormalizedNamed(msg.Reference)
|
||||
if err != nil {
|
||||
// The reference is the string representation of a valid canonical reference from the server which
|
||||
// is expected to be parseable.
|
||||
return mri, fmt.Errorf("parse reference: %w", err)
|
||||
}
|
||||
if canonicalRef, ok := ref.(reference.Canonical); ok {
|
||||
mri.Image.Reference = canonicalRef
|
||||
} else {
|
||||
return mri, fmt.Errorf("unexpected non-canonical image reference: %s", msg.Reference)
|
||||
}
|
||||
|
||||
manifest := manifestMediaType{}
|
||||
if err = json.Unmarshal(msg.Manifest, &manifest); err != nil {
|
||||
return mri, fmt.Errorf("unmarshal manifest: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case manifest.MediaType.IsIndex():
|
||||
index := &ocispec.Index{}
|
||||
if err = json.Unmarshal(msg.Manifest, index); err != nil {
|
||||
return mri, fmt.Errorf("unmarshal index manifest: %w", err)
|
||||
}
|
||||
mri.Image.IndexManifest = index
|
||||
case manifest.MediaType.IsImage():
|
||||
image := &ocispec.Manifest{}
|
||||
if err = json.Unmarshal(msg.Manifest, image); err != nil {
|
||||
return mri, fmt.Errorf("unmarshal image manifest: %w", err)
|
||||
}
|
||||
mri.Image.ImageManifest = image
|
||||
default:
|
||||
return mri, fmt.Errorf("unexpected manifest media type: %s", manifest.MediaType)
|
||||
}
|
||||
|
||||
return mri, nil
|
||||
}
|
||||
|
||||
// CreateServiceContainer creates a new container for the service with the given specifications.
|
||||
func (c *Client) CreateServiceContainer(
|
||||
ctx context.Context, serviceID string, spec api.ServiceSpec, containerName string,
|
||||
) (container.CreateResponse, error) {
|
||||
var resp container.CreateResponse
|
||||
|
||||
specBytes, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("marshal service spec: %w", err)
|
||||
}
|
||||
grpcResp, err := c.grpcClient.CreateServiceContainer(ctx, &pb.CreateServiceContainerRequest{
|
||||
ServiceId: serviceID,
|
||||
ServiceSpec: specBytes,
|
||||
ContainerName: containerName,
|
||||
})
|
||||
if err != nil {
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return resp, errdefs.NotFound(err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(grpcResp.Response, &resp); err != nil {
|
||||
return resp, fmt.Errorf("unmarshal gRPC response: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// InspectServiceContainer returns the container information and service specification that was used to create the
|
||||
// container with the given ID.
|
||||
func (c *Client) InspectServiceContainer(ctx context.Context, id string) (api.ServiceContainer, error) {
|
||||
var resp api.ServiceContainer
|
||||
|
||||
grpcResp, err := c.grpcClient.InspectServiceContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||
if err != nil {
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return resp, errdefs.NotFound(err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(grpcResp.Container, &resp.Container); err != nil {
|
||||
return resp, fmt.Errorf("unmarshal container: %w", err)
|
||||
}
|
||||
if err = json.Unmarshal(grpcResp.ServiceSpec, &resp.ServiceSpec); err != nil {
|
||||
return resp, fmt.Errorf("unmarshal service spec: %w", err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type MachineServiceContainers struct {
|
||||
Metadata *pb.Metadata
|
||||
Containers []api.ServiceContainer
|
||||
}
|
||||
|
||||
// ListServiceContainers returns all containers on requested machines that belong to the service with the given
|
||||
// name or ID. If serviceNameOrID is empty, all service containers are returned.
|
||||
func (c *Client) ListServiceContainers(
|
||||
ctx context.Context, serviceNameOrID string, opts container.ListOptions,
|
||||
) ([]MachineServiceContainers, error) {
|
||||
optsBytes, err := json.Marshal(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.grpcClient.ListServiceContainers(ctx, &pb.ListServiceContainersRequest{
|
||||
ServiceId: serviceNameOrID,
|
||||
Options: optsBytes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
machineContainers := make([]MachineServiceContainers, len(resp.Messages))
|
||||
for i, msg := range resp.Messages {
|
||||
machineContainers[i].Metadata = msg.Metadata
|
||||
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
containers := make([]api.ServiceContainer, len(msg.Containers))
|
||||
for j, sc := range msg.Containers {
|
||||
if err = json.Unmarshal(sc.Container, &containers[j].Container); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal container: %w", err)
|
||||
}
|
||||
if err = json.Unmarshal(sc.ServiceSpec, &containers[j].ServiceSpec); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal service spec: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
machineContainers[i].Containers = containers
|
||||
}
|
||||
|
||||
return machineContainers, nil
|
||||
}
|
||||
|
||||
// RemoveServiceContainer stops (kills after grace period) and removes a service container with the given ID.
|
||||
// A service container is a container that has been created with CreateServiceContainer.
|
||||
func (c *Client) RemoveServiceContainer(ctx context.Context, id string, opts container.RemoveOptions) error {
|
||||
optsBytes, err := json.Marshal(opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
_, err = c.grpcClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
|
||||
Id: id,
|
||||
Options: optsBytes,
|
||||
})
|
||||
if err != nil {
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return errdefs.NotFound(err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"github.com/docker/docker/api/types/events"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"log/slog"
|
||||
"time"
|
||||
"uncloud/internal/api"
|
||||
"uncloud/internal/machine/store"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/docker/docker/libnetwork/iptables"
|
||||
"log/slog"
|
||||
"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
|
||||
|
||||
@@ -2,32 +2,54 @@ package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"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"
|
||||
"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/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"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.
|
||||
type Server struct {
|
||||
pb.UnimplementedDockerServer
|
||||
client *client.Client
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewServer creates a new Docker gRPC server with the provided Docker client.
|
||||
func NewServer(cli *client.Client) *Server {
|
||||
return &Server{client: cli}
|
||||
func NewServer(cli *client.Client, db *sqlx.DB) *Server {
|
||||
return &Server{
|
||||
client: cli,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
}
|
||||
containers := make([]types.ContainerJSON, len(containerSummaries))
|
||||
for i, cs := range containerSummaries {
|
||||
containers := make([]types.ContainerJSON, 0, len(containerSummaries))
|
||||
for _, cs := range containerSummaries {
|
||||
c, 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)
|
||||
}
|
||||
containers[i] = c
|
||||
containers = append(containers, c)
|
||||
}
|
||||
|
||||
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
@@ -5,14 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"
|
||||
"net"
|
||||
"net/netip"
|
||||
@@ -21,17 +13,27 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"uncloud/internal/corrosion"
|
||||
"uncloud/internal/docker"
|
||||
"uncloud/internal/fs"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
apiproxy "uncloud/internal/machine/api/proxy"
|
||||
"uncloud/internal/machine/caddyfile"
|
||||
"uncloud/internal/machine/cluster"
|
||||
"uncloud/internal/machine/corroservice"
|
||||
machinedocker "uncloud/internal/machine/docker"
|
||||
"uncloud/internal/machine/network"
|
||||
"uncloud/internal/machine/store"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/go-connections/sockets"
|
||||
"github.com/psviderski/uncloud/internal/corrosion"
|
||||
"github.com/psviderski/uncloud/internal/docker"
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
apiproxy "github.com/psviderski/uncloud/internal/machine/api/proxy"
|
||||
"github.com/psviderski/uncloud/internal/machine/caddyfile"
|
||||
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||
"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 (
|
||||
@@ -57,9 +59,9 @@ type Config struct {
|
||||
// DockerClient manages system and user containers using the local Docker daemon.
|
||||
DockerClient *client.Client
|
||||
|
||||
// CaddyfilePath 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.
|
||||
CaddyfilePath string
|
||||
// CaddyConfigPath specifies where the machine generates the Caddy reverse proxy configuration file for routing
|
||||
// external traffic to service containers across the internal network. Default is DataDir/caddy/caddy.json.
|
||||
CaddyConfigPath string
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
cfg.CaddyfilePath = filepath.Join(cfg.DataDir, "caddy", "caddy.json")
|
||||
if cfg.CaddyConfigPath == "" {
|
||||
cfg.CaddyConfigPath = filepath.Join(cfg.DataDir, "caddy", "caddy.json")
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
@@ -209,7 +211,13 @@ func NewMachine(config *Config) (*Machine, error) {
|
||||
if err != nil {
|
||||
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.
|
||||
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.
|
||||
// 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 {
|
||||
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{
|
||||
Id: records[0].Container.ServiceID(),
|
||||
Name: records[0].Container.ServiceName(),
|
||||
Mode: records[0].Container.ServiceMode(),
|
||||
Id: ctr.ServiceID(),
|
||||
Name: ctr.ServiceName(),
|
||||
Mode: ctr.ServiceMode(),
|
||||
Containers: containers,
|
||||
}
|
||||
return &pb.InspectServiceResponse{Service: svc}, nil
|
||||
|
||||
@@ -14,12 +14,12 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
"uncloud/internal/machine/caddyfile"
|
||||
"uncloud/internal/machine/corroservice"
|
||||
"uncloud/internal/machine/docker"
|
||||
"uncloud/internal/machine/network"
|
||||
"uncloud/internal/machine/store"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/caddyfile"
|
||||
"github.com/psviderski/uncloud/internal/machine/corroservice"
|
||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -2,10 +2,10 @@ package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"net"
|
||||
"net/netip"
|
||||
"uncloud/internal/secret"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
||||
@@ -2,9 +2,9 @@ package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"net"
|
||||
"net/netip"
|
||||
"uncloud/internal/secret"
|
||||
)
|
||||
|
||||
// MachineIP returns the IP address of the machine which is the first address in the subnet.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
"uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"net/netip"
|
||||
"time"
|
||||
"uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
"uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
)
|
||||
|
||||
type WireGuardNetwork struct {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
"uncloud/internal/api"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"fmt"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"log/slog"
|
||||
"uncloud/internal/corrosion"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/corrosion"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"net/netip"
|
||||
"time"
|
||||
"uncloud/internal/machine"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
"uncloud/internal/machine/cluster"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -2,7 +2,7 @@ package ucind
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
)
|
||||
|
||||
type ConfigUpdater struct {
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
"github.com/docker/docker/api/types/image"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/psviderski/uncloud/pkg/client/connector"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
"uncloud/internal/cli/client"
|
||||
"uncloud/internal/cli/client/connector"
|
||||
"uncloud/internal/secret"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
ContainerClient
|
||||
DNSClient
|
||||
ImageClient
|
||||
MachineClient
|
||||
ServiceClient
|
||||
}
|
||||
|
||||
type ContainerClient interface {
|
||||
CreateContainer(
|
||||
ctx context.Context, serviceID string, spec ServiceSpec, machineID string,
|
||||
) (container.CreateResponse, error)
|
||||
InspectContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) (MachineServiceContainer, error)
|
||||
RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
|
||||
StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error
|
||||
StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error
|
||||
}
|
||||
|
||||
type DNSClient interface {
|
||||
GetDomain(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
type ImageClient interface {
|
||||
InspectImage(ctx context.Context, id string) ([]MachineImage, error)
|
||||
InspectRemoteImage(ctx context.Context, id string) ([]MachineRemoteImage, error)
|
||||
}
|
||||
|
||||
type MachineClient interface {
|
||||
InspectMachine(ctx context.Context, id string) (*pb.MachineMember, error)
|
||||
ListMachines(ctx context.Context) ([]*pb.MachineMember, error)
|
||||
}
|
||||
|
||||
type ServiceClient interface {
|
||||
InspectService(ctx context.Context, id string) (Service, error)
|
||||
}
|
||||
|
||||
// ProxyMachinesContext returns a new context that proxies gRPC requests to the specified machines.
|
||||
// If namesOrIDs is nil, all machines are included.
|
||||
func ProxyMachinesContext(ctx context.Context, cli MachineClient, namesOrIDs []string) (context.Context, error) {
|
||||
machines, err := cli.ListMachines(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
|
||||
md := metadata.New(nil)
|
||||
for _, m := range machines {
|
||||
if namesOrIDs == nil ||
|
||||
slices.Contains(namesOrIDs, m.Machine.Name) || slices.Contains(namesOrIDs, m.Machine.Id) {
|
||||
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
|
||||
md.Append("machines", machineIP.String())
|
||||
}
|
||||
}
|
||||
|
||||
return metadata.NewOutgoingContext(ctx, md), nil
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/go-units"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/go-units"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,70 +22,6 @@ type Container struct {
|
||||
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.
|
||||
// A running container with no health check configured is considered healthy.
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
func (c *Container) ConflictingServicePorts(ports []PortSpec) ([]PortSpec, error) {
|
||||
func (c *ServiceContainer) ConflictingServicePorts(ports []PortSpec) ([]PortSpec, error) {
|
||||
svcPorts, err := c.ServicePorts()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get service ports: %w", err)
|
||||
@@ -1,60 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -325,13 +280,13 @@ func TestContainer_ConflictingServicePorts(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctr := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ctr := &ServiceContainer{Container: Container{ContainerJSON: types.ContainerJSON{
|
||||
Config: &container.Config{
|
||||
Labels: map[string]string{
|
||||
LabelServicePorts: tt.containerPorts,
|
||||
},
|
||||
},
|
||||
}}
|
||||
}}}
|
||||
|
||||
got, err := ctr.ConflictingServicePorts(tt.checkPorts)
|
||||
if tt.wantErr {
|
||||
@@ -0,0 +1,5 @@
|
||||
package api
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
@@ -0,0 +1,28 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/docker/api/types"
|
||||
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
type MachineImage struct {
|
||||
Metadata *pb.Metadata
|
||||
Image types.ImageInspect
|
||||
}
|
||||
|
||||
// MachineRemoteImage represents an image in a remote registry fetched by a particular machine.
|
||||
type MachineRemoteImage struct {
|
||||
Metadata *pb.Metadata
|
||||
Image RemoteImage
|
||||
}
|
||||
|
||||
// RemoteImage represents an image in a remote registry. The canonical reference includes the image digest.
|
||||
// Either IndexManifest or ImageManifest must be set depending on whether the reference points to an index
|
||||
// (multi-platform image) or a single-platform image.
|
||||
type RemoteImage struct {
|
||||
Reference reference.Canonical
|
||||
IndexManifest *v1.Index
|
||||
ImageManifest *v1.Manifest
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -261,3 +262,31 @@ func validateHostname(hostname string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PortsEqual returns true if the two port sets are equal. The order of the ports is not important.
|
||||
func PortsEqual(a, b []PortSpec) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
var err error
|
||||
aSerialised := make([]string, len(a))
|
||||
bSerialised := make([]string, len(b))
|
||||
|
||||
for i := range a {
|
||||
aSerialised[i], err = a[i].String()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
bSerialised[i], err = b[i].String()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(aSerialised)
|
||||
slices.Sort(bSerialised)
|
||||
|
||||
return slices.Equal(aSerialised, bSerialised)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
const (
|
||||
ServiceModeReplicated = "replicated"
|
||||
ServiceModeGlobal = "global"
|
||||
|
||||
// PullPolicyAlways means the image is always pulled from the registry.
|
||||
PullPolicyAlways = "always"
|
||||
// PullPolicyMissing means the image is pulled from the registry only if it's not available on the machine where
|
||||
// a container is started. This is the default pull policy.
|
||||
// TODO: make each machine aware of the images on other machines and it possible to pull from them.
|
||||
// Pull from the registry only if the image is missing on all machines.
|
||||
PullPolicyMissing = "missing"
|
||||
// PullPolicyNever means the image is never pulled from the registry. A service with this pull policy can only be
|
||||
// deployed to machines where the image is already available.
|
||||
// TODO: see the TODO above for PullPolicyMissing. Pull from other machines in the cluster if available.
|
||||
PullPolicyNever = "never"
|
||||
)
|
||||
|
||||
var serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
|
||||
|
||||
func ValidateServiceID(id string) bool {
|
||||
return serviceIDRegexp.MatchString(id)
|
||||
}
|
||||
|
||||
// ServiceSpec defines the desired state of a service.
|
||||
// ATTENTION: after changing this struct, verify if deploy.EvalContainerSpecChange needs to be updated.
|
||||
type ServiceSpec struct {
|
||||
Container ContainerSpec
|
||||
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
|
||||
Mode string
|
||||
Name string
|
||||
// Ports defines what service ports to publish to make the service accessible outside the cluster.
|
||||
Ports []PortSpec
|
||||
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
|
||||
Replicas uint `json:",omitempty"`
|
||||
}
|
||||
|
||||
func (s *ServiceSpec) SetDefaults() ServiceSpec {
|
||||
spec := s.Clone()
|
||||
|
||||
if spec.Mode == "" {
|
||||
spec.Mode = ServiceModeReplicated
|
||||
}
|
||||
// Ensure the replicated service has at least one replica.
|
||||
if spec.Mode == ServiceModeReplicated && spec.Replicas == 0 {
|
||||
spec.Replicas = 1
|
||||
}
|
||||
spec.Container = spec.Container.SetDefaults()
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
func (s *ServiceSpec) Validate() error {
|
||||
if err := s.Container.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch s.Mode {
|
||||
case "", ServiceModeGlobal, ServiceModeReplicated:
|
||||
default:
|
||||
return fmt.Errorf("invalid mode: %q", s.Mode)
|
||||
}
|
||||
|
||||
// TODO: validate the service name is a valid DNS label.
|
||||
|
||||
for _, p := range s.Ports {
|
||||
if (p.Mode == "" || p.Mode == PortModeIngress) &&
|
||||
p.Protocol != ProtocolHTTP && p.Protocol != ProtocolHTTPS {
|
||||
return fmt.Errorf("unsupported protocol for ingress port %d: %s", p.ContainerPort, p.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: validate there is no conflict between ports.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServiceSpec) Clone() ServiceSpec {
|
||||
spec := *s
|
||||
|
||||
if s.Ports != nil {
|
||||
spec.Ports = make([]PortSpec, len(s.Ports))
|
||||
copy(spec.Ports, s.Ports)
|
||||
}
|
||||
spec.Container = s.Container.Clone()
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
// ContainerSpec defines the desired state of a container in a service.
|
||||
// ATTENTION: after changing this struct, verify if deploy.EvalContainerSpecChange needs to be updated.
|
||||
type ContainerSpec struct {
|
||||
// Command overrides the default CMD of the image to be executed when running a container.
|
||||
Command []string
|
||||
// Entrypoint overrides the default ENTRYPOINT of the image.
|
||||
Entrypoint []string
|
||||
Image string
|
||||
// Run a custom init inside the container. If nil, use the daemon's configured settings.
|
||||
Init *bool
|
||||
// PullPolicy determines when to pull the image from the registry or use the image already available in the cluster.
|
||||
// Default is PullPolicyMissing if empty.
|
||||
PullPolicy string
|
||||
// List of volumes to bind mount into the container.
|
||||
Volumes []string
|
||||
}
|
||||
|
||||
// SetDefaults returns a copy of the container spec with default values set.
|
||||
func (s *ContainerSpec) SetDefaults() ContainerSpec {
|
||||
spec := s.Clone()
|
||||
if spec.PullPolicy == "" {
|
||||
spec.PullPolicy = PullPolicyMissing
|
||||
}
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
func (s *ContainerSpec) Validate() error {
|
||||
if _, err := reference.ParseDockerRef(s.Image); err != nil {
|
||||
return fmt.Errorf("invalid image: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ContainerSpec) Equals(spec ContainerSpec) bool {
|
||||
orig := s.SetDefaults()
|
||||
spec = spec.SetDefaults()
|
||||
|
||||
slices.Sort(orig.Volumes)
|
||||
slices.Sort(spec.Volumes)
|
||||
|
||||
return reflect.DeepEqual(orig, spec)
|
||||
}
|
||||
|
||||
func (s *ContainerSpec) Clone() ContainerSpec {
|
||||
spec := *s
|
||||
|
||||
if s.Command != nil {
|
||||
spec.Command = make([]string, len(s.Command))
|
||||
copy(spec.Command, s.Command)
|
||||
}
|
||||
if s.Entrypoint != nil {
|
||||
spec.Entrypoint = make([]string, len(s.Entrypoint))
|
||||
copy(spec.Entrypoint, s.Entrypoint)
|
||||
}
|
||||
if s.Volumes != nil {
|
||||
spec.Volumes = make([]string, len(s.Volumes))
|
||||
copy(spec.Volumes, s.Volumes)
|
||||
}
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
ID string
|
||||
Name string
|
||||
Mode string
|
||||
Containers []MachineServiceContainer
|
||||
}
|
||||
|
||||
type MachineServiceContainer struct {
|
||||
MachineID string
|
||||
Container ServiceContainer
|
||||
}
|
||||
|
||||
// Endpoints returns the exposed HTTP and HTTPS endpoints of the service.
|
||||
func (s *Service) Endpoints() []string {
|
||||
endpoints := make(map[string]struct{})
|
||||
|
||||
// Container specs may differ between containers in the same service, e.g. during a rolling update,
|
||||
// so we need to collect all unique endpoints.
|
||||
for _, ctr := range s.Containers {
|
||||
ports, err := ctr.Container.ServicePorts()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, port := range ports {
|
||||
protocol := ""
|
||||
switch port.Protocol {
|
||||
case ProtocolHTTP:
|
||||
protocol = "http"
|
||||
case ProtocolHTTPS:
|
||||
protocol = "https"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if port.Hostname == "" {
|
||||
// There shouldn't be http(s) ports without a hostname but just in case ignore them.
|
||||
continue
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s://%s", protocol, port.Hostname)
|
||||
if port.PublishedPort != 0 {
|
||||
// For non-standard ports (80/443), include the port in the URL.
|
||||
if !(port.Protocol == ProtocolHTTP && port.PublishedPort == 80) &&
|
||||
!(port.Protocol == ProtocolHTTPS && port.PublishedPort == 443) {
|
||||
endpoint += fmt.Sprintf(":%d", port.PublishedPort)
|
||||
}
|
||||
}
|
||||
|
||||
endpoint += fmt.Sprintf(" → :%d", port.ContainerPort)
|
||||
endpoints[endpoint] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return slices.Sorted(maps.Keys(endpoints))
|
||||
}
|
||||
|
||||
func ServiceFromProto(s *pb.Service) (Service, error) {
|
||||
var err error
|
||||
containers := make([]MachineServiceContainer, len(s.Containers))
|
||||
for i, sc := range s.Containers {
|
||||
containers[i], err = machineContainerFromProto(sc)
|
||||
if err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return Service{
|
||||
ID: s.Id,
|
||||
Name: s.Name,
|
||||
Mode: s.Mode,
|
||||
Containers: containers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func machineContainerFromProto(sc *pb.Service_Container) (MachineServiceContainer, error) {
|
||||
var c Container
|
||||
if err := json.Unmarshal(sc.Container, &c); err != nil {
|
||||
return MachineServiceContainer{}, fmt.Errorf("unmarshal container: %w", err)
|
||||
}
|
||||
|
||||
return MachineServiceContainer{
|
||||
MachineID: sc.MachineId,
|
||||
Container: ServiceContainer{Container: c},
|
||||
}, nil
|
||||
}
|
||||
@@ -6,8 +6,9 @@ import (
|
||||
"github.com/distribution/reference"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
"regexp"
|
||||
"uncloud/internal/api"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -21,8 +22,8 @@ var caddyImageTagRegex = regexp.MustCompile(`^2\.\d+\.\d+$`)
|
||||
// NewCaddyDeployment creates a new deployment for a Caddy reverse proxy service.
|
||||
// The service is deployed in global mode to all machines in the cluster. If the image is not provided, the latest
|
||||
// version of the official Caddy Docker image is used.
|
||||
func (cli *Client) NewCaddyDeployment(image string, filter MachineFilter) (*Deployment, error) {
|
||||
latest, err := latestCaddyImage()
|
||||
func (cli *Client) NewCaddyDeployment(image string, filter deploy.MachineFilter) (*deploy.Deployment, error) {
|
||||
latest, err := LatestCaddyImage()
|
||||
if err != nil {
|
||||
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.
|
||||
func latestCaddyImage() (reference.NamedTagged, error) {
|
||||
func LatestCaddyImage() (reference.NamedTagged, error) {
|
||||
repo, err := name.NewRepository(CaddyImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse image: %w", err)
|
||||
@@ -0,0 +1,17 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"github.com/distribution/reference"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLatestCaddyImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
image, err := LatestCaddyImage()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, reference.FamiliarString(image))
|
||||
}
|
||||
@@ -4,16 +4,16 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"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/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.
|
||||
type Client struct {
|
||||
connector Connector
|
||||
@@ -26,6 +26,8 @@ type Client struct {
|
||||
Docker *docker.Client
|
||||
}
|
||||
|
||||
var _ api.Client = (*Client)(nil)
|
||||
|
||||
// Connector is an interface for establishing a connection to the machine API.
|
||||
type Connector interface {
|
||||
Connect(ctx context.Context) (*grpc.ClientConn, error)
|
||||
@@ -2,8 +2,9 @@ package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
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) {
|
||||
@@ -0,0 +1,112 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/compose-spec/compose-go/v2/graph"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
api.DNSClient
|
||||
deploy.Client
|
||||
}
|
||||
|
||||
type Deployment struct {
|
||||
Client Client
|
||||
Project *types.Project
|
||||
SpecResolver *deploy.ServiceSpecResolver
|
||||
plan *deploy.SequenceOperation
|
||||
}
|
||||
|
||||
func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*Deployment, error) {
|
||||
domain, err := cli.GetDomain(ctx)
|
||||
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||
return nil, fmt.Errorf("get cluster domain: %w", err)
|
||||
}
|
||||
|
||||
resolver := &deploy.ServiceSpecResolver{
|
||||
// If the domain is not found (not reserved), an empty domain is used for the resolver.
|
||||
ClusterDomain: domain,
|
||||
// TODO: provide an image resolver.
|
||||
}
|
||||
|
||||
return &Deployment{
|
||||
Client: cli,
|
||||
Project: project,
|
||||
SpecResolver: resolver,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error) {
|
||||
if d.plan != nil {
|
||||
return *d.plan, nil
|
||||
}
|
||||
|
||||
plan := deploy.SequenceOperation{}
|
||||
err := graph.InDependencyOrder(ctx, d.Project,
|
||||
func(ctx context.Context, name string, _ types.ServiceConfig) error {
|
||||
spec, err := d.ServiceSpec(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert compose service '%s' to service spec: %w", name, err)
|
||||
}
|
||||
|
||||
// TODO: properly handle depends_on conditions in the service deployment plan as the first operation.
|
||||
deployment := deploy.NewDeployment(d.Client, spec, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create deployment for service '%s': %w", name, err)
|
||||
}
|
||||
|
||||
servicePlan, err := deployment.Plan(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create deployment plan for service '%s': %w", name, err)
|
||||
}
|
||||
|
||||
// Skip no-op (up-to-date) service plans.
|
||||
if len(servicePlan.Operations) > 0 {
|
||||
plan.Operations = append(plan.Operations, &servicePlan)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
d.plan = &plan
|
||||
}
|
||||
|
||||
return plan, err
|
||||
}
|
||||
|
||||
// ServiceSpec returns the service specification for the given compose service that is ready for deployment.
|
||||
func (d *Deployment) ServiceSpec(name string) (api.ServiceSpec, error) {
|
||||
service, err := d.Project.GetService(name)
|
||||
if err != nil {
|
||||
return api.ServiceSpec{}, fmt.Errorf("get config for compose service '%s': %w", name, err)
|
||||
}
|
||||
|
||||
spec, err := ServiceSpecFromCompose(name, service)
|
||||
if err != nil {
|
||||
return spec, fmt.Errorf("convert compose service '%s' to service spec: %w", name, err)
|
||||
}
|
||||
|
||||
// TODO: resolve the image to a digest and supported platforms using an image resolver that broadcasts requests
|
||||
// to all machines in the cluster. If service.PullPolicy is "missing":
|
||||
// - Broadcast request if any machine contains a particular image and resolve it to image@digest.
|
||||
// - If not found, broadcast request to resolve an image using a registry, and resolve it to image@digest.
|
||||
// TODO: configure placement filter based on the supported platforms of the image.
|
||||
|
||||
// TODO: maybe instantiate ImageResolver here based on PullPolicy of each service?
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func (d *Deployment) Run(ctx context.Context) error {
|
||||
plan, err := d.Plan(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create plan: %w", err)
|
||||
}
|
||||
|
||||
return plan.Execute(ctx, d.Client)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
const PortsExtensionKey = "x-ports"
|
||||
|
||||
type PortsSource []string
|
||||
|
||||
// TransformServicesPortsExtension transforms the ports extension of all services in the project by replacing a string
|
||||
// representation of each port with a parsed PortSpec.
|
||||
func transformServicesPortsExtension(project *types.Project) (*types.Project, error) {
|
||||
return project.WithServicesTransform(func(name string, service types.ServiceConfig) (types.ServiceConfig, error) {
|
||||
ports, ok := service.Extensions[PortsExtensionKey].(PortsSource)
|
||||
if !ok {
|
||||
return service, nil
|
||||
}
|
||||
|
||||
specs, err := transformPortsExtension(ports)
|
||||
if err != nil {
|
||||
return service, err
|
||||
}
|
||||
|
||||
service.Extensions[PortsExtensionKey] = specs
|
||||
return service, nil
|
||||
})
|
||||
}
|
||||
|
||||
func transformPortsExtension(ports PortsSource) ([]api.PortSpec, error) {
|
||||
var specs []api.PortSpec
|
||||
for _, port := range ports {
|
||||
spec, err := api.ParsePortSpec(port)
|
||||
if err != nil {
|
||||
return specs, fmt.Errorf("parse port %q: %w", port, err)
|
||||
}
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
|
||||
return specs, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package compose
|
||||
|
||||
// TODO: make compose, cli, and api packages public.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
)
|
||||
|
||||
func LoadProject(ctx context.Context, paths []string) (*types.Project, error) {
|
||||
options, err := composecli.NewProjectOptions(
|
||||
paths,
|
||||
// First apply os.Environment, always wins.
|
||||
composecli.WithOsEnv,
|
||||
// Read dot env file to populate project environment.
|
||||
composecli.WithDotEnv,
|
||||
// Get compose file path set by COMPOSE_FILE.
|
||||
composecli.WithConfigFileEnv,
|
||||
// If none was selected, get default compose-ports-long.yaml file from current dir or parent folders.
|
||||
composecli.WithDefaultConfigPath,
|
||||
composecli.WithExtension(PortsExtensionKey, PortsSource{}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create compose parser options: %w", err)
|
||||
}
|
||||
|
||||
project, err := options.LoadProject(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if project, err = transformServicesPortsExtension(project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
func ServiceSpecFromCompose(name string, service types.ServiceConfig) (api.ServiceSpec, error) {
|
||||
pullPolicy := ""
|
||||
switch service.PullPolicy {
|
||||
case types.PullPolicyAlways:
|
||||
pullPolicy = api.PullPolicyAlways
|
||||
case "", types.PullPolicyMissing, types.PullPolicyIfNotPresent:
|
||||
pullPolicy = api.PullPolicyMissing
|
||||
case types.PullPolicyNever:
|
||||
pullPolicy = api.PullPolicyNever
|
||||
default:
|
||||
return api.ServiceSpec{}, fmt.Errorf("unsupported pull policy: '%s'", service.PullPolicy)
|
||||
}
|
||||
|
||||
spec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Command: service.Command,
|
||||
Image: service.Image,
|
||||
Init: service.Init,
|
||||
PullPolicy: pullPolicy,
|
||||
// TODO: env
|
||||
// TODO: volumes
|
||||
},
|
||||
Name: name,
|
||||
}
|
||||
|
||||
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok {
|
||||
spec.Ports = ports
|
||||
}
|
||||
|
||||
if service.Deploy != nil {
|
||||
switch service.Deploy.Mode {
|
||||
case "global":
|
||||
spec.Mode = api.ServiceModeGlobal
|
||||
case "", "replicated":
|
||||
spec.Mode = api.ServiceModeReplicated
|
||||
if service.Deploy.Replicas != nil {
|
||||
spec.Replicas = uint(*service.Deploy.Replicas)
|
||||
}
|
||||
default:
|
||||
return spec, fmt.Errorf("unsupported deploy mode: %s", service.Deploy.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
@@ -3,13 +3,13 @@ package connector
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/sshexec"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"net"
|
||||
"strings"
|
||||
"uncloud/internal/machine"
|
||||
"uncloud/internal/sshexec"
|
||||
)
|
||||
|
||||
type SSHConnectorConfig struct {
|
||||
@@ -3,16 +3,16 @@ package connector
|
||||
import (
|
||||
"context"
|
||||
"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/credentials/insecure"
|
||||
"net"
|
||||
"net/netip"
|
||||
"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
|
||||
@@ -4,18 +4,15 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"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"
|
||||
"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.
|
||||
@@ -24,8 +21,9 @@ func (cli *Client) CreateContainer(
|
||||
) (container.CreateResponse, error) {
|
||||
var resp container.CreateResponse
|
||||
|
||||
if !api.ValidateServiceID(serviceID) {
|
||||
return resp, fmt.Errorf("invalid service ID: '%s'", serviceID)
|
||||
spec = spec.SetDefaults()
|
||||
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.
|
||||
|
||||
@@ -40,82 +38,39 @@ func (cli *Client) CreateContainer(
|
||||
}
|
||||
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.
|
||||
ctx = proxyToMachine(ctx, machine.Machine)
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Container %s on %s", containerName, machine.Machine.Name)
|
||||
|
||||
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 {
|
||||
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) {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -235,29 +190,26 @@ func toPullProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
|
||||
}
|
||||
|
||||
// InspectContainer returns the information about the specified container within the service.
|
||||
func (cli *Client) InspectContainer(ctx context.Context, serviceID, containerID string) (api.MachineContainer, error) {
|
||||
var ctr api.MachineContainer
|
||||
|
||||
svc, err := cli.InspectService(ctx, serviceID)
|
||||
func (cli *Client) InspectContainer(
|
||||
ctx context.Context, serviceNameOrID, containerNameOrID string,
|
||||
) (api.MachineServiceContainer, error) {
|
||||
svc, err := cli.InspectService(ctx, serviceNameOrID)
|
||||
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 {
|
||||
if c.Container.ID == containerID || c.Container.NameWithoutSlash() == containerID {
|
||||
ctr = c
|
||||
if c.Container.ID == containerNameOrID || c.Container.Name == containerNameOrID {
|
||||
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.
|
||||
func (cli *Client) StartContainer(ctx context.Context, serviceID, containerID string) error {
|
||||
ctr, err := cli.InspectContainer(ctx, serviceID, containerID)
|
||||
func (cli *Client) StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error {
|
||||
ctr, err := cli.InspectContainer(ctx, serviceNameOrID, containerNameOrID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -269,7 +221,7 @@ func (cli *Client) StartContainer(ctx context.Context, serviceID, containerID st
|
||||
ctx = proxyToMachine(ctx, machine.Machine)
|
||||
|
||||
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))
|
||||
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.
|
||||
func (cli *Client) StopContainer(
|
||||
ctx context.Context, serviceID, containerID string, opts container.StopOptions,
|
||||
ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions,
|
||||
) error {
|
||||
ctr, err := cli.InspectContainer(ctx, serviceID, containerID)
|
||||
ctr, err := cli.InspectContainer(ctx, serviceNameOrID, containerNameOrID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -296,7 +248,7 @@ func (cli *Client) StopContainer(
|
||||
ctx = proxyToMachine(ctx, machine.Machine)
|
||||
|
||||
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))
|
||||
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.
|
||||
func (cli *Client) RemoveContainer(
|
||||
ctx context.Context, serviceID, containerID string, opts container.RemoveOptions,
|
||||
ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions,
|
||||
) error {
|
||||
ctr, err := cli.InspectContainer(ctx, serviceID, containerID)
|
||||
ctr, err := cli.InspectContainer(ctx, serviceNameOrID, containerNameOrID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -323,10 +275,10 @@ func (cli *Client) RemoveContainer(
|
||||
ctx = proxyToMachine(ctx, machine.Machine)
|
||||
|
||||
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))
|
||||
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
|
||||
}
|
||||
pw.Event(progress.RemovedEvent(eventID))
|
||||
@@ -0,0 +1,12 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
)
|
||||
|
||||
// NewDeployment creates a new deployment for the given service specification.
|
||||
// If strategy is nil, a default deploy.RollingStrategy will be used.
|
||||
func (cli *Client) NewDeployment(spec api.ServiceSpec, strategy deploy.Strategy) *deploy.Deployment {
|
||||
return deploy.NewDeployment(cli, spec, strategy)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
type ContainerSpecStatus string
|
||||
|
||||
const ContainerUpToDate ContainerSpecStatus = "up-to-date"
|
||||
const ContainerNeedsUpdate ContainerSpecStatus = "needs-update"
|
||||
const ContainerNeedsRecreate ContainerSpecStatus = "needs-recreate"
|
||||
|
||||
func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) ContainerSpecStatus {
|
||||
current = current.SetDefaults()
|
||||
new = new.SetDefaults()
|
||||
|
||||
// Pull policy doesn't affect the container configuration.
|
||||
new.Container.PullPolicy = current.Container.PullPolicy
|
||||
if !current.Container.Equals(new.Container) {
|
||||
return ContainerNeedsRecreate
|
||||
}
|
||||
|
||||
if current.Mode != new.Mode {
|
||||
return ContainerNeedsRecreate
|
||||
}
|
||||
if current.Name != new.Name {
|
||||
return ContainerNeedsRecreate
|
||||
}
|
||||
|
||||
// TODO: compare mutable properties such as memory or CPU limits when they are implemented.
|
||||
|
||||
// TODO: change ports check to ContainerNeedsUpdate when ingress ports are stored only the machine DB instead
|
||||
// of as labels ans synced to the cluster store. Host ports changes should be handled as ContainerNeedsRecreate.
|
||||
if !api.PortsEqual(current.Ports, new.Ports) {
|
||||
return ContainerNeedsRecreate
|
||||
}
|
||||
|
||||
return ContainerUpToDate
|
||||
}
|
||||
@@ -1,25 +1,34 @@
|
||||
package client
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"uncloud/internal/api"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/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.
|
||||
// It coordinates the validation, planning, and execution of deployment operations.
|
||||
type Deployment struct {
|
||||
Service *api.Service
|
||||
Spec api.ServiceSpec
|
||||
Strategy Strategy
|
||||
cli *Client
|
||||
cli Client
|
||||
plan *Plan
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
ServiceID string
|
||||
ServiceID string
|
||||
ServiceName string
|
||||
SequenceOperation
|
||||
}
|
||||
|
||||
@@ -31,7 +40,7 @@ var ErrNoMatchingMachines = errors.New("no machines match the filter")
|
||||
|
||||
// NewDeployment creates a new deployment for the given service specification.
|
||||
// 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 {
|
||||
strategy = &RollingStrategy{}
|
||||
}
|
||||
@@ -40,7 +49,7 @@ func (cli *Client) NewDeployment(spec api.ServiceSpec, strategy Strategy) (*Depl
|
||||
Spec: spec,
|
||||
Strategy: strategy,
|
||||
cli: cli,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Validate the new spec before planning.
|
||||
// Validate the user-provided spec before resolving it.
|
||||
if err := d.Validate(ctx); err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return fmt.Errorf("invalid service spec: %w", err)
|
||||
}
|
||||
if d.Spec.Name == "" {
|
||||
return errors.New("service name is required")
|
||||
}
|
||||
|
||||
if d.Service == nil {
|
||||
svc, err := d.cli.InspectService(ctx, d.Spec.Name)
|
||||
if err == nil {
|
||||
d.Service = &svc
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
} else if !errors.Is(err, api.ErrNotFound) {
|
||||
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 {
|
||||
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
|
||||
@@ -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
|
||||
// the existing one to match the desired specification.
|
||||
// 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)
|
||||
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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"strings"
|
||||
"uncloud/internal/api"
|
||||
)
|
||||
|
||||
// Operation represents a single atomic operation in a deployment process.
|
||||
// Operations can be composed to form complex deployment strategies.
|
||||
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(resolver NameResolver) string
|
||||
String() string
|
||||
@@ -30,7 +33,7 @@ type RunContainerOperation struct {
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create container: %w", err)
|
||||
@@ -61,7 +64,7 @@ type StopContainerOperation struct {
|
||||
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 {
|
||||
return fmt.Errorf("stop container: %w", err)
|
||||
}
|
||||
@@ -85,7 +88,7 @@ type RemoveContainerOperation struct {
|
||||
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 {
|
||||
return fmt.Errorf("stop container: %w", err)
|
||||
}
|
||||
@@ -111,7 +114,7 @@ type SequenceOperation struct {
|
||||
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 {
|
||||
if err := op.Execute(ctx, cli); err != nil {
|
||||
return err
|
||||
@@ -137,49 +140,3 @@ func (o *SequenceOperation) String() string {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"uncloud/internal/api"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
"uncloud/internal/secret"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"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
|
||||
@@ -17,7 +18,7 @@ type Strategy interface {
|
||||
Type() string
|
||||
// Plan returns the operation to reconcile the service to the desired state.
|
||||
// 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
|
||||
@@ -32,35 +33,29 @@ func (s *RollingStrategy) Type() string {
|
||||
}
|
||||
|
||||
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) {
|
||||
// We can assume that the spec is valid at this point because it has been validated by the deployment.
|
||||
switch spec.Mode {
|
||||
case api.ServiceModeReplicated:
|
||||
return s.planReplicated(ctx, cli, svc, spec)
|
||||
case api.ServiceModeGlobal:
|
||||
return s.planGlobal(ctx, cli, svc, spec)
|
||||
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.
|
||||
// For replicated services, we want to maintain a specific number of containers (replicas) across the available machines
|
||||
// in the cluster.
|
||||
// TODO: schedule containers only on machines that contain the image if pull policy is set to 'never'.
|
||||
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) {
|
||||
var plan Plan
|
||||
|
||||
// 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 {
|
||||
return plan, fmt.Errorf("generate service ID: %w", err)
|
||||
}
|
||||
plan, err := newEmptyPlan(svc, spec)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
|
||||
machines, err := cli.ListMachines(ctx)
|
||||
@@ -95,30 +90,30 @@ func (s *RollingStrategy) planReplicated(
|
||||
})
|
||||
|
||||
// Organise existing containers by machine.
|
||||
containersOnMachine := make(map[string][]api.Container)
|
||||
containersOnMachine := make(map[string][]api.ServiceContainer)
|
||||
upToDateContainersOnMachine := make(map[string]int)
|
||||
containerSpecStatuses := make(map[string]ContainerSpecStatus)
|
||||
if svc != nil {
|
||||
runningSpecs := make(map[string]api.ServiceSpec)
|
||||
for _, c := range svc.Containers {
|
||||
if !c.Container.State.Running || c.Container.State.Paused {
|
||||
// Skip containers that are not running.
|
||||
continue
|
||||
}
|
||||
cs, err := c.Container.ServiceSpec()
|
||||
if err == nil {
|
||||
runningSpecs[c.Container.ID] = cs
|
||||
if cs.Equals(spec) {
|
||||
upToDateContainersOnMachine[c.MachineID] += 1
|
||||
}
|
||||
|
||||
status := EvalContainerSpecChange(c.Container.ServiceSpec, spec)
|
||||
containerSpecStatuses[c.Container.ID] = status
|
||||
|
||||
if status == ContainerUpToDate {
|
||||
upToDateContainersOnMachine[c.MachineID] += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Sort containers such that containers with the desired spec are first.
|
||||
slices.SortFunc(svc.Containers, func(c1, c2 api.MachineContainer) int {
|
||||
if spec1, ok := runningSpecs[c1.Container.ID]; ok && spec1.Equals(spec) {
|
||||
// Sort containers such that running containers with the desired spec are first.
|
||||
slices.SortFunc(svc.Containers, func(c1, c2 api.MachineServiceContainer) int {
|
||||
if status, ok := containerSpecStatuses[c1.Container.ID]; ok && status == ContainerUpToDate {
|
||||
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 0
|
||||
@@ -163,14 +158,14 @@ func (s *RollingStrategy) planReplicated(
|
||||
ctr := containers[0]
|
||||
containersOnMachine[m.Id] = containers[1:]
|
||||
|
||||
if ctr.State.Running {
|
||||
ctrSpec, specErr := ctr.ServiceSpec()
|
||||
if specErr == nil && ctrSpec.Equals(spec) {
|
||||
if status, ok := containerSpecStatuses[ctr.ID]; ok { // Contains statuses for only running containers.
|
||||
if status == ContainerUpToDate {
|
||||
continue
|
||||
}
|
||||
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
|
||||
|
||||
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.
|
||||
plan.Operations = append(plan.Operations, &StopContainerOperation{
|
||||
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
|
||||
// that are down.
|
||||
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) {
|
||||
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
|
||||
// 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.
|
||||
containersOnMachine := make(map[string][]api.MachineContainer)
|
||||
containersOnMachine := make(map[string][]api.MachineServiceContainer)
|
||||
if svc != nil {
|
||||
plan.ServiceID = svc.ID
|
||||
for _, c := range svc.Containers {
|
||||
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)
|
||||
@@ -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?
|
||||
// WARNING: failed to run a service container on machine '%s' which is Down.
|
||||
var machinesDown []*pb.MachineInfo
|
||||
for _, m := range machines {
|
||||
// Skip machines that are down but collect them to report a warning later.
|
||||
if m.State == pb.MachineMember_DOWN {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// removing old ones. If there is a host port conflict, it stops the old container before starting a new one.
|
||||
func reconcileGlobalContainer(
|
||||
containers []api.MachineContainer, spec api.ServiceSpec, serviceID, machineID string,
|
||||
containers []api.MachineServiceContainer, spec api.ServiceSpec, serviceID, machineID string,
|
||||
) ([]Operation, error) {
|
||||
var ops []Operation
|
||||
|
||||
@@ -300,11 +291,8 @@ func reconcileGlobalContainer(
|
||||
continue
|
||||
}
|
||||
|
||||
svcSpec, err := c.Container.ServiceSpec()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get service spec: %w", err)
|
||||
}
|
||||
if svcSpec.Equals(spec) {
|
||||
status := EvalContainerSpecChange(c.Container.ServiceSpec, spec)
|
||||
if status == ContainerUpToDate {
|
||||
// The container is already running with the same spec.
|
||||
upToDate = true
|
||||
for j, old := range containers {
|
||||
@@ -319,6 +307,7 @@ func reconcileGlobalContainer(
|
||||
}
|
||||
break
|
||||
}
|
||||
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
|
||||
}
|
||||
if upToDate {
|
||||
return ops, nil
|
||||
@@ -362,3 +351,23 @@ func reconcileGlobalContainer(
|
||||
|
||||
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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"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/status"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"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.
|
||||
@@ -20,7 +22,7 @@ func (cli *Client) GetDomain(ctx context.Context) (string, error) {
|
||||
domain, err := cli.ClusterClient.GetDomain(ctx, nil)
|
||||
if err != nil {
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return "", ErrNotFound
|
||||
return "", api.ErrNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
@@ -62,59 +64,8 @@ func (cli *Client) CreateIngressRecords(ctx context.Context, serviceID string) (
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
// Verify that the Caddy container is reachable on the machine by its public IP.
|
||||
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"))
|
||||
if err = verifyCaddyReachable(ctx, m.Machine); err == nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func unreachable(id string) progress.Event {
|
||||
return progress.NewEvent(
|
||||
@@ -0,0 +1,20 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
func (cli *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
||||
images, err := cli.Docker.InspectImage(ctx, id)
|
||||
if dockerclient.IsErrNotFound(err) {
|
||||
err = api.ErrNotFound
|
||||
}
|
||||
|
||||
return images, err
|
||||
}
|
||||
|
||||
func (cli *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
|
||||
return cli.Docker.InspectRemoteImage(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
// MapNameResolver resolves machine and container IDs to their names using a static map.
|
||||
type MapNameResolver struct {
|
||||
machines map[string]string
|
||||
containers map[string]string
|
||||
}
|
||||
|
||||
func NewNameResolver(machines, containers map[string]string) *MapNameResolver {
|
||||
return &MapNameResolver{
|
||||
machines: machines,
|
||||
containers: containers,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MapNameResolver) MachineName(machineID string) string {
|
||||
if name, ok := r.machines[machineID]; ok {
|
||||
return name
|
||||
}
|
||||
return machineID
|
||||
}
|
||||
|
||||
func (r *MapNameResolver) ContainerName(containerID string) string {
|
||||
if name, ok := r.containers[containerID]; ok {
|
||||
return name
|
||||
}
|
||||
return containerID
|
||||
}
|
||||
|
||||
// ServiceOperationNameResolver returns a machine and container name resolver for a service that can be used to format
|
||||
// deployment operations.
|
||||
func (cli *Client) ServiceOperationNameResolver(ctx context.Context, svc api.Service) (*MapNameResolver, error) {
|
||||
machines, err := cli.ListMachines(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
machineNames := make(map[string]string, len(machines))
|
||||
for _, m := range machines {
|
||||
machineNames[m.Machine.Id] = m.Machine.Name
|
||||
}
|
||||
containerNames := make(map[string]string, len(svc.Containers))
|
||||
for _, c := range svc.Containers {
|
||||
containerNames[c.Container.ID] = c.Container.Name
|
||||
}
|
||||
|
||||
return NewNameResolver(machineNames, containerNames), nil
|
||||
}
|
||||
@@ -4,41 +4,27 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"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/metadata"
|
||||
"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 {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (cli *Client) RunService(
|
||||
ctx context.Context, spec api.ServiceSpec, filter MachineFilter,
|
||||
ctx context.Context, spec api.ServiceSpec, filter deploy.MachineFilter,
|
||||
) (RunServiceResponse, error) {
|
||||
var resp RunServiceResponse
|
||||
|
||||
@@ -52,40 +38,35 @@ func (cli *Client) RunService(
|
||||
if err == nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
if spec, err = cli.PrepareDeploymentSpec(ctx, spec); err != nil {
|
||||
return resp, fmt.Errorf("prepare service spec ready for deployment: %w", err)
|
||||
deployment := cli.NewDeployment(spec, &deploy.RollingStrategy{MachineFilter: filter})
|
||||
plan, err := deployment.Plan(ctx)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("plan deployment: %w", err)
|
||||
}
|
||||
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
deploy, err := cli.NewDeployment(spec, &RollingStrategy{MachineFilter: filter})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create deployment: %w", err)
|
||||
}
|
||||
|
||||
serviceID, err := deploy.Run(ctx)
|
||||
_, err = deployment.Run(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp.ID = serviceID
|
||||
// TODO: get the service name from the plan when it's available.
|
||||
resp.Name = spec.Name
|
||||
resp.ID = plan.ServiceID
|
||||
resp.Name = plan.ServiceName
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// InspectService returns detailed information about a service and its containers.
|
||||
// The id parameter can be either a service ID or name.
|
||||
func (cli *Client) InspectService(ctx context.Context, id string) (api.Service, error) {
|
||||
// The nameOrID parameter can be either a service name or ID.
|
||||
func (cli *Client) InspectService(ctx context.Context, nameOrID string) (api.Service, error) {
|
||||
var svc api.Service
|
||||
|
||||
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)
|
||||
|
||||
// List only uncloud-managed containers that belong to some service.
|
||||
opts := container.ListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(
|
||||
filters.Arg("label", api.LabelServiceID),
|
||||
filters.Arg("label", api.LabelManaged),
|
||||
),
|
||||
}
|
||||
machineContainers, err := cli.Docker.ListContainers(listCtx, opts)
|
||||
// List all service containers including stopped ones.
|
||||
opts := container.ListOptions{All: true}
|
||||
machineContainers, err := cli.Docker.ListServiceContainers(listCtx, nameOrID, opts)
|
||||
if err != nil {
|
||||
return svc, fmt.Errorf("list containers: %w", err)
|
||||
}
|
||||
|
||||
// Collect all containers on all machines that belong to the specified service.
|
||||
foundByID := false
|
||||
var containers []api.MachineContainer
|
||||
var containers []api.MachineServiceContainer
|
||||
for _, mc := range machineContainers {
|
||||
// Metadata can be nil if the request was broadcasted to only one machine.
|
||||
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 {
|
||||
ctr := api.Container{ContainerJSON: c}
|
||||
if ctr.ServiceID() == id || ctr.ServiceName() == id {
|
||||
containers = append(containers, api.MachineContainer{
|
||||
for _, ctr := range mc.Containers {
|
||||
if ctr.ServiceID() == nameOrID || ctr.ServiceName() == nameOrID {
|
||||
containers = append(containers, api.MachineServiceContainer{
|
||||
MachineID: machineID,
|
||||
Container: ctr,
|
||||
})
|
||||
|
||||
if ctr.ServiceID() == id {
|
||||
if ctr.ServiceID() == nameOrID {
|
||||
foundByID = true
|
||||
}
|
||||
}
|
||||
@@ -166,22 +140,22 @@ func (cli *Client) InspectService(ctx context.Context, id string) (api.Service,
|
||||
}
|
||||
|
||||
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
|
||||
// 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.
|
||||
if foundByID {
|
||||
containers = slices.DeleteFunc(containers, func(mc api.MachineContainer) bool {
|
||||
return mc.Container.ServiceID() != id
|
||||
containers = slices.DeleteFunc(containers, func(mc api.MachineServiceContainer) bool {
|
||||
return mc.Container.ServiceID() != nameOrID
|
||||
})
|
||||
} else {
|
||||
// Matched only by name but there could be multiple services with the same name.
|
||||
serviceID := containers[0].Container.ServiceID()
|
||||
for _, mc := range containers[1:] {
|
||||
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 s, ok := status.FromError(err); ok {
|
||||
if s.Code() == codes.NotFound {
|
||||
return svc, ErrNotFound
|
||||
return svc, api.ErrNotFound
|
||||
}
|
||||
}
|
||||
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{})
|
||||
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)
|
||||
}
|
||||
}()
|
||||
@@ -293,15 +267,9 @@ func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
|
||||
}
|
||||
listCtx := metadata.NewOutgoingContext(ctx, md)
|
||||
|
||||
// List only uncloud-managed containers that belong to some service.
|
||||
opts := container.ListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(
|
||||
filters.Arg("label", api.LabelServiceID),
|
||||
filters.Arg("label", api.LabelManaged),
|
||||
),
|
||||
}
|
||||
machineContainers, err := cli.Docker.ListContainers(listCtx, opts)
|
||||
// List all containers including stopped ones.
|
||||
opts := container.ListOptions{All: true}
|
||||
machineContainers, err := cli.Docker.ListServiceContainers(listCtx, "", opts)
|
||||
if err != nil {
|
||||
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 {
|
||||
if mc.Metadata != nil && mc.Metadata.Error != "" {
|
||||
// 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)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, c := range mc.Containers {
|
||||
ctr := api.Container{ContainerJSON: c}
|
||||
for _, ctr := range mc.Containers {
|
||||
if _, ok := servicesByID[ctr.ServiceID()]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
svc, err := cli.InspectService(ctx, ctr.ServiceID())
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
if errors.Is(err, api.ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("inspect service: %w", err)
|
||||
@@ -2,10 +2,10 @@ package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"net/netip"
|
||||
"uncloud/internal/machine/network"
|
||||
"uncloud/internal/secret"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
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/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -11,9 +14,6 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
"uncloud/internal/cli/client"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
"uncloud/internal/ucind"
|
||||
)
|
||||
|
||||
func createTestCluster(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user