Compare commits

..
42 Commits
Author SHA1 Message Date
Pavel Sviderski c4398f9f06 refactor: deploy global service using Deployment with RollingStrategy 2025-02-14 20:45:23 +10:00
Pavel Sviderski a53fa2c1d8 feat: do not redeploy global service if spec hasn't changed 2025-02-14 17:49:46 +10:00
Pavel Sviderski 5e2b2ac836 refactor: api.Container to use the detailed ContainerJSON struct instead of Container (Summary) 2025-02-14 16:37:40 +10:00
Pavel Sviderski 43f28284f1 feat: rolling deploy strategy for global service, always recreate 2025-02-13 21:32:08 +10:00
Pavel Sviderski b17fd7531f feat: detect conflicing service port on a container 2025-02-13 11:50:34 +10:00
Pavel Sviderski 9859be50a6 feat: add Deployment, Strategy, Operation primitives to compose service deployments 2025-02-12 21:17:30 +10:00
Pavel Sviderski f209851986 feat: progress for removing service(s) 2025-02-11 21:21:50 +10:00
Pavel Sviderski 4da51636fd refactor: remove service with StopContainer and RemoveContainer 2025-02-11 20:43:31 +10:00
Pavel Sviderski 76a9f8e8b6 refactor: client StartContainer pass serviceID 2025-02-11 16:47:52 +10:00
Pavel Sviderski a477db6b16 refactor: client CreateContainer and StartContainer methods 2025-02-11 15:59:36 +10:00
Pavel Sviderski 04fd1b1425 feat: introduce client.Service entity for managing service containers 2025-02-10 20:21:42 +10:00
Pavel Sviderski c0bf1f2930 add contributors section to README, feature @cedws 2025-02-10 11:38:35 +10:00
Pavel Sviderski 0bf759333a fix: populate ssh_key_file in the config when adding machine to cluster 2025-02-10 09:14:54 +10:00
Pavel SviderskiandGitHub b482d836aa Merge pull request #21 from cedws/identityfile
feat: add ssh_key_file field to connections config
2025-02-10 09:03:13 +10:00
Connor Edwards 3d2d9b78e8 fix: rename IdentityFile to SSHKeyFile 2025-02-09 18:39:44 +00:00
Connor Edwards 99219dc376 fix: set identity_file in config after init 2025-02-08 15:59:05 +00:00
Connor Edwards 408703360c fix: use os.UserHomeDir() instead of $HOME 2025-02-08 15:56:18 +00:00
Connor Edwards ac48b8bee6 feat: add identity_file field to config connections config 2025-02-08 15:56:18 +00:00
Pavel SviderskiandGitHub e3a310397b Merge pull request #20 from cedws/init-19
fix: execute valid bash cmd when running init as root
2025-02-08 16:42:14 +10:00
Connor Edwards b2f7c6fb33 fix: execute valid bash cmd when running init as root 2025-02-07 21:14:27 +00:00
Pavel Sviderski 653f47f507 add clean up step to quick start 2025-02-07 14:53:30 +10:00
Pavel Sviderski 5b66cec627 verify checksum in install_cli.sh 2025-02-06 15:17:33 +10:00
Pavel Sviderski 0d9a2b6e07 add brew install command to README 2025-02-06 14:54:55 +10:00
Pavel Sviderski fb68303825 fix: cli install script, add manual instruction 2025-02-06 14:38:55 +10:00
Pavel Sviderski cfe5b018f3 fix: install.sh install command 2025-02-06 14:01:55 +10:00
Pavel Sviderski b451f2cf7c fix: install_cli.sh install command 2025-02-06 13:56:56 +10:00
Pavel Sviderski 7d25bbafdf fix: goreleaser format for homebrew 2025-02-06 13:51:15 +10:00
Pavel Sviderski 58e1c2eea3 goreleaser: specify id for brew 2025-02-06 13:21:52 +10:00
Pavel Sviderski 6c791840bd update token for brew release 2025-02-06 13:15:27 +10:00
Pavel Sviderski 3af97306e1 add homebrew release to goreleaser 2025-02-06 13:09:34 +10:00
Pavel Sviderski d645958631 make DNS A record creation a separate step in quick start 2025-02-06 08:42:39 +10:00
Pavel Sviderski 48bc94828e link design doc 2025-02-05 21:35:16 +10:00
Pavel Sviderski a43f4bf5df minor README 2025-02-05 21:01:55 +10:00
Pavel Sviderski 22f705d6c9 update image name on landing 2025-02-05 20:48:02 +10:00
Pavel Sviderski efcb9ee947 add How it works section to README 2025-02-05 20:47:53 +10:00
Pavel Sviderski a347b32b65 fix typo 2025-02-03 11:55:03 +10:00
Pavel Sviderski 7e865b61c2 update get started links on landing 2025-02-03 11:48:11 +10:00
Pavel Sviderski b959f5d9f3 remove duplicate feature 2025-02-02 22:17:51 +10:00
Pavel Sviderski df64a00813 header padding 2025-02-02 22:15:48 +10:00
Pavel Sviderski 07dc5c373d update README: header, features, why uncloud 2025-02-02 22:12:42 +10:00
Pavel SviderskiandGitHub a6e8616b36 Update curl url in README.md 2025-01-30 15:14:07 +10:00
Pavel Sviderski 7041244728 update curl | sh url 2025-01-24 22:49:57 +10:00
40 changed files with 2350 additions and 614 deletions
+22 -3
View File
@@ -40,7 +40,9 @@ archives:
- id: uncloud - id: uncloud
builds: builds:
- uncloud - uncloud
format: gz formats:
# gz is not compatible with homebrew releases, so we use tar.gz
- tar.gz
name_template: >- name_template: >-
{{ .Binary }}_ {{ .Binary }}_
{{- if eq .Os "darwin"}}macos {{- if eq .Os "darwin"}}macos
@@ -49,14 +51,16 @@ archives:
# use zip for windows archives # use zip for windows archives
format_overrides: format_overrides:
- goos: windows - goos: windows
format: zip formats:
- zip
files: files:
- none* - none*
- id: uncloudd - id: uncloudd
builds: builds:
- uncloudd - uncloudd
format: gz formats:
- tar.gz
name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}" name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}"
files: files:
- none* - none*
@@ -70,3 +74,18 @@ changelog:
exclude: exclude:
- "^docs:" - "^docs:"
- "^test:" - "^test:"
brews:
- name: uncloud
description: "Uncloud CLI"
homepage: "https://uncloud.run"
ids:
- uncloud
install: |
bin.install "uncloud"
bin.install_symlink "uncloud" => "uc"
skip_upload: false
repository:
owner: psviderski
name: homebrew-tap
token: "{{ .Env.GITHUB_TOKEN_HOMEBREW_TAP }}"
+158 -17
View File
@@ -1,16 +1,76 @@
<div align="center"> <div align="center">
<img src="./website/images/logo.svg" height="100" alt="Uncloud logo"/> <img src="./website/images/logo.svg" height="100" alt="Uncloud logo"/>
<h1>Uncloud</h2> <h1>Uncloud</h1>
A lightweight Docker clustering tool for running web apps on your own servers — from cloud VMs to bare metal.<br> <p><strong>Docker simplicity. Multi-machine power.</strong></p>
Replace Heroku and Render, no Kubernetes required.
</div> </div>
## Quick start Uncloud is a lightweight clustering and container orchestration tool that lets you deploy and manage web apps across
cloud VMs and bare metal with minimized cluster management overhead. It creates a secure WireGuard mesh network between
your Docker hosts and provides automatic service discovery, load balancing, ingress with HTTPS, and simple CLI commands
to manage your apps.
Unlike traditional orchestrators, there's no central control plane and quorum to maintain. Each machine maintains a
synchronized copy of the cluster state through peer-to-peer communication, keeping cluster operations functional even if
some machines go offline.
Uncloud aims to be the solution for developers who want the flexibility of self-hosted infrastructure without the
operational complexity of Kubernetes.
## ✨ Features
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment —
regardless of location or provider.
* **Zero-config private network**: Automatic WireGuard mesh with peer discovery and NAT traversal. Containers get unique
IPs for direct cross-machine communication.
* **No control plane**: Fully decentralized design eliminates single points of failure and reduces operational overhead.
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
model and troubleshooting.
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
Encrypt.
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
Coming soon:
* Project isolation through environments/namespaces
* Infrastructure as Code using Docker Compose format
* Persistent volumes and secrets management
* Monitoring and log aggregation
* Database deployment and management
* Curated application catalog
Here is a diagram of an Uncloud multi-provider cluster of 3 machines:
![Diagram: multi-provider cluster of 3 machines](website/images/diagram.webp)
## 💫 Why Uncloud?
Modern cloud platforms like Heroku and Render offer amazing developer experiences but at a premium price. Traditional
container orchestrators like Kubernetes provide power and flexibility but require significant operational expertise. I
believe there's a sweet spot in between — a pragmatic solution for the majority of us who aren't running at Google
scale. You should be able to:
* **Own your infrastructure and data**: Whether driven by costs, compliance, or flexibility, run applications on any
combination of cloud VMs and personal hardware while maintaining the cloud-like experience you love.
* **Stay simple**: Don't worry about control planes, highly-available clusters, or complex YAML configurations for
common use cases.
* **Build with proven primitives**: Get production-grade networking, deployment primitives, service discovery, load
balancing, and ingress with HTTPS out of the box without becoming a distributed systems expert.
* **Support sustainable computing** 🌿: Minimize system overhead to maximize resources available for your applications.
Uncloud's goal is to make deployment and management of containerized applications feel as seamless as using a cloud
platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bare metal servers.
## 🚀 Quick start
1. Install Uncloud CLI: 1. Install Uncloud CLI:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/psviderski/uncloud/main/scripts/install_cli.sh | sh brew install psviderski/tap/uncloud
# or using curl (macOS/Linux)
curl -fsS https://get.uncloud.run/install.sh | sh
``` ```
2. Initialize your first machine: 2. Initialize your first machine:
@@ -19,26 +79,107 @@ curl -fsSL https://raw.githubusercontent.com/psviderski/uncloud/main/scripts/ins
uc machine init root@your-server-ip uc machine init root@your-server-ip
``` ```
3. Create a DNS A record pointing `app.example.com` to your server's IP address, then deploy your app with 3. Create a DNS A record in your DNS provider (Cloudflare, Namecheap, etc.) that points `app.example.com` to your
automatic HTTPS: server's IP address. Allow a few minutes for DNS propagation.
4. Deploy your app from a Docker image:
```bash ```bash
uc run --name my-app -p app.example.com:8000/https registry/app uc run -p app.example.com:8000/https my-app-image
``` ```
That's it! Your app is now running and accessible at https://app.example.com ✨ That's it! Your app is now running and accessible at https://app.example.com ✨
## Project status 5. Clean up when you're done:
Uncloud is an open source project I'm ([@psviderski](https://github.com/psviderski)) actively developing. I'd love to ```bash
share this journey with you. [Subscribe](https://uncloud.run/) to follow the progress, get early insights into new uc ls
# Copy the service ID from the output and remove it:
uc rm my-app-id
```
## ⚙️ How it works
Check out the [design document](docs/design.md) to understand Uncloud's design philosophy and goals. Here, let's peek
under the hood to see what happens when you run certain commands.
**When you initialize a new cluster on a machine:**
```bash
uc machine init root@your-server-ip
```
1. The CLI SSHs into the machine and installs Docker, the `uncloudd` machine daemon and
[corrosion](https://github.com/superfly/corrosion) service, managed by systemd.
2. Generates a unique WireGuard key pair, allocates a dedicated subnet `10.210.0.0/24` for the machine and its
containers, and configures `uncloudd` accordingly. All subsequent communication happens with `uncloudd`
through its gRPC API over SSH.
3. Configures and starts `corrosion`, a CRDT-based distributed SQLite database to share cluster state between machines.
4. Creates a Docker bridge network connected to the WireGuard interface.
5. This machine becomes an entry point for the newly created cluster which is stored in the cluster config under
`~/.config/uncloud` on your local machine.
**When you add another machine:**
```bash
uc machine add ubuntu@second-server-ip
```
1. The second machine gets provisioned just like the first. A non-root SSH user will need `sudo` access.
2. Allocates a new subnet `10.210.1.0/24` for the second machine and its containers.
3. Registers the second machine in the cluster state and exchanges WireGuard keys with the first machine.
4. Both machines establish a WireGuard tunnel between each other, allowing Docker containers connected to the bridge
network to communicate directly across machines.
5. Configures and starts `corrosion` on the second machine to sync the cluster state.
6. The second machine is added as an alternative entry point in the cluster config.
7. If one of the machines goes offline, the other machine can still serve cluster operations.
If one more machine is added, the process repeats with a new subnet. The new machine needs to establish a WireGuard
connection with only one of the existing machines. Other machines will learn about it through the shared cluster state
and automatically establish a WireGuard tunnel with it.
**When you run a service:**
```bash
uc run -p app.example.com:8000/https my-app-image
```
1. CLI picks a machine to run your container.
2. `uncloudd` that the CLI communicates with uses [`grpc-proxy`](https://github.com/siderolabs/grpc-proxy) to forward
the request to the target machine to launch a container there.
3. `uncloudd` on the target machine starts the Docker container in the bridge network and stores its info in the
cluster's distributed state.
4. The container gets a cluster-unique IP address from the bridge network (in the `10.210.X.2-254` range) and becomes
accessible from other machines in the cluster.
5. Caddy reverse proxy which runs in [`global`](https://github.com/compose-spec/compose-spec/blob/main/deploy.md#mode)
mode on each machine watches the cluster state for new services and updates its configuration to route traffic to the
new container.
Look ma, no control plane or master nodes to maintain! Just a simple overlay network and eventually consistent state
sync that lets machines work together. Want to check on things or make changes? Connect to any machine either implicitly
using the CLI or directly over SSH. They all have the complete cluster state and can control everything. It's like each
machine is a full backup of your control plane.
## 🏗 Project status
Uncloud is currently in active development and is **not ready for production use**. Features may change significantly
and there may be breaking changes between releases.
I'd love your input! Here's how you can contribute:
* 🐛 Found a bug? [Open an issue](https://github.com/psviderski/uncloud/issues)
* 💡 Have ideas? [Join the discussion](https://github.com/psviderski/uncloud/discussions)
## 📫 Stay updated
* [Subscribe](https://uncloud.run/#subscribe) to my newsletter to follow the progress, get early insights into new
features, and be the first to know when it's ready for production use. features, and be the first to know when it's ready for production use.
* Watch this repository for releases.
* Follow [@psviderski](https://github.com/psviderski) on GitHub.
I'd also love your input: ## ❤️ Contributors
- 🐛 Found a bug? [Open an issue](https://github.com/psviderski/uncloud/issues) Thank you [@cedws](https://github.com/cedws) for being the first contributor to Uncloud! 🎉
- 💡 Have ideas? [Join the discussion](https://github.com/psviderski/uncloud/discussions)
## Motivation <a href="https://github.com/psviderski/uncloud/graphs/contributors">
<img src="https://contrib.rocks/image?repo=psviderski/uncloud" />
TBD </a>
+9 -2
View File
@@ -70,13 +70,20 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
} }
for _, ctr := range svc.Containers { for _, ctr := range svc.Containers {
createdAt := time.Unix(ctr.Container.Created, 0) createdAt, err := time.Parse(time.RFC3339Nano, ctr.Container.Created)
if err != nil {
return fmt.Errorf("parse created time: %w", err)
}
created := units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago" created := units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
machine := machinesNamesByID[ctr.MachineID] machine := machinesNamesByID[ctr.MachineID]
if machine == "" { if machine == "" {
machine = ctr.MachineID machine = ctr.MachineID
} }
state, err := ctr.Container.HumanState()
if err != nil {
return fmt.Errorf("get human state: %w", err)
}
_, err = fmt.Fprintf( _, err = fmt.Fprintf(
tw, tw,
@@ -84,7 +91,7 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
stringid.TruncateID(ctr.Container.ID), stringid.TruncateID(ctr.Container.ID),
ctr.Container.Image, ctr.Container.Image,
created, created,
ctr.Container.Status, state,
machine, machine,
) )
if err != nil { if err != nil {
+7 -2
View File
@@ -3,7 +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/spf13/cobra" "github.com/spf13/cobra"
"os"
"uncloud/internal/cli" "uncloud/internal/cli"
) )
@@ -40,10 +43,12 @@ func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
defer client.Close() defer client.Close()
for _, s := range opts.services { for _, s := range opts.services {
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if err = client.RemoveService(ctx, s); err != nil { if err = client.RemoveService(ctx, s); err != nil {
return fmt.Errorf("remove service %q: %w", s, err) return fmt.Errorf("remove service '%s': %w", s, err)
} }
fmt.Printf("Service %q removed.\n", s) return nil
}, streams.NewOut(os.Stdout), "Removing service "+s)
} }
return nil return nil
+1
View File
@@ -10,6 +10,7 @@ func NewRootCommand() *cobra.Command {
Short: "Manage services in an Uncloud cluster.", Short: "Manage services in an Uncloud cluster.",
} }
cmd.AddCommand( cmd.AddCommand(
NewInspectCommand(),
NewListCommand(), NewListCommand(),
NewRmCommand(), NewRmCommand(),
NewRunCommand(), NewRunCommand(),
+2
View File
@@ -57,6 +57,7 @@ require (
github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Masterminds/sprig/v3 v3.2.3 // indirect github.com/Masterminds/sprig/v3 v3.2.3 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/OneOfOne/xxhash v1.2.8 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
github.com/armon/go-metrics v0.4.1 // indirect github.com/armon/go-metrics v0.4.1 // indirect
@@ -79,6 +80,7 @@ require (
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/compose-spec/compose-go/v2 v2.4.5 // 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
+12 -2
View File
@@ -45,8 +45,9 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Microsoft/hcsshim v0.12.5 h1:bpTInLlDy/nDRWFVcefDZZ1+U8tS+rz3MxjKgu9boo0= github.com/Microsoft/hcsshim v0.12.5 h1:bpTInLlDy/nDRWFVcefDZZ1+U8tS+rz3MxjKgu9boo0=
github.com/Microsoft/hcsshim v0.12.5/go.mod h1:tIUGego4G1EN5Hb6KC90aDYiUI2dqLSTTOCjVNpOgZ8= github.com/Microsoft/hcsshim v0.12.5/go.mod h1:tIUGego4G1EN5Hb6KC90aDYiUI2dqLSTTOCjVNpOgZ8=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs=
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
@@ -166,8 +167,9 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e85keuznYcH5rqI438v41pKcBl4ZxQ=
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
github.com/cloudflare/cfssl v1.6.4 h1:NMOvfrEjFfC63K3SGXgAnFdsgkmiq4kATme5BfcqrO8=
github.com/cloudflare/cfssl v1.6.4/go.mod h1:8b3CQMxfWPAeom3zBnGJ6sd+G1NkL5TXqmDXacb+1J0=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
@@ -591,6 +593,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.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
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=
@@ -1058,6 +1062,8 @@ github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsX
github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y= github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
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/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
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=
@@ -1070,6 +1076,10 @@ github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCR
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc h1:zkGwegkOW709y0oiAraH/3D8njopUR/pARHv4tZZ6pw=
github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc/go.mod h1:FM4U1E3NzlNMRnSUTU3P1UdukWhYGifqEsjk9fn7BCk=
github.com/zmap/zlint/v3 v3.1.0 h1:WjVytZo79m/L1+/Mlphl09WBob6YTGljN5IGWZFpAv0=
github.com/zmap/zlint/v3 v3.1.0/go.mod h1:L7t8s3sEKkb0A2BxGy1IWrxt1ZATa1R4QfJZaQOD3zU=
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
+125 -27
View File
@@ -1,9 +1,11 @@
package api package api
import ( import (
"fmt"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"regexp" "github.com/docker/go-units"
"strings" "strings"
"time"
) )
const ( const (
@@ -15,30 +17,38 @@ const (
) )
type Container struct { type Container struct {
types.Container types.ContainerJSON
}
// NameWithoutSlash returns the container name without the leading slash.
func (c *Container) NameWithoutSlash() string {
return c.Name[1:]
} }
// ServiceID returns the ID of the service this container belongs to. // ServiceID returns the ID of the service this container belongs to.
func (c *Container) ServiceID() string { func (c *Container) ServiceID() string {
return c.Labels[LabelServiceID] return c.Config.Labels[LabelServiceID]
} }
// ServiceName returns the name of the service this container belongs to. // ServiceName returns the name of the service this container belongs to.
func (c *Container) ServiceName() string { func (c *Container) ServiceName() string {
return c.Labels[LabelServiceName] return c.Config.Labels[LabelServiceName]
} }
// ServiceMode returns the replication mode of the service this container belongs to. // ServiceMode returns the replication mode of the service this container belongs to.
func (c *Container) ServiceMode() string { func (c *Container) ServiceMode() string {
return c.Labels[LabelServiceMode] return c.Config.Labels[LabelServiceMode]
} }
// ServicePorts returns the ports this container publishes as part of its service. // ServicePorts returns the ports this container publishes as part of its service.
func (c *Container) ServicePorts() ([]PortSpec, error) { func (c *Container) ServicePorts() ([]PortSpec, error) {
encoded, ok := c.Labels[LabelServicePorts] encoded, ok := c.Config.Labels[LabelServicePorts]
if !ok { if !ok {
return nil, nil return nil, nil
} }
if strings.TrimSpace(encoded) == "" {
return nil, nil
}
publishPorts := strings.Split(encoded, ",") publishPorts := strings.Split(encoded, ",")
ports := make([]PortSpec, len(publishPorts)) ports := make([]PortSpec, len(publishPorts))
@@ -53,34 +63,122 @@ func (c *Container) ServicePorts() ([]PortSpec, error) {
return ports, nil return ports, nil
} }
// runningStatusRegex matches the status string of a running container. // ServiceSpec constructs a service spec from the container's configuration.
// - "Up 3 minutes (healthy)" -> groups: ["Up 3 minutes (healthy)", "healthy"] func (c *Container) ServiceSpec() (ServiceSpec, error) {
// - "Up 5 seconds" -> groups: ["Up 5 seconds", ""] ports, err := c.ServicePorts()
// - "Up 2 hours (unhealthy)" -> groups: ["Up 2 hours (unhealthy)", "unhealthy"] if err != nil {
// - "Up 1 minute (health: starting)" -> groups: ["Up 1 minute (health: starting)", "health: starting"] return ServiceSpec{}, fmt.Errorf("get service ports: %w", err)
// - "Restarting (0) 5 seconds ago" -> no match }
// See https://github.com/moby/moby/blob/c130ce1f5d1e38b98a97044a39557de43bc0d58f/container/state.go#L77-L90
// for more details on how the status string for a running container is formatted.
var runningStatusRegex = regexp.MustCompile(`^Up [^(]+(?:\(([^)]+)\))?$`)
// Healthy determines if the container is running and healthy based on its status string. return ServiceSpec{
Container: ContainerSpec{
Command: c.Config.Cmd,
Image: c.Config.Image,
Init: c.HostConfig.Init,
Volumes: c.HostConfig.Binds,
},
Mode: c.ServiceMode(),
Name: c.ServiceName(),
Ports: ports,
}, nil
}
// Healthy determines if the container is running and healthy.
// A running container with no health check configured is considered healthy. // A running container with no health check configured is considered healthy.
func (c *Container) Healthy() bool { func (c *Container) Healthy() bool {
if c.State != "running" { if !c.State.Running || c.State.Paused || c.State.Restarting {
return false return false
} }
matches := runningStatusRegex.FindStringSubmatch(c.Status) // If there's no health status (no health check configured), container is considered healthy.
// Not "Up" or invalid format. if c.State.Health == nil {
if matches == nil {
return false
}
// If there's no health status (no health check configured so no parentheses), container is considered healthy.
if matches[1] == "" {
return true return true
} }
// If the health status in parentheses is "healthy", the container is considered healthy. return c.State.Health.Status == types.Healthy
return matches[1] == types.Healthy }
// HumanState returns a human-readable description of the container's state. Based on the Docker implementation:
// https://github.com/moby/moby/blob/b343d235a0a1f30c8f05b1d651238e72158dc25d/container/state.go#L79-L113
func (c *Container) HumanState() (string, error) {
startedAt, err := time.Parse(time.RFC3339Nano, c.State.StartedAt)
if err != nil {
return "", fmt.Errorf("parse started time: %w", err)
}
finishedAt, err := time.Parse(time.RFC3339Nano, c.State.FinishedAt)
if err != nil {
return "", fmt.Errorf("parse finished time: %w", err)
}
if c.State.Running {
if c.State.Paused {
return fmt.Sprintf("Up %s (Paused)", units.HumanDuration(time.Now().UTC().Sub(startedAt))), nil
}
if c.State.Restarting {
return fmt.Sprintf("Restarting (%d) %s ago",
c.State.ExitCode, units.HumanDuration(time.Now().UTC().Sub(finishedAt))), nil
}
if c.State.Health != nil {
status := c.State.Health.Status
if status == types.Starting {
status = "health: " + status
}
return fmt.Sprintf("Up %s (%s)", units.HumanDuration(time.Now().UTC().Sub(startedAt)), status), nil
}
return fmt.Sprintf("Up %s", units.HumanDuration(time.Now().UTC().Sub(startedAt))), nil
}
if c.State.Status == "removing" {
return "Removal In Progress", nil
}
if c.State.Dead {
return "Dead", nil
}
if startedAt.IsZero() {
return "Created", nil
}
if finishedAt.IsZero() {
return "", nil
}
return fmt.Sprintf("Exited (%d) %s ago",
c.State.ExitCode, units.HumanDuration(time.Now().UTC().Sub(finishedAt))), nil
}
// ConflictingServicePorts returns a list of service ports that conflict with the given ports.
func (c *Container) ConflictingServicePorts(ports []PortSpec) ([]PortSpec, error) {
svcPorts, err := c.ServicePorts()
if err != nil {
return nil, fmt.Errorf("get service ports: %w", err)
}
var conflicting []PortSpec
for _, p := range ports {
if p.Mode != PortModeHost {
continue
}
// Two host ports conflict if they have the same published port number and protocol, and either:
// * At least one host IP is not set (meaning it uses all interfaces)
// * Both host IPs are identical
for _, svcPort := range svcPorts {
if svcPort.Mode != PortModeHost ||
svcPort.PublishedPort != p.PublishedPort ||
svcPort.Protocol != p.Protocol {
continue
}
if !svcPort.HostIP.IsValid() || !p.HostIP.IsValid() || svcPort.HostIP.Compare(p.HostIP) == 0 {
conflicting = append(conflicting, p)
}
}
}
return conflicting, nil
} }
+294 -40
View File
@@ -2,91 +2,345 @@ package api
import ( import (
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/netip"
"reflect"
"testing" "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()
t.Run("exited", func(t *testing.T) { t.Run("exited", func(t *testing.T) {
t.Parallel() t.Parallel()
c := &Container{Container: types.Container{ c := &Container{ContainerJSON: types.ContainerJSON{
State: "exited", ContainerJSONBase: &types.ContainerJSONBase{
Status: "Exited (0) 2 minutes ago", State: &types.ContainerState{
Running: false,
Dead: false,
ExitCode: 0,
},
},
}} }}
assert.False(t, c.Healthy()) assert.False(t, c.Healthy())
}) })
t.Run("running with no health check", func(t *testing.T) { t.Run("running with no health check", func(t *testing.T) {
t.Parallel() t.Parallel()
c := &Container{Container: types.Container{ c := &Container{ContainerJSON: types.ContainerJSON{
State: "running", ContainerJSONBase: &types.ContainerJSONBase{
Status: "Up 5 minutes", State: &types.ContainerState{
Running: true,
},
},
}} }}
assert.True(t, c.Healthy()) assert.True(t, c.Healthy())
}) })
t.Run("running and healthy", func(t *testing.T) { t.Run("running and healthy", func(t *testing.T) {
t.Parallel() t.Parallel()
c := &Container{Container: types.Container{ c := &Container{ContainerJSON: types.ContainerJSON{
State: "running", ContainerJSONBase: &types.ContainerJSONBase{
Status: "Up 3 minutes (healthy)", State: &types.ContainerState{
Running: true,
Health: &types.Health{
Status: types.Healthy,
},
},
},
}} }}
assert.True(t, c.Healthy()) assert.True(t, c.Healthy())
}) })
t.Run("running but unhealthy", func(t *testing.T) { t.Run("running but unhealthy", func(t *testing.T) {
t.Parallel() t.Parallel()
c := &Container{Container: types.Container{ c := &Container{ContainerJSON: types.ContainerJSON{
State: "running", ContainerJSONBase: &types.ContainerJSONBase{
Status: "Up 2 hours (unhealthy)", State: &types.ContainerState{
Running: true,
Health: &types.Health{
Status: types.Unhealthy,
},
},
},
}} }}
assert.False(t, c.Healthy()) assert.False(t, c.Healthy())
}) })
t.Run("running with health starting", func(t *testing.T) { t.Run("running with health starting", func(t *testing.T) {
t.Parallel() t.Parallel()
c := &Container{Container: types.Container{ c := &Container{ContainerJSON: types.ContainerJSON{
State: "running", ContainerJSONBase: &types.ContainerJSONBase{
Status: "Up 1 minute (health: starting)", State: &types.ContainerState{
Running: true,
Health: &types.Health{
Status: "starting",
},
},
},
}} }}
assert.False(t, c.Healthy()) assert.False(t, c.Healthy())
}) })
t.Run("invalid up format no time", func(t *testing.T) { t.Run("dead", func(t *testing.T) {
t.Parallel() t.Parallel()
c := &Container{Container: types.Container{ c := &Container{ContainerJSON: types.ContainerJSON{
State: "running", ContainerJSONBase: &types.ContainerJSONBase{
Status: "Up", State: &types.ContainerState{
}} Dead: true,
assert.False(t, c.Healthy()) Running: false,
}) },
},
t.Run("invalid up format empty parentheses", func(t *testing.T) {
t.Parallel()
c := &Container{Container: types.Container{
State: "running",
Status: "Up 5 minutes ()",
}}
assert.False(t, c.Healthy())
})
t.Run("malformed status", func(t *testing.T) {
t.Parallel()
c := &Container{Container: types.Container{
State: "running",
Status: "Invalid status",
}} }}
assert.False(t, c.Healthy()) assert.False(t, c.Healthy())
}) })
t.Run("restarting", func(t *testing.T) { t.Run("restarting", func(t *testing.T) {
t.Parallel() t.Parallel()
c := &Container{Container: types.Container{ c := &Container{ContainerJSON: types.ContainerJSON{
State: "running", ContainerJSONBase: &types.ContainerJSONBase{
Status: "Restarting (0) 5 seconds ago", State: &types.ContainerState{
Restarting: true,
Running: true,
ExitCode: 1,
},
},
}}
assert.False(t, c.Healthy())
})
t.Run("paused", func(t *testing.T) {
t.Parallel()
c := &Container{ContainerJSON: types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
State: &types.ContainerState{
Paused: true,
Running: true,
},
},
}} }}
assert.False(t, c.Healthy()) assert.False(t, c.Healthy())
}) })
} }
func TestContainer_ConflictingServicePorts(t *testing.T) {
tests := []struct {
name string
containerPorts string
checkPorts []PortSpec
want []PortSpec
wantErr bool
}{
{
name: "no conflicts when container has no ports",
containerPorts: "",
checkPorts: []PortSpec{
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP},
},
want: nil,
wantErr: false,
},
{
name: "host mode ports with same published port and protocol conflict",
containerPorts: "8080:80/tcp@host",
checkPorts: []PortSpec{
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP},
},
want: []PortSpec{
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP},
},
wantErr: false,
},
{
name: "host mode ports with same port but different protocols don't conflict",
containerPorts: "8080:80/tcp@host",
checkPorts: []PortSpec{
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolUDP},
},
want: nil,
wantErr: false,
},
{
name: "multiple protocols on same port don't conflict",
containerPorts: "8080:80/tcp@host,8080:80/udp@host",
checkPorts: []PortSpec{
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolUDP},
},
want: []PortSpec{
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolUDP},
},
wantErr: false,
},
{
name: "host mode ports with different published ports don't conflict",
containerPorts: "8080:80/tcp@host",
checkPorts: []PortSpec{
{Mode: PortModeHost, PublishedPort: 8081, ContainerPort: 80, Protocol: ProtocolTCP},
},
want: nil,
wantErr: false,
},
{
name: "host mode ports with same published port but different host IPs don't conflict",
containerPorts: "127.0.0.1:8080:80/tcp@host",
checkPorts: []PortSpec{
{
Mode: PortModeHost,
HostIP: netip.MustParseAddr("127.0.0.2"),
PublishedPort: 8080,
ContainerPort: 80,
Protocol: ProtocolTCP,
},
},
want: nil,
wantErr: false,
},
{
name: "host mode ports with same published port, protocol, and host IP conflict",
containerPorts: "127.0.0.1:8080:80/tcp@host",
checkPorts: []PortSpec{
{
Mode: PortModeHost,
HostIP: netip.MustParseAddr("127.0.0.1"),
PublishedPort: 8080,
ContainerPort: 80,
Protocol: ProtocolTCP,
},
},
want: []PortSpec{
{
Mode: PortModeHost,
HostIP: netip.MustParseAddr("127.0.0.1"),
PublishedPort: 8080,
ContainerPort: 80,
Protocol: ProtocolTCP,
},
},
wantErr: false,
},
{
name: "host mode port with no host IP conflicts with specific host IP on same port and protocol",
containerPorts: "8080:80/tcp@host",
checkPorts: []PortSpec{
{
Mode: PortModeHost,
HostIP: netip.MustParseAddr("127.0.0.1"),
PublishedPort: 8080,
ContainerPort: 80,
Protocol: ProtocolTCP,
},
},
want: []PortSpec{
{
Mode: PortModeHost,
HostIP: netip.MustParseAddr("127.0.0.1"),
PublishedPort: 8080,
ContainerPort: 80,
Protocol: ProtocolTCP,
},
},
wantErr: false,
},
{
name: "host mode port with no host IP doesn't conflict with different protocol",
containerPorts: "8080:80/tcp@host",
checkPorts: []PortSpec{
{
Mode: PortModeHost,
HostIP: netip.MustParseAddr("127.0.0.1"),
PublishedPort: 8080,
ContainerPort: 80,
Protocol: ProtocolUDP,
},
},
want: nil,
wantErr: false,
},
{
name: "ingress mode ports don't conflict with host mode ports",
containerPorts: "8080:80/tcp",
checkPorts: []PortSpec{
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP},
},
want: nil,
wantErr: false,
},
{
name: "container with invalid port spec returns error",
containerPorts: "invalid:port:spec",
checkPorts: []PortSpec{
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP},
},
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctr := &Container{ContainerJSON: types.ContainerJSON{
Config: &container.Config{
Labels: map[string]string{
LabelServicePorts: tt.containerPorts,
},
},
}}
got, err := ctr.ConflictingServicePorts(tt.checkPorts)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
+7 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/distribution/reference" "github.com/distribution/reference"
"reflect"
"uncloud/internal/machine/api/pb" "uncloud/internal/machine/api/pb"
) )
@@ -37,6 +38,11 @@ func (s *ServiceSpec) Validate() error {
return nil return nil
} }
func (s *ServiceSpec) Equals(spec ServiceSpec) bool {
// TODO: ignore order of ports.
return reflect.DeepEqual(*s, spec)
}
type ContainerSpec struct { type ContainerSpec struct {
Command []string Command []string
Image string Image string
@@ -47,8 +53,7 @@ type ContainerSpec struct {
} }
func (s *ContainerSpec) Validate() error { func (s *ContainerSpec) Validate() error {
_, err := reference.ParseDockerRef(s.Image) if _, err := reference.ParseDockerRef(s.Image); err != nil {
if err != nil {
return fmt.Errorf("invalid image: %w", err) return fmt.Errorf("invalid image: %w", err)
} }
+11 -2
View File
@@ -4,15 +4,17 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"github.com/charmbracelet/huh"
"google.golang.org/protobuf/types/known/emptypb"
"net/netip" "net/netip"
"uncloud/internal/cli/client" "uncloud/internal/cli/client"
"uncloud/internal/cli/client/connector" "uncloud/internal/cli/client/connector"
"uncloud/internal/cli/config" "uncloud/internal/cli/config"
"uncloud/internal/fs"
"uncloud/internal/machine" "uncloud/internal/machine"
"uncloud/internal/machine/api/pb" "uncloud/internal/machine/api/pb"
"uncloud/internal/sshexec" "uncloud/internal/sshexec"
"github.com/charmbracelet/huh"
"google.golang.org/protobuf/types/known/emptypb"
) )
const defaultClusterName = "default" const defaultClusterName = "default"
@@ -89,10 +91,14 @@ func (cli *CLI) ConnectCluster(ctx context.Context, clusterName string) (*client
if err != nil { if err != nil {
return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err) return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err)
} }
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
sshConfig := &connector.SSHConnectorConfig{ sshConfig := &connector.SSHConnectorConfig{
User: user, User: user,
Host: host, Host: host,
Port: port, Port: port,
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.IsValid() {
@@ -157,9 +163,11 @@ func (cli *CLI) initRemoteMachine(
return fmt.Errorf("set current cluster: %w", err) return fmt.Errorf("set current cluster: %w", err)
} }
} }
// Save the machine's SSH connection details in the cluster config. // Save the machine's SSH connection details in the cluster config.
connCfg := config.MachineConnection{ connCfg := config.MachineConnection{
SSH: config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), SSH: config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port),
SSHKeyFile: remoteMachine.KeyPath,
} }
cli.config.Clusters[clusterName].Connections = append(cli.config.Clusters[clusterName].Connections, connCfg) cli.config.Clusters[clusterName].Connections = append(cli.config.Clusters[clusterName].Connections, connCfg)
if err = cli.config.Save(); err != nil { if err = cli.config.Save(); err != nil {
@@ -246,6 +254,7 @@ func (cli *CLI) AddMachine(ctx context.Context, remoteMachine RemoteMachine, clu
// Save the machine's SSH connection details in the cluster config. // Save the machine's SSH connection details in the cluster config.
connCfg := config.MachineConnection{ connCfg := config.MachineConnection{
SSH: config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), SSH: config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port),
SSHKeyFile: remoteMachine.KeyPath,
} }
if clusterName == "" { if clusterName == "" {
clusterName = cli.config.CurrentCluster clusterName = cli.config.CurrentCluster
+12 -5
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/streams"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"os" "os"
"uncloud/internal/machine/api/pb" "uncloud/internal/machine/api/pb"
"uncloud/internal/machine/docker" "uncloud/internal/machine/docker"
@@ -20,12 +21,11 @@ type Client struct {
pb.MachineClient pb.MachineClient
pb.ClusterClient pb.ClusterClient
*DockerClient // Docker is a namespaced client for the Docker service to distinguish Uncloud-specific service container operations
// from generic Docker operations.
Docker *docker.Client
} }
// DockerClient is a type alias for the Docker client to embed it in Client with a more specific name.
type DockerClient = docker.Client
// 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)
@@ -46,7 +46,7 @@ func New(ctx context.Context, connector Connector) (*Client, error) {
c.MachineClient = pb.NewMachineClient(c.conn) c.MachineClient = pb.NewMachineClient(c.conn)
c.ClusterClient = pb.NewClusterClient(c.conn) c.ClusterClient = pb.NewClusterClient(c.conn)
c.DockerClient = docker.NewClient(c.conn) c.Docker = docker.NewClient(c.conn)
return c, nil return c, nil
} }
@@ -58,3 +58,10 @@ func (cli *Client) Close() error {
func (cli *Client) progressOut() *streams.Out { func (cli *Client) progressOut() *streams.Out {
return streams.NewOut(os.Stdout) return streams.NewOut(os.Stdout)
} }
// proxyToMachine returns a new context that proxies gRPC requests to the specified machine.
func proxyToMachine(ctx context.Context, machine *pb.MachineInfo) context.Context {
machineIP, _ := machine.Network.ManagementIp.ToAddr()
md := metadata.Pairs("machines", machineIP.String())
return metadata.NewOutgoingContext(ctx, md)
}
+15
View File
@@ -6,6 +6,21 @@ import (
"uncloud/internal/machine/api/pb" "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) { func (cli *Client) ListMachines(ctx context.Context) ([]*pb.MachineMember, error) {
resp, err := cli.ClusterClient.ListMachines(ctx, &emptypb.Empty{}) resp, err := cli.ClusterClient.ListMachines(ctx, &emptypb.Empty{})
if err != nil { if err != nil {
+324
View File
@@ -0,0 +1,324 @@
package client
import (
"context"
"errors"
"fmt"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
dockerclient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/jsonmessage"
"github.com/docker/go-connections/nat"
"strconv"
"strings"
"uncloud/internal/api"
machinedocker "uncloud/internal/machine/docker"
"uncloud/internal/secret"
)
// CreateContainer creates a new container for the given service on the specified machine.
func (cli *Client) CreateContainer(
ctx context.Context, serviceID string, spec api.ServiceSpec, machineID string,
) (container.CreateResponse, error) {
var resp container.CreateResponse
if serviceID == "" {
return resp, errors.New("service ID is required")
}
machine, err := cli.InspectMachine(ctx, machineID)
if err != nil {
return resp, fmt.Errorf("inspect machine '%s': %w", machineID, err)
}
suffix, err := secret.RandomAlphaNumeric(4)
if err != nil {
return resp, fmt.Errorf("generate random suffix: %w", err)
}
containerName := fmt.Sprintf("%s-%s", spec.Name, suffix)
config := &container.Config{
Cmd: spec.Container.Command,
Image: spec.Container.Image,
Labels: map[string]string{
api.LabelServiceID: serviceID,
api.LabelServiceName: spec.Name,
api.LabelManaged: "",
},
}
if spec.Mode != "" {
config.Labels[api.LabelServiceMode] = spec.Mode
}
if len(spec.Ports) > 0 {
encodedPorts := make([]string, len(spec.Ports))
for i, p := range spec.Ports {
encodedPorts[i], err = p.String()
if err != nil {
return resp, fmt.Errorf("encode service port spec: %w", err)
}
}
config.Labels[api.LabelServicePorts] = strings.Join(encodedPorts, ",")
}
portBindings := make(nat.PortMap)
for _, p := range spec.Ports {
if p.Mode != api.PortModeHost {
continue
}
port := nat.Port(fmt.Sprintf("%d/%s", p.ContainerPort, p.Protocol))
portBindings[port] = []nat.PortBinding{
{
HostPort: strconv.Itoa(int(p.PublishedPort)),
},
}
if p.HostIP.IsValid() {
portBindings[port][0].HostIP = p.HostIP.String()
}
}
hostConfig := &container.HostConfig{
Binds: spec.Container.Volumes,
Init: spec.Container.Init,
PortBindings: portBindings,
}
netConfig := &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
machinedocker.NetworkName: {},
},
}
// Proxy Docker gRPC requests to the selected machine.
ctx = proxyToMachine(ctx, machine.Machine)
pw := progress.ContextWriter(ctx)
eventID := fmt.Sprintf("Container %s on %s", containerName, machine.Machine.Name)
pw.Event(progress.CreatingEvent(eventID))
resp, err = cli.Docker.CreateContainer(ctx, config, hostConfig, netConfig, nil, containerName)
if err != nil {
if !dockerclient.IsErrNotFound(err) {
return resp, err
}
// Pull the missing image and create the container again.
if err = cli.pullImageWithProgress(ctx, config.Image, machine.Machine.Name, eventID); err != nil {
return resp, err
}
if resp, err = cli.Docker.CreateContainer(ctx, config, hostConfig, netConfig, nil, containerName); err != nil {
return resp, err
}
}
pw.Event(progress.CreatedEvent(eventID))
return resp, nil
}
func (cli *Client) pullImageWithProgress(ctx context.Context, image, machineName, parentEventID string) error {
pw := progress.ContextWriter(ctx)
eventID := fmt.Sprintf("Image %s on %s", image, machineName)
pw.Event(progress.Event{
ID: eventID,
ParentID: parentEventID,
Status: progress.Working,
StatusText: "Pulling",
})
pullCh, err := cli.Docker.PullImage(ctx, image)
if err != nil {
pw.Event(progress.Event{
ID: eventID,
ParentID: parentEventID,
Text: "Error",
Status: progress.Error,
StatusText: errors.Unwrap(err).Error(),
})
return fmt.Errorf("pull image: %w", err)
}
// Wait for pull to complete by reading all progress messages and converting them to events.
for msg := range pullCh {
if msg.Err != nil {
err = msg.Err
} else {
if msg.Message.Error != nil {
err = errors.New(msg.Message.Error.Message)
}
}
if err != nil {
pw.Event(progress.Event{
ID: eventID,
ParentID: parentEventID,
Text: "Error",
Status: progress.Error,
StatusText: errors.Unwrap(err).Error(),
})
return fmt.Errorf("pull image: %w", err)
}
// TODO: add like in compose: --quiet-pull Pull without printing progress information
e := toPullProgressEvent(msg.Message)
if e != nil {
e.ID = fmt.Sprintf("%s on %s", e.ID, machineName)
e.ParentID = eventID
// Grand children events are not printed by the tty progress writer but they are still required
// to calculate the progress line of their parent.
pw.Event(*e)
}
}
pw.Event(progress.Event{
ID: eventID,
ParentID: parentEventID,
Status: progress.Done,
StatusText: "Pulled",
})
return nil
}
// toPullProgressEvent converts a JSON progress message from the Docker API to a progress event.
// It's based on toPullProgressEvent from Docker Compose.
func toPullProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
if jm.ID == "" || jm.Progress == nil {
return nil
}
var (
total int64
percent int
current int64
)
text := jm.Progress.String()
stat := progress.Working
switch jm.Status {
case "Preparing", "Waiting", "Pulling fs layer":
percent = 0
case "Downloading", "Extracting", "Verifying Checksum":
current = jm.Progress.Current
total = jm.Progress.Total
if jm.Progress.Total > 0 {
percent = int(jm.Progress.Current * 100 / jm.Progress.Total)
}
case "Download complete", "Already exists", "Pull complete":
stat = progress.Done
percent = 100
}
if strings.Contains(jm.Status, "Image is up to date") ||
strings.Contains(jm.Status, "Downloaded newer image") {
stat = progress.Done
percent = 100
}
return &progress.Event{
ID: jm.ID,
Current: current,
Total: total,
Percent: percent,
Text: jm.Status,
Status: stat,
StatusText: text,
}
}
// InspectContainer returns the information about the specified container within the service.
func (cli *Client) InspectContainer(ctx context.Context, serviceID, containerID string) (api.MachineContainer, error) {
var ctr api.MachineContainer
svc, err := cli.InspectService(ctx, serviceID)
if err != nil {
return ctr, fmt.Errorf("inspect service: %w", err)
}
for _, c := range svc.Containers {
if c.Container.ID == containerID || c.Container.NameWithoutSlash() == containerID {
ctr = c
}
}
if ctr.MachineID == "" {
return ctr, ErrNotFound
}
return ctr, nil
}
// StartContainer starts the specified container within the service.
func (cli *Client) StartContainer(ctx context.Context, serviceID, containerID string) error {
ctr, err := cli.InspectContainer(ctx, serviceID, containerID)
if err != nil {
return err
}
machine, err := cli.InspectMachine(ctx, ctr.MachineID)
if err != nil {
return fmt.Errorf("inspect machine '%s': %w", ctr.MachineID, err)
}
ctx = proxyToMachine(ctx, machine.Machine)
pw := progress.ContextWriter(ctx)
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.NameWithoutSlash(), machine.Machine.Name)
pw.Event(progress.StartingEvent(eventID))
if err = cli.Docker.StartContainer(ctx, ctr.Container.ID, container.StartOptions{}); err != nil {
return err
}
pw.Event(progress.StartedEvent(eventID))
return nil
}
// StopContainer stops the specified container within the service.
func (cli *Client) StopContainer(
ctx context.Context, serviceID, containerID string, opts container.StopOptions,
) error {
ctr, err := cli.InspectContainer(ctx, serviceID, containerID)
if err != nil {
return err
}
machine, err := cli.InspectMachine(ctx, ctr.MachineID)
if err != nil {
return fmt.Errorf("inspect machine '%s': %w", ctr.MachineID, err)
}
ctx = proxyToMachine(ctx, machine.Machine)
pw := progress.ContextWriter(ctx)
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.NameWithoutSlash(), machine.Machine.Name)
pw.Event(progress.StoppingEvent(eventID))
if err = cli.Docker.StopContainer(ctx, ctr.Container.ID, opts); err != nil {
return err
}
pw.Event(progress.StoppedEvent(eventID))
return nil
}
// RemoveContainer removes the specified container within the service.
func (cli *Client) RemoveContainer(
ctx context.Context, serviceID, containerID string, opts container.RemoveOptions,
) error {
ctr, err := cli.InspectContainer(ctx, serviceID, containerID)
if err != nil {
return err
}
machine, err := cli.InspectMachine(ctx, ctr.MachineID)
if err != nil {
return fmt.Errorf("inspect machine '%s': %w", ctr.MachineID, err)
}
ctx = proxyToMachine(ctx, machine.Machine)
pw := progress.ContextWriter(ctx)
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.NameWithoutSlash(), machine.Machine.Name)
pw.Event(progress.RemovingEvent(eventID))
if err = cli.Docker.RemoveContainer(ctx, ctr.Container.ID, opts); err != nil {
return err
}
pw.Event(progress.RemovedEvent(eventID))
return nil
}
+129
View File
@@ -0,0 +1,129 @@
package client
import (
"context"
"errors"
"fmt"
"github.com/distribution/reference"
"strings"
"uncloud/internal/api"
"uncloud/internal/secret"
)
// Deployment manages the process of creating or updating a service to match a desired state.
// It coordinates the validation, planning, and execution of deployment operations.
type Deployment struct {
Service *api.Service
Spec api.ServiceSpec
Strategy Strategy
cli *Client
plan *Plan
}
type Plan struct {
ServiceID string
Operation
}
// NewDeployment creates a new deployment for the given service specification.
// If strategy is nil, a default RollingStrategy will be used.
func (cli *Client) NewDeployment(spec api.ServiceSpec, strategy Strategy) (*Deployment, error) {
if err := spec.Validate(); err != nil {
return nil, fmt.Errorf("invalid service spec: %w", err)
}
if spec.Name == "" {
// Generate a random service name from the image when not provided.
img, err := reference.ParseDockerRef(spec.Container.Image)
if err != nil {
return nil, 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 nil, fmt.Errorf("generate random suffix: %w", err)
}
spec.Name = fmt.Sprintf("%s-%s", imageName, suffix)
}
if strategy == nil {
strategy = &RollingStrategy{}
}
return &Deployment{
Spec: spec,
Strategy: strategy,
cli: cli,
}, nil
}
// Plan returns a plan of operations to reconcile the service to the desired state.
// If a plan has already been created, the same plan will be returned.
func (d *Deployment) Plan(ctx context.Context) (Plan, error) {
if d.plan != nil {
return *d.plan, nil
}
// Validate the new spec before planning.
if err := d.Validate(ctx); err != nil {
return Plan{}, fmt.Errorf("invalid deployment: %w", err)
}
plan, err := d.Strategy.Plan(ctx, d.cli, d.Service, d.Spec)
if err != nil {
return Plan{}, fmt.Errorf("create plan using %T: %w", d.Strategy, err)
}
d.plan = &plan
return plan, nil
}
// Validate checks if the deployment specification is valid.
func (d *Deployment) Validate(ctx context.Context) error {
if err := d.Spec.Validate(); err != nil {
return fmt.Errorf("invalid service spec: %w", err)
}
if d.Spec.Name == "" {
return errors.New("service name is required")
}
if d.Service == nil {
svc, err := d.cli.InspectService(ctx, d.Spec.Name)
if err == nil {
d.Service = &svc
} else if !errors.Is(err, ErrNotFound) {
return fmt.Errorf("inspect service: %w", err)
}
}
// d.Service is nil if the service doesn't exist yet (first deployment).
if d.Service == nil {
return nil
}
if d.Service.Name != d.Spec.Name {
return errors.New("service name cannot be changed")
}
if d.Service.Mode != d.Spec.Mode {
return errors.New("service mode cannot be changed")
}
return nil
}
// Run executes the deployment plan and returns the ID of the created or updated service.
// It will create a new plan if one hasn't been created yet. The deployment will either create a new service or update
// the existing one to match the desired specification.
// TODO: forbid to run the same deployment more than once.
func (d *Deployment) Run(ctx context.Context) (string, error) {
plan, err := d.Plan(ctx)
if err != nil {
return "", fmt.Errorf("plan: %w", err)
}
return plan.ServiceID, plan.Execute(ctx, d.cli)
}
+104
View File
@@ -0,0 +1,104 @@
package client
import (
"context"
"fmt"
"github.com/docker/docker/api/types/container"
"strings"
"uncloud/internal/api"
)
// Operation represents a single atomic operation in a deployment process.
// Operations can be composed to form complex deployment strategies.
type Operation interface {
Execute(ctx context.Context, cli *Client) error
String() string
}
// RunContainerOperation creates and starts a new container on a specific machine.
type RunContainerOperation struct {
ServiceID string
Spec api.ServiceSpec
MachineID string
}
func (o *RunContainerOperation) Execute(ctx context.Context, cli *Client) error {
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
if err != nil {
return fmt.Errorf("create container: %w", err)
}
if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil {
return fmt.Errorf("start container: %w", err)
}
// TODO: wait for the container to become healthy
return nil
}
func (o *RunContainerOperation) String() string {
return fmt.Sprintf("RunContainerOperation[%s, %s, %s]", o.ServiceID, o.Spec.Name, o.MachineID)
}
// StopContainerOperation stops a container on a specific machine.
type StopContainerOperation struct {
ServiceID string
ContainerID string
MachineID string
}
func (o *StopContainerOperation) Execute(ctx context.Context, cli *Client) error {
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil {
return fmt.Errorf("stop container: %w", err)
}
return nil
}
func (o *StopContainerOperation) String() string {
return fmt.Sprintf("StopContainerOperation[%s, %s, %s]", o.ServiceID, o.ContainerID, o.MachineID)
}
// RemoveContainerOperation stops and removes a container from a specific machine.
type RemoveContainerOperation struct {
ServiceID string
ContainerID string
MachineID string
}
func (o *RemoveContainerOperation) Execute(ctx context.Context, cli *Client) error {
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil {
return fmt.Errorf("stop container: %w", err)
}
if err := cli.RemoveContainer(ctx, o.ServiceID, o.ContainerID, container.RemoveOptions{}); err != nil {
return fmt.Errorf("remove container: %w", err)
}
return nil
}
func (o *RemoveContainerOperation) String() string {
return fmt.Sprintf("RemoveContainerOperation[%s, %s, %s]", o.ServiceID, o.ContainerID, o.MachineID)
}
// SequenceOperation is a composite operation that executes a sequence of operations in order.
type SequenceOperation struct {
Operations []Operation
}
func (o *SequenceOperation) Execute(ctx context.Context, cli *Client) error {
for _, op := range o.Operations {
if err := op.Execute(ctx, cli); err != nil {
return err
}
}
return nil
}
func (o *SequenceOperation) String() string {
ops := make([]string, len(o.Operations))
for i, op := range o.Operations {
ops[i] = op.String()
}
return fmt.Sprintf("SequenceOperation[%s]", strings.Join(ops, ", "))
}
+29 -280
View File
@@ -8,32 +8,20 @@ import (
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
dockerclient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/jsonmessage"
"github.com/docker/go-connections/nat"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"slices" "slices"
"strconv"
"strings" "strings"
"sync" "sync"
"uncloud/internal/api" "uncloud/internal/api"
"uncloud/internal/machine/api/pb" "uncloud/internal/machine/api/pb"
machinedocker "uncloud/internal/machine/docker"
"uncloud/internal/secret" "uncloud/internal/secret"
) )
type RunServiceResponse struct { type RunServiceResponse struct {
ID string ID string
Name string Name string
Containers []MachineContainerID
}
type MachineContainerID struct {
MachineID string
ContainerID string
} }
func (cli *Client) RunService(ctx context.Context, spec api.ServiceSpec) (RunServiceResponse, error) { func (cli *Client) RunService(ctx context.Context, spec api.ServiceSpec) (RunServiceResponse, error) {
@@ -83,7 +71,21 @@ func (cli *Client) RunService(ctx context.Context, spec api.ServiceSpec) (RunSer
case "", api.ServiceModeReplicated: case "", api.ServiceModeReplicated:
resp, err = cli.runReplicatedService(ctx, serviceID, spec) resp, err = cli.runReplicatedService(ctx, serviceID, spec)
case api.ServiceModeGlobal: case api.ServiceModeGlobal:
resp, err = cli.runGlobalService(ctx, serviceID, spec) deploy, err := cli.NewDeployment(spec, &RollingStrategy{})
if err != nil {
return fmt.Errorf("create deployment: %w", err)
}
serviceID, err = deploy.Run(ctx)
if err != nil {
return err
}
resp.ID = serviceID
// TODO: get the service name from the plan when it's available.
resp.Name = spec.Name
return nil
default: default:
return fmt.Errorf("invalid mode: %q", spec.Mode) return fmt.Errorf("invalid mode: %q", spec.Mode)
} }
@@ -126,16 +128,10 @@ func (cli *Client) runReplicatedService(ctx context.Context, id string, spec api
return resp, errors.New("no available machine to run the service") return resp, errors.New("no available machine to run the service")
} }
runResp, err := cli.runContainer(ctx, id, spec, m.Machine) if _, err = cli.runContainer(ctx, id, spec, m.Machine); err != nil {
if err != nil {
return resp, fmt.Errorf("run container: %w", err) return resp, fmt.Errorf("run container: %w", err)
} }
resp.Containers = append(resp.Containers, MachineContainerID{
MachineID: m.Machine.Id,
ContainerID: runResp.ID,
})
return resp, nil return resp, nil
} }
@@ -156,265 +152,21 @@ func firstAvailableMachine(machines []*pb.MachineMember) *pb.MachineMember {
return nil return nil
} }
func (cli *Client) runGlobalService(ctx context.Context, id string, spec api.ServiceSpec) (RunServiceResponse, error) {
resp := RunServiceResponse{
ID: id,
Name: spec.Name,
}
machines, err := cli.ListMachines(ctx)
if err != nil {
return resp, fmt.Errorf("list machines: %w", err)
}
wg := sync.WaitGroup{}
errCh := make(chan error)
mu := sync.Mutex{}
// Run a service container on each available machine.
for _, m := range machines {
if m.State != pb.MachineMember_UP && m.State != pb.MachineMember_SUSPECT {
// TODO: return failed machines in the response.
fmt.Printf("WARNING: failed to run a service container on machine '%s' which is Down.\n", m.Machine.Name)
continue
}
wg.Add(1)
go func() {
defer wg.Done()
runResp, err := cli.runContainer(ctx, id, spec, m.Machine)
if err != nil {
errCh <- fmt.Errorf("run container on machine '%s': %w", m.Machine.Name, err)
return
}
mu.Lock()
resp.Containers = append(resp.Containers, MachineContainerID{
MachineID: m.Machine.Id,
ContainerID: runResp.ID,
})
mu.Unlock()
}()
}
go func() {
wg.Wait()
close(errCh)
}()
err = nil
for e := range errCh {
err = errors.Join(err, e)
}
return resp, err
}
func (cli *Client) runContainer( func (cli *Client) runContainer(
ctx context.Context, serviceID string, spec api.ServiceSpec, machine *pb.MachineInfo, ctx context.Context, serviceID string, spec api.ServiceSpec, machine *pb.MachineInfo,
) (container.CreateResponse, error) { ) (container.CreateResponse, error) {
var resp container.CreateResponse resp, err := cli.CreateContainer(ctx, serviceID, spec, machine.Name)
// Proxy Docker gRPC requests to the selected machine.
machineIP, _ := machine.Network.ManagementIp.ToAddr()
md := metadata.Pairs("machines", machineIP.String())
ctx = metadata.NewOutgoingContext(ctx, md)
suffix, err := secret.RandomAlphaNumeric(4)
if err != nil { if err != nil {
return resp, fmt.Errorf("generate random suffix: %w", err)
}
containerName := fmt.Sprintf("%s-%s", spec.Name, suffix)
config := &container.Config{
Cmd: spec.Container.Command,
Image: spec.Container.Image,
Labels: map[string]string{
api.LabelServiceID: serviceID,
api.LabelServiceName: spec.Name,
api.LabelManaged: "",
},
}
if spec.Mode == api.ServiceModeGlobal {
config.Labels[api.LabelServiceMode] = api.ServiceModeGlobal
}
if len(spec.Ports) > 0 {
encodedPorts := make([]string, len(spec.Ports))
for i, p := range spec.Ports {
encodedPorts[i], err = p.String()
if err != nil {
return resp, fmt.Errorf("encode service port spec: %w", err)
}
}
config.Labels[api.LabelServicePorts] = strings.Join(encodedPorts, ",")
}
portBindings := make(nat.PortMap)
for _, p := range spec.Ports {
if p.Mode != api.PortModeHost {
continue
}
port := nat.Port(fmt.Sprintf("%d/%s", p.ContainerPort, p.Protocol))
portBindings[port] = []nat.PortBinding{
{
HostPort: strconv.Itoa(int(p.PublishedPort)),
},
}
if p.HostIP.IsValid() {
portBindings[port][0].HostIP = p.HostIP.String()
}
}
hostConfig := &container.HostConfig{
Binds: spec.Container.Volumes,
Init: spec.Container.Init,
PortBindings: portBindings,
}
netConfig := &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
machinedocker.NetworkName: {},
},
}
pw := progress.ContextWriter(ctx)
eventID := fmt.Sprintf("Container %s on %s", containerName, machine.Name)
pw.Event(progress.CreatingEvent(eventID))
resp, err = cli.CreateContainer(ctx, config, hostConfig, netConfig, nil, containerName)
if err != nil {
if !dockerclient.IsErrNotFound(err) {
return resp, fmt.Errorf("create container: %w", err) return resp, fmt.Errorf("create container: %w", err)
} }
// Pull the missing image and create the container again. if err = cli.StartContainer(ctx, serviceID, resp.ID); err != nil {
if err = cli.pullImageWithProgress(ctx, config.Image, machine.Name, eventID); err != nil {
return resp, err
}
if resp, err = cli.CreateContainer(ctx, config, hostConfig, netConfig, nil, containerName); err != nil {
return resp, fmt.Errorf("create container: %w", err)
}
}
pw.Event(progress.CreatedEvent(eventID))
pw.Event(progress.StartingEvent(eventID))
if err = cli.StartContainer(ctx, resp.ID, container.StartOptions{}); err != nil {
return resp, fmt.Errorf("start container: %w", err) return resp, fmt.Errorf("start container: %w", err)
} }
pw.Event(progress.StartedEvent(eventID))
return resp, nil return resp, nil
} }
func (cli *Client) pullImageWithProgress(ctx context.Context, image, machineName, parentEventID string) error {
pw := progress.ContextWriter(ctx)
eventID := fmt.Sprintf("Image %s on %s", image, machineName)
pw.Event(progress.Event{
ID: eventID,
ParentID: parentEventID,
Status: progress.Working,
StatusText: "Pulling",
})
pullCh, err := cli.PullImage(ctx, image)
if err != nil {
pw.Event(progress.Event{
ID: eventID,
ParentID: parentEventID,
Text: "Error",
Status: progress.Error,
StatusText: errors.Unwrap(err).Error(),
})
return fmt.Errorf("pull image: %w", err)
}
// Wait for pull to complete by reading all progress messages and converting them to events.
for msg := range pullCh {
if msg.Err != nil {
err = msg.Err
} else {
if msg.Message.Error != nil {
err = errors.New(msg.Message.Error.Message)
}
}
if err != nil {
pw.Event(progress.Event{
ID: eventID,
ParentID: parentEventID,
Text: "Error",
Status: progress.Error,
StatusText: errors.Unwrap(err).Error(),
})
return fmt.Errorf("pull image: %w", err)
}
// TODO: add like in compose: --quiet-pull Pull without printing progress information
e := toPullProgressEvent(msg.Message)
if e != nil {
e.ID = fmt.Sprintf("%s on %s", e.ID, machineName)
e.ParentID = eventID
// Grand children events are not printed by the tty progress writer but they are still required
// to calculate the progress line of their parent.
pw.Event(*e)
}
}
pw.Event(progress.Event{
ID: eventID,
ParentID: parentEventID,
Status: progress.Done,
StatusText: "Pulled",
})
return nil
}
// toPullProgressEvent converts a JSON progress message from the Docker API to a progress event.
// It's based on toPullProgressEvent from Docker Compose.
func toPullProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
if jm.ID == "" || jm.Progress == nil {
return nil
}
var (
total int64
percent int
current int64
)
text := jm.Progress.String()
stat := progress.Working
switch jm.Status {
case "Preparing", "Waiting", "Pulling fs layer":
percent = 0
case "Downloading", "Extracting", "Verifying Checksum":
current = jm.Progress.Current
total = jm.Progress.Total
if jm.Progress.Total > 0 {
percent = int(jm.Progress.Current * 100 / jm.Progress.Total)
}
case "Download complete", "Already exists", "Pull complete":
stat = progress.Done
percent = 100
}
if strings.Contains(jm.Status, "Image is up to date") ||
strings.Contains(jm.Status, "Downloaded newer image") {
stat = progress.Done
percent = 100
}
return &progress.Event{
ID: jm.ID,
Current: current,
Total: total,
Percent: percent,
Text: jm.Status,
Status: stat,
StatusText: text,
}
}
// InspectService returns detailed information about a service and its containers. // InspectService returns detailed information about a service and its containers.
// The id parameter can be either a service ID or name. // The id parameter can be either a service ID or name.
func (cli *Client) InspectService(ctx context.Context, id string) (api.Service, error) { func (cli *Client) InspectService(ctx context.Context, id string) (api.Service, error) {
@@ -447,7 +199,7 @@ func (cli *Client) InspectService(ctx context.Context, id string) (api.Service,
filters.Arg("label", api.LabelManaged), filters.Arg("label", api.LabelManaged),
), ),
} }
machineContainers, err := cli.ListContainers(listCtx, opts) machineContainers, err := cli.Docker.ListContainers(listCtx, opts)
if err != nil { if err != nil {
return svc, fmt.Errorf("list containers: %w", err) return svc, fmt.Errorf("list containers: %w", err)
} }
@@ -483,7 +235,7 @@ func (cli *Client) InspectService(ctx context.Context, id string) (api.Service,
} }
for _, c := range mc.Containers { for _, c := range mc.Containers {
ctr := api.Container{Container: c} ctr := api.Container{ContainerJSON: c}
if ctr.ServiceID() == id || ctr.ServiceName() == id { if ctr.ServiceID() == id || ctr.ServiceName() == id {
containers = append(containers, api.MachineContainer{ containers = append(containers, api.MachineContainer{
MachineID: machineID, MachineID: machineID,
@@ -513,7 +265,7 @@ func (cli *Client) InspectService(ctx context.Context, id string) (api.Service,
serviceID := containers[0].Container.ServiceID() serviceID := containers[0].Container.ServiceID()
for _, mc := range containers[1:] { for _, mc := range containers[1:] {
if mc.Container.ServiceID() != serviceID { if mc.Container.ServiceID() != serviceID {
return svc, fmt.Errorf("multiple services found with name: %s", id) return svc, fmt.Errorf("multiple services found with name '%s', use the service ID instead", id)
} }
} }
} }
@@ -582,19 +334,16 @@ func (cli *Client) RemoveService(ctx context.Context, id string) error {
go func() { go func() {
defer wg.Done() defer wg.Done()
machineIP, ok := machineManagementIPByID[mc.MachineID] err := cli.StopContainer(ctx, svc.ID, mc.Container.ID, container.StopOptions{})
if !ok { if err != nil {
errCh <- fmt.Errorf("machine not found by ID: %s", mc.MachineID) errCh <- fmt.Errorf("stop container '%s': %w", mc.Container.ID, err)
return return
} }
removeCtx := metadata.NewOutgoingContext(ctx, metadata.Pairs("machines", machineIP))
// TODO: gracefully stop the container before removing it without force. err = cli.RemoveContainer(ctx, svc.ID, mc.Container.ID, container.RemoveOptions{})
err := cli.RemoveContainer(removeCtx, mc.Container.ID, container.RemoveOptions{Force: true}) if err != nil && !errors.Is(err, ErrNotFound) {
if err != nil {
if !dockerclient.IsErrNotFound(err) {
errCh <- fmt.Errorf("remove container '%s': %w", mc.Container.ID, err) errCh <- fmt.Errorf("remove container '%s': %w", mc.Container.ID, err)
} }
}
}() }()
} }
@@ -636,7 +385,7 @@ func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
filters.Arg("label", api.LabelManaged), filters.Arg("label", api.LabelManaged),
), ),
} }
machineContainers, err := cli.ListContainers(listCtx, opts) machineContainers, err := cli.Docker.ListContainers(listCtx, opts)
if err != nil { if err != nil {
return nil, fmt.Errorf("list containers: %w", err) return nil, fmt.Errorf("list containers: %w", err)
} }
@@ -653,7 +402,7 @@ func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
} }
for _, c := range mc.Containers { for _, c := range mc.Containers {
ctr := api.Container{Container: c} ctr := api.Container{ContainerJSON: c}
if _, ok := servicesByID[ctr.ServiceID()]; ok { if _, ok := servicesByID[ctr.ServiceID()]; ok {
continue continue
} }
+186
View File
@@ -0,0 +1,186 @@
package client
import (
"context"
"errors"
"fmt"
"uncloud/internal/api"
"uncloud/internal/machine/api/pb"
"uncloud/internal/secret"
)
// Strategy defines how a service should be deployed or updated. Different implementations can provide various
// deployment patterns such as rolling updates, blue/green deployments, etc.
type Strategy interface {
// Plan returns the operation to reconcile the service to the desired state.
// If the service does not exist (new deployment), svc will be nil.
Plan(ctx context.Context, cli *Client, svc *api.Service, spec api.ServiceSpec) (Plan, error)
}
// RollingStrategy implements a rolling update deployment pattern where containers are updated one at a time
// to minimize service disruption.
type RollingStrategy struct{}
func (s *RollingStrategy) Plan(
ctx context.Context, cli *Client, svc *api.Service, spec api.ServiceSpec,
) (Plan, error) {
switch spec.Mode {
case "", api.ServiceModeReplicated:
return s.planReplicated(ctx, cli, svc, spec)
case api.ServiceModeGlobal:
return s.planGlobal(ctx, cli, svc, spec)
default:
return Plan{}, fmt.Errorf("unsupported service mode: %s", spec.Mode)
}
}
// planReplicated creates a plan for a replicated service deployment.
func (s *RollingStrategy) planReplicated(
ctx context.Context, cli *Client, svc *api.Service, spec api.ServiceSpec,
) (Plan, error) {
return Plan{}, errors.New("not implemented")
}
// planGlobal creates a plan for a global service deployment, ensuring one container runs on each available machine.
// For machines with an existing container, it attempts to start a new container before removing the old one if
// possible. If the new container would have port conflicts with the existing one, the old container is removed first.
// It handles multiple containers per machine (though this should not occur in normal operation) and skips machines
// that are down.
func (s *RollingStrategy) planGlobal(
ctx context.Context, cli *Client, svc *api.Service, spec api.ServiceSpec,
) (Plan, error) {
var plan Plan
// Map machineID to service containers on that machine. For the global mode, there should be at most one
// container per machine but we use a slice to handle multiple containers that may exist due to a bug
// or interruption in the previous deployment.
containersOnMachine := make(map[string][]api.MachineContainer)
if svc != nil {
plan.ServiceID = svc.ID
for _, c := range svc.Containers {
containersOnMachine[c.MachineID] = append(containersOnMachine[c.MachineID], c)
}
} else {
// Generate a new service ID for the first service deployment.
var err error
plan.ServiceID, err = secret.NewID()
if err != nil {
return plan, fmt.Errorf("generate service ID: %w", err)
}
}
machines, err := cli.ListMachines(ctx)
if err != nil {
return plan, fmt.Errorf("list machines: %w", err)
}
seqOp := &SequenceOperation{}
// TODO: figure out how to return a warning if there are machines down. Embed the machinesDown in the plan?
// WARNING: failed to run a service container on machine '%s' which is Down.
var machinesDown []*pb.MachineInfo
for _, m := range machines {
// Skip machines that are down but collect them to report a warning later.
if m.State == pb.MachineMember_DOWN {
machinesDown = append(machinesDown, m.Machine)
continue
}
containers := containersOnMachine[m.Machine.Id]
ops, err := reconcileGlobalContainer(containers, spec, plan.ServiceID, m.Machine.Id)
if err != nil {
return plan, err
}
seqOp.Operations = append(seqOp.Operations, ops...)
}
plan.Operation = seqOp
return plan, nil
}
// reconcileGlobalContainer returns a sequence of operations to reconcile containers on a machine for a global service.
// It ensures exactly one container with the desired spec is running on the machine by creating a new container and
// removing old ones. If there is a host port conflict, it stops the old container before starting a new one.
func reconcileGlobalContainer(
containers []api.MachineContainer, spec api.ServiceSpec, serviceID, machineID string,
) ([]Operation, error) {
var ops []Operation
if len(containers) == 0 {
// No containers on this machine, create a new one.
ops = append(ops, &RunContainerOperation{
ServiceID: serviceID,
Spec: spec,
MachineID: machineID,
})
return ops, nil
}
// Check if there is a container with the same spec already running. If so, remove the rest.
upToDate := false
for i, c := range containers {
if !c.Container.State.Running || c.Container.State.Paused {
// Skip containers that are not running.
continue
}
svcSpec, err := c.Container.ServiceSpec()
if err != nil {
return nil, fmt.Errorf("get service spec: %w", err)
}
if svcSpec.Equals(spec) {
// The container is already running with the same spec.
upToDate = true
for j, old := range containers {
if i == j {
continue
}
ops = append(ops, &RemoveContainerOperation{
ServiceID: serviceID,
ContainerID: old.Container.ID,
MachineID: old.MachineID,
})
}
break
}
}
if upToDate {
return ops, nil
}
// The machine has containers but none of them match the new spec.
// Stop the old running containers that have conflicting ports with the new spec before running a new one.
for _, c := range containers {
if c.Container.State.Running {
conflictingPorts, err := c.Container.ConflictingServicePorts(spec.Ports)
if err != nil {
return nil, fmt.Errorf("check conflicting ports: %w", err)
}
if len(conflictingPorts) > 0 {
// Stop the running container with conflicting ports.
ops = append(ops, &StopContainerOperation{
ServiceID: serviceID,
ContainerID: c.Container.ID,
MachineID: c.MachineID,
})
}
}
}
// Run a new container.
ops = append(ops, &RunContainerOperation{
ServiceID: serviceID,
Spec: spec,
MachineID: machineID,
})
// Remove the old containers.
for _, c := range containers {
ops = append(ops, &RemoveContainerOperation{
ServiceID: serviceID,
ContainerID: c.Container.ID,
MachineID: c.MachineID,
})
}
return ops, nil
}
+1
View File
@@ -18,6 +18,7 @@ type MachineConnection struct {
TCP netip.AddrPort `toml:"tcp,omitempty"` TCP netip.AddrPort `toml:"tcp,omitempty"`
Host string `toml:"host,omitempty"` Host string `toml:"host,omitempty"`
PublicKey secret.Secret `toml:"public_key,omitempty"` PublicKey secret.Secret `toml:"public_key,omitempty"`
SSHKeyFile string `toml:"ssh_key_file,omitempty"`
} }
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port". // SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
+22 -10
View File
@@ -17,24 +17,36 @@ type RemoteMachine struct {
KeyPath string KeyPath string
} }
func installCmd(user string) string {
sudoPrefix := "sudo"
// 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
curlBashCmd := fmt.Sprintf(
"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),
)
}
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 { func provisionMachine(ctx context.Context, exec sshexec.Executor) 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)
} }
sudoPrefix, env := "", ""
if user != "root" { installCmd := installCmd(user)
sudoPrefix = "sudo"
// 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
}
fmt.Println("Downloading Uncloud install script:", installScriptURL) fmt.Println("Downloading Uncloud install script:", installScriptURL)
curlBashCmd := fmt.Sprintf(
"curl -fsSL %s | %s %s bash", sshexec.Quote(installScriptURL), sudoPrefix, sshexec.Quote(env), cmd := sshexec.QuoteCommand("bash", "-c", "set -o pipefail; "+installCmd)
)
cmd := sshexec.QuoteCommand("bash", "-c", "set -o pipefail; "+curlBashCmd)
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)
} }
+19
View File
@@ -0,0 +1,19 @@
package cli
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestInstallCmd(t *testing.T) {
t.Run("root", func(t *testing.T) {
cmd := installCmd("root")
assert.NotContains(t, cmd, "sudo")
})
t.Run("nonroot", func(t *testing.T) {
cmd := installCmd("nonroot")
assert.Contains(t, cmd, "sudo")
})
}
+15
View File
@@ -5,8 +5,23 @@ import (
"os" "os"
"os/user" "os/user"
"strconv" "strconv"
"strings"
) )
func ExpandHomeDir(path string) string {
if len(path) == 0 {
return path
}
if path[0] == '~' {
home, err := os.UserHomeDir()
if err != nil {
return path
}
return strings.Replace(path, "~", home, 1)
}
return path
}
// LookupUIDGID returns the user and group IDs for the given username. // LookupUIDGID returns the user and group IDs for the given username.
func LookupUIDGID(username string) (uid, gid int, err error) { func LookupUIDGID(username string) (uid, gid int, err error) {
usr, err := user.Lookup(username) usr, err := user.Lookup(username)
+22
View File
@@ -0,0 +1,22 @@
package fs
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestExpandHomeDir(t *testing.T) {
t.Run("empty", func(t *testing.T) {
assert.Equal(t, "", ExpandHomeDir(""))
})
t.Run("no home", func(t *testing.T) {
assert.Equal(t, "/path", ExpandHomeDir("/path"))
})
t.Run("home", func(t *testing.T) {
t.Setenv("HOME", "/home/user")
assert.Equal(t, "/home/user/path", ExpandHomeDir("~/path"))
})
}
+319 -105
View File
@@ -152,6 +152,101 @@ func (x *CreateContainerResponse) GetResponse() []byte {
return nil return nil
} }
type InspectContainerRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
}
func (x *InspectContainerRequest) Reset() {
*x = InspectContainerRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *InspectContainerRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*InspectContainerRequest) ProtoMessage() {}
func (x *InspectContainerRequest) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[2]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use InspectContainerRequest.ProtoReflect.Descriptor instead.
func (*InspectContainerRequest) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{2}
}
func (x *InspectContainerRequest) GetId() string {
if x != nil {
return x.Id
}
return ""
}
type InspectContainerResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// JSON serialized container.InspectResponse.
Response []byte `protobuf:"bytes,1,opt,name=response,proto3" json:"response,omitempty"`
}
func (x *InspectContainerResponse) Reset() {
*x = InspectContainerResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *InspectContainerResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*InspectContainerResponse) ProtoMessage() {}
func (x *InspectContainerResponse) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[3]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use InspectContainerResponse.ProtoReflect.Descriptor instead.
func (*InspectContainerResponse) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{3}
}
func (x *InspectContainerResponse) GetResponse() []byte {
if x != nil {
return x.Response
}
return nil
}
type StartContainerRequest struct { type StartContainerRequest struct {
state protoimpl.MessageState state protoimpl.MessageState
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
@@ -165,7 +260,7 @@ type StartContainerRequest struct {
func (x *StartContainerRequest) Reset() { func (x *StartContainerRequest) Reset() {
*x = StartContainerRequest{} *x = StartContainerRequest{}
if protoimpl.UnsafeEnabled { if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[2] mi := &file_internal_machine_api_pb_docker_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -178,7 +273,7 @@ func (x *StartContainerRequest) String() string {
func (*StartContainerRequest) ProtoMessage() {} func (*StartContainerRequest) ProtoMessage() {}
func (x *StartContainerRequest) ProtoReflect() protoreflect.Message { func (x *StartContainerRequest) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[2] mi := &file_internal_machine_api_pb_docker_proto_msgTypes[4]
if protoimpl.UnsafeEnabled && x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -191,7 +286,7 @@ func (x *StartContainerRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartContainerRequest.ProtoReflect.Descriptor instead. // Deprecated: Use StartContainerRequest.ProtoReflect.Descriptor instead.
func (*StartContainerRequest) Descriptor() ([]byte, []int) { func (*StartContainerRequest) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{2} return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{4}
} }
func (x *StartContainerRequest) GetId() string { func (x *StartContainerRequest) GetId() string {
@@ -208,6 +303,62 @@ func (x *StartContainerRequest) GetOptions() []byte {
return nil return nil
} }
type StopContainerRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
// JSON serialized container.StopOptions.
Options []byte `protobuf:"bytes,2,opt,name=options,proto3" json:"options,omitempty"`
}
func (x *StopContainerRequest) Reset() {
*x = StopContainerRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *StopContainerRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StopContainerRequest) ProtoMessage() {}
func (x *StopContainerRequest) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[5]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StopContainerRequest.ProtoReflect.Descriptor instead.
func (*StopContainerRequest) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{5}
}
func (x *StopContainerRequest) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *StopContainerRequest) GetOptions() []byte {
if x != nil {
return x.Options
}
return nil
}
type ListContainersRequest struct { type ListContainersRequest struct {
state protoimpl.MessageState state protoimpl.MessageState
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
@@ -220,7 +371,7 @@ type ListContainersRequest struct {
func (x *ListContainersRequest) Reset() { func (x *ListContainersRequest) Reset() {
*x = ListContainersRequest{} *x = ListContainersRequest{}
if protoimpl.UnsafeEnabled { if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[3] mi := &file_internal_machine_api_pb_docker_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -233,7 +384,7 @@ func (x *ListContainersRequest) String() string {
func (*ListContainersRequest) ProtoMessage() {} func (*ListContainersRequest) ProtoMessage() {}
func (x *ListContainersRequest) ProtoReflect() protoreflect.Message { func (x *ListContainersRequest) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[3] mi := &file_internal_machine_api_pb_docker_proto_msgTypes[6]
if protoimpl.UnsafeEnabled && x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -246,7 +397,7 @@ func (x *ListContainersRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListContainersRequest.ProtoReflect.Descriptor instead. // Deprecated: Use ListContainersRequest.ProtoReflect.Descriptor instead.
func (*ListContainersRequest) Descriptor() ([]byte, []int) { func (*ListContainersRequest) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{3} return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{6}
} }
func (x *ListContainersRequest) GetOptions() []byte { func (x *ListContainersRequest) GetOptions() []byte {
@@ -268,7 +419,7 @@ type ListContainersResponse struct {
func (x *ListContainersResponse) Reset() { func (x *ListContainersResponse) Reset() {
*x = ListContainersResponse{} *x = ListContainersResponse{}
if protoimpl.UnsafeEnabled { if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[4] mi := &file_internal_machine_api_pb_docker_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -281,7 +432,7 @@ func (x *ListContainersResponse) String() string {
func (*ListContainersResponse) ProtoMessage() {} func (*ListContainersResponse) ProtoMessage() {}
func (x *ListContainersResponse) ProtoReflect() protoreflect.Message { func (x *ListContainersResponse) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[4] mi := &file_internal_machine_api_pb_docker_proto_msgTypes[7]
if protoimpl.UnsafeEnabled && x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -294,7 +445,7 @@ func (x *ListContainersResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListContainersResponse.ProtoReflect.Descriptor instead. // Deprecated: Use ListContainersResponse.ProtoReflect.Descriptor instead.
func (*ListContainersResponse) Descriptor() ([]byte, []int) { func (*ListContainersResponse) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{4} return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{7}
} }
func (x *ListContainersResponse) GetMessages() []*MachineContainers { func (x *ListContainersResponse) GetMessages() []*MachineContainers {
@@ -310,14 +461,14 @@ type MachineContainers struct {
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
Metadata *Metadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` Metadata *Metadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"`
// JSON serialized []container.Summary. // JSON serialized []container.ContainerJSON.
Containers []byte `protobuf:"bytes,2,opt,name=containers,proto3" json:"containers,omitempty"` Containers []byte `protobuf:"bytes,2,opt,name=containers,proto3" json:"containers,omitempty"`
} }
func (x *MachineContainers) Reset() { func (x *MachineContainers) Reset() {
*x = MachineContainers{} *x = MachineContainers{}
if protoimpl.UnsafeEnabled { if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[5] mi := &file_internal_machine_api_pb_docker_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -330,7 +481,7 @@ func (x *MachineContainers) String() string {
func (*MachineContainers) ProtoMessage() {} func (*MachineContainers) ProtoMessage() {}
func (x *MachineContainers) ProtoReflect() protoreflect.Message { func (x *MachineContainers) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[5] mi := &file_internal_machine_api_pb_docker_proto_msgTypes[8]
if protoimpl.UnsafeEnabled && x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -343,7 +494,7 @@ func (x *MachineContainers) ProtoReflect() protoreflect.Message {
// Deprecated: Use MachineContainers.ProtoReflect.Descriptor instead. // Deprecated: Use MachineContainers.ProtoReflect.Descriptor instead.
func (*MachineContainers) Descriptor() ([]byte, []int) { func (*MachineContainers) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{5} return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{8}
} }
func (x *MachineContainers) GetMetadata() *Metadata { func (x *MachineContainers) GetMetadata() *Metadata {
@@ -373,7 +524,7 @@ type RemoveContainerRequest struct {
func (x *RemoveContainerRequest) Reset() { func (x *RemoveContainerRequest) Reset() {
*x = RemoveContainerRequest{} *x = RemoveContainerRequest{}
if protoimpl.UnsafeEnabled { if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[6] mi := &file_internal_machine_api_pb_docker_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -386,7 +537,7 @@ func (x *RemoveContainerRequest) String() string {
func (*RemoveContainerRequest) ProtoMessage() {} func (*RemoveContainerRequest) ProtoMessage() {}
func (x *RemoveContainerRequest) ProtoReflect() protoreflect.Message { func (x *RemoveContainerRequest) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[6] mi := &file_internal_machine_api_pb_docker_proto_msgTypes[9]
if protoimpl.UnsafeEnabled && x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -399,7 +550,7 @@ func (x *RemoveContainerRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveContainerRequest.ProtoReflect.Descriptor instead. // Deprecated: Use RemoveContainerRequest.ProtoReflect.Descriptor instead.
func (*RemoveContainerRequest) Descriptor() ([]byte, []int) { func (*RemoveContainerRequest) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{6} return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{9}
} }
func (x *RemoveContainerRequest) GetId() string { func (x *RemoveContainerRequest) GetId() string {
@@ -429,7 +580,7 @@ type PullImageRequest struct {
func (x *PullImageRequest) Reset() { func (x *PullImageRequest) Reset() {
*x = PullImageRequest{} *x = PullImageRequest{}
if protoimpl.UnsafeEnabled { if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[7] mi := &file_internal_machine_api_pb_docker_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -442,7 +593,7 @@ func (x *PullImageRequest) String() string {
func (*PullImageRequest) ProtoMessage() {} func (*PullImageRequest) ProtoMessage() {}
func (x *PullImageRequest) ProtoReflect() protoreflect.Message { func (x *PullImageRequest) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[7] mi := &file_internal_machine_api_pb_docker_proto_msgTypes[10]
if protoimpl.UnsafeEnabled && x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -455,7 +606,7 @@ func (x *PullImageRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use PullImageRequest.ProtoReflect.Descriptor instead. // Deprecated: Use PullImageRequest.ProtoReflect.Descriptor instead.
func (*PullImageRequest) Descriptor() ([]byte, []int) { func (*PullImageRequest) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{7} return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{10}
} }
func (x *PullImageRequest) GetImage() string { func (x *PullImageRequest) GetImage() string {
@@ -484,7 +635,7 @@ type JSONMessage struct {
func (x *JSONMessage) Reset() { func (x *JSONMessage) Reset() {
*x = JSONMessage{} *x = JSONMessage{}
if protoimpl.UnsafeEnabled { if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[8] mi := &file_internal_machine_api_pb_docker_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -497,7 +648,7 @@ func (x *JSONMessage) String() string {
func (*JSONMessage) ProtoMessage() {} func (*JSONMessage) ProtoMessage() {}
func (x *JSONMessage) ProtoReflect() protoreflect.Message { func (x *JSONMessage) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_docker_proto_msgTypes[8] mi := &file_internal_machine_api_pb_docker_proto_msgTypes[11]
if protoimpl.UnsafeEnabled && x != nil { if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -510,7 +661,7 @@ func (x *JSONMessage) ProtoReflect() protoreflect.Message {
// Deprecated: Use JSONMessage.ProtoReflect.Descriptor instead. // Deprecated: Use JSONMessage.ProtoReflect.Descriptor instead.
func (*JSONMessage) Descriptor() ([]byte, []int) { func (*JSONMessage) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{8} return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{11}
} }
func (x *JSONMessage) GetMessage() []byte { func (x *JSONMessage) GetMessage() []byte {
@@ -544,62 +695,82 @@ var file_internal_machine_api_pb_docker_proto_rawDesc = []byte{
0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x22, 0x41, 0x0a, 0x15, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x22, 0x29, 0x0a, 0x17, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61,
0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x36, 0x0a, 0x18, 0x49,
0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52,
0x6f, 0x6e, 0x73, 0x22, 0x31, 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f,
0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x6e, 0x73, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f,
0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x41, 0x0a, 0x15, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x74,
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x4c, 0x0a, 0x16, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02,
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18, 0x0a, 0x07,
0x12, 0x32, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6f,
0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x40, 0x0a, 0x14, 0x53, 0x74, 0x6f, 0x70, 0x43, 0x6f,
0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x08, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x22, 0x5e, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x43,
0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x29, 0x0a, 0x08, 0x6d, 0x65, 0x74,
0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70,
0x69, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61,
0x64, 0x61, 0x74, 0x61, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65,
0x72, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69,
0x6e, 0x65, 0x72, 0x73, 0x22, 0x42, 0x0a, 0x16, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f,
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e,
0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18,
0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52,
0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x42, 0x0a, 0x10, 0x50, 0x75, 0x6c, 0x6c, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x31, 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74,
0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01,
0x67, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x28, 0x0c, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x4c, 0x0a, 0x16, 0x4c,
0x01, 0x28, 0x0c, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x27, 0x0a, 0x0b, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73,
0x4a, 0x53, 0x4f, 0x4e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61,
0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0xe7, 0x02, 0x0a, 0x06, 0x44, 0x6f, 0x63, 0x6b, 0x65, 0x72, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52,
0x12, 0x4c, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x5e, 0x0a, 0x11, 0x4d, 0x61, 0x63,
0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x68, 0x69, 0x6e, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x29,
0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
0x1a, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52,
0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x44, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x6f, 0x6e,
0x0a, 0x0e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x63,
0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x22, 0x42, 0x0a, 0x16, 0x52, 0x65, 0x6d,
0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75,
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x6d, 0x70, 0x74, 0x79, 0x12, 0x49, 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x02, 0x69, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02,
0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x42, 0x0a,
0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x10, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f,
0x46, 0x0a, 0x0f, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e,
0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x73, 0x22, 0x27, 0x0a, 0x0b, 0x4a, 0x53, 0x4f, 0x4e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x0c, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0xfc, 0x03, 0x0a, 0x06, 0x44,
0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x36, 0x0a, 0x09, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6f, 0x63, 0x6b, 0x65, 0x72, 0x12, 0x4c, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43,
0x6d, 0x61, 0x67, 0x65, 0x12, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43,
0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65,
0x69, 0x2e, 0x4a, 0x53, 0x4f, 0x4e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x30, 0x01, 0x42, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61,
0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x10, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f,
0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e,
0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 0x73, 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70,
0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x44, 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, 0x6f, 0x6e,
0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61,
0x72, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x42, 0x0a, 0x0d, 0x53, 0x74,
0x6f, 0x70, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x19, 0x2e, 0x61, 0x70,
0x69, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x49,
0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73,
0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61,
0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61,
0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72,
0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x0f, 0x52, 0x65, 0x6d,
0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61,
0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e,
0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74,
0x79, 0x12, 0x36, 0x0a, 0x09, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x15,
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4a, 0x53, 0x4f, 0x4e,
0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x30, 0x01, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74,
0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73,
0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72,
0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f,
0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
} }
var ( var (
@@ -614,35 +785,42 @@ func file_internal_machine_api_pb_docker_proto_rawDescGZIP() []byte {
return file_internal_machine_api_pb_docker_proto_rawDescData return file_internal_machine_api_pb_docker_proto_rawDescData
} }
var file_internal_machine_api_pb_docker_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_internal_machine_api_pb_docker_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_internal_machine_api_pb_docker_proto_goTypes = []any{ var file_internal_machine_api_pb_docker_proto_goTypes = []any{
(*CreateContainerRequest)(nil), // 0: api.CreateContainerRequest (*CreateContainerRequest)(nil), // 0: api.CreateContainerRequest
(*CreateContainerResponse)(nil), // 1: api.CreateContainerResponse (*CreateContainerResponse)(nil), // 1: api.CreateContainerResponse
(*StartContainerRequest)(nil), // 2: api.StartContainerRequest (*InspectContainerRequest)(nil), // 2: api.InspectContainerRequest
(*ListContainersRequest)(nil), // 3: api.ListContainersRequest (*InspectContainerResponse)(nil), // 3: api.InspectContainerResponse
(*ListContainersResponse)(nil), // 4: api.ListContainersResponse (*StartContainerRequest)(nil), // 4: api.StartContainerRequest
(*MachineContainers)(nil), // 5: api.MachineContainers (*StopContainerRequest)(nil), // 5: api.StopContainerRequest
(*RemoveContainerRequest)(nil), // 6: api.RemoveContainerRequest (*ListContainersRequest)(nil), // 6: api.ListContainersRequest
(*PullImageRequest)(nil), // 7: api.PullImageRequest (*ListContainersResponse)(nil), // 7: api.ListContainersResponse
(*JSONMessage)(nil), // 8: api.JSONMessage (*MachineContainers)(nil), // 8: api.MachineContainers
(*Metadata)(nil), // 9: api.Metadata (*RemoveContainerRequest)(nil), // 9: api.RemoveContainerRequest
(*emptypb.Empty)(nil), // 10: google.protobuf.Empty (*PullImageRequest)(nil), // 10: api.PullImageRequest
(*JSONMessage)(nil), // 11: api.JSONMessage
(*Metadata)(nil), // 12: api.Metadata
(*emptypb.Empty)(nil), // 13: google.protobuf.Empty
} }
var file_internal_machine_api_pb_docker_proto_depIdxs = []int32{ var file_internal_machine_api_pb_docker_proto_depIdxs = []int32{
5, // 0: api.ListContainersResponse.messages:type_name -> api.MachineContainers 8, // 0: api.ListContainersResponse.messages:type_name -> api.MachineContainers
9, // 1: api.MachineContainers.metadata:type_name -> api.Metadata 12, // 1: api.MachineContainers.metadata:type_name -> api.Metadata
0, // 2: api.Docker.CreateContainer:input_type -> api.CreateContainerRequest 0, // 2: api.Docker.CreateContainer:input_type -> api.CreateContainerRequest
2, // 3: api.Docker.StartContainer:input_type -> api.StartContainerRequest 2, // 3: api.Docker.InspectContainer:input_type -> api.InspectContainerRequest
3, // 4: api.Docker.ListContainers:input_type -> api.ListContainersRequest 4, // 4: api.Docker.StartContainer:input_type -> api.StartContainerRequest
6, // 5: api.Docker.RemoveContainer:input_type -> api.RemoveContainerRequest 5, // 5: api.Docker.StopContainer:input_type -> api.StopContainerRequest
7, // 6: api.Docker.PullImage:input_type -> api.PullImageRequest 6, // 6: api.Docker.ListContainers:input_type -> api.ListContainersRequest
1, // 7: api.Docker.CreateContainer:output_type -> api.CreateContainerResponse 9, // 7: api.Docker.RemoveContainer:input_type -> api.RemoveContainerRequest
10, // 8: api.Docker.StartContainer:output_type -> google.protobuf.Empty 10, // 8: api.Docker.PullImage:input_type -> api.PullImageRequest
4, // 9: api.Docker.ListContainers:output_type -> api.ListContainersResponse 1, // 9: api.Docker.CreateContainer:output_type -> api.CreateContainerResponse
10, // 10: api.Docker.RemoveContainer:output_type -> google.protobuf.Empty 3, // 10: api.Docker.InspectContainer:output_type -> api.InspectContainerResponse
8, // 11: api.Docker.PullImage:output_type -> api.JSONMessage 13, // 11: api.Docker.StartContainer:output_type -> google.protobuf.Empty
7, // [7:12] is the sub-list for method output_type 13, // 12: api.Docker.StopContainer:output_type -> google.protobuf.Empty
2, // [2:7] is the sub-list for method input_type 7, // 13: api.Docker.ListContainers:output_type -> api.ListContainersResponse
13, // 14: api.Docker.RemoveContainer:output_type -> google.protobuf.Empty
11, // 15: api.Docker.PullImage:output_type -> api.JSONMessage
9, // [9:16] is the sub-list for method output_type
2, // [2:9] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name 2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee 2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name 0, // [0:2] is the sub-list for field type_name
@@ -680,7 +858,7 @@ func file_internal_machine_api_pb_docker_proto_init() {
} }
} }
file_internal_machine_api_pb_docker_proto_msgTypes[2].Exporter = func(v any, i int) any { file_internal_machine_api_pb_docker_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*StartContainerRequest); i { switch v := v.(*InspectContainerRequest); i {
case 0: case 0:
return &v.state return &v.state
case 1: case 1:
@@ -692,7 +870,7 @@ func file_internal_machine_api_pb_docker_proto_init() {
} }
} }
file_internal_machine_api_pb_docker_proto_msgTypes[3].Exporter = func(v any, i int) any { file_internal_machine_api_pb_docker_proto_msgTypes[3].Exporter = func(v any, i int) any {
switch v := v.(*ListContainersRequest); i { switch v := v.(*InspectContainerResponse); i {
case 0: case 0:
return &v.state return &v.state
case 1: case 1:
@@ -704,7 +882,7 @@ func file_internal_machine_api_pb_docker_proto_init() {
} }
} }
file_internal_machine_api_pb_docker_proto_msgTypes[4].Exporter = func(v any, i int) any { file_internal_machine_api_pb_docker_proto_msgTypes[4].Exporter = func(v any, i int) any {
switch v := v.(*ListContainersResponse); i { switch v := v.(*StartContainerRequest); i {
case 0: case 0:
return &v.state return &v.state
case 1: case 1:
@@ -716,7 +894,7 @@ func file_internal_machine_api_pb_docker_proto_init() {
} }
} }
file_internal_machine_api_pb_docker_proto_msgTypes[5].Exporter = func(v any, i int) any { file_internal_machine_api_pb_docker_proto_msgTypes[5].Exporter = func(v any, i int) any {
switch v := v.(*MachineContainers); i { switch v := v.(*StopContainerRequest); i {
case 0: case 0:
return &v.state return &v.state
case 1: case 1:
@@ -728,7 +906,7 @@ func file_internal_machine_api_pb_docker_proto_init() {
} }
} }
file_internal_machine_api_pb_docker_proto_msgTypes[6].Exporter = func(v any, i int) any { file_internal_machine_api_pb_docker_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*RemoveContainerRequest); i { switch v := v.(*ListContainersRequest); i {
case 0: case 0:
return &v.state return &v.state
case 1: case 1:
@@ -740,7 +918,7 @@ func file_internal_machine_api_pb_docker_proto_init() {
} }
} }
file_internal_machine_api_pb_docker_proto_msgTypes[7].Exporter = func(v any, i int) any { file_internal_machine_api_pb_docker_proto_msgTypes[7].Exporter = func(v any, i int) any {
switch v := v.(*PullImageRequest); i { switch v := v.(*ListContainersResponse); i {
case 0: case 0:
return &v.state return &v.state
case 1: case 1:
@@ -752,6 +930,42 @@ func file_internal_machine_api_pb_docker_proto_init() {
} }
} }
file_internal_machine_api_pb_docker_proto_msgTypes[8].Exporter = func(v any, i int) any { file_internal_machine_api_pb_docker_proto_msgTypes[8].Exporter = func(v any, i int) any {
switch v := v.(*MachineContainers); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_internal_machine_api_pb_docker_proto_msgTypes[9].Exporter = func(v any, i int) any {
switch v := v.(*RemoveContainerRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_internal_machine_api_pb_docker_proto_msgTypes[10].Exporter = func(v any, i int) any {
switch v := v.(*PullImageRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_internal_machine_api_pb_docker_proto_msgTypes[11].Exporter = func(v any, i int) any {
switch v := v.(*JSONMessage); i { switch v := v.(*JSONMessage); i {
case 0: case 0:
return &v.state return &v.state
@@ -770,7 +984,7 @@ func file_internal_machine_api_pb_docker_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_internal_machine_api_pb_docker_proto_rawDesc, RawDescriptor: file_internal_machine_api_pb_docker_proto_rawDesc,
NumEnums: 0, NumEnums: 0,
NumMessages: 9, NumMessages: 12,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },
+18 -1
View File
@@ -9,7 +9,9 @@ import "internal/machine/api/pb/common.proto";
service Docker { service Docker {
rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse); rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse);
rpc InspectContainer(InspectContainerRequest) returns (InspectContainerResponse);
rpc StartContainer(StartContainerRequest) returns (google.protobuf.Empty); rpc StartContainer(StartContainerRequest) 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);
@@ -32,12 +34,27 @@ message CreateContainerResponse {
bytes response = 1; bytes response = 1;
} }
message InspectContainerRequest {
string id = 1;
}
message InspectContainerResponse {
// JSON serialized container.InspectResponse.
bytes response = 1;
}
message StartContainerRequest { message StartContainerRequest {
string id = 1; string id = 1;
// JSON serialized container.StartOptions. // JSON serialized container.StartOptions.
bytes options = 2; bytes options = 2;
} }
message StopContainerRequest {
string id = 1;
// JSON serialized container.StopOptions.
bytes options = 2;
}
message ListContainersRequest { message ListContainersRequest {
// JSON serialized container.ListOptions. // JSON serialized container.ListOptions.
bytes options = 1; bytes options = 1;
@@ -50,7 +67,7 @@ message ListContainersResponse {
message MachineContainers { message MachineContainers {
Metadata metadata = 1; Metadata metadata = 1;
// JSON serialized []container.Summary. // JSON serialized []container.ContainerJSON.
bytes containers = 2; bytes containers = 2;
} }
+76
View File
@@ -21,7 +21,9 @@ const _ = grpc.SupportPackageIsVersion9
const ( const (
Docker_CreateContainer_FullMethodName = "/api.Docker/CreateContainer" Docker_CreateContainer_FullMethodName = "/api.Docker/CreateContainer"
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_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"
@@ -32,7 +34,9 @@ const (
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type DockerClient interface { type DockerClient interface {
CreateContainer(ctx context.Context, in *CreateContainerRequest, opts ...grpc.CallOption) (*CreateContainerResponse, error) CreateContainer(ctx context.Context, in *CreateContainerRequest, opts ...grpc.CallOption) (*CreateContainerResponse, error)
InspectContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*InspectContainerResponse, error)
StartContainer(ctx context.Context, in *StartContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) StartContainer(ctx context.Context, in *StartContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
StopContainer(ctx context.Context, in *StopContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
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)
@@ -56,6 +60,16 @@ func (c *dockerClient) CreateContainer(ctx context.Context, in *CreateContainerR
return out, nil return out, nil
} }
func (c *dockerClient) InspectContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*InspectContainerResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(InspectContainerResponse)
err := c.cc.Invoke(ctx, Docker_InspectContainer_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dockerClient) StartContainer(ctx context.Context, in *StartContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { func (c *dockerClient) StartContainer(ctx context.Context, in *StartContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty) out := new(emptypb.Empty)
@@ -66,6 +80,16 @@ func (c *dockerClient) StartContainer(ctx context.Context, in *StartContainerReq
return out, nil return out, nil
} }
func (c *dockerClient) StopContainer(ctx context.Context, in *StopContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, Docker_StopContainer_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dockerClient) ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ListContainersResponse, error) { func (c *dockerClient) ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ListContainersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListContainersResponse) out := new(ListContainersResponse)
@@ -110,7 +134,9 @@ type Docker_PullImageClient = grpc.ServerStreamingClient[JSONMessage]
// for forward compatibility. // for forward compatibility.
type DockerServer interface { type DockerServer interface {
CreateContainer(context.Context, *CreateContainerRequest) (*CreateContainerResponse, error) CreateContainer(context.Context, *CreateContainerRequest) (*CreateContainerResponse, error)
InspectContainer(context.Context, *InspectContainerRequest) (*InspectContainerResponse, error)
StartContainer(context.Context, *StartContainerRequest) (*emptypb.Empty, error) StartContainer(context.Context, *StartContainerRequest) (*emptypb.Empty, error)
StopContainer(context.Context, *StopContainerRequest) (*emptypb.Empty, error)
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
@@ -127,9 +153,15 @@ type UnimplementedDockerServer struct{}
func (UnimplementedDockerServer) CreateContainer(context.Context, *CreateContainerRequest) (*CreateContainerResponse, error) { func (UnimplementedDockerServer) CreateContainer(context.Context, *CreateContainerRequest) (*CreateContainerResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateContainer not implemented") return nil, status.Errorf(codes.Unimplemented, "method CreateContainer not implemented")
} }
func (UnimplementedDockerServer) InspectContainer(context.Context, *InspectContainerRequest) (*InspectContainerResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method InspectContainer not implemented")
}
func (UnimplementedDockerServer) StartContainer(context.Context, *StartContainerRequest) (*emptypb.Empty, error) { func (UnimplementedDockerServer) StartContainer(context.Context, *StartContainerRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method StartContainer not implemented") return nil, status.Errorf(codes.Unimplemented, "method StartContainer not implemented")
} }
func (UnimplementedDockerServer) StopContainer(context.Context, *StopContainerRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method StopContainer not implemented")
}
func (UnimplementedDockerServer) ListContainers(context.Context, *ListContainersRequest) (*ListContainersResponse, error) { func (UnimplementedDockerServer) ListContainers(context.Context, *ListContainersRequest) (*ListContainersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListContainers not implemented") return nil, status.Errorf(codes.Unimplemented, "method ListContainers not implemented")
} }
@@ -178,6 +210,24 @@ func _Docker_CreateContainer_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _Docker_InspectContainer_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).InspectContainer(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Docker_InspectContainer_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DockerServer).InspectContainer(ctx, req.(*InspectContainerRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Docker_StartContainer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _Docker_StartContainer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartContainerRequest) in := new(StartContainerRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@@ -196,6 +246,24 @@ func _Docker_StartContainer_Handler(srv interface{}, ctx context.Context, dec fu
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _Docker_StopContainer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StopContainerRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DockerServer).StopContainer(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Docker_StopContainer_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DockerServer).StopContainer(ctx, req.(*StopContainerRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Docker_ListContainers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _Docker_ListContainers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListContainersRequest) in := new(ListContainersRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@@ -254,10 +322,18 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
MethodName: "CreateContainer", MethodName: "CreateContainer",
Handler: _Docker_CreateContainer_Handler, Handler: _Docker_CreateContainer_Handler,
}, },
{
MethodName: "InspectContainer",
Handler: _Docker_InspectContainer_Handler,
},
{ {
MethodName: "StartContainer", MethodName: "StartContainer",
Handler: _Docker_StartContainer_Handler, Handler: _Docker_StartContainer_Handler,
}, },
{
MethodName: "StopContainer",
Handler: _Docker_StopContainer_Handler,
},
{ {
MethodName: "ListContainers", MethodName: "ListContainers",
Handler: _Docker_ListContainers_Handler, Handler: _Docker_ListContainers_Handler,
+4 -4
View File
@@ -89,18 +89,18 @@ func (c *Controller) Run(ctx context.Context) error {
} }
} }
// filterAvailableContainers filters out containers that are likely unavailable from this machine. 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(containerRecords []store.ContainerRecord) ([]api.Container, error) {
containers := make([]*api.Container, len(containerRecords)) containers := make([]api.Container, len(containerRecords))
for i, cr := range containerRecords { for i, cr := range containerRecords {
containers[i] = cr.Container containers[i] = cr.Container
} }
return containers, nil return containers, nil
} }
func (c *Controller) generateConfig(containers []*api.Container) error { func (c *Controller) generateConfig(containers []api.Container) 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)
+6 -2
View File
@@ -210,9 +210,13 @@ func (c *Cluster) ListMachines(ctx context.Context, _ *emptypb.Empty) (*pb.ListM
state := pb.MachineMember_DOWN state := pb.MachineMember_DOWN
addr, _ := m.Network.ManagementIp.ToAddr() addr, _ := m.Network.ManagementIp.ToAddr()
for _, s := range states { for _, s := range states {
if s.Addr.Addr().Compare(addr) == 0 && if s.Addr.Addr().Compare(addr) == 0 {
(s.State == corrosion.MembershipStateAlive || s.State == corrosion.MembershipStateSuspect) { switch s.State {
case corrosion.MembershipStateAlive:
state = pb.MachineMember_UP state = pb.MachineMember_UP
case corrosion.MembershipStateSuspect:
state = pb.MachineMember_SUSPECT
}
break break
} }
} }
+42 -1
View File
@@ -86,6 +86,26 @@ func (c *Client) CreateContainer(
return resp, nil return resp, nil
} }
// InspectContainer returns the container information for the given container ID.
func (c *Client) InspectContainer(ctx context.Context, id string) (types.ContainerJSON, error) {
var resp types.ContainerJSON
grpcResp, err := c.grpcClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
if err != nil {
if s, ok := status.FromError(err); ok {
if s.Code() == codes.NotFound {
return resp, errdefs.NotFound(err)
}
}
return resp, err
}
if err = json.Unmarshal(grpcResp.Response, &resp); err != nil {
return resp, fmt.Errorf("unmarshal gRPC response: %w", err)
}
return resp, nil
}
// StartContainer starts a container with the given ID and options. // StartContainer starts a container with the given ID and options.
func (c *Client) StartContainer(ctx context.Context, id string, opts container.StartOptions) error { func (c *Client) StartContainer(ctx context.Context, id string, opts container.StartOptions) error {
optsBytes, err := json.Marshal(opts) optsBytes, err := json.Marshal(opts)
@@ -107,9 +127,30 @@ func (c *Client) StartContainer(ctx context.Context, id string, opts container.S
return err return err
} }
// StopContainer stops a container with the given ID and options.
func (c *Client) StopContainer(ctx context.Context, id string, opts container.StopOptions) error {
optsBytes, err := json.Marshal(opts)
if err != nil {
return fmt.Errorf("marshal options: %w", err)
}
_, err = c.grpcClient.StopContainer(ctx, &pb.StopContainerRequest{
Id: id,
Options: optsBytes,
})
if err != nil {
if s, ok := status.FromError(err); ok {
if s.Code() == codes.NotFound {
return errdefs.NotFound(err)
}
}
}
return err
}
type MachineContainers struct { type MachineContainers struct {
Metadata *pb.Metadata Metadata *pb.Metadata
Containers []types.Container Containers []types.ContainerJSON
} }
func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions) ([]MachineContainers, error) { func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions) ([]MachineContainers, error) {
+15 -5
View File
@@ -149,8 +149,9 @@ func (m *Manager) syncContainersToStore(ctx context.Context) error {
if err != nil { if err != nil {
return fmt.Errorf("list containers from store: %w", err) return fmt.Errorf("list containers from store: %w", err)
} }
// List only Uncloud service containers identified by their labels. // List only Uncloud service containers identified by their labels.
containers, err := m.client.ContainerList(ctx, dockercontainer.ListOptions{ containerSummaries, err := m.client.ContainerList(ctx, dockercontainer.ListOptions{
Filters: filters.NewArgs( Filters: filters.NewArgs(
filters.Arg("label", api.LabelServiceID), filters.Arg("label", api.LabelServiceID),
filters.Arg("label", api.LabelServiceName), filters.Arg("label", api.LabelServiceName),
@@ -161,11 +162,21 @@ func (m *Manager) syncContainersToStore(ctx context.Context) error {
return fmt.Errorf("list Docker containers: %w", err) return fmt.Errorf("list Docker containers: %w", err)
} }
// Delete containers that are not present in the Docker daemon from the store. // Inspect each container to get the full container details.
containers := make([]api.Container, len(containerSummaries))
for i, cs := range containerSummaries {
ctr, err := m.client.ContainerInspect(ctx, cs.ID)
if err != nil {
return fmt.Errorf("inspect container '%s': %w", cs.ID, err)
}
containers[i] = api.Container{ContainerJSON: ctr}
}
// Delete containers from the store that are no longer present in the Docker daemon.
var deleteIDs []string var deleteIDs []string
for _, sc := range storeContainers { for _, sc := range storeContainers {
found := false found := false
for i, _ := range containers { for i := range containers {
if containers[i].ID == sc.Container.ID { if containers[i].ID == sc.Container.ID {
found = true found = true
break break
@@ -184,8 +195,7 @@ func (m *Manager) syncContainersToStore(ctx context.Context) error {
} }
// Create or update the current Docker containers in the store. // Create or update the current Docker containers in the store.
for _, dc := range containers { for _, c := range containers {
c := &api.Container{Container: dc}
if err = m.store.CreateOrUpdateContainer(ctx, c, m.machineID); err != nil { if err = m.store.CreateOrUpdateContainer(ctx, c, m.machineID); err != nil {
storeErr = errors.Join(storeErr, fmt.Errorf("create or update container %q: %w", c.ID, err)) storeErr = errors.Join(storeErr, fmt.Errorf("create or update container %q: %w", c.ID, err))
} }
+1 -1
View File
@@ -9,6 +9,6 @@ import (
) )
// EnsureUncloudNetwork is a stub for darwin. // EnsureUncloudNetwork is a stub for darwin.
func (d *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix) error { func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix) error {
return fmt.Errorf("not supported on darwin") return fmt.Errorf("not supported on darwin")
} }
+5 -5
View File
@@ -14,10 +14,10 @@ import (
// EnsureUncloudNetwork creates the Docker bridge network NetworkName with the provided machine subnet // EnsureUncloudNetwork creates the Docker bridge network NetworkName with the provided machine subnet
// if it doesn't exist. If the network exists but has a different subnet, it removes and recreates the network. // if it doesn't exist. If the network exists but has a different subnet, it removes and recreates the network.
// It also configures iptables to allow container access from the WireGuard network. // It also configures iptables to allow container access from the WireGuard network.
func (d *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix) error { func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix) error {
// Ensure the Docker network 'uncloud' is created with the correct subnet. // Ensure the Docker network 'uncloud' is created with the correct subnet.
needsCreation := false needsCreation := false
nw, err := d.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{}) nw, err := m.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
if err != nil { if err != nil {
if !client.IsErrNotFound(err) { if !client.IsErrNotFound(err) {
return fmt.Errorf("inspect Docker network %q: %w", NetworkName, err) return fmt.Errorf("inspect Docker network %q: %w", NetworkName, err)
@@ -29,7 +29,7 @@ func (d *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix)
slog.Info( slog.Info(
"Removing Docker network with old subnet.", "name", NetworkName, "subnet", nw.IPAM.Config[0].Subnet, "Removing Docker network with old subnet.", "name", NetworkName, "subnet", nw.IPAM.Config[0].Subnet,
) )
if err = d.client.NetworkRemove(ctx, NetworkName); err != nil { if err = m.client.NetworkRemove(ctx, NetworkName); err != nil {
// It can still fail if the network is in use by a container. Leave it to the user to resolve the issue. // It can still fail if the network is in use by a container. Leave it to the user to resolve the issue.
return fmt.Errorf("remove Docker network %q: %w", NetworkName, err) return fmt.Errorf("remove Docker network %q: %w", NetworkName, err)
} }
@@ -37,7 +37,7 @@ func (d *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix)
} }
if needsCreation { if needsCreation {
if _, err = d.client.NetworkCreate( if _, err = m.client.NetworkCreate(
ctx, NetworkName, dnetwork.CreateOptions{ ctx, NetworkName, dnetwork.CreateOptions{
Driver: "bridge", Driver: "bridge",
Scope: "local", Scope: "local",
@@ -54,7 +54,7 @@ func (d *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix)
} }
slog.Info("Docker network created.", "name", NetworkName, "subnet", subnet.String()) slog.Info("Docker network created.", "name", NetworkName, "subnet", subnet.String())
if nw, err = d.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{}); err != nil { if nw, err = m.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{}); err != nil {
return fmt.Errorf("inspect Docker network %q: %w", NetworkName, err) return fmt.Errorf("inspect Docker network %q: %w", NetworkName, err)
} }
} }
+56 -10
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"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"
@@ -53,9 +54,9 @@ func (s *Server) CreateContainer(ctx context.Context, req *pb.CreateContainerReq
resp, err := s.client.ContainerCreate(ctx, &config, &hostConfig, &networkConfig, &platform, req.Name) resp, err := s.client.ContainerCreate(ctx, &config, &hostConfig, &networkConfig, &platform, req.Name)
if err != nil { if err != nil {
if client.IsErrNotFound(err) { if client.IsErrNotFound(err) {
return nil, status.Errorf(codes.NotFound, "create container: %v", err) return nil, status.Errorf(codes.NotFound, err.Error())
} }
return nil, status.Errorf(codes.Internal, "create container: %v", err) return nil, status.Errorf(codes.Internal, err.Error())
} }
respBytes, err := json.Marshal(resp) respBytes, err := json.Marshal(resp)
@@ -66,6 +67,24 @@ func (s *Server) CreateContainer(ctx context.Context, req *pb.CreateContainerReq
return &pb.CreateContainerResponse{Response: respBytes}, nil return &pb.CreateContainerResponse{Response: respBytes}, nil
} }
// InspectContainer returns the container information for the given container ID.
func (s *Server) InspectContainer(ctx context.Context, req *pb.InspectContainerRequest) (*pb.InspectContainerResponse, error) {
resp, 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())
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
}
return &pb.InspectContainerResponse{Response: respBytes}, nil
}
// StartContainer starts a container with the given ID and options. // StartContainer starts a container with the given ID and options.
func (s *Server) StartContainer(ctx context.Context, req *pb.StartContainerRequest) (*emptypb.Empty, error) { func (s *Server) StartContainer(ctx context.Context, req *pb.StartContainerRequest) (*emptypb.Empty, error) {
var opts container.StartOptions var opts container.StartOptions
@@ -77,9 +96,28 @@ func (s *Server) StartContainer(ctx context.Context, req *pb.StartContainerReque
if err := s.client.ContainerStart(ctx, req.Id, opts); err != nil { if err := s.client.ContainerStart(ctx, req.Id, opts); err != nil {
if client.IsErrNotFound(err) { if client.IsErrNotFound(err) {
return nil, status.Errorf(codes.NotFound, "start container: %v", err) return nil, status.Errorf(codes.NotFound, err.Error())
} }
return nil, status.Errorf(codes.Internal, "start container: %v", err) return nil, status.Errorf(codes.Internal, err.Error())
}
return &emptypb.Empty{}, nil
}
// StopContainer stops a container with the given ID and options.
func (s *Server) StopContainer(ctx context.Context, req *pb.StopContainerRequest) (*emptypb.Empty, error) {
var opts container.StopOptions
if len(req.Options) > 0 {
if err := json.Unmarshal(req.Options, &opts); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
}
}
if err := s.client.ContainerStop(ctx, req.Id, opts); err != nil {
if client.IsErrNotFound(err) {
return nil, status.Errorf(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, err.Error())
} }
return &emptypb.Empty{}, nil return &emptypb.Empty{}, nil
@@ -107,9 +145,17 @@ func (s *Server) ListContainers(ctx context.Context, req *pb.ListContainersReque
} }
} }
containers, err := s.client.ContainerList(ctx, opts) containerSummaries, err := s.client.ContainerList(ctx, opts)
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "list container: %v", err) return nil, status.Errorf(codes.Internal, err.Error())
}
containers := make([]types.ContainerJSON, len(containerSummaries))
for i, cs := range containerSummaries {
c, err := s.client.ContainerInspect(ctx, cs.ID)
if err != nil {
return nil, status.Errorf(codes.Internal, "inspect container %s: %v", cs.ID, err)
}
containers[i] = c
} }
containersBytes, err := json.Marshal(containers) containersBytes, err := json.Marshal(containers)
@@ -137,9 +183,9 @@ func (s *Server) RemoveContainer(ctx context.Context, req *pb.RemoveContainerReq
if err := s.client.ContainerRemove(ctx, req.Id, opts); err != nil { if err := s.client.ContainerRemove(ctx, req.Id, opts); err != nil {
if client.IsErrNotFound(err) { if client.IsErrNotFound(err) {
return nil, status.Errorf(codes.NotFound, "remove container: %v", err) return nil, status.Errorf(codes.NotFound, err.Error())
} }
return nil, status.Errorf(codes.Internal, "remove container: %v", err) return nil, status.Errorf(codes.Internal, err.Error())
} }
return &emptypb.Empty{}, nil return &emptypb.Empty{}, nil
@@ -158,7 +204,7 @@ func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreaming
respBody, err := s.client.ImagePull(ctx, req.Image, opts) respBody, err := s.client.ImagePull(ctx, req.Image, opts)
if err != nil { if err != nil {
return status.Errorf(codes.Internal, "pull image: %v", err) return status.Errorf(codes.Internal, err.Error())
} }
defer respBody.Close() defer respBody.Close()
@@ -189,7 +235,7 @@ func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreaming
case err = <-errCh: case err = <-errCh:
return err return err
case <-ctx.Done(): case <-ctx.Done():
return status.Errorf(codes.Canceled, "pull image: %v", ctx.Err()) return status.Errorf(codes.Canceled, ctx.Err().Error())
} }
} }
} }
+15 -12
View File
@@ -22,7 +22,7 @@ const (
) )
type ContainerRecord struct { type ContainerRecord struct {
Container *api.Container Container api.Container
MachineID string MachineID string
SyncStatus string SyncStatus string
UpdatedAt time.Time UpdatedAt time.Time
@@ -47,8 +47,11 @@ type DeleteOptions struct {
// CreateOrUpdateContainer creates a new container record or updates an existing one in the store database. // CreateOrUpdateContainer creates a new container record or updates an existing one in the store database.
// The container is associated with the given machine ID that indicates which machine the container is running on. // The container is associated with the given machine ID that indicates which machine the container is running on.
func (s *Store) CreateOrUpdateContainer(ctx context.Context, c *api.Container, machineID string) error { func (s *Store) CreateOrUpdateContainer(ctx context.Context, ctr api.Container, machineID string) error {
cJSON, err := json.Marshal(c) // Remove the environment variables from the container record before storing it in the database
// to avoid leaking secrets.
ctr.Config.Env = nil
cJSON, err := json.Marshal(ctr)
if err != nil { if err != nil {
return fmt.Errorf("marshal container: %w", err) return fmt.Errorf("marshal container: %w", err)
} }
@@ -63,19 +66,19 @@ func (s *Store) CreateOrUpdateContainer(ctx context.Context, c *api.Container, m
updated_at = excluded.updated_at updated_at = excluded.updated_at
WHERE containers.container != excluded.container WHERE containers.container != excluded.container
OR containers.machine_id != excluded.machine_id`, OR containers.machine_id != excluded.machine_id`,
c.ID, string(cJSON), machineID, SyncStatusSynced) ctr.ID, string(cJSON), machineID, SyncStatusSynced)
if err != nil { if err != nil {
return fmt.Errorf("upsert query: %w", err) return fmt.Errorf("upsert query: %w", err)
} }
if res.RowsAffected > 0 { if res.RowsAffected > 0 {
slog.Debug("Container record updated in store DB.", "id", c.ID, "machine_id", machineID) slog.Debug("Container record updated in store DB.", "id", ctr.ID, "machine_id", machineID)
} }
return nil return nil
} }
// ListContainers returns a list of container records from the store database that match the given options. // ListContainers returns a list of container records from the store database that match the given options.
func (s *Store) ListContainers(ctx context.Context, opts ListOptions) ([]*ContainerRecord, error) { func (s *Store) ListContainers(ctx context.Context, opts ListOptions) ([]ContainerRecord, error) {
q := sq.Select("container", "machine_id", "sync_status", "updated_at").From("containers"). q := sq.Select("container", "machine_id", "sync_status", "updated_at").From("containers").
Where(sq.Eq{"sync_status": SyncStatusSynced}) Where(sq.Eq{"sync_status": SyncStatusSynced})
@@ -105,7 +108,7 @@ func (s *Store) ListContainers(ctx context.Context, opts ListOptions) ([]*Contai
} }
defer rows.Close() defer rows.Close()
var containers []*ContainerRecord var containers []ContainerRecord
var cJSON, machineID, syncStatus, updatedAtStr string var cJSON, machineID, syncStatus, updatedAtStr string
var updatedAt time.Time var updatedAt time.Time
@@ -121,8 +124,8 @@ func (s *Store) ListContainers(ctx context.Context, opts ListOptions) ([]*Contai
if updatedAt, err = time.Parse(time.DateTime, updatedAtStr); err != nil { if updatedAt, err = time.Parse(time.DateTime, updatedAtStr); err != nil {
return nil, fmt.Errorf("parse updated_at: %w", err) return nil, fmt.Errorf("parse updated_at: %w", err)
} }
containers = append(containers, &ContainerRecord{ containers = append(containers, ContainerRecord{
Container: &c, Container: c,
MachineID: machineID, MachineID: machineID,
SyncStatus: syncStatus, SyncStatus: syncStatus,
UpdatedAt: updatedAt, UpdatedAt: updatedAt,
@@ -158,7 +161,7 @@ func (s *Store) DeleteContainers(ctx context.Context, opts DeleteOptions) error
// SubscribeContainers returns a list of containers and a channel that signals changes to the list. The channel doesn't // SubscribeContainers returns a list of containers and a channel that signals changes to the list. The channel doesn't
// receive any values, it just signals when a container(s) has been added, updated, or deleted in the database. // receive any values, it just signals when a container(s) has been added, updated, or deleted in the database.
func (s *Store) SubscribeContainers(ctx context.Context) ([]*ContainerRecord, <-chan struct{}, error) { func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-chan struct{}, error) {
// TODO: figure out whether we need sync_status at all. // TODO: figure out whether we need sync_status at all.
q := sq.Select("container", "machine_id", "sync_status", "updated_at").From("containers"). q := sq.Select("container", "machine_id", "sync_status", "updated_at").From("containers").
Where(sq.Eq{"sync_status": SyncStatusSynced}) Where(sq.Eq{"sync_status": SyncStatusSynced})
@@ -172,7 +175,7 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]*ContainerRecord, <-
return nil, nil, err return nil, nil, err
} }
var containers []*ContainerRecord var containers []ContainerRecord
var cJSON, updatedAtStr string var cJSON, updatedAtStr string
rows := sub.Rows() rows := sub.Rows()
@@ -188,7 +191,7 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]*ContainerRecord, <-
if cr.UpdatedAt, err = time.Parse(time.DateTime, updatedAtStr); err != nil { if cr.UpdatedAt, err = time.Parse(time.DateTime, updatedAtStr); err != nil {
return nil, nil, fmt.Errorf("parse updated_at: %w", err) return nil, nil, fmt.Errorf("parse updated_at: %w", err)
} }
containers = append(containers, &cr) containers = append(containers, cr)
} }
events, err := sub.Changes() events, err := sub.Changes()
if err != nil { if err != nil {
+2 -2
View File
@@ -22,8 +22,8 @@ CREATE TABLE containers
-- container is a JSON-serialized api.Container struct. -- container is a JSON-serialized api.Container struct.
container TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(container)), container TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(container)),
machine_id TEXT NOT NULL DEFAULT '', machine_id TEXT NOT NULL DEFAULT '',
service_id TEXT AS (json_extract(container, '$.Labels."uncloud.service.id"')), service_id TEXT AS (json_extract(container, '$.Config.Labels."uncloud.service.id"')),
service_name TEXT AS (json_extract(container, '$.Labels."uncloud.service.name"')), service_name TEXT AS (json_extract(container, '$.Config.Labels."uncloud.service.name"')),
-- sync_status indicates if the record reflects the actual Docker state of the container. -- sync_status indicates if the record reflects the actual Docker state of the container.
sync_status TEXT NOT NULL DEFAULT '', sync_status TEXT NOT NULL DEFAULT '',
-- updated_at is the last time the record was updated. -- updated_at is the last time the record was updated.
+6 -2
View File
@@ -2,12 +2,13 @@ package sshexec
import ( import (
"fmt" "fmt"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"net" "net"
"os" "os"
"strconv" "strconv"
"time" "time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
) )
func Connect(user, host string, port int, sshKeyPath string) (*ssh.Client, error) { func Connect(user, host string, port int, sshKeyPath string) (*ssh.Client, error) {
@@ -34,6 +35,9 @@ func Connect(user, host string, port int, sshKeyPath string) (*ssh.Client, error
} }
keyAuth, err := privateKeyAuth(sshKeyPath) keyAuth, err := privateKeyAuth(sshKeyPath)
if err != nil {
return nil, err
}
config := &ssh.ClientConfig{ config := &ssh.ClientConfig{
User: user, User: user,
Auth: []ssh.AuthMethod{keyAuth}, Auth: []ssh.AuthMethod{keyAuth},
+6 -6
View File
@@ -112,18 +112,18 @@ install_uncloud_binaries() {
local uncloudd_url local uncloudd_url
if [ "${UNCLOUD_VERSION}" == "latest" ]; then if [ "${UNCLOUD_VERSION}" == "latest" ]; then
uncloudd_url="${UNCLOUD_GITHUB_URL}/releases/latest/download/uncloudd_linux_${file_arch}.gz" uncloudd_url="${UNCLOUD_GITHUB_URL}/releases/latest/download/uncloudd_linux_${file_arch}.tar.gz"
else else
uncloudd_url="${UNCLOUD_GITHUB_URL}/releases/download/${UNCLOUD_VERSION}/uncloudd_linux_${file_arch}.gz" uncloudd_url="${UNCLOUD_GITHUB_URL}/releases/download/${UNCLOUD_VERSION}/uncloudd_linux_${file_arch}.tar.gz"
fi fi
local uncloudd_download_path="${tmp_dir}/uncloudd.gz" local uncloudd_download_path="${tmp_dir}/uncloudd.tar.gz"
log "⏳ Downloading uncloudd binary: ${uncloudd_url}" log "⏳ Downloading uncloudd binary: ${uncloudd_url}"
if ! curl -fsSL -o "${uncloudd_download_path}" "${uncloudd_url}"; then if ! curl -fsSL -o "${uncloudd_download_path}" "${uncloudd_url}"; then
error "Failed to download uncloudd binary." error "Failed to download uncloudd binary."
fi fi
gzip -d "${uncloudd_download_path}" tar -xf "${uncloudd_download_path}"
if ! install "${uncloudd_download_path%.gz}" "${uncloudd_install_path}"; then if ! install ./uncloudd "${uncloudd_install_path}"; then
error "Failed to install uncloud binary to ${uncloudd_install_path}" error "Failed to install uncloud binary to ${uncloudd_install_path}"
fi fi
log "✓ uncloudd binary installed: ${uncloudd_install_path}" log "✓ uncloudd binary installed: ${uncloudd_install_path}"
@@ -196,7 +196,7 @@ install_corrosion() {
if ! curl -fsSL -o "${corrosion_download_path}" "${corrosion_url}"; then if ! curl -fsSL -o "${corrosion_download_path}" "${corrosion_url}"; then
error "Failed to download uncloud-corrosion binary." error "Failed to download uncloud-corrosion binary."
fi fi
tar -xzf "${corrosion_download_path}" -C "${tmp_dir}" tar -xf "${corrosion_download_path}" -C "${tmp_dir}"
if ! install "${tmp_dir}/corrosion" "${corrosion_install_path}"; then if ! install "${tmp_dir}/corrosion" "${corrosion_install_path}"; then
error "Failed to install uncloud-corrosion binary to ${corrosion_install_path}" error "Failed to install uncloud-corrosion binary to ${corrosion_install_path}"
fi fi
+23 -24
View File
@@ -8,25 +8,25 @@ INSTALL_DIR=${INSTALL_DIR:-/usr/local/bin}
VERSION=${VERSION:-latest} VERSION=${VERSION:-latest}
print_manual_install() { print_manual_install() {
# TODO: review RELEASES_URL="https://github.com/${GITHUB_REPO}/releases/${VERSION}"
echo "You can manually install uncloud by:" echo "Failed while attempting to install uncloud CLI. You can install it manually:"
echo "1. Opening $RELEASES_URL" echo " 1. Open your web browser and go to ${RELEASES_URL}"
echo "2. Downloading uncloud_*_${OS}_${ARCH}.tar.gz for your platform" echo " 2. Download uncloud_<OS>_<ARCH>.tar.gz for your platform (OS: linux/macos, ARCH: amd64/arm64)."
echo "3. Verifying the checksum from checksums.txt" echo " 3. Extract the 'uncloud' binary from the archive: tar -xvf uncloud_*.tar.gz"
echo "4. Extracting the archive: tar xzf uncloud_*_${OS}_${ARCH}.tar.gz" echo " 4. Install the binary to /usr/local/bin: sudo install ./uncloud ${INSTALL_DIR}/uncloud"
echo "5. Installing the binary: sudo install -m 755 uncloud /usr/local/bin/" echo " 5. Optionally create a 'uc' symlink: sudo ln -sf ${INSTALL_DIR}/uncloud ${INSTALL_DIR}/uc"
echo "6. Creating symlink: sudo ln -sf /usr/local/bin/uncloud /usr/local/bin/uc" echo " 6. Delete the downloaded archive and extracted binary: rm uncloud*"
echo " 7. Run 'uncloud --help' to verify the installation. Enjoy! ✨"
} }
latest_version() { fetch_latest_version() {
api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/latest" api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/latest"
version=$(curl -fsSL "$api_url" | grep -o '"tag_name": "[^"]*' | cut -d'"' -f4) VERSION=$(curl -fsSL "$api_url" | grep -o '"tag_name": "[^"]*' | cut -d'"' -f4)
if [ -z "$version" ]; then if [ -z "$VERSION" ]; then
echo "Failed to fetch the latest version from GitHub." echo "Failed to fetch the latest version from GitHub."
print_manual_install print_manual_install
exit 1 exit 1
fi fi
echo "$version"
} }
# Check if not running as root and need to use sudo to write to INSTALL_DIR. # Check if not running as root and need to use sudo to write to INSTALL_DIR.
@@ -62,9 +62,9 @@ esac
# Use the latest version if not specified explicitly. # Use the latest version if not specified explicitly.
if [ "$VERSION" = "latest" ]; then if [ "$VERSION" = "latest" ]; then
VERSION=$(latest_version) fetch_latest_version
fi fi
BINARY_NAME="uncloud_${BINARY_OS}_${BINARY_ARCH}.gz" BINARY_NAME="uncloud_${BINARY_OS}_${BINARY_ARCH}.tar.gz"
BINARY_URL="https://github.com/${GITHUB_REPO}/releases/download/${VERSION}/${BINARY_NAME}" BINARY_URL="https://github.com/${GITHUB_REPO}/releases/download/${VERSION}/${BINARY_NAME}"
CHECKSUM_URL="https://github.com/${GITHUB_REPO}/releases/download/$VERSION/checksums.txt" CHECKSUM_URL="https://github.com/${GITHUB_REPO}/releases/download/$VERSION/checksums.txt"
@@ -78,25 +78,24 @@ curl -fsSL "$BINARY_URL" -o "${TMP_DIR}/${BINARY_NAME}"
curl -fsSL "$CHECKSUM_URL" -o "${TMP_DIR}/checksums.txt" curl -fsSL "$CHECKSUM_URL" -o "${TMP_DIR}/checksums.txt"
echo "Download complete." echo "Download complete."
# TODO: fix name_template in goreleaser config. echo "Verifying checksum..."
#echo "Verifying checksum..."
cd "$TMP_DIR" cd "$TMP_DIR"
#if ! sha256sum --check --ignore-missing "checksums.txt"; then if ! sha256sum --check --ignore-missing "checksums.txt"; then
# echo "Checksum verification failed." echo "Checksum verification failed."
# print_manual_install print_manual_install
# exit 1 exit 1
#fi fi
#echo "Checksum is valid." echo "Checksum is valid."
# Decompress and install the binary. # Decompress and install the binary.
gzip -d "${BINARY_NAME}" tar -xf "${BINARY_NAME}"
if [ -z "${SUDO}" ]; then if [ -z "${SUDO}" ]; then
echo "Installing uncloud binary to ${INSTALL_DIR}" echo "Installing uncloud binary to ${INSTALL_DIR}"
else else
echo "Installing uncloud binary to ${INSTALL_DIR} using sudo. You may be prompted for your password." echo "Installing uncloud binary to ${INSTALL_DIR} using sudo. You may be prompted for your password."
fi fi
if ! $SUDO install "${BINARY_NAME%.gz}" "${INSTALL_DIR}/uncloud"; then if ! $SUDO install ./uncloud "${INSTALL_DIR}/uncloud"; then
echo "Failed to install uncloud binary to ${INSTALL_DIR}" echo "Failed to install uncloud binary to ${INSTALL_DIR}"
print_manual_install print_manual_install
exit 1 exit 1
+203 -15
View File
@@ -2,7 +2,8 @@ package e2e
import ( import (
"context" "context"
dockerclient "github.com/docker/docker/client" "errors"
"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"
"testing" "testing"
@@ -11,6 +12,161 @@ import (
"uncloud/internal/ucind" "uncloud/internal/ucind"
) )
func TestDeployment(t *testing.T) {
t.Parallel()
clusterName := "ucind-test.deployment"
ctx := context.Background()
c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 3}, true)
cli, err := c.Machines[0].Connect(ctx)
require.NoError(t, err)
t.Run("global", func(t *testing.T) {
t.Parallel()
name := "global-deployment"
t.Cleanup(func() {
err := cli.RemoveService(ctx, name)
if errors.Is(err, client.ErrNotFound) {
require.NoError(t, err)
}
_, err = cli.InspectService(ctx, name)
require.ErrorIs(t, err, client.ErrNotFound)
})
spec := api.ServiceSpec{
Name: name,
Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
},
}
deploy, err := cli.NewDeployment(spec, nil)
require.NoError(t, err)
err = deploy.Validate(ctx)
require.NoError(t, err)
plan, err := deploy.Plan(ctx)
require.NoError(t, err)
assert.IsType(t, &client.SequenceOperation{}, plan.Operation)
assert.Len(t, plan.Operation.(*client.SequenceOperation).Operations, 3) // 3 run
svcID, err := deploy.Run(ctx)
require.NoError(t, err)
assert.NotEmpty(t, svcID)
svc, err := cli.InspectService(ctx, name)
require.NoError(t, err)
assert.Equal(t, name, svc.Name)
assert.Equal(t, api.ServiceModeGlobal, svc.Mode)
assert.Len(t, svc.Containers, 3)
svcSpec, err := svc.Containers[0].Container.ServiceSpec()
require.NoError(t, err)
assert.True(t, svcSpec.Equals(spec))
// Deploy a published port.
specWithPort := api.ServiceSpec{
Name: name,
Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
},
Ports: []api.PortSpec{
{
PublishedPort: 8000,
ContainerPort: 8000,
Protocol: api.ProtocolTCP,
Mode: api.PortModeHost,
},
},
}
deploy, err = cli.NewDeployment(specWithPort, nil)
require.NoError(t, err)
plan, err = deploy.Plan(ctx)
require.NoError(t, err)
assert.IsType(t, &client.SequenceOperation{}, plan.Operation)
assert.Len(t, plan.Operation.(*client.SequenceOperation).Operations, 6) // 3 run + 3 remove
svcID, err = deploy.Run(ctx)
require.NoError(t, err)
assert.NotEmpty(t, svcID)
svc, err = cli.InspectService(ctx, name)
require.NoError(t, err)
assert.Equal(t, name, svc.Name)
assert.Equal(t, api.ServiceModeGlobal, svc.Mode)
assert.Len(t, svc.Containers, 3)
svcSpec, err = svc.Containers[0].Container.ServiceSpec()
require.NoError(t, err)
assert.True(t, svcSpec.Equals(specWithPort))
// Deploy the same conflicting port but with container spec changes
init := true
specWithPortAndInit := api.ServiceSpec{
Name: name,
Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
Init: &init,
},
Ports: []api.PortSpec{
{
PublishedPort: 8000,
ContainerPort: 8000,
Protocol: api.ProtocolTCP,
Mode: api.PortModeHost,
},
},
}
deploy, err = cli.NewDeployment(specWithPortAndInit, nil)
require.NoError(t, err)
plan, err = deploy.Plan(ctx)
require.NoError(t, err)
assert.IsType(t, &client.SequenceOperation{}, plan.Operation)
assert.Len(t, plan.Operation.(*client.SequenceOperation).Operations, 9) // 3 stop + 3 run + 3 remove
svcID, err = deploy.Run(ctx)
require.NoError(t, err)
assert.NotEmpty(t, svcID)
svc, err = cli.InspectService(ctx, name)
require.NoError(t, err)
assert.Equal(t, name, svc.Name)
assert.Equal(t, api.ServiceModeGlobal, svc.Mode)
assert.Len(t, svc.Containers, 3)
svcSpec, err = svc.Containers[0].Container.ServiceSpec()
require.NoError(t, err)
assert.True(t, svcSpec.Equals(specWithPortAndInit))
// Deploying the same spec should be a no-op.
deploy, err = cli.NewDeployment(specWithPortAndInit, nil)
require.NoError(t, err)
plan, err = deploy.Plan(ctx)
require.NoError(t, err)
assert.IsType(t, &client.SequenceOperation{}, plan.Operation)
assert.Len(t, plan.Operation.(*client.SequenceOperation).Operations, 0) // no-op
svcID, err = deploy.Run(ctx)
require.NoError(t, err)
assert.NotEmpty(t, svcID)
svc, err = cli.InspectService(ctx, name)
require.NoError(t, err)
assert.Equal(t, name, svc.Name)
assert.Equal(t, api.ServiceModeGlobal, svc.Mode)
assert.Len(t, svc.Containers, 3)
})
}
func TestRunService(t *testing.T) { func TestRunService(t *testing.T) {
t.Parallel() t.Parallel()
@@ -21,13 +177,50 @@ func TestRunService(t *testing.T) {
cli, err := c.Machines[0].Connect(ctx) cli, err := c.Machines[0].Connect(ctx)
require.NoError(t, err) require.NoError(t, err)
t.Run("container lifecycle", func(t *testing.T) {
t.Parallel()
svcName := "pause-container-lifecycle"
spec := api.ServiceSpec{
Name: svcName,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
},
}
machineID := c.Machines[0].Name
ctr, err := cli.CreateContainer(ctx, svcName, spec, machineID)
require.NoError(t, err)
assert.NotEmpty(t, ctr.ID)
t.Cleanup(func() {
err := cli.RemoveContainer(ctx, svcName, ctr.ID, container.RemoveOptions{Force: true})
if !errors.Is(err, client.ErrNotFound) {
require.NoError(t, err)
}
})
err = cli.StartContainer(ctx, svcName, ctr.ID)
require.NoError(t, err)
timeout := 1
err = cli.StopContainer(ctx, svcName, ctr.ID, container.StopOptions{Timeout: &timeout})
require.NoError(t, err)
err = cli.RemoveContainer(ctx, svcName, ctr.ID, container.RemoveOptions{})
require.NoError(t, err)
err = cli.RemoveContainer(ctx, svcName, ctr.ID, container.RemoveOptions{})
require.ErrorIs(t, err, client.ErrNotFound)
})
t.Run("1 replica", func(t *testing.T) { t.Run("1 replica", func(t *testing.T) {
t.Parallel() t.Parallel()
name := "busybox-1-replica" name := "pause-1-replica"
t.Cleanup(func() { t.Cleanup(func() {
err := cli.RemoveService(ctx, name) err := cli.RemoveService(ctx, name)
if !dockerclient.IsErrNotFound(err) { if errors.Is(err, client.ErrNotFound) {
require.NoError(t, err) require.NoError(t, err)
} }
@@ -39,15 +232,13 @@ func TestRunService(t *testing.T) {
Name: name, Name: name,
Mode: api.ServiceModeReplicated, Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{ Container: api.ContainerSpec{
Command: []string{"sleep", "infinity"}, Image: "portainer/pause:latest",
Image: "busybox:latest",
}, },
}) })
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, resp.ID) assert.NotEmpty(t, resp.ID)
assert.Equal(t, name, resp.Name) assert.Equal(t, name, resp.Name)
assert.Len(t, resp.Containers, 1)
svc, err := cli.InspectService(ctx, name) svc, err := cli.InspectService(ctx, name)
require.NoError(t, err) require.NoError(t, err)
@@ -76,10 +267,10 @@ func TestRunService(t *testing.T) {
t.Run("1 replica with ports", func(t *testing.T) { t.Run("1 replica with ports", func(t *testing.T) {
t.Parallel() t.Parallel()
name := "busybox-1-replica-ports" name := "pause-1-replica-ports"
t.Cleanup(func() { t.Cleanup(func() {
err := cli.RemoveService(ctx, name) err := cli.RemoveService(ctx, name)
if !dockerclient.IsErrNotFound(err) { if errors.Is(err, client.ErrNotFound) {
require.NoError(t, err) require.NoError(t, err)
} }
@@ -91,8 +282,7 @@ func TestRunService(t *testing.T) {
Name: name, Name: name,
Mode: api.ServiceModeReplicated, Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{ Container: api.ContainerSpec{
Command: []string{"sleep", "infinity"}, Image: "portainer/pause:latest",
Image: "busybox:latest",
}, },
Ports: []api.PortSpec{ Ports: []api.PortSpec{
{ {
@@ -131,10 +321,10 @@ func TestRunService(t *testing.T) {
t.Run("global mode", func(t *testing.T) { t.Run("global mode", func(t *testing.T) {
t.Parallel() t.Parallel()
name := "busybox-global" name := "pause-global"
t.Cleanup(func() { t.Cleanup(func() {
err := cli.RemoveService(ctx, name) err := cli.RemoveService(ctx, name)
if !dockerclient.IsErrNotFound(err) { if errors.Is(err, client.ErrNotFound) {
require.NoError(t, err) require.NoError(t, err)
} }
@@ -146,15 +336,13 @@ func TestRunService(t *testing.T) {
Name: name, Name: name,
Mode: api.ServiceModeGlobal, Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{ Container: api.ContainerSpec{
Command: []string{"sleep", "infinity"}, Image: "portainer/pause:latest",
Image: "busybox:latest",
}, },
}) })
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, resp.ID) assert.NotEmpty(t, resp.ID)
assert.Equal(t, name, resp.Name) assert.Equal(t, name, resp.Name)
assert.Len(t, resp.Containers, 3, "expected 1 container on each machine")
svc, err := cli.InspectService(ctx, name) svc, err := cli.InspectService(ctx, name)
require.NoError(t, err) require.NoError(t, err)
+4 -4
View File
@@ -45,7 +45,7 @@
</li> </li>
<li class="ml-1"> <li class="ml-1">
<a class="btn-sm text-zinc-100 bg-zinc-900 hover:bg-zinc-800 w-full shadow" <a class="btn-sm text-zinc-100 bg-zinc-900 hover:bg-zinc-800 w-full shadow"
href="https://github.com/psviderski/uncloud?tab=readme-ov-file#quick-start">Get Started</a> href="https://github.com/psviderski/uncloud/?tab=readme-ov-file#-quick-start">Get Started</a>
</li> </li>
</ul> </ul>
</nav> </nav>
@@ -76,7 +76,7 @@
<div class="max-w-xs mx-auto sm:max-w-none sm:inline-flex sm:justify-center space-y-4 sm:space-y-0 sm:space-x-4"> <div class="max-w-xs mx-auto sm:max-w-none sm:inline-flex sm:justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<div> <div>
<a class="btn text-zinc-100 bg-zinc-900 hover:bg-zinc-800 w-full shadow" <a class="btn text-zinc-100 bg-zinc-900 hover:bg-zinc-800 w-full shadow"
href="https://github.com/psviderski/uncloud?tab=readme-ov-file#quick-start">Quick Start</a> href="https://github.com/psviderski/uncloud/?tab=readme-ov-file#-quick-start">Quick Start</a>
</div> </div>
<div> <div>
<a class="btn text-zinc-600 bg-white hover:text-zinc-900 w-full shadow flex items-center justify-center" <a class="btn text-zinc-600 bg-white hover:text-zinc-900 w-full shadow flex items-center justify-center"
@@ -112,7 +112,7 @@
<pre> </pre> <pre> </pre>
<pre class="text-zinc-500 select-none"># Deploy your app with automatic HTTPS</pre> <pre class="text-zinc-500 select-none"># Deploy your app with automatic HTTPS</pre>
<pre><span class="text-emerald-500 select-none">$ </span>uc run --name my-app -p app.example.com:8000/https registry/app</pre> <pre><span class="text-emerald-500 select-none">$ </span>uc run --name my-app -p app.example.com:8000/https app-image:latest</pre>
<pre class="text-zinc-200">✨ Your app is available at <span class="underline">https://app.example.com</span></pre> <pre class="text-zinc-200">✨ Your app is available at <span class="underline">https://app.example.com</span></pre>
<pre> </pre> <pre> </pre>
@@ -505,7 +505,7 @@
</section> </section>
<!-- CTA --> <!-- CTA -->
<section> <section id="subscribe">
<div class="py-12 md:py-20"> <div class="py-12 md:py-20">
<div class="max-w-6xl mx-auto px-4 sm:px-6"> <div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="relative max-w-3xl mx-auto text-center"> <div class="relative max-w-3xl mx-auto text-center">