Files
cozystack/internal/lineagecontrollerwebhook/config_test.go
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

182 lines
5.6 KiB
Go

package lineagecontrollerwebhook
import (
"testing"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// TestGetApplicationLabel_NilLabelsErrors pins the explicit nil-map
// guard so a HelmRelease with no labels at all does not panic.
func TestGetApplicationLabel_NilLabelsErrors(t *testing.T) {
hr := &helmv2.HelmRelease{
ObjectMeta: metav1.ObjectMeta{Name: "x", Namespace: "y"},
}
if _, err := getApplicationLabel(hr, "any"); err == nil {
t.Errorf("expected error on nil labels, got nil")
}
}
// TestGetApplicationLabel_MissingKey pins the descriptive error for
// missing labels — the message should name the key that was looked up.
func TestGetApplicationLabel_MissingKey(t *testing.T) {
hr := &helmv2.HelmRelease{
ObjectMeta: metav1.ObjectMeta{
Name: "rel", Namespace: "tenant-foo",
Labels: map[string]string{"unrelated": "x"},
},
}
_, err := getApplicationLabel(hr, "apps.cozystack.io/application.kind")
if err == nil {
t.Fatalf("expected error, got nil")
}
if got := err.Error(); got == "" {
t.Errorf("expected non-empty error message")
}
}
// TestGetApplicationLabel_Present pins the happy path.
func TestGetApplicationLabel_Present(t *testing.T) {
hr := &helmv2.HelmRelease{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"apps.cozystack.io/application.kind": "Harbor",
},
},
}
got, err := getApplicationLabel(hr, "apps.cozystack.io/application.kind")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "Harbor" {
t.Errorf("expected Harbor, got %q", got)
}
}
// TestMap_HappyPath pins the four-tuple HelmRelease → (apiVersion,
// kind, prefix, error). prefix is derived by stripping the
// application.name suffix from the HelmRelease name.
func TestMap_HappyPath(t *testing.T) {
hr := &helmv2.HelmRelease{
ObjectMeta: metav1.ObjectMeta{
Name: "myinstance-harbor",
Labels: map[string]string{
"apps.cozystack.io/application.kind": "Harbor",
"apps.cozystack.io/application.group": "apps.cozystack.io",
"apps.cozystack.io/application.name": "harbor",
},
},
}
l := &LineageControllerWebhook{}
apiVersion, kind, prefix, err := l.Map(hr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if apiVersion != "apps.cozystack.io/v1alpha1" {
t.Errorf("apiVersion=%q, want apps.cozystack.io/v1alpha1", apiVersion)
}
if kind != "Harbor" {
t.Errorf("kind=%q, want Harbor", kind)
}
if prefix != "myinstance-" {
t.Errorf("prefix=%q, want myinstance-", prefix)
}
}
// TestMap_AppNameAppearsTwiceInHRName pins the prefix-derivation guard:
// stripping the suffix once must yield "<prefix><appName>" == hr.Name.
// If the name has the appName embedded but not as suffix, return error
// rather than producing a wrong prefix.
func TestMap_AppNameAppearsTwiceInHRName(t *testing.T) {
hr := &helmv2.HelmRelease{
// name="harbor-staging" with appName="harbor": strip "harbor"
// from the end yields "harbor-stagi", which when combined with
// "harbor" doesn't reconstruct "harbor-staging" → error.
ObjectMeta: metav1.ObjectMeta{
Name: "harbor-staging",
Labels: map[string]string{
"apps.cozystack.io/application.kind": "Harbor",
"apps.cozystack.io/application.group": "apps.cozystack.io",
"apps.cozystack.io/application.name": "harbor",
},
},
}
l := &LineageControllerWebhook{}
_, _, _, err := l.Map(hr)
if err == nil {
t.Errorf("expected derivation error, got nil")
}
}
// TestMap_MissingLabel pins the failure mode when any of the three
// required application.* labels is absent.
func TestMap_MissingLabel(t *testing.T) {
cases := []struct {
name string
labels map[string]string
}{
{"missing kind", map[string]string{
"apps.cozystack.io/application.group": "apps.cozystack.io",
"apps.cozystack.io/application.name": "harbor",
}},
{"missing group", map[string]string{
"apps.cozystack.io/application.kind": "Harbor",
"apps.cozystack.io/application.name": "harbor",
}},
{"missing name", map[string]string{
"apps.cozystack.io/application.kind": "Harbor",
"apps.cozystack.io/application.group": "apps.cozystack.io",
}},
{"nil labels", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
hr := &helmv2.HelmRelease{
ObjectMeta: metav1.ObjectMeta{
Name: "x", Namespace: "y",
Labels: tc.labels,
},
}
l := &LineageControllerWebhook{}
_, _, _, err := l.Map(hr)
if err == nil {
t.Errorf("expected error, got nil")
}
})
}
}
// TestInitConfig_StoresEmptyMap pins the lazy init contract: after
// initConfig the runtimeConfig must exist with an empty appCRDMap, so
// downstream lookups don't NPE on a fresh webhook instance.
func TestInitConfig_StoresEmptyMap(t *testing.T) {
l := &LineageControllerWebhook{}
l.initConfig()
cfg, ok := l.config.Load().(*runtimeConfig)
if !ok || cfg == nil {
t.Fatalf("expected *runtimeConfig stored, got %T", l.config.Load())
}
if cfg.appCRDMap == nil {
t.Errorf("expected empty appCRDMap, got nil")
}
if len(cfg.appCRDMap) != 0 {
t.Errorf("expected empty appCRDMap, got %d entries", len(cfg.appCRDMap))
}
}
// TestInitConfig_IdempotentDoesNotOverwrite pins that calling
// initConfig twice does not replace the stored runtimeConfig pointer
// — sync.Once guarantees the body runs only once, so the second call
// is observably a no-op.
func TestInitConfig_IdempotentDoesNotOverwrite(t *testing.T) {
l := &LineageControllerWebhook{}
l.initConfig() // first call — stores
first := l.config.Load()
l.initConfig() // second call — sync.Once short-circuits
second := l.config.Load()
if first != second {
t.Errorf("initConfig overwrote existing config: %p → %p", first, second)
}
}