Files
uncloud/pkg/client/caddy.go
T

246 lines
6.7 KiB
Go

package client
import (
"context"
"fmt"
"regexp"
"time"
"github.com/Masterminds/semver"
"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 (
CaddyServiceName = "caddy"
// CaddyImage is the official Caddy Docker image on Docker Hub: https://hub.docker.com/_/caddy
CaddyImage = "caddy"
)
var caddyImageTagRegex = regexp.MustCompile(`^2\.\d+\.\d+$`)
// 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.
// Custom images must include curl and start Caddy with /etc/caddy/Caddyfile.
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 (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(ctx)
if err != nil {
return nil, fmt.Errorf("look up latest Caddy image: %w", err)
}
image = reference.FamiliarString(latest)
}
spec := api.ServiceSpec{
Container: api.ContainerSpec{
Env: map[string]string{
"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,
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/etc/caddy",
ReadOnly: true,
},
{
// Keep local TLS assets persistent while Caddy uses the default file system storage.
VolumeName: "data",
ContainerPath: "/data",
},
{
VolumeName: "run",
ContainerPath: "/run/caddy",
},
{
VolumeName: "uncloud-api",
ContainerPath: "/run/uncloud/api",
ReadOnly: true,
},
},
},
Mode: api.ServiceModeGlobal,
Name: CaddyServiceName,
Placement: opts.Placement,
Ports: []api.PortSpec{
{
PublishedPort: 80,
ContainerPort: 80,
Protocol: api.ProtocolTCP,
Mode: api.PortModeHost,
},
{
PublishedPort: 443,
ContainerPort: 443,
Protocol: api.ProtocolTCP,
Mode: api.PortModeHost,
},
// Needed for HTTP/3 (QUIC)
{
PublishedPort: 443,
ContainerPort: 443,
Protocol: api.ProtocolUDP,
Mode: api.PortModeHost,
},
},
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/var/lib/uncloud/caddy",
},
},
{
Name: "run",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/run/uncloud/caddy",
CreateHostPath: true,
},
},
// Bind the Uncloud API socket so caddy.storage.uncloud module can use it to store assets in the cluster.
{
Name: "uncloud-api",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
// Mount the parent directory that contains the socket so it lets the container see a replacement
// socket after the Uncloud daemon restarts (in case it doesn't use a systemd-activated socket).
HostPath: "/run/uncloud/api",
},
},
},
}
if opts.Config != "" {
spec.Caddy = &api.CaddySpec{
Config: opts.Config,
}
}
return c.client.NewDeployment(spec, nil), nil
}
// 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(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, remote.WithContext(ctx))
if err != nil {
return nil, fmt.Errorf("list image tags: %w", err)
}
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 {
if !caddyImageTagRegex.MatchString(t) {
continue
}
v, err := semver.NewVersion(t)
if err != nil {
continue
}
if latestVersion == nil || v.GreaterThan(latestVersion) {
latestVersion = v
latestTag = t
}
}
return latestTag
}