mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
938e7d05c0 | ||
|
|
6c2759f315 | ||
|
|
72bf34632f | ||
|
|
7db8bba6ea | ||
|
|
badb8ecd3c | ||
|
|
9413f1c0b1 | ||
|
|
d8818ba770 | ||
|
|
440215b811 | ||
|
|
02a418aafb | ||
|
|
33550be65e | ||
|
|
edad2429c5 | ||
|
|
b605a64fbe | ||
|
|
16c876d83f | ||
|
|
d6ff62159d | ||
|
|
fcf164dda1 | ||
|
|
e5957d9d6b | ||
|
|
143e68cc95 | ||
|
|
835c310987 | ||
|
|
5fce84d33c | ||
|
|
45497fd20c | ||
|
|
f08d9fe405 | ||
|
|
565a8d8366 | ||
|
|
343357adf9 | ||
|
|
8ee551ca8f | ||
|
|
948201cb6f | ||
|
|
f71fc57f34 | ||
|
|
1acb0ff110 | ||
|
|
03c2681792 | ||
|
|
f3cb6657ae | ||
|
|
4532b985d4 | ||
|
|
000a8a1d3e | ||
|
|
7705db2c4f | ||
|
|
590a1b231f | ||
|
|
8709ac0ed5 | ||
|
|
f8ebd94a79 | ||
|
|
87331a9265 | ||
|
|
2f5a2c6344 | ||
|
|
97e032cb7b | ||
|
|
410a95ec88 | ||
|
|
df4ab40e12 | ||
|
|
c6205d4f57 | ||
|
|
13dea92311 | ||
|
|
9100c22e7f | ||
|
|
6125553a97 | ||
|
|
c9452f1509 | ||
|
|
25b173128a | ||
|
|
4e40a93991 | ||
|
|
9fc54c9d64 | ||
|
|
cacfca7a88 | ||
|
|
b3885b0888 | ||
|
|
3ada89c4f8 | ||
|
|
20b2ca2ea5 | ||
|
|
1bbefdd0d2 | ||
|
|
f467fad84c | ||
|
|
0e80f2e5b2 | ||
|
|
64f6a4f3d3 | ||
|
|
0975cbab66 | ||
|
|
615022c9ac | ||
|
|
ecbd4378e4 | ||
|
|
1becd033e6 | ||
|
|
1cba1eef0a | ||
|
|
3893005866 | ||
|
|
1911e03c82 | ||
|
|
641b11a92d | ||
|
|
7c7530f977 | ||
|
|
6e7fe405ef | ||
|
|
03c5c054e1 | ||
|
|
c42968c812 | ||
|
|
ffef41081b | ||
|
|
58b51b974c | ||
|
|
2d923380a3 | ||
|
|
3910101a88 | ||
|
|
7a9d0cf9db | ||
|
|
042dd594e0 | ||
|
|
1471a94162 | ||
|
|
b1abd07dde | ||
|
|
3757813b20 | ||
|
|
f1a150821c | ||
|
|
874af1ba10 | ||
|
|
2ad098c612 | ||
|
|
4172cb826f | ||
|
|
041d51b74f | ||
|
|
fb01604d21 | ||
|
|
bd8bcbd2f9 | ||
|
|
07501813c8 | ||
|
|
1bbcfa8519 | ||
|
|
e8715fce38 | ||
|
|
a0a66f34c4 | ||
|
|
b799b7518e | ||
|
|
2571498ed2 | ||
|
|
5bcbfc332d | ||
|
|
34574e41fc | ||
|
|
c7355afa3d | ||
|
|
a907fdb1af | ||
|
|
c9ba4adccb | ||
|
|
a0551d2d64 | ||
|
|
76129ea056 | ||
|
|
6ffd35f596 | ||
|
|
0ef776e7f1 | ||
|
|
0b22b2136a | ||
|
|
e1530bb6f3 | ||
|
|
7fc07ce4bd | ||
|
|
eb42082955 | ||
|
|
8719e37856 | ||
|
|
e1cc39b0d5 | ||
|
|
efe646f54c | ||
|
|
e130d803e4 | ||
|
|
56e7e8baa2 | ||
|
|
b963ef39d1 | ||
|
|
1df0fa290f | ||
|
|
c3bb0c9f49 | ||
|
|
cf65cecd9e | ||
|
|
fff17dbb60 | ||
|
|
586a4fa129 | ||
|
|
2a82f75cad | ||
|
|
805afd0d28 | ||
|
|
11098ea7d1 | ||
|
|
90e357a1dc | ||
|
|
91d58bc701 | ||
|
|
69d3a511d6 | ||
|
|
c3123820b2 | ||
|
|
2e49d94fad | ||
|
|
bb51e01c5c | ||
|
|
cdb2884e4e |
@@ -4,6 +4,7 @@
|
|||||||
# Allow files and directories.
|
# Allow files and directories.
|
||||||
!cmd/
|
!cmd/
|
||||||
!internal/
|
!internal/
|
||||||
|
!pkg/
|
||||||
!scripts/docker/
|
!scripts/docker/
|
||||||
!go.*
|
!go.*
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -27,8 +27,9 @@ go.work.sum
|
|||||||
.env
|
.env
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
data
|
/data/
|
||||||
dist/
|
/dist/
|
||||||
|
/uc
|
||||||
|
|
||||||
# Web
|
# Web
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ reset-dev:
|
|||||||
ssh spy@192.168.40.176 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
ssh spy@192.168.40.176 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
||||||
ssh ubuntu@152.67.101.197 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
ssh ubuntu@152.67.101.197 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
||||||
|
|
||||||
|
demo-reset:
|
||||||
|
rm -fv ~/.config/uncloud/config.yaml
|
||||||
|
ssh ubuntu@152.67.101.197 "AUTO_CONFIRM=true sudo -E uncloud-uninstall && docker rmi caddy:2.9.1"
|
||||||
|
ssh root@5.223.45.199 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
|
||||||
|
ssh spy@192.168.40.243 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
|
||||||
|
|
||||||
.PHONY: proto
|
.PHONY: proto
|
||||||
proto:
|
proto:
|
||||||
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ some machines go offline.
|
|||||||
Uncloud aims to be the solution for developers who want the flexibility of self-hosted infrastructure without the
|
Uncloud aims to be the solution for developers who want the flexibility of self-hosted infrastructure without the
|
||||||
operational complexity of Kubernetes.
|
operational complexity of Kubernetes.
|
||||||
|
|
||||||
|
## 🎬 Quick demo
|
||||||
|
|
||||||
|
Deploy a highly available web app with automatic HTTPS across multiple regions and on-premises in just a couple minutes.
|
||||||
|
|
||||||
|
<a href="https://uncloud.wistia.com/medias/k47uwt9uau?wvideo=k47uwt9uau">
|
||||||
|
<img src="https://embed-ssl.wistia.com/deliveries/3cf7014a48b93afc556444bed3e39a8c.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" />
|
||||||
|
</a>
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment —
|
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment —
|
||||||
@@ -25,7 +33,10 @@ operational complexity of Kubernetes.
|
|||||||
* **No control plane**: Fully decentralized design eliminates single points of failure and reduces operational overhead.
|
* **No control plane**: Fully decentralized design eliminates single points of failure and reduces operational overhead.
|
||||||
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
|
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
|
||||||
model and troubleshooting.
|
model and troubleshooting.
|
||||||
|
* **Zero-downtime deployments**: Rolling updates with health checks and automatic rollback on failure.
|
||||||
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
||||||
|
* **Managed DNS**: Automatic DNS records `*.<id>.cluster.uncloud.run` for services with public access via managed
|
||||||
|
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
||||||
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
||||||
Encrypt.
|
Encrypt.
|
||||||
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
||||||
@@ -33,12 +44,11 @@ operational complexity of Kubernetes.
|
|||||||
|
|
||||||
Coming soon:
|
Coming soon:
|
||||||
|
|
||||||
* Project isolation through environments/namespaces
|
|
||||||
* Infrastructure as Code using Docker Compose format
|
* Infrastructure as Code using Docker Compose format
|
||||||
|
* Project isolation through environments/namespaces
|
||||||
* Persistent volumes and secrets management
|
* Persistent volumes and secrets management
|
||||||
* Monitoring and log aggregation
|
* Monitoring and log aggregation
|
||||||
* Database deployment and management
|
* Database deployment and management
|
||||||
* Curated application catalog
|
|
||||||
|
|
||||||
Here is a diagram of an Uncloud multi-provider cluster of 3 machines:
|
Here is a diagram of an Uncloud multi-provider cluster of 3 machines:
|
||||||
|
|
||||||
@@ -66,36 +76,45 @@ platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bar
|
|||||||
|
|
||||||
1. Install Uncloud CLI:
|
1. Install Uncloud CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install psviderski/tap/uncloud
|
brew install psviderski/tap/uncloud
|
||||||
|
|
||||||
# or using curl (macOS/Linux)
|
# or using curl (macOS/Linux)
|
||||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Initialize your first machine:
|
2. Initialize your first machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uc machine init root@your-server-ip
|
uc machine init root@your-server-ip
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Create a DNS A record in your DNS provider (Cloudflare, Namecheap, etc.) that points `app.example.com` to your
|
3. Deploy your app from a Docker image and publish its container port 8000 as HTTPS using `app.example.com` domain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uc run -p app.example.com:8000/https image/my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Create a DNS A record in your DNS provider (Cloudflare, Namecheap, etc.) that points `app.example.com` to your
|
||||||
server's IP address. Allow a few minutes for DNS propagation.
|
server's IP address. Allow a few minutes for DNS propagation.
|
||||||
4. Deploy your app from a Docker image:
|
|
||||||
|
|
||||||
```bash
|
That's it! Your app is now running and accessible at https://app.example.com ✨
|
||||||
uc run -p app.example.com:8000/https my-app-image
|
|
||||||
```
|
|
||||||
|
|
||||||
That's it! Your app is now running and accessible at https://app.example.com ✨
|
|
||||||
|
|
||||||
5. Clean up when you're done:
|
5. Clean up when you're done:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uc ls
|
uc ls
|
||||||
# Copy the service ID from the output and remove it:
|
# Copy the service name from the output and run the rm command:
|
||||||
uc rm my-app-id
|
uc rm my-app-name
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you want to fully uninstall Uncloud on a machine, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uncloud-uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
View the [user guide](docs/user_guide.md) for more information.
|
||||||
|
|
||||||
## ⚙️ How it works
|
## ⚙️ How it works
|
||||||
|
|
||||||
@@ -105,7 +124,42 @@ under the hood to see what happens when you run certain commands.
|
|||||||
**When you initialize a new cluster on a machine:**
|
**When you initialize a new cluster on a machine:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uc machine init root@your-server-ip
|
$ uc machine init --name oracle-vm ubuntu@152.67.101.197
|
||||||
|
|
||||||
|
Downloading Uncloud install script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh
|
||||||
|
⏳ Running Uncloud install script...
|
||||||
|
✓ Docker is already installed.
|
||||||
|
⏳ Installing Docker...
|
||||||
|
...
|
||||||
|
✓ Docker installed successfully.
|
||||||
|
✓ Linux user and group 'uncloud' created.
|
||||||
|
✓ Linux user 'ubuntu' added to group 'uncloud'.
|
||||||
|
⏳ Installing Uncloud binaries...
|
||||||
|
⏳ Downloading uncloudd binary: https://github.com/psviderski/uncloud/releases/latest/download/uncloudd_linux_arm64.tar.gz
|
||||||
|
✓ uncloudd binary installed: /usr/local/bin/uncloudd
|
||||||
|
⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh
|
||||||
|
✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall
|
||||||
|
✓ Systemd unit file created: /etc/systemd/system/uncloud.service
|
||||||
|
Created symlink /etc/systemd/system/multi-user.target.wants/uncloud.service → /etc/systemd/system/uncloud.service.
|
||||||
|
⏳ Downloading uncloud-corrosion binary: https://github.com/psviderski/corrosion/releases/latest/download/corrosion-aarch64-unknown-linux-gnu.tar.gz
|
||||||
|
✓ uncloud-corrosion binary installed: /usr/local/bin/uncloud-corrosion
|
||||||
|
✓ Systemd unit file created: /etc/systemd/system/uncloud-corrosion.service
|
||||||
|
⏳ Starting Uncloud machine daemon (uncloud.service)...
|
||||||
|
✓ Uncloud machine daemon started.
|
||||||
|
✓ Uncloud installed on the machine successfully! 🎉
|
||||||
|
Cluster "default" initialised with machine "oracle-vm"
|
||||||
|
Waiting for the machine to be ready...
|
||||||
|
|
||||||
|
Reserved cluster domain: xuw3xd.cluster.uncloud.run
|
||||||
|
[+] Deploying service caddy 1/1
|
||||||
|
✔ Container caddy-c47x on oracle-vm Started 0.9s
|
||||||
|
|
||||||
|
Updating cluster domain records in Uncloud DNS to point to machines running caddy service...
|
||||||
|
[+] Verifying internet access to caddy service 1/1
|
||||||
|
✔ Machine oracle-vm (152.67.101.197) Reachable 0.1s
|
||||||
|
|
||||||
|
DNS records updated to use only the internet-reachable machines running caddy service:
|
||||||
|
*.xuw3xd.cluster.uncloud.run A → 152.67.101.197
|
||||||
```
|
```
|
||||||
|
|
||||||
1. The CLI SSHs into the machine and installs Docker, the `uncloudd` machine daemon and
|
1. The CLI SSHs into the machine and installs Docker, the `uncloudd` machine daemon and
|
||||||
@@ -121,7 +175,42 @@ uc machine init root@your-server-ip
|
|||||||
**When you add another machine:**
|
**When you add another machine:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uc machine add ubuntu@second-server-ip
|
$ uc machine add --name hetzner-server root@5.223.45.199
|
||||||
|
Downloading Uncloud install script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh
|
||||||
|
⏳ Running Uncloud install script...
|
||||||
|
✓ Docker is already installed.
|
||||||
|
✓ Linux user and group 'uncloud' created.
|
||||||
|
⏳ Installing Uncloud binaries...
|
||||||
|
⏳ Downloading uncloudd binary: https://github.com/psviderski/uncloud/releases/latest/download/uncloudd_linux_amd64.tar.gz
|
||||||
|
✓ uncloudd binary installed: /usr/local/bin/uncloudd
|
||||||
|
⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh
|
||||||
|
✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall
|
||||||
|
✓ Systemd unit file created: /etc/systemd/system/uncloud.service
|
||||||
|
Created symlink /etc/systemd/system/multi-user.target.wants/uncloud.service → /etc/systemd/system/uncloud.service.
|
||||||
|
⏳ Downloading uncloud-corrosion binary: https://github.com/psviderski/corrosion/releases/latest/download/corrosion-x86_64-unknown-linux-gnu.tar.gz
|
||||||
|
✓ uncloud-corrosion binary installed: /usr/local/bin/uncloud-corrosion
|
||||||
|
✓ Systemd unit file created: /etc/systemd/system/uncloud-corrosion.service
|
||||||
|
⏳ Starting Uncloud machine daemon (uncloud.service)...
|
||||||
|
✓ Uncloud machine daemon started.
|
||||||
|
✓ Uncloud installed on the machine successfully! 🎉
|
||||||
|
Machine "hetzner-server" added to cluster
|
||||||
|
Waiting for the machine to be ready...
|
||||||
|
|
||||||
|
[+] Deploying service caddy 1/1
|
||||||
|
✔ Container caddy-d36c on hetzner-server Started 1.0s
|
||||||
|
|
||||||
|
Updating cluster domain records in Uncloud DNS to point to machines running caddy service...
|
||||||
|
[+] Verifying internet access to caddy service 2/2
|
||||||
|
✔ Machine hetzner-server (5.223.45.199) Reachable 0.2s
|
||||||
|
✔ Machine oracle-vm (152.67.101.197) Reachable 0.1s
|
||||||
|
|
||||||
|
DNS records updated to use only the internet-reachable machines running caddy service:
|
||||||
|
*.xuw3xd.cluster.uncloud.run A → 152.67.101.197, 5.223.45.199
|
||||||
|
|
||||||
|
$ uc machine ls
|
||||||
|
NAME STATE ADDRESS PUBLIC IP WIREGUARD ENDPOINTS
|
||||||
|
oracle-vm Up 10.210.0.1/24 152.67.101.197 10.0.0.95:51820, 152.67.101.197:51820
|
||||||
|
hetzner-server Up 10.210.1.1/24 5.223.45.199 5.223.45.199:51820, [2a01:4ff:2f0:128b::1]:51820
|
||||||
```
|
```
|
||||||
|
|
||||||
1. The second machine gets provisioned just like the first. A non-root SSH user will need `sudo` access.
|
1. The second machine gets provisioned just like the first. A non-root SSH user will need `sudo` access.
|
||||||
@@ -140,7 +229,14 @@ and automatically establish a WireGuard tunnel with it.
|
|||||||
**When you run a service:**
|
**When you run a service:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uc run -p app.example.com:8000/https my-app-image
|
$ uc run -p app.example.com:8000/https image/my-app
|
||||||
|
|
||||||
|
[+] Running service my-app-1b3b (replicated mode) 1/1
|
||||||
|
✔ Container my-app-1b3b-tcex on oracle-vm Started
|
||||||
|
|
||||||
|
my-app-1b3b endpoints:
|
||||||
|
• https://app.example.com → :8000
|
||||||
|
• https://my-app-1b3b.xuw3xd.cluster.uncloud.run → :8000
|
||||||
```
|
```
|
||||||
|
|
||||||
1. CLI picks a machine to run your container.
|
1. CLI picks a machine to run your container.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package cluster
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"uncloud/internal/ucind"
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewCreateCommand() *cobra.Command {
|
func NewCreateCommand() *cobra.Command {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package cluster
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"uncloud/internal/ucind"
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRemoveCommand() *cobra.Command {
|
func NewRemoveCommand() *cobra.Command {
|
||||||
|
|||||||
+7
-6
@@ -3,12 +3,13 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/docker/docker/client"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/cmd/ucind/cluster"
|
|
||||||
"uncloud/internal/ucind"
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/psviderski/uncloud/cmd/ucind/cluster"
|
||||||
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -44,9 +45,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
||||||
cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.toml",
|
cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||||
"path to the Uncloud configuration file.")
|
"path to the Uncloud configuration file.")
|
||||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "toml")
|
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
cluster.NewRootCommand(),
|
cluster.NewRootCommand(),
|
||||||
|
|||||||
+44
-93
@@ -4,21 +4,23 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"maps"
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/internal/cli"
|
|
||||||
"uncloud/internal/cli/client"
|
"github.com/docker/cli/cli/streams"
|
||||||
"uncloud/internal/machine/api/pb"
|
"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/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type deployOptions struct {
|
type deployOptions struct {
|
||||||
image string
|
image string
|
||||||
machine string
|
machines []string
|
||||||
cluster string
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDeployCommand() *cobra.Command {
|
func NewDeployCommand() *cobra.Command {
|
||||||
@@ -31,24 +33,25 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
"A rolling update is performed when updating existing containers to minimise disruption.",
|
"A rolling update is performed when updating existing containers to minimise disruption.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return deploy(cmd.Context(), uncli, opts)
|
return runDeploy(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVar(&opts.image, "image", "",
|
cmd.Flags().StringVar(&opts.image, "image", "",
|
||||||
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
|
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
|
||||||
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
"Machine names to deploy to (comma-separated). (default is all machines)")
|
"Machine names to deploy to. Can be specified multiple times or as a comma-separated "+
|
||||||
|
"list of machine names. (default is all machines)")
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "cluster", "c", "",
|
&opts.context, "context", "c", "",
|
||||||
"Name of the cluster to deploy to. (default is the current cluster)",
|
"Name of the cluster context to deploy to. (default is the current context)",
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -56,7 +59,7 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
|
|
||||||
svc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
svc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, client.ErrNotFound) {
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
return fmt.Errorf("inspect caddy service: %w", err)
|
return fmt.Errorf("inspect caddy service: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("Service: %s (not running)\n", client.CaddyServiceName)
|
fmt.Printf("Service: %s (not running)\n", client.CaddyServiceName)
|
||||||
@@ -85,74 +88,46 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Preparing a deployment plan...")
|
fmt.Println("Preparing a deployment plan...")
|
||||||
|
|
||||||
var filter client.MachineFilter
|
placement := api.Placement{
|
||||||
if opts.machine != "" {
|
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||||
machines := strings.Split(opts.machine, ",")
|
|
||||||
for i, m := range machines {
|
|
||||||
machines[i] = strings.TrimSpace(m)
|
|
||||||
}
|
|
||||||
filter = machineFilter(machines)
|
|
||||||
}
|
}
|
||||||
|
d, err := clusterClient.NewCaddyDeployment(opts.image, placement)
|
||||||
d, err := clusterClient.NewCaddyDeployment(opts.image, filter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize a machine and container name resolver to properly format the plan output.
|
|
||||||
machines, err := clusterClient.ListMachines(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("list machines: %w", err)
|
|
||||||
}
|
|
||||||
machineNames := make(map[string]string, len(machines))
|
|
||||||
for _, m := range machines {
|
|
||||||
machineNames[m.Machine.Id] = m.Machine.Name
|
|
||||||
}
|
|
||||||
containerNames := make(map[string]string, len(svc.Containers))
|
|
||||||
for _, c := range svc.Containers {
|
|
||||||
containerNames[c.Container.ID] = c.Container.NameWithoutSlash()
|
|
||||||
}
|
|
||||||
resolver := client.NewNameResolver(machineNames, containerNames)
|
|
||||||
|
|
||||||
if opts.image == "" {
|
if opts.image == "" {
|
||||||
fmt.Printf("Target image: %s (latest stable)\n", d.Spec.Container.Image)
|
fmt.Printf("Target image: %s (latest stable)\n", d.Spec.Container.Image)
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, err := d.Plan(ctx)
|
plan, err := d.Plan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, client.ErrNoMatchingMachines) {
|
|
||||||
return fmt.Errorf("no machines found matching: %s", opts.machine)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("plan caddy deployment: %w", err)
|
return fmt.Errorf("plan caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(plan.SequenceOperation.Operations) == 0 {
|
if len(plan.Operations) == 0 {
|
||||||
if opts.machine != "" {
|
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
|
||||||
fmt.Printf("%s service is up to date on selected machines.\n", client.CaddyServiceName)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if svc.ID == "" {
|
if svc.ID == "" {
|
||||||
if opts.machine != "" {
|
if len(opts.machines) > 0 {
|
||||||
fmt.Println("This will run a Caddy container on selected machines.")
|
fmt.Println("This will run a Caddy container on each selected machine.")
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("This will run a Caddy container on each machine.")
|
fmt.Println("This will run a Caddy container on each machine.")
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if opts.machine != "" {
|
|
||||||
fmt.Println("This will perform a rolling update of Caddy containers on selected machines.")
|
|
||||||
} else {
|
|
||||||
fmt.Println("This will perform a rolling update of Caddy containers on each machine.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
|
// Initialise a machine and container name resolver to properly format the plan output.
|
||||||
|
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
fmt.Println("Deployment plan:")
|
fmt.Println("Deployment plan:")
|
||||||
fmt.Println(plan.Format(resolver))
|
fmt.Println(plan.Format(resolver))
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
confirmed, err := confirm()
|
confirmed, err := cli.Confirm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("confirm deployment: %w", err)
|
return fmt.Errorf("confirm deployment: %w", err)
|
||||||
}
|
}
|
||||||
@@ -173,8 +148,12 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
if _, err = clusterClient.GetDomain(ctx); err != nil {
|
return UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut())
|
||||||
if errors.Is(err, client.ErrNotFound) {
|
}
|
||||||
|
|
||||||
|
func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, progressOut *streams.Out) error {
|
||||||
|
if _, err := clusterClient.GetDomain(ctx); err != nil {
|
||||||
|
if errors.Is(err, api.ErrNotFound) {
|
||||||
fmt.Println("Skipping DNS records update as no cluster domain is reserved (see 'uc dns').")
|
fmt.Println("Skipping DNS records update as no cluster domain is reserved (see 'uc dns').")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -185,10 +164,11 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
// TODO: split the method into two: one to get the records and one to update them to ask for update confirmation.
|
// TODO: split the method into two: one to get the records and one to update them to ask for update confirmation.
|
||||||
|
|
||||||
var records []*pb.DNSRecord
|
var records []*pb.DNSRecord
|
||||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
err := progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
var err error
|
||||||
records, err = clusterClient.CreateIngressRecords(ctx, client.CaddyServiceName)
|
records, err = clusterClient.CreateIngressRecords(ctx, client.CaddyServiceName)
|
||||||
return err
|
return err
|
||||||
}, uncli.ProgressOut(), "Verifying internet access to caddy service")
|
}, progressOut, "Verifying internet access to caddy service")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, client.ErrNoReachableMachines) {
|
if errors.Is(err, client.ErrNoReachableMachines) {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
@@ -216,32 +196,3 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirm() (bool, error) {
|
|
||||||
var confirmed bool
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title(
|
|
||||||
"Do you want to continue?",
|
|
||||||
).
|
|
||||||
Affirmative("Yes!").
|
|
||||||
Negative("No").
|
|
||||||
Value(&confirmed),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return confirmed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func machineFilter(machines []string) client.MachineFilter {
|
|
||||||
if len(machines) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return func(m *pb.MachineInfo) bool {
|
|
||||||
return slices.Contains(machines, m.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewListCommand() *cobra.Command {
|
||||||
|
var clusterContext string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ls",
|
||||||
|
Aliases: []string{"list"},
|
||||||
|
Short: "List available cluster contexts.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return list(uncli)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&clusterContext, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(uncli *cli.CLI) error {
|
||||||
|
if uncli.Config == nil {
|
||||||
|
return fmt.Errorf("context management is not available: Uncloud configuration file is not being used")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(uncli.Config.Contexts) == 0 {
|
||||||
|
fmt.Println("No contexts found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
contextNames := slices.Sorted(maps.Keys(uncli.Config.Contexts))
|
||||||
|
currentContext := uncli.Config.CurrentContext
|
||||||
|
|
||||||
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "NAME\tCURRENT\tCONNECTIONS")
|
||||||
|
|
||||||
|
for _, name := range contextNames {
|
||||||
|
current := ""
|
||||||
|
if name == currentContext {
|
||||||
|
current = "*"
|
||||||
|
}
|
||||||
|
connCount := len(uncli.Config.Contexts[name].Connections)
|
||||||
|
fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tw.Flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRootCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ctx",
|
||||||
|
Aliases: []string{"context"},
|
||||||
|
Short: "Switch between different cluster contexts. Contains subcommands to manage contexts.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return selectContext(uncli)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
NewListCommand(),
|
||||||
|
NewUseCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewUseCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "use [CONTEXT]",
|
||||||
|
Short: "Switch to a different cluster context.",
|
||||||
|
Long: "Switch to a different cluster context. If no context is provided, " +
|
||||||
|
"a list of available contexts will be displayed for selection.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
|
if len(args) == 1 {
|
||||||
|
if err := uncli.SetCurrentContext(args[0]); err != nil {
|
||||||
|
return fmt.Errorf("failed to set the current cluster context to '%s': %w", args[0], err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Current cluster context is now '%s'.\n", args[0])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectContext(uncli)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectContext(uncli *cli.CLI) error {
|
||||||
|
if uncli.Config == nil {
|
||||||
|
return fmt.Errorf("context management is not available: Uncloud configuration file is not being used")
|
||||||
|
}
|
||||||
|
if len(uncli.Config.Contexts) == 0 {
|
||||||
|
return fmt.Errorf("no contexts found in Uncloud config (%s)", uncli.Config.Path())
|
||||||
|
}
|
||||||
|
|
||||||
|
contextNames := slices.Sorted(maps.Keys(uncli.Config.Contexts))
|
||||||
|
|
||||||
|
var selected string
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Select a cluster context").
|
||||||
|
Options(buildContextOptions(contextNames, uncli.Config.CurrentContext)...).
|
||||||
|
Value(&selected),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return fmt.Errorf("select cluster context: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uncli.SetCurrentContext(selected); err != nil {
|
||||||
|
return fmt.Errorf("set current cluster context: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Current cluster context is now '%s'.\n", selected)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildContextOptions(contexts []string, current string) []huh.Option[string] {
|
||||||
|
options := make([]huh.Option[string], len(contexts))
|
||||||
|
|
||||||
|
for i, ctx := range contexts {
|
||||||
|
opt := huh.NewOption(ctx, ctx)
|
||||||
|
if ctx == current {
|
||||||
|
opt.Key += " (current)"
|
||||||
|
opt = opt.Selected(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
options[i] = opt
|
||||||
|
}
|
||||||
|
|
||||||
|
options[1].Selected(true)
|
||||||
|
return options
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deployOptions struct {
|
||||||
|
files []string
|
||||||
|
services []string
|
||||||
|
|
||||||
|
context 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...]",
|
||||||
|
Short: "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.context, "context", "c", "",
|
||||||
|
"Name of the cluster context to deploy to (default is the current context)")
|
||||||
|
// TODO: Consider adding a filter flag to specify which machines to deploy to but keep the rest running.
|
||||||
|
// Could be useful to test a new version on a subset of machines before rolling out to all.
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Includes service dependencies by default. This is the default docker compose behavior.
|
||||||
|
project, err = project.WithSelectedServices(opts.services)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("select services: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
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:")
|
||||||
|
if err = printPlan(ctx, clusterClient, plan); err != nil {
|
||||||
|
return fmt.Errorf("print deployment plan: %w", err)
|
||||||
|
}
|
||||||
|
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 printPlan(ctx context.Context, cli *client.Client, plan deploy.SequenceOperation) error {
|
||||||
|
for _, op := range plan.Operations {
|
||||||
|
svcPlan, ok := op.(*deploy.Plan)
|
||||||
|
if !ok {
|
||||||
|
fmt.Println("- " + op.Format(nil))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := cli.InspectService(ctx, svcPlan.ServiceID)
|
||||||
|
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||||
|
return fmt.Errorf("inspect service: %w", err)
|
||||||
|
}
|
||||||
|
// Initialise a machine and container name resolver to properly format the service plan output.
|
||||||
|
resolver, err := cli.ServiceOperationNameResolver(ctx, svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("- Deploy service [name=%s]\n", svcPlan.ServiceName)
|
||||||
|
fmt.Println(indent(svcPlan.Format(resolver), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func indent(text, prefix string) string {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
lines[i] = prefix + line
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
@@ -4,15 +4,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
"uncloud/internal/cli"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type releaseOptions struct {
|
type releaseOptions struct {
|
||||||
cluster string
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReleaseCommand() *cobra.Command {
|
func NewReleaseCommand() *cobra.Command {
|
||||||
@@ -28,15 +29,15 @@ func NewReleaseCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "cluster", "c", "",
|
&opts.context, "context", "c", "",
|
||||||
"Name of the cluster. (default is the current cluster)",
|
"Name of the cluster context. (default is the current context)",
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func release(ctx context.Context, uncli *cli.CLI, opts releaseOptions) error {
|
func release(ctx context.Context, uncli *cli.CLI, opts releaseOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-11
@@ -4,18 +4,22 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"uncloud/internal/cli"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultUncloudDNSAPIEndpoint = "https://dns.uncloud.run/v1"
|
const DefaultUncloudDNSAPIEndpoint = "https://dns.uncloud.run/v1"
|
||||||
|
|
||||||
type reserveOptions struct {
|
type reserveOptions struct {
|
||||||
endpoint string
|
endpoint string
|
||||||
cluster string
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReserveCommand() *cobra.Command {
|
func NewReserveCommand() *cobra.Command {
|
||||||
@@ -33,21 +37,21 @@ func NewReserveCommand() *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&opts.endpoint, "endpoint", DefaultUncloudDNSAPIEndpoint,
|
cmd.Flags().StringVar(&opts.endpoint, "endpoint", DefaultUncloudDNSAPIEndpoint,
|
||||||
"API endpoint for the Uncloud DNS service.")
|
"API endpoint for the Uncloud DNS service.")
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "cluster", "c", "",
|
&opts.context, "context", "c", "",
|
||||||
"Name of the cluster. (default is the current cluster)",
|
"Name of the cluster context. (default is the current context)",
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer clusterClient.Close()
|
||||||
|
|
||||||
domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.endpoint})
|
domain, err := clusterClient.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.endpoint})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if status.Convert(err).Code() == codes.AlreadyExists {
|
if status.Convert(err).Code() == codes.AlreadyExists {
|
||||||
return errors.New("domain already reserved")
|
return errors.New("domain already reserved")
|
||||||
@@ -56,7 +60,16 @@ func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Reserved cluster domain: %s\n", domain.Name)
|
fmt.Printf("Reserved cluster domain: %s\n", domain.Name)
|
||||||
fmt.Println("Redeploy the Caddy service ('uc caddy deploy') to configure DNS records for the domain " +
|
|
||||||
"to route traffic to the services in the cluster.")
|
// Update cluster domain records in Uncloud DNS to point to machines running caddy service if it has been deployed.
|
||||||
return nil
|
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,13 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"uncloud/internal/cli"
|
|
||||||
"uncloud/internal/cli/client"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type showOptions struct {
|
type showOptions struct {
|
||||||
cluster string
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewShowCommand() *cobra.Command {
|
func NewShowCommand() *cobra.Command {
|
||||||
@@ -26,15 +27,15 @@ func NewShowCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "cluster", "c", "",
|
&opts.context, "context", "c", "",
|
||||||
"Name of the cluster. (default is the current cluster)",
|
"Name of the cluster context. (default is the current context)",
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func show(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
func show(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -42,7 +43,7 @@ func show(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
|||||||
|
|
||||||
domain, err := clusterClient.GetDomain(ctx)
|
domain, err := clusterClient.GetDomain(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, client.ErrNotFound) {
|
if errors.Is(err, api.ErrNotFound) {
|
||||||
return errors.New("no domain reserved")
|
return errors.New("no domain reserved")
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|||||||
+38
-24
@@ -4,18 +4,19 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
|
||||||
"net/netip"
|
|
||||||
"time"
|
|
||||||
"uncloud/internal/cli"
|
|
||||||
"uncloud/internal/cli/client"
|
|
||||||
"uncloud/internal/cli/config"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type addOptions struct {
|
type addOptions struct {
|
||||||
@@ -23,7 +24,8 @@ type addOptions struct {
|
|||||||
noCaddy bool
|
noCaddy bool
|
||||||
publicIP string
|
publicIP string
|
||||||
sshKey string
|
sshKey string
|
||||||
cluster string
|
context string
|
||||||
|
version string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAddCommand() *cobra.Command {
|
func NewAddCommand() *cobra.Command {
|
||||||
@@ -63,10 +65,15 @@ func NewAddCommand() *cobra.Command {
|
|||||||
&opts.sshKey, "ssh-key", "i", "",
|
&opts.sshKey, "ssh-key", "i", "",
|
||||||
"path to SSH private key for SSH remote login. (default ~/.ssh/id_*)",
|
"path to SSH private key for SSH remote login. (default ~/.ssh/id_*)",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVar(
|
||||||
&opts.cluster, "cluster", "c", "",
|
&opts.version, "version", "latest",
|
||||||
"Name of the cluster to add the machine to. (default is the current cluster)",
|
"Version of the Uncloud daemon to install on the machine.",
|
||||||
)
|
)
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context to add the machine to. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +92,13 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, o
|
|||||||
publicIP = &ip
|
publicIP = &ip
|
||||||
}
|
}
|
||||||
|
|
||||||
machineClient, err := uncli.AddMachine(ctx, remoteMachine, opts.cluster, opts.name, publicIP)
|
machineClient, err := uncli.AddMachine(ctx, cli.AddMachineOptions{
|
||||||
|
Context: opts.context,
|
||||||
|
MachineName: opts.name,
|
||||||
|
PublicIP: publicIP,
|
||||||
|
RemoteMachine: remoteMachine,
|
||||||
|
Version: opts.version,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -97,24 +110,17 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, o
|
|||||||
|
|
||||||
// Wait for the cluster to be initialised to be able to deploy the Caddy service.
|
// Wait for the cluster to be initialised to be able to deploy the Caddy service.
|
||||||
fmt.Println("Waiting for the machine to be ready...")
|
fmt.Println("Waiting for the machine to be ready...")
|
||||||
|
fmt.Println()
|
||||||
if err = waitClusterInitialised(ctx, machineClient); err != nil {
|
if err = waitClusterInitialised(ctx, machineClient); err != nil {
|
||||||
return fmt.Errorf("wait for cluster to be initialised on machine: %w", err)
|
return fmt.Errorf("wait for cluster to be initialised on machine: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inspect the added machine to get its ID to create a filter for the Caddy deployment.
|
|
||||||
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("inspect machine: %w", err)
|
|
||||||
}
|
|
||||||
filter := func(m *pb.MachineInfo) bool {
|
|
||||||
return m.Id == minfo.Id
|
|
||||||
}
|
|
||||||
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
|
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
|
||||||
// use the deployed image version. Otherwise, use the latest version.
|
// use the deployed image version. Otherwise, use the latest version.
|
||||||
caddyImage := ""
|
caddyImage := ""
|
||||||
caddySvc, err := machineClient.InspectService(ctx, client.CaddyServiceName)
|
caddySvc, err := machineClient.InspectService(ctx, client.CaddyServiceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, client.ErrNotFound) {
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
return fmt.Errorf("inspect caddy service: %w", err)
|
return fmt.Errorf("inspect caddy service: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -133,17 +139,25 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, o
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
d, err := machineClient.NewCaddyDeployment(caddyImage, filter)
|
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
||||||
|
// that may cause a small downtime.
|
||||||
|
d, err := machineClient.NewCaddyDeployment(caddyImage, api.Placement{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
if _, err = d.Run(ctx); err != nil {
|
if _, err = d.Run(ctx); err != nil {
|
||||||
return fmt.Errorf("deploy caddy: %w", err)
|
return fmt.Errorf("deploy caddy: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
|
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
return caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut())
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitClusterInitialised(ctx context.Context, client *client.Client) error {
|
func waitClusterInitialised(ctx context.Context, client *client.Client) error {
|
||||||
@@ -153,7 +167,7 @@ func waitClusterInitialised(ctx context.Context, client *client.Client) error {
|
|||||||
), ctx)
|
), ctx)
|
||||||
|
|
||||||
check := func() error {
|
check := func() error {
|
||||||
_, err := client.ListMachines(ctx)
|
_, err := client.ListMachines(ctx, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-26
@@ -3,29 +3,38 @@ package machine
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"uncloud/internal/cli"
|
|
||||||
"uncloud/internal/cli/config"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"uncloud/internal/machine/cluster"
|
"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/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type initOptions struct {
|
type initOptions struct {
|
||||||
name string
|
dnsEndpoint string
|
||||||
network string
|
name string
|
||||||
noCaddy bool
|
network string
|
||||||
publicIP string
|
noCaddy bool
|
||||||
sshKey string
|
noDNS bool
|
||||||
cluster string
|
publicIP string
|
||||||
|
sshKey string
|
||||||
|
version string
|
||||||
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInitCommand() *cobra.Command {
|
func NewInitCommand() *cobra.Command {
|
||||||
opts := initOptions{}
|
opts := initOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "init [USER@HOST:PORT]",
|
Use: "init [USER@HOST:PORT]",
|
||||||
Short: "Initialise a new cluster that consists of the local or remote machine.",
|
Short: "Initialise a new cluster with a remote machine as the first member.",
|
||||||
// TODO: include usage examples of initialising a local and remote machine.
|
// TODO: include usage examples of initialising a remote machine.
|
||||||
|
// TODO: support initialising a cluster on the local machine.
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
@@ -47,6 +56,8 @@ func NewInitCommand() *cobra.Command {
|
|||||||
return initCluster(cmd.Context(), uncli, remoteMachine, opts)
|
return initCluster(cmd.Context(), uncli, remoteMachine, opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().StringVar(&opts.dnsEndpoint, "dns-endpoint", dns.DefaultUncloudDNSAPIEndpoint,
|
||||||
|
"API endpoint for the Uncloud DNS service.")
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.name, "name", "n", "",
|
&opts.name, "name", "n", "",
|
||||||
"Assign a name to the machine.",
|
"Assign a name to the machine.",
|
||||||
@@ -59,6 +70,10 @@ func NewInitCommand() *cobra.Command {
|
|||||||
&opts.noCaddy, "no-caddy", false,
|
&opts.noCaddy, "no-caddy", false,
|
||||||
"Don't deploy Caddy reverse proxy service to the machine.",
|
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||||
)
|
)
|
||||||
|
cmd.Flags().BoolVar(
|
||||||
|
&opts.noDNS, "no-dns", false,
|
||||||
|
"Don't reserve a cluster domain in Uncloud DNS.",
|
||||||
|
)
|
||||||
cmd.Flags().StringVar(
|
cmd.Flags().StringVar(
|
||||||
&opts.publicIP, "public-ip", "auto",
|
&opts.publicIP, "public-ip", "auto",
|
||||||
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
||||||
@@ -66,11 +81,15 @@ func NewInitCommand() *cobra.Command {
|
|||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.sshKey, "ssh-key", "i", "",
|
&opts.sshKey, "ssh-key", "i", "",
|
||||||
"path to SSH private key for SSH remote login. (default ~/.ssh/id_*)",
|
"Path to SSH private key for SSH remote login. (default ~/.ssh/id_*)",
|
||||||
|
)
|
||||||
|
cmd.Flags().StringVar(
|
||||||
|
&opts.version, "version", "latest",
|
||||||
|
"Version of the Uncloud daemon to install on the machine.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "cluster", "c", "",
|
&opts.context, "context", "c", "default",
|
||||||
"Name of the cluster in the local config if initialising a remote machine.",
|
"Name of the created context for the initialised cluster in the Uncloud config.",
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -96,13 +115,20 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
publicIP = &ip
|
publicIP = &ip
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := uncli.InitCluster(ctx, remoteMachine, opts.cluster, opts.name, netPrefix, publicIP)
|
client, err := uncli.InitCluster(ctx, cli.InitClusterOptions{
|
||||||
|
Context: opts.context,
|
||||||
|
MachineName: opts.name,
|
||||||
|
Network: netPrefix,
|
||||||
|
PublicIP: publicIP,
|
||||||
|
RemoteMachine: remoteMachine,
|
||||||
|
Version: opts.version,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
if opts.noCaddy {
|
if opts.noCaddy && opts.noDNS {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,16 +136,35 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
// The creation of a deployment plan talks to cluster API. Since the API needs a few moments to become available
|
// The creation of a deployment plan talks to cluster API. Since the API needs a few moments to become available
|
||||||
// after cluster initialisation, we keep the user informed during this wait.
|
// after cluster initialisation, we keep the user informed during this wait.
|
||||||
fmt.Println("Waiting for the machine to be ready...")
|
fmt.Println("Waiting for the machine to be ready...")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
d, err := client.NewCaddyDeployment("", nil)
|
if !opts.noDNS {
|
||||||
if err != nil {
|
domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint})
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
if err != nil {
|
||||||
|
return fmt.Errorf("reserve cluster domain in Uncloud DNS: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Reserved cluster domain: %s\n", domain.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
if !opts.noCaddy {
|
||||||
if _, err = d.Run(ctx); err != nil {
|
d, err := client.NewCaddyDeployment("", api.Placement{})
|
||||||
return fmt.Errorf("deploy caddy: %w", err)
|
if err != nil {
|
||||||
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if _, err = d.Run(ctx); err != nil {
|
||||||
|
return fmt.Errorf("deploy caddy: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
return caddy.UpdateDomainRecords(ctx, client, uncli.ProgressOut())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,29 +3,30 @@ package machine
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"uncloud/internal/cli"
|
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
var cluster string
|
var clusterContext string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
Aliases: []string{"list"},
|
Aliases: []string{"list"},
|
||||||
Short: "List machines in a cluster.",
|
Short: "List machines in a cluster.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return list(cmd.Context(), uncli, cluster)
|
return list(cmd.Context(), uncli, clusterContext)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&cluster, "cluster", "c", "",
|
&clusterContext, "context", "c", "",
|
||||||
"Name of the cluster. (default is the current cluster)",
|
"Name of the cluster context. (default is the current context)",
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -37,7 +38,7 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
|||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
machines, err := client.ListMachines(ctx)
|
machines, err := client.ListMachines(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("list machines: %w", err)
|
return fmt.Errorf("list machines: %w", err)
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@ package machine
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"uncloud/internal/daemon"
|
"github.com/psviderski/uncloud/internal/daemon"
|
||||||
"uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
)
|
)
|
||||||
|
|
||||||
type tokenOptions struct {
|
type tokenOptions struct {
|
||||||
|
|||||||
+51
-18
@@ -3,47 +3,78 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
"net/netip"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/cmd/uncloud/caddy"
|
|
||||||
"uncloud/cmd/uncloud/dns"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
"uncloud/cmd/uncloud/machine"
|
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
|
||||||
"uncloud/cmd/uncloud/service"
|
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||||
"uncloud/internal/cli"
|
"github.com/psviderski/uncloud/cmd/uncloud/machine"
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/service"
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/volume"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type globalOptions struct {
|
||||||
|
configPath string
|
||||||
|
connect string
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var configPath string
|
opts := globalOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "uncloud",
|
Use: "uncloud",
|
||||||
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if strings.HasPrefix(configPath, "~/") {
|
var conn *config.MachineConnection
|
||||||
home, err := os.UserHomeDir()
|
if opts.connect != "" {
|
||||||
if err != nil {
|
if strings.HasPrefix(opts.connect, "tcp://") {
|
||||||
return fmt.Errorf("get user home directory to resolve %q: %w", configPath, err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("initialize CLI: %w", err)
|
return fmt.Errorf("initialise CLI: %w", err)
|
||||||
}
|
}
|
||||||
cmd.SetContext(context.WithValue(cmd.Context(), "cli", uncli))
|
cmd.SetContext(context.WithValue(cmd.Context(), "cli", uncli))
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
||||||
|
"Connect to a remote cluster machine without using the Uncloud configuration file.\n"+
|
||||||
|
"Format: [ssh://]user@host[:port] or tcp://host:port")
|
||||||
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
||||||
cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.toml",
|
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||||
"path to the Uncloud configuration file.")
|
"Path to the Uncloud configuration file.")
|
||||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "toml")
|
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||||
|
// TODO: make --context a global flag and pass it as a value of the command context.
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
|
NewDeployCommand(),
|
||||||
caddy.NewRootCommand(),
|
caddy.NewRootCommand(),
|
||||||
|
cmdcontext.NewRootCommand(),
|
||||||
dns.NewRootCommand(),
|
dns.NewRootCommand(),
|
||||||
machine.NewRootCommand(),
|
machine.NewRootCommand(),
|
||||||
service.NewRootCommand(),
|
service.NewRootCommand(),
|
||||||
@@ -51,6 +82,8 @@ func main() {
|
|||||||
service.NewListCommand(),
|
service.NewListCommand(),
|
||||||
service.NewRmCommand(),
|
service.NewRmCommand(),
|
||||||
service.NewRunCommand(),
|
service.NewRunCommand(),
|
||||||
|
service.NewScaleCommand(),
|
||||||
|
volume.NewRootCommand(),
|
||||||
)
|
)
|
||||||
cobra.CheckErr(cmd.Execute())
|
cobra.CheckErr(cmd.Execute())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/docker/docker/pkg/stringid"
|
|
||||||
"github.com/docker/go-units"
|
|
||||||
"os"
|
"os"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/pkg/stringid"
|
||||||
|
"github.com/docker/go-units"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"uncloud/internal/cli"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type inspectOptions struct {
|
type inspectOptions struct {
|
||||||
@@ -31,8 +32,8 @@ func NewInspectCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "cluster", "c", "",
|
&opts.cluster, "context", "c", "",
|
||||||
"Name of the cluster. (default is the current cluster)",
|
"Name of the cluster context. (default is the current context)",
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -49,7 +50,7 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
|||||||
return fmt.Errorf("inspect service: %w", err)
|
return fmt.Errorf("inspect service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
machines, err := client.ListMachines(ctx)
|
machines, err := client.ListMachines(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("list machines: %w", err)
|
return fmt.Errorf("list machines: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"uncloud/internal/cli"
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
|
// TODO(lhf): rename to context
|
||||||
var cluster string
|
var cluster string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
@@ -22,8 +24,8 @@ func NewListCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&cluster, "cluster", "c", "",
|
&cluster, "context", "c", "",
|
||||||
"Name of the cluster. (default is the current cluster)",
|
"Name of the cluster context. (default is the current context)",
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,10 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/docker/cli/cli/streams"
|
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"os"
|
|
||||||
"uncloud/internal/cli"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type rmOptions struct {
|
type rmOptions struct {
|
||||||
@@ -29,8 +28,8 @@ func NewRmCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "cluster", "c", "",
|
&opts.cluster, "context", "c", "",
|
||||||
"Name of the cluster. (default is the current cluster)",
|
"Name of the cluster context. (default is the current context)",
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -48,7 +47,7 @@ func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
|||||||
return fmt.Errorf("remove service '%s': %w", s, err)
|
return fmt.Errorf("remove service '%s': %w", s, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, streams.NewOut(os.Stdout), "Removing service "+s)
|
}, uncli.ProgressOut(), "Removing service "+s)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ import (
|
|||||||
|
|
||||||
func NewRootCommand() *cobra.Command {
|
func NewRootCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "service",
|
Use: "service",
|
||||||
Short: "Manage services in an Uncloud cluster.",
|
Aliases: []string{"svc"},
|
||||||
|
Short: "Manage services in an Uncloud cluster.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewInspectCommand(),
|
NewInspectCommand(),
|
||||||
NewListCommand(),
|
NewListCommand(),
|
||||||
NewRmCommand(),
|
NewRmCommand(),
|
||||||
NewRunCommand(),
|
NewRunCommand(),
|
||||||
|
NewScaleCommand(),
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
+279
-75
@@ -3,24 +3,36 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
"os"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/internal/api"
|
|
||||||
"uncloud/internal/cli"
|
dockeropts "github.com/docker/cli/opts"
|
||||||
"uncloud/internal/cli/client"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/docker/docker/daemon/names"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type runOptions struct {
|
type runOptions struct {
|
||||||
command []string
|
command []string
|
||||||
image string
|
cpu dockeropts.NanoCPUs
|
||||||
machines []string
|
entrypoint string
|
||||||
mode string
|
entrypointChanged bool
|
||||||
name string
|
env []string
|
||||||
publish []string
|
image string
|
||||||
replicas uint
|
machines []string
|
||||||
volumes []string
|
memory dockeropts.MemBytes
|
||||||
|
mode string
|
||||||
|
name string
|
||||||
|
privileged bool
|
||||||
|
publish []string
|
||||||
|
pull string
|
||||||
|
replicas uint
|
||||||
|
user string
|
||||||
|
volumes []string
|
||||||
|
|
||||||
cluster string
|
cluster string
|
||||||
}
|
}
|
||||||
@@ -35,6 +47,7 @@ func NewRunCommand() *cobra.Command {
|
|||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
|
opts.entrypointChanged = cmd.Flag("entrypoint").Changed
|
||||||
opts.image = args[0]
|
opts.image = args[0]
|
||||||
if len(args) > 1 {
|
if len(args) > 1 {
|
||||||
opts.command = args[1:]
|
opts.command = args[1:]
|
||||||
@@ -44,115 +57,306 @@ func NewRunCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().VarP(&opts.cpu, "cpu", "",
|
||||||
|
"Maximum number of CPU cores a service container can use. Fractional values are allowed: "+
|
||||||
|
"0.5 for half a core or 2.25 for two and a quarter cores.")
|
||||||
|
cmd.Flags().StringVar(&opts.entrypoint, "entrypoint", "",
|
||||||
|
"Overwrite the default ENTRYPOINT of the image. Pass an empty string \"\" to reset it.")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.env, "env", "e", nil,
|
||||||
|
"Set an environment variable for service containers. Can be specified multiple times.\n"+
|
||||||
|
"Format: VAR=value or just VAR to use the value from the local environment.")
|
||||||
cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated,
|
cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated,
|
||||||
fmt.Sprintf("Replication mode of the service: either %q (a specified number of containers across "+
|
fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+
|
||||||
"the machines) or %q (one container on every machine).",
|
"the machines) or '%s' (one container on every machine).",
|
||||||
api.ServiceModeReplicated, api.ServiceModeGlobal))
|
api.ServiceModeReplicated, api.ServiceModeGlobal))
|
||||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
"Placement constraint by machine name, limiting which machines the service can run on. Can be specified "+
|
"Placement constraint by machine names, limiting which machines the service can run on. Can be specified "+
|
||||||
"multiple times or as a comma-separated list of machine names. (default is any suitable machine)")
|
"multiple times or as a comma-separated list of machine names. (default is any suitable machine)")
|
||||||
|
cmd.Flags().VarP(&opts.memory, "memory", "",
|
||||||
|
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix "+
|
||||||
|
"(b, k, m, g). Default unit is bytes if no suffix specified.\n"+
|
||||||
|
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
|
||||||
cmd.Flags().StringVarP(&opts.name, "name", "n", "",
|
cmd.Flags().StringVarP(&opts.name, "name", "n", "",
|
||||||
"Assign a name to the service. A random name is generated if not specified.")
|
"Assign a name to the service. A random name is generated if not specified.")
|
||||||
|
cmd.Flags().BoolVar(&opts.privileged, "privileged", false,
|
||||||
|
"Give extended privileges to service containers. This is a security risk and should be used with caution.")
|
||||||
cmd.Flags().StringSliceVarP(&opts.publish, "publish", "p", nil,
|
cmd.Flags().StringSliceVarP(&opts.publish, "publish", "p", nil,
|
||||||
"Publish a service port to make it accessible outside the cluster. Can be specified multiple times.\n"+
|
"Publish a service port to make it accessible outside the cluster. Can be specified multiple times.\n"+
|
||||||
"Format: [hostname:][load_balancer_port:]container_port[/protocol] or [host_ip:]:host_port:container_port[/protocol]@host\n"+
|
"Format: [hostname:][load_balancer_port:]container_port[/protocol] or [host_ip:]:host_port:container_port[/protocol]@host\n"+
|
||||||
"Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified,\n"+
|
"Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified\n"+
|
||||||
"service-name.cluster-domain will be used as the hostname.\n"+
|
"and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.\n"+
|
||||||
"Examples:\n"+
|
"Examples:\n"+
|
||||||
" -p 8080/https Publish port 8080 as HTTPS via load balancer with default service-name.cluster-domain hostname\n"+
|
" -p 8080/https Publish port 8080 as HTTPS via load balancer with default service-name.cluster-domain hostname\n"+
|
||||||
" -p app.example.com:8080/https Publish port 8080 as HTTPS via load balancer with custom hostname\n"+
|
" -p app.example.com:8080/https Publish port 8080 as HTTPS via load balancer with custom hostname\n"+
|
||||||
" -p 9000:8080 Publish port 8080 as TCP port 9000 via load balancer\n"+
|
" -p 9000:8080 Publish port 8080 as TCP port 9000 via load balancer\n"+
|
||||||
" -p 53:5353/udp@host Bind UDP port 5353 to host port 53")
|
" -p 53:5353/udp@host Bind UDP port 5353 to host port 53")
|
||||||
|
cmd.Flags().StringVar(&opts.pull, "pull", api.PullPolicyMissing,
|
||||||
|
fmt.Sprintf("Pull image from the registry before running service containers ('%s', '%s', '%s').",
|
||||||
|
api.PullPolicyAlways, api.PullPolicyMissing, api.PullPolicyNever))
|
||||||
cmd.Flags().UintVar(&opts.replicas, "replicas", 1,
|
cmd.Flags().UintVar(&opts.replicas, "replicas", 1,
|
||||||
"Number of containers to run for the service. Only valid for a replicated service.")
|
"Number of containers to run for the service. Only valid for a replicated service.")
|
||||||
|
cmd.Flags().StringVarP(&opts.user, "user", "u", "",
|
||||||
|
"User name or UID and optionally group name or GID used for running the command inside service containers.\n"+
|
||||||
|
"Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.")
|
||||||
cmd.Flags().StringSliceVarP(&opts.volumes, "volume", "v", nil,
|
cmd.Flags().StringSliceVarP(&opts.volumes, "volume", "v", nil,
|
||||||
"Bind mount a host file or directory into a service container using the format "+
|
"Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where\n"+
|
||||||
"/host/path:/container/path[:ro]. Can be specified multiple times.")
|
"the volume is located. Can be specified multiple times.\n"+
|
||||||
|
"Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]\n"+
|
||||||
|
"Examples:\n"+
|
||||||
|
" -v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container\n"+
|
||||||
|
" -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container\n"+
|
||||||
|
" -v /host/path:/container/path:ro Bind mount a host directory or file as read-only")
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "cluster", "c", "",
|
&opts.cluster, "context", "c", "",
|
||||||
"Name of the cluster to run the service in. (default is the current cluster)",
|
"Name of the cluster context to run the service in. (default is the current context)",
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
||||||
|
spec, err := prepareServiceSpec(opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
var resp api.RunServiceResponse
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
resp, err = clusterClient.RunService(ctx, spec)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("run service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), fmt.Sprintf("Running service %s (%s mode)", spec.Name, spec.Mode))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := clusterClient.InspectService(ctx, resp.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
||||||
|
var spec api.ServiceSpec
|
||||||
|
|
||||||
|
env, err := parseEnv(opts.env)
|
||||||
|
if err != nil {
|
||||||
|
return spec, err
|
||||||
|
}
|
||||||
|
|
||||||
switch opts.mode {
|
switch opts.mode {
|
||||||
case api.ServiceModeReplicated, api.ServiceModeGlobal:
|
case api.ServiceModeReplicated, api.ServiceModeGlobal:
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid replication mode: %q", opts.mode)
|
return spec, fmt.Errorf("invalid replication mode: '%s'", opts.mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
var machineFilter client.MachineFilter
|
switch opts.pull {
|
||||||
if len(opts.machines) > 0 {
|
case api.PullPolicyAlways, api.PullPolicyMissing, api.PullPolicyNever:
|
||||||
var machines []string
|
default:
|
||||||
for _, value := range opts.machines {
|
return spec, fmt.Errorf("invalid pull policy: '%s'", opts.pull)
|
||||||
if value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
mlist := strings.Split(value, ",")
|
|
||||||
for _, m := range mlist {
|
|
||||||
if m = strings.TrimSpace(m); m != "" {
|
|
||||||
machines = append(machines, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(machines) > 0 {
|
|
||||||
machineFilter = func(m *pb.MachineInfo) bool {
|
|
||||||
return slices.Contains(machines, m.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ports := make([]api.PortSpec, len(opts.publish))
|
ports := make([]api.PortSpec, len(opts.publish))
|
||||||
for i, publishPort := range opts.publish {
|
for i, publishPort := range opts.publish {
|
||||||
port, err := api.ParsePortSpec(publishPort)
|
port, err := api.ParsePortSpec(publishPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid service port '%s': %w", publishPort, err)
|
return spec, fmt.Errorf("invalid service port '%s': %w", publishPort, err)
|
||||||
}
|
}
|
||||||
ports[i] = port
|
ports[i] = port
|
||||||
}
|
}
|
||||||
// TODO: parse and validate opts.volumes to fail fast if invalid.
|
|
||||||
|
|
||||||
spec := api.ServiceSpec{
|
volumes, mounts, err := parseVolumeFlags(opts.volumes)
|
||||||
|
if err != nil {
|
||||||
|
return spec, err
|
||||||
|
}
|
||||||
|
|
||||||
|
placement := api.Placement{
|
||||||
|
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||||
|
}
|
||||||
|
|
||||||
|
spec = api.ServiceSpec{
|
||||||
Container: api.ContainerSpec{
|
Container: api.ContainerSpec{
|
||||||
Command: opts.command,
|
Command: opts.command,
|
||||||
Image: opts.image,
|
Env: env,
|
||||||
Volumes: opts.volumes,
|
Image: opts.image,
|
||||||
|
Privileged: opts.privileged,
|
||||||
|
PullPolicy: opts.pull,
|
||||||
|
Resources: api.ContainerResources{
|
||||||
|
CPU: opts.cpu.Value(),
|
||||||
|
Memory: opts.memory.Value(),
|
||||||
|
},
|
||||||
|
User: opts.user,
|
||||||
|
VolumeMounts: mounts,
|
||||||
},
|
},
|
||||||
Mode: opts.mode,
|
Mode: opts.mode,
|
||||||
Name: opts.name,
|
Name: opts.name,
|
||||||
Ports: ports,
|
Placement: placement,
|
||||||
Replicas: opts.replicas,
|
Ports: ports,
|
||||||
}
|
Replicas: opts.replicas,
|
||||||
if err := spec.Validate(); err != nil {
|
Volumes: volumes,
|
||||||
return fmt.Errorf("invalid service configuration: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
// Overwrite the default ENTRYPOINT of the image or reset it if an empty string is passed.
|
||||||
if err != nil {
|
if opts.entrypoint != "" {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
spec.Container.Entrypoint = []string{opts.entrypoint}
|
||||||
}
|
} else if opts.entrypointChanged {
|
||||||
defer client.Close()
|
spec.Container.Entrypoint = []string{""}
|
||||||
|
|
||||||
resp, err := client.RunService(ctx, spec, machineFilter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("run service: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
svc, err := client.InspectService(ctx, resp.ID)
|
if err = spec.Validate(); err != nil {
|
||||||
if err != nil {
|
return spec, fmt.Errorf("invalid service configuration: %w", err)
|
||||||
return fmt.Errorf("inspect service: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
// Generate a service name if not specified to be able to include it in the progress title.
|
||||||
fmt.Printf("%s endpoints:\n", svc.Name)
|
if spec.Name == "" {
|
||||||
for _, endpoint := range svc.Endpoints() {
|
spec.Name, err = deploy.GenerateServiceName(spec.Container.Image)
|
||||||
fmt.Printf(" • %s\n", endpoint)
|
if err != nil {
|
||||||
|
return spec, fmt.Errorf("generate service name: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return spec, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEnv parses the environment variables from the command line arguments.
|
||||||
|
// It supports two formats: "VAR=value" or just "VAR" to use the value from the local environment.
|
||||||
|
func parseEnv(env []string) (api.EnvVars, error) {
|
||||||
|
envVars := make(api.EnvVars)
|
||||||
|
for _, e := range env {
|
||||||
|
key, value, hasValue := strings.Cut(e, "=")
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("invalid environment variable: '%s'", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasValue {
|
||||||
|
envVars[key] = value
|
||||||
|
} else {
|
||||||
|
if localEnvValue, ok := os.LookupEnv(key); ok {
|
||||||
|
envVars[key] = localEnvValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return envVars, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVolumeFlags parses volume flag values in Docker CLI format and returns VolumeSpecs and VolumeMounts.
|
||||||
|
// It handles both named volumes (volume_name:/container/path[:ro|volume-nocopy])
|
||||||
|
// and bind mounts (/host/path:/container/path[:ro]).
|
||||||
|
func parseVolumeFlags(volumes []string) ([]api.VolumeSpec, []api.VolumeMount, error) {
|
||||||
|
specs := make([]api.VolumeSpec, 0, len(volumes))
|
||||||
|
mounts := make([]api.VolumeMount, 0, len(volumes))
|
||||||
|
|
||||||
|
// Track volume names to avoid duplicate specs.
|
||||||
|
seenVolumes := make(map[string]struct{})
|
||||||
|
|
||||||
|
for _, vol := range volumes {
|
||||||
|
spec, mount, err := parseVolumeFlagValue(vol)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("invalid volume mount '%s': %w", vol, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := seenVolumes[spec.Name]; !ok {
|
||||||
|
specs = append(specs, spec)
|
||||||
|
seenVolumes[spec.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
mounts = append(mounts, mount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return specs, mounts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVolumeFlagValue(volume string) (api.VolumeSpec, api.VolumeMount, error) {
|
||||||
|
var spec api.VolumeSpec
|
||||||
|
var mount api.VolumeMount
|
||||||
|
|
||||||
|
parts := strings.Split(volume, ":")
|
||||||
|
switch len(parts) {
|
||||||
|
case 1:
|
||||||
|
return spec, mount, fmt.Errorf("invalid format, must contain at least one separator ':'")
|
||||||
|
case 2, 3:
|
||||||
|
// Format: (volume_name|/host/path):/container/path[:opts]
|
||||||
|
if !strings.HasPrefix(parts[1], "/") {
|
||||||
|
return spec, mount, fmt.Errorf("invalid container mount path: '%s', must be absolute path", parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
mount.ContainerPath = parts[1]
|
||||||
|
volumeNoCopy := false
|
||||||
|
|
||||||
|
if len(parts) == 3 {
|
||||||
|
opts := strings.Split(parts[2], ",")
|
||||||
|
for _, opt := range opts {
|
||||||
|
switch opt {
|
||||||
|
case "ro", "readonly":
|
||||||
|
mount.ReadOnly = true
|
||||||
|
case "volume-nocopy":
|
||||||
|
volumeNoCopy = true
|
||||||
|
default:
|
||||||
|
return spec, mount, fmt.Errorf("invalid option: '%s'", opt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(parts[0], "/") {
|
||||||
|
// Host path bind mount: /host/path:/container/path
|
||||||
|
suffix, err := secret.RandomAlphaNumeric(4)
|
||||||
|
if err != nil {
|
||||||
|
return spec, mount, fmt.Errorf("generate random suffix: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
spec = api.VolumeSpec{
|
||||||
|
Name: "bind-" + suffix,
|
||||||
|
Type: api.VolumeTypeBind,
|
||||||
|
BindOptions: &api.BindOptions{
|
||||||
|
HostPath: parts[0],
|
||||||
|
CreateHostPath: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Named volume mount: volume_name:/container/path
|
||||||
|
volumeName := parts[0]
|
||||||
|
if !names.RestrictedNamePattern.MatchString(volumeName) {
|
||||||
|
return spec, mount, fmt.Errorf("volume name '%s' includes invalid characters, only '%s' are allowed. "+
|
||||||
|
"If you intended to pass a host directory or file, use absolute path",
|
||||||
|
volumeName, names.RestrictedNameChars)
|
||||||
|
}
|
||||||
|
|
||||||
|
spec = api.VolumeSpec{
|
||||||
|
Name: volumeName,
|
||||||
|
Type: api.VolumeTypeVolume,
|
||||||
|
VolumeOptions: &api.VolumeOptions{
|
||||||
|
Name: volumeName,
|
||||||
|
NoCopy: volumeNoCopy,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mount.VolumeName = spec.Name
|
||||||
|
default:
|
||||||
|
return spec, mount, fmt.Errorf("invalid format, must container at most 2 separators ':'")
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, mount, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type scaleOptions struct {
|
||||||
|
service string
|
||||||
|
replicas uint
|
||||||
|
cluster string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScaleCommand() *cobra.Command {
|
||||||
|
opts := scaleOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "scale SERVICE REPLICAS",
|
||||||
|
Short: "Scale a replicated service by changing the number of replicas.",
|
||||||
|
Long: "Scale a replicated service by changing the number of replicas. Scaling down requires confirmation.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
|
opts.service = args[0]
|
||||||
|
replicas, err := strconv.ParseUint(args[1], 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid number of replicas: %w", err)
|
||||||
|
}
|
||||||
|
opts.replicas = uint(replicas)
|
||||||
|
|
||||||
|
return scale(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.cluster, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
||||||
|
if opts.replicas == 0 {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"scaling to zero replicas is not supported. This would effectively remove the service without preserving "+
|
||||||
|
"its configuration, making it impossible to scale back up. Uncloud derives the service configuration "+
|
||||||
|
"from existing containers. Use 'uc rm %s' instead if you want to remove the service",
|
||||||
|
opts.service,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
svc, err := clusterClient.InspectService(ctx, opts.service)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect service '%s': %w", opts.service, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if svc.Mode != api.ServiceModeReplicated {
|
||||||
|
return fmt.Errorf("scaling is only supported for services in %s mode, service '%s' is in %s mode",
|
||||||
|
api.ServiceModeReplicated, svc.Name, svc.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentReplicas := uint(len(svc.Containers))
|
||||||
|
|
||||||
|
if currentReplicas == opts.replicas {
|
||||||
|
fmt.Printf("Service '%s' already has %d replicas. No changes required.\n", svc.Name, currentReplicas)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Check if all containers have the same spec. If not, prompt user to choose which one to scale.
|
||||||
|
// This can happen if a service deployment failed midway and some containers were not updated.
|
||||||
|
spec := svc.Containers[0].Container.ServiceSpec
|
||||||
|
spec.Replicas = opts.replicas
|
||||||
|
deployment := clusterClient.NewDeployment(spec, nil)
|
||||||
|
plan, err := deployment.Plan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan deployment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(plan.Operations) == 0 {
|
||||||
|
fmt.Printf("Service '%s' is already scaled to %d replicas.\n", svc.Name, opts.replicas)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.replicas < currentReplicas {
|
||||||
|
// Initialise a machine and container name resolver to properly format the plan output.
|
||||||
|
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Scaling plan for service %s (%d → %d replicas):\n", svc.Name, currentReplicas, opts.replicas)
|
||||||
|
fmt.Println(plan.Format(resolver))
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Ask for confirmation before scaling down as it may cause data loss.
|
||||||
|
confirmed, err := confirm()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("confirm scaling: %w", err)
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
fmt.Println("Cancelled. No changes were made.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title := fmt.Sprintf("Scaling service %s (%d → %d replicas)", svc.Name, currentReplicas, opts.replicas)
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if _, err = deployment.Run(ctx); err != nil {
|
||||||
|
return fmt.Errorf("deploy service: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), title)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirm() (bool, error) {
|
||||||
|
var confirmed bool
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewConfirm().
|
||||||
|
Title(
|
||||||
|
"Do you want to continue?",
|
||||||
|
).
|
||||||
|
Affirmative("Yes!").
|
||||||
|
Negative("No").
|
||||||
|
Value(&confirmed),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return confirmed, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package volume
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/docker/docker/api/types/volume"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type createOptions struct {
|
||||||
|
driver string
|
||||||
|
driverOpts []string
|
||||||
|
labels []string
|
||||||
|
machine string
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateCommand() *cobra.Command {
|
||||||
|
opts := createOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "create [FLAGS] VOLUME_NAME",
|
||||||
|
Short: "Create a volume on a specific machine.",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
opts.driver = strings.TrimSpace(opts.driver)
|
||||||
|
|
||||||
|
volumeName := args[0]
|
||||||
|
if volumeName == "" {
|
||||||
|
return fmt.Errorf("volume name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return create(cmd.Context(), uncli, volumeName, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.driver, "driver", "d", "local",
|
||||||
|
"Volume driver to use.")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.driverOpts, "opt", "o", nil,
|
||||||
|
"Driver specific options in the form of 'key=value' pairs. Can be specified multiple times.")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.labels, "label", "l", nil,
|
||||||
|
"Labels to assign to the volume in the form of 'key=value' pairs. Can be specified multiple times.")
|
||||||
|
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||||
|
"Name or ID of the machine to create the volume on.")
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func create(ctx context.Context, uncli *cli.CLI, name string, opts createOptions) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Parse driver options.
|
||||||
|
driverOpts := make(map[string]string)
|
||||||
|
for _, opt := range opts.driverOpts {
|
||||||
|
k, v, ok := strings.Cut(opt, "=")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid driver option format: '%s' (expected key=value)", opt)
|
||||||
|
}
|
||||||
|
driverOpts[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse labels.
|
||||||
|
labels := make(map[string]string)
|
||||||
|
for _, label := range opts.labels {
|
||||||
|
k, v, ok := strings.Cut(label, "=")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid label format: '%s' (expected key=value)", label)
|
||||||
|
}
|
||||||
|
labels[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// List machines and filter by the specified machine name or ID.
|
||||||
|
// If no machine is specified, prompt the user to select one.
|
||||||
|
machines, err := client.ListMachines(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
var selectedMachine *pb.MachineInfo
|
||||||
|
|
||||||
|
if opts.machine == "" {
|
||||||
|
if len(machines) == 1 {
|
||||||
|
selectedMachine = machines[0].Machine
|
||||||
|
} else {
|
||||||
|
if selectedMachine, err = promptSelectMachine(ctx, machines); err != nil {
|
||||||
|
return fmt.Errorf("select machine: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m := machines.FindByNameOrID(opts.machine)
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("machine '%s' not found", opts.machine)
|
||||||
|
}
|
||||||
|
selectedMachine = m.Machine
|
||||||
|
}
|
||||||
|
|
||||||
|
createOpts := volume.CreateOptions{
|
||||||
|
Name: name,
|
||||||
|
Driver: opts.driver,
|
||||||
|
DriverOpts: driverOpts,
|
||||||
|
Labels: labels,
|
||||||
|
}
|
||||||
|
vol, err := client.CreateVolume(ctx, selectedMachine.Id, createOpts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create volume '%s' on machine '%s': %w", name, selectedMachine.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Volume '%s' created on machine '%s'.\n", vol.Volume.Name, vol.MachineName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptSelectMachine(ctx context.Context, machines api.MachineMembersList) (*pb.MachineInfo, error) {
|
||||||
|
options := make([]huh.Option[*pb.MachineInfo], len(machines))
|
||||||
|
for i, m := range machines {
|
||||||
|
options[i] = huh.NewOption(m.Machine.Name, m.Machine)
|
||||||
|
}
|
||||||
|
slices.SortFunc(options, func(a, b huh.Option[*pb.MachineInfo]) int {
|
||||||
|
return strings.Compare(a.Key, b.Key)
|
||||||
|
})
|
||||||
|
|
||||||
|
var selected *pb.MachineInfo
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewSelect[*pb.MachineInfo]().
|
||||||
|
Title("Select a machine to create the volume on (or specify with --machine flag)").
|
||||||
|
Options(options...).
|
||||||
|
Value(&selected),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.RunWithContext(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package volume
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inspectOptions struct {
|
||||||
|
machine string
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInspectCommand() *cobra.Command {
|
||||||
|
opts := inspectOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "inspect [FLAGS] VOLUME_NAME",
|
||||||
|
Short: "Display detailed information on a volume.",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return inspect(cmd.Context(), uncli, args[0], opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||||
|
"Name or ID of the machine where the volume is located. "+
|
||||||
|
"If not specified, the volume will be searched across all machines.")
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspect(ctx context.Context, uncli *cli.CLI, name string, opts inspectOptions) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
filter := &api.VolumeFilter{
|
||||||
|
Names: []string{name},
|
||||||
|
}
|
||||||
|
if opts.machine != "" {
|
||||||
|
filter.Machines = []string{opts.machine}
|
||||||
|
}
|
||||||
|
|
||||||
|
volumes, err := client.ListVolumes(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list volumes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(volumes) == 0 {
|
||||||
|
if opts.machine != "" {
|
||||||
|
return fmt.Errorf("volume '%s' not found on machine '%s'", name, opts.machine)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("volume '%s' not found on any machine", name)
|
||||||
|
}
|
||||||
|
if len(volumes) > 1 {
|
||||||
|
fmt.Printf("Volume '%s' found on multiple machines:\n", name)
|
||||||
|
for _, v := range volumes {
|
||||||
|
fmt.Printf(" • %s\n", v.MachineName)
|
||||||
|
}
|
||||||
|
return errors.New("specify --machine flag to choose which machine to use")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(volumes[0], "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal volume: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println(string(data))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package volume
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type listOptions struct {
|
||||||
|
machines []string
|
||||||
|
quiet bool
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListCommand() *cobra.Command {
|
||||||
|
opts := listOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ls [FLAGS]",
|
||||||
|
Aliases: []string{"list"},
|
||||||
|
Short: "List volumes across all machines in the cluster.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return list(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
|
"Filter volumes by machine name or ID. Can be specified multiple times or as a comma-separated list. "+
|
||||||
|
"(default is include all machines)")
|
||||||
|
cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false,
|
||||||
|
"Only display volume names.")
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Apply machine filter if specified.
|
||||||
|
var filter *api.VolumeFilter
|
||||||
|
if len(opts.machines) > 0 {
|
||||||
|
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||||
|
filter = &api.VolumeFilter{
|
||||||
|
Machines: machines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
volumes, err := client.ListVolumes(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list volumes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(volumes) == 0 {
|
||||||
|
if !opts.quiet {
|
||||||
|
fmt.Println("No volumes found.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the volumes by name first, then by machine name.
|
||||||
|
slices.SortFunc(volumes, func(a, b api.MachineVolume) int {
|
||||||
|
cmp := strings.Compare(a.Volume.Name, b.Volume.Name)
|
||||||
|
if cmp != 0 {
|
||||||
|
return cmp
|
||||||
|
}
|
||||||
|
return strings.Compare(a.MachineName, b.MachineName)
|
||||||
|
})
|
||||||
|
|
||||||
|
// If quiet mode, just print volume names.
|
||||||
|
if opts.quiet {
|
||||||
|
for _, v := range volumes {
|
||||||
|
fmt.Println(v.Volume.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print the volumes in a table format.
|
||||||
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "NAME\tDRIVER\tMACHINE")
|
||||||
|
|
||||||
|
for _, v := range volumes {
|
||||||
|
fmt.Fprintf(tw, "%s\t%s\t%s\n",
|
||||||
|
v.Volume.Name,
|
||||||
|
v.Volume.Driver,
|
||||||
|
v.MachineName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tw.Flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package volume
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type removeOptions struct {
|
||||||
|
force bool
|
||||||
|
machines []string
|
||||||
|
yes bool
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRemoveCommand() *cobra.Command {
|
||||||
|
opts := removeOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "rm [FLAGS] VOLUME_NAME [VOLUME_NAME...]",
|
||||||
|
Aliases: []string{"remove", "delete"},
|
||||||
|
Short: "Remove one or more volumes.",
|
||||||
|
Long: "Remove one or more volumes. You cannot remove a volume that is in use by a container.",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return remove(cmd.Context(), uncli, args, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVarP(&opts.force, "force", "f", false,
|
||||||
|
"Force the removal of one or more volumes.")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
|
"Name or ID of the machine to remove one or more volumes from. "+
|
||||||
|
"Can be specified multiple times or as a comma-separated list.\n"+
|
||||||
|
"If not specified, the found volume(s) will be removed from all machines.")
|
||||||
|
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||||
|
"Do not prompt for confirmation before removing the volume(s).")
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOptions) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
filter := &api.VolumeFilter{
|
||||||
|
Names: names,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.machines) > 0 {
|
||||||
|
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||||
|
filter.Machines = machines
|
||||||
|
}
|
||||||
|
|
||||||
|
volumes, err := client.ListVolumes(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list volumes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(volumes) == 0 {
|
||||||
|
if len(names) == 1 {
|
||||||
|
return fmt.Errorf("volume '%s' not found", names[0])
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no volumes found matching the specified names")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm removal if not using --yes flag.
|
||||||
|
if !opts.yes {
|
||||||
|
fmt.Println("The following volumes will be removed:")
|
||||||
|
for _, v := range volumes {
|
||||||
|
fmt.Printf(" • '%s' on machine '%s'\n", v.Volume.Name, v.MachineName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
confirmed, err := cli.Confirm()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("confirm removal: %w", err)
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
fmt.Println("Cancelled. No volumes were removed.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the volumes one by one collecting errors.
|
||||||
|
var removeErr error
|
||||||
|
for _, v := range volumes {
|
||||||
|
if err = client.RemoveVolume(ctx, v.MachineID, v.Volume.Name, opts.force); err != nil {
|
||||||
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
|
removeErr = errors.Join(removeErr, fmt.Errorf("failed to remove volume '%s' on machine '%s': %w",
|
||||||
|
v.Volume.Name, v.MachineName, err))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Volume '%s' removed from machine '%s'.\n", v.Volume.Name, v.MachineName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return removeErr
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package volume
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRootCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "volume",
|
||||||
|
Short: "Manage volumes in an Uncloud cluster.",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(
|
||||||
|
NewCreateCommand(),
|
||||||
|
NewInspectCommand(),
|
||||||
|
NewListCommand(),
|
||||||
|
NewRemoveCommand(),
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"uncloud/internal/daemon"
|
"github.com/psviderski/uncloud/internal/daemon"
|
||||||
"uncloud/internal/log"
|
"github.com/psviderski/uncloud/internal/log"
|
||||||
"uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# User Guide
|
||||||
|
|
||||||
|
## Initialising a cluster
|
||||||
|
|
||||||
|
To begin setting up a new Uncloud cluster, create the desired nodes. Ensure that their firewall allows the required ports.
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
Nodes running Uncloud with a standard configuration must have the following inbound ports allowed in the firewall:
|
||||||
|
|
||||||
|
* 51820/udp (WireGuard meshnet)
|
||||||
|
* 22/tcp (default SSH management port)
|
||||||
|
* 80/tcp (required for challenge if uncloud.run DNS is enabled, pass --no-dns during init to disable)
|
||||||
|
|
||||||
|
In addition, any ports for any workloads you want to expose will need to be open.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Uncloud stores its configuration in `~/.config/uncloud/config.yaml`. If you wish to reinitialise a cluster, simply remove it from this config.
|
||||||
|
|
||||||
|
### Initialisation
|
||||||
|
|
||||||
|
Begin by initialising the first node in your cluster with `uc machine init [USER@HOST:PORT]`. If you do not have a need for Caddy reverse proxy, you may disable this feature with `--no-caddy`.
|
||||||
|
|
||||||
|
This command will idempotently install Docker, uncloudd, uncloud-corrosion. If Caddy is enabled, it will set up a reverse proxy. If Uncloud DNS is enabled, it will create a DNS A record for the machine's public IP address under `*.[CLUSTER ID].cluster.uncloud.run`.
|
||||||
|
|
||||||
|
If you wish to uninstall Uncloud and its components, run `uncloud-uninstall`.
|
||||||
|
|
||||||
|
### Adding a node
|
||||||
|
|
||||||
|
Just like the initialisation of the first node, a node can be added to the cluster with `uc machine add`.
|
||||||
|
|
||||||
|
### DNS
|
||||||
|
|
||||||
|
Uncloud (uncloud.run) DNS can be managed with the `uc dns` subcommand.
|
||||||
|
|
||||||
|
* To reserve a domain name, run `uc dns reserve`
|
||||||
|
* To release a domain name, run `uc dns release`.
|
||||||
|
* To see the domain name, run `uc dns show`
|
||||||
|
|
||||||
|
### Running a service
|
||||||
|
|
||||||
|
Services on an Uncloud cluster can be managed with `uc service`.
|
||||||
|
|
||||||
|
You can run a service with two replicas that expose port 80 like the following:
|
||||||
|
|
||||||
|
```
|
||||||
|
uc service run -p 80/http --replicas 2 nginxdemos/hello
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires the Caddy reverse proxy to be deployed. If it wasn't previously, it can be deployed with `uc caddy deploy`.
|
||||||
|
|
||||||
|
Since this doesn't specify a service name, a random one will be generated.
|
||||||
|
|
||||||
|
The service can be deleted by its service name:
|
||||||
|
|
||||||
|
```
|
||||||
|
uc service rm hello-gsdo
|
||||||
|
```
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module uncloud
|
module github.com/psviderski/uncloud
|
||||||
|
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
@@ -11,7 +11,10 @@ require (
|
|||||||
github.com/caddyserver/caddy/v2 v2.8.4
|
github.com/caddyserver/caddy/v2 v2.8.4
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0
|
github.com/cenkalti/backoff/v4 v4.3.0
|
||||||
github.com/charmbracelet/huh v0.6.0
|
github.com/charmbracelet/huh v0.6.0
|
||||||
|
github.com/charmbracelet/lipgloss v0.13.0
|
||||||
|
github.com/compose-spec/compose-go/v2 v2.4.5
|
||||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||||
|
github.com/deckarep/golang-set/v2 v2.8.0
|
||||||
github.com/dgraph-io/badger/v3 v3.2103.5
|
github.com/dgraph-io/badger/v3 v3.2103.5
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/docker/cli v27.5.0+incompatible
|
github.com/docker/cli v27.5.0+incompatible
|
||||||
@@ -19,6 +22,8 @@ require (
|
|||||||
github.com/docker/docker v27.4.0-rc.2+incompatible
|
github.com/docker/docker v27.4.0-rc.2+incompatible
|
||||||
github.com/docker/go-connections v0.5.0
|
github.com/docker/go-connections v0.5.0
|
||||||
github.com/docker/go-units v0.5.0
|
github.com/docker/go-units v0.5.0
|
||||||
|
github.com/goccy/go-yaml v1.17.1
|
||||||
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/google/go-containerregistry v0.20.2
|
github.com/google/go-containerregistry v0.20.2
|
||||||
github.com/hashicorp/memberlist v0.5.1
|
github.com/hashicorp/memberlist v0.5.1
|
||||||
github.com/hashicorp/serf v0.10.1
|
github.com/hashicorp/serf v0.10.1
|
||||||
@@ -29,7 +34,9 @@ require (
|
|||||||
github.com/ipfs/go-ds-crdt v0.5.2
|
github.com/ipfs/go-ds-crdt v0.5.2
|
||||||
github.com/ipfs/go-ipld-format v0.6.0
|
github.com/ipfs/go-ipld-format v0.6.0
|
||||||
github.com/ipfs/go-log/v2 v2.5.1
|
github.com/ipfs/go-log/v2 v2.5.1
|
||||||
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/lmittmann/tint v1.0.5
|
github.com/lmittmann/tint v1.0.5
|
||||||
|
github.com/opencontainers/go-digest v1.0.0
|
||||||
github.com/opencontainers/image-spec v1.1.0
|
github.com/opencontainers/image-spec v1.1.0
|
||||||
github.com/siderolabs/discovery-api v0.1.4
|
github.com/siderolabs/discovery-api v0.1.4
|
||||||
github.com/siderolabs/discovery-client v0.1.9
|
github.com/siderolabs/discovery-client v0.1.9
|
||||||
@@ -42,12 +49,13 @@ require (
|
|||||||
golang.org/x/crypto v0.32.0
|
golang.org/x/crypto v0.32.0
|
||||||
golang.org/x/net v0.34.0
|
golang.org/x/net v0.34.0
|
||||||
golang.org/x/sync v0.10.0
|
golang.org/x/sync v0.10.0
|
||||||
golang.org/x/sys v0.29.0
|
golang.org/x/sys v0.31.0
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576
|
||||||
google.golang.org/grpc v1.68.1
|
google.golang.org/grpc v1.68.1
|
||||||
google.golang.org/protobuf v1.36.3
|
google.golang.org/protobuf v1.36.3
|
||||||
|
modernc.org/sqlite v1.36.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -77,13 +85,11 @@ require (
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/bubbles v0.20.0 // indirect
|
github.com/charmbracelet/bubbles v0.20.0 // indirect
|
||||||
github.com/charmbracelet/bubbletea v1.1.1 // indirect
|
github.com/charmbracelet/bubbletea v1.1.1 // indirect
|
||||||
github.com/charmbracelet/lipgloss v0.13.0 // indirect
|
|
||||||
github.com/charmbracelet/x/ansi v0.3.2 // indirect
|
github.com/charmbracelet/x/ansi v0.3.2 // indirect
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.0 // indirect
|
github.com/charmbracelet/x/term v0.2.0 // indirect
|
||||||
github.com/chzyer/readline v1.5.1 // indirect
|
github.com/chzyer/readline v1.5.1 // indirect
|
||||||
github.com/cloudflare/cfssl v1.6.4 // indirect
|
github.com/cloudflare/cfssl v1.6.4 // indirect
|
||||||
github.com/compose-spec/compose-go/v2 v2.4.5 // indirect
|
|
||||||
github.com/containerd/console v1.0.4 // indirect
|
github.com/containerd/console v1.0.4 // indirect
|
||||||
github.com/containerd/containerd v1.7.24 // indirect
|
github.com/containerd/containerd v1.7.24 // indirect
|
||||||
github.com/containerd/containerd/api v1.7.19 // indirect
|
github.com/containerd/containerd/api v1.7.19 // indirect
|
||||||
@@ -118,7 +124,7 @@ require (
|
|||||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
@@ -131,8 +137,7 @@ require (
|
|||||||
github.com/google/btree v1.1.2 // indirect
|
github.com/google/btree v1.1.2 // indirect
|
||||||
github.com/google/cel-go v0.20.1 // indirect
|
github.com/google/cel-go v0.20.1 // indirect
|
||||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||||
github.com/google/go-cmp v0.6.0 // indirect
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
|
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/mux v1.8.1 // indirect
|
github.com/gorilla/mux v1.8.1 // indirect
|
||||||
@@ -224,8 +229,8 @@ require (
|
|||||||
github.com/multiformats/go-multistream v0.5.0 // indirect
|
github.com/multiformats/go-multistream v0.5.0 // indirect
|
||||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
|
||||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||||
github.com/pires/go-proxyproto v0.7.0 // indirect
|
github.com/pires/go-proxyproto v0.7.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
@@ -239,6 +244,7 @@ require (
|
|||||||
github.com/prometheus/procfs v0.15.1 // indirect
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.48.2 // indirect
|
github.com/quic-go/quic-go v0.48.2 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rs/xid v1.5.0 // indirect
|
github.com/rs/xid v1.5.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
@@ -269,6 +275,9 @@ require (
|
|||||||
github.com/urfave/cli v1.22.16 // indirect
|
github.com/urfave/cli v1.22.16 // indirect
|
||||||
github.com/vbatts/tar-split v0.11.6 // indirect
|
github.com/vbatts/tar-split v0.11.6 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // indirect
|
github.com/vishvananda/netns v0.0.4 // indirect
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||||
go.etcd.io/bbolt v1.3.11 // indirect
|
go.etcd.io/bbolt v1.3.11 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
@@ -306,4 +315,7 @@ require (
|
|||||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
||||||
howett.net/plist v1.0.0 // indirect
|
howett.net/plist v1.0.0 // indirect
|
||||||
lukechampine.com/blake3 v1.3.0 // indirect
|
lukechampine.com/blake3 v1.3.0 // indirect
|
||||||
|
modernc.org/libc v1.61.13 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.8.2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -233,6 +233,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU=
|
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU=
|
||||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U=
|
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U=
|
||||||
|
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
|
||||||
|
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y=
|
github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
|
||||||
@@ -332,8 +334,8 @@ github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4
|
|||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
github.com/go-stack/stack v1.6.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
github.com/go-stack/stack v1.6.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
@@ -341,6 +343,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZ
|
|||||||
github.com/go-viper/mapstructure/v2 v2.0.0 h1:dhn8MZ1gZ0mzeodTG3jt5Vj/o87xZKuNAprG2mQfMfc=
|
github.com/go-viper/mapstructure/v2 v2.0.0 h1:dhn8MZ1gZ0mzeodTG3jt5Vj/o87xZKuNAprG2mQfMfc=
|
||||||
github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
|
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
|
||||||
|
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
|
||||||
|
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||||
@@ -395,8 +399,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/go-containerregistry v0.20.2 h1:B1wPJ1SN/S7pB+ZAimcciVD+r+yV/l/DSArMxlbwseo=
|
github.com/google/go-containerregistry v0.20.2 h1:B1wPJ1SN/S7pB+ZAimcciVD+r+yV/l/DSArMxlbwseo=
|
||||||
github.com/google/go-containerregistry v0.20.2/go.mod h1:z38EKdKh4h7IP2gSfUUqEvalZBqs6AoLeWfUy34nQC8=
|
github.com/google/go-containerregistry v0.20.2/go.mod h1:z38EKdKh4h7IP2gSfUUqEvalZBqs6AoLeWfUy34nQC8=
|
||||||
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
|
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
|
||||||
@@ -408,8 +412,8 @@ github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5b
|
|||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||||
@@ -597,8 +601,8 @@ github.com/jinzhu/gorm v0.0.0-20170222002820-5409931a1bb8/go.mod h1:Vla75njaFJ8c
|
|||||||
github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d h1:jRQLvyVGL+iVtDElaEIDdKwpPqUIZJfzkNLV34htpEc=
|
github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d h1:jRQLvyVGL+iVtDElaEIDdKwpPqUIZJfzkNLV34htpEc=
|
||||||
github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/jmoiron/sqlx v1.3.3 h1:j82X0bf7oQ27XeqxicSZsTU5suPwKElg3oyxNn43iTk=
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
github.com/jmoiron/sqlx v1.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||||
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||||
@@ -639,8 +643,9 @@ github.com/lib/pq v0.0.0-20150723085316-0dad96c0b94f/go.mod h1:5WUZQaWbwv1U+lTRe
|
|||||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
|
||||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
|
github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
|
||||||
github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||||
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
|
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
|
||||||
@@ -701,6 +706,8 @@ github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh
|
|||||||
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
|
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
|
||||||
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
||||||
github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||||
@@ -809,6 +816,8 @@ github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOEL
|
|||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
|
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
|
||||||
@@ -925,6 +934,8 @@ github.com/quic-go/webtransport-go v0.8.0 h1:HxSrwun11U+LlmwpgM1kEqIqH90IT4N8auv
|
|||||||
github.com/quic-go/webtransport-go v0.8.0/go.mod h1:N99tjprW432Ut5ONql/aUhSLT0YVSlwHohQsuac9WaM=
|
github.com/quic-go/webtransport-go v0.8.0/go.mod h1:N99tjprW432Ut5ONql/aUhSLT0YVSlwHohQsuac9WaM=
|
||||||
github.com/raulk/go-watchdog v1.3.0 h1:oUmdlHxdkXRJlwfG0O9omj8ukerm8MEQavSiDTEtBsk=
|
github.com/raulk/go-watchdog v1.3.0 h1:oUmdlHxdkXRJlwfG0O9omj8ukerm8MEQavSiDTEtBsk=
|
||||||
github.com/raulk/go-watchdog v1.3.0/go.mod h1:fIvOnLbF0b0ZwkB9YU4mOW9Did//4vPZtDqv66NfsMU=
|
github.com/raulk/go-watchdog v1.3.0/go.mod h1:fIvOnLbF0b0ZwkB9YU4mOW9Did//4vPZtDqv66NfsMU=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
@@ -1069,6 +1080,13 @@ github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSD
|
|||||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
|
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
|
||||||
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b h1:FsyNrX12e5BkplJq7wKOLk0+C6LZ+KGXvuEcKUYm5ss=
|
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b h1:FsyNrX12e5BkplJq7wKOLk0+C6LZ+KGXvuEcKUYm5ss=
|
||||||
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
|
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
@@ -1283,8 +1301,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -1420,3 +1438,27 @@ k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
|
|||||||
k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
|
k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
|
||||||
lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
|
lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
|
||||||
lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
|
lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
|
||||||
|
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||||
|
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
|
||||||
|
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
|
||||||
|
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||||
|
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||||
|
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.36.3 h1:qYMYlFR+rtLDUzuXoST1SDIdEPbX8xzuhdF90WsX1ss=
|
||||||
|
modernc.org/sqlite v1.36.3/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"github.com/distribution/reference"
|
|
||||||
"maps"
|
|
||||||
"reflect"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
ServiceModeReplicated = "replicated"
|
|
||||||
ServiceModeGlobal = "global"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ServiceSpec struct {
|
|
||||||
Container ContainerSpec
|
|
||||||
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
|
|
||||||
Mode string
|
|
||||||
Name string
|
|
||||||
// Ports defines what service ports to publish to make the service accessible outside the cluster.
|
|
||||||
Ports []PortSpec
|
|
||||||
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
|
|
||||||
Replicas uint
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServiceSpec) Validate() error {
|
|
||||||
if err := s.Container.Validate(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch s.Mode {
|
|
||||||
case "", ServiceModeGlobal, ServiceModeReplicated:
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("invalid mode: %q", s.Mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: validate there is no conflict between ports.
|
|
||||||
// TODO: return error if there are non-HTTP/HTTPS ingress ports that we don't support yet.
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Equals returns true if the service spec is equal to the given spec ignoring the number of replicas.
|
|
||||||
func (s *ServiceSpec) Equals(spec ServiceSpec) bool {
|
|
||||||
// TODO: ignore order of ports.
|
|
||||||
sCopy := *s
|
|
||||||
// Ignore the number of replicas when comparing.
|
|
||||||
sCopy.Replicas = 0
|
|
||||||
spec.Replicas = 0
|
|
||||||
return reflect.DeepEqual(*s, spec)
|
|
||||||
}
|
|
||||||
|
|
||||||
var serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
|
|
||||||
|
|
||||||
func ValidateServiceID(id string) bool {
|
|
||||||
return serviceIDRegexp.MatchString(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ContainerSpec struct {
|
|
||||||
Command []string
|
|
||||||
Image string
|
|
||||||
// Run a custom init inside the container. If nil, use the daemon's configured settings.
|
|
||||||
Init *bool
|
|
||||||
// List of volumes to bind mount into the container.
|
|
||||||
Volumes []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ContainerSpec) Validate() error {
|
|
||||||
if _, err := reference.ParseDockerRef(s.Image); err != nil {
|
|
||||||
return fmt.Errorf("invalid image: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
Mode string
|
|
||||||
Containers []MachineContainer
|
|
||||||
}
|
|
||||||
|
|
||||||
type MachineContainer struct {
|
|
||||||
MachineID string
|
|
||||||
Container Container
|
|
||||||
}
|
|
||||||
|
|
||||||
// Endpoints returns the exposed HTTP and HTTPS endpoints of the service.
|
|
||||||
func (s *Service) Endpoints() []string {
|
|
||||||
endpoints := make(map[string]struct{})
|
|
||||||
|
|
||||||
// Container specs may differ between containers in the same service, e.g. during a rolling update,
|
|
||||||
// so we need to collect all unique endpoints.
|
|
||||||
for _, ctr := range s.Containers {
|
|
||||||
ports, err := ctr.Container.ServicePorts()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, port := range ports {
|
|
||||||
protocol := ""
|
|
||||||
switch port.Protocol {
|
|
||||||
case ProtocolHTTP:
|
|
||||||
protocol = "http"
|
|
||||||
case ProtocolHTTPS:
|
|
||||||
protocol = "https"
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if port.Hostname == "" {
|
|
||||||
// There shouldn't be http(s) ports without a hostname but just in case ignore them.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint := fmt.Sprintf("%s://%s", protocol, port.Hostname)
|
|
||||||
if port.PublishedPort != 0 {
|
|
||||||
// For non-standard ports (80/443), include the port in the URL.
|
|
||||||
if !(port.Protocol == ProtocolHTTP && port.PublishedPort == 80) &&
|
|
||||||
!(port.Protocol == ProtocolHTTPS && port.PublishedPort == 443) {
|
|
||||||
endpoint += fmt.Sprintf(":%d", port.PublishedPort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint += fmt.Sprintf(" → :%d", port.ContainerPort)
|
|
||||||
endpoints[endpoint] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return slices.Sorted(maps.Keys(endpoints))
|
|
||||||
}
|
|
||||||
|
|
||||||
func ServiceFromProto(s *pb.Service) (Service, error) {
|
|
||||||
var err error
|
|
||||||
containers := make([]MachineContainer, len(s.Containers))
|
|
||||||
for i, sc := range s.Containers {
|
|
||||||
containers[i], err = machineContainerFromProto(sc)
|
|
||||||
if err != nil {
|
|
||||||
return Service{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Service{
|
|
||||||
ID: s.Id,
|
|
||||||
Name: s.Name,
|
|
||||||
Mode: s.Mode,
|
|
||||||
Containers: containers,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func machineContainerFromProto(sc *pb.Service_Container) (MachineContainer, error) {
|
|
||||||
var c Container
|
|
||||||
if err := json.Unmarshal(sc.Container, &c); err != nil {
|
|
||||||
return MachineContainer{}, fmt.Errorf("unmarshal container: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return MachineContainer{
|
|
||||||
MachineID: sc.MachineId,
|
|
||||||
Container: c,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
+154
-110
@@ -4,90 +4,124 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/docker/cli/cli/streams"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"uncloud/internal/cli/client"
|
|
||||||
"uncloud/internal/cli/client/connector"
|
"github.com/docker/cli/cli/streams"
|
||||||
"uncloud/internal/cli/config"
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
"uncloud/internal/fs"
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
"uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"uncloud/internal/sshexec"
|
"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"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultClusterName = "default"
|
const defaultContextName = "default"
|
||||||
|
|
||||||
type CLI struct {
|
type CLI struct {
|
||||||
config *config.Config
|
Config *config.Config
|
||||||
|
conn *config.MachineConnection
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(configPath string) (*CLI, error) {
|
// New creates a new CLI instance with the given config path or remote machine connection.
|
||||||
|
// If the connection is provided, the config is ignored for all operations which is useful for interacting with
|
||||||
|
// a cluster without creating a config.
|
||||||
|
func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
|
||||||
|
if conn != nil {
|
||||||
|
return &CLI{conn: conn}, nil
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.NewFromFile(configPath)
|
cfg, err := config.NewFromFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read Uncloud config: %w", err)
|
return nil, fmt.Errorf("read Uncloud config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CLI{
|
return &CLI{
|
||||||
config: cfg,
|
Config: cfg,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) CreateCluster(name string) error {
|
func (cli *CLI) CreateContext(name string) error {
|
||||||
if _, ok := cli.config.Clusters[name]; ok {
|
if _, ok := cli.Config.Contexts[name]; ok {
|
||||||
return fmt.Errorf("cluster %q already exists", name)
|
return fmt.Errorf("context '%s' already exists", name)
|
||||||
}
|
}
|
||||||
cli.config.Clusters[name] = &config.Cluster{
|
cli.Config.Contexts[name] = &config.Context{
|
||||||
Name: name,
|
Name: name,
|
||||||
}
|
}
|
||||||
return cli.config.Save()
|
return cli.Config.Save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) SetCurrentCluster(name string) error {
|
func (cli *CLI) SetCurrentContext(name string) error {
|
||||||
if _, ok := cli.config.Clusters[name]; !ok {
|
if _, ok := cli.Config.Contexts[name]; !ok {
|
||||||
return client.ErrNotFound
|
return api.ErrNotFound
|
||||||
}
|
}
|
||||||
cli.config.CurrentCluster = name
|
cli.Config.CurrentContext = name
|
||||||
return cli.config.Save()
|
return cli.Config.Save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) ConnectCluster(ctx context.Context, clusterName string) (*client.Client, error) {
|
// ConnectCluster connects to a cluster using the given context name or the current context if not specified.
|
||||||
if len(cli.config.Clusters) == 0 {
|
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
||||||
return nil, errors.New(
|
func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client.Client, error) {
|
||||||
"no clusters found in the Uncloud config. " +
|
if cli.conn != nil {
|
||||||
"Please initialise a cluster with `uncloud machine init` first",
|
return connectCluster(ctx, *cli.conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cli.Config.Contexts) == 0 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"no cluster contexts found in the Uncloud config (%s). "+
|
||||||
|
"Please initialise a cluster with 'uncloud machine init' first",
|
||||||
|
cli.Config.Path(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if clusterName == "" {
|
if contextName == "" {
|
||||||
// If the cluster is not specified, use the current cluster if set.
|
// If the cluster is not specified, use the current cluster if set.
|
||||||
if cli.config.CurrentCluster == "" {
|
if cli.Config.CurrentContext == "" {
|
||||||
return nil, errors.New(
|
|
||||||
"the current cluster is not set in the Uncloud config. " +
|
|
||||||
"Please specify a cluster with the --cluster flag or set current_cluster in the config",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, ok := cli.config.Clusters[cli.config.CurrentCluster]; !ok {
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"current cluster %q not found in the config. "+
|
"the current cluster context is not set in the Uncloud config (%s). "+
|
||||||
"Please specify a cluster with the --cluster flag or update current_cluster in the config",
|
"Please specify the context with the '--context' flag or set 'current_context' in the config",
|
||||||
cli.config.CurrentCluster,
|
cli.Config.Path(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
clusterName = cli.config.CurrentCluster
|
if _, ok := cli.Config.Contexts[cli.Config.CurrentContext]; !ok {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"current cluster context '%s' not found in the Uncloud config (%s). "+
|
||||||
|
"Please specify the context with the '--context' flag or update 'current_context' in the config",
|
||||||
|
cli.Config.CurrentContext,
|
||||||
|
cli.Config.Path(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
contextName = cli.Config.CurrentContext
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, ok := cli.config.Clusters[clusterName]
|
cfg, ok := cli.Config.Contexts[contextName]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("cluster %q not found in the config", clusterName)
|
return nil, fmt.Errorf("cluster context '%s' not found in the Uncloud config (%s)",
|
||||||
|
contextName, cli.Config.Path())
|
||||||
}
|
}
|
||||||
if len(cfg.Connections) == 0 {
|
if len(cfg.Connections) == 0 {
|
||||||
return nil, fmt.Errorf("no connection configurations found for cluster %q in the config", clusterName)
|
return nil, fmt.Errorf(
|
||||||
|
"no connection configurations found for cluster context '%s' in the Uncloud config (%s)",
|
||||||
|
contextName, cli.Config.Path(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: iterate over all connections and try to connect to the cluster using the first successful connection.
|
// TODO: iterate over all connections and try to connect to the cluster using the first successful connection.
|
||||||
conn := cfg.Connections[0]
|
conn := cfg.Connections[0]
|
||||||
|
|
||||||
|
c, err := connectCluster(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||||
if conn.SSH != "" {
|
if conn.SSH != "" {
|
||||||
user, host, port, err := conn.SSH.Parse()
|
user, host, port, err := conn.SSH.Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,45 +137,42 @@ func (cli *CLI) ConnectCluster(ctx context.Context, clusterName string) (*client
|
|||||||
KeyPath: keyPath,
|
KeyPath: keyPath,
|
||||||
}
|
}
|
||||||
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||||
} else if conn.TCP.IsValid() {
|
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
||||||
return client.New(ctx, connector.NewTCPConnector(conn.TCP))
|
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
||||||
}
|
}
|
||||||
return nil, errors.New("no valid connection configuration found for the cluster")
|
|
||||||
|
return nil, errors.New("connection configuration is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
type InitClusterOptions struct {
|
||||||
|
Context string
|
||||||
|
MachineName string
|
||||||
|
Network netip.Prefix
|
||||||
|
PublicIP *netip.Addr
|
||||||
|
RemoteMachine *RemoteMachine
|
||||||
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
|
// InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
|
||||||
// The client should be closed after use by the caller.
|
// The client should be closed after use by the caller.
|
||||||
func (cli *CLI) InitCluster(
|
func (cli *CLI) InitCluster(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
||||||
ctx context.Context,
|
if opts.RemoteMachine != nil {
|
||||||
remoteMachine *RemoteMachine,
|
return cli.initRemoteMachine(ctx, opts)
|
||||||
clusterName,
|
|
||||||
machineName string,
|
|
||||||
netPrefix netip.Prefix,
|
|
||||||
publicIP *netip.Addr,
|
|
||||||
) (*client.Client, error) {
|
|
||||||
if remoteMachine != nil {
|
|
||||||
return cli.initRemoteMachine(ctx, *remoteMachine, clusterName, machineName, netPrefix, publicIP)
|
|
||||||
}
|
}
|
||||||
// TODO: implement local machine initialisation
|
// TODO: implement local machine initialisation
|
||||||
return nil, fmt.Errorf("local machine initialisation is not implemented yet")
|
return nil, fmt.Errorf("local machine initialisation is not implemented yet")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) initRemoteMachine(
|
func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
||||||
ctx context.Context,
|
contextName := opts.Context
|
||||||
remoteMachine RemoteMachine,
|
if contextName == "" {
|
||||||
clusterName,
|
contextName = defaultContextName
|
||||||
machineName string,
|
|
||||||
netPrefix netip.Prefix,
|
|
||||||
publicIP *netip.Addr,
|
|
||||||
) (*client.Client, error) {
|
|
||||||
if clusterName == "" {
|
|
||||||
clusterName = defaultClusterName
|
|
||||||
}
|
}
|
||||||
if _, ok := cli.config.Clusters[clusterName]; ok {
|
if _, ok := cli.Config.Contexts[contextName]; ok {
|
||||||
return nil, fmt.Errorf("cluster %q already exists", clusterName)
|
return nil, fmt.Errorf("cluster context '%s' already exists", contextName)
|
||||||
}
|
}
|
||||||
|
|
||||||
machineClient, err := cli.provisionRemoteMachine(ctx, remoteMachine)
|
machineClient, err := cli.provisionRemoteMachine(ctx, *opts.RemoteMachine, opts.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -164,12 +195,12 @@ func (cli *CLI) initRemoteMachine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
req := &pb.InitClusterRequest{
|
req := &pb.InitClusterRequest{
|
||||||
MachineName: machineName,
|
MachineName: opts.MachineName,
|
||||||
Network: pb.NewIPPrefix(netPrefix),
|
Network: pb.NewIPPrefix(opts.Network),
|
||||||
}
|
}
|
||||||
if publicIP != nil {
|
if opts.PublicIP != nil {
|
||||||
if publicIP.IsValid() {
|
if opts.PublicIP.IsValid() {
|
||||||
req.PublicIpConfig = &pb.InitClusterRequest_PublicIp{PublicIp: pb.NewIP(*publicIP)}
|
req.PublicIpConfig = &pb.InitClusterRequest_PublicIp{PublicIp: pb.NewIP(*opts.PublicIP)}
|
||||||
} else {
|
} else {
|
||||||
// Invalid or in other words zero IP means to automatically detect the public IP.
|
// Invalid or in other words zero IP means to automatically detect the public IP.
|
||||||
req.PublicIpConfig = &pb.InitClusterRequest_PublicIpAuto{PublicIpAuto: true}
|
req.PublicIpConfig = &pb.InitClusterRequest_PublicIpAuto{PublicIpAuto: true}
|
||||||
@@ -180,42 +211,50 @@ func (cli *CLI) initRemoteMachine(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("init cluster: %w", err)
|
return nil, fmt.Errorf("init cluster: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("Cluster %q initialised with machine %q\n", clusterName, resp.Machine.Name)
|
fmt.Printf("Cluster initialised with machine '%s' and saved as context '%s' in your local config (%s)\n",
|
||||||
|
resp.Machine.Name, contextName, cli.Config.Path())
|
||||||
if err = cli.CreateCluster(clusterName); err != nil {
|
if err = cli.CreateContext(contextName); err != nil {
|
||||||
return nil, fmt.Errorf("save cluster to config: %w", err)
|
return nil, fmt.Errorf("save cluster context to config: %w", err)
|
||||||
}
|
}
|
||||||
// Set the current cluster to the just created one if it is the only cluster in the config.
|
if err = cli.SetCurrentContext(contextName); err != nil {
|
||||||
if len(cli.config.Clusters) == 1 {
|
return nil, fmt.Errorf("set current cluster context: %w", err)
|
||||||
if err = cli.SetCurrentCluster(clusterName); err != nil {
|
|
||||||
return nil, fmt.Errorf("set current cluster: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
fmt.Printf("Current cluster context is now '%s'.\n", contextName)
|
||||||
|
|
||||||
// Save the machine's SSH connection details in the cluster config.
|
// Save the machine's SSH connection details in the context config.
|
||||||
connCfg := config.MachineConnection{
|
connCfg := config.MachineConnection{
|
||||||
SSH: config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port),
|
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
|
||||||
SSHKeyFile: remoteMachine.KeyPath,
|
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
||||||
}
|
}
|
||||||
cli.config.Clusters[clusterName].Connections = append(cli.config.Clusters[clusterName].Connections, connCfg)
|
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
||||||
if err = cli.config.Save(); err != nil {
|
if err = cli.Config.Save(); err != nil {
|
||||||
return nil, fmt.Errorf("save config: %w", err)
|
return nil, fmt.Errorf("save config: %w", err)
|
||||||
}
|
}
|
||||||
return machineClient, nil
|
return machineClient, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AddMachineOptions struct {
|
||||||
|
Context string
|
||||||
|
MachineName string
|
||||||
|
PublicIP *netip.Addr
|
||||||
|
RemoteMachine RemoteMachine
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
// AddMachine provisions a remote machine and adds it to the cluster. It returns a client to interact with the machine
|
// AddMachine provisions a remote machine and adds it to the cluster. It returns a client to interact with the machine
|
||||||
// which should be closed after use by the caller.
|
// which should be closed after use by the caller.
|
||||||
func (cli *CLI) AddMachine(
|
func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, error) {
|
||||||
ctx context.Context, remoteMachine RemoteMachine, clusterName, machineName string, publicIP *netip.Addr,
|
contextName := opts.Context
|
||||||
) (*client.Client, error) {
|
if contextName == "" {
|
||||||
c, err := cli.ConnectCluster(ctx, clusterName)
|
contextName = cli.Config.CurrentContext
|
||||||
|
}
|
||||||
|
c, err := cli.ConnectCluster(ctx, contextName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("connect to cluster: %w", err)
|
return nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
||||||
}
|
}
|
||||||
defer c.Close()
|
defer c.Close()
|
||||||
|
|
||||||
machineClient, err := cli.provisionRemoteMachine(ctx, remoteMachine)
|
machineClient, err := cli.provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -251,15 +290,15 @@ func (cli *CLI) AddMachine(
|
|||||||
endpoints[i] = pb.NewIPPort(addrPort)
|
endpoints[i] = pb.NewIPPort(addrPort)
|
||||||
}
|
}
|
||||||
addReq := &pb.AddMachineRequest{
|
addReq := &pb.AddMachineRequest{
|
||||||
Name: machineName,
|
Name: opts.MachineName,
|
||||||
Network: &pb.NetworkConfig{
|
Network: &pb.NetworkConfig{
|
||||||
Endpoints: endpoints,
|
Endpoints: endpoints,
|
||||||
PublicKey: token.PublicKey,
|
PublicKey: token.PublicKey,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if publicIP != nil {
|
if opts.PublicIP != nil {
|
||||||
if publicIP.IsValid() {
|
if opts.PublicIP.IsValid() {
|
||||||
addReq.PublicIp = pb.NewIP(*publicIP)
|
addReq.PublicIp = pb.NewIP(*opts.PublicIP)
|
||||||
} else if token.PublicIP.IsValid() {
|
} else if token.PublicIP.IsValid() {
|
||||||
// Invalid or in other words zero IP means to use an automatically detected public IP from the token.
|
// Invalid or in other words zero IP means to use an automatically detected public IP from the token.
|
||||||
addReq.PublicIp = pb.NewIP(token.PublicIP)
|
addReq.PublicIp = pb.NewIP(token.PublicIP)
|
||||||
@@ -268,11 +307,11 @@ func (cli *CLI) AddMachine(
|
|||||||
|
|
||||||
addResp, err := c.AddMachine(ctx, addReq)
|
addResp, err := c.AddMachine(ctx, addReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("add machine to cluster: %w", err)
|
return nil, fmt.Errorf("add machine to cluster (context '%s'): %w", contextName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List other machines in the cluster to include them in the join request.
|
// List other machines in the cluster to include them in the join request.
|
||||||
machines, err := c.ListMachines(ctx)
|
machines, err := c.ListMachines(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list cluster machines: %w", err)
|
return nil, fmt.Errorf("list cluster machines: %w", err)
|
||||||
}
|
}
|
||||||
@@ -292,18 +331,19 @@ func (cli *CLI) AddMachine(
|
|||||||
return nil, fmt.Errorf("join cluster: %w", err)
|
return nil, fmt.Errorf("join cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Machine %q added to cluster\n", addResp.Machine.Name)
|
// TODO: fix empty context name when using the current context (contextName == "").
|
||||||
|
fmt.Printf("Machine '%s' added to the cluster (context '%s').\n", addResp.Machine.Name, contextName)
|
||||||
|
|
||||||
// Save the machine's SSH connection details in the cluster config.
|
// Save the machine's SSH connection details in the context config.
|
||||||
connCfg := config.MachineConnection{
|
connCfg := config.MachineConnection{
|
||||||
SSH: config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port),
|
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
|
||||||
SSHKeyFile: remoteMachine.KeyPath,
|
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
||||||
}
|
}
|
||||||
if clusterName == "" {
|
if contextName == "" {
|
||||||
clusterName = cli.config.CurrentCluster
|
contextName = cli.Config.CurrentContext
|
||||||
}
|
}
|
||||||
cli.config.Clusters[clusterName].Connections = append(cli.config.Clusters[clusterName].Connections, connCfg)
|
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
||||||
if err = cli.config.Save(); err != nil {
|
if err = cli.Config.Save(); err != nil {
|
||||||
return nil, fmt.Errorf("save config: %w", err)
|
return nil, fmt.Errorf("save config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +352,10 @@ func (cli *CLI) AddMachine(
|
|||||||
|
|
||||||
// provisionRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and returns
|
// provisionRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and returns
|
||||||
// a machine API client to interact with the machine. The client should be closed after use by the caller.
|
// a machine API client to interact with the machine. The client should be closed after use by the caller.
|
||||||
func (cli *CLI) provisionRemoteMachine(ctx context.Context, remoteMachine RemoteMachine) (*client.Client, error) {
|
// The version parameter specifies the version of the Uncloud daemon to install. If empty, the latest version is used.
|
||||||
|
func (cli *CLI) provisionRemoteMachine(
|
||||||
|
ctx context.Context, remoteMachine RemoteMachine, version string,
|
||||||
|
) (*client.Client, error) {
|
||||||
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
||||||
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -323,7 +366,7 @@ func (cli *CLI) provisionRemoteMachine(ctx context.Context, remoteMachine Remote
|
|||||||
}
|
}
|
||||||
exec := sshexec.NewRemote(sshClient)
|
exec := sshexec.NewRemote(sshClient)
|
||||||
// Install and run the Uncloud daemon and dependencies on the remote machine.
|
// Install and run the Uncloud daemon and dependencies on the remote machine.
|
||||||
if err = provisionMachine(ctx, exec); err != nil {
|
if err = provisionMachine(ctx, exec, version); err != nil {
|
||||||
return nil, fmt.Errorf("provision machine: %w", err)
|
return nil, fmt.Errorf("provision machine: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,7 +412,8 @@ func (cli *CLI) promptResetMachine() error {
|
|||||||
return fmt.Errorf("remote machine is already initialised as a cluster member")
|
return fmt.Errorf("remote machine is already initialised as a cluster member")
|
||||||
}
|
}
|
||||||
// TODO: implement resetting the remote machine.
|
// TODO: implement resetting the remote machine.
|
||||||
return fmt.Errorf("resetting the remote machine is not implemented yet")
|
return fmt.Errorf("resetting the remote machine is not implemented yet. " +
|
||||||
|
"Please manually run 'uncloud-uninstall' on the remote machine to fully uninstall Uncloud from it")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProgressOut returns an output stream for progress writer.
|
// ProgressOut returns an output stream for progress writer.
|
||||||
|
|||||||
@@ -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,30 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (cli *Client) InspectMachine(ctx context.Context, id string) (*pb.MachineMember, error) {
|
|
||||||
machines, err := cli.ListMachines(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, m := range machines {
|
|
||||||
if m.Machine.Id == id || m.Machine.Name == id {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cli *Client) ListMachines(ctx context.Context) ([]*pb.MachineMember, error) {
|
|
||||||
resp, err := cli.ClusterClient.ListMachines(ctx, &emptypb.Empty{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return resp.Machines, nil
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
type Cluster struct {
|
|
||||||
Name string `toml:"-"`
|
|
||||||
Connections []MachineConnection `toml:"connections"`
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,15 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/BurntSushi/toml"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Clusters map[string]*Cluster `toml:"clusters"`
|
CurrentContext string `yaml:"current_context"`
|
||||||
CurrentCluster string `toml:"current_cluster"`
|
Contexts map[string]*Context `yaml:"contexts"`
|
||||||
|
|
||||||
// path is the file path config is read from.
|
// path is the file path config is read from.
|
||||||
path string
|
path string
|
||||||
@@ -18,10 +19,10 @@ type Config struct {
|
|||||||
func NewFromFile(path string) (*Config, error) {
|
func NewFromFile(path string) (*Config, error) {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("check file permissions %q: %w", path, err)
|
return nil, fmt.Errorf("check file permissions '%s': %w", path, err)
|
||||||
}
|
}
|
||||||
c := &Config{
|
c := &Config{
|
||||||
Clusters: map[string]*Cluster{},
|
Contexts: map[string]*Context{},
|
||||||
path: path,
|
path: path,
|
||||||
}
|
}
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -34,30 +35,37 @@ func NewFromFile(path string) (*Config, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Config) Path() string {
|
||||||
|
return c.path
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Config) Read() error {
|
func (c *Config) Read() error {
|
||||||
_, err := toml.DecodeFile(c.path, c)
|
data, err := os.ReadFile(c.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read config file %q: %w", c.path, err)
|
return fmt.Errorf("read config file '%s': %w", c.path, err)
|
||||||
}
|
}
|
||||||
|
if err = yaml.Unmarshal(data, c); err != nil {
|
||||||
|
return fmt.Errorf("parse config file '%s': %s", c.path, yaml.FormatError(err, true, true))
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) Save() error {
|
func (c *Config) Save() error {
|
||||||
dir, _ := filepath.Split(c.path)
|
dir, _ := filepath.Split(c.path)
|
||||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
return fmt.Errorf("create config directory %q: %w", dir, err)
|
return fmt.Errorf("create config directory '%s': %w", dir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := os.OpenFile(c.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
f, err := os.OpenFile(c.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write config file %q: %w", c.path, err)
|
return fmt.Errorf("write config file '%s': %w", c.path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
encoder := toml.NewEncoder(f)
|
encoder := yaml.NewEncoder(f, yaml.Indent(2), yaml.IndentSequence(true))
|
||||||
encoder.Indent = ""
|
|
||||||
if err = encoder.Encode(c); err != nil {
|
if err = encoder.Encode(c); err != nil {
|
||||||
_ = f.Close()
|
_ = f.Close()
|
||||||
return fmt.Errorf("encode config file %q: %w", c.path, err)
|
return fmt.Errorf("encode config file '%s': %w", c.path, err)
|
||||||
}
|
}
|
||||||
return f.Close()
|
return f.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/internal/secret"
|
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -14,11 +15,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type MachineConnection struct {
|
type MachineConnection struct {
|
||||||
SSH SSHDestination `toml:"ssh,omitempty"`
|
SSH SSHDestination `yaml:"ssh,omitempty"`
|
||||||
TCP netip.AddrPort `toml:"tcp,omitempty"`
|
SSHKeyFile string `yaml:"ssh_key_file,omitempty"`
|
||||||
Host string `toml:"host,omitempty"`
|
// TCP is the address and port of the machine's API server.
|
||||||
PublicKey secret.Secret `toml:"public_key,omitempty"`
|
// The pointer is used to omit the field when not set. Otherwise, yaml marshalling includes an empty object.
|
||||||
SSHKeyFile string `toml:"ssh_key_file,omitempty"`
|
TCP *netip.AddrPort `yaml:"tcp,omitempty"`
|
||||||
|
Host string `yaml:"host,omitempty"`
|
||||||
|
PublicKey secret.Secret `yaml:"public_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type Context struct {
|
||||||
|
Name string `yaml:"-"`
|
||||||
|
Connections []MachineConnection `yaml:"connections"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExpandCommaSeparatedValues takes a slice of strings and expands any comma-separated values into individual elements.
|
||||||
|
func ExpandCommaSeparatedValues(values []string) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var expanded []string
|
||||||
|
for _, value := range values {
|
||||||
|
for _, v := range strings.Split(value, ",") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
expanded = append(expanded, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
+20
-16
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"uncloud/internal/sshexec"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/sshexec"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: support pinning the script version to the CLI version.
|
// TODO: support pinning the script version to the CLI version.
|
||||||
@@ -17,36 +19,38 @@ type RemoteMachine struct {
|
|||||||
KeyPath string
|
KeyPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func installCmd(user string) string {
|
func installCmd(user string, version string) string {
|
||||||
sudoPrefix := "sudo"
|
sudoPrefix := ""
|
||||||
|
var env []string
|
||||||
|
|
||||||
// Add the SSH user (non-root) to the uncloud group to allow access to the Uncloud daemon unix socket.
|
// Add the SSH user (non-root) to the uncloud group to allow access to the Uncloud daemon unix socket.
|
||||||
env := "UNCLOUD_GROUP_ADD_USER=" + user
|
if user != "root" {
|
||||||
|
sudoPrefix = "sudo"
|
||||||
curlBashCmd := fmt.Sprintf(
|
env = append(env, "UNCLOUD_GROUP_ADD_USER="+sshexec.Quote(user))
|
||||||
"curl -fsSL %s | %s %s bash", sshexec.Quote(installScriptURL), sudoPrefix, sshexec.Quote(env),
|
|
||||||
)
|
|
||||||
|
|
||||||
if user == "root" {
|
|
||||||
curlBashCmd = fmt.Sprintf(
|
|
||||||
"curl -fsSL %s | bash", sshexec.Quote(installScriptURL),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
if version != "" {
|
||||||
|
env = append(env, "UNCLOUD_VERSION="+sshexec.Quote(version))
|
||||||
|
}
|
||||||
|
|
||||||
|
envCmd := strings.Join(env, " ")
|
||||||
|
curlBashCmd := fmt.Sprintf("curl -fsSL %s | %s %s bash", sshexec.Quote(installScriptURL), sudoPrefix, envCmd)
|
||||||
|
|
||||||
return curlBashCmd
|
return curlBashCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// provisionMachine provisions the remote machine by downloading the Uncloud install script from GitHub and running it.
|
// provisionMachine provisions the remote machine by downloading the Uncloud install script from GitHub and running it.
|
||||||
func provisionMachine(ctx context.Context, exec sshexec.Executor) error {
|
// If version is specified, it will be passed to the install script as UNCLOUD_VERSION environment variable.
|
||||||
|
func provisionMachine(ctx context.Context, exec sshexec.Executor, version string) error {
|
||||||
user, err := exec.Run(ctx, "whoami")
|
user, err := exec.Run(ctx, "whoami")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run whoami: %w", err)
|
return fmt.Errorf("run whoami: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
installCmd := installCmd(user)
|
cmd := installCmd(user, version)
|
||||||
|
|
||||||
fmt.Println("Downloading Uncloud install script:", installScriptURL)
|
fmt.Println("Downloading Uncloud install script:", installScriptURL)
|
||||||
|
|
||||||
cmd := sshexec.QuoteCommand("bash", "-c", "set -o pipefail; "+installCmd)
|
cmd = sshexec.QuoteCommand("bash", "-c", "set -o pipefail; "+cmd)
|
||||||
if err = exec.Stream(ctx, cmd, os.Stdout, os.Stderr); err != nil {
|
if err = exec.Stream(ctx, cmd, os.Stdout, os.Stderr); err != nil {
|
||||||
return fmt.Errorf("download and run install script: %w", err)
|
return fmt.Errorf("download and run install script: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,29 @@ import (
|
|||||||
|
|
||||||
func TestInstallCmd(t *testing.T) {
|
func TestInstallCmd(t *testing.T) {
|
||||||
t.Run("root", func(t *testing.T) {
|
t.Run("root", func(t *testing.T) {
|
||||||
cmd := installCmd("root")
|
cmd := installCmd("root", "")
|
||||||
assert.NotContains(t, cmd, "sudo")
|
assert.NotContains(t, cmd, "sudo")
|
||||||
|
assert.NotContains(t, cmd, "UNCLOUD_GROUP_ADD_USER")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with version
|
||||||
|
t.Run("root with version", func(t *testing.T) {
|
||||||
|
cmd := installCmd("root", "v1.2.3")
|
||||||
|
assert.NotContains(t, cmd, "sudo")
|
||||||
|
assert.NotContains(t, cmd, "UNCLOUD_GROUP_ADD_USER")
|
||||||
|
assert.Contains(t, cmd, "UNCLOUD_VERSION=v1.2.3")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("nonroot", func(t *testing.T) {
|
t.Run("nonroot", func(t *testing.T) {
|
||||||
cmd := installCmd("nonroot")
|
cmd := installCmd("nonroot", "")
|
||||||
assert.Contains(t, cmd, "sudo")
|
assert.Contains(t, cmd, "sudo")
|
||||||
|
assert.Contains(t, cmd, "UNCLOUD_GROUP_ADD_USER=nonroot")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nonroot with version", func(t *testing.T) {
|
||||||
|
cmd := installCmd("nonroot", "v1.2.3")
|
||||||
|
assert.Contains(t, cmd, "sudo")
|
||||||
|
assert.Contains(t, cmd, "UNCLOUD_GROUP_ADD_USER=nonroot")
|
||||||
|
assert.Contains(t, cmd, "UNCLOUD_VERSION=v1.2.3")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/huh"
|
||||||
|
|
||||||
|
func Confirm() (bool, error) {
|
||||||
|
var confirmed bool
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewConfirm().
|
||||||
|
Title(
|
||||||
|
"Do you want to continue?",
|
||||||
|
).
|
||||||
|
Affirmative("Yes!").
|
||||||
|
Negative("No").
|
||||||
|
Value(&confirmed),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return confirmed, nil
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
systemd "github.com/coreos/go-systemd/daemon"
|
systemd "github.com/coreos/go-systemd/daemon"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Daemon struct {
|
type Daemon struct {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package daemon
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"uncloud/internal/machine"
|
|
||||||
"uncloud/internal/machine/network"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MachineToken returns the local machine's token that can be used for adding the machine to a cluster.
|
// MachineToken returns the local machine's token that can be used for adding the machine to a cluster.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,23 +14,37 @@ service Docker {
|
|||||||
rpc StopContainer(StopContainerRequest) returns (google.protobuf.Empty);
|
rpc StopContainer(StopContainerRequest) returns (google.protobuf.Empty);
|
||||||
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
|
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
|
||||||
rpc RemoveContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
rpc RemoveContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
||||||
|
|
||||||
rpc PullImage(PullImageRequest) returns (stream JSONMessage);
|
rpc PullImage(PullImageRequest) returns (stream JSONMessage);
|
||||||
|
rpc InspectImage(InspectImageRequest) returns (InspectImageResponse);
|
||||||
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
|
// Docker auth credentials if necessary.
|
||||||
|
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
|
||||||
|
|
||||||
|
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
|
||||||
|
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
|
||||||
|
rpc RemoveVolume(RemoveVolumeRequest) returns (google.protobuf.Empty);
|
||||||
|
|
||||||
|
rpc CreateServiceContainer(CreateServiceContainerRequest) returns (CreateContainerResponse);
|
||||||
|
rpc InspectServiceContainer(InspectContainerRequest) returns (ServiceContainer);
|
||||||
|
rpc ListServiceContainers(ListServiceContainersRequest) returns (ListServiceContainersResponse);
|
||||||
|
rpc RemoveServiceContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateContainerRequest {
|
message CreateContainerRequest {
|
||||||
// JSON serialized container.Config.
|
// JSON serialised container.Config.
|
||||||
bytes config = 1;
|
bytes config = 1;
|
||||||
// JSON serialized container.HostConfig.
|
// JSON serialised container.HostConfig.
|
||||||
bytes host_config = 2;
|
bytes host_config = 2;
|
||||||
// JSON serialized network.NetworkingConfig.
|
// JSON serialised network.NetworkingConfig.
|
||||||
bytes network_config = 3;
|
bytes network_config = 3;
|
||||||
// JSON serialized ocispec.Platform.
|
// JSON serialised ocispec.Platform.
|
||||||
bytes platform = 4;
|
bytes platform = 4;
|
||||||
string name = 5;
|
string name = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateContainerResponse {
|
message CreateContainerResponse {
|
||||||
// JSON serialized container.CreateResponse.
|
// JSON serialised container.CreateResponse.
|
||||||
bytes response = 1;
|
bytes response = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,51 +53,145 @@ message InspectContainerRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message InspectContainerResponse {
|
message InspectContainerResponse {
|
||||||
// JSON serialized container.InspectResponse.
|
// JSON serialised container.InspectResponse.
|
||||||
bytes response = 1;
|
bytes response = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message StartContainerRequest {
|
message StartContainerRequest {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
// JSON serialized container.StartOptions.
|
// JSON serialised container.StartOptions.
|
||||||
bytes options = 2;
|
bytes options = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message StopContainerRequest {
|
message StopContainerRequest {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
// JSON serialized container.StopOptions.
|
// JSON serialised container.StopOptions.
|
||||||
bytes options = 2;
|
bytes options = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListContainersRequest {
|
message ListContainersRequest {
|
||||||
// JSON serialized container.ListOptions.
|
// JSON serialised container.ListOptions.
|
||||||
bytes options = 1;
|
bytes options = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListContainersResponse structure allows broadcasting ListContainers requests to multiple machines.
|
|
||||||
message ListContainersResponse {
|
message ListContainersResponse {
|
||||||
|
// Must contain only one repeated messages field to allow broadcasting ListContainers requests to multiple machines.
|
||||||
repeated MachineContainers messages = 1;
|
repeated MachineContainers messages = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message MachineContainers {
|
message MachineContainers {
|
||||||
Metadata metadata = 1;
|
Metadata metadata = 1;
|
||||||
// JSON serialized []container.ContainerJSON.
|
// JSON serialised []container.InspectResponse.
|
||||||
bytes containers = 2;
|
bytes containers = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message RemoveContainerRequest {
|
message RemoveContainerRequest {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
// JSON serialized container.RemoveOptions.
|
// JSON serialised container.RemoveOptions.
|
||||||
bytes options = 2;
|
bytes options = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message PullImageRequest {
|
message PullImageRequest {
|
||||||
string image = 1;
|
string image = 1;
|
||||||
// JSON serialized image.PullOptions.
|
// JSON serialised image.PullOptions.
|
||||||
bytes options = 2;
|
bytes options = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message JSONMessage {
|
message JSONMessage {
|
||||||
// JSON serialized jsonmessage.JSONMessage.
|
// JSON serialised jsonmessage.JSONMessage.
|
||||||
bytes message = 1;
|
bytes message = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message InspectImageRequest {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InspectImageResponse {
|
||||||
|
// Must contain only one repeated messages field to allow broadcasting InspectImage requests to multiple machines.
|
||||||
|
repeated Image messages = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Image {
|
||||||
|
Metadata metadata = 1;
|
||||||
|
// JSON serialised types.ImageInspect.
|
||||||
|
bytes image = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InspectRemoteImageRequest {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InspectRemoteImageResponse {
|
||||||
|
// Must contain only one repeated messages field to allow broadcasting InspectRemoteImage requests to multiple machines.
|
||||||
|
repeated RemoteImage messages = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoteImage {
|
||||||
|
Metadata metadata = 1;
|
||||||
|
// Image reference in the canonical form with the digest.
|
||||||
|
string reference = 2;
|
||||||
|
// Raw JSON manifest from the registry.
|
||||||
|
bytes manifest = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateVolumeRequest {
|
||||||
|
// JSON serialised volume.CreateOptions.
|
||||||
|
bytes options = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateVolumeResponse {
|
||||||
|
// JSON serialised volume.Volume.
|
||||||
|
bytes volume = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListVolumesRequest {
|
||||||
|
// JSON serialised volume.ListOptions.
|
||||||
|
bytes options = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListVolumesResponse {
|
||||||
|
// Must contain only one repeated messages field to allow broadcasting ListVolumes requests to multiple machines.
|
||||||
|
repeated MachineVolumes messages = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MachineVolumes {
|
||||||
|
Metadata metadata = 1;
|
||||||
|
// JSON serialised volume.ListResponse.
|
||||||
|
bytes response = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoveVolumeRequest {
|
||||||
|
string id = 1;
|
||||||
|
bool force = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,22 @@ import (
|
|||||||
const _ = grpc.SupportPackageIsVersion9
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Docker_CreateContainer_FullMethodName = "/api.Docker/CreateContainer"
|
Docker_CreateContainer_FullMethodName = "/api.Docker/CreateContainer"
|
||||||
Docker_InspectContainer_FullMethodName = "/api.Docker/InspectContainer"
|
Docker_InspectContainer_FullMethodName = "/api.Docker/InspectContainer"
|
||||||
Docker_StartContainer_FullMethodName = "/api.Docker/StartContainer"
|
Docker_StartContainer_FullMethodName = "/api.Docker/StartContainer"
|
||||||
Docker_StopContainer_FullMethodName = "/api.Docker/StopContainer"
|
Docker_StopContainer_FullMethodName = "/api.Docker/StopContainer"
|
||||||
Docker_ListContainers_FullMethodName = "/api.Docker/ListContainers"
|
Docker_ListContainers_FullMethodName = "/api.Docker/ListContainers"
|
||||||
Docker_RemoveContainer_FullMethodName = "/api.Docker/RemoveContainer"
|
Docker_RemoveContainer_FullMethodName = "/api.Docker/RemoveContainer"
|
||||||
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
|
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
|
||||||
|
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
|
||||||
|
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
|
||||||
|
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
|
||||||
|
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
|
||||||
|
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
|
||||||
|
Docker_CreateServiceContainer_FullMethodName = "/api.Docker/CreateServiceContainer"
|
||||||
|
Docker_InspectServiceContainer_FullMethodName = "/api.Docker/InspectServiceContainer"
|
||||||
|
Docker_ListServiceContainers_FullMethodName = "/api.Docker/ListServiceContainers"
|
||||||
|
Docker_RemoveServiceContainer_FullMethodName = "/api.Docker/RemoveServiceContainer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DockerClient is the client API for Docker service.
|
// DockerClient is the client API for Docker service.
|
||||||
@@ -40,6 +49,17 @@ type DockerClient interface {
|
|||||||
ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ListContainersResponse, error)
|
ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ListContainersResponse, error)
|
||||||
RemoveContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
RemoveContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], error)
|
PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], error)
|
||||||
|
InspectImage(ctx context.Context, in *InspectImageRequest, opts ...grpc.CallOption) (*InspectImageResponse, error)
|
||||||
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
|
// Docker auth credentials if necessary.
|
||||||
|
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
|
||||||
|
CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error)
|
||||||
|
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
|
||||||
|
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
|
CreateServiceContainer(ctx context.Context, in *CreateServiceContainerRequest, opts ...grpc.CallOption) (*CreateContainerResponse, error)
|
||||||
|
InspectServiceContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*ServiceContainer, error)
|
||||||
|
ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error)
|
||||||
|
RemoveServiceContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type dockerClient struct {
|
type dockerClient struct {
|
||||||
@@ -129,6 +149,96 @@ func (c *dockerClient) PullImage(ctx context.Context, in *PullImageRequest, opts
|
|||||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
type Docker_PullImageClient = grpc.ServerStreamingClient[JSONMessage]
|
type Docker_PullImageClient = grpc.ServerStreamingClient[JSONMessage]
|
||||||
|
|
||||||
|
func (c *dockerClient) InspectImage(ctx context.Context, in *InspectImageRequest, opts ...grpc.CallOption) (*InspectImageResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(InspectImageResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_InspectImage_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(InspectRemoteImageResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_InspectRemoteImage_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CreateVolumeResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_CreateVolume_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ListVolumesResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_ListVolumes_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(emptypb.Empty)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_RemoveVolume_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) CreateServiceContainer(ctx context.Context, in *CreateServiceContainerRequest, opts ...grpc.CallOption) (*CreateContainerResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CreateContainerResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_CreateServiceContainer_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) InspectServiceContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*ServiceContainer, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ServiceContainer)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_InspectServiceContainer_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ListServiceContainersResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_ListServiceContainers_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) RemoveServiceContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(emptypb.Empty)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_RemoveServiceContainer_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DockerServer is the server API for Docker service.
|
// DockerServer is the server API for Docker service.
|
||||||
// All implementations must embed UnimplementedDockerServer
|
// All implementations must embed UnimplementedDockerServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@@ -140,6 +250,17 @@ type DockerServer interface {
|
|||||||
ListContainers(context.Context, *ListContainersRequest) (*ListContainersResponse, error)
|
ListContainers(context.Context, *ListContainersRequest) (*ListContainersResponse, error)
|
||||||
RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
||||||
PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error
|
PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error
|
||||||
|
InspectImage(context.Context, *InspectImageRequest) (*InspectImageResponse, error)
|
||||||
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
|
// Docker auth credentials if necessary.
|
||||||
|
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
|
||||||
|
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
|
||||||
|
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
|
||||||
|
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error)
|
||||||
|
CreateServiceContainer(context.Context, *CreateServiceContainerRequest) (*CreateContainerResponse, error)
|
||||||
|
InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error)
|
||||||
|
ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error)
|
||||||
|
RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
||||||
mustEmbedUnimplementedDockerServer()
|
mustEmbedUnimplementedDockerServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +292,33 @@ func (UnimplementedDockerServer) RemoveContainer(context.Context, *RemoveContain
|
|||||||
func (UnimplementedDockerServer) PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error {
|
func (UnimplementedDockerServer) PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error {
|
||||||
return status.Errorf(codes.Unimplemented, "method PullImage not implemented")
|
return status.Errorf(codes.Unimplemented, "method PullImage not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedDockerServer) InspectImage(context.Context, *InspectImageRequest) (*InspectImageResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method InspectImage not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedDockerServer) InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method InspectRemoteImage not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedDockerServer) ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListVolumes not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedDockerServer) RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method RemoveVolume not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedDockerServer) CreateServiceContainer(context.Context, *CreateServiceContainerRequest) (*CreateContainerResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method CreateServiceContainer not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedDockerServer) InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method InspectServiceContainer not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedDockerServer) ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListServiceContainers not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedDockerServer) RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method RemoveServiceContainer not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedDockerServer) mustEmbedUnimplementedDockerServer() {}
|
func (UnimplementedDockerServer) mustEmbedUnimplementedDockerServer() {}
|
||||||
func (UnimplementedDockerServer) testEmbeddedByValue() {}
|
func (UnimplementedDockerServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -311,6 +459,168 @@ func _Docker_PullImage_Handler(srv interface{}, stream grpc.ServerStream) error
|
|||||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
type Docker_PullImageServer = grpc.ServerStreamingServer[JSONMessage]
|
type Docker_PullImageServer = grpc.ServerStreamingServer[JSONMessage]
|
||||||
|
|
||||||
|
func _Docker_InspectImage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(InspectImageRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).InspectImage(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_InspectImage_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).InspectImage(ctx, req.(*InspectImageRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Docker_InspectRemoteImage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(InspectRemoteImageRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).InspectRemoteImage(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_InspectRemoteImage_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).InspectRemoteImage(ctx, req.(*InspectRemoteImageRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(CreateVolumeRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).CreateVolume(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_CreateVolume_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).CreateVolume(ctx, req.(*CreateVolumeRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Docker_ListVolumes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ListVolumesRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).ListVolumes(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_ListVolumes_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).ListVolumes(ctx, req.(*ListVolumesRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Docker_RemoveVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(RemoveVolumeRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).RemoveVolume(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_RemoveVolume_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).RemoveVolume(ctx, req.(*RemoveVolumeRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Docker_CreateServiceContainer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(CreateServiceContainerRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).CreateServiceContainer(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_CreateServiceContainer_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).CreateServiceContainer(ctx, req.(*CreateServiceContainerRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Docker_InspectServiceContainer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(InspectContainerRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).InspectServiceContainer(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_InspectServiceContainer_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).InspectServiceContainer(ctx, req.(*InspectContainerRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Docker_ListServiceContainers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ListServiceContainersRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).ListServiceContainers(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_ListServiceContainers_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).ListServiceContainers(ctx, req.(*ListServiceContainersRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Docker_RemoveServiceContainer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(RemoveContainerRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).RemoveServiceContainer(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_RemoveServiceContainer_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).RemoveServiceContainer(ctx, req.(*RemoveContainerRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
// Docker_ServiceDesc is the grpc.ServiceDesc for Docker service.
|
// Docker_ServiceDesc is the grpc.ServiceDesc for Docker service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -342,6 +652,42 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "RemoveContainer",
|
MethodName: "RemoveContainer",
|
||||||
Handler: _Docker_RemoveContainer_Handler,
|
Handler: _Docker_RemoveContainer_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "InspectImage",
|
||||||
|
Handler: _Docker_InspectImage_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "InspectRemoteImage",
|
||||||
|
Handler: _Docker_InspectRemoteImage_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "CreateVolume",
|
||||||
|
Handler: _Docker_CreateVolume_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListVolumes",
|
||||||
|
Handler: _Docker_ListVolumes_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "RemoveVolume",
|
||||||
|
Handler: _Docker_RemoveVolume_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "CreateServiceContainer",
|
||||||
|
Handler: _Docker_CreateServiceContainer_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "InspectServiceContainer",
|
||||||
|
Handler: _Docker_InspectServiceContainer_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListServiceContainers",
|
||||||
|
Handler: _Docker_ListServiceContainers_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "RemoveServiceContainer",
|
||||||
|
Handler: _Docker_RemoveServiceContainer_Handler,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{
|
Streams: []grpc.StreamDesc{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/encoding/protowire"
|
"google.golang.org/protobuf/encoding/protowire"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// One2ManyResponder converts upstream responses into messages from upstreams, so that multiple
|
// One2ManyResponder converts upstream responses into messages from upstreams, so that multiple
|
||||||
|
|||||||
@@ -4,21 +4,22 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/caddyserver/caddy/v2"
|
|
||||||
"github.com/caddyserver/caddy/v2/caddyconfig"
|
|
||||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
|
||||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"uncloud/internal/api"
|
|
||||||
"uncloud/internal/machine/docker"
|
"github.com/caddyserver/caddy/v2"
|
||||||
|
"github.com/caddyserver/caddy/v2/caddyconfig"
|
||||||
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||||
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GenerateConfig(containers []api.Container, verifyResponse string) (*caddy.Config, error) {
|
func GenerateConfig(containers []api.ServiceContainer, verifyResponse string) (*caddy.Config, error) {
|
||||||
// Maps hostnames to lists of upstreams (container IP:port pairs).
|
// Maps hostnames to lists of upstreams (container IP:port pairs).
|
||||||
httpHostUpstreams := make(map[string][]string)
|
httpHostUpstreams := make(map[string][]string)
|
||||||
httpsHostUpstreams := make(map[string][]string)
|
httpsHostUpstreams := make(map[string][]string)
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
package caddyfile
|
package caddyfile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"uncloud/internal/api"
|
|
||||||
"uncloud/internal/machine/docker"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGenerateConfig(t *testing.T) {
|
func TestGenerateConfig(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
containers []api.Container
|
containers []api.ServiceContainer
|
||||||
want string
|
want string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty containers",
|
name: "empty containers",
|
||||||
containers: []api.Container{},
|
containers: []api.ServiceContainer{},
|
||||||
want: `{
|
want: `{
|
||||||
"servers": {
|
"servers": {
|
||||||
"http": {
|
"http": {
|
||||||
@@ -45,7 +46,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
|
|
||||||
{
|
{
|
||||||
name: "HTTP container",
|
name: "HTTP container",
|
||||||
containers: []api.Container{
|
containers: []api.ServiceContainer{
|
||||||
newContainer("10.210.0.2", "app.example.com:8080/http"),
|
newContainer("10.210.0.2", "app.example.com:8080/http"),
|
||||||
},
|
},
|
||||||
want: `{
|
want: `{
|
||||||
@@ -79,7 +80,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "load balancing multiple containers",
|
name: "load balancing multiple containers",
|
||||||
containers: []api.Container{
|
containers: []api.ServiceContainer{
|
||||||
newContainer("10.210.0.2", "app.example.com:8080/http"),
|
newContainer("10.210.0.2", "app.example.com:8080/http"),
|
||||||
newContainer("10.210.0.3", "app.example.com:8080/http"),
|
newContainer("10.210.0.3", "app.example.com:8080/http"),
|
||||||
},
|
},
|
||||||
@@ -117,7 +118,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "HTTPS container",
|
name: "HTTPS container",
|
||||||
containers: []api.Container{
|
containers: []api.ServiceContainer{
|
||||||
newContainer("10.210.0.2", "secure.example.com:8000/https"),
|
newContainer("10.210.0.2", "secure.example.com:8000/https"),
|
||||||
},
|
},
|
||||||
want: `{
|
want: `{
|
||||||
@@ -153,7 +154,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mixed HTTP and HTTPS",
|
name: "mixed HTTP and HTTPS",
|
||||||
containers: []api.Container{
|
containers: []api.ServiceContainer{
|
||||||
newContainer("10.210.0.2",
|
newContainer("10.210.0.2",
|
||||||
"app.example.com:8080/http",
|
"app.example.com:8080/http",
|
||||||
"web.example.com:8000/http"),
|
"web.example.com:8000/http"),
|
||||||
@@ -227,7 +228,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "container without uncloud network ignored",
|
name: "container without uncloud network ignored",
|
||||||
containers: []api.Container{
|
containers: []api.ServiceContainer{
|
||||||
newContainerWithoutNetwork("ignored.example.com:8080/http"),
|
newContainerWithoutNetwork("ignored.example.com:8080/http"),
|
||||||
},
|
},
|
||||||
want: `{
|
want: `{
|
||||||
@@ -252,7 +253,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "container with invalid port ignored",
|
name: "container with invalid port ignored",
|
||||||
containers: []api.Container{
|
containers: []api.ServiceContainer{
|
||||||
newContainer("10.210.0.2", "invalid-port"),
|
newContainer("10.210.0.2", "invalid-port"),
|
||||||
},
|
},
|
||||||
want: `{
|
want: `{
|
||||||
@@ -277,7 +278,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "containers with unsupported protocols ignored",
|
name: "containers with unsupported protocols ignored",
|
||||||
containers: []api.Container{
|
containers: []api.ServiceContainer{
|
||||||
newContainer("10.210.0.2", "5000/tcp"),
|
newContainer("10.210.0.2", "5000/tcp"),
|
||||||
newContainer("10.210.0.3", "5000/udp"),
|
newContainer("10.210.0.3", "5000/udp"),
|
||||||
newContainer("10.210.0.4", "80:8080/tcp@host"),
|
newContainer("10.210.0.4", "80:8080/tcp@host"),
|
||||||
@@ -322,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, ",")
|
portsLabel := strings.Join(ports, ",")
|
||||||
return api.Container{
|
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
|
||||||
ContainerJSON: types.ContainerJSON{
|
ContainerJSONBase: &types.ContainerJSONBase{},
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{},
|
NetworkSettings: &types.NetworkSettings{
|
||||||
NetworkSettings: &types.NetworkSettings{
|
Networks: map[string]*network.EndpointSettings{
|
||||||
Networks: map[string]*network.EndpointSettings{
|
docker.NetworkName: {
|
||||||
docker.NetworkName: {
|
IPAddress: ip,
|
||||||
IPAddress: ip,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Config: &container.Config{
|
|
||||||
Labels: map[string]string{
|
|
||||||
api.LabelServicePorts: portsLabel,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
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, ",")
|
portsLabel := strings.Join(ports, ",")
|
||||||
return api.Container{
|
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
|
||||||
ContainerJSON: types.ContainerJSON{
|
ContainerJSONBase: &types.ContainerJSONBase{},
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{},
|
NetworkSettings: &types.NetworkSettings{
|
||||||
NetworkSettings: &types.NetworkSettings{
|
Networks: map[string]*network.EndpointSettings{
|
||||||
Networks: map[string]*network.EndpointSettings{
|
"other-network": {
|
||||||
"other-network": {
|
IPAddress: "172.17.0.2",
|
||||||
IPAddress: "172.17.0.2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Config: &container.Config{
|
|
||||||
Labels: map[string]string{
|
|
||||||
api.LabelServicePorts: portsLabel,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
Config: &container.Config{
|
||||||
|
Labels: map[string]string{
|
||||||
|
api.LabelServicePorts: portsLabel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"uncloud/internal/api"
|
|
||||||
"uncloud/internal/fs"
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
"uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -89,15 +90,20 @@ func (c *Controller) Run(ctx context.Context) error {
|
|||||||
// filterAvailableContainers filters out containers from this machine that are likely unavailable. The availability
|
// filterAvailableContainers filters out containers from this machine that are likely unavailable. The availability
|
||||||
// is determined by the cluster membership state of the machine that the container is running on.
|
// is determined by the cluster membership state of the machine that the container is running on.
|
||||||
// TODO: implement machine membership check using Corrossion Admin client.
|
// TODO: implement machine membership check using Corrossion Admin client.
|
||||||
func (c *Controller) filterAvailableContainers(containerRecords []store.ContainerRecord) ([]api.Container, error) {
|
func (c *Controller) filterAvailableContainers(
|
||||||
containers := make([]api.Container, len(containerRecords))
|
containerRecords []store.ContainerRecord,
|
||||||
|
) ([]api.ServiceContainer, error) {
|
||||||
|
containers := make([]api.ServiceContainer, len(containerRecords))
|
||||||
for i, cr := range containerRecords {
|
for i, cr := range containerRecords {
|
||||||
containers[i] = cr.Container
|
containers[i] = api.ServiceContainer{
|
||||||
|
Container: cr.Container,
|
||||||
|
// TODO: restore ServiceSpec from the container record once it's saved in the store.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return containers, nil
|
return containers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) generateConfig(containers []api.Container) error {
|
func (c *Controller) generateConfig(containers []api.ServiceContainer) error {
|
||||||
config, err := GenerateConfig(containers, c.verifyResponse)
|
config, err := GenerateConfig(containers, c.verifyResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/corrosion"
|
"github.com/psviderski/uncloud/internal/corrosion"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Cluster struct {
|
type Cluster struct {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"github.com/psviderski/uncloud/internal/dns"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
"uncloud/internal/dns"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
"uncloud/internal/machine/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// uncloudDNSKey is the key used to store the details of the reserved domain in the store.
|
// uncloudDNSKey is the key used to store the details of the reserved domain in the store.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package cluster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewMachineID generates a new unique machine ID.
|
// NewMachineID generates a new unique machine ID.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"uncloud/internal/fs"
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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_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'))
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
if _, err = db.Exec(schema); err != nil {
|
||||||
|
return nil, fmt.Errorf("create schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
@@ -3,18 +3,24 @@ package docker
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
|
"github.com/docker/docker/api/types/volume"
|
||||||
"github.com/docker/docker/errdefs"
|
"github.com/docker/docker/errdefs"
|
||||||
"github.com/docker/docker/pkg/jsonmessage"
|
"github.com/docker/docker/pkg/jsonmessage"
|
||||||
|
regtypes "github.com/google/go-containerregistry/pkg/v1/types"
|
||||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"io"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client is a gRPC client for the Docker service that provides a similar interface to the Docker HTTP client.
|
// Client is a gRPC client for the Docker service that provides a similar interface to the Docker HTTP client.
|
||||||
@@ -72,10 +78,8 @@ func (c *Client) CreateContainer(
|
|||||||
Name: name,
|
Name: name,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s, ok := status.FromError(err); ok {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
if s.Code() == codes.NotFound {
|
return resp, errdefs.NotFound(err)
|
||||||
return resp, errdefs.NotFound(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
@@ -92,10 +96,8 @@ func (c *Client) InspectContainer(ctx context.Context, id string) (types.Contain
|
|||||||
|
|
||||||
grpcResp, err := c.grpcClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
grpcResp, err := c.grpcClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s, ok := status.FromError(err); ok {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
if s.Code() == codes.NotFound {
|
return resp, errdefs.NotFound(err)
|
||||||
return resp, errdefs.NotFound(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
@@ -118,10 +120,8 @@ func (c *Client) StartContainer(ctx context.Context, id string, opts container.S
|
|||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s, ok := status.FromError(err); ok {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
if s.Code() == codes.NotFound {
|
return errdefs.NotFound(err)
|
||||||
return errdefs.NotFound(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
@@ -139,10 +139,8 @@ func (c *Client) StopContainer(ctx context.Context, id string, opts container.St
|
|||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s, ok := status.FromError(err); ok {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
if s.Code() == codes.NotFound {
|
return errdefs.NotFound(err)
|
||||||
return errdefs.NotFound(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
@@ -191,10 +189,8 @@ func (c *Client) RemoveContainer(ctx context.Context, id string, opts container.
|
|||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s, ok := status.FromError(err); ok {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
if s.Code() == codes.NotFound {
|
return errdefs.NotFound(err)
|
||||||
return errdefs.NotFound(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
@@ -237,3 +233,297 @@ func (c *Client) PullImage(ctx context.Context, image string) (<-chan PullImageM
|
|||||||
|
|
||||||
return ch, nil
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InspectImage returns the image information for the given image ID. The request may be sent to multiple machines.
|
||||||
|
func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
||||||
|
resp, err := c.grpcClient.InspectImage(ctx, &pb.InspectImageRequest{Id: id})
|
||||||
|
if err != nil {
|
||||||
|
// If the request was sent to only one machine, err is an actual error from the machine.
|
||||||
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
|
return nil, errdefs.NotFound(err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
notFoundCount := 0
|
||||||
|
for _, msg := range resp.Messages {
|
||||||
|
if msg.Metadata != nil && msg.Metadata.Status != nil && codes.Code(msg.Metadata.Status.Code) == codes.NotFound {
|
||||||
|
notFoundCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(resp.Messages) == notFoundCount {
|
||||||
|
return nil, errdefs.NotFound(fmt.Errorf("image not found: %s", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
images := make([]api.MachineImage, len(resp.Messages))
|
||||||
|
for i, msg := range resp.Messages {
|
||||||
|
images[i].Metadata = msg.Metadata
|
||||||
|
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(msg.Image, &images[i].Image); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal image: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return images, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's Docker auth
|
||||||
|
// credentials if necessary. If the response from a machine doesn't contain an error, the api.RemoteImage will either
|
||||||
|
// contain an IndexManifest or an ImageManifest.
|
||||||
|
func (c *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
|
||||||
|
resp, err := c.grpcClient.InspectRemoteImage(ctx, &pb.InspectRemoteImageRequest{Id: id})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
images := make([]api.MachineRemoteImage, 0, len(resp.Messages))
|
||||||
|
var parseErr error
|
||||||
|
for _, msg := range resp.Messages {
|
||||||
|
mri, err := parseRemoteImageMessage(msg)
|
||||||
|
if err != nil {
|
||||||
|
parseErr = errors.Join(parseErr, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
images = append(images, mri)
|
||||||
|
}
|
||||||
|
|
||||||
|
return images, parseErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestMediaType struct {
|
||||||
|
MediaType regtypes.MediaType `json:"mediaType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRemoteImageMessage(msg *pb.RemoteImage) (api.MachineRemoteImage, error) {
|
||||||
|
mri := api.MachineRemoteImage{
|
||||||
|
Metadata: msg.Metadata,
|
||||||
|
}
|
||||||
|
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||||
|
return mri, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err := reference.ParseNormalizedNamed(msg.Reference)
|
||||||
|
if err != nil {
|
||||||
|
// The reference is the string representation of a valid canonical reference from the server which
|
||||||
|
// is expected to be parseable.
|
||||||
|
return mri, fmt.Errorf("parse reference: %w", err)
|
||||||
|
}
|
||||||
|
if canonicalRef, ok := ref.(reference.Canonical); ok {
|
||||||
|
mri.Image.Reference = canonicalRef
|
||||||
|
} else {
|
||||||
|
return mri, fmt.Errorf("unexpected non-canonical image reference: %s", msg.Reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := manifestMediaType{}
|
||||||
|
if err = json.Unmarshal(msg.Manifest, &manifest); err != nil {
|
||||||
|
return mri, fmt.Errorf("unmarshal manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case manifest.MediaType.IsIndex():
|
||||||
|
index := &ocispec.Index{}
|
||||||
|
if err = json.Unmarshal(msg.Manifest, index); err != nil {
|
||||||
|
return mri, fmt.Errorf("unmarshal index manifest: %w", err)
|
||||||
|
}
|
||||||
|
mri.Image.IndexManifest = index
|
||||||
|
case manifest.MediaType.IsImage():
|
||||||
|
image := &ocispec.Manifest{}
|
||||||
|
if err = json.Unmarshal(msg.Manifest, image); err != nil {
|
||||||
|
return mri, fmt.Errorf("unmarshal image manifest: %w", err)
|
||||||
|
}
|
||||||
|
mri.Image.ImageManifest = image
|
||||||
|
default:
|
||||||
|
return mri, fmt.Errorf("unexpected manifest media type: %s", manifest.MediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mri, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateVolume creates a new volume with the given options.
|
||||||
|
func (c *Client) CreateVolume(ctx context.Context, opts volume.CreateOptions) (volume.Volume, error) {
|
||||||
|
var vol volume.Volume
|
||||||
|
|
||||||
|
optsBytes, err := json.Marshal(opts)
|
||||||
|
if err != nil {
|
||||||
|
return vol, fmt.Errorf("marshal options: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.grpcClient.CreateVolume(ctx, &pb.CreateVolumeRequest{Options: optsBytes})
|
||||||
|
if err != nil {
|
||||||
|
return vol, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(resp.Volume, &vol); err != nil {
|
||||||
|
return vol, fmt.Errorf("unmarshal volume: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return vol, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MachineVolumes represents a volume list response from a machine.
|
||||||
|
type MachineVolumes struct {
|
||||||
|
Metadata *pb.Metadata
|
||||||
|
Response volume.ListResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListVolumes returns a list of all volumes matching the filter.
|
||||||
|
func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]MachineVolumes, error) {
|
||||||
|
optsBytes, err := json.Marshal(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal options: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.grpcClient.ListVolumes(ctx, &pb.ListVolumesRequest{Options: optsBytes})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
machineVolumes := make([]MachineVolumes, len(resp.Messages))
|
||||||
|
for i, msg := range resp.Messages {
|
||||||
|
machineVolumes[i].Metadata = msg.Metadata
|
||||||
|
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(msg.Response, &machineVolumes[i].Response); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return machineVolumes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveVolume removes a volume with the given ID.
|
||||||
|
func (c *Client) RemoveVolume(ctx context.Context, id string, force bool) error {
|
||||||
|
_, err := c.grpcClient.RemoveVolume(ctx, &pb.RemoveVolumeRequest{
|
||||||
|
Id: id,
|
||||||
|
Force: force,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
|
return errdefs.NotFound(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateServiceContainer creates a new container for the service with the given specifications.
|
||||||
|
func (c *Client) CreateServiceContainer(
|
||||||
|
ctx context.Context, serviceID string, spec api.ServiceSpec, containerName string,
|
||||||
|
) (container.CreateResponse, error) {
|
||||||
|
var resp container.CreateResponse
|
||||||
|
|
||||||
|
specBytes, err := json.Marshal(spec)
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("marshal service spec: %w", err)
|
||||||
|
}
|
||||||
|
grpcResp, err := c.grpcClient.CreateServiceContainer(ctx, &pb.CreateServiceContainerRequest{
|
||||||
|
ServiceId: serviceID,
|
||||||
|
ServiceSpec: specBytes,
|
||||||
|
ContainerName: containerName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
|
return resp, errdefs.NotFound(err)
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(grpcResp.Response, &resp); err != nil {
|
||||||
|
return resp, fmt.Errorf("unmarshal gRPC response: %w", err)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectServiceContainer returns the container information and service specification that was used to create the
|
||||||
|
// container with the given ID.
|
||||||
|
func (c *Client) InspectServiceContainer(ctx context.Context, id string) (api.ServiceContainer, error) {
|
||||||
|
var resp api.ServiceContainer
|
||||||
|
|
||||||
|
grpcResp, err := c.grpcClient.InspectServiceContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||||
|
if err != nil {
|
||||||
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
|
return resp, errdefs.NotFound(err)
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(grpcResp.Container, &resp.Container); err != nil {
|
||||||
|
return resp, fmt.Errorf("unmarshal container: %w", err)
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(grpcResp.ServiceSpec, &resp.ServiceSpec); err != nil {
|
||||||
|
return resp, fmt.Errorf("unmarshal service spec: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineServiceContainers struct {
|
||||||
|
Metadata *pb.Metadata
|
||||||
|
Containers []api.ServiceContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListServiceContainers returns all containers on requested machines that belong to the service with the given
|
||||||
|
// name or ID. If serviceNameOrID is empty, all service containers are returned.
|
||||||
|
func (c *Client) ListServiceContainers(
|
||||||
|
ctx context.Context, serviceNameOrID string, opts container.ListOptions,
|
||||||
|
) ([]MachineServiceContainers, error) {
|
||||||
|
optsBytes, err := json.Marshal(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal options: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.grpcClient.ListServiceContainers(ctx, &pb.ListServiceContainersRequest{
|
||||||
|
ServiceId: serviceNameOrID,
|
||||||
|
Options: optsBytes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
machineContainers := make([]MachineServiceContainers, len(resp.Messages))
|
||||||
|
for i, msg := range resp.Messages {
|
||||||
|
machineContainers[i].Metadata = msg.Metadata
|
||||||
|
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
containers := make([]api.ServiceContainer, len(msg.Containers))
|
||||||
|
for j, sc := range msg.Containers {
|
||||||
|
if err = json.Unmarshal(sc.Container, &containers[j].Container); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal container: %w", err)
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(sc.ServiceSpec, &containers[j].ServiceSpec); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal service spec: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
machineContainers[i].Containers = containers
|
||||||
|
}
|
||||||
|
|
||||||
|
return machineContainers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveServiceContainer stops (kills after grace period) and removes a service container with the given ID.
|
||||||
|
// A service container is a container that has been created with CreateServiceContainer.
|
||||||
|
func (c *Client) RemoveServiceContainer(ctx context.Context, id string, opts container.RemoveOptions) error {
|
||||||
|
optsBytes, err := json.Marshal(opts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal options: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.grpcClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
|
||||||
|
Id: id,
|
||||||
|
Options: optsBytes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
|
return errdefs.NotFound(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"github.com/docker/docker/api/types/events"
|
"github.com/docker/docker/api/types/events"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/api"
|
|
||||||
"uncloud/internal/machine/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"github.com/docker/docker/libnetwork/iptables"
|
"github.com/docker/docker/libnetwork/iptables"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnsureUncloudNetwork creates the Docker bridge network NetworkName with the provided machine subnet
|
// EnsureUncloudNetwork creates the Docker bridge network NetworkName with the provided machine subnet
|
||||||
|
|||||||
@@ -2,32 +2,57 @@ package docker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
|
"github.com/docker/docker/api/types/volume"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/docker/go-connections/nat"
|
||||||
|
"github.com/google/go-containerregistry/pkg/authn"
|
||||||
|
"github.com/google/go-containerregistry/pkg/name"
|
||||||
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/opencontainers/go-digest"
|
||||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
"io"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var fullDockerIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||||
|
|
||||||
// Server implements the gRPC Docker service that proxies requests to the Docker daemon.
|
// Server implements the gRPC Docker service that proxies requests to the Docker daemon.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
pb.UnimplementedDockerServer
|
pb.UnimplementedDockerServer
|
||||||
client *client.Client
|
client *client.Client
|
||||||
|
db *sqlx.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new Docker gRPC server with the provided Docker client.
|
// NewServer creates a new Docker gRPC server with the provided Docker client.
|
||||||
func NewServer(cli *client.Client) *Server {
|
func NewServer(cli *client.Client, db *sqlx.DB) *Server {
|
||||||
return &Server{client: cli}
|
return &Server{
|
||||||
|
client: cli,
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateContainer creates a new container based on the given configuration.
|
// CreateContainer creates a new container based on the given configuration.
|
||||||
@@ -149,13 +174,17 @@ func (s *Server) ListContainers(ctx context.Context, req *pb.ListContainersReque
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, err.Error())
|
return nil, status.Errorf(codes.Internal, err.Error())
|
||||||
}
|
}
|
||||||
containers := make([]types.ContainerJSON, len(containerSummaries))
|
containers := make([]types.ContainerJSON, 0, len(containerSummaries))
|
||||||
for i, cs := range containerSummaries {
|
for _, cs := range containerSummaries {
|
||||||
c, err := s.client.ContainerInspect(ctx, cs.ID)
|
c, err := s.client.ContainerInspect(ctx, cs.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if client.IsErrNotFound(err) {
|
||||||
|
// The listed container may have been removed while we were inspecting other containers.
|
||||||
|
continue
|
||||||
|
}
|
||||||
return nil, status.Errorf(codes.Internal, "inspect container %s: %v", cs.ID, err)
|
return nil, status.Errorf(codes.Internal, "inspect container %s: %v", cs.ID, err)
|
||||||
}
|
}
|
||||||
containers[i] = c
|
containers = append(containers, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
containersBytes, err := json.Marshal(containers)
|
containersBytes, err := json.Marshal(containers)
|
||||||
@@ -239,3 +268,530 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateVolume creates a new volume with the given options.
|
||||||
|
func (s *Server) CreateVolume(ctx context.Context, req *pb.CreateVolumeRequest) (*pb.CreateVolumeResponse, error) {
|
||||||
|
var opts volume.CreateOptions
|
||||||
|
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vol, err := s.client.VolumeCreate(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
volBytes, err := json.Marshal(vol)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "marshal volume: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.CreateVolumeResponse{Volume: volBytes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListVolumes returns a list of all volumes matching the filter.
|
||||||
|
func (s *Server) ListVolumes(ctx context.Context, req *pb.ListVolumesRequest) (*pb.ListVolumesResponse, error) {
|
||||||
|
var opts volume.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.client.VolumeList(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.ListVolumesResponse{
|
||||||
|
Messages: []*pb.MachineVolumes{
|
||||||
|
{
|
||||||
|
Response: respBytes,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveVolume removes a volume with the given ID.
|
||||||
|
func (s *Server) RemoveVolume(ctx context.Context, req *pb.RemoveVolumeRequest) (*emptypb.Empty, error) {
|
||||||
|
if err := s.client.VolumeRemove(ctx, req.Id, req.Force); err != nil {
|
||||||
|
if client.IsErrNotFound(err) {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return &emptypb.Empty{}, 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,
|
||||||
|
Env: spec.Container.Env.ToSlice(),
|
||||||
|
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: "",
|
||||||
|
},
|
||||||
|
User: spec.Container.User,
|
||||||
|
}
|
||||||
|
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, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
mounts, err := ToDockerMounts(spec.Volumes, spec.Container.VolumeMounts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = s.verifyDockerVolumesExist(ctx, mounts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
Mounts: mounts,
|
||||||
|
PortBindings: portBindings,
|
||||||
|
Privileged: spec.Container.Privileged,
|
||||||
|
Resources: container.Resources{
|
||||||
|
NanoCPUs: spec.Container.Resources.CPU,
|
||||||
|
Memory: spec.Container.Resources.Memory,
|
||||||
|
MemoryReservation: spec.Container.Resources.MemoryReservation,
|
||||||
|
},
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if spec.Container.LogDriver != nil {
|
||||||
|
hostConfig.LogConfig = container.LogConfig{
|
||||||
|
Type: spec.Container.LogDriver.Name,
|
||||||
|
Config: spec.Container.LogDriver.Options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_spec) VALUES ($1, $2)`,
|
||||||
|
resp.ID, string(specBytes)); err != nil {
|
||||||
|
removeContainer()
|
||||||
|
return nil, status.Errorf(codes.Internal, "store container in database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.CreateContainerResponse{Response: respBytes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToDockerMounts(volumes []api.VolumeSpec, mounts []api.VolumeMount) ([]mount.Mount, error) {
|
||||||
|
normalisedVolumes := make([]api.VolumeSpec, len(volumes))
|
||||||
|
for i, v := range volumes {
|
||||||
|
normalisedVolumes[i] = v.SetDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
dockerMounts := make([]mount.Mount, 0, len(mounts))
|
||||||
|
for _, m := range mounts {
|
||||||
|
idx := slices.IndexFunc(normalisedVolumes, func(v api.VolumeSpec) bool {
|
||||||
|
return v.Name == m.VolumeName
|
||||||
|
})
|
||||||
|
if idx == -1 {
|
||||||
|
return nil, fmt.Errorf("volume mount references a volume that doesn't exist in the volumes spec: '%s'",
|
||||||
|
m.VolumeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
vol := normalisedVolumes[idx]
|
||||||
|
if err := vol.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid volume: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dm := mount.Mount{
|
||||||
|
Type: mount.Type(vol.Type),
|
||||||
|
Target: m.ContainerPath,
|
||||||
|
ReadOnly: m.ReadOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch vol.Type {
|
||||||
|
case api.VolumeTypeBind:
|
||||||
|
dm.Source = vol.BindOptions.HostPath
|
||||||
|
dm.BindOptions = toDockerBindOptions(vol.BindOptions)
|
||||||
|
case api.VolumeTypeVolume:
|
||||||
|
dm.Source = vol.DockerVolumeName()
|
||||||
|
dm.VolumeOptions = &mount.VolumeOptions{
|
||||||
|
NoCopy: vol.VolumeOptions.NoCopy,
|
||||||
|
Labels: vol.VolumeOptions.Labels,
|
||||||
|
Subpath: vol.VolumeOptions.SubPath,
|
||||||
|
DriverConfig: vol.VolumeOptions.Driver,
|
||||||
|
}
|
||||||
|
case api.VolumeTypeTmpfs:
|
||||||
|
dm.TmpfsOptions = vol.TmpfsOptions
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported volume type: '%s'", vol.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
dockerMounts = append(dockerMounts, dm)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dockerMounts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
|
||||||
|
if opts == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dockerOpts := &mount.BindOptions{
|
||||||
|
Propagation: opts.Propagation,
|
||||||
|
CreateMountpoint: opts.CreateHostPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch opts.Recursive {
|
||||||
|
case "disabled":
|
||||||
|
dockerOpts.NonRecursive = true
|
||||||
|
case "writable":
|
||||||
|
dockerOpts.ReadOnlyNonRecursive = true
|
||||||
|
case "readonly":
|
||||||
|
dockerOpts.ReadOnlyForceRecursive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return dockerOpts
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyDockerVolumesExist checks if the Docker named volumes referenced in the mounts exist on the machine.
|
||||||
|
func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mount) error {
|
||||||
|
for _, m := range mounts {
|
||||||
|
if m.Type != mount.TypeVolume {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: non-local volume drivers should likely be handled differently (needs proper investigation).
|
||||||
|
if _, err := s.client.VolumeInspect(ctx, m.Source); err != nil {
|
||||||
|
if client.IsErrNotFound(err) {
|
||||||
|
return status.Errorf(codes.NotFound, "volume '%s' not found", m.Source)
|
||||||
|
}
|
||||||
|
return status.Errorf(codes.Internal, "inspect volume '%s': %v", m.Source, err.Error())
|
||||||
|
}
|
||||||
|
// TODO: check if the volume driver and options are the same as in the mount and fail if not.
|
||||||
|
// Should we even ignore driver-specific options in the volume spec for externally managed volumes?
|
||||||
|
// Instead, just inspect the existing volume and construct the mount from it.
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectServiceContainer returns the container information and service specification that was used to create the
|
||||||
|
// container with the given ID.
|
||||||
|
func (s *Server) InspectServiceContainer(
|
||||||
|
ctx context.Context, req *pb.InspectContainerRequest,
|
||||||
|
) (*pb.ServiceContainer, error) {
|
||||||
|
ctr, err := s.client.ContainerInspect(ctx, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
if client.IsErrNotFound(err) {
|
||||||
|
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
ctrBytes, err := json.Marshal(ctr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var specBytes []byte
|
||||||
|
err = s.db.QueryRowContext(ctx, `SELECT service_spec FROM containers WHERE id = $1`, ctr.ID).Scan(&specBytes)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "service spec not found for container: '%s'", ctr.ID)
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, "get service spec for container '%s' from machine database: %v",
|
||||||
|
ctr.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.ServiceContainer{
|
||||||
|
Container: ctrBytes,
|
||||||
|
ServiceSpec: specBytes,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListServiceContainers returns all containers that belong to the service with the given name or ID.
|
||||||
|
// If req.ServiceId is empty, all service containers are returned.
|
||||||
|
func (s *Server) ListServiceContainers(
|
||||||
|
ctx context.Context, req *pb.ListServiceContainersRequest,
|
||||||
|
) (*pb.ListServiceContainersResponse, error) {
|
||||||
|
var opts container.ListOptions
|
||||||
|
if len(req.Options) > 0 {
|
||||||
|
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle filters separately because they implement custom JSON unmarshalling.
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(req.Options, &raw); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options to raw map: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filtersBytes, ok := raw["Filters"]; ok {
|
||||||
|
args, err := filters.FromJSON(string(filtersBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal filters: %v", err)
|
||||||
|
}
|
||||||
|
opts.Filters = args
|
||||||
|
} else {
|
||||||
|
opts.Filters = filters.NewArgs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only uncloud-managed containers that belong to some service.
|
||||||
|
opts.Filters.Add("label", api.LabelServiceID)
|
||||||
|
opts.Filters.Add("label", api.LabelManaged)
|
||||||
|
|
||||||
|
containerSummaries, err := s.client.ContainerList(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
containers := make([]*pb.ServiceContainer, 0, len(containerSummaries))
|
||||||
|
for _, cs := range containerSummaries {
|
||||||
|
if req.ServiceId != "" &&
|
||||||
|
cs.Labels[api.LabelServiceID] != req.ServiceId && cs.Labels[api.LabelServiceName] != req.ServiceId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ctr, err := s.client.ContainerInspect(ctx, cs.ID)
|
||||||
|
if err != nil {
|
||||||
|
if client.IsErrNotFound(err) {
|
||||||
|
// The listed container may have been removed while we were inspecting other containers.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, "inspect container %s: %v", cs.ID, err)
|
||||||
|
}
|
||||||
|
ctrBytes, err := json.Marshal(ctr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "marshal container: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var specBytes []byte
|
||||||
|
err = s.db.QueryRowContext(ctx, `SELECT service_spec FROM containers WHERE id = $1`, ctr.ID).Scan(&specBytes)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// If this happens, there is a bug in the code, or someone manually removed the container from the DB,
|
||||||
|
// or created a managed container out of band.
|
||||||
|
slog.Error("Service container not found in machine database.", "id", ctr.ID)
|
||||||
|
// Just ignore such a container to not fail the list operation as it's not easily recoverable.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, "get service spec for container '%s' from machine database: %v",
|
||||||
|
ctr.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
containers = append(containers, &pb.ServiceContainer{
|
||||||
|
Container: ctrBytes,
|
||||||
|
ServiceSpec: specBytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.ListServiceContainersResponse{
|
||||||
|
Messages: []*pb.MachineServiceContainers{
|
||||||
|
{
|
||||||
|
Containers: containers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveServiceContainer stops (kills after grace period) and removes a service container with the given ID.
|
||||||
|
// The difference between this method and RemoveContainer is that it also removes the container from the machine
|
||||||
|
// database.
|
||||||
|
func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveContainerRequest) (*emptypb.Empty, error) {
|
||||||
|
ctrID := req.Id
|
||||||
|
// If the ID is not a full Docker ID, inspect the container to get its full ID.
|
||||||
|
if !fullDockerIDRegex.MatchString(req.Id) {
|
||||||
|
ctr, err := s.client.ContainerInspect(ctx, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
if client.IsErrNotFound(err) {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
ctrID = ctr.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.RemoveContainer(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = s.db.ExecContext(ctx, `DELETE FROM containers WHERE id = $1`, ctrID); err != nil {
|
||||||
|
slog.Error("Failed to remove container from machine database.", "err", err, "id", ctrID)
|
||||||
|
// Do not return an error because the container has already been removed from the Docker daemon.
|
||||||
|
// The orphaned db record will be ignored and eventually cleaned up by the garbage collector.
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|||||||
+41
-31
@@ -5,32 +5,35 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/docker/docker/client"
|
|
||||||
"github.com/docker/go-connections/sockets"
|
|
||||||
"github.com/siderolabs/grpc-proxy/proxy"
|
|
||||||
"golang.org/x/sync/errgroup"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"uncloud/internal/corrosion"
|
|
||||||
"uncloud/internal/docker"
|
"github.com/docker/docker/client"
|
||||||
"uncloud/internal/fs"
|
"github.com/docker/go-connections/sockets"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/corrosion"
|
||||||
apiproxy "uncloud/internal/machine/api/proxy"
|
"github.com/psviderski/uncloud/internal/docker"
|
||||||
"uncloud/internal/machine/caddyfile"
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
"uncloud/internal/machine/cluster"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"uncloud/internal/machine/corroservice"
|
apiproxy "github.com/psviderski/uncloud/internal/machine/api/proxy"
|
||||||
machinedocker "uncloud/internal/machine/docker"
|
"github.com/psviderski/uncloud/internal/machine/caddyfile"
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||||
"uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/corroservice"
|
||||||
|
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/siderolabs/grpc-proxy/proxy"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -56,9 +59,9 @@ type Config struct {
|
|||||||
// DockerClient manages system and user containers using the local Docker daemon.
|
// DockerClient manages system and user containers using the local Docker daemon.
|
||||||
DockerClient *client.Client
|
DockerClient *client.Client
|
||||||
|
|
||||||
// CaddyfilePath specifies where the machine generates the Caddy reverse proxy configuration file for routing
|
// CaddyConfigPath specifies where the machine generates the Caddy reverse proxy configuration file for routing
|
||||||
// external traffic to service containers across the internal network. Default is DataDir/caddy/Caddyfile.
|
// external traffic to service containers across the internal network. Default is DataDir/caddy/caddy.json.
|
||||||
CaddyfilePath string
|
CaddyConfigPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDefaults returns a new Config with default values set where not provided.
|
// SetDefaults returns a new Config with default values set where not provided.
|
||||||
@@ -121,8 +124,8 @@ func (c *Config) SetDefaults() (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.CaddyfilePath == "" {
|
if cfg.CaddyConfigPath == "" {
|
||||||
cfg.CaddyfilePath = filepath.Join(cfg.DataDir, "caddy", "caddy.json")
|
cfg.CaddyConfigPath = filepath.Join(cfg.DataDir, "caddy", "caddy.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
@@ -208,7 +211,13 @@ func NewMachine(config *Config) (*Machine, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create Docker client: %w", err)
|
return nil, fmt.Errorf("create Docker client: %w", err)
|
||||||
}
|
}
|
||||||
dockerServer := machinedocker.NewServer(dockerCli)
|
|
||||||
|
dbFilePath := filepath.Join(config.DataDir, DBFileName)
|
||||||
|
db, err := NewDB(dbFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("init machine database: %w", err)
|
||||||
|
}
|
||||||
|
dockerServer := machinedocker.NewServer(dockerCli, db)
|
||||||
|
|
||||||
// Init a local gRPC proxy server that proxies requests to the local or remote machine API servers.
|
// Init a local gRPC proxy server that proxies requests to the local or remote machine API servers.
|
||||||
proxyDirector := apiproxy.NewDirector(config.MachineSockPath, APIPort)
|
proxyDirector := apiproxy.NewDirector(config.MachineSockPath, APIPort)
|
||||||
@@ -359,7 +368,7 @@ func (m *Machine) Run(ctx context.Context) error {
|
|||||||
|
|
||||||
// Create a new Caddyfile controller for managing the Caddy reverse proxy configuration.
|
// Create a new Caddyfile controller for managing the Caddy reverse proxy configuration.
|
||||||
// It will also serve the current machine ID at /.uncloud-verify to verify Caddy reachability.
|
// It will also serve the current machine ID at /.uncloud-verify to verify Caddy reachability.
|
||||||
caddyfileCtrl, err := caddyfile.NewController(m.store, m.config.CaddyfilePath, m.state.ID)
|
caddyfileCtrl, err := caddyfile.NewController(m.store, m.config.CaddyConfigPath, m.state.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create Caddyfile controller: %w", err)
|
return fmt.Errorf("create Caddyfile controller: %w", err)
|
||||||
}
|
}
|
||||||
@@ -540,7 +549,7 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
|
|||||||
}
|
}
|
||||||
publicIP, pubIPErr := network.GetPublicIP()
|
publicIP, pubIPErr := network.GetPublicIP()
|
||||||
// Ignore the error if failed to get the public IP using API services.
|
// Ignore the error if failed to get the public IP using API services.
|
||||||
if pubIPErr == nil {
|
if pubIPErr == nil && !slices.Contains(ips, publicIP) {
|
||||||
ips = append(ips, publicIP)
|
ips = append(ips, publicIP)
|
||||||
}
|
}
|
||||||
endpoints := make([]*pb.IPPort, len(ips))
|
endpoints := make([]*pb.IPPort, len(ips))
|
||||||
@@ -682,7 +691,7 @@ func (m *Machine) Token(_ context.Context, _ *emptypb.Empty) (*pb.TokenResponse,
|
|||||||
}
|
}
|
||||||
publicIP, err := network.GetPublicIP()
|
publicIP, err := network.GetPublicIP()
|
||||||
// Ignore the error if failed to get the public IP using API services.
|
// Ignore the error if failed to get the public IP using API services.
|
||||||
if err == nil {
|
if err == nil && !slices.Contains(ips, publicIP) {
|
||||||
ips = append(ips, publicIP)
|
ips = append(ips, publicIP)
|
||||||
}
|
}
|
||||||
endpoints := make([]netip.AddrPort, len(ips))
|
endpoints := make([]netip.AddrPort, len(ips))
|
||||||
@@ -743,10 +752,11 @@ func (m *Machine) InspectService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctr := api.ServiceContainer{Container: records[0].Container}
|
||||||
svc := &pb.Service{
|
svc := &pb.Service{
|
||||||
Id: records[0].Container.ServiceID(),
|
Id: ctr.ServiceID(),
|
||||||
Name: records[0].Container.ServiceName(),
|
Name: ctr.ServiceName(),
|
||||||
Mode: records[0].Container.ServiceMode(),
|
Mode: ctr.ServiceMode(),
|
||||||
Containers: containers,
|
Containers: containers,
|
||||||
}
|
}
|
||||||
return &pb.InspectServiceResponse{Service: svc}, nil
|
return &pb.InspectServiceResponse{Service: svc}, nil
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"uncloud/internal/machine/caddyfile"
|
"github.com/psviderski/uncloud/internal/machine/caddyfile"
|
||||||
"uncloud/internal/machine/corroservice"
|
"github.com/psviderski/uncloud/internal/machine/corroservice"
|
||||||
"uncloud/internal/machine/docker"
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package network
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"uncloud/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package network
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"uncloud/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MachineIP returns the IP address of the machine which is the first address in the subnet.
|
// MachineIP returns the IP address of the machine which is the first address in the subnet.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WireGuardNetwork struct {
|
type WireGuardNetwork struct {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
sq "github.com/Masterminds/squirrel"
|
sq "github.com/Masterminds/squirrel"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"uncloud/internal/api"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"google.golang.org/protobuf/encoding/protojson"
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"uncloud/internal/corrosion"
|
"github.com/psviderski/uncloud/internal/corrosion"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
+47
-11
@@ -4,17 +4,18 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
dockerclient "github.com/docker/docker/client"
|
dockerclient "github.com/docker/docker/client"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
"net/netip"
|
|
||||||
"time"
|
|
||||||
"uncloud/internal/machine"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
"uncloud/internal/machine/cluster"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -31,6 +32,29 @@ type CreateClusterOptions struct {
|
|||||||
Machines int
|
Machines int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Cluster) PopulateMachineIDs(ctx context.Context) error {
|
||||||
|
for i := range c.Machines {
|
||||||
|
if c.Machines[i].ID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cli, err := c.Machines[i].Connect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to machine '%s': %w", c.Machines[i].Name, err)
|
||||||
|
}
|
||||||
|
//goland:noinspection GoDeferInLoop
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
m, err := cli.Inspect(ctx, &emptypb.Empty{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect machine '%s': %w", c.Machines[i].Name, err)
|
||||||
|
}
|
||||||
|
c.Machines[i].ID = m.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Provisioner) CreateCluster(ctx context.Context, name string, opts CreateClusterOptions) (Cluster, error) {
|
func (p *Provisioner) CreateCluster(ctx context.Context, name string, opts CreateClusterOptions) (Cluster, error) {
|
||||||
var c Cluster
|
var c Cluster
|
||||||
|
|
||||||
@@ -74,12 +98,15 @@ func (p *Provisioner) CreateCluster(ctx context.Context, name string, opts Creat
|
|||||||
if err = p.initCluster(ctx, c.Machines); err != nil {
|
if err = p.initCluster(ctx, c.Machines); err != nil {
|
||||||
return c, err
|
return c, err
|
||||||
}
|
}
|
||||||
|
if err = c.PopulateMachineIDs(ctx); err != nil {
|
||||||
|
return c, fmt.Errorf("populate machine IDs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if p.configUpdater != nil {
|
if p.configUpdater != nil {
|
||||||
if err = p.configUpdater.AddCluster(c); err != nil {
|
if err = p.configUpdater.AddCluster(c); err != nil {
|
||||||
return c, fmt.Errorf("add cluster to Uncloud config: %w", err)
|
return c, fmt.Errorf("add cluster to Uncloud config: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("Cluster '%s' added to Uncloud config.\n", c.Name)
|
fmt.Printf("Cluster '%s' added to Uncloud config as the current context.\n", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
@@ -89,7 +116,7 @@ func (p *Provisioner) initCluster(ctx context.Context, machines []Machine) error
|
|||||||
// Init a new cluster on the first machine.
|
// Init a new cluster on the first machine.
|
||||||
initMachine := machines[0]
|
initMachine := machines[0]
|
||||||
|
|
||||||
if err := p.WaitMachineReady(ctx, initMachine, 30*time.Second); err != nil {
|
if err := WaitMachineReady(ctx, initMachine, 30*time.Second); err != nil {
|
||||||
return fmt.Errorf("wait for machine %q to be ready: %w", initMachine.Name, err)
|
return fmt.Errorf("wait for machine %q to be ready: %w", initMachine.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +138,7 @@ func (p *Provisioner) initCluster(ctx context.Context, machines []Machine) error
|
|||||||
|
|
||||||
// Join the rest of the machines to the cluster.
|
// Join the rest of the machines to the cluster.
|
||||||
for _, m := range machines[1:] {
|
for _, m := range machines[1:] {
|
||||||
if err = p.WaitMachineReady(ctx, m, 5*time.Second); err != nil {
|
if err = WaitMachineReady(ctx, m, 5*time.Second); err != nil {
|
||||||
return fmt.Errorf("wait for machine %q to be ready: %w", m.Name, err)
|
return fmt.Errorf("wait for machine %q to be ready: %w", m.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,13 +242,17 @@ func (p *Provisioner) InspectCluster(ctx context.Context, name string) (Cluster,
|
|||||||
c.Machines = append(c.Machines, m)
|
c.Machines = append(c.Machines, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = c.PopulateMachineIDs(ctx); err != nil {
|
||||||
|
return c, fmt.Errorf("populate machine IDs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitClusterReady waits for all machines in the cluster to be ready and UP.
|
// WaitClusterReady waits for all machines in the cluster to be ready and UP.
|
||||||
func (p *Provisioner) WaitClusterReady(ctx context.Context, c Cluster, timeout time.Duration) error {
|
func (p *Provisioner) WaitClusterReady(ctx context.Context, c Cluster, timeout time.Duration) error {
|
||||||
firstMachine := c.Machines[0]
|
firstMachine := c.Machines[0]
|
||||||
if err := p.WaitMachineReady(ctx, firstMachine, timeout); err != nil {
|
if err := WaitMachineReady(ctx, firstMachine, timeout); err != nil {
|
||||||
return fmt.Errorf("wait for machine '%s' to be ready: %w", firstMachine.Name, err)
|
return fmt.Errorf("wait for machine '%s' to be ready: %w", firstMachine.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +269,7 @@ func (p *Provisioner) WaitClusterReady(ctx context.Context, c Cluster, timeout t
|
|||||||
), ctx)
|
), ctx)
|
||||||
|
|
||||||
checkMachinesUp := func() error {
|
checkMachinesUp := func() error {
|
||||||
machines, err := cli.ListMachines(ctx)
|
machines, err := cli.ListMachines(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("list machines: %w", err)
|
return fmt.Errorf("list machines: %w", err)
|
||||||
}
|
}
|
||||||
@@ -278,7 +309,12 @@ func (p *Provisioner) RemoveCluster(ctx context.Context, name string) error {
|
|||||||
return fmt.Errorf("list Docker containers with cluster name '%s': %w", name, err)
|
return fmt.Errorf("list Docker containers with cluster name '%s': %w", name, err)
|
||||||
}
|
}
|
||||||
for _, c := range containers {
|
for _, c := range containers {
|
||||||
if err = p.dockerCli.ContainerRemove(ctx, c.ID, container.RemoveOptions{Force: true}); err != nil {
|
removeOpts := container.RemoveOptions{
|
||||||
|
// Remove anonymous volumes attached to the container (typically /var/lib/docker).
|
||||||
|
RemoveVolumes: true,
|
||||||
|
Force: true,
|
||||||
|
}
|
||||||
|
if err = p.dockerCli.ContainerRemove(ctx, c.ID, removeOpts); err != nil {
|
||||||
return fmt.Errorf("remove Docker container '%s': %w", c.ID, err)
|
return fmt.Errorf("remove Docker container '%s': %w", c.ID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-13
@@ -2,7 +2,8 @@ package ucind
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"uncloud/internal/cli/config"
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConfigUpdater struct {
|
type ConfigUpdater struct {
|
||||||
@@ -19,22 +20,22 @@ func (u *ConfigUpdater) AddCluster(c Cluster) error {
|
|||||||
return fmt.Errorf("read Uncloud config: %w", err)
|
return fmt.Errorf("read Uncloud config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := cfg.Clusters[c.Name]; ok {
|
if _, ok := cfg.Contexts[c.Name]; ok {
|
||||||
return fmt.Errorf("cluster '%s' already exists", c.Name)
|
return fmt.Errorf("cluster context '%s' already exists", c.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterCfg := &config.Cluster{
|
clusterCfg := &config.Context{
|
||||||
Name: c.Name,
|
Name: c.Name,
|
||||||
Connections: make([]config.MachineConnection, len(c.Machines)),
|
Connections: make([]config.MachineConnection, len(c.Machines)),
|
||||||
}
|
}
|
||||||
for i, m := range c.Machines {
|
for i, m := range c.Machines {
|
||||||
clusterCfg.Connections[i] = config.MachineConnection{
|
clusterCfg.Connections[i] = config.MachineConnection{
|
||||||
TCP: m.APIAddress,
|
TCP: &m.APIAddress,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.Clusters[c.Name] = clusterCfg
|
cfg.Contexts[c.Name] = clusterCfg
|
||||||
cfg.CurrentCluster = c.Name
|
cfg.CurrentContext = c.Name
|
||||||
|
|
||||||
if err = cfg.Save(); err != nil {
|
if err = cfg.Save(); err != nil {
|
||||||
return fmt.Errorf("save config: %w", err)
|
return fmt.Errorf("save config: %w", err)
|
||||||
@@ -48,17 +49,17 @@ func (u *ConfigUpdater) RemoveCluster(name string) error {
|
|||||||
return fmt.Errorf("read Uncloud config: %w", err)
|
return fmt.Errorf("read Uncloud config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := cfg.Clusters[name]; !ok {
|
if _, ok := cfg.Contexts[name]; !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(cfg.Clusters, name)
|
delete(cfg.Contexts, name)
|
||||||
|
|
||||||
if cfg.CurrentCluster == name {
|
if cfg.CurrentContext == name {
|
||||||
cfg.CurrentCluster = ""
|
cfg.CurrentContext = ""
|
||||||
}
|
}
|
||||||
if _, ok := cfg.Clusters["default"]; ok {
|
if _, ok := cfg.Contexts["default"]; ok {
|
||||||
cfg.CurrentCluster = "default"
|
cfg.CurrentContext = "default"
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = cfg.Save(); err != nil {
|
if err = cfg.Save(); err != nil {
|
||||||
|
|||||||
@@ -4,19 +4,20 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
dockerclient "github.com/docker/docker/client"
|
dockerclient "github.com/docker/docker/client"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/connector"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/netip"
|
|
||||||
"time"
|
|
||||||
"uncloud/internal/cli/client"
|
|
||||||
"uncloud/internal/cli/client/connector"
|
|
||||||
"uncloud/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -29,6 +30,7 @@ const (
|
|||||||
type Machine struct {
|
type Machine struct {
|
||||||
ClusterName string
|
ClusterName string
|
||||||
ContainerName string
|
ContainerName string
|
||||||
|
ID string
|
||||||
Name string
|
Name string
|
||||||
APIAddress netip.AddrPort
|
APIAddress netip.AddrPort
|
||||||
}
|
}
|
||||||
@@ -185,7 +187,7 @@ func randomMachineName() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WaitMachineReady waits for the machine API to respond.
|
// WaitMachineReady waits for the machine API to respond.
|
||||||
func (p *Provisioner) WaitMachineReady(ctx context.Context, m Machine, timeout time.Duration) error {
|
func WaitMachineReady(ctx context.Context, m Machine, timeout time.Duration) error {
|
||||||
cli, err := m.Connect(ctx)
|
cli, err := m.Connect(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to machine over TCP '%s': %w", m.APIAddress, err)
|
return fmt.Errorf("connect to machine over TCP '%s': %w", m.APIAddress, err)
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/volume"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client interface {
|
||||||
|
ContainerClient
|
||||||
|
DNSClient
|
||||||
|
ImageClient
|
||||||
|
MachineClient
|
||||||
|
ServiceClient
|
||||||
|
VolumeClient
|
||||||
|
}
|
||||||
|
|
||||||
|
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, filter *MachineFilter) (MachineMembersList, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceClient interface {
|
||||||
|
RunService(ctx context.Context, spec ServiceSpec) (RunServiceResponse, error)
|
||||||
|
InspectService(ctx context.Context, id string) (Service, error)
|
||||||
|
RemoveService(ctx context.Context, id string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type VolumeClient interface {
|
||||||
|
CreateVolume(ctx context.Context, machineNameOrID string, opts volume.CreateOptions) (MachineVolume, error)
|
||||||
|
ListVolumes(ctx context.Context, filter *VolumeFilter) ([]MachineVolume, error)
|
||||||
|
RemoveVolume(ctx context.Context, machineNameOrID, volumeName string, force bool) 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, MachineMembersList, error) {
|
||||||
|
machines, err := cli.ListMachines(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxiedMachines MachineMembersList
|
||||||
|
md := metadata.New(nil)
|
||||||
|
for _, m := range machines {
|
||||||
|
if len(namesOrIDs) == 0 ||
|
||||||
|
slices.Contains(namesOrIDs, m.Machine.Name) || slices.Contains(namesOrIDs, m.Machine.Id) {
|
||||||
|
proxiedMachines = append(proxiedMachines, m)
|
||||||
|
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
|
||||||
|
md.Append("machines", machineIP.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata.NewOutgoingContext(ctx, md), proxiedMachines, nil
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/docker/docker/api/types"
|
|
||||||
"github.com/docker/go-units"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/go-units"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -20,70 +22,6 @@ type Container struct {
|
|||||||
types.ContainerJSON
|
types.ContainerJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// NameWithoutSlash returns the container name without the leading slash.
|
|
||||||
// TODO: modify Name in original ContainerJSON structure when inspecting a Docker container and get rid of this method.
|
|
||||||
func (c *Container) NameWithoutSlash() string {
|
|
||||||
return c.Name[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceID returns the ID of the service this container belongs to.
|
|
||||||
func (c *Container) ServiceID() string {
|
|
||||||
return c.Config.Labels[LabelServiceID]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceName returns the name of the service this container belongs to.
|
|
||||||
func (c *Container) ServiceName() string {
|
|
||||||
return c.Config.Labels[LabelServiceName]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceMode returns the replication mode of the service this container belongs to.
|
|
||||||
func (c *Container) ServiceMode() string {
|
|
||||||
return c.Config.Labels[LabelServiceMode]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServicePorts returns the ports this container publishes as part of its service.
|
|
||||||
func (c *Container) ServicePorts() ([]PortSpec, error) {
|
|
||||||
encoded, ok := c.Config.Labels[LabelServicePorts]
|
|
||||||
if !ok {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(encoded) == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
publishPorts := strings.Split(encoded, ",")
|
|
||||||
ports := make([]PortSpec, len(publishPorts))
|
|
||||||
for i, p := range publishPorts {
|
|
||||||
port, err := ParsePortSpec(strings.TrimSpace(p))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ports[i] = port
|
|
||||||
}
|
|
||||||
|
|
||||||
return ports, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceSpec constructs a service spec from the container's configuration.
|
|
||||||
func (c *Container) ServiceSpec() (ServiceSpec, error) {
|
|
||||||
ports, err := c.ServicePorts()
|
|
||||||
if err != nil {
|
|
||||||
return ServiceSpec{}, fmt.Errorf("get service ports: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ServiceSpec{
|
|
||||||
Container: ContainerSpec{
|
|
||||||
Command: c.Config.Cmd,
|
|
||||||
Image: c.Config.Image,
|
|
||||||
Init: c.HostConfig.Init,
|
|
||||||
Volumes: c.HostConfig.Binds,
|
|
||||||
},
|
|
||||||
Mode: c.ServiceMode(),
|
|
||||||
Name: c.ServiceName(),
|
|
||||||
Ports: ports,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Healthy determines if the container is running and healthy.
|
// Healthy determines if the container is running and healthy.
|
||||||
// A running container with no health check configured is considered healthy.
|
// A running container with no health check configured is considered healthy.
|
||||||
func (c *Container) Healthy() bool {
|
func (c *Container) Healthy() bool {
|
||||||
@@ -152,8 +90,66 @@ func (c *Container) HumanState() (string, error) {
|
|||||||
c.State.ExitCode, units.HumanDuration(time.Now().UTC().Sub(finishedAt))), nil
|
c.State.ExitCode, units.HumanDuration(time.Now().UTC().Sub(finishedAt))), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Container) UnmarshalJSON(data []byte) error {
|
||||||
|
// A temporary type that's identical to Container but doesn't have the UnmarshalJSON method.
|
||||||
|
type ContainerAlias Container
|
||||||
|
|
||||||
|
var temp ContainerAlias
|
||||||
|
if err := json.Unmarshal(data, &temp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*c = Container(temp)
|
||||||
|
c.Name = strings.TrimPrefix(c.Name, "/")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceContainer struct {
|
||||||
|
Container
|
||||||
|
ServiceSpec ServiceSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceID returns the ID of the service this container belongs to.
|
||||||
|
func (c *ServiceContainer) ServiceID() string {
|
||||||
|
return c.Config.Labels[LabelServiceID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceName returns the name of the service this container belongs to.
|
||||||
|
func (c *ServiceContainer) ServiceName() string {
|
||||||
|
return c.Config.Labels[LabelServiceName]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceMode returns the replication mode of the service this container belongs to.
|
||||||
|
func (c *ServiceContainer) ServiceMode() string {
|
||||||
|
return c.Config.Labels[LabelServiceMode]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServicePorts returns the ports this container publishes as part of its service.
|
||||||
|
func (c *ServiceContainer) ServicePorts() ([]PortSpec, error) {
|
||||||
|
encoded, ok := c.Config.Labels[LabelServicePorts]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(encoded) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
publishPorts := strings.Split(encoded, ",")
|
||||||
|
ports := make([]PortSpec, len(publishPorts))
|
||||||
|
for i, p := range publishPorts {
|
||||||
|
port, err := ParsePortSpec(strings.TrimSpace(p))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ports[i] = port
|
||||||
|
}
|
||||||
|
|
||||||
|
return ports, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ConflictingServicePorts returns a list of service ports that conflict with the given ports.
|
// ConflictingServicePorts returns a list of service ports that conflict with the given ports.
|
||||||
func (c *Container) ConflictingServicePorts(ports []PortSpec) ([]PortSpec, error) {
|
func (c *ServiceContainer) ConflictingServicePorts(ports []PortSpec) ([]PortSpec, error) {
|
||||||
svcPorts, err := c.ServicePorts()
|
svcPorts, err := c.ServicePorts()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get service ports: %w", err)
|
return nil, fmt.Errorf("get service ports: %w", err)
|
||||||
@@ -1,60 +1,15 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"net/netip"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestContainer_ServiceSpec(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
init := true
|
|
||||||
ctr := &Container{ContainerJSON: types.ContainerJSON{
|
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
|
||||||
HostConfig: &container.HostConfig{
|
|
||||||
Binds: []string{"/host/path:/container/path"},
|
|
||||||
Init: &init,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Config: &container.Config{
|
|
||||||
Cmd: []string{"/app/server"},
|
|
||||||
Image: "app:latest",
|
|
||||||
Labels: map[string]string{
|
|
||||||
LabelServiceID: "test-service-id",
|
|
||||||
LabelServiceName: "test-service-name",
|
|
||||||
LabelServicePorts: "app.example.com:8000/https",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
|
|
||||||
expectedSpec := ServiceSpec{
|
|
||||||
Container: ContainerSpec{
|
|
||||||
Command: []string{"/app/server"},
|
|
||||||
Image: "app:latest",
|
|
||||||
Init: &init,
|
|
||||||
Volumes: []string{"/host/path:/container/path"},
|
|
||||||
},
|
|
||||||
Name: "test-service-name",
|
|
||||||
Ports: []PortSpec{
|
|
||||||
{
|
|
||||||
Hostname: "app.example.com",
|
|
||||||
ContainerPort: 8000,
|
|
||||||
Protocol: ProtocolHTTPS,
|
|
||||||
Mode: PortModeIngress,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
spec, err := ctr.ServiceSpec()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, reflect.DeepEqual(spec, expectedSpec))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContainer_Healthy(t *testing.T) {
|
func TestContainer_Healthy(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -325,13 +280,13 @@ func TestContainer_ConflictingServicePorts(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
ctr := &Container{ContainerJSON: types.ContainerJSON{
|
ctr := &ServiceContainer{Container: Container{ContainerJSON: types.ContainerJSON{
|
||||||
Config: &container.Config{
|
Config: &container.Config{
|
||||||
Labels: map[string]string{
|
Labels: map[string]string{
|
||||||
LabelServicePorts: tt.containerPorts,
|
LabelServicePorts: tt.containerPorts,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}}
|
||||||
|
|
||||||
got, err := ctr.ConflictingServicePorts(tt.checkPorts)
|
got, err := ctr.ConflictingServicePorts(tt.checkPorts)
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MachineImage struct {
|
||||||
|
Metadata *pb.Metadata
|
||||||
|
Image types.ImageInspect
|
||||||
|
}
|
||||||
|
|
||||||
|
// MachineRemoteImage represents an image in a remote registry fetched by a particular machine.
|
||||||
|
type MachineRemoteImage struct {
|
||||||
|
Metadata *pb.Metadata
|
||||||
|
Image RemoteImage
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteImage represents an image in a remote registry. The canonical reference includes the image digest.
|
||||||
|
// Either IndexManifest or ImageManifest must be set depending on whether the reference points to an index
|
||||||
|
// (multi-platform image) or a single-platform image.
|
||||||
|
type RemoteImage struct {
|
||||||
|
Reference reference.Canonical
|
||||||
|
IndexManifest *v1.Index
|
||||||
|
ImageManifest *v1.Manifest
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
|
||||||
|
// MachineFilter defines criteria to filter machines in ListMachines.
|
||||||
|
type MachineFilter struct {
|
||||||
|
// Available filters machines that are not DOWN.
|
||||||
|
Available bool
|
||||||
|
// NamesOrIDs filters machines by their names or IDs.
|
||||||
|
NamesOrIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineMembersList []*pb.MachineMember
|
||||||
|
|
||||||
|
func (m MachineMembersList) FindByManagementIP(ip string) *pb.MachineMember {
|
||||||
|
for _, machine := range m {
|
||||||
|
addr, err := machine.Machine.Network.ManagementIp.ToAddr()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if addr.String() == ip {
|
||||||
|
return machine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MachineMembersList) FindByNameOrID(nameOrID string) *pb.MachineMember {
|
||||||
|
for _, machine := range m {
|
||||||
|
if machine.Machine.Id == nameOrID || machine.Machine.Name == nameOrID {
|
||||||
|
return machine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Placement defines the placement constraints for service containers.
|
||||||
|
type Placement struct {
|
||||||
|
// Machines is a list of machine names or IDs where service containers are allowed to be deployed.
|
||||||
|
// If empty, containers can be deployed to any available machine in the cluster.
|
||||||
|
Machines []string `json:",omitempty"`
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -261,3 +262,31 @@ func validateHostname(hostname string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PortsEqual returns true if the two port sets are equal. The order of the ports is not important.
|
||||||
|
func PortsEqual(a, b []PortSpec) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
aSerialised := make([]string, len(a))
|
||||||
|
bSerialised := make([]string, len(b))
|
||||||
|
|
||||||
|
for i := range a {
|
||||||
|
aSerialised[i], err = a[i].String()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
bSerialised[i], err = b[i].String()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(aSerialised)
|
||||||
|
slices.Sort(bSerialised)
|
||||||
|
|
||||||
|
return slices.Equal(aSerialised, bSerialised)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
const (
|
||||||
|
MilliCore = 1_000_000
|
||||||
|
Core = 1000 * MilliCore
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContainerResources struct {
|
||||||
|
// CPU is the maximum amount of CPU nanocores (1000000000 = 1 CPU core) the container can use.
|
||||||
|
CPU int64
|
||||||
|
// Memory is the maximum amount of memory (in bytes) the container can use.
|
||||||
|
Memory int64
|
||||||
|
// MemoryReservation is the minimum amount of memory (in bytes) the container needs to run efficiently.
|
||||||
|
// TODO: implement a placement constraint that checks available memory on machines.
|
||||||
|
MemoryReservation int64
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
|
"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
|
||||||
|
// Placement defines the placement constraints for the service.
|
||||||
|
Placement Placement
|
||||||
|
// 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"`
|
||||||
|
// Volumes is list of data volumes that can be mounted into the container.
|
||||||
|
Volumes []VolumeSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceSpec) Volume(name string) (VolumeSpec, bool) {
|
||||||
|
for _, v := range s.Volumes {
|
||||||
|
if v.Name == name {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return VolumeSpec{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountedDockerVolumes returns the list of volumes of VolumeTypeVolume type that are mounted into the container.
|
||||||
|
func (s *ServiceSpec) MountedDockerVolumes() []VolumeSpec {
|
||||||
|
volumes := make(map[string]VolumeSpec)
|
||||||
|
for _, m := range s.Container.VolumeMounts {
|
||||||
|
if v, ok := s.Volume(m.VolumeName); ok && v.Type == VolumeTypeVolume {
|
||||||
|
volumes[v.Name] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slices.Collect(maps.Values(volumes))
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
for i, v := range spec.Volumes {
|
||||||
|
spec.Volumes[i] = v.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.
|
||||||
|
|
||||||
|
volumeNames := make(map[string]struct{})
|
||||||
|
for _, v := range s.Volumes {
|
||||||
|
if err := v.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid volume: %w", err)
|
||||||
|
}
|
||||||
|
if _, ok := volumeNames[v.Name]; ok {
|
||||||
|
return fmt.Errorf("duplicate volume name: '%s'", v.Name)
|
||||||
|
}
|
||||||
|
volumeNames[v.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range s.Container.VolumeMounts {
|
||||||
|
if !slices.ContainsFunc(s.Volumes, func(v VolumeSpec) bool {
|
||||||
|
return v.Name == m.VolumeName
|
||||||
|
}) {
|
||||||
|
return fmt.Errorf("volume mount references a volume that doesn't exist in the service spec: '%s'",
|
||||||
|
m.VolumeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
if s.Volumes != nil {
|
||||||
|
spec.Volumes = make([]VolumeSpec, len(s.Volumes))
|
||||||
|
for i, v := range s.Volumes {
|
||||||
|
spec.Volumes[i] = v.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
|
||||||
|
// Env defines the environment variables to set inside the container.
|
||||||
|
Env EnvVars
|
||||||
|
Image string
|
||||||
|
// Run a custom init inside the container. If nil, use the daemon's configured settings.
|
||||||
|
Init *bool
|
||||||
|
// LogDriver overrides the default logging driver for the container. Each Docker daemon can have its own default.
|
||||||
|
LogDriver *LogDriver
|
||||||
|
// Privileged gives extended privileges to the container. This is a security risk and should be used with caution.
|
||||||
|
Privileged 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
|
||||||
|
// Resource allocation for the container.
|
||||||
|
Resources ContainerResources
|
||||||
|
// User overrides the default user of the image used to run the container. Format: user|UID[:group|GID].
|
||||||
|
User string
|
||||||
|
// VolumeMounts specifies how volumes are mounted into the container filesystem.
|
||||||
|
// Each mount references a volume defined in ServiceSpec.Volumes.
|
||||||
|
VolumeMounts []VolumeMount
|
||||||
|
// Volumes is list of data volumes to mount into the container.
|
||||||
|
// TODO(lhf): delete all usage, has been replaced with []VolumeMounts.
|
||||||
|
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 '%s': %w", s.Image, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range s.VolumeMounts {
|
||||||
|
if err := m.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid volume mount: %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)
|
||||||
|
|
||||||
|
sortVolumeMounts(orig.VolumeMounts)
|
||||||
|
sortVolumeMounts(spec.VolumeMounts)
|
||||||
|
|
||||||
|
return cmp.Equal(orig, spec, cmpopts.EquateEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
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.LogDriver != nil {
|
||||||
|
logDriver := *s.LogDriver
|
||||||
|
if s.LogDriver.Options != nil {
|
||||||
|
logDriver.Options = maps.Clone(s.LogDriver.Options)
|
||||||
|
}
|
||||||
|
spec.LogDriver = &logDriver
|
||||||
|
}
|
||||||
|
if s.Volumes != nil {
|
||||||
|
spec.Volumes = make([]string, len(s.Volumes))
|
||||||
|
copy(spec.Volumes, s.Volumes)
|
||||||
|
}
|
||||||
|
if s.VolumeMounts != nil {
|
||||||
|
spec.VolumeMounts = make([]VolumeMount, len(s.VolumeMounts))
|
||||||
|
copy(spec.VolumeMounts, s.VolumeMounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvVars map[string]string
|
||||||
|
|
||||||
|
// ToSlice converts the environment variables to a slice of strings in the format "key=value".
|
||||||
|
func (e EnvVars) ToSlice() []string {
|
||||||
|
env := make([]string, 0, len(e))
|
||||||
|
for k, v := range e {
|
||||||
|
if k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
env = append(env, fmt.Sprintf("%s=%s", k, v))
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogDriver struct {
|
||||||
|
// Name of the logging driver to use.
|
||||||
|
Name string
|
||||||
|
// Options is the configuration options to pass to the logging driver.
|
||||||
|
Options map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunServiceResponse struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/mount"
|
||||||
|
"github.com/docker/docker/api/types/volume"
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// VolumeTypeBind is the type for mounting a host path.
|
||||||
|
VolumeTypeBind = "bind"
|
||||||
|
// VolumeTypeVolume is the type for mounting a named Docker volume.
|
||||||
|
VolumeTypeVolume = "volume"
|
||||||
|
// VolumeTypeTmpfs is the type for mounting a temporary file system stored in the host memory.
|
||||||
|
VolumeTypeTmpfs = "tmpfs"
|
||||||
|
|
||||||
|
// VolumeDriverLocal is the default volume driver for local named Docker volumes.
|
||||||
|
VolumeDriverLocal = "local"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VolumeSpec defines a volume mount specification. As of April 2025, the volume must be created before deploying
|
||||||
|
// a service using it.
|
||||||
|
type VolumeSpec struct {
|
||||||
|
// Name is the volume name used to reference this volume in container mounts.
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
BindOptions *BindOptions `json:",omitempty"`
|
||||||
|
TmpfsOptions *mount.TmpfsOptions `json:",omitempty"`
|
||||||
|
VolumeOptions *VolumeOptions `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindOptions represents options for a bind volume.
|
||||||
|
type BindOptions struct {
|
||||||
|
// HostPath is the absolute path on the host filesystem.
|
||||||
|
HostPath string
|
||||||
|
// CreateHostPath indicates whether the host path should be created if it doesn't exist.
|
||||||
|
// If false, deployment will fail if the path doesn't exist.
|
||||||
|
CreateHostPath bool `json:",omitempty"`
|
||||||
|
Propagation mount.Propagation `json:",omitempty"`
|
||||||
|
Recursive string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VolumeOptions represents options for a named Docker volume.
|
||||||
|
type VolumeOptions struct {
|
||||||
|
// Driver specifies the volume driver and its options for volume creation.
|
||||||
|
// TODO: It seems we don't really need Driver and Labels if we only support externally managed volumes.
|
||||||
|
// However we may need them in the future if we add support for isolated container-scoped volumes.
|
||||||
|
Driver *mount.Driver `json:",omitempty"`
|
||||||
|
// Labels are key-value metadata to apply to the volume if creating a new volume.
|
||||||
|
Labels map[string]string `json:",omitempty"`
|
||||||
|
// Name of the named Docker volume to use. If not specified, defaults to the VolumeSpec.Name.
|
||||||
|
Name string `json:",omitempty"`
|
||||||
|
// NoCopy prevents automatic copying of data from the container mount path to the volume.
|
||||||
|
NoCopy bool `json:",omitempty"`
|
||||||
|
// SubPath is the path within the volume to mount instead of its root.
|
||||||
|
SubPath string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VolumeSpec) DockerVolumeName() string {
|
||||||
|
if v.Type != VolumeTypeVolume {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v.VolumeOptions != nil && v.VolumeOptions.Name != "" {
|
||||||
|
return v.VolumeOptions.Name
|
||||||
|
}
|
||||||
|
return v.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VolumeSpec) SetDefaults() VolumeSpec {
|
||||||
|
spec := v.Clone()
|
||||||
|
|
||||||
|
if spec.Type == VolumeTypeVolume {
|
||||||
|
if spec.VolumeOptions == nil {
|
||||||
|
spec.VolumeOptions = &VolumeOptions{}
|
||||||
|
}
|
||||||
|
if spec.VolumeOptions.Name == "" {
|
||||||
|
spec.VolumeOptions.Name = spec.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: set explicit default values for Propagation and Recursive for bind mounts?
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VolumeSpec) Validate() error {
|
||||||
|
if v.Name == "" {
|
||||||
|
return fmt.Errorf("volume name must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v.Type {
|
||||||
|
case VolumeTypeBind:
|
||||||
|
if v.BindOptions == nil {
|
||||||
|
return fmt.Errorf("bind volume must have bind options")
|
||||||
|
}
|
||||||
|
case VolumeTypeVolume, VolumeTypeTmpfs:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid volume type: '%s', must be one of '%s', '%s', '%s')",
|
||||||
|
v.Type, VolumeTypeBind, VolumeTypeVolume, VolumeTypeTmpfs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VolumeSpec) Equals(other VolumeSpec) bool {
|
||||||
|
vol := v.SetDefaults()
|
||||||
|
other = other.SetDefaults()
|
||||||
|
|
||||||
|
return cmp.Equal(vol, other, cmpopts.EquateEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchesDockerVolume checks if this VolumeSpec is compatible with the given named Docker volume.
|
||||||
|
// In other words, it checks if the spec could be used to mount the volume.
|
||||||
|
func (v *VolumeSpec) MatchesDockerVolume(vol volume.Volume) bool {
|
||||||
|
if v.Type != VolumeTypeVolume {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
spec := v.SetDefaults()
|
||||||
|
|
||||||
|
if spec.DockerVolumeName() != vol.Name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The volume spec may not define the driver which means to use the default driver if creating a new volume
|
||||||
|
// or accept any driver when mounting an existing volume. If the driver is specified in the spec, the spec's
|
||||||
|
// driver and options must match the volume's driver and options.
|
||||||
|
if spec.VolumeOptions.Driver != nil {
|
||||||
|
volDriver := vol.Driver
|
||||||
|
if volDriver == "" {
|
||||||
|
volDriver = VolumeDriverLocal
|
||||||
|
}
|
||||||
|
|
||||||
|
if spec.VolumeOptions.Driver.Name != volDriver {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(spec.VolumeOptions.Driver.Options, vol.Options) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VolumeSpec) Clone() VolumeSpec {
|
||||||
|
spec := *v
|
||||||
|
|
||||||
|
if v.BindOptions != nil {
|
||||||
|
opts := *v.BindOptions
|
||||||
|
spec.BindOptions = &opts
|
||||||
|
}
|
||||||
|
|
||||||
|
if v.VolumeOptions != nil {
|
||||||
|
opts := *v.VolumeOptions
|
||||||
|
if v.VolumeOptions.Driver != nil {
|
||||||
|
driver := *v.VolumeOptions.Driver
|
||||||
|
if driver.Options != nil {
|
||||||
|
driver.Options = make(map[string]string, len(v.VolumeOptions.Driver.Options))
|
||||||
|
for k, val := range v.VolumeOptions.Driver.Options {
|
||||||
|
driver.Options[k] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
opts.Driver = &driver
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Labels != nil {
|
||||||
|
opts.Labels = make(map[string]string, len(v.VolumeOptions.Labels))
|
||||||
|
for k, val := range v.VolumeOptions.Labels {
|
||||||
|
opts.Labels[k] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spec.VolumeOptions = &opts
|
||||||
|
}
|
||||||
|
|
||||||
|
if v.TmpfsOptions != nil {
|
||||||
|
opts := *v.TmpfsOptions
|
||||||
|
opts.Options = make([][]string, len(v.TmpfsOptions.Options))
|
||||||
|
for i, opt := range v.TmpfsOptions.Options {
|
||||||
|
opts.Options[i] = make([]string, len(opt))
|
||||||
|
copy(opts.Options[i], opt)
|
||||||
|
}
|
||||||
|
spec.TmpfsOptions = &opts
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
// VolumeMount defines how a volume is mounted into a container.
|
||||||
|
type VolumeMount struct {
|
||||||
|
// VolumeName references a volume defined in ServiceSpec.Volumes by its Name field.
|
||||||
|
VolumeName string
|
||||||
|
// ContainerPath is the absolute path where the volume is mounted in the container.
|
||||||
|
ContainerPath string
|
||||||
|
// ReadOnly indicates whether the volume should be mounted read-only.
|
||||||
|
// If false (default), the volume is mounted read-write.
|
||||||
|
ReadOnly bool `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *VolumeMount) Validate() error {
|
||||||
|
if m.VolumeName == "" {
|
||||||
|
return fmt.Errorf("volume name must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(m.ContainerPath, "/") {
|
||||||
|
return fmt.Errorf("invalid container path: '%s', must be an absolute path in the container", m.ContainerPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortVolumeMounts(mounts []VolumeMount) {
|
||||||
|
sort.Slice(mounts, func(i, j int) bool {
|
||||||
|
if mounts[i].VolumeName != mounts[j].VolumeName {
|
||||||
|
return mounts[i].VolumeName < mounts[j].VolumeName
|
||||||
|
}
|
||||||
|
if mounts[i].ContainerPath != mounts[j].ContainerPath {
|
||||||
|
return mounts[i].ContainerPath < mounts[j].ContainerPath
|
||||||
|
}
|
||||||
|
if mounts[i].ReadOnly != mounts[j].ReadOnly {
|
||||||
|
if !mounts[i].ReadOnly {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MachineVolume represents a volume on a specific machine.
|
||||||
|
type MachineVolume struct {
|
||||||
|
// MachineID is the ID of the machine where the volume exists.
|
||||||
|
MachineID string
|
||||||
|
// MachineName is the name of the machine where the volume exists.
|
||||||
|
MachineName string
|
||||||
|
// Volume is the Docker volume model.
|
||||||
|
Volume volume.Volume
|
||||||
|
}
|
||||||
|
|
||||||
|
// VolumeFilter defines criteria to filter volumes in ListVolumes.
|
||||||
|
type VolumeFilter struct {
|
||||||
|
// Driver filters volumes by storage driver name.
|
||||||
|
Driver string
|
||||||
|
// Labels filters volumes by label key-value pairs. Volumes must match all labels.
|
||||||
|
Labels map[string]string
|
||||||
|
// MachineIDs filters volumes to those on the specified machines (names or IDs).
|
||||||
|
Machines []string
|
||||||
|
// Names filters volumes by name. Volumes must match one of the names.
|
||||||
|
Names []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchesFilter checks if a volume matches the specified filter criteria.
|
||||||
|
func (v *MachineVolume) MatchesFilter(filter *VolumeFilter) bool {
|
||||||
|
if filter == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Filter by name.
|
||||||
|
if len(filter.Names) > 0 && !slices.Contains(filter.Names, v.Volume.Name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Filter by driver.
|
||||||
|
if filter.Driver != "" && v.Volume.Driver != filter.Driver {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Filter by labels.
|
||||||
|
for key, value := range filter.Labels {
|
||||||
|
labelValue, exists := v.Volume.Labels[key]
|
||||||
|
if !exists || labelValue != value {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filter by machines.
|
||||||
|
if len(filter.Machines) > 0 {
|
||||||
|
if !slices.ContainsFunc(filter.Machines, func(nameOrID string) bool {
|
||||||
|
return v.MachineName == nameOrID || v.MachineID == nameOrID
|
||||||
|
}) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"github.com/Masterminds/semver"
|
"github.com/Masterminds/semver"
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/google/go-containerregistry/pkg/name"
|
"github.com/google/go-containerregistry/pkg/name"
|
||||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||||
"regexp"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"uncloud/internal/api"
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -21,8 +23,8 @@ var caddyImageTagRegex = regexp.MustCompile(`^2\.\d+\.\d+$`)
|
|||||||
// NewCaddyDeployment creates a new deployment for a Caddy reverse proxy service.
|
// NewCaddyDeployment creates a new deployment for a Caddy reverse proxy service.
|
||||||
// The service is deployed in global mode to all machines in the cluster. If the image is not provided, the latest
|
// The service is deployed in global mode to all machines in the cluster. If the image is not provided, the latest
|
||||||
// version of the official Caddy Docker image is used.
|
// version of the official Caddy Docker image is used.
|
||||||
func (cli *Client) NewCaddyDeployment(image string, filter MachineFilter) (*Deployment, error) {
|
func (cli *Client) NewCaddyDeployment(image string, placement api.Placement) (*deploy.Deployment, error) {
|
||||||
latest, err := latestCaddyImage()
|
latest, err := LatestCaddyImage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("look up latest Caddy image: %w", err)
|
return nil, fmt.Errorf("look up latest Caddy image: %w", err)
|
||||||
}
|
}
|
||||||
@@ -31,15 +33,20 @@ func (cli *Client) NewCaddyDeployment(image string, filter MachineFilter) (*Depl
|
|||||||
image = reference.FamiliarString(latest)
|
image = reference.FamiliarString(latest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: set restart policy to always. https://github.com/psviderski/uncloud/issues/26
|
|
||||||
spec := api.ServiceSpec{
|
spec := api.ServiceSpec{
|
||||||
Container: api.ContainerSpec{
|
Container: api.ContainerSpec{
|
||||||
Command: []string{"caddy", "run", "-c", "/config/caddy.json", "--watch"},
|
Command: []string{"caddy", "run", "-c", "/config/caddy.json", "--watch"},
|
||||||
Image: image,
|
Image: image,
|
||||||
Volumes: []string{"/var/lib/uncloud/caddy:/config"},
|
VolumeMounts: []api.VolumeMount{
|
||||||
|
{
|
||||||
|
VolumeName: "config",
|
||||||
|
ContainerPath: "/config",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Mode: api.ServiceModeGlobal,
|
Mode: api.ServiceModeGlobal,
|
||||||
Name: CaddyServiceName,
|
Name: CaddyServiceName,
|
||||||
|
Placement: placement,
|
||||||
Ports: []api.PortSpec{
|
Ports: []api.PortSpec{
|
||||||
{
|
{
|
||||||
PublishedPort: 80,
|
PublishedPort: 80,
|
||||||
@@ -54,14 +61,23 @@ func (cli *Client) NewCaddyDeployment(image string, filter MachineFilter) (*Depl
|
|||||||
Mode: api.PortModeHost,
|
Mode: api.PortModeHost,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Volumes: []api.VolumeSpec{
|
||||||
|
{
|
||||||
|
Name: "config",
|
||||||
|
Type: api.VolumeTypeBind,
|
||||||
|
BindOptions: &api.BindOptions{
|
||||||
|
HostPath: "/var/lib/uncloud/caddy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return cli.NewDeployment(spec, &RollingStrategy{MachineFilter: filter})
|
return cli.NewDeployment(spec, nil), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// latestCaddyImage returns the latest image of the official Caddy Docker image on Docker Hub.
|
// LatestCaddyImage returns the latest image of the official Caddy Docker image on Docker Hub.
|
||||||
// The latest image is determined by the latest version tag 2.x.x.
|
// The latest image is determined by the latest version tag 2.x.x.
|
||||||
func latestCaddyImage() (reference.NamedTagged, error) {
|
func LatestCaddyImage() (reference.NamedTagged, error) {
|
||||||
repo, err := name.NewRepository(CaddyImage)
|
repo, err := name.NewRepository(CaddyImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse image: %w", err)
|
return nil, fmt.Errorf("parse image: %w", err)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLatestCaddyImage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
image, err := LatestCaddyImage()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, reference.FamiliarString(image))
|
||||||
|
}
|
||||||
@@ -4,16 +4,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/docker/cli/cli/streams"
|
"github.com/docker/cli/cli/streams"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
"os"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
"uncloud/internal/machine/docker"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNotFound = errors.New("not found")
|
|
||||||
|
|
||||||
// Client is a client for the machine API.
|
// Client is a client for the machine API.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
connector Connector
|
connector Connector
|
||||||
@@ -26,6 +26,8 @@ type Client struct {
|
|||||||
Docker *docker.Client
|
Docker *docker.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ api.Client = (*Client)(nil)
|
||||||
|
|
||||||
// Connector is an interface for establishing a connection to the machine API.
|
// Connector is an interface for establishing a connection to the machine API.
|
||||||
type Connector interface {
|
type Connector interface {
|
||||||
Connect(ctx context.Context) (*grpc.ClientConn, error)
|
Connect(ctx context.Context) (*grpc.ClientConn, error)
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/compose-spec/compose-go/v2/graph"
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/docker/docker/api/types/volume"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client interface {
|
||||||
|
api.DNSClient
|
||||||
|
deploy.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type Deployment struct {
|
||||||
|
Client Client
|
||||||
|
Project *types.Project
|
||||||
|
SpecResolver *deploy.ServiceSpecResolver
|
||||||
|
state *scheduler.ClusterState
|
||||||
|
plan *deploy.SequenceOperation
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*Deployment, error) {
|
||||||
|
state, err := scheduler.InspectClusterState(ctx, cli)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("inspect cluster state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Deployment{
|
||||||
|
Client: cli,
|
||||||
|
Project: project,
|
||||||
|
SpecResolver: resolver,
|
||||||
|
state: state,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error) {
|
||||||
|
if d.plan != nil {
|
||||||
|
return *d.plan, nil
|
||||||
|
}
|
||||||
|
plan := deploy.SequenceOperation{}
|
||||||
|
|
||||||
|
// Generate service specs for all services in the project.
|
||||||
|
var serviceSpecs []api.ServiceSpec
|
||||||
|
var mu sync.Mutex
|
||||||
|
err := graph.InDependencyOrder(ctx, d.Project,
|
||||||
|
func(ctx context.Context, name string, _ types.ServiceConfig) error {
|
||||||
|
spec, err := d.ServiceSpec(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The graph is traversed concurrently, so we need to use a mutex to protect the shared slice.
|
||||||
|
mu.Lock()
|
||||||
|
serviceSpecs = append(serviceSpecs, spec)
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return plan, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check external volumes and plan the creation of missing volumes before deploying services.
|
||||||
|
volumeOps, err := d.planVolumes(serviceSpecs)
|
||||||
|
if err != nil {
|
||||||
|
return plan, err
|
||||||
|
}
|
||||||
|
for _, op := range volumeOps {
|
||||||
|
plan.Operations = append(plan.Operations, op)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, spec := range serviceSpecs {
|
||||||
|
// TODO: properly handle depends_on conditions in the service deployment plan as the first operation.
|
||||||
|
// Pass the update cluster state with scheduled volumes to the deployment.
|
||||||
|
deployment := deploy.NewDeployment(d.Client, spec, &deploy.RollingStrategy{State: d.state})
|
||||||
|
servicePlan, err := deployment.Plan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return plan, fmt.Errorf("create deployment plan for service '%s': %w", spec.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip no-op (up-to-date) service plans.
|
||||||
|
if len(servicePlan.Operations) > 0 {
|
||||||
|
plan.Operations = append(plan.Operations, &servicePlan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d.plan = &plan
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceSpec returns the service specification for the given compose service that is ready for deployment.
|
||||||
|
func (d *Deployment) ServiceSpec(name string) (api.ServiceSpec, error) {
|
||||||
|
spec, err := ServiceSpecFromCompose(d.Project, name)
|
||||||
|
if err != nil {
|
||||||
|
return spec, fmt.Errorf("convert compose service '%s' to service spec: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanVolumes checks if the external volumes exist and plans the creation of missing volumes.
|
||||||
|
func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*deploy.CreateVolumeOperation, error) {
|
||||||
|
if len(d.Project.Volumes) == 0 {
|
||||||
|
// No volumes to check or create.
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := d.checkExternalVolumesExist(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: The scheduler should ideally work with the resolved service specs to correctly identify eligible machines.
|
||||||
|
// Figure out where the best place to resolve the specs is.
|
||||||
|
volumeScheduler, err := scheduler.NewVolumeScheduler(d.state, serviceSpecs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("init volume scheduler: %w", err)
|
||||||
|
}
|
||||||
|
scheduledVolumes, err := volumeScheduler.Schedule()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("schedule volumes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate operations to create scheduled missing volumes.
|
||||||
|
var ops []*deploy.CreateVolumeOperation
|
||||||
|
for machineID, volumes := range scheduledVolumes {
|
||||||
|
for _, v := range volumes {
|
||||||
|
machineName := machineID
|
||||||
|
if m, ok := d.state.Machine(machineID); ok {
|
||||||
|
machineName = m.Info.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
ops = append(ops, &deploy.CreateVolumeOperation{
|
||||||
|
MachineID: machineID,
|
||||||
|
MachineName: machineName,
|
||||||
|
VolumeSpec: v,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkExternalVolumesExist checks that all external volumes exist in the cluster.
|
||||||
|
func (d *Deployment) checkExternalVolumesExist() error {
|
||||||
|
var externalNames []string
|
||||||
|
for _, v := range d.Project.Volumes {
|
||||||
|
if v.External {
|
||||||
|
externalNames = append(externalNames, v.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var notFound []string
|
||||||
|
for _, name := range externalNames {
|
||||||
|
if !slices.ContainsFunc(d.state.Machines, func(m *scheduler.Machine) bool {
|
||||||
|
return slices.ContainsFunc(m.Volumes, func(vol volume.Volume) bool {
|
||||||
|
return vol.Name == name
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
notFound = append(notFound, fmt.Sprintf("'%s'", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(notFound) > 0 {
|
||||||
|
return fmt.Errorf("external volumes not found: %s", strings.Join(notFound, ", "))
|
||||||
|
}
|
||||||
|
return 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,44 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FakeProjectName is a placeholder name for the project to be able to strip it from the resource names used as prefix.
|
||||||
|
const FakeProjectName = "f-a-k-e"
|
||||||
|
|
||||||
|
func LoadProject(ctx context.Context, paths []string) (*types.Project, error) {
|
||||||
|
options, err := composecli.NewProjectOptions(
|
||||||
|
paths,
|
||||||
|
composecli.WithName(FakeProjectName),
|
||||||
|
// 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,241 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/docker/docker/api/types/mount"
|
||||||
|
"github.com/opencontainers/go-digest"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.ServiceSpec, error) {
|
||||||
|
service, err := project.GetService(serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return api.ServiceSpec{}, fmt.Errorf("get config for compose service '%s': %w", serviceName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
env := make(map[string]string, len(service.Environment))
|
||||||
|
for k, v := range service.Environment {
|
||||||
|
if v == nil {
|
||||||
|
// nil value means the variable misses a value in the compose file, and it hasn't been resolved
|
||||||
|
// to a variable from the local environment running this code.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
env[k] = *v
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := api.ServiceSpec{
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Command: service.Command,
|
||||||
|
Entrypoint: service.Entrypoint,
|
||||||
|
Env: env,
|
||||||
|
Image: service.Image,
|
||||||
|
Init: service.Init,
|
||||||
|
Privileged: service.Privileged,
|
||||||
|
PullPolicy: pullPolicy,
|
||||||
|
Resources: resourcesFromCompose(service),
|
||||||
|
User: service.User,
|
||||||
|
},
|
||||||
|
Name: serviceName,
|
||||||
|
Mode: api.ServiceModeReplicated,
|
||||||
|
// TODO: implement and map x-machines to Placement.
|
||||||
|
}
|
||||||
|
|
||||||
|
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok {
|
||||||
|
spec.Ports = ports
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map LogDriver if specified
|
||||||
|
if service.Logging != nil && service.Logging.Driver != "" {
|
||||||
|
spec.Container.LogDriver = &api.LogDriver{
|
||||||
|
Name: service.Logging.Driver,
|
||||||
|
Options: service.Logging.Options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if service.Scale != nil {
|
||||||
|
spec.Replicas = uint(*service.Scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
if service.Deploy != nil {
|
||||||
|
switch service.Deploy.Mode {
|
||||||
|
case "global":
|
||||||
|
spec.Mode = api.ServiceModeGlobal
|
||||||
|
case "", "replicated":
|
||||||
|
if service.Deploy.Replicas != nil {
|
||||||
|
spec.Replicas = uint(*service.Deploy.Replicas)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return spec, fmt.Errorf("unsupported deploy mode: '%s'", service.Deploy.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: can service.tmpfs be handled as tmpfs volume mounts as well?
|
||||||
|
volumeSpecs, volumeMounts, err := volumeSpecsFromCompose(project.Volumes, service.Volumes)
|
||||||
|
if err != nil {
|
||||||
|
return spec, err
|
||||||
|
}
|
||||||
|
|
||||||
|
spec.Volumes = volumeSpecs
|
||||||
|
spec.Container.VolumeMounts = volumeMounts
|
||||||
|
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources {
|
||||||
|
resources := api.ContainerResources{
|
||||||
|
CPU: int64(service.CPUS * 1e9),
|
||||||
|
Memory: int64(service.MemLimit),
|
||||||
|
MemoryReservation: int64(service.MemReservation),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map resources from deploy section if specified.
|
||||||
|
if service.Deploy != nil {
|
||||||
|
if service.Deploy.Resources.Limits != nil {
|
||||||
|
if service.Deploy.Resources.Limits.NanoCPUs > 0 {
|
||||||
|
// It seems Limits.NanoCPUs is actually not nano CPUs but a CPU fraction.
|
||||||
|
resources.CPU = int64(service.Deploy.Resources.Limits.NanoCPUs * 1e9)
|
||||||
|
}
|
||||||
|
if service.Deploy.Resources.Limits.MemoryBytes > 0 {
|
||||||
|
resources.Memory = int64(service.Deploy.Resources.Limits.MemoryBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if service.Deploy.Resources.Reservations != nil {
|
||||||
|
if service.Deploy.Resources.Reservations.MemoryBytes > 0 {
|
||||||
|
resources.MemoryReservation = int64(service.Deploy.Resources.Reservations.MemoryBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resources
|
||||||
|
}
|
||||||
|
|
||||||
|
func volumeSpecsFromCompose(
|
||||||
|
volumes types.Volumes, serviceVolumes []types.ServiceVolumeConfig,
|
||||||
|
) ([]api.VolumeSpec, []api.VolumeMount, error) {
|
||||||
|
volumeSpecs := make(map[string]api.VolumeSpec)
|
||||||
|
var volumeMounts []api.VolumeMount
|
||||||
|
|
||||||
|
for _, serviceVolume := range serviceVolumes {
|
||||||
|
var volSpec api.VolumeSpec
|
||||||
|
|
||||||
|
switch serviceVolume.Type {
|
||||||
|
case types.VolumeTypeBind:
|
||||||
|
volSpec = bindVolumeSpecFromCompose(serviceVolume)
|
||||||
|
case types.VolumeTypeVolume:
|
||||||
|
volSpec = dockerVolumeSpecFromCompose(serviceVolume, volumes[serviceVolume.Source])
|
||||||
|
case types.VolumeTypeTmpfs:
|
||||||
|
volSpec = tmpfsVolumeSpecFromCompose(serviceVolume)
|
||||||
|
default:
|
||||||
|
return nil, nil, fmt.Errorf("unsupported volume type: '%s'", serviceVolume.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing, ok := volumeSpecs[volSpec.Name]; ok {
|
||||||
|
if !existing.Equals(volSpec) {
|
||||||
|
return nil, nil, fmt.Errorf("volume '%s' is used multiple times with different options", volSpec.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
volumeSpecs[volSpec.Name] = volSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
volumeMounts = append(volumeMounts, api.VolumeMount{
|
||||||
|
VolumeName: volSpec.Name,
|
||||||
|
ContainerPath: serviceVolume.Target,
|
||||||
|
ReadOnly: serviceVolume.ReadOnly,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return slices.Collect(maps.Values(volumeSpecs)), volumeMounts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig) api.VolumeSpec {
|
||||||
|
// compose-go parser deduplicates volumes by the target path so it's safe to use it as the unique name.
|
||||||
|
name := "bind-" + digest.SHA256.FromString(serviceVolume.Target).Encoded()
|
||||||
|
spec := api.VolumeSpec{
|
||||||
|
Name: name,
|
||||||
|
Type: api.VolumeTypeBind,
|
||||||
|
BindOptions: &api.BindOptions{
|
||||||
|
HostPath: serviceVolume.Source,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if serviceVolume.Bind != nil {
|
||||||
|
spec.BindOptions.CreateHostPath = serviceVolume.Bind.CreateHostPath
|
||||||
|
spec.BindOptions.Propagation = mount.Propagation(serviceVolume.Bind.Propagation)
|
||||||
|
spec.BindOptions.Recursive = serviceVolume.Bind.Recursive
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig, volume types.VolumeConfig) api.VolumeSpec {
|
||||||
|
spec := api.VolumeSpec{
|
||||||
|
Name: serviceVolume.Source,
|
||||||
|
Type: api.VolumeTypeVolume,
|
||||||
|
VolumeOptions: &api.VolumeOptions{
|
||||||
|
Name: strings.TrimPrefix(volume.Name, FakeProjectName+"_"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if serviceVolume.Volume != nil {
|
||||||
|
spec.VolumeOptions.NoCopy = serviceVolume.Volume.NoCopy
|
||||||
|
spec.VolumeOptions.SubPath = serviceVolume.Volume.Subpath
|
||||||
|
}
|
||||||
|
|
||||||
|
if !volume.External {
|
||||||
|
if volume.Driver != "" {
|
||||||
|
spec.VolumeOptions.Driver = &mount.Driver{
|
||||||
|
Name: volume.Driver,
|
||||||
|
Options: volume.DriverOpts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := mergeLabels(volume.Labels, volume.CustomLabels)
|
||||||
|
if len(labels) > 0 {
|
||||||
|
spec.VolumeOptions.Labels = labels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeLabels(labels ...types.Labels) types.Labels {
|
||||||
|
merged := types.Labels{}
|
||||||
|
for _, l := range labels {
|
||||||
|
for k, v := range l {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func tmpfsVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig) api.VolumeSpec {
|
||||||
|
// compose-go parser deduplicates volumes by the target path so it's safe to use it as the unique name.
|
||||||
|
name := "tmpfs-" + digest.SHA256.FromString(serviceVolume.Target).Encoded()
|
||||||
|
spec := api.VolumeSpec{
|
||||||
|
Name: name,
|
||||||
|
Type: api.VolumeTypeTmpfs,
|
||||||
|
TmpfsOptions: &mount.TmpfsOptions{
|
||||||
|
SizeBytes: int64(serviceVolume.Tmpfs.Size),
|
||||||
|
Mode: os.FileMode(serviceVolume.Tmpfs.Mode),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user