Compare commits

...
9 Commits
15 changed files with 653 additions and 165 deletions
+36
View File
@@ -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. -->
+1 -1
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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)
}
+80
View File
@@ -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)
}
})
}
}
+12 -1
View File
@@ -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
+111 -40
View File
@@ -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{
+142
View File
@@ -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
}
+66 -35
View File
@@ -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)
-71
View File
@@ -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
View File
@@ -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",
},
+44 -5
View File
@@ -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
+1 -1
View File
@@ -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
+4
View File
@@ -2203,6 +2203,10 @@ input[type="search"]::-webkit-search-results-decoration {
margin-right: 0.25rem;
}
.sm\:block{
display: block;
}
.sm\:inline{
display: inline;
}