mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b88b8b810 | ||
|
|
97bdb8eae1 | ||
|
|
5baa8087e5 | ||
|
|
d25864e52f | ||
|
|
ec2787c99c | ||
|
|
9963f9df2d | ||
|
|
835834322b | ||
|
|
51ba3c7df8 | ||
|
|
047f661462 |
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
**How to reproduce**
|
||||
|
||||
<!-- Steps to reproduce the behavior:
|
||||
|
||||
1. Run ...
|
||||
2. Do ...
|
||||
-->
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
**Environment:**
|
||||
|
||||
- Uncloud versions:
|
||||
- Control (client) node (`uc --version`):
|
||||
- Uncloud daemon (from the server) (`uncloudd --version`):
|
||||
- OS version (`uname -a`):
|
||||
- Client (control node):
|
||||
- Server:
|
||||
|
||||
**Additional context**
|
||||
|
||||
<!-- Add any other context about the problem here. -->
|
||||
@@ -89,7 +89,7 @@ func NewInitCommand() *cobra.Command {
|
||||
"Version of the Uncloud daemon to install on the machine.",
|
||||
)
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", "default",
|
||||
&opts.context, "context", "c", cli.DefaultContextName,
|
||||
"Name of the created context for the initialised cluster in the Uncloud config.",
|
||||
)
|
||||
|
||||
|
||||
+30
-7
@@ -26,7 +26,7 @@ const (
|
||||
// DefaultSSHKeyPath is the fallback location for the SSH private key when provisioning remote machines.
|
||||
// Used when no key is explicitly provided and SSH agent authentication fails.
|
||||
DefaultSSHKeyPath = "~/.ssh/id_ed25519"
|
||||
defaultContextName = "default"
|
||||
DefaultContextName = "default"
|
||||
)
|
||||
|
||||
type CLI struct {
|
||||
@@ -170,12 +170,9 @@ func (cli *CLI) InitCluster(ctx context.Context, opts InitClusterOptions) (*clie
|
||||
}
|
||||
|
||||
func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
||||
contextName := opts.Context
|
||||
if contextName == "" {
|
||||
contextName = defaultContextName
|
||||
}
|
||||
if _, ok := cli.Config.Contexts[contextName]; ok {
|
||||
return nil, fmt.Errorf("cluster context '%s' already exists", contextName)
|
||||
contextName, err := cli.newContextName(opts.Context)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
machineClient, err := provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
||||
@@ -250,6 +247,32 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
||||
return machineClient, nil
|
||||
}
|
||||
|
||||
// newContextName returns a unique name for a new cluster context. If the provided name is not DefaultContextName,
|
||||
// and it's already taken, an error is returned. If the name is not provided or is DefaultContextName, the first
|
||||
// available name "default[-N]" is returned.
|
||||
func (cli *CLI) newContextName(name string) (string, error) {
|
||||
if name == "" {
|
||||
name = DefaultContextName
|
||||
}
|
||||
|
||||
if _, exists := cli.Config.Contexts[name]; !exists {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// If non-default context already exists, error out.
|
||||
if name != DefaultContextName {
|
||||
return "", fmt.Errorf("cluster context '%s' already exists", name)
|
||||
}
|
||||
|
||||
// The default context already exists, generate a numbered suffix to make it unique.
|
||||
for i := 1; ; i++ {
|
||||
name = fmt.Sprintf("%s-%d", DefaultContextName, i)
|
||||
if _, exists := cli.Config.Contexts[name]; !exists {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type AddMachineOptions struct {
|
||||
Context string
|
||||
MachineName string
|
||||
|
||||
@@ -53,6 +53,10 @@ func (c *Config) Read() error {
|
||||
|
||||
func (c *Config) Save() error {
|
||||
dir, _ := filepath.Split(c.path)
|
||||
// If dir is empty (e.g., when path is just a filename), use current directory
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create config directory '%s': %w", dir, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfig_Save(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a temporary directory for the test
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Change to temp directory so relative paths resolve correctly
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current directory: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Chdir(originalDir); err != nil {
|
||||
t.Logf("Failed to restore original directory: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
configPath string
|
||||
contextName string
|
||||
expectFileAt string // Expected file location for verification
|
||||
useAbsolutePath bool // Whether to use absolute path for expectFileAt
|
||||
}{
|
||||
{
|
||||
name: "relative path without prefix",
|
||||
configPath: "test-config.yaml",
|
||||
contextName: "test",
|
||||
},
|
||||
{
|
||||
name: "relative path with prefix",
|
||||
configPath: "./test-config-2.yaml",
|
||||
contextName: "test2",
|
||||
},
|
||||
{
|
||||
name: "absolute path",
|
||||
configPath: filepath.Join(tmpDir, "absolute-config.yaml"),
|
||||
contextName: "test3",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Failed to change to temp directory: %v", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
CurrentContext: tt.contextName,
|
||||
Contexts: map[string]*Context{
|
||||
tt.contextName: {
|
||||
Name: tt.contextName,
|
||||
},
|
||||
},
|
||||
path: tt.configPath,
|
||||
}
|
||||
|
||||
// This should not fail when saving the config
|
||||
err := cfg.Save()
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error when saving config, got: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file was created
|
||||
if _, err := os.Stat(tt.configPath); os.IsNotExist(err) {
|
||||
t.Errorf("Config file was not created at expected path: %s", tt.configPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,9 @@ https://{{$hostname}} {
|
||||
}
|
||||
log
|
||||
}{{end}}
|
||||
`
|
||||
caddyfileUnavailabeFooter = `# NOTE: User-defined configs for services were skipped because Caddy is not running on this machine
|
||||
# or the latest generated config is invalid. Please check the Caddy logs if it's running.
|
||||
`
|
||||
)
|
||||
|
||||
@@ -95,7 +98,11 @@ func NewCaddyfileGenerator(machineID string, validator CaddyfileValidator, log *
|
||||
// [service-a x-caddy]
|
||||
// ...
|
||||
// [service-z x-caddy]
|
||||
func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.ContainerRecord) (string, error) {
|
||||
//
|
||||
// If includeCustom is false, custom Caddy configs (x-caddy) are not included in the generated Caddyfile.
|
||||
func (g *CaddyfileGenerator) Generate(
|
||||
ctx context.Context, records []store.ContainerRecord, includeCustom bool,
|
||||
) (string, error) {
|
||||
containers := make([]api.ServiceContainer, len(records))
|
||||
for i, cr := range records {
|
||||
containers[i] = cr.Container
|
||||
@@ -113,6 +120,10 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta
|
||||
return "", fmt.Errorf("generate base Caddyfile from service ports: %w", err)
|
||||
}
|
||||
|
||||
if !includeCustom {
|
||||
return fmt.Sprintf("%s\n%s\n%s", caddyfileHeader, caddyfile, caddyfileUnavailabeFooter), nil
|
||||
}
|
||||
|
||||
upstreams := serviceUpstreams(containers)
|
||||
// Track validation errors for reporting.
|
||||
var configErrors []string
|
||||
|
||||
@@ -18,8 +18,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCaddyfileGenerator(t *testing.T) {
|
||||
caddyfileHeader := `# This file is autogenerated by Uncloud based on the configuration of running services.
|
||||
const testCaddyfileHeader = `# This file is autogenerated by Uncloud based on the configuration of running services.
|
||||
# Do not edit manually. Any manual changes will be overwritten on the next update.
|
||||
|
||||
# Health check endpoint to verify Caddy reachability on this machine.
|
||||
@@ -38,6 +37,7 @@ http:// {
|
||||
}
|
||||
`
|
||||
|
||||
func TestCaddyfileGenerator(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
containers []store.ContainerRecord
|
||||
@@ -47,14 +47,14 @@ http:// {
|
||||
{
|
||||
name: "empty containers",
|
||||
containers: []store.ContainerRecord{},
|
||||
want: caddyfileHeader,
|
||||
want: testCaddyfileHeader,
|
||||
},
|
||||
{
|
||||
name: "HTTP container",
|
||||
containers: []store.ContainerRecord{
|
||||
newContainerRecord(newContainer("10.210.0.2", "app.example.com:8080/http"), "mach1"),
|
||||
},
|
||||
want: caddyfileHeader + `
|
||||
want: testCaddyfileHeader + `
|
||||
# Sites generated from service ports.
|
||||
|
||||
http://app.example.com {
|
||||
@@ -71,7 +71,7 @@ http://app.example.com {
|
||||
newContainerRecord(newContainer("10.210.0.2", "app.example.com:8080/http"), "mach1"),
|
||||
newContainerRecord(newContainer("10.210.0.3", "app.example.com:8080/http"), "mach1"),
|
||||
},
|
||||
want: caddyfileHeader + `
|
||||
want: testCaddyfileHeader + `
|
||||
# Sites generated from service ports.
|
||||
|
||||
http://app.example.com {
|
||||
@@ -87,7 +87,7 @@ http://app.example.com {
|
||||
containers: []store.ContainerRecord{
|
||||
newContainerRecord(newContainer("10.210.0.2", "secure.example.com:8000/https"), "mach1"),
|
||||
},
|
||||
want: caddyfileHeader + `
|
||||
want: testCaddyfileHeader + `
|
||||
# Sites generated from service ports.
|
||||
|
||||
https://secure.example.com {
|
||||
@@ -127,7 +127,7 @@ https://secure.example.com {
|
||||
"mach1",
|
||||
),
|
||||
},
|
||||
want: caddyfileHeader + `
|
||||
want: testCaddyfileHeader + `
|
||||
# Sites generated from service ports.
|
||||
|
||||
http://app.example.com {
|
||||
@@ -157,14 +157,14 @@ https://secure.example.com {
|
||||
containers: []store.ContainerRecord{
|
||||
newContainerRecord(newContainerWithoutNetwork("ignored.example.com:8080/http"), "mach1"),
|
||||
},
|
||||
want: caddyfileHeader,
|
||||
want: testCaddyfileHeader,
|
||||
},
|
||||
{
|
||||
name: "container with invalid port ignored",
|
||||
containers: []store.ContainerRecord{
|
||||
newContainerRecord(newContainer("10.210.0.2", "invalid-port"), "mach1"),
|
||||
},
|
||||
want: caddyfileHeader,
|
||||
want: testCaddyfileHeader,
|
||||
},
|
||||
{
|
||||
name: "containers with unsupported protocols and host mode ignored",
|
||||
@@ -173,7 +173,7 @@ https://secure.example.com {
|
||||
newContainerRecord(newContainer("10.210.0.3", "5000/udp"), "mach1"),
|
||||
newContainerRecord(newContainer("10.210.0.4", "80:8080/tcp@host"), "mach1"),
|
||||
},
|
||||
want: caddyfileHeader,
|
||||
want: testCaddyfileHeader,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ https://secure.example.com {
|
||||
// Validator is not expected to be called in these tests.
|
||||
generator := NewCaddyfileGenerator("test-machine-id", nil, nil)
|
||||
|
||||
config, err := generator.Generate(ctx, tt.containers)
|
||||
config, err := generator.Generate(ctx, tt.containers, true)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
@@ -197,25 +197,6 @@ https://secure.example.com {
|
||||
}
|
||||
|
||||
func TestCaddyfileGeneratorWithCustomConfigs(t *testing.T) {
|
||||
caddyfileBase := `# This file is autogenerated by Uncloud based on the configuration of running services.
|
||||
# Do not edit manually. Any manual changes will be overwritten on the next update.
|
||||
|
||||
# Health check endpoint to verify Caddy reachability on this machine.
|
||||
http:// {
|
||||
handle /.uncloud-verify {
|
||||
respond "test-machine-id" 200
|
||||
}
|
||||
log
|
||||
}
|
||||
|
||||
(common_proxy) {
|
||||
# Retry failed requests up to lb_retries times against other available upstreams.
|
||||
lb_retries 3
|
||||
# Upstreams are marked unhealthy for fail_duration after a failed request (passive health checking).
|
||||
fail_duration 30s
|
||||
}
|
||||
`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
containers []store.ContainerRecord
|
||||
@@ -275,7 +256,7 @@ web.example.com {
|
||||
time.Now(),
|
||||
),
|
||||
},
|
||||
want: caddyfileBase + `
|
||||
want: testCaddyfileHeader + `
|
||||
# User-defined config for service 'web'.
|
||||
# Custom config for web service
|
||||
web.example.com {
|
||||
@@ -297,7 +278,7 @@ bad.config.com {
|
||||
time.Now(),
|
||||
),
|
||||
},
|
||||
want: caddyfileBase + `
|
||||
want: testCaddyfileHeader + `
|
||||
# Skipped invalid user-defined configs:
|
||||
# - service 'bad-service': validation failed: invalid config detected
|
||||
`,
|
||||
@@ -316,7 +297,7 @@ bad.template.com {
|
||||
time.Now(),
|
||||
),
|
||||
},
|
||||
want: caddyfileBase + `
|
||||
want: testCaddyfileHeader + `
|
||||
# Skipped invalid user-defined configs:
|
||||
# - service 'bad-template': failed to render template: parse config as Go template: template: Caddyfile:3: unexpected "}" in operand
|
||||
`,
|
||||
@@ -335,7 +316,7 @@ localhost {
|
||||
time.Now(),
|
||||
),
|
||||
},
|
||||
want: caddyfileBase + `
|
||||
want: testCaddyfileHeader + `
|
||||
# Skipped invalid user-defined configs:
|
||||
# - service 'caddy': validation failed: invalid config detected
|
||||
`,
|
||||
@@ -354,7 +335,7 @@ localhost {
|
||||
time.Now(),
|
||||
),
|
||||
},
|
||||
want: caddyfileBase,
|
||||
want: testCaddyfileHeader,
|
||||
},
|
||||
{
|
||||
name: "multiple services with mixed valid and invalid configs",
|
||||
@@ -388,7 +369,7 @@ bad.example.com {
|
||||
time.Now(),
|
||||
),
|
||||
},
|
||||
want: caddyfileBase + `
|
||||
want: testCaddyfileHeader + `
|
||||
# User-defined config for service 'api'.
|
||||
api.example.com {
|
||||
reverse_proxy api:8080
|
||||
@@ -490,7 +471,7 @@ api.example.com {
|
||||
"test-machine-id",
|
||||
),
|
||||
},
|
||||
want: caddyfileBase + `
|
||||
want: testCaddyfileHeader + `
|
||||
# Sites generated from service ports.
|
||||
|
||||
http://api.example.com {
|
||||
@@ -530,7 +511,7 @@ new.example.com {
|
||||
time.Now(),
|
||||
),
|
||||
},
|
||||
want: caddyfileBase + `
|
||||
want: testCaddyfileHeader + `
|
||||
# User-defined config for service 'web'.
|
||||
# New config
|
||||
new.example.com {
|
||||
@@ -815,7 +796,7 @@ invalid.example.com {
|
||||
time.Now(),
|
||||
),
|
||||
},
|
||||
want: caddyfileBase + `
|
||||
want: testCaddyfileHeader + `
|
||||
# User-defined config for service 'valid'.
|
||||
valid.example.com {
|
||||
respond "Valid config"
|
||||
@@ -843,7 +824,7 @@ valid.example.com {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
generator := NewCaddyfileGenerator("test-machine-id", validator, nil)
|
||||
|
||||
config, err := generator.Generate(ctx, tt.containers)
|
||||
config, err := generator.Generate(ctx, tt.containers, true)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
@@ -899,6 +880,96 @@ func newContainerRecordWithCaddyConfig(serviceName, ip, caddyConfig, machineID s
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaddyfileGeneratorWithoutCustomConfigs(t *testing.T) {
|
||||
// Test that when includeCustom is false (Caddy not available), x-caddy configs are skipped.
|
||||
tests := []struct {
|
||||
name string
|
||||
containers []store.ContainerRecord
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "x-caddy configs are skipped",
|
||||
containers: []store.ContainerRecord{
|
||||
newContainerRecordWithCaddyConfig(
|
||||
"caddy",
|
||||
"10.210.0.1",
|
||||
`# Global config
|
||||
{
|
||||
global directive
|
||||
}`,
|
||||
"test-machine-id",
|
||||
time.Now(),
|
||||
),
|
||||
newContainerRecordWithCaddyConfig(
|
||||
"web",
|
||||
"10.210.0.2",
|
||||
`web.example.com {
|
||||
reverse_proxy web:3000
|
||||
}`,
|
||||
"test-machine-id",
|
||||
time.Now(),
|
||||
),
|
||||
newContainerRecordWithPorts(
|
||||
"api",
|
||||
"10.210.0.3",
|
||||
[]string{"api.example.com:8080/http"},
|
||||
"test-machine-id",
|
||||
),
|
||||
},
|
||||
want: testCaddyfileHeader + `
|
||||
# Sites generated from service ports.
|
||||
|
||||
http://api.example.com {
|
||||
reverse_proxy 10.210.0.3:8080 {
|
||||
import common_proxy
|
||||
}
|
||||
log
|
||||
}
|
||||
|
||||
# NOTE: User-defined configs for services were skipped because Caddy is not running on this machine
|
||||
# or the latest generated config is invalid. Please check the Caddy logs if it's running.
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "no containers with x-caddy configs",
|
||||
containers: []store.ContainerRecord{
|
||||
newContainerRecordWithPorts(
|
||||
"api",
|
||||
"10.210.0.3",
|
||||
[]string{"api.example.com:8080/http"},
|
||||
"test-machine-id",
|
||||
),
|
||||
},
|
||||
want: testCaddyfileHeader + `
|
||||
# Sites generated from service ports.
|
||||
|
||||
http://api.example.com {
|
||||
reverse_proxy 10.210.0.3:8080 {
|
||||
import common_proxy
|
||||
}
|
||||
log
|
||||
}
|
||||
|
||||
# NOTE: User-defined configs for services were skipped because Caddy is not running on this machine
|
||||
# or the latest generated config is invalid. Please check the Caddy logs if it's running.
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Validator is not expected to be called in these tests.
|
||||
generator := NewCaddyfileGenerator("test-machine-id", nil, nil)
|
||||
|
||||
config, err := generator.Generate(ctx, tt.containers, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.want, config, "Generated Caddyfile doesn't match")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newContainerRecordWithPorts(serviceName, ip string, ports []string, machineID string) store.ContainerRecord {
|
||||
portsLabel := strings.Join(ports, ",")
|
||||
return store.ContainerRecord{
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package caddyconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
)
|
||||
|
||||
// CaddyAdminClient is a client for interacting with the Caddy admin API over a Unix socket.
|
||||
type CaddyAdminClient struct {
|
||||
socketPath string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewCaddyAdminClient(socketPath string) *CaddyAdminClient {
|
||||
return &CaddyAdminClient{
|
||||
socketPath: socketPath,
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
return net.Dial("unix", socketPath)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// IsAvailable checks if the local Caddy instance is running and responding to admin API requests.
|
||||
func (c *CaddyAdminClient) IsAvailable(ctx context.Context) bool {
|
||||
// Caddy doesn't serve a /ping endpoint. It's a random endpoint we can use to check if Caddy is running.
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost/ping", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Any HTTP response means Caddy is running and accessible.
|
||||
return true
|
||||
}
|
||||
|
||||
// Adapt converts a Caddyfile to JSON configuration without loading or running it.
|
||||
func (c *CaddyAdminClient) Adapt(ctx context.Context, caddyfile string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "http://localhost/adapt", strings.NewReader(caddyfile))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create adapt request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/caddyfile")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("send adapt request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
// Parse the response body to extract the result field.
|
||||
var msg struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &msg); err != nil {
|
||||
return "", fmt.Errorf("parse adapt response: %w", err)
|
||||
}
|
||||
return string(msg.Result), nil
|
||||
}
|
||||
|
||||
// If the response is a 400 Bad Request, try to parse the error message from it.
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
var apiError caddy.APIError
|
||||
if err = json.Unmarshal(body, &apiError); err == nil {
|
||||
return "", errors.New(apiError.Message)
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New(string(body))
|
||||
}
|
||||
|
||||
// Load loads a Caddyfile configuration into the Caddy instance running on the machine.
|
||||
// Due to a Caddy bug (https://github.com/caddyserver/caddy/issues/7246), we first adapt the Caddyfile to JSON
|
||||
// and then load the JSON config to get proper error handling.
|
||||
func (c *CaddyAdminClient) Load(ctx context.Context, caddyfile string) error {
|
||||
jsonConfig, err := c.Adapt(ctx, caddyfile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adapt Caddyfile to JSON config: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "http://localhost/load", strings.NewReader(jsonConfig))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create load request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send load request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
// If the response is a 400 Bad Request, try to parse the error message from it.
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
var apiError caddy.APIError
|
||||
if err = json.Unmarshal(body, &apiError); err == nil {
|
||||
return fmt.Errorf("caddy responded with error: %s", apiError.Message)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("caddy responded with error: HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Validate checks if the provided Caddyfile can be adapted to Caddy JSON config using the running Caddy instance via
|
||||
// its admin API. It doesn't guarantee that the Caddyfile is actually valid and can be loaded. For example, a tls
|
||||
// directive with a missing certificate will pass the adaptation but will fail when Caddy tries to load it.
|
||||
// But this is the best we can do over the admin API.
|
||||
// TODO: run 'docker exec caddy-container caddy validate' to do proper validation or implement a Caddy module that
|
||||
// exposes a validation endpoint.
|
||||
func (c *CaddyAdminClient) Validate(ctx context.Context, caddyfile string) error {
|
||||
_, err := c.Adapt(ctx, caddyfile)
|
||||
return err
|
||||
}
|
||||
@@ -23,11 +23,12 @@ const (
|
||||
// proxy. The generated configuration allows Caddy to route external traffic to service containers across the internal
|
||||
// network.
|
||||
type Controller struct {
|
||||
machineID string
|
||||
configDir string
|
||||
generator *CaddyfileGenerator
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
machineID string
|
||||
caddyfilePath string
|
||||
generator *CaddyfileGenerator
|
||||
client *CaddyAdminClient
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func NewController(machineID, configDir, adminSock string, store *store.Store) (*Controller, error) {
|
||||
@@ -39,15 +40,16 @@ func NewController(machineID, configDir, adminSock string, store *store.Store) (
|
||||
}
|
||||
|
||||
log := slog.With("component", "caddy-controller")
|
||||
validator := NewCaddyAdminValidator(adminSock)
|
||||
generator := NewCaddyfileGenerator(machineID, validator, log)
|
||||
client := NewCaddyAdminClient(adminSock)
|
||||
generator := NewCaddyfileGenerator(machineID, client, log)
|
||||
|
||||
return &Controller{
|
||||
machineID: machineID,
|
||||
configDir: configDir,
|
||||
generator: generator,
|
||||
store: store,
|
||||
log: log,
|
||||
machineID: machineID,
|
||||
caddyfilePath: filepath.Join(configDir, "Caddyfile"),
|
||||
generator: generator,
|
||||
client: client,
|
||||
store: store,
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -59,11 +61,11 @@ func (c *Controller) Run(ctx context.Context) error {
|
||||
c.log.Info("Subscribed to container changes in the cluster to generate Caddy configuration.")
|
||||
|
||||
containers = filterHealthyContainers(containers)
|
||||
if err = c.generateCaddyfile(ctx, containers); err != nil {
|
||||
return fmt.Errorf("generate Caddyfile configuration: %w", err)
|
||||
}
|
||||
c.generateAndLoadCaddyfile(ctx, containers)
|
||||
|
||||
// TODO: left for backward compatibility, remove later.
|
||||
if err = c.generateJSONConfig(containers); err != nil {
|
||||
return fmt.Errorf("generate Caddy JSON configuration: %w", err)
|
||||
c.log.Error("Failed to generate Caddy JSON configuration to disk.", "err", err)
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -80,15 +82,12 @@ func (c *Controller) Run(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
containers = filterHealthyContainers(containers)
|
||||
c.generateAndLoadCaddyfile(ctx, containers)
|
||||
|
||||
if err = c.generateCaddyfile(ctx, containers); err != nil {
|
||||
c.log.Error("Failed to generate Caddyfile configuration.", "err", err)
|
||||
}
|
||||
// TODO: left for backward compatibility, remove later.
|
||||
if err = c.generateJSONConfig(containers); err != nil {
|
||||
c.log.Error("Failed to generate Caddy JSON configuration.", "err", err)
|
||||
c.log.Error("Failed to generate Caddy JSON configuration to disk.", "err", err)
|
||||
}
|
||||
|
||||
c.log.Info("Updated Caddy configuration.", "dir", c.configDir)
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
@@ -109,21 +108,53 @@ func filterHealthyContainers(containers []store.ContainerRecord) []store.Contain
|
||||
return healthy
|
||||
}
|
||||
|
||||
func (c *Controller) generateCaddyfile(ctx context.Context, containers []store.ContainerRecord) error {
|
||||
caddyfile, err := c.generator.Generate(ctx, containers)
|
||||
func (c *Controller) generateAndLoadCaddyfile(ctx context.Context, containers []store.ContainerRecord) {
|
||||
// Check if Caddy is available before attempting to generate and load config.
|
||||
caddyAvailable := c.client.IsAvailable(ctx)
|
||||
caddyfile, err := c.generator.Generate(ctx, containers, caddyAvailable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate Caddyfile: %w", err)
|
||||
}
|
||||
caddyfilePath := filepath.Join(c.configDir, "Caddyfile")
|
||||
|
||||
// TODO: use atomic file write to avoid partial loads on Caddy watch reload.
|
||||
if err = os.WriteFile(caddyfilePath, []byte(caddyfile), 0o640); err != nil {
|
||||
return fmt.Errorf("write Caddyfile to file '%s': %w", caddyfilePath, err)
|
||||
}
|
||||
if err = fs.Chown(caddyfilePath, "", CaddyGroup); err != nil {
|
||||
return fmt.Errorf("change owner of Caddyfile '%s': %w", caddyfilePath, err)
|
||||
c.log.Error("Failed to generate Caddyfile configuration.", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !caddyAvailable {
|
||||
// Caddy is not running so the generated Caddyfile should not include user-defined configs thus must be valid.
|
||||
// It's safe to write the config to disk so that when Caddy is deployed on this machine, it can pick it up.
|
||||
if err = c.writeCaddyfile(caddyfile); err != nil {
|
||||
c.log.Error("Failed to write Caddyfile to disk.", "err", err)
|
||||
return
|
||||
}
|
||||
c.log.Debug("Caddy is not running on this machine, skipping configuration load.", "path", c.caddyfilePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Caddy is available, try to load the config which may fail if the config is invalid. Generally, a config can
|
||||
// pass the adaptation/validation step but still fail to load, for example, if it references resources that are
|
||||
// not available.
|
||||
if err = c.client.Load(ctx, caddyfile); err != nil {
|
||||
c.log.Error("Failed to load new Caddy configuration into local Caddy instance.",
|
||||
"err", err, "path", c.caddyfilePath)
|
||||
// Don't write invalid config to disk.
|
||||
return
|
||||
}
|
||||
|
||||
// Config loaded successfully, now write it to disk.
|
||||
if err = c.writeCaddyfile(caddyfile); err != nil {
|
||||
c.log.Error("Failed to write Caddyfile to disk after successful load.", "err", err)
|
||||
// Config is already loaded in Caddy, so this is not critical.
|
||||
}
|
||||
|
||||
c.log.Info("New Caddy configuration loaded into local Caddy instance.", "path", c.caddyfilePath)
|
||||
}
|
||||
|
||||
// writeCaddyfile writes the Caddyfile content to disk with proper permissions.
|
||||
func (c *Controller) writeCaddyfile(caddyfile string) error {
|
||||
if err := os.WriteFile(c.caddyfilePath, []byte(caddyfile), 0o640); err != nil {
|
||||
return fmt.Errorf("write Caddyfile to file '%s': %w", c.caddyfilePath, err)
|
||||
}
|
||||
if err := fs.Chown(c.caddyfilePath, "", CaddyGroup); err != nil {
|
||||
return fmt.Errorf("change owner of Caddyfile '%s': %w", c.caddyfilePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -142,7 +173,7 @@ func (c *Controller) generateJSONConfig(containers []store.ContainerRecord) erro
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal Caddy configuration: %w", err)
|
||||
}
|
||||
configPath := filepath.Join(c.configDir, "caddy.json")
|
||||
configPath := filepath.Join(filepath.Dir(c.caddyfilePath), "caddy.json")
|
||||
|
||||
if err = os.WriteFile(configPath, configBytes, 0o640); err != nil {
|
||||
return fmt.Errorf("write Caddy configuration to file '%s': %w", configPath, err)
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package caddyconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
)
|
||||
|
||||
// CaddyAdminValidator validates Caddyfile via the Caddy admin API.
|
||||
type CaddyAdminValidator struct {
|
||||
socketPath string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewCaddyAdminValidator(socketPath string) *CaddyAdminValidator {
|
||||
return &CaddyAdminValidator{
|
||||
socketPath: socketPath,
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
return net.Dial("unix", socketPath)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks if the provided Caddyfile can be adapted to Caddy JSON config using the running Caddy instance via
|
||||
// its admin API. It doesn't guarantee that the Caddyfile is actually valid and can be loaded. For example, a tls
|
||||
// directive with a missing certificate will pass the adaptation but will fail when Caddy tries to load it.
|
||||
// But this is the best we can do over the admin API.
|
||||
// TODO: run 'docker exec caddy-container caddy validate' to do proper validation or implement a Caddy module that
|
||||
// exposes a validation endpoint.
|
||||
func (c *CaddyAdminValidator) Validate(ctx context.Context, caddyfile string) error {
|
||||
// Bogus host is used so that http.NewRequest is happy but it doesn't matter since we're using a Unix socket.
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "http://localhost/adapt", strings.NewReader(caddyfile))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create adapt request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/caddyfile")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send adapt request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
// If the response is a 400 Bad Request, try to parse the error message from it.
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
var apiError caddy.APIError
|
||||
if err = json.Unmarshal(body, &apiError); err == nil {
|
||||
return errors.New(apiError.Message)
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New(string(body))
|
||||
}
|
||||
+1
-1
@@ -35,7 +35,7 @@ func (cli *Client) NewCaddyDeployment(image, config string, placement api.Placem
|
||||
|
||||
spec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Command: []string{"caddy", "run", "-c", "/config/Caddyfile", "--watch"},
|
||||
Command: []string{"caddy", "run", "-c", "/config/Caddyfile"},
|
||||
Env: map[string]string{
|
||||
"CADDY_ADMIN": "unix//run/caddy/admin.sock",
|
||||
},
|
||||
|
||||
@@ -379,19 +379,16 @@ func TestDeployment(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assertServiceMatchesSpec(t, svc, spec)
|
||||
|
||||
// Check that the generated Caddyfile contains a comment with invalid user-defined configs.
|
||||
// Check that the generated Caddyfile contains a comment that user-define configs were skipped.
|
||||
var config *pb.GetCaddyConfigResponse
|
||||
require.Eventually(t, func() bool {
|
||||
config, err = cli.Caddy.GetConfig(ctx, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(config.Caddyfile, "invalid user-defined configs")
|
||||
return strings.Contains(config.Caddyfile, "# NOTE: User-defined configs for services were skipped")
|
||||
}, 5*time.Second, 100*time.Millisecond)
|
||||
|
||||
assert.Regexp(t, "- service 'test-custom-caddy-config': validation failed:.*"+
|
||||
"/run/uncloud/caddy/admin.sock: connect:.*", config.Caddyfile,
|
||||
"Expected comment about validation failure for service's user-defined Caddy config")
|
||||
assert.NotContains(t, config.Caddyfile, "test-custom-caddy-config.example.com {")
|
||||
|
||||
// Now deploy caddy with custom config.
|
||||
@@ -441,6 +438,48 @@ myapp.example.com {
|
||||
|
||||
assert.NotContains(t, config.Caddyfile, "invalid user-defined configs",
|
||||
"Should not have validation failure comments after caddy is deployed")
|
||||
|
||||
// Store the current valid config for later comparison.
|
||||
validConfig := config.Caddyfile
|
||||
|
||||
// Now deploy a service with invalid Caddyfile that references missing cert files and check it isn't included.
|
||||
invalidServiceName := "test-invalid-caddy-config"
|
||||
t.Cleanup(func() {
|
||||
err := cli.RemoveService(ctx, invalidServiceName)
|
||||
if !errors.Is(err, api.ErrNotFound) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
|
||||
invalidCaddyfile := `test-invalid.example.com {
|
||||
tls cert.pem key.pem
|
||||
}`
|
||||
invalidSpec := api.ServiceSpec{
|
||||
Name: invalidServiceName,
|
||||
Container: api.ContainerSpec{
|
||||
Image: "portainer/pause:latest",
|
||||
},
|
||||
Caddy: &api.CaddySpec{
|
||||
Config: invalidCaddyfile,
|
||||
},
|
||||
}
|
||||
|
||||
invalidDeployment := cli.NewDeployment(invalidSpec, nil)
|
||||
_, err = invalidDeployment.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
invalidSvc, err := cli.InspectService(ctx, invalidServiceName)
|
||||
require.NoError(t, err)
|
||||
assertServiceMatchesSpec(t, invalidSvc, invalidSpec)
|
||||
|
||||
// Wait a bit for any config updates to potentially happen.
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Check that the Caddy config hasn't changed.
|
||||
newConfig, err := cli.Caddy.GetConfig(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, validConfig, newConfig.Caddyfile,
|
||||
"Caddy config should not change when an invalid user-defined Caddy config is deployed")
|
||||
})
|
||||
|
||||
t.Run("replicated", func(t *testing.T) {
|
||||
|
||||
@@ -318,8 +318,126 @@ Give it a moment for Caddy to obtain a TLS certificate, then visit https://excal
|
||||
|
||||
## Clean up
|
||||
|
||||
TBD
|
||||
When you're done experimenting, you can remove the `excalidraw` service or uninstall Uncloud completely.
|
||||
|
||||
## Next steps
|
||||
### Remove the service
|
||||
|
||||
TBD
|
||||
Remove the `excalidraw` service while keeping your Uncloud machine running for future deployments:
|
||||
|
||||
```shell
|
||||
uc rm excalidraw
|
||||
```
|
||||
|
||||
### Uninstall Uncloud
|
||||
|
||||
If you want to completely uninstall Uncloud from your server and clean up everything it created, SSH into your server
|
||||
and run:
|
||||
|
||||
```shell
|
||||
sudo uncloud-uninstall
|
||||
```
|
||||
|
||||
This command will:
|
||||
|
||||
- Remove all Uncloud-managed containers (including Caddy)
|
||||
- Remove the Uncloud-managed Docker and WireGuard networks
|
||||
- Uninstall the Uncloud daemon from the server
|
||||
|
||||
<details>
|
||||
<summary>💡 Expand to see example output</summary>
|
||||
|
||||
```
|
||||
⚠️This script will uninstall Uncloud and remove ALL Uncloud managed containers on this machine.
|
||||
The following actions will be performed:
|
||||
- Remove Uncloud systemd services
|
||||
- Remove Uncloud binaries and data
|
||||
- Remove Uncloud user and group
|
||||
- Remove all Docker containers managed by Uncloud
|
||||
- Remove Uncloud Docker network
|
||||
- Remove Uncloud WireGuard interface
|
||||
Do you want to proceed with uninstallation? [y/N] y
|
||||
⏳ Stopping systemd services...
|
||||
Removed "/etc/systemd/system/multi-user.target.wants/uncloud.service".
|
||||
The unit files have no installation config (WantedBy=, RequiredBy=, UpheldBy=,
|
||||
Also=, or Alias= settings in the [Install] section, and DefaultInstance= for
|
||||
template units). This means they are not meant to be enabled or disabled using systemctl.
|
||||
|
||||
Possible reasons for having these kinds of units are:
|
||||
• A unit may be statically enabled by being symlinked from another unit's
|
||||
.wants/, .requires/, or .upholds/ directory.
|
||||
• A unit's purpose may be to act as a helper for some other unit which has
|
||||
a requirement dependency on it.
|
||||
• A unit may be started when needed via activation (socket, path, timer,
|
||||
D-Bus, udev, scripted systemctl call, ...).
|
||||
• In case of template units, the unit is meant to be enabled with some
|
||||
instance name specified.
|
||||
✓ Systemd services stopped.
|
||||
⏳ Removing systemd service files...
|
||||
removed '/etc/systemd/system/uncloud.service'
|
||||
removed '/etc/systemd/system/uncloud-corrosion.service'
|
||||
✓ Systemd service files removed.
|
||||
⏳ Removing binaries...
|
||||
removed '/usr/local/bin/uncloudd'
|
||||
removed '/usr/local/bin/uncloud-corrosion'
|
||||
✓ Binaries removed.
|
||||
⏳ Removing data and run directories...
|
||||
removed '/var/lib/uncloud/machine.db-wal'
|
||||
removed '/var/lib/uncloud/caddy/caddy/autosave.json'
|
||||
removed directory '/var/lib/uncloud/caddy/caddy'
|
||||
removed '/var/lib/uncloud/caddy/caddy.json'
|
||||
removed directory '/var/lib/uncloud/caddy'
|
||||
removed '/var/lib/uncloud/machine.json'
|
||||
removed '/var/lib/uncloud/machine.db-shm'
|
||||
removed '/var/lib/uncloud/corrosion/admin.sock'
|
||||
removed '/var/lib/uncloud/corrosion/config.toml'
|
||||
removed '/var/lib/uncloud/corrosion/subscriptions/b4e825113f1143e5b27715b62193a9f8/sub.sqlite-wal'
|
||||
removed '/var/lib/uncloud/corrosion/subscriptions/b4e825113f1143e5b27715b62193a9f8/sub.sqlite-shm'
|
||||
removed '/var/lib/uncloud/corrosion/subscriptions/b4e825113f1143e5b27715b62193a9f8/sub.sqlite'
|
||||
removed directory '/var/lib/uncloud/corrosion/subscriptions/b4e825113f1143e5b27715b62193a9f8'
|
||||
removed '/var/lib/uncloud/corrosion/subscriptions/5e04cbb20a2743c382cfbd4949922351/sub.sqlite'
|
||||
removed directory '/var/lib/uncloud/corrosion/subscriptions/5e04cbb20a2743c382cfbd4949922351'
|
||||
removed directory '/var/lib/uncloud/corrosion/subscriptions'
|
||||
removed '/var/lib/uncloud/corrosion/schema.sql'
|
||||
removed '/var/lib/uncloud/corrosion/store.db'
|
||||
removed directory '/var/lib/uncloud/corrosion'
|
||||
removed '/var/lib/uncloud/machine.db'
|
||||
removed directory '/var/lib/uncloud'
|
||||
removed directory '/run/uncloud'
|
||||
✓ Data and run directories removed.
|
||||
⏳ Removing Linux user and group...
|
||||
✓ Linux user 'uncloud' removed.
|
||||
Linux group 'uncloud' does not exist or was already removed.
|
||||
⏳ Looking for Docker containers and network created by Uncloud...
|
||||
Found 4 Uncloud managed containers.
|
||||
⏳ Stopping Uncloud managed containers...
|
||||
20613f6046d0
|
||||
1f1a65b78e93
|
||||
4300bde4a2b0
|
||||
053fdd57ec56
|
||||
⏳ Removing Uncloud managed containers...
|
||||
20613f6046d0
|
||||
1f1a65b78e93
|
||||
4300bde4a2b0
|
||||
053fdd57ec56
|
||||
✓ Uncloud managed containers stopped and removed.
|
||||
⏳ Removing Docker network uncloud...
|
||||
uncloud
|
||||
✓ Docker network uncloud removed.
|
||||
⏳ Removing WireGuard interface uncloud...
|
||||
✓ WireGuard interface uncloud removed.
|
||||
⏳ Removing uninstall script...
|
||||
removed '/usr/local/bin/uncloud-uninstall'
|
||||
✓ Uninstall script removed.
|
||||
|
||||
✅ Uncloud has been uninstalled successfully!
|
||||
Note: Docker installation was preserved. If you want to completely remove Docker as well, follow https://docs.docker.com/engine/install/ubuntu/#uninstall-docker-engine
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Further reading
|
||||
|
||||
- **[Add more machines](../9-cli-reference/uc_machine_add.md)**: Scale horizontally by creating a cluster of machines
|
||||
- **[Ingress & HTTP](../3-concepts/1-ingress/1-overview.md)**: Learn how Uncloud handles incoming traffic and how to
|
||||
expose your services to the internet
|
||||
- **[CLI reference](../9-cli-reference/uc.md)**: Explore all available commands and options
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
<span class="ml-1 hidden sm:inline">GitHub</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="ml-1">
|
||||
<li class="ml-1 hidden sm:block">
|
||||
<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
|
||||
|
||||
@@ -2203,6 +2203,10 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.sm\:block{
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sm\:inline{
|
||||
display: inline;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user