feat(caddy): add ListCertificates method, refactor Caddy client layout (#31)

This commit is contained in:
Pasha Sviderski committed 2026-09-30 16:43:46 +10:00
1 parent e50c5fbe57
commit 930e7ea637
23 files changed
+1112 -149

No files matched your search

+84 -21
View File
@@ -1,6 +1,7 @@
package client
import (
"context"
"fmt"
"regexp"
@@ -8,8 +9,10 @@ import (
"github.com/distribution/reference"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy"
"google.golang.org/protobuf/types/known/emptypb"
)
const (
@@ -20,12 +23,65 @@ const (
var caddyImageTagRegex = regexp.MustCompile(`^2\.\d+\.\d+$`)
// NewCaddyDeployment creates a new deployment for a Caddy reverse proxy service.
// CaddyClient provides Caddy operations over its parent Client's connection.
// The parent client owns the connection and must be used to close it.
type CaddyClient struct {
// Storage provides low-level access to Caddy's cluster-backed storage.
Storage pb.CaddyStorageClient
grpc pb.CaddyClient
client *Client
}
// CaddyConfigOptions controls which machine's saved Caddy configuration is retrieved.
type CaddyConfigOptions struct {
// Machine is the machine name or ID. If empty, the configuration is retrieved from the machine the client
// is connected to.
Machine string
}
// Config retrieves the saved Caddy configuration from the machine selected by opts.
func (c *CaddyClient) Config(ctx context.Context, opts CaddyConfigOptions) (api.CaddyConfig, error) {
if opts.Machine != "" {
ctx = ProxySingleMachineContext(ctx, opts.Machine)
}
resp, err := c.grpc.GetConfig(ctx, &emptypb.Empty{})
if err != nil {
return api.CaddyConfig{}, fmt.Errorf("get Caddy config: %w", err)
}
config := api.CaddyConfig{
Caddyfile: resp.Caddyfile,
LastReconciliationError: resp.LastReconciliationError,
}
if resp.ModifiedAt != nil {
if err := resp.ModifiedAt.CheckValid(); err != nil {
return api.CaddyConfig{}, fmt.Errorf("invalid Caddy config modification timestamp: %w", err)
}
config.ModifiedAt = resp.ModifiedAt.AsTime()
}
return config, nil
}
// CaddyDeploymentOptions configures a Caddy reverse proxy deployment.
type CaddyDeploymentOptions struct {
// Image defaults to the latest stable 2.x.x official Caddy image.
Image string
// Config contains an optional global Caddyfile.
Config string
Placement api.Placement
}
// NewDeployment creates a new deployment for a Caddy reverse proxy service.
// The service is deployed in global mode to all machines in the cluster. If the image is not provided, the latest
// version of the official Caddy Docker image is used.
func (cli *Client) NewCaddyDeployment(image, config string, placement api.Placement) (*deploy.Deployment, error) {
func (c *CaddyClient) NewDeployment(ctx context.Context, opts CaddyDeploymentOptions) (*deploy.Deployment, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
image := opts.Image
if image == "" {
latest, err := LatestCaddyImage()
latest, err := latestCaddyImage(ctx)
if err != nil {
return nil, fmt.Errorf("look up latest Caddy image: %w", err)
}
@@ -62,7 +118,7 @@ func (cli *Client) NewCaddyDeployment(image, config string, placement api.Placem
},
Mode: api.ServiceModeGlobal,
Name: CaddyServiceName,
Placement: placement,
Placement: opts.Placement,
Ports: []api.PortSpec{
{
PublishedPort: 80,
@@ -113,28 +169,44 @@ func (cli *Client) NewCaddyDeployment(image, config string, placement api.Placem
},
}
if config != "" {
if opts.Config != "" {
spec.Caddy = &api.CaddySpec{
Config: config,
Config: opts.Config,
}
}
return cli.NewDeployment(spec, nil), nil
return c.client.NewDeployment(spec, nil), nil
}
// LatestCaddyImage returns the latest image of the official Caddy Docker image on Docker Hub.
// latestCaddyImage returns the latest image of the official Caddy Docker image on Docker Hub.
// The latest image is determined by the latest version tag 2.x.x.
func LatestCaddyImage() (reference.NamedTagged, error) {
func latestCaddyImage(ctx context.Context) (reference.NamedTagged, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
repo, err := name.NewRepository(CaddyImage)
if err != nil {
return nil, fmt.Errorf("parse image: %w", err)
}
tags, err := remote.List(repo)
tags, err := remote.List(repo, remote.WithContext(ctx))
if err != nil {
return nil, fmt.Errorf("list image tags: %w", err)
}
// Default to the 'latest' tag but try to find the latest version tag 2.x.x.
image, err := reference.ParseDockerRef(CaddyImage)
if err != nil {
return nil, fmt.Errorf("parse image: %w", err)
}
imageWithTag, err := reference.WithTag(image, latestCaddyTag(tags))
if err != nil {
return nil, fmt.Errorf("set image tag: %w", err)
}
return imageWithTag, nil
}
// latestCaddyTag selects the newest stable 2.x.x tag, falling back to latest.
func latestCaddyTag(tags []string) string {
latestTag := "latest"
var latestVersion *semver.Version
for _, t := range tags {
@@ -152,14 +224,5 @@ func LatestCaddyImage() (reference.NamedTagged, error) {
}
}
image, err := reference.ParseDockerRef(CaddyImage)
if err != nil {
return nil, fmt.Errorf("parse image: %w", err)
}
imageWithTag, err := reference.WithTag(image, latestTag)
if err != nil {
return nil, fmt.Errorf("set image tag: %w", err)
}
return imageWithTag, nil
return latestTag
}
+44
View File
@@ -0,0 +1,44 @@
package client
import (
"context"
"errors"
"github.com/psviderski/uncloud/pkg/api"
"google.golang.org/protobuf/types/known/emptypb"
)
// CaddyListCertificatesOptions controls which machine's certificate storage replica is queried.
type CaddyListCertificatesOptions struct {
// Machine is the machine name or ID. If empty, the machine the client is connected to is used.
Machine string
}
// ListCertificates lists issued certificates from the Caddy's managed certificate storage backed by the distributed
// cluster store. It doesn't verify trust or whether Caddy serves them and may include expired certificates.
// Private key assets are never read or returned.
//
// This is a non-atomic inventory of one store replica. It does not wait for replication.
// It returns parsing errors as a combined error, but still returns successfully read certificates regardless
// of errors.
func (c *CaddyClient) ListCertificates(ctx context.Context, opts CaddyListCertificatesOptions) ([]api.IssuedCertificate, error) {
if opts.Machine != "" {
ctx = ProxySingleMachineContext(ctx, opts.Machine)
}
resp, err := c.Storage.ListCertificates(ctx, &emptypb.Empty{})
if err != nil {
return nil, err
}
var certs []api.IssuedCertificate
var errs []error
for _, p := range resp.Certificates {
if cert, err := api.IssuedCertificateFromProto(p); err != nil {
errs = append(errs, err)
} else {
certs = append(certs, cert)
}
}
return certs, errors.Join(errs...)
}
+23 -1
View File
@@ -1,6 +1,7 @@
package client
import (
"context"
"testing"
"github.com/distribution/reference"
@@ -11,8 +12,29 @@ import (
func TestLatestCaddyImage(t *testing.T) {
t.Parallel()
image, err := LatestCaddyImage()
image, err := latestCaddyImage(context.Background())
require.NoError(t, err)
assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, reference.FamiliarString(image))
}
func TestLatestCaddyTag(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
tags []string
want string
}{
{name: "empty", want: "latest"},
{name: "semantic ordering", tags: []string{"2.9.9", "2.11.4", "2.10.0", "2.11.3"}, want: "2.11.4"},
{name: "ignore other versions and variants", tags: []string{
"latest", "1.0.0", "3.0.0", "2.12.0-rc.1", "2.12.0-alpine", "2.12", "v2.12.0", "2.11.4",
}, want: "2.11.4"},
{name: "no stable version", tags: []string{"builder", "2.12.0-beta.1"}, want: "latest"},
} {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, latestCaddyTag(tt.tags))
})
}
}
+7 -4
View File
@@ -25,8 +25,8 @@ type Client struct {
// Methods such as Reset or Inspect are ambiguous in the context of a machine+cluster client.
pb.MachineClient
pb.ClusterClient
Caddy pb.CaddyClient
CaddyStorage pb.CaddyStorageClient
// Caddy provides Caddy configuration, deployment, and certificate storage operations.
Caddy *CaddyClient
// Docker is a namespaced client for the Docker service to distinguish Uncloud-specific service container operations
// from generic Docker operations.
Docker *docker.Client
@@ -58,8 +58,11 @@ func New(ctx context.Context, connector Connector) (*Client, error) {
c.MachineClient = pb.NewMachineClient(c.conn)
c.ClusterClient = pb.NewClusterClient(c.conn)
c.Caddy = pb.NewCaddyClient(c.conn)
c.CaddyStorage = pb.NewCaddyStorageClient(c.conn)
c.Caddy = &CaddyClient{
Storage: pb.NewCaddyStorageClient(c.conn),
grpc: pb.NewCaddyClient(c.conn),
client: c,
}
c.Docker = docker.NewClient(c.conn)
c.leases = distlockgrpc.NewLeaseClient(c.conn)