Files
Aleksei Sviridkin d2e851f63b feat: Gateway API support via Cilium
Replace ingress-nginx as the default ingress layer for cozystack-native
services with Cilium Gateway API, materialised per-tenant via a new
TenantGateway CRD reconciled by cozystack-controller.

The extra/gateway chart renders one gateway.cozystack.io/v1alpha1
TenantGateway per tenant; the controller owns the resulting Gateway,
per-tenant Issuer, Certificate(s), and the http->https redirect
HTTPRoute. This avoids the helm-vs-controller race on
Gateway.spec.listeners when route-driven listeners are added.

publishing.certificates.solver selects HTTP-01 (default; per-app HTTPS
listener + per-app Certificate) or DNS-01 (opt-in; single wildcard
Certificate per apex; providers: cloudflare, route53, digitalocean,
rfc2136). HTTP-01 keeps zero-config per-app onboarding; DNS-01 saves
Let's Encrypt budget on apex-shared clusters.

Security model -- seven independent layers, all admission-time gates
fail-closed:

  1. Listener allowedRoutes namespace whitelist via the
     kube-apiserver-written kubernetes.io/metadata.name label;
     plain-HTTP listener is strictly narrower than HTTPS, and HTTPS /
     passthrough listeners restrict allowedRoutes.kinds to
     HTTPRoute / TLSRoute respectively.
  2. cozystack-gateway-hostname-policy VAP on Gateway.
  3. cozystack-gateway-attached-namespaces-policy VAP on Package --
     blocks tenant-* in gateway.attachedNamespaces.
  4. cozystack-tenant-host-policy VAP on Tenant -- blocks spec.host
     writes from non-trusted callers.
  5. cozystack-namespace-host-label-policy VAP on Namespace -- blocks
     writes to namespace.cozystack.io/host from non-trusted callers.
  6. Render-time fail in cozystack-basics for tenant-* in
     gateway.attachedNamespaces.
  7. cozystack-route-hostname-policy VAP on HTTPRoute / TLSRoute,
     scoped to tenant-* namespaces.

Within-apex cross-namespace hostname conflict is resolved at reconcile
time by a HostnameConflict condition (cozy-* namespace wins).

Five reconcile paths refuse to rewrite pre-existing objects with the
controller-derived name but no OwnerReference back to the
TenantGateway: Gateway, redirect HTTPRoute, per-tenant Issuer, wildcard
Certificate, per-listener Certificate.

Derived-apex tenants (tenants whose host is a subdomain of a parent
tenant's) auto-enable Gateway when the platform-level gateway.enabled
is on.

Bonus fix: pkg/registry/apps/application/rest.go was ignoring the
createValidation and deleteValidation callbacks that genericapiserver
hands to every storage method, silently bypassing admission for Create
and Delete on every apps.cozystack.io resource (Tenant, MariaDB,
Postgres, Kubernetes, ...). Aligned with the existing updateValidation
pattern.

Always-on changes: cilium.envoy.enabled + cilium.gatewayAPI.enabled
flip to true (extra cilium-envoy DaemonSet, ~100 MB RAM/node idle),
and the cozystack-api admission-chain fix. Everything else is gated
behind gateway.enabled=false and tenant.spec.gateway=false; existing
clusters see no behavioural change until an operator opts in.

Tests: 60+ Go cases in internal/controller/tenantgateway/, admission-
chain regression tests in pkg/registry/apps/application/, helm-unittest
across 7 charts (extra/gateway, core/platform, apps/tenant,
system/cozystack-basics, system/cert-manager-issuers,
system/cozystack-api, system/dashboard), and 14 e2e bats scenarios in
hack/e2e-apps/gateway.bats. New make test-controllers target wired
into pull-requests.yaml.

Deps: sigs.k8s.io/gateway-api v1.4.1, cert-manager v1.17.4.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-05-25 17:07:02 +03:00

210 lines
7.8 KiB
Go

package lineagecontrollerwebhook
import (
"context"
"testing"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// TestMatchName_NilMatchesEverything pins the documented behaviour:
// resourceNames=nil means "match any name", so we don't accidentally
// regress to require an explicit list.
func TestMatchName_NilMatchesEverything(t *testing.T) {
if !matchName(context.Background(), "anything", nil, nil) {
t.Errorf("matchName(name=anything, resourceNames=nil) = false, want true")
}
}
// TestMatchName_EmptySliceMatchesNothing pins the edge between "no
// names" (block all) and "no list" (allow all). Empty slice is "no
// names allowed", which evaluates to false on every input.
func TestMatchName_EmptySliceMatchesNothing(t *testing.T) {
if matchName(context.Background(), "anything", nil, []string{}) {
t.Errorf("matchName(name=anything, resourceNames=[]) = true, want false")
}
}
// TestMatchName_ExactLiteral pins literal matching with no template
// variables.
func TestMatchName_ExactLiteral(t *testing.T) {
cases := []struct {
name string
input string
templates []string
want bool
}{
{"matches one of", "harbor", []string{"foo", "harbor", "bucket"}, true},
{"no match", "registry", []string{"foo", "harbor"}, false},
{"single match", "only", []string{"only"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := matchName(context.Background(), tc.input, nil, tc.templates)
if got != tc.want {
t.Errorf("matchName(%q, %v) = %v, want %v", tc.input, tc.templates, got, tc.want)
}
})
}
}
// TestMatchName_TemplateVariables pins go-template substitution from
// the templateContext map. This is the documented mechanism that
// resourceNames entries like "{{ .name }}-secret" rely on.
func TestMatchName_TemplateVariables(t *testing.T) {
ctx := map[string]string{
"name": "harbor",
"kind": "harbor",
"namespace": "tenant-foo",
}
templates := []string{
"{{ .name }}-secret",
"{{ .kind }}-{{ .name }}-tls",
"specificname",
}
cases := []struct {
name string
input string
want bool
}{
{"first template", "harbor-secret", true},
{"second template", "harbor-harbor-tls", true},
{"literal", "specificname", true},
{"none", "registry-secret", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := matchName(context.Background(), tc.input, ctx, templates)
if got != tc.want {
t.Errorf("matchName(%q) = %v, want %v", tc.input, got, tc.want)
}
})
}
}
// TestMatchName_BadTemplateContinues pins error tolerance: a malformed
// go-template entry must be logged and skipped, not break the loop.
// Subsequent valid entries should still match.
func TestMatchName_BadTemplateContinues(t *testing.T) {
templates := []string{
"{{ .unclosed", // bad — parse error
"{{ .missingfield }}", // bad — execute error (no such field)
"valid-target", // good
}
got := matchName(context.Background(), "valid-target", map[string]string{"name": "x"}, templates)
if !got {
t.Errorf("expected fallthrough to literal entry to match, got false")
}
}
// TestMatchResourceToSelector_LabelsAndName pins both halves of the
// selector match: labels must satisfy the LabelSelector AND name must
// satisfy resourceNames (when set).
func TestMatchResourceToSelector_LabelsAndName(t *testing.T) {
sel := &cozyv1alpha1.ApplicationDefinitionResourceSelector{
LabelSelector: metav1.LabelSelector{
MatchLabels: map[string]string{"app": "harbor"},
},
ResourceNames: []string{"harbor-secret"},
}
cases := []struct {
name string
objName string
objLabels map[string]string
want bool
}{
{"both match", "harbor-secret", map[string]string{"app": "harbor"}, true},
{"name match, labels mismatch", "harbor-secret", map[string]string{"app": "registry"}, false},
{"labels match, name mismatch", "registry-secret", map[string]string{"app": "harbor"}, false},
{"neither", "registry-secret", map[string]string{"app": "registry"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := matchResourceToSelector(context.Background(), tc.objName, nil, tc.objLabels, sel)
if got != tc.want {
t.Errorf("matchResourceToSelector = %v, want %v", got, tc.want)
}
})
}
}
// TestMatchResourceToSelector_NilResourceNamesAllowsAnyName pins that
// when ResourceNames is nil the selector reduces to label-only
// matching.
func TestMatchResourceToSelector_NilResourceNamesAllowsAnyName(t *testing.T) {
sel := &cozyv1alpha1.ApplicationDefinitionResourceSelector{
LabelSelector: metav1.LabelSelector{
MatchLabels: map[string]string{"app": "harbor"},
},
}
if !matchResourceToSelector(context.Background(), "any-name", nil, map[string]string{"app": "harbor"}, sel) {
t.Errorf("expected label-only match, got false")
}
if matchResourceToSelector(context.Background(), "any-name", nil, map[string]string{"app": "registry"}, sel) {
t.Errorf("expected mismatch on labels, got true")
}
}
// TestMatchResourceToSelectorArray_AnyMatch pins OR semantics across
// the selector array — at least one selector must match.
func TestMatchResourceToSelectorArray_AnyMatch(t *testing.T) {
selectors := []*cozyv1alpha1.ApplicationDefinitionResourceSelector{
{LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"role": "primary"}}},
{LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"role": "replica"}}},
}
if !matchResourceToSelectorArray(context.Background(), "x", nil, map[string]string{"role": "replica"}, selectors) {
t.Errorf("expected match on second selector, got false")
}
if matchResourceToSelectorArray(context.Background(), "x", nil, map[string]string{"role": "other"}, selectors) {
t.Errorf("expected no match, got true")
}
}
// TestMatchResourceToSelectorArray_EmptyArrayIsFalse pins the contract:
// no selectors means no match (deliberately not "match anything").
func TestMatchResourceToSelectorArray_EmptyArrayIsFalse(t *testing.T) {
if matchResourceToSelectorArray(context.Background(), "x", nil, nil, nil) {
t.Errorf("expected empty selector array to never match, got true")
}
}
// TestMatchResourceToExcludeInclude_NilResourcesIsFalse pins the
// guard: nil resources block always evaluates false.
func TestMatchResourceToExcludeInclude_NilResourcesIsFalse(t *testing.T) {
if matchResourceToExcludeInclude(context.Background(), "x", nil, nil, nil) {
t.Errorf("expected nil resources to never match, got true")
}
}
// TestMatchResourceToExcludeInclude_ExcludeWins pins precedence: an
// exclude match short-circuits to false even if include also matches.
func TestMatchResourceToExcludeInclude_ExcludeWins(t *testing.T) {
resources := &cozyv1alpha1.ApplicationDefinitionResources{
Exclude: []*cozyv1alpha1.ApplicationDefinitionResourceSelector{
{LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"sensitive": "true"}}},
},
Include: []*cozyv1alpha1.ApplicationDefinitionResourceSelector{
{LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "harbor"}}},
},
}
got := matchResourceToExcludeInclude(context.Background(), "x", nil,
map[string]string{"app": "harbor", "sensitive": "true"}, resources)
if got {
t.Errorf("expected exclude to win over include, got true")
}
}
// TestMatchResourceToExcludeInclude_OnlyInclude pins the simple case:
// no excludes, include matches → true.
func TestMatchResourceToExcludeInclude_OnlyInclude(t *testing.T) {
resources := &cozyv1alpha1.ApplicationDefinitionResources{
Include: []*cozyv1alpha1.ApplicationDefinitionResourceSelector{
{LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "harbor"}}},
},
}
if !matchResourceToExcludeInclude(context.Background(), "x", nil, map[string]string{"app": "harbor"}, resources) {
t.Errorf("expected include match, got false")
}
}