mirror of
https://github.com/psviderski/uncloud.git
synced 2026-10-06 13:18:58 +00:00
feat(caddy): add ListCertificates method, refactor Caddy client layout (#31)
This commit is contained in:
1 parent
e50c5fbe57
commit
930e7ea637
23 files changed
+1112
-149
No files matched your search
+13
-1
@@ -1,6 +1,18 @@
|
||||
package api
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CaddyConfig is the saved Caddy configuration on a machine.
|
||||
type CaddyConfig struct {
|
||||
Caddyfile string
|
||||
// ModifiedAt is zero when the server does not supply a modification timestamp.
|
||||
ModifiedAt time.Time
|
||||
// LastReconciliationError describes the latest unsuccessful configuration reconciliation.
|
||||
LastReconciliationError string
|
||||
}
|
||||
|
||||
// CaddySpec is the Caddy reverse proxy configuration for a service.
|
||||
type CaddySpec struct {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mholt/acmez/v3/acme"
|
||||
"github.com/psviderski/uncloud/api/pb"
|
||||
)
|
||||
|
||||
// IssuedCertificate describes a certificate in Caddy's managed certificate storage.
|
||||
// It does not indicate whether Caddy currently serves the certificate or whether it is trusted.
|
||||
type IssuedCertificate struct {
|
||||
// SAN is the Subject Alternative Name (SAN) of the certificate.
|
||||
// Caddy doesn't issue certificates with multiple SANs.
|
||||
SAN string
|
||||
// Chain contains the parsed certificates in stored order, with the leaf first.
|
||||
Chain []*x509.Certificate
|
||||
// IssuerData is extra information associated with the certificate, usually provided by the issuer implementation.
|
||||
IssuerData CertificateIssuerData
|
||||
}
|
||||
|
||||
// CertificateIssuerData preserves issuer-specific metadata and provides a best-effort typed view of ACME records.
|
||||
type CertificateIssuerData struct {
|
||||
// Raw is the original issuer_data JSON, including unrecognized formats and fields.
|
||||
Raw json.RawMessage
|
||||
// ACME is populated when the metadata can be decoded as an ACME record with CA and certificate URLs.
|
||||
// It is nil for absent, unrecognized, or malformed metadata.
|
||||
ACME *ACMEIssuerData
|
||||
}
|
||||
|
||||
// ACMEIssuerData identifies the ACME resources used to issue a certificate.
|
||||
type ACMEIssuerData struct {
|
||||
// URL is the certificate resource URL as provisioned by the ACME server.
|
||||
URL string
|
||||
// CA is the directory URL of the ACME CA that issued this certificate.
|
||||
CA string
|
||||
// Account is the URL of the account that obtained the certificate.
|
||||
Account string
|
||||
// RenewalInfo is the stored renewal guidance, not a guarantee of when renewal will run.
|
||||
RenewalInfo *acme.RenewalInfo
|
||||
}
|
||||
|
||||
// IssuedCertificateFromProto parses a stored certificate chain without verifying trust or expiry.
|
||||
// Issuer metadata is decoded as ACME on a best-effort basis and is always preserved in its raw form.
|
||||
func IssuedCertificateFromProto(p *pb.IssuedCertificate) (IssuedCertificate, error) {
|
||||
if p == nil || strings.TrimSpace(p.San) == "" {
|
||||
return IssuedCertificate{}, fmt.Errorf("invalid certificate: missing SAN")
|
||||
}
|
||||
chain, err := parseCertificateChain(p.Chain)
|
||||
if err != nil {
|
||||
return IssuedCertificate{}, fmt.Errorf("parse certificate '%s': %w", p.San, err)
|
||||
}
|
||||
return IssuedCertificate{
|
||||
SAN: p.San,
|
||||
Chain: chain,
|
||||
IssuerData: parseCertificateIssuerData(p.IssuerData),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseCertificateChain(data []byte) ([]*x509.Certificate, error) {
|
||||
var chain []*x509.Certificate
|
||||
for len(data) > 0 {
|
||||
var block *pem.Block
|
||||
block, data = pem.Decode(data)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return nil, fmt.Errorf("unexpected PEM block type '%s'", block.Type)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse X.509 certificate: %w", err)
|
||||
}
|
||||
chain = append(chain, cert)
|
||||
}
|
||||
|
||||
if len(chain) == 0 {
|
||||
return nil, fmt.Errorf("empty certificate chain")
|
||||
}
|
||||
|
||||
return chain, nil
|
||||
}
|
||||
|
||||
func parseCertificateIssuerData(raw json.RawMessage) CertificateIssuerData {
|
||||
data := CertificateIssuerData{Raw: raw}
|
||||
// Recognize ACME by its resource URLs and decodable metadata.
|
||||
// Unknown formats and malformed records remain raw-only.
|
||||
var cert acme.Certificate
|
||||
if err := json.Unmarshal(raw, &cert); err != nil || cert.CA == "" || cert.URL == "" {
|
||||
return data
|
||||
}
|
||||
data.ACME = &ACMEIssuerData{
|
||||
URL: cert.URL,
|
||||
CA: cert.CA,
|
||||
Account: cert.Account,
|
||||
RenewalInfo: cert.RenewalInfo,
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mholt/acmez/v3/acme"
|
||||
"github.com/psviderski/uncloud/api/pb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssuedCertificateFromProto(t *testing.T) {
|
||||
leaf := testCertificatePEM(t, 1)
|
||||
issuer := testCertificatePEM(t, 2)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
chain []byte
|
||||
serials []int64
|
||||
}{
|
||||
{name: "single certificate", chain: leaf, serials: []int64{1}},
|
||||
{name: "certificate chain", chain: append(append([]byte(nil), leaf...), issuer...), serials: []int64{1, 2}},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cert, err := IssuedCertificateFromProto(&pb.IssuedCertificate{
|
||||
San: "app.example.com", Chain: tt.chain,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "app.example.com", cert.SAN)
|
||||
require.Len(t, cert.Chain, len(tt.serials))
|
||||
|
||||
for i, serial := range tt.serials {
|
||||
assert.Equal(t, big.NewInt(serial), cert.Chain[i].SerialNumber, "chain order must be preserved")
|
||||
}
|
||||
assert.Equal(t, []string{"app.example.com"}, cert.Chain[0].DNSNames)
|
||||
// Expired, self-signed certificates are parsed without verifying trust or validity.
|
||||
assert.Equal(t, time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), cert.Chain[0].NotAfter)
|
||||
assert.Empty(t, cert.IssuerData)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuedCertificateFromProto_InvalidCertificate(t *testing.T) {
|
||||
valid := testCertificatePEM(t, 1)
|
||||
invalidDER := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("invalid")})
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
input *pb.IssuedCertificate
|
||||
wantErr string
|
||||
}{
|
||||
{name: "nil input", 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: "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: "wrong PEM block type", input: &pb.IssuedCertificate{San: "app.example.com",
|
||||
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},
|
||||
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) {
|
||||
cert, err := IssuedCertificateFromProto(tt.input)
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
assert.Empty(t, cert)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuedCertificateFromProto_IssuerData(t *testing.T) {
|
||||
chain := testCertificatePEM(t, 1)
|
||||
retryAfter := time.Date(2029, 12, 1, 0, 0, 0, 0, time.UTC)
|
||||
renewalInfo := &acme.RenewalInfo{
|
||||
ExplanationURL: "https://ca.example/why",
|
||||
UniqueIdentifier: "aki.serial",
|
||||
RetryAfter: &retryAfter,
|
||||
SelectedTime: time.Date(2030, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
renewalInfo.SuggestedWindow.Start = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
renewalInfo.SuggestedWindow.End = time.Date(2030, 1, 3, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
raw string
|
||||
want *ACMEIssuerData
|
||||
}{
|
||||
{
|
||||
name: "ACME",
|
||||
raw: `{"url":"https://ca.example/cert/1","ca":"https://ca.example/directory","account":"https://ca.example/acct/1","future_field":true}`,
|
||||
want: &ACMEIssuerData{
|
||||
URL: "https://ca.example/cert/1", CA: "https://ca.example/directory", Account: "https://ca.example/acct/1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ACME with renewal information",
|
||||
raw: `{
|
||||
"url": "https://ca.example/cert/1",
|
||||
"ca": "https://ca.example/directory",
|
||||
"account": "https://ca.example/acct/1",
|
||||
"renewal_info": {
|
||||
"suggestedWindow": {"start": "2030-01-01T00:00:00Z", "end": "2030-01-03T00:00:00Z"},
|
||||
"explanationURL": "https://ca.example/why",
|
||||
"_uniqueIdentifier": "aki.serial",
|
||||
"_retryAfter": "2029-12-01T00:00:00Z",
|
||||
"_selectedTime": "2030-01-02T00:00:00Z"
|
||||
}
|
||||
}`,
|
||||
want: &ACMEIssuerData{
|
||||
URL: "https://ca.example/cert/1", CA: "https://ca.example/directory",
|
||||
Account: "https://ca.example/acct/1", RenewalInfo: renewalInfo,
|
||||
},
|
||||
},
|
||||
{name: "absent"},
|
||||
{name: "null", raw: "null"},
|
||||
{name: "unknown issuer", raw: `{"id":"provider-id","status":"issued"}`},
|
||||
{name: "non-object JSON", raw: `"provider-record"`},
|
||||
{name: "missing CA", raw: `{"url":"https://ca.example/cert/1"}`},
|
||||
{name: "missing certificate URL", raw: `{"ca":"https://ca.example/directory"}`},
|
||||
{name: "invalid JSON", raw: "{"},
|
||||
{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"}}`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cert, err := IssuedCertificateFromProto(&pb.IssuedCertificate{
|
||||
San: "app.example.com", Chain: chain, IssuerData: []byte(tt.raw),
|
||||
})
|
||||
require.NoError(t, err, "issuer metadata must not prevent certificate parsing")
|
||||
require.Len(t, cert.Chain, 1)
|
||||
assert.Equal(t, tt.raw, string(cert.IssuerData.Raw), "preserve original JSON including unknown fields")
|
||||
assert.Equal(t, tt.want, cert.IssuerData.ACME)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testCertificatePEM creates an expired, self-signed certificate for parsing tests.
|
||||
func testCertificatePEM(t *testing.T, serial int64) []byte {
|
||||
t.Helper()
|
||||
public, private, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(serial),
|
||||
DNSNames: []string{"app.example.com"},
|
||||
NotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
NotAfter: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, public, private)
|
||||
require.NoError(t, err)
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
}
|
||||
+84
-21
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user