mirror of
https://github.com/psviderski/uncloud.git
synced 2026-11-11 13:14:17 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c58108aa82 | ||
|
|
5d7952952a | ||
|
|
87e99ae369 | ||
|
|
f78a8ca8b5 | ||
|
|
e4bd1ad443 | ||
|
|
0e53f505c0 | ||
|
|
9c21ae6b08 | ||
|
|
70de7b1b8f | ||
|
|
af277639dd | ||
|
|
25ca0a57e8 | ||
|
|
ebecf334d7 | ||
|
|
9ee3ca15eb | ||
|
|
b28c458029 | ||
|
|
3fc1cddf01 | ||
|
|
54a7328a34 | ||
|
|
47a4a9158e | ||
|
|
ad2e1a06d5 | ||
|
|
81d36edc66 | ||
|
|
5fb80e2071 | ||
|
|
d1fe23dec0 | ||
|
|
cec3c75a10 |
@@ -18,3 +18,6 @@ indent_size = 4
|
|||||||
[*.sh]
|
[*.sh]
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
|
|
||||||
|
[release-notes-*.md]
|
||||||
|
max_line_length = off
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ complexity of Kubernetes.
|
|||||||
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
||||||
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
||||||
Encrypt.
|
Encrypt.
|
||||||
|
* **Cluster storage for TLS certificates**: Native cluster storage for Caddy shares certificates and ACME challenge
|
||||||
|
tokens across machines and coordinates issuance.
|
||||||
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
* **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.
|
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ service Machine {
|
|||||||
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
||||||
rpc InspectMachine(google.protobuf.Empty) returns (InspectMachineResponse);
|
rpc InspectMachine(google.protobuf.Empty) returns (InspectMachineResponse);
|
||||||
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
|
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
|
||||||
// no known missing or pending transactions through those versions.
|
// no known missing or pending transactions through those versions from active members.
|
||||||
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
|
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
|
||||||
// have been superseded.
|
// have been superseded.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ type MachineClient interface {
|
|||||||
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
||||||
InspectMachine(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectMachineResponse, error)
|
InspectMachine(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectMachineResponse, error)
|
||||||
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
|
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
|
||||||
// no known missing or pending transactions through those versions.
|
// no known missing or pending transactions through those versions from active members.
|
||||||
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
|
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
|
||||||
// have been superseded.
|
// have been superseded.
|
||||||
//
|
//
|
||||||
@@ -224,7 +224,7 @@ type MachineServer interface {
|
|||||||
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
||||||
InspectMachine(context.Context, *emptypb.Empty) (*InspectMachineResponse, error)
|
InspectMachine(context.Context, *emptypb.Empty) (*InspectMachineResponse, error)
|
||||||
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
|
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
|
||||||
// no known missing or pending transactions through those versions.
|
// no known missing or pending transactions through those versions from active members.
|
||||||
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
|
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
|
||||||
// have been superseded.
|
// have been superseded.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package cert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/docker/go-units"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type listOptions struct {
|
||||||
|
machine string
|
||||||
|
output string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListCommand() *cobra.Command {
|
||||||
|
opts := listOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ls",
|
||||||
|
Aliases: []string{"list"},
|
||||||
|
Short: "List certificates in cluster storage for Caddy.",
|
||||||
|
Long: `List certificates stored in the Uncloud cluster storage for Caddy.
|
||||||
|
|
||||||
|
Caddy must use the Uncloud storage module (https://github.com/unlabs-dev/caddy-uncloud)
|
||||||
|
configured with 'storage uncloud' in the global options for its certificates to appear here.
|
||||||
|
|
||||||
|
This inventory does not check whether Caddy currently serves or trusts a certificate.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return list(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||||
|
"Name or ID of the machine to read certificate storage from. (default is connected machine)")
|
||||||
|
cmd.Flags().StringVarP(&opts.output, "output", "o", "",
|
||||||
|
"Output format: 'json' or empty for a human-readable table.")
|
||||||
|
completion.MachinesFlag(cmd)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||||
|
if opts.output != "" && opts.output != "json" {
|
||||||
|
return fmt.Errorf("unsupported output format '%s' (supported: json)", opts.output)
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
certs, listErr := clusterClient.Caddy.ListCertificates(ctx,
|
||||||
|
client.CaddyListCertificatesOptions{Machine: opts.machine})
|
||||||
|
if len(certs) > 0 || listErr == nil {
|
||||||
|
if err = printCertificates(os.Stdout, certs, opts.output); err != nil {
|
||||||
|
return fmt.Errorf("print Caddy certificates: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if listErr != nil {
|
||||||
|
if len(certs) > 0 {
|
||||||
|
return fmt.Errorf("certificate list is partial: %w", listErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("list Caddy certificates: %w", listErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printCertificates(out io.Writer, certs []api.IssuedCertificate, output string) error {
|
||||||
|
type certificate struct {
|
||||||
|
api.IssuedCertificate
|
||||||
|
Fingerprint string
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]certificate, 0, len(certs))
|
||||||
|
for _, cert := range certs {
|
||||||
|
fingerprint := cert.Fingerprint()
|
||||||
|
items = append(items, certificate{
|
||||||
|
IssuedCertificate: cert,
|
||||||
|
Fingerprint: hex.EncodeToString(fingerprint[:]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.SortFunc(items, func(a, b certificate) int {
|
||||||
|
if cmp := strings.Compare(a.SAN, b.SAN); cmp != 0 {
|
||||||
|
return cmp
|
||||||
|
}
|
||||||
|
if cmp := a.Chain[0].NotAfter.Compare(b.Chain[0].NotAfter); cmp != 0 {
|
||||||
|
return cmp
|
||||||
|
}
|
||||||
|
return strings.Compare(a.Fingerprint, b.Fingerprint)
|
||||||
|
})
|
||||||
|
|
||||||
|
if output == "json" {
|
||||||
|
encoder := json.NewEncoder(out)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err := encoder.Encode(items); err != nil {
|
||||||
|
return fmt.Errorf("marshal certificates: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
_, err := fmt.Fprintln(out, "No Caddy certificates found in cluster storage.")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
t := tui.NewTable()
|
||||||
|
t.Headers("ID", "NAME", "ISSUER", "EXPIRES")
|
||||||
|
for _, item := range items {
|
||||||
|
t.Row(
|
||||||
|
item.Fingerprint[:12],
|
||||||
|
item.SAN,
|
||||||
|
formatIssuer(item.IssuerData),
|
||||||
|
formatExpiry(item.Chain[0].NotAfter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_, err := lipgloss.Fprintln(out, t)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatIssuer(data api.CertificateIssuerData) string {
|
||||||
|
if data.ACME == nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch data.ACME.CA {
|
||||||
|
case certmagic.LetsEncryptProductionCA:
|
||||||
|
return "Let's Encrypt"
|
||||||
|
case certmagic.LetsEncryptStagingCA:
|
||||||
|
return "Let's Encrypt (staging)"
|
||||||
|
case certmagic.ZeroSSLProductionCA:
|
||||||
|
return "ZeroSSL"
|
||||||
|
case certmagic.GoogleTrustProductionCA:
|
||||||
|
return "Google Trust Services"
|
||||||
|
case certmagic.GoogleTrustStagingCA:
|
||||||
|
return "Google Trust Services (staging)"
|
||||||
|
default:
|
||||||
|
return data.ACME.CA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatExpiry(expires time.Time) string {
|
||||||
|
delta := expires.Sub(time.Now())
|
||||||
|
if delta <= 0 {
|
||||||
|
return fmt.Sprintf("%s (expired %s ago)", expires.UTC().Format(time.DateOnly),
|
||||||
|
strings.ToLower(units.HumanDuration(-delta)))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (%s)", expires.UTC().Format(time.DateOnly),
|
||||||
|
strings.ToLower(units.HumanDuration(delta)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package cert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrintCertificates(t *testing.T) {
|
||||||
|
now := time.Now().UTC().Truncate(time.Hour)
|
||||||
|
certs := []api.IssuedCertificate{
|
||||||
|
testIssuedCertificate("z.example.com", "z", now.Add(48*time.Hour), certmagic.LetsEncryptProductionCA),
|
||||||
|
testIssuedCertificate("a.example.com", "old", now.Add(-48*time.Hour), certmagic.LetsEncryptStagingCA),
|
||||||
|
testIssuedCertificate("a.example.com", "new", now.Add(72*time.Hour), "https://ca.example/directory"),
|
||||||
|
testIssuedCertificate("unknown.example.com", "unknown", now.Add(96*time.Hour), ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
var table bytes.Buffer
|
||||||
|
require.NoError(t, printCertificates(&table, certs, ""))
|
||||||
|
output := table.String()
|
||||||
|
assert.Contains(t, output, "ID")
|
||||||
|
assert.Contains(t, output, "NAME")
|
||||||
|
assert.Contains(t, output, "ISSUER")
|
||||||
|
assert.Contains(t, output, "EXPIRES")
|
||||||
|
assert.Contains(t, output, now.Add(-48*time.Hour).Format(time.DateOnly)+" (expired ")
|
||||||
|
assert.Contains(t, output, now.Add(48*time.Hour).Format(time.DateOnly)+" (")
|
||||||
|
assert.Contains(t, output, "Let's Encrypt (staging)")
|
||||||
|
assert.Contains(t, output, "https://ca.example/directory")
|
||||||
|
assert.Less(t, strings.Index(output, now.Add(-48*time.Hour).Format(time.DateOnly)),
|
||||||
|
strings.Index(output, now.Add(72*time.Hour).Format(time.DateOnly)))
|
||||||
|
assert.Less(t, strings.Index(output, "a.example.com"), strings.Index(output, "z.example.com"))
|
||||||
|
assert.Contains(t, output, testFingerprint("old")[:12])
|
||||||
|
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
require.NoError(t, printCertificates(&encoded, certs, "json"))
|
||||||
|
var items []struct {
|
||||||
|
api.IssuedCertificate
|
||||||
|
Fingerprint string
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(encoded.Bytes(), &items))
|
||||||
|
require.Len(t, items, 4)
|
||||||
|
assert.Equal(t, testFingerprint("old"), items[0].Fingerprint)
|
||||||
|
assert.Equal(t, "a.example.com", items[0].SAN)
|
||||||
|
require.Len(t, items[0].Chain, 1)
|
||||||
|
assert.Equal(t, []byte("old"), items[0].Chain[0].Raw)
|
||||||
|
assert.Equal(t, now.Add(-48*time.Hour), items[0].Chain[0].NotAfter)
|
||||||
|
require.NotNil(t, items[0].IssuerData.ACME)
|
||||||
|
assert.Equal(t, certmagic.LetsEncryptStagingCA, items[0].IssuerData.ACME.CA)
|
||||||
|
assert.Equal(t, testFingerprint("new"), items[1].Fingerprint)
|
||||||
|
require.NotNil(t, items[1].IssuerData.ACME)
|
||||||
|
assert.Equal(t, "https://ca.example/directory", items[1].IssuerData.ACME.CA)
|
||||||
|
assert.Nil(t, items[2].IssuerData.ACME)
|
||||||
|
assert.Equal(t, "z.example.com", items[3].SAN)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintCertificates_Empty(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
output string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{output: "", want: "No Caddy certificates found in cluster storage.\n"},
|
||||||
|
{output: "json", want: "[]\n"},
|
||||||
|
} {
|
||||||
|
var out bytes.Buffer
|
||||||
|
require.NoError(t, printCertificates(&out, nil, tt.output))
|
||||||
|
assert.Equal(t, tt.want, out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatIssuer(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
ca string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "production", ca: certmagic.LetsEncryptProductionCA, want: "Let's Encrypt"},
|
||||||
|
{name: "staging", ca: certmagic.LetsEncryptStagingCA, want: "Let's Encrypt (staging)"},
|
||||||
|
{name: "ZeroSSL", ca: certmagic.ZeroSSLProductionCA, want: "ZeroSSL"},
|
||||||
|
{name: "Google Trust Services", ca: certmagic.GoogleTrustProductionCA, want: "Google Trust Services"},
|
||||||
|
{name: "Google Trust Services staging", ca: certmagic.GoogleTrustStagingCA, want: "Google Trust Services (staging)"},
|
||||||
|
{name: "other", ca: "https://ca.example/directory", want: "https://ca.example/directory"},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, formatIssuer(api.CertificateIssuerData{
|
||||||
|
ACME: &api.ACMEIssuerData{CA: tt.ca},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "unknown", formatIssuer(api.CertificateIssuerData{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatExpiry_UTC(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
brisbane := time.FixedZone("AEST", 10*60*60)
|
||||||
|
expires := time.Date(now.Year(), now.Month(), now.Day()+3, 3, 0, 0, 0, brisbane)
|
||||||
|
assert.True(t, strings.HasPrefix(formatExpiry(expires), expires.UTC().Format(time.DateOnly)+" ("))
|
||||||
|
assert.NotEqual(t, expires.Format(time.DateOnly), expires.UTC().Format(time.DateOnly))
|
||||||
|
expired := now.Add(-2 * time.Hour)
|
||||||
|
assert.True(t, strings.HasPrefix(formatExpiry(expired), expired.UTC().Format(time.DateOnly)+" (expired "))
|
||||||
|
assert.Contains(t, formatExpiry(expired), " ago)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIssuedCertificate(name, raw string, expires time.Time, ca string) api.IssuedCertificate {
|
||||||
|
cert := api.IssuedCertificate{
|
||||||
|
SAN: name,
|
||||||
|
Chain: []*x509.Certificate{{Raw: []byte(raw), NotAfter: expires}},
|
||||||
|
}
|
||||||
|
if ca != "" {
|
||||||
|
cert.IssuerData.ACME = &api.ACMEIssuerData{CA: ca}
|
||||||
|
}
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFingerprint(raw string) string {
|
||||||
|
sum := sha256.Sum256([]byte(raw))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package cert
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func NewRootCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "cert",
|
||||||
|
Short: "Inspect certificates in cluster storage for Caddy.",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(NewListCommand())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package caddy
|
package caddy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/psviderski/uncloud/cmd/uc/caddy/cert"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
Short: "Manage Caddy reverse proxy service.",
|
Short: "Manage Caddy reverse proxy service.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
|
cert.NewRootCommand(),
|
||||||
NewConfigCommand(),
|
NewConfigCommand(),
|
||||||
NewDeployCommand(),
|
NewDeployCommand(),
|
||||||
NewLogsCommand(),
|
NewLogsCommand(),
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
"github.com/psviderski/uncloud/api/pb"
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
|
"github.com/psviderski/uncloud/internal/grpcversion"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ func set(ctx context.Context, uncli *cli.CLI, name string) error {
|
|||||||
}
|
}
|
||||||
defer clusterClient.Close()
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
ctx = metadata.AppendToOutgoingContext(ctx, grpcversion.MetadataKeyMinServerVersion, "0.21.0")
|
||||||
_, err = clusterClient.SetDomain(ctx, &pb.SetDomainRequest{Name: name})
|
_, err = clusterClient.SetDomain(ctx, &pb.SetDomainRequest{Name: name})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if status.Convert(err).Code() == codes.AlreadyExists {
|
if status.Convert(err).Code() == codes.AlreadyExists {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const adminCommandTimeout = 5 * time.Second
|
||||||
|
|
||||||
// AdminClient is a client for the Corrosion admin API.
|
// AdminClient is a client for the Corrosion admin API.
|
||||||
type AdminClient struct {
|
type AdminClient struct {
|
||||||
sockPath string
|
sockPath string
|
||||||
@@ -32,10 +34,15 @@ type Response struct {
|
|||||||
// The channel will be closed after sending the last or error response. The caller must read from the channel until
|
// The channel will be closed after sending the last or error response. The caller must read from the channel until
|
||||||
// it is closed.
|
// it is closed.
|
||||||
func (c *AdminClient) SendCommand(cmd []byte) (<-chan Response, error) {
|
func (c *AdminClient) SendCommand(cmd []byte) (<-chan Response, error) {
|
||||||
conn, err := net.Dial("unix", c.sockPath)
|
// TODO: Accept contexts in admin methods so callers can cancel requests and set their deadlines.
|
||||||
|
conn, err := net.DialTimeout("unix", c.sockPath, adminCommandTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("connect to admin socket: %w", err)
|
return nil, fmt.Errorf("connect to admin socket: %w", err)
|
||||||
}
|
}
|
||||||
|
if err = conn.SetDeadline(time.Now().Add(adminCommandTimeout)); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("set admin connection deadline: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if _, err = conn.Write(encodeFrame(cmd)); err != nil {
|
if _, err = conn.Write(encodeFrame(cmd)); err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
|
|||||||
@@ -262,11 +262,11 @@ func NewMachine(config *Config) (*Machine, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create corrosion API client: %w", err)
|
return nil, fmt.Errorf("create corrosion API client: %w", err)
|
||||||
}
|
}
|
||||||
corroStore := store.New(corro)
|
|
||||||
corroAdmin, err := corrosion.NewAdminClient(config.CorrosionAdminSockPath)
|
corroAdmin, err := corrosion.NewAdminClient(config.CorrosionAdminSockPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create corrosion admin client: %w", err)
|
return nil, fmt.Errorf("create corrosion admin client: %w", err)
|
||||||
}
|
}
|
||||||
|
corroStore := store.New(corro, corroAdmin)
|
||||||
|
|
||||||
initialised := make(chan struct{})
|
initialised := make(chan struct{})
|
||||||
clusterReady := make(chan struct{})
|
clusterReady := make(chan struct{})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/psviderski/uncloud/internal/corrosion"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,8 +16,8 @@ import (
|
|||||||
var ErrInvalidStoreVersion = errors.New("invalid store version")
|
var ErrInvalidStoreVersion = errors.New("invalid store version")
|
||||||
|
|
||||||
// WaitForVersion waits until the local store has reached each actor's minimum version in minVersion, with no known
|
// WaitForVersion waits until the local store has reached each actor's minimum version in minVersion, with no known
|
||||||
// missing or pending transactions through those versions. Corrosion may satisfy a version by applying its surviving
|
// missing or pending transactions through those versions from active members. Corrosion may satisfy a version
|
||||||
// changes or by marking it complete because its changes have been superseded.
|
// by applying its surviving changes or by marking it complete because its changes have been superseded.
|
||||||
//
|
//
|
||||||
// Waiting normally makes the captured data available locally. However, if another write replaces some of that data
|
// Waiting normally makes the captured data available locally. However, if another write replaces some of that data
|
||||||
// before it arrives, Corrosion can complete the older version without transferring the replaced data. If the
|
// before it arrives, Corrosion can complete the older version without transferring the replaced data. If the
|
||||||
@@ -53,28 +54,64 @@ func (s *Store) WaitForVersion(ctx context.Context, minVersion api.StoreVersion)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all bookkeeping in one SQLite snapshot. Raw buffered rows can remain after application,
|
queryPrefix := `WITH min_version(actor_id, version) AS (VALUES ` + strings.Join(placeholders, ", ") + `)
|
||||||
// but sequence bookkeeping is removed in the same transaction that applies or clears a version.
|
|
||||||
query := `WITH min_version(actor_id, version) AS (VALUES ` + strings.Join(placeholders, ", ") + `)
|
|
||||||
SELECT NOT EXISTS (
|
SELECT NOT EXISTS (
|
||||||
SELECT 1 FROM min_version
|
SELECT 1 FROM min_version
|
||||||
LEFT JOIN crsql_db_versions AS current ON current.site_id = min_version.actor_id
|
LEFT JOIN crsql_db_versions AS current ON current.site_id = min_version.actor_id
|
||||||
WHERE COALESCE(current.db_version, 0) < min_version.version
|
WHERE COALESCE(current.db_version, 0) < min_version.version`
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1 FROM __corro_bookkeeping_gaps
|
versionsReached := false
|
||||||
WHERE actor_id = min_version.actor_id AND start <= min_version.version
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1 FROM __corro_seq_bookkeeping
|
|
||||||
WHERE site_id = min_version.actor_id AND db_version <= min_version.version
|
|
||||||
)
|
|
||||||
)`
|
|
||||||
|
|
||||||
ticker := time.NewTicker(100 * time.Millisecond)
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
rows, err := s.corro.QueryContext(ctx, query, args...)
|
query := queryPrefix
|
||||||
|
queryArgs := args
|
||||||
|
// Check for gaps and pending transactions only after the requested versions are reached.
|
||||||
|
if versionsReached {
|
||||||
|
states, err := s.corroAdmin.ClusterMembershipStates(true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get cluster membership for store replication: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activePlaceholders := make([]string, 0, len(states))
|
||||||
|
queryArgs = make([]any, len(args), len(args)+len(states))
|
||||||
|
copy(queryArgs, args)
|
||||||
|
for _, state := range states {
|
||||||
|
if state.State != corrosion.MembershipStateAlive && state.State != corrosion.MembershipStateSuspect {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
actorID, err := uuid.Parse(state.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse cluster member actor '%s': %w", state.ID, err)
|
||||||
|
}
|
||||||
|
activePlaceholders = append(activePlaceholders, "?")
|
||||||
|
queryArgs = append(queryArgs, [16]byte(actorID))
|
||||||
|
}
|
||||||
|
if len(activePlaceholders) > 0 {
|
||||||
|
// Check active members' gaps and pending sequences in the same query for simplicity.
|
||||||
|
// Buffered rows can remain in __corro_buffered_changes after application, but sequence bookkeeping
|
||||||
|
// is removed when a version completes.
|
||||||
|
query += `
|
||||||
|
OR (
|
||||||
|
min_version.actor_id IN (` + strings.Join(activePlaceholders, ", ") + `)
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM __corro_bookkeeping_gaps
|
||||||
|
WHERE actor_id = min_version.actor_id AND start <= min_version.version
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM __corro_seq_bookkeeping
|
||||||
|
WHERE site_id = min_version.actor_id AND db_version <= min_version.version
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query += ")"
|
||||||
|
|
||||||
|
rows, err := s.corro.QueryContext(ctx, query, queryArgs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("check store replication: %w", err)
|
return fmt.Errorf("check store replication: %w", err)
|
||||||
}
|
}
|
||||||
@@ -84,6 +121,7 @@ func (s *Store) WaitForVersion(ctx context.Context, minVersion api.StoreVersion)
|
|||||||
}
|
}
|
||||||
return errors.New("check store replication: store version query returned no rows")
|
return errors.New("check store replication: store version query returned no rows")
|
||||||
}
|
}
|
||||||
|
|
||||||
var reached int
|
var reached int
|
||||||
if err = rows.Scan(&reached); err != nil {
|
if err = rows.Scan(&reached); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
@@ -95,7 +133,11 @@ func (s *Store) WaitForVersion(ctx context.Context, minVersion api.StoreVersion)
|
|||||||
return fmt.Errorf("check store replication: %w", err)
|
return fmt.Errorf("check store replication: %w", err)
|
||||||
}
|
}
|
||||||
if reached == 1 {
|
if reached == 1 {
|
||||||
return ctx.Err()
|
if versionsReached {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
versionsReached = true
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ var (
|
|||||||
|
|
||||||
// Store is a cluster store backed by a distributed Corrosion database.
|
// Store is a cluster store backed by a distributed Corrosion database.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
corro *corrosion.APIClient
|
corro *corrosion.APIClient
|
||||||
|
corroAdmin *corrosion.AdminClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(corro *corrosion.APIClient) *Store {
|
func New(corro *corrosion.APIClient, corroAdmin *corrosion.AdminClient) *Store {
|
||||||
return &Store{corro: corro}
|
return &Store{corro: corro, corroAdmin: corroAdmin}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves an unnamespaced legacy value.
|
// Get retrieves an unnamespaced legacy value.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
@@ -23,6 +24,11 @@ type IssuedCertificate struct {
|
|||||||
IssuerData CertificateIssuerData
|
IssuerData CertificateIssuerData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fingerprint returns the SHA-256 fingerprint of the leaf certificate.
|
||||||
|
func (c IssuedCertificate) Fingerprint() [sha256.Size]byte {
|
||||||
|
return sha256.Sum256(c.Chain[0].Raw)
|
||||||
|
}
|
||||||
|
|
||||||
// CertificateIssuerData preserves issuer-specific metadata and provides a best-effort typed view of ACME records.
|
// CertificateIssuerData preserves issuer-specific metadata and provides a best-effort typed view of ACME records.
|
||||||
type CertificateIssuerData struct {
|
type CertificateIssuerData struct {
|
||||||
// Raw is the original issuer_data JSON, including unrecognized formats and fields.
|
// Raw is the original issuer_data JSON, including unrecognized formats and fields.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"math/big"
|
"math/big"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -45,6 +46,17 @@ func TestIssuedCertificateFromProto(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIssuedCertificateFingerprint(t *testing.T) {
|
||||||
|
cert := IssuedCertificate{Chain: []*x509.Certificate{
|
||||||
|
{Raw: []byte("abc")},
|
||||||
|
{Raw: []byte("issuer")},
|
||||||
|
}}
|
||||||
|
want, err := hex.DecodeString("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
|
||||||
|
require.NoError(t, err)
|
||||||
|
fingerprint := cert.Fingerprint()
|
||||||
|
assert.Equal(t, want, fingerprint[:])
|
||||||
|
}
|
||||||
|
|
||||||
func TestIssuedCertificateFromProto_InvalidCertificate(t *testing.T) {
|
func TestIssuedCertificateFromProto_InvalidCertificate(t *testing.T) {
|
||||||
valid := testCertificatePEM(t, 1)
|
valid := testCertificatePEM(t, 1)
|
||||||
invalidDER := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("invalid")})
|
invalidDER := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("invalid")})
|
||||||
@@ -57,15 +69,25 @@ func TestIssuedCertificateFromProto_InvalidCertificate(t *testing.T) {
|
|||||||
{name: "missing SAN", input: &pb.IssuedCertificate{Chain: valid}, wantErr: "missing SAN"},
|
{name: "missing SAN", input: &pb.IssuedCertificate{Chain: valid}, wantErr: "missing SAN"},
|
||||||
{name: "blank SAN", input: &pb.IssuedCertificate{San: " \t", Chain: valid}, wantErr: "missing SAN"},
|
{name: "blank SAN", input: &pb.IssuedCertificate{San: " \t", Chain: valid}, wantErr: "missing SAN"},
|
||||||
{name: "empty chain", input: &pb.IssuedCertificate{San: "app.example.com"}, wantErr: "empty certificate chain"},
|
{name: "empty chain", input: &pb.IssuedCertificate{San: "app.example.com"}, wantErr: "empty certificate chain"},
|
||||||
{name: "invalid PEM", input: &pb.IssuedCertificate{San: "app.example.com", Chain: []byte("not PEM")},
|
{
|
||||||
wantErr: "empty certificate chain"},
|
name: "invalid PEM", input: &pb.IssuedCertificate{San: "app.example.com", Chain: []byte("not PEM")},
|
||||||
{name: "wrong PEM block type", input: &pb.IssuedCertificate{San: "app.example.com",
|
wantErr: "empty certificate chain",
|
||||||
Chain: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("key")})},
|
},
|
||||||
wantErr: "unexpected PEM block type"},
|
{
|
||||||
{name: "invalid DER", input: &pb.IssuedCertificate{San: "app.example.com", Chain: invalidDER},
|
name: "wrong PEM block type", input: &pb.IssuedCertificate{
|
||||||
wantErr: "parse X.509 certificate"},
|
San: "app.example.com",
|
||||||
{name: "invalid certificate after valid leaf", input: &pb.IssuedCertificate{San: "app.example.com",
|
Chain: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("key")}),
|
||||||
Chain: append(append([]byte(nil), valid...), invalidDER...)}, wantErr: "parse X.509 certificate"},
|
},
|
||||||
|
wantErr: "unexpected PEM block type",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid DER", input: &pb.IssuedCertificate{San: "app.example.com", Chain: invalidDER},
|
||||||
|
wantErr: "parse X.509 certificate",
|
||||||
|
},
|
||||||
|
{name: "invalid certificate after valid leaf", input: &pb.IssuedCertificate{
|
||||||
|
San: "app.example.com",
|
||||||
|
Chain: append(append([]byte(nil), valid...), invalidDER...),
|
||||||
|
}, wantErr: "parse X.509 certificate"},
|
||||||
} {
|
} {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
cert, err := IssuedCertificateFromProto(tt.input)
|
cert, err := IssuedCertificateFromProto(tt.input)
|
||||||
@@ -126,8 +148,10 @@ func TestIssuedCertificateFromProto_IssuerData(t *testing.T) {
|
|||||||
{name: "missing certificate URL", raw: `{"ca":"https://ca.example/directory"}`},
|
{name: "missing certificate URL", raw: `{"ca":"https://ca.example/directory"}`},
|
||||||
{name: "invalid JSON", raw: "{"},
|
{name: "invalid JSON", raw: "{"},
|
||||||
{name: "invalid field types", raw: `{"url":123,"ca":false}`},
|
{name: "invalid field types", raw: `{"url":123,"ca":false}`},
|
||||||
{name: "malformed renewal information",
|
{
|
||||||
raw: `{"url":"https://ca.example/cert/1","ca":"https://ca.example/directory","renewal_info":{"_selectedTime":"invalid"}}`},
|
name: "malformed renewal information",
|
||||||
|
raw: `{"url":"https://ca.example/cert/1","ca":"https://ca.example/directory","renewal_info":{"_selectedTime":"invalid"}}`,
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
cert, err := IssuedCertificateFromProto(&pb.IssuedCertificate{
|
cert, err := IssuedCertificateFromProto(&pb.IssuedCertificate{
|
||||||
|
|||||||
+19
-2
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Masterminds/semver"
|
"github.com/Masterminds/semver"
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
@@ -66,6 +67,7 @@ func (c *CaddyClient) Config(ctx context.Context, opts CaddyConfigOptions) (api.
|
|||||||
// CaddyDeploymentOptions configures a Caddy reverse proxy deployment.
|
// CaddyDeploymentOptions configures a Caddy reverse proxy deployment.
|
||||||
type CaddyDeploymentOptions struct {
|
type CaddyDeploymentOptions struct {
|
||||||
// Image defaults to the latest stable 2.x.x official Caddy image.
|
// Image defaults to the latest stable 2.x.x official Caddy image.
|
||||||
|
// Custom images must include curl and start Caddy with /etc/caddy/Caddyfile.
|
||||||
Image string
|
Image string
|
||||||
// Config contains an optional global Caddyfile.
|
// Config contains an optional global Caddyfile.
|
||||||
Config string
|
Config string
|
||||||
@@ -91,17 +93,32 @@ func (c *CaddyClient) NewDeployment(ctx context.Context, opts CaddyDeploymentOpt
|
|||||||
|
|
||||||
spec := api.ServiceSpec{
|
spec := api.ServiceSpec{
|
||||||
Container: api.ContainerSpec{
|
Container: api.ContainerSpec{
|
||||||
Command: []string{"caddy", "run", "-c", "/config/Caddyfile"},
|
|
||||||
Env: map[string]string{
|
Env: map[string]string{
|
||||||
"CADDY_ADMIN": "unix//run/caddy/admin.sock",
|
"CADDY_ADMIN": "unix//run/caddy/admin.sock",
|
||||||
},
|
},
|
||||||
|
Healthcheck: &api.HealthcheckSpec{
|
||||||
|
Test: []string{
|
||||||
|
"CMD",
|
||||||
|
"curl", "-fsS",
|
||||||
|
"-o", "/dev/null",
|
||||||
|
"--unix-socket", "/run/caddy/admin.sock",
|
||||||
|
"http://localhost/config/",
|
||||||
|
},
|
||||||
|
Interval: 30 * time.Second,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Retries: 3,
|
||||||
|
StartPeriod: 10 * time.Second,
|
||||||
|
StartInterval: 1 * time.Second,
|
||||||
|
},
|
||||||
Image: image,
|
Image: image,
|
||||||
VolumeMounts: []api.VolumeMount{
|
VolumeMounts: []api.VolumeMount{
|
||||||
{
|
{
|
||||||
VolumeName: "data",
|
VolumeName: "data",
|
||||||
ContainerPath: "/config",
|
ContainerPath: "/etc/caddy",
|
||||||
|
ReadOnly: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Keep local TLS assets persistent while Caddy uses the default file system storage.
|
||||||
VolumeName: "data",
|
VolumeName: "data",
|
||||||
ContainerPath: "/data",
|
ContainerPath: "/data",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ func (cli *Client) WaitClusterReady(ctx context.Context, timeout time.Duration)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WaitForStoreVersion waits until the cluster store on the target machine has reached each requested actor version
|
// WaitForStoreVersion waits until the cluster store on the target machine has reached each requested actor version
|
||||||
// in minVersion, with no known missing or pending transactions through those versions.
|
// in minVersion, with no known missing or pending transactions through those versions from active members.
|
||||||
// The context controls cancellation and the deadline. An empty minVersion requires no replication.
|
// The context controls cancellation and the deadline. An empty minVersion requires no replication.
|
||||||
// This method observes replication without initiating synchronisation.
|
// This method observes replication without initiating synchronisation.
|
||||||
//
|
//
|
||||||
|
|||||||
+25
-41
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -320,40 +321,18 @@ func (cli *Client) StartService(ctx context.Context, id string) error {
|
|||||||
|
|
||||||
// ListServices returns a list of all services and their containers.
|
// ListServices returns a list of all services and their containers.
|
||||||
func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
|
func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
|
||||||
machines, err := cli.ListMachines(ctx, nil)
|
// Broadcast the container list request to all machines in the cluster.
|
||||||
if err != nil {
|
listCtx := ProxyMachinesContext(ctx, nil)
|
||||||
return nil, fmt.Errorf("list machines: %w", err)
|
// List all containers including stopped ones and deployment hooks.
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast the container list request to all available machines.
|
|
||||||
md := metadata.New(nil)
|
|
||||||
for _, m := range machines {
|
|
||||||
if m.State == pb.MachineMember_UP || m.State == pb.MachineMember_SUSPECT {
|
|
||||||
md.Append("machines", m.Machine.Id)
|
|
||||||
} else {
|
|
||||||
tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine '%s' (state is %s). "+
|
|
||||||
"The results may be incomplete.", m.Machine.Name, m.State.String()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
listCtx := metadata.NewOutgoingContext(ctx, md)
|
|
||||||
|
|
||||||
// List all containers including stopped ones.
|
|
||||||
opts := container.ListOptions{All: true}
|
opts := container.ListOptions{All: true}
|
||||||
machineContainers, err := cli.Docker.ListServiceContainers(listCtx, "", opts)
|
machineContainers, err := cli.Docker.ListServiceContainers(listCtx, "", opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list containers: %w", err)
|
return nil, fmt.Errorf("list containers: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: optimise by extracting services from the list of all containers instead of inspecting each service.
|
// Group containers by service ID using the broadcast results.
|
||||||
// Most of the code can be reused in both InspectService and ListServices.
|
|
||||||
servicesByID := make(map[string]api.Service)
|
servicesByID := make(map[string]api.Service)
|
||||||
for _, mc := range machineContainers {
|
for _, mc := range machineContainers {
|
||||||
// NOTE: Metadata should never be nil in practice. This is legacy fallback that will be removed.
|
|
||||||
if mc.Metadata == nil {
|
|
||||||
tui.PrintWarning("metadata is missing in response from unknown server")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if mc.Metadata.Error != "" {
|
if mc.Metadata.Error != "" {
|
||||||
// TODO: return failed machines in the response.
|
// TODO: return failed machines in the response.
|
||||||
tui.PrintWarning(fmt.Sprintf("failed to list containers on machine '%s': %s",
|
tui.PrintWarning(fmt.Sprintf("failed to list containers on machine '%s': %s",
|
||||||
@@ -362,25 +341,30 @@ func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, ctr := range append(mc.Containers, mc.HookContainers...) {
|
for _, ctr := range append(mc.Containers, mc.HookContainers...) {
|
||||||
if _, ok := servicesByID[ctr.ServiceID()]; ok {
|
serviceID := ctr.ServiceID()
|
||||||
continue
|
svc, ok := servicesByID[serviceID]
|
||||||
}
|
if !ok {
|
||||||
|
svc = api.Service{
|
||||||
svc, err := cli.InspectService(ctx, ctr.ServiceID())
|
ID: serviceID,
|
||||||
if err != nil {
|
Name: ctr.ServiceName(),
|
||||||
if errors.Is(err, api.ErrNotFound) {
|
Mode: ctr.ServiceMode(),
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("inspect service: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
servicesByID[ctr.ServiceID()] = svc
|
machineContainer := api.MachineServiceContainer{
|
||||||
|
MachineID: mc.Metadata.MachineId,
|
||||||
|
MachineName: mc.Metadata.MachineName,
|
||||||
|
Container: ctr,
|
||||||
|
}
|
||||||
|
if ctr.IsHook() {
|
||||||
|
svc.HookContainers = append(svc.HookContainers, machineContainer)
|
||||||
|
} else {
|
||||||
|
svc.Containers = append(svc.Containers, machineContainer)
|
||||||
|
}
|
||||||
|
|
||||||
|
servicesByID[serviceID] = svc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
services := make([]api.Service, 0, len(servicesByID))
|
return slices.Collect(maps.Values(servicesByID)), nil
|
||||||
for _, svc := range servicesByID {
|
|
||||||
services = append(services, svc)
|
|
||||||
}
|
|
||||||
return services, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ traditional container orchestrators like Kubernetes or Swarm:
|
|||||||
* Scaling services across multiple machines
|
* Scaling services across multiple machines
|
||||||
* Cross-machine service communication without exposing ports to the internet
|
* Cross-machine service communication without exposing ports to the internet
|
||||||
* DNS-based service discovery
|
* DNS-based service discovery
|
||||||
* Automatic HTTPS and reverse proxy configuration
|
* Automatic HTTPS and reverse proxy configuration with clustered storage for certificates
|
||||||
* Load balancing
|
* Load balancing
|
||||||
* Persistent storage
|
* Persistent storage
|
||||||
|
|
||||||
|
|||||||
@@ -24,3 +24,11 @@ When you [publish a service port](2-publishing-services.md), Uncloud automatical
|
|||||||
|
|
||||||
For advanced use cases, Uncloud allows to customise the Caddy config using the `x-caddy` extension in Compose files.
|
For advanced use cases, Uncloud allows to customise the Caddy config using the `x-caddy` extension in Compose files.
|
||||||
See [Custom Caddy configuration](2-publishing-services.md#custom-caddy-configuration) for details.
|
See [Custom Caddy configuration](2-publishing-services.md#custom-caddy-configuration) for details.
|
||||||
|
|
||||||
|
:::tip Shared TLS certificates
|
||||||
|
|
||||||
|
Uncloud also provides native cluster storage for Caddy. It shares TLS certificates and ACME challenge tokens across
|
||||||
|
machines and coordinates certificate issuance. See [Cluster storage for Caddy](4-cluster-storage-for-caddy.md)
|
||||||
|
to enable it.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
Caddy is automatically deployed as a global service `caddy` when you initialise a cluster with `uc machine init`. By
|
Caddy is automatically deployed as a global service `caddy` when you initialise a cluster with `uc machine init`. By
|
||||||
default, it runs on every machine to handle incoming HTTP/HTTPS traffic and route it to your services.
|
default, it runs on every machine to handle incoming HTTP/HTTPS traffic and route it to your services.
|
||||||
|
|
||||||
|
Since [v0.21.0](https://github.com/psviderski/uncloud/releases/tag/v0.21.0), Uncloud provides native
|
||||||
|
[cluster storage for Caddy](4-cluster-storage-for-caddy.md) (opt-in) to share TLS certificates and coordinate
|
||||||
|
certificate issuance across machines.
|
||||||
|
|
||||||
## Checking status
|
## Checking status
|
||||||
|
|
||||||
View the `caddy` service status and which machines it's running on:
|
View the `caddy` service status and which machines it's running on:
|
||||||
@@ -34,7 +38,7 @@ uc caddy deploy
|
|||||||
Deploy a specific version or custom image:
|
Deploy a specific version or custom image:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
uc caddy deploy --image caddybuilds/caddy-cloudflare:2.10.2
|
uc caddy deploy --image caddybuilds/caddy-cloudflare:2.11.4
|
||||||
```
|
```
|
||||||
|
|
||||||
Deploy only to a specific machine or a subset of machines (comma-separated list):
|
Deploy only to a specific machine or a subset of machines (comma-separated list):
|
||||||
@@ -80,19 +84,27 @@ config that uses the DNS challenge with Cloudflare to obtain a wildcard TLS cert
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
caddy:
|
caddy:
|
||||||
image: caddybuilds/caddy-cloudflare:2.10.2
|
image: caddybuilds/caddy-cloudflare:2.11.4
|
||||||
command: caddy run -c /config/Caddyfile
|
|
||||||
environment:
|
environment:
|
||||||
# unix// is not a typo. Caddy uses network/address format, not a unix:// URL.
|
# unix// is not a typo. Caddy uses network/address format, not a unix:// URL.
|
||||||
CADDY_ADMIN: unix//run/caddy/admin.sock
|
CADDY_ADMIN: unix//run/caddy/admin.sock
|
||||||
env_file:
|
env_file:
|
||||||
# Contains CLOUDFLARE_API_TOKEN=xxxxx
|
# Contains CLOUDFLARE_API_TOKEN=xxxxx
|
||||||
- .env.secrets
|
- .env.secrets
|
||||||
|
healthcheck:
|
||||||
|
test: curl -fsS -o /dev/null --unix-socket /run/caddy/admin.sock http://localhost/config/
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
start_interval: 1s
|
||||||
volumes:
|
volumes:
|
||||||
|
- /var/lib/uncloud/caddy:/etc/caddy:ro
|
||||||
|
# Persists TLS certificates and other assets when using Caddy's default local storage.
|
||||||
- /var/lib/uncloud/caddy:/data
|
- /var/lib/uncloud/caddy:/data
|
||||||
- /var/lib/uncloud/caddy:/config
|
|
||||||
- /run/uncloud/caddy:/run/caddy
|
- /run/uncloud/caddy:/run/caddy
|
||||||
# Required by caddy.storage.uncloud module. Remove this mount if Uncloud cluster storage is not used for Caddy.
|
# Required by the Uncloud cluster storage module (https://github.com/unlabs-dev/caddy-uncloud).
|
||||||
|
# Remove this mount if cluster storage is not used for Caddy.
|
||||||
# Mount the directory, not the socket file, so Caddy sees a replacement socket after the daemon restarts.
|
# Mount the directory, not the socket file, so Caddy sees a replacement socket after the daemon restarts.
|
||||||
- /run/uncloud/api:/run/uncloud/api:ro
|
- /run/uncloud/api:/run/uncloud/api:ro
|
||||||
x-ports:
|
x-ports:
|
||||||
@@ -142,9 +154,15 @@ internal.example.com {
|
|||||||
|
|
||||||
:::info note
|
:::info note
|
||||||
|
|
||||||
The specified `command`, `environment`, `volumes`, and `x-ports` properties are essential for Caddy to function
|
The specified `environment`, `volumes`, and `x-ports` properties are essential for Caddy to function correctly in the
|
||||||
correctly in the Uncloud cluster. Do not change the source paths of the volume mounts as the Uncloud daemon relies on
|
Uncloud cluster. Do not change the source paths of the volume mounts as the Uncloud daemon relies on them to communicate
|
||||||
them to communicate with Caddy and update its configuration.
|
with Caddy and update its configuration.
|
||||||
|
|
||||||
|
The image must include `curl` for the healthcheck and start Caddy with `/etc/caddy/Caddyfile`. Images based on the
|
||||||
|
official [Caddy image](https://hub.docker.com/_/caddy) do both by default.
|
||||||
|
|
||||||
|
Keep the `/data` mount while using Caddy's default local storage so TLS certificates survive container updates. You can
|
||||||
|
remove this mount when using [cluster storage for Caddy](4-cluster-storage-for-caddy.md).
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Cluster storage for Caddy
|
||||||
|
|
||||||
|
The [Uncloud storage module](https://github.com/unlabs-dev/caddy-uncloud) lets Caddy instances share TLS certificates,
|
||||||
|
private keys, and ACME challenge tokens through Uncloud's cluster store. It uses distributed locks to coordinate
|
||||||
|
certificate issuance. Once one instance obtains a certificate, the others can use it too.
|
||||||
|
|
||||||
|
## Why use cluster storage?
|
||||||
|
|
||||||
|
By default, each Caddy instance stores its certificates locally. When DNS points to multiple machines or a load balancer
|
||||||
|
distributes traffic between them, an ACME challenge can reach a different instance from the one requesting the
|
||||||
|
certificate. That instance may not have the challenge token, which can delay or prevent certificate issuance.
|
||||||
|
|
||||||
|
Cluster storage is optional. The default Caddy image does not include the module, so you need to deploy an image that
|
||||||
|
includes it and configure Caddy to use it.
|
||||||
|
|
||||||
|
## Enabling cluster storage
|
||||||
|
|
||||||
|
:::info Requirements
|
||||||
|
|
||||||
|
Cluster storage requires Uncloud **v0.21.0 or newer** for both the `uc` CLI and the daemon on every cluster machine.
|
||||||
|
Check your CLI version with `uc version` and daemon versions with `uc machine ls`. Upgrade older versions before
|
||||||
|
enabling cluster storage.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
Create a global Caddyfile, or add `storage uncloud` to the global options in your existing one:
|
||||||
|
|
||||||
|
```caddyfile title="global.Caddyfile"
|
||||||
|
{
|
||||||
|
storage uncloud
|
||||||
|
|
||||||
|
# Uncomment to enable debug logs useful for troubleshooting storage operations:
|
||||||
|
# debug
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy Caddy with the pre-built module image and your global config:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uc caddy deploy --image ghcr.io/unlabs-dev/caddy-uncloud:0.1.1 --caddyfile global.Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [module README](https://github.com/unlabs-dev/caddy-uncloud#usage) for custom image builds, Compose deployment,
|
||||||
|
and additional storage options.
|
||||||
|
|
||||||
|
## Verifying storage
|
||||||
|
|
||||||
|
Check that `storage uncloud` appears in the Caddy config:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uc caddy config
|
||||||
|
```
|
||||||
|
|
||||||
|
List issued certificates in cluster storage with [`uc caddy cert ls`](../../9-cli-reference/uc_caddy_cert_ls.md):
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uc caddy cert ls
|
||||||
|
```
|
||||||
@@ -20,6 +20,7 @@ Manage Caddy reverse proxy service.
|
|||||||
## See also
|
## See also
|
||||||
|
|
||||||
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes.
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes.
|
||||||
|
* [uc caddy cert](uc_caddy_cert.md) - Inspect certificates in cluster storage for Caddy.
|
||||||
* [uc caddy config](uc_caddy_config.md) - Show the current Caddy configuration (Caddyfile).
|
* [uc caddy config](uc_caddy_config.md) - Show the current Caddy configuration (Caddyfile).
|
||||||
* [uc caddy deploy](uc_caddy_deploy.md) - Deploy or upgrade Caddy reverse proxy across all machines in the cluster.
|
* [uc caddy deploy](uc_caddy_deploy.md) - Deploy or upgrade Caddy reverse proxy across all machines in the cluster.
|
||||||
* [uc caddy logs](uc_caddy_logs.md) - View caddy logs.
|
* [uc caddy logs](uc_caddy_logs.md) - View caddy logs.
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# uc caddy cert
|
||||||
|
|
||||||
|
Inspect certificates in cluster storage for Caddy.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-h, --help help for cert
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||||
|
Format: [ssh://]user@host[:port], ssh+go://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock
|
||||||
|
-c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc caddy](uc_caddy.md) - Manage Caddy reverse proxy service.
|
||||||
|
* [uc caddy cert ls](uc_caddy_cert_ls.md) - List certificates in cluster storage for Caddy.
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# uc caddy cert ls
|
||||||
|
|
||||||
|
List certificates in cluster storage for Caddy.
|
||||||
|
|
||||||
|
## Synopsis
|
||||||
|
|
||||||
|
List certificates stored in the Uncloud cluster storage for Caddy.
|
||||||
|
|
||||||
|
Caddy must use the Uncloud storage module (https://github.com/unlabs-dev/caddy-uncloud)
|
||||||
|
configured with 'storage uncloud' in the global options for its certificates to appear here.
|
||||||
|
|
||||||
|
This inventory does not check whether Caddy currently serves or trusts a certificate.
|
||||||
|
|
||||||
|
```
|
||||||
|
uc caddy cert ls [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
-h, --help help for ls
|
||||||
|
-m, --machine string Name or ID of the machine to read certificate storage from. (default is connected machine)
|
||||||
|
-o, --output string Output format: 'json' or empty for a human-readable table.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options inherited from parent commands
|
||||||
|
|
||||||
|
```
|
||||||
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||||
|
Format: [ssh://]user@host[:port], ssh+go://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock
|
||||||
|
-c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]
|
||||||
|
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
* [uc caddy cert](uc_caddy_cert.md) - Inspect certificates in cluster storage for Caddy.
|
||||||
|
|
||||||
Reference in New Issue
Block a user