Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions pkg/api/tests/refapp_kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@ var ResolvedRefappKubernetesServerResources = map[string]api.ResourceDescriptor{
Kubeconfig: "<kube-config>",
},
CaddyConfig: &k8s.CaddyConfig{
Enable: lo.ToPtr(true),
Namespace: lo.ToPtr("caddy"),
Replicas: lo.ToPtr(2),
Enable: lo.ToPtr(true),
Namespace: lo.ToPtr("caddy"),
Replicas: lo.ToPtr(2),
TrustedProxies: []string{}, // placeholder resolution converts nil → empty slice
},
}},
},
Expand Down
13 changes: 12 additions & 1 deletion pkg/clouds/k8s/caddy.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,16 @@ type CaddyResource struct {
}

func CaddyReadConfig(config *api.Config) (api.Config, error) {
return api.ConvertConfig(config, &CaddyResource{})
cfg, err := api.ConvertConfig(config, &CaddyResource{})
if err != nil {
return cfg, err
}
// Normalize empty slices to nil — yaml.Unmarshal into inline pointer
// structs can produce []string{} instead of nil for absent fields.
if res, ok := cfg.Config.(*CaddyResource); ok && res.CaddyConfig != nil {
if len(res.CaddyConfig.TrustedProxies) == 0 {
res.CaddyConfig.TrustedProxies = nil
}
}
return cfg, nil
}
1 change: 1 addition & 0 deletions pkg/clouds/k8s/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type CaddyConfig struct {
Replicas *int `json:"replicas,omitempty" yaml:"replicas,omitempty"`
Resources *Resources `json:"resources,omitempty" yaml:"resources,omitempty"` // CPU and memory limits/requests for Caddy container
VPA *VPAConfig `json:"vpa,omitempty" yaml:"vpa,omitempty"` // Vertical Pod Autoscaler configuration for Caddy
TrustedProxies []string `json:"trustedProxies,omitempty" yaml:"trustedProxies,omitempty"` // CIDR ranges trusted as reverse proxies (preserves X-Forwarded-For from these sources)
UsePrefixes bool `json:"usePrefixes,omitempty" yaml:"usePrefixes,omitempty"` // whether to use prefixes instead of domains (default: false)
ServiceType *string `json:"serviceType,omitempty" yaml:"serviceType,omitempty"` // whether to use custom service type instead of LoadBalancer (default: LoadBalancer)
ProvisionIngress bool `json:"provisionIngress,omitempty" yaml:"provisionIngress,omitempty"` // whether to provision ingress for caddy (default: false)
Expand Down
25 changes: 10 additions & 15 deletions pkg/clouds/pulumi/gcp/gke_autopilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,22 +216,17 @@ func GkeAutopilot(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, pa
return nil, errors.Wrapf(err, "failed to provision ACME storage for Caddy in cluster %q", clusterName)
}

// Build Caddyfile prefix with GCS storage configuration
// Merge with user-provided prefix if it exists
caddyfilePrefix := bucket.Name.ApplyT(func(bucketName string) string {
gcsStorageConfig := fmt.Sprintf(`{
storage gcs {
bucket-name %s
}
}`, bucketName)

// If user provided custom prefix, merge it
if gkeInput.Caddy.CaddyfilePrefix != nil && *gkeInput.Caddy.CaddyfilePrefix != "" {
// User prefix first, then GCS storage config
return fmt.Sprintf("%s\n\n%s", gcsStorageConfig, *gkeInput.Caddy.CaddyfilePrefix)
}
// Validate trusted proxies early (before Pulumi apply)
trustedProxiesBlock, err := pulumiKubernetes.BuildTrustedProxiesBlock(lo.FromPtrOr(gkeInput.Caddy, k8s.CaddyConfig{}))
if err != nil {
return nil, errors.Wrapf(err, "invalid caddy trusted proxies for cluster %q", clusterName)
}

return gcsStorageConfig
// Build Caddyfile prefix with GCS storage configuration and trusted proxies
caddyfilePrefix := bucket.Name.ApplyT(func(bucketName string) string {
storageBlock := fmt.Sprintf(" storage gcs {\n bucket-name %s\n }", bucketName)
userPrefix := lo.FromPtrOr(gkeInput.Caddy.CaddyfilePrefix, "")
return pulumiKubernetes.BuildCaddyfileGlobalOptions(storageBlock, trustedProxiesBlock, userPrefix)
}).(sdk.StringOutput)

// Prepare GCP credentials as a secret volume output (Pulumi output)
Expand Down
18 changes: 11 additions & 7 deletions pkg/clouds/pulumi/kubernetes/caddy.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,17 +156,21 @@ func DeployCaddyService(ctx *sdk.Context, caddy CaddyDeployment, input api.Resou

// Add Caddyfile prefix - prefer dynamic output over static config
if isPulumiOutputSet(caddy.CaddyfilePrefixOut) {
// Use dynamic Caddyfile prefix from cloud provider (e.g., GCS storage config)
// Use dynamic Caddyfile prefix from cloud provider (e.g., GCS storage config with trusted proxies baked in)
envVars = append(envVars, corev1.EnvVarArgs{
Name: sdk.String("CADDYFILE_PREFIX"),
Value: caddy.CaddyfilePrefixOut.ToStringOutput(),
})
} else if caddy.CaddyfilePrefix != nil {
// Use static Caddyfile prefix from config
envVars = append(envVars, corev1.EnvVarArgs{
Name: sdk.String("CADDYFILE_PREFIX"),
Value: sdk.String(lo.FromPtr(caddy.CaddyfilePrefix)),
})
} else {
// Build static Caddyfile prefix from config (non-GKE path)
trustedBlock, _ := BuildTrustedProxiesBlock(lo.FromPtrOr(caddy.CaddyConfig, k8s.CaddyConfig{}))
userPrefix := lo.FromPtrOr(caddy.CaddyfilePrefix, "")
if prefix := BuildCaddyfileGlobalOptions("", trustedBlock, userPrefix); prefix != "" {
envVars = append(envVars, corev1.EnvVarArgs{
Name: sdk.String("CADDYFILE_PREFIX"),
Value: sdk.String(prefix),
})
}
}

return envVars
Expand Down
58 changes: 58 additions & 0 deletions pkg/clouds/pulumi/kubernetes/caddy_global_opts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package kubernetes

import (
"fmt"
"net"
"strings"

"github.com/pkg/errors"

"github.com/simple-container-com/api/pkg/clouds/k8s"
)

// BuildTrustedProxiesBlock builds the Caddy servers { trusted_proxies ... } block
// from the CaddyConfig. Returns empty string if no trusted proxies are configured.
func BuildTrustedProxiesBlock(cfg k8s.CaddyConfig) (string, error) {
if len(cfg.TrustedProxies) == 0 {
return "", nil
}

for _, cidr := range cfg.TrustedProxies {
if _, _, err := net.ParseCIDR(cidr); err != nil {
if net.ParseIP(cidr) == nil {
return "", errors.Errorf("invalid trusted proxy entry %q: must be a valid CIDR or IP address", cidr)
}
}
}

cidrs := strings.Join(cfg.TrustedProxies, " ")
return fmt.Sprintf(" servers {\n trusted_proxies static %s\n }", cidrs), nil
}

// BuildCaddyfileGlobalOptions builds the Caddyfile global options block.
// storageBlock is optional (empty string if not needed, e.g., non-GKE deployments).
// userPrefix is appended after the global block if provided.
func BuildCaddyfileGlobalOptions(storageBlock string, trustedProxiesBlock string, userPrefix string) string {
var globalOpts []string

if storageBlock != "" {
globalOpts = append(globalOpts, storageBlock)
}
if trustedProxiesBlock != "" {
globalOpts = append(globalOpts, trustedProxiesBlock)
}

var result string
if len(globalOpts) > 0 {
result = fmt.Sprintf("{\n%s\n}", strings.Join(globalOpts, "\n"))
}

if userPrefix != "" {
if result != "" {
return fmt.Sprintf("%s\n\n%s", result, userPrefix)
}
return userPrefix
}

return result
}
81 changes: 81 additions & 0 deletions pkg/clouds/pulumi/kubernetes/caddy_global_opts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package kubernetes

import (
"testing"

. "github.com/onsi/gomega"

"github.com/simple-container-com/api/pkg/clouds/k8s"
)

func TestBuildTrustedProxiesBlock(t *testing.T) {
g := NewGomegaWithT(t)

t.Run("empty proxies returns empty string", func(t *testing.T) {
block, err := BuildTrustedProxiesBlock(k8s.CaddyConfig{})
g.Expect(err).ToNot(HaveOccurred())
g.Expect(block).To(BeEmpty())
})

t.Run("valid CIDRs produce servers block", func(t *testing.T) {
block, err := BuildTrustedProxiesBlock(k8s.CaddyConfig{
TrustedProxies: []string{"10.0.0.0/8", "172.16.0.0/12"},
})
g.Expect(err).ToNot(HaveOccurred())
g.Expect(block).To(ContainSubstring("trusted_proxies static 10.0.0.0/8 172.16.0.0/12"))
g.Expect(block).To(ContainSubstring("servers {"))
})

t.Run("single IP is valid", func(t *testing.T) {
block, err := BuildTrustedProxiesBlock(k8s.CaddyConfig{
TrustedProxies: []string{"10.0.0.1"},
})
g.Expect(err).ToNot(HaveOccurred())
g.Expect(block).To(ContainSubstring("10.0.0.1"))
})

t.Run("invalid CIDR returns error", func(t *testing.T) {
_, err := BuildTrustedProxiesBlock(k8s.CaddyConfig{
TrustedProxies: []string{"not-a-cidr"},
})
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("invalid trusted proxy entry"))
})
}

func TestBuildCaddyfileGlobalOptions(t *testing.T) {
g := NewGomegaWithT(t)

storageBlock := " storage gcs {\n bucket-name test-bucket\n }"
trustedBlock := " servers {\n trusted_proxies static 10.0.0.0/8\n }"

t.Run("storage only matches previous format", func(t *testing.T) {
result := BuildCaddyfileGlobalOptions(storageBlock, "", "")
g.Expect(result).To(Equal("{\n storage gcs {\n bucket-name test-bucket\n }\n}"))
})

t.Run("storage plus trusted proxies", func(t *testing.T) {
result := BuildCaddyfileGlobalOptions(storageBlock, trustedBlock, "")
g.Expect(result).To(ContainSubstring("storage gcs"))
g.Expect(result).To(ContainSubstring("trusted_proxies static"))
g.Expect(result).To(HavePrefix("{"))
g.Expect(result).To(HaveSuffix("}"))
})

t.Run("with user prefix appended after", func(t *testing.T) {
result := BuildCaddyfileGlobalOptions(storageBlock, "", "import custom")
g.Expect(result).To(ContainSubstring("storage gcs"))
g.Expect(result).To(ContainSubstring("import custom"))
})

t.Run("empty everything returns empty", func(t *testing.T) {
result := BuildCaddyfileGlobalOptions("", "", "")
g.Expect(result).To(BeEmpty())
})

t.Run("only trusted proxies no storage", func(t *testing.T) {
result := BuildCaddyfileGlobalOptions("", trustedBlock, "")
g.Expect(result).To(ContainSubstring("trusted_proxies"))
g.Expect(result).To(HavePrefix("{"))
})
}
Loading