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
54 changes: 32 additions & 22 deletions e2e/envoygateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@ func TestEnvoyGateway_InstallAgainstKwok(t *testing.T) {
setupCluster(t)

if err := envoygateway.Install(context.Background(), envoygateway.Options{
ContextName: contextName,
CacheOverride: sharedEnvoyGatewayCache(t),
Logger: logger(t),
ReadyTimeout: -1, // skip wait: kwok doesn't run the real controller
ContextName: contextName,
CacheOverride: sharedEnvoyGatewayCache(t),
Logger: logger(t),
ReadyTimeout: -1, // skip wait: kwok doesn't run the real controller
GatewayClassName: "y-cluster", // matches the production default
DNSHintIP: "127.0.0.1", // simulates qemu/docker host-loopback case
}); err != nil {
t.Fatalf("Install: %v", err)
}
Expand Down Expand Up @@ -95,41 +97,49 @@ func TestEnvoyGateway_InstallAgainstKwok(t *testing.T) {
}

// Default GatewayClass landed and points at EG's controller.
gcOut := kubectl(t, "get", "gatewayclass", "eg",
gcOut := kubectl(t, "get", "gatewayclass", "y-cluster",
"-o", "jsonpath={.spec.controllerName}")
want := "gateway.envoyproxy.io/gatewayclass-controller"
if gcOut != want {
t.Errorf("GatewayClass eg.spec.controllerName = %q, want %q", gcOut, want)
t.Errorf("GatewayClass y-cluster.spec.controllerName = %q, want %q", gcOut, want)
}

// dns-hint-ip annotation landed: this is the contract ystack's
// y-k8s-ingress-hosts (and any future host-side resolver tool)
// reads to find the host-routable address without user-side
// config. Pinned because consumers cite the exact annotation key.
hintOut := kubectl(t, "get", "gatewayclass", "y-cluster",
"-o", "jsonpath={.metadata.annotations."+strings.ReplaceAll(envoygateway.DNSHintIPAnnotation, ".", "\\.")+"}")
if hintOut != "127.0.0.1" {
t.Errorf("GatewayClass y-cluster annotation %s = %q, want 127.0.0.1",
envoygateway.DNSHintIPAnnotation, hintOut)
}
}

// TestEnvoyGateway_InstallSkipGatewayClass verifies the opt-out
// for consumers that bring their own GatewayClass.
func TestEnvoyGateway_InstallSkipGatewayClass(t *testing.T) {
// TestEnvoyGateway_InstallEmptyClassNameSkipsApply verifies that
// passing GatewayClassName="" makes Install skip the GatewayClass
// apply (controller still installs). This is the test-only path
// for "controller without a default GatewayClass"; the production
// CommonConfig.GatewayConfig is all-or-nothing per cluster
// config, but the underlying Options field stays expressive.
func TestEnvoyGateway_InstallEmptyClassNameSkipsApply(t *testing.T) {
setupCluster(t)

// Apply the bundle without the default GatewayClass; if a
// previous run created one, remove it first so the assertion
// below isn't a stale-state false negative.
_ = exec.Command("kubectl", "--context="+contextName,
"delete", "gatewayclass", "eg-skip-test", "--ignore-not-found").Run()

if err := envoygateway.Install(context.Background(), envoygateway.Options{
ContextName: contextName,
CacheOverride: sharedEnvoyGatewayCache(t),
Logger: logger(t),
ReadyTimeout: -1,
SkipGatewayClass: true,
GatewayClassName: "", // explicit: do not apply a GatewayClass
}); err != nil {
t.Fatalf("Install: %v", err)
}

// The default `eg` GatewayClass may exist from a prior test;
// what we want to prove is that SkipGatewayClass doesn't
// create a NEW one. The TestEnvoyGateway_InstallAgainstKwok
// covers the create path; here we just check the option
// was wired (no panic, Install returned nil) -- the negative
// behaviour is hard to assert when tests share a cluster.
// Tests share the kwok cluster, so a previously-created
// GatewayClass may still be present from another test; we
// can't assert "no GatewayClass exists". What we can assert
// is that Install did not error -- proving the empty-name
// path is wired and doesn't crash on the missing resource.
}

// kubectl runs `kubectl --context=<setup> args...` and returns
Expand Down
90 changes: 88 additions & 2 deletions pkg/provision/config/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,68 @@ type CommonConfig struct {
K3s K3sConfig `yaml:"k3s,omitempty" json:"k3s,omitempty" jsonschema:"description=k3s install settings. Defaults track pkg/provision/config/k3s.yaml."`
PortForwards []PortForward `yaml:"portForwards,omitempty" json:"portForwards,omitempty" jsonschema:"description=Host->guest TCP port forwards. Defaults to 6443/80/443 when omitted. Must include a guest:6443 entry so the host's kubectl can reach the API server."`
Registries Registries `yaml:"registries,omitempty" json:"registries,omitempty" jsonschema:"description=k3s registries.yaml content. Written to /etc/rancher/k3s/registries.yaml on the node before k3s starts. ${VAR} substitution is supported on credential and endpoint fields."`
Gateway GatewayConfig `yaml:"gateway,omitempty" json:"gateway,omitempty" jsonschema:"description=Bundled Envoy Gateway install. Skip the install entirely (no CRDs, controller, or GatewayClass) by setting skip:true; rename the default GatewayClass via name."`
}

// GatewayConfig controls the bundled Envoy Gateway install
// (pkg/provision/envoygateway). Two knobs:
//
// - skip: false (default) install CRDs, controller, default GatewayClass
// - skip: true no CRDs, controller, or GatewayClass
// - className: <string> (default "y-cluster") rename the default GatewayClass
//
// All-or-nothing: there is no "install controller without a default
// GatewayClass" option. A consumer that wants to ship their own
// GatewayClass should also ship their own controller install.
//
// The host-side dial address (where /etc/hosts on the developer
// machine should resolve gateway hostnames to) is intentionally NOT
// a field here. It's derived from PortForwards via HostRoutableIP
// and exposed to consumers as the yolean.se/dns-hint-ip annotation
// on the GatewayClass. No user-facing knob -- the value is a
// physical fact about the host/guest port-forward layer, not a
// preference.
type GatewayConfig struct {
// Skip omits the entire Envoy Gateway install (CRDs, controller,
// GatewayClass). Useful for test clusters that don't need HTTP
// ingress -- saves the ~50 MB image pull and a few seconds of
// rollout. k3s --disable=traefik is still passed; if you want a
// different ingress, install it yourself.
Skip bool `yaml:"skip,omitempty" json:"skip,omitempty" jsonschema:"description=If true, do not install Envoy Gateway. k3s still runs with --disable=traefik."`

// ClassName names the default GatewayClass y-cluster applies
// after the EG controller is up. Consumer Gateway resources
// reference this via gatewayClassName.
//
// Default: y-cluster. Set to "eg" to keep compatibility with
// consumers that hardcoded that name in pre-v0.4 cluster
// configs (the ystack gateway-v4 surface, for one).
//
// Ignored when Skip is true.
ClassName string `yaml:"className,omitempty" json:"className,omitempty" jsonschema:"default=y-cluster,description=GatewayClass name. Consumer Gateway resources reference this via gatewayClassName. Ignored when skip is true."`
}

// applyGatewayDefaults fills ClassName when the install is
// enabled. When Skip is set, ClassName is left as the user
// supplied it so debug logs make the operator's intent obvious.
func (c *CommonConfig) applyGatewayDefaults() {
if c.Gateway.Skip {
return
}
if c.Gateway.ClassName == "" {
c.Gateway.ClassName = "y-cluster"
}
}

// EffectiveGatewayClassName returns the GatewayClass name the
// provisioner should hand to envoygateway.Install. Empty string
// means "do not apply a GatewayClass" (because the whole install
// is skipped).
func (c CommonConfig) EffectiveGatewayClassName() string {
if c.Gateway.Skip {
return ""
}
return c.Gateway.ClassName
}

// PortForward maps a host port to a guest port. Common to all
Expand All @@ -71,6 +133,29 @@ type PortForward struct {
Guest string `yaml:"guest" json:"guest" jsonschema:"description=Guest port to forward to."`
}

// HostRoutableIP returns the IP at which the host reaches the
// cluster's HTTP ingress (Envoy Gateway). Today the only providers
// y-cluster supports (qemu SLIRP, docker port-forwards) bind ingress
// on the host loopback, so the value is "127.0.0.1" whenever guest:80
// is in PortForwards. Empty means "no host-side dial address" --
// either no guest:80 forward, or a future provisioner topology that
// doesn't tunnel through the host (multi-VM bridged, cloud LB).
//
// The provisioner publishes this value to the cluster as the
// yolean.se/dns-hint-ip annotation on the y-cluster GatewayClass,
// so consumer tooling like ystack's y-k8s-ingress-hosts can read it
// without any user-side configuration. The value derives entirely
// from PortForwards -- there is no config field that lets the user
// influence it directly.
func (c CommonConfig) HostRoutableIP() string {
for _, pf := range c.PortForwards {
if pf.Guest == "80" {
return "127.0.0.1"
}
}
return ""
}

// HostAPIPort returns the host-side port mapped to guest 6443.
// Provisioners use this to surface the kubectl-facing endpoint:
// qemu rewrites the extracted kubeconfig server URL, docker does
Expand Down Expand Up @@ -103,8 +188,8 @@ type K3sConfig struct {
}

// applyCommonDefaults fills defaults that the reflective tag-default
// pass can't reach: K3s.Version (data-file driven) and PortForwards
// (slice default).
// pass can't reach: K3s.Version (data-file driven), PortForwards
// (slice default), GatewayConfig.Name (default y-cluster).
func (c *CommonConfig) applyCommonDefaults() {
if c.K3s.Version == "" {
c.K3s.Version = K3sDefaultVersion()
Expand All @@ -120,6 +205,7 @@ func (c *CommonConfig) applyCommonDefaults() {
{Host: "443", Guest: "443"},
}
}
c.applyGatewayDefaults()
}

// validateCommon checks invariants every provider relies on. The
Expand Down
62 changes: 62 additions & 0 deletions pkg/provision/config/gateway_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package config

import "testing"

// TestGateway_DefaultClassName: an empty GatewayConfig defaults
// to the well-known "y-cluster" GatewayClass name. Pinned because
// downstream consumers (ystack) reference this name verbatim.
func TestGateway_DefaultClassName(t *testing.T) {
c := &CommonConfig{}
c.applyCommonDefaults()
if c.Gateway.ClassName != "y-cluster" {
t.Fatalf("ClassName: got %q, want y-cluster", c.Gateway.ClassName)
}
if c.Gateway.Skip {
t.Fatal("Skip should remain false by default")
}
}

// TestGateway_PreservesExplicitClassName: a user pinning a
// non-default class name (e.g. "eg" for compat) survives
// defaulting.
func TestGateway_PreservesExplicitClassName(t *testing.T) {
c := &CommonConfig{Gateway: GatewayConfig{ClassName: "eg"}}
c.applyCommonDefaults()
if c.Gateway.ClassName != "eg" {
t.Fatalf("ClassName: got %q, want eg", c.Gateway.ClassName)
}
}

// TestGateway_SkipLeavesClassNameAlone: when Skip is set, the
// defaulter doesn't fill ClassName -- the rendered config / debug
// logs make the operator's intent (no install at all) obvious.
func TestGateway_SkipLeavesClassNameAlone(t *testing.T) {
c := &CommonConfig{Gateway: GatewayConfig{Skip: true}}
c.applyCommonDefaults()
if c.Gateway.ClassName != "" {
t.Fatalf("Skip:true should leave ClassName empty, got %q", c.Gateway.ClassName)
}
}

// TestEffectiveGatewayClassName covers the helper Provision uses
// to pick what (if anything) to pass to envoygateway.Install:
// empty when skipped, the configured name otherwise.
func TestEffectiveGatewayClassName(t *testing.T) {
cases := []struct {
name string
gw GatewayConfig
want string
}{
{"default", GatewayConfig{ClassName: "y-cluster"}, "y-cluster"},
{"custom name", GatewayConfig{ClassName: "eg"}, "eg"},
{"skipped", GatewayConfig{Skip: true, ClassName: "y-cluster"}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := CommonConfig{Gateway: tc.gw}
if got := c.EffectiveGatewayClassName(); got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
52 changes: 52 additions & 0 deletions pkg/provision/config/host_routable_ip_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package config

import "testing"

// TestHostRoutableIP_NoForwards covers the cloud-shaped
// (no-host-tunneling) topology: empty PortForwards mean there's no
// host-side dial address to advertise, so the helper returns ""
// and the provisioner omits the dns-hint-ip annotation.
func TestHostRoutableIP_NoForwards(t *testing.T) {
c := CommonConfig{}
if got := c.HostRoutableIP(); got != "" {
t.Fatalf("HostRoutableIP with no forwards: %q", got)
}
}

// TestHostRoutableIP_NoIngressForward covers a config that has an
// API forward but no ingress (guest:80) forward. The cluster is
// reachable for kubectl but no host loopback maps to Envoy, so
// there's nothing to hint at.
func TestHostRoutableIP_NoIngressForward(t *testing.T) {
c := CommonConfig{PortForwards: []PortForward{
{Host: "26443", Guest: "6443"},
}}
if got := c.HostRoutableIP(); got != "" {
t.Fatalf("HostRoutableIP without guest:80: %q", got)
}
}

// TestHostRoutableIP_WithIngress covers the qemu/docker default
// shape: guest:80 is bound to the host loopback via PortForwards,
// so the helper returns 127.0.0.1.
func TestHostRoutableIP_WithIngress(t *testing.T) {
c := CommonConfig{PortForwards: []PortForward{
{Host: "26443", Guest: "6443"},
{Host: "80", Guest: "80"},
{Host: "443", Guest: "443"},
}}
if got := c.HostRoutableIP(); got != "127.0.0.1" {
t.Fatalf("HostRoutableIP: %q (want 127.0.0.1)", got)
}
}

// TestHostRoutableIP_DefaultedConfig pins the breaking-change
// contract: a defaulted config (any provider) gets the hint IP for
// free because the default port forwards include guest:80.
func TestHostRoutableIP_DefaultedConfig(t *testing.T) {
c := &DockerConfig{CommonConfig: CommonConfig{Provider: ProviderDocker}}
c.ApplyDefaults()
if got := c.HostRoutableIP(); got != "127.0.0.1" {
t.Fatalf("defaulted DockerConfig HostRoutableIP: %q", got)
}
}
24 changes: 17 additions & 7 deletions pkg/provision/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,24 @@ func Provision(ctx context.Context, cfg config.DockerConfig, logger *zap.Logger)

// Install the bundled Envoy Gateway (CRDs + controller +
// default GatewayClass). Replaces Traefik, which we disabled
// in the k3s server cmd above.
if err := envoygateway.Install(ctx, envoygateway.Options{
ContextName: cfg.Context,
Logger: logger,
}); err != nil {
return nil, fmt.Errorf("install envoy gateway: %w", err)
// in the k3s server cmd above. Skipped wholesale when
// gateway.skip is set in cluster config.
if cfg.Gateway.Skip {
logger.Info("envoy gateway install skipped (gateway.skip)")
} else {
if err := envoygateway.Install(ctx, envoygateway.Options{
ContextName: cfg.Context,
GatewayClassName: cfg.Gateway.ClassName,
DNSHintIP: cfg.HostRoutableIP(),
Logger: logger,
}); err != nil {
return nil, fmt.Errorf("install envoy gateway: %w", err)
}
logger.Info("envoy gateway ready",
zap.String("version", envoygateway.Version),
zap.String("gatewayClass", cfg.Gateway.ClassName),
)
}
logger.Info("envoy gateway ready", zap.String("version", envoygateway.Version))

return c, nil
}
Expand Down
13 changes: 0 additions & 13 deletions pkg/provision/envoygateway/assets/gatewayclass.yaml

This file was deleted.

Loading