diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml new file mode 100644 index 0000000..fc3813c --- /dev/null +++ b/.github/workflows/goreleaser.yml @@ -0,0 +1,40 @@ +--- +name: goreleaser +on: + push: + tags: + - v* +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.22" + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to GHCR + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Run GoReleaser for tag + if: "startsWith(github.ref, 'refs/tags/')" + uses: goreleaser/goreleaser-action@v4 + with: + version: latest + args: release --clean + env: + GORELEASER_CURRENT_TAG: ${{ github.ref_name }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Clear + if: always() + run: rm -f ${HOME}/.docker/config.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d0037ff --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +on: [push, pull_request] +name: Test +jobs: + test: + strategy: + matrix: + go-version: [1.24.x] + runs-on: ubuntu-latest + steps: + - name: Install Go + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + - name: Checkout code + uses: actions/checkout@v2 + - name: Test + run: go test ./... diff --git a/.gitignore b/.gitignore index 3cb7e26..65557c5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .vscode vendor build +api-gateway-controller diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..07dde7b --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,48 @@ +--- +before: + hooks: + - go mod tidy +builds: + - id: api-gateway-controller + dir: ./cmd + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + - arm + binary: api-gateway-controller + ldflags: + - "-s -w -X main.version={{ if not .IsSnapshot }}v{{ end }}{{ .Version }} -X main.gitCommit={{ .ShortCommit }}" + +dockers: + - build_flag_templates: [--platform=linux/amd64] + dockerfile: Dockerfile + goarch: amd64 + image_templates: ["ghcr.io/serverscom/api-gateway-controller:{{ if not .IsSnapshot }}v{{ end }}{{ .Version }}-amd64"] + use: buildx + - build_flag_templates: [--platform=linux/arm64] + dockerfile: Dockerfile + goarch: arm64 + image_templates: ["ghcr.io/serverscom/api-gateway-controller:{{ if not .IsSnapshot }}v{{ end }}{{ .Version }}-arm64v8"] + use: buildx + - build_flag_templates: [--platform=linux/arm/v6] + dockerfile: Dockerfile + goarch: arm + goarm: 6 + image_templates: ["ghcr.io/serverscom/api-gateway-controller:{{ if not .IsSnapshot }}v{{ end }}{{ .Version }}-armv6"] + +docker_manifests: + - name_template: ghcr.io/serverscom/api-gateway-controller:{{ if not .IsSnapshot }}v{{ end }}{{ .Version }} + image_templates: + - ghcr.io/serverscom/api-gateway-controller:{{ if not .IsSnapshot }}v{{ end }}{{ .Version }}-amd64 + - ghcr.io/serverscom/api-gateway-controller:{{ if not .IsSnapshot }}v{{ end }}{{ .Version }}-arm64v8 + - ghcr.io/serverscom/api-gateway-controller:{{ if not .IsSnapshot }}v{{ end }}{{ .Version }}-armv6 + +release: + ids: [""] + draft: true + extra_files: + - glob: "./api-gateway-controller-*.tgz" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..79713e2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM alpine:3.22 + +RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/* +COPY api-gateway-controller /bin/api-gateway-controller + +ENTRYPOINT ["/bin/api-gateway-controller"] diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..a6a5827 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,117 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/serverscom/api-gateway-controller/internal/config" + "github.com/serverscom/api-gateway-controller/internal/flags" + "github.com/serverscom/api-gateway-controller/internal/gateway/controller" + lbsrv "github.com/serverscom/api-gateway-controller/internal/service/lb" + tlssrv "github.com/serverscom/api-gateway-controller/internal/service/tls" + + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/healthz" + ctrlZap "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + "k8s.io/apimachinery/pkg/runtime" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var ( + version string + gitCommit string + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + _ = clientgoscheme.AddToScheme(scheme) + _ = gatewayv1.Install(scheme) +} + +func main() { + var opts ctrlZap.Options + opts.BindFlags(flag.CommandLine) + ctrlConf, err := flags.ParseFlags() + if err != nil { + log.Fatalf("Error parsing flags: %v\n", err) + } + + if ctrlConf.ShowVersion { + fmt.Printf("Version=%v GitCommit=%v\n", version, gitCommit) + os.Exit(0) + } + + ctrl.SetLogger(ctrlZap.New(ctrlZap.UseFlagOptions(&opts))) + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Cache: cache.Options{ + DefaultNamespaces: map[string]cache.Config{ + ctrlConf.Namespace: {}, + }, + }, + Metrics: server.Options{ + BindAddress: ctrlConf.MetricsAddr, + }, + HealthProbeBindAddress: ctrlConf.ProbeAddr, + LeaderElection: ctrlConf.EnableLeaderElection, + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + // setup sc client + scCli, err := config.NewServerscomClient() + if err != nil { + setupLog.Error(err, "unable to create servers.com client") + os.Exit(1) + } + scCli.SetupUserAgent(fmt.Sprintf("%s/%s %s", ctrlConf.ControllerName, version, gitCommit)) + + // setup gw class reconciler + if err = (&controller.GatewayClassReconciler{ + Client: mgr.GetClient(), + ControllerName: ctrlConf.ControllerName, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "GatewayClass") + os.Exit(1) + } + + // setup gw reconciler + if err = (&controller.GatewayReconciler{ + Client: mgr.GetClient(), + Recorder: mgr.GetEventRecorderFor("gateway-controller"), + ControllerName: ctrlConf.ControllerName, + GatewayClassName: ctrlConf.GatewayClassName, + LBMgr: lbsrv.NewManager(scCli), + TLSMgr: tlssrv.NewManager(scCli), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Gateway") + os.Exit(1) + } + + // Health checks + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + setupLog.Info("starting manager", "version", version, "gitCommit", gitCommit) + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } + +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..d7d95d4 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,62 @@ +# Gateway API Controller — Example Installation for local machine + +This directory contains sample manifests and step-by-step instructions for deploying a Gateway API controller on Kubernetes local setup. + +## Prerequisites + +- A running Kubernetes cluster (tested with Docker Desktop) +- [kubectl](https://kubernetes.io/docs/tasks/tools/) +- Your controller image built locally as `gw-controller:local` +The image can be built by running the following command from the root of this repo: +```bash +CGO_ENABLED=0 GOOS=linux go build -o api-gateway-controller cmd/main.go && docker build -t gw-controller:local . +``` + +## Installation Steps + +### 1. Install Gateway API CRDs + +Apply the latest CRDs directly from the official repository: + +```bash +kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.3.0/standard-install.yaml +``` + + +### 2. Create the Namespace + +```bash +kubectl apply -f namespace.yaml +``` + + +### 3. Create the serverscom API Secret + +Update the values to your actual credentials before applying: + +```bash +kubectl apply -f secret.yaml +``` + + +### 4. Create ServiceAccount, RBAC, and Bindings + +```bash +kubectl apply -f rbac.yaml +``` + + +### 5. Deploy the Gateway Controller + +```bash +kubectl apply -f deployment.yaml +``` + +## Files Overview + +- `namespace.yaml` — Namespace for the controller +- `secret.yaml` — API credentials for serverscom (update these for your environment) +- `rbac.yaml` — ServiceAccount and required RBAC permissions +- `deployment.yaml` — Deployment manifest for the controller + +> **Note:** Adjust the image ( image with gw controller should exist ), environment variables, and secrets as needed for your cluster. \ No newline at end of file diff --git a/examples/deployment.yaml b/examples/deployment.yaml new file mode 100644 index 0000000..e66e40c --- /dev/null +++ b/examples/deployment.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gw-controller + namespace: gateway-serverscom +spec: + replicas: 1 + selector: + matchLabels: + app: gw-controller + template: + metadata: + labels: + app: gw-controller + spec: + serviceAccountName: gw-controller + containers: + - name: gw-controller + image: gw-controller:local + imagePullPolicy: IfNotPresent + args: + # - "--zap-log-level=debug" + env: + - name: SC_ACCESS_TOKEN + valueFrom: + secretKeyRef: + name: serverscom + key: access-token + - name: SC_API_URL + valueFrom: + secretKeyRef: + name: serverscom + key: api-url + - name: SC_CLUSTER_ID + valueFrom: + secretKeyRef: + name: serverscom + key: cluster-id + - name: SC_LOCATION_ID + valueFrom: + secretKeyRef: + name: serverscom + key: location-id \ No newline at end of file diff --git a/examples/namespace.yaml b/examples/namespace.yaml new file mode 100644 index 0000000..c4bb1e7 --- /dev/null +++ b/examples/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: gateway-serverscom diff --git a/examples/rbac.yaml b/examples/rbac.yaml new file mode 100644 index 0000000..8d09376 --- /dev/null +++ b/examples/rbac.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: gw-controller + namespace: gateway-serverscom +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: system:controller:gw-controller +rules: +- apiGroups: [""] + resources: ["secrets", "endpoints", "services", "pods", "nodes", "namespaces", "configmaps", "events"] + verbs: ["get", "list", "watch", "update", "create", "patch"] +- apiGroups: ["gateway.networking.k8s.io"] + resources: ["gatewayclasses", "gateways", "httproutes", "grpcroutes", "referencegrants"] + verbs: ["get", "list", "watch", "update", "create", "patch"] +- apiGroups: ["gateway.networking.k8s.io"] + resources: ["gatewayclasses/status", "gateways/status"] + verbs: ["update", "patch"] +- apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "watch", "list", "create", "update"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: system:controller:gw-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:controller:gw-controller +subjects: +- kind: ServiceAccount + name: gw-controller + namespace: gateway-serverscom diff --git a/examples/secret.yaml b/examples/secret.yaml new file mode 100644 index 0000000..fa7261f --- /dev/null +++ b/examples/secret.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Secret +metadata: + name: serverscom + namespace: gateway-serverscom +stringData: + access-token: '12345' + api-url: 'https://api.servers.com/v1' + cluster-id: '123' + location-id: '1' diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a303c6f --- /dev/null +++ b/go.mod @@ -0,0 +1,79 @@ +module github.com/serverscom/api-gateway-controller + +go 1.24.6 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/onsi/gomega v1.38.2 + github.com/serverscom/serverscom-go-client v1.0.22 + github.com/spf13/pflag v1.0.6 + go.uber.org/mock v0.6.0 + k8s.io/api v0.34.0 + k8s.io/apimachinery v0.34.0 + k8s.io/client-go v0.34.0 + sigs.k8s.io/controller-runtime v0.20.4 + sigs.k8s.io/gateway-api v1.3.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-resty/resty/v2 v2.16.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/maxbrunsfeld/counterfeiter/v6 v6.11.3 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/time v0.9.0 // indirect + golang.org/x/tools v0.36.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.7 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.32.3 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) + +tool github.com/maxbrunsfeld/counterfeiter/v6 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..02a70bb --- /dev/null +++ b/go.sum @@ -0,0 +1,209 @@ +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-resty/resty/v2 v2.16.2 h1:CpRqTjIzq/rweXUt9+GxzzQdlkqMdt8Lm/fuK/CAbAg= +github.com/go-resty/resty/v2 v2.16.2/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/maxbrunsfeld/counterfeiter/v6 v6.11.3 h1:Eaq36EIyJNp7b3qDhjV7jmDVq/yPeW2v4pTqzGbOGB4= +github.com/maxbrunsfeld/counterfeiter/v6 v6.11.3/go.mod h1:6KKUoQBZBW6PDXJtNfqeEjPXMj/ITTk+cWK9t9uS5+E= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.25.1 h1:Fwp6crTREKM+oA6Cz4MsO8RhKQzs2/gOIVOUscMAfZY= +github.com/onsi/ginkgo/v2 v2.25.1/go.mod h1:ppTWQ1dh9KM/F1XgpeRqelR+zHVwV81DGRSDnFxK7Sk= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8= +github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM= +github.com/serverscom/serverscom-go-client v1.0.22 h1:XqB31B6VsewBW/RJD0Z/yuyoTm9X4C7M71BtMrcQ2cI= +github.com/serverscom/serverscom-go-client v1.0.22/go.mod h1:/Nf+XygKOxm19Sl2gvMzT55O4X+tWDkj/UM4mjzfKgM= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= +k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= +k8s.io/apiextensions-apiserver v0.32.3 h1:4D8vy+9GWerlErCwVIbcQjsWunF9SUGNu7O7hiQTyPY= +k8s.io/apiextensions-apiserver v0.32.3/go.mod h1:8YwcvVRMVzw0r1Stc7XfGAzB/SIVLunqApySV5V7Dss= +k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= +k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= +k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+n0DGU= +sigs.k8s.io/controller-runtime v0.20.4/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= +sigs.k8s.io/gateway-api v1.3.0 h1:q6okN+/UKDATola4JY7zXzx40WO4VISk7i9DIfOvr9M= +sigs.k8s.io/gateway-api v1.3.0/go.mod h1:d8NV8nJbaRbEKem+5IuxkL8gJGOZ+FJ+NvOIltV8gDk= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a8668bc --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,16 @@ +package config + +import ( + "log" + "os" + + "github.com/joho/godotenv" +) + +func init() { + if _, err := os.Stat(".env"); err == nil { + if err := godotenv.Load(".env"); err != nil { + log.Fatalf("Error loading .env file: %v", err) + } + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..a257446 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,27 @@ +package config + +import ( + "os" + "testing" + + . "github.com/onsi/gomega" +) + +func TestNewServerscomClient(t *testing.T) { + g := NewWithT(t) + os.Setenv("SC_ACCESS_TOKEN", "123") + _, err := NewServerscomClient() + g.Expect(err).To(BeNil()) +} + +func TestFetchEnv(t *testing.T) { + g := NewWithT(t) + os.Setenv("SC_TEST", "123") + + e := FetchEnv("SC_TEST") + g.Expect(e).To(Equal("123")) + + e = FetchEnv("NOT_EXISTS", "default") + g.Expect(e).To(Equal("default")) + +} diff --git a/internal/config/const.go b/internal/config/const.go new file mode 100644 index 0000000..a678b73 --- /dev/null +++ b/internal/config/const.go @@ -0,0 +1,15 @@ +package config + +const ( + GW_DOMAIN = "k8s.srvrscloud.com" + DEFAULT_GATEWAY_CLASS = "" // all + DEFAULT_CONTROLLER_NAME = GW_DOMAIN + "/gateway-controller" + GW_FINALIZER = GW_DOMAIN + "/gateway-cleanup" + GW_LABEL_ID = GW_DOMAIN + "/api-gateway-id" + SECRET_LABEL_ID = GW_DOMAIN + "/api-secret-id" + TLS_EXTERNAL_ID_KEY = "sc-certmgr-cert-id" + + SC_API_URL = "https://api.servers.com/v1" + + LB_ACTIVE_STATUS = "active" +) diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..b2a0fff --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,15 @@ +package config + +import "os" + +// FetchEnv gets var from env or use first default value +func FetchEnv(key string, defaultValue ...string) string { + value, ok := os.LookupEnv(key) + if !ok { + if len(defaultValue) > 0 { + return defaultValue[0] + } + return "" + } + return value +} diff --git a/internal/config/provider.go b/internal/config/provider.go new file mode 100644 index 0000000..376a040 --- /dev/null +++ b/internal/config/provider.go @@ -0,0 +1,20 @@ +package config + +import ( + "fmt" + + serverscom "github.com/serverscom/serverscom-go-client/pkg" +) + +// NewServerscomClient creates a new SC client to interact with SC public api +func NewServerscomClient() (*serverscom.Client, error) { + token := FetchEnv("SC_ACCESS_TOKEN") + apiUrl := FetchEnv("SC_API_URL", SC_API_URL) + if apiUrl == "" { + apiUrl = SC_API_URL + } + if token == "" { + return nil, fmt.Errorf("SC_ACCESS_TOKEN env is empty, can't create SC client") + } + return serverscom.NewClientWithEndpoint(token, apiUrl), nil +} diff --git a/internal/flags/flags.go b/internal/flags/flags.go new file mode 100644 index 0000000..c7b5476 --- /dev/null +++ b/internal/flags/flags.go @@ -0,0 +1,72 @@ +package flags + +import ( + "flag" + "os" + + "github.com/serverscom/api-gateway-controller/internal/config" + + "github.com/spf13/pflag" + v1 "k8s.io/api/core/v1" +) + +type Configuration struct { + ShowVersion bool + + Namespace string + + MetricsAddr string + ProbeAddr string + EnableLeaderElection bool + + GatewayClassName string + ControllerName string + LBLabelSelector string +} + +func ParseFlags() (*Configuration, error) { + var ( + flags = pflag.NewFlagSet("", pflag.ExitOnError) + + showVersion = flags.Bool("version", false, + `Show controller version and exit.`) + + watchNamespace = flags.String("watch-namespace", v1.NamespaceAll, + `Namespace to watch for Services/Endpoints. (Optional)`) + + metricsAddr = flags.String("metrics-bind-address", ":8080", + "The address the metric endpoint binds to.") + probeAddr = flags.String("health-probe-bind-address", ":8081", + "The address the probe endpoint binds to.") + enableLeaderElection = flags.Bool("leader-elect", false, + "Enable leader election for controller manager.") + gatewayClassName = flags.String("gateway-class-name", config.DEFAULT_GATEWAY_CLASS, + `Name of the GatewayClass this controller watches. (Optional, empty = watch all)`) + controllerName = flags.String("controller-name", config.DEFAULT_CONTROLLER_NAME, + `Controller field to match in GatewayClass resources.`) + lbLabelSelector = flags.String("lb-label-selector", config.GW_LABEL_ID, + `Label selector key for Services representing API Gateways.`) + ) + + flags.AddGoFlagSet(flag.CommandLine) + + if err := flags.Parse(os.Args); err != nil { + return nil, err + } + + conf := &Configuration{ + ShowVersion: *showVersion, + + Namespace: *watchNamespace, + + MetricsAddr: *metricsAddr, + ProbeAddr: *probeAddr, + EnableLeaderElection: *enableLeaderElection, + + GatewayClassName: *gatewayClassName, + ControllerName: *controllerName, + LBLabelSelector: *lbLabelSelector, + } + + return conf, nil +} diff --git a/internal/gateway/controller/gateway.go b/internal/gateway/controller/gateway.go new file mode 100644 index 0000000..0af262f --- /dev/null +++ b/internal/gateway/controller/gateway.go @@ -0,0 +1,567 @@ +package controller + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/serverscom/api-gateway-controller/internal/config" + lbsrv "github.com/serverscom/api-gateway-controller/internal/service/lb" + tlssrv "github.com/serverscom/api-gateway-controller/internal/service/tls" + "github.com/serverscom/api-gateway-controller/internal/types" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var ( + IPAddressType = gatewayv1.IPAddressType +) + +// GatewayReconciler reconciles a Gateway object +type GatewayReconciler struct { + client.Client // controller-runtime client + Recorder record.EventRecorder + ControllerName string + GatewayClassName string + + LBMgr lbsrv.LBManagerInterface + TLSMgr tlssrv.TLSManagerInterface +} + +// SetupWithManager sets up controller with Manager +func (r *GatewayReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For( + &gatewayv1.Gateway{}, + builder.WithPredicates(r.managedPredicate()), + ). + Watches( + &gatewayv1.HTTPRoute{}, + handler.EnqueueRequestsFromMapFunc(r.findGatewaysForHTTPRoute), + ). + Watches( + &corev1.Service{}, + handler.EnqueueRequestsFromMapFunc(r.findGatewaysForService), + ). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(r.findGatewaysForSecret), + ). + WithOptions(controller.Options{ + MaxConcurrentReconciles: 1, + }). + WithEventFilter(predicate.GenerationChangedPredicate{}). + Complete(r) +} + +// Reconcile syncs Gateway state with external resources. +// It manages finalizers, TLS, load balancer, and status updates. +func (r *GatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var gw gatewayv1.Gateway + if err := r.Get(ctx, req.NamespacedName, &gw); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // cleanup if gw was deleted + if !gw.DeletionTimestamp.IsZero() && controllerutil.ContainsFinalizer(&gw, config.GW_FINALIZER) { + if err := r.cleanup(ctx, &gw, config.GW_FINALIZER); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + managed, err := r.isManagedGateway(ctx, &gw) + if err != nil { + return ctrl.Result{}, err + } + + // cleanup and update Programmed cond if not managed but was before + if !managed { + if err := r.cleanup(ctx, &gw, config.GW_FINALIZER); err != nil { + return ctrl.Result{}, err + } + + orig := gw.DeepCopy() + cond := metav1.Condition{ + Type: "Programmed", + Status: metav1.ConditionFalse, + Reason: "NoLongerManaged", + Message: fmt.Sprintf("Gateway is no longer managed by %s", r.ControllerName), + ObservedGeneration: gw.Generation, + } + meta.SetStatusCondition(&gw.Status.Conditions, cond) + if err := r.Status().Patch(ctx, &gw, client.MergeFrom(orig)); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil + } + + // add finalizer + if !controllerutil.ContainsFinalizer(&gw, config.GW_FINALIZER) { + orig := gw.DeepCopy() + controllerutil.AddFinalizer(&gw, config.GW_FINALIZER) + if err := r.Patch(ctx, &gw, client.MergeFrom(orig)); err != nil { + return ctrl.Result{}, err + } + } + + tlsInfo, err := r.buildTLSInfo(ctx, &gw) + if err != nil { + r.Recorder.Event(&gw, corev1.EventTypeWarning, "InvalidTLS", err.Error()) + _ = r.setGatewayStatusCondition(ctx, &gw, "Accepted", "InvalidTLS", err.Error(), metav1.ConditionFalse) + return ctrl.Result{}, nil + + } + + gwInfo, err := r.buildGatewayInfo(ctx, &gw) + if err != nil { + r.Recorder.Event(&gw, corev1.EventTypeWarning, "InvalidGateway", err.Error()) + _ = r.setGatewayStatusCondition(ctx, &gw, "Accepted", "InvalidGateway", err.Error(), metav1.ConditionFalse) + return ctrl.Result{}, nil + } + + // set Accepted cond + _ = r.setGatewayStatusCondition(ctx, &gw, "Accepted", "Accepted", "Gateway is valid and accepted", metav1.ConditionTrue) + + // sync tls + hostsCertIDMap, err := r.TLSMgr.EnsureTLS(ctx, tlsInfo) + if err != nil { + _ = r.setGatewayStatusCondition(ctx, &gw, "Programmed", "SyncTLSFailed", err.Error(), metav1.ConditionFalse) + r.Recorder.Event(&gw, corev1.EventTypeWarning, "SyncTLSFailed", err.Error()) + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + // sync lb + lb, err := r.LBMgr.EnsureLB(ctx, gwInfo, hostsCertIDMap) + if err != nil { + _ = r.setGatewayStatusCondition(ctx, &gw, "Programmed", "SyncFailed", err.Error(), metav1.ConditionFalse) + r.Recorder.Event(&gw, corev1.EventTypeWarning, "SyncFailed", err.Error()) + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + + } + + if strings.ToLower(lb.Status) != config.LB_ACTIVE_STATUS { + msg := "Load balancer created, waiting for status=Active" + _ = r.setGatewayStatusCondition(ctx, &gw, "Programmed", "Created", msg, metav1.ConditionFalse) + r.Recorder.Event(&gw, corev1.EventTypeWarning, "Created", msg) + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + var addresses []gatewayv1.GatewayStatusAddress + for _, ip := range lb.ExternalAddresses { + addresses = append(addresses, gatewayv1.GatewayStatusAddress{Type: &IPAddressType, Value: ip}) + } + + // not use SetGatewayStatusCondition because we need update addresses too + orig := gw.DeepCopy() + gw.Status.Addresses = addresses + cond := metav1.Condition{ + Type: "Programmed", + Status: metav1.ConditionTrue, + Reason: "Programmed", + Message: "Successfully programmed", + ObservedGeneration: gw.Generation, + } + meta.SetStatusCondition(&gw.Status.Conditions, cond) + if err := r.Status().Patch(ctx, &gw, client.MergeFrom(orig)); err != nil { + return ctrl.Result{}, err + } + r.Recorder.Event(&gw, corev1.EventTypeNormal, "Synced", "Successfully synced") + + return ctrl.Result{}, nil +} + +// isManagedGateway checks if gateway has our controller name and class +func (r *GatewayReconciler) isManagedGateway(ctx context.Context, gw *gatewayv1.Gateway) (bool, error) { + var gwClass gatewayv1.GatewayClass + gwClassName := string(gw.Spec.GatewayClassName) + + if err := r.Get(ctx, client.ObjectKey{Name: gwClassName}, &gwClass); err != nil { + return false, client.IgnoreNotFound(err) + } + + if string(gwClass.Spec.ControllerName) != r.ControllerName { + return false, nil + } + + if r.GatewayClassName != "" && gwClass.Name != r.GatewayClassName { + return false, nil + } + + return true, nil +} + +// managedPredicate filters not managed gateways before reconcile loop +func (r *GatewayReconciler) managedPredicate() predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + gw := e.Object.(*gatewayv1.Gateway) + managed, _ := r.isManagedGateway(context.Background(), gw) + return managed + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldGw := e.ObjectOld.(*gatewayv1.Gateway) + newGw := e.ObjectNew.(*gatewayv1.Gateway) + if !newGw.DeletionTimestamp.IsZero() { + return true // handle orphaned gateways + } + oldManaged, _ := r.isManagedGateway(context.Background(), oldGw) + newManaged, _ := r.isManagedGateway(context.Background(), newGw) + return oldManaged || newManaged + }, + GenericFunc: func(e event.GenericEvent) bool { return false }, + } +} + +// cleanup ensures that load balancer deleted and removes finalizer +func (r *GatewayReconciler) cleanup(ctx context.Context, gw *gatewayv1.Gateway, finalizer string) error { + labelSelector := config.GW_LABEL_ID + "=" + string(gw.UID) + if err := r.LBMgr.DeleteLB(ctx, labelSelector); err != nil { + return err + } + + orig := gw.DeepCopy() + controllerutil.RemoveFinalizer(gw, finalizer) + if err := r.Patch(ctx, gw, client.MergeFrom(orig)); err != nil { + return err + } + + return nil +} + +// buildGatewayInfo gathers all info needed to build load balancer input. +func (r *GatewayReconciler) buildGatewayInfo(ctx context.Context, gw *gatewayv1.Gateway) (*types.GatewayInfo, error) { + log := ctrl.LoggerFrom(ctx) + nodeIps, err := r.getNodesIpList(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get nodes IPs: %w", err) + } + + // prepare listeners + seenListeners := make(map[gatewayv1.SectionName]bool) + var listeners []types.ListenerInfo + + for _, l := range gw.Spec.Listeners { + if seenListeners[l.Name] { + return nil, fmt.Errorf("duplicate listener name: %q", l.Name) + } + seenListeners[l.Name] = true + var hostname string + if l.Hostname != nil { + hostname = string(*l.Hostname) + } + // allowedRoutes + allowedFrom := "Same" // default + selector := map[string]string(nil) + + if l.AllowedRoutes != nil && l.AllowedRoutes.Namespaces != nil { + ns := l.AllowedRoutes.Namespaces + if ns.From != nil { + allowedFrom = string(*ns.From) + if *ns.From == gatewayv1.NamespacesFromSelector && ns.Selector != nil && ns.Selector.MatchLabels != nil { + selector = ns.Selector.MatchLabels + } + } + } + listeners = append(listeners, types.ListenerInfo{ + Name: string(l.Name), + Hostname: hostname, + Protocol: string(l.Protocol), + Port: int32(l.Port), + AllowedFrom: allowedFrom, + Selector: selector, + }) + } + + vhostMap := map[string]*types.VHostInfo{} + routeForDomain := map[string]string{} + + var httpRoutes gatewayv1.HTTPRouteList + if err := r.List(ctx, &httpRoutes); err != nil { + return nil, fmt.Errorf("failed to list HTTPRoutes: %w", err) + } + + for _, route := range httpRoutes.Items { + if !isRouteAttachedToGateway(&route, gw) { + continue + } + var routeHostnames []string + if len(route.Spec.Hostnames) == 0 { + return nil, fmt.Errorf("HTTPRoute %s/%s: Hostname must be specified (no wildcards, no empty values supported)", route.Namespace, route.Name) + } + for _, h := range route.Spec.Hostnames { + host := string(h) + if host == "" || strings.ContainsRune(host, '*') { + return nil, fmt.Errorf("HTTPRoute %s/%s: Invalid hostname %q (must be concrete, no wildcards, no empty)", route.Namespace, route.Name, host) + } + routeHostnames = append(routeHostnames, host) + } + + sectionNames := map[string]struct{}{} + for _, pr := range route.Spec.ParentRefs { + if pr.SectionName != nil { + sectionNames[string(*pr.SectionName)] = struct{}{} + } + } + + nsLabels, err := r.getNamespaceLabels(ctx, route.Namespace) + if err != nil { + return nil, fmt.Errorf("cannot get labels for namespace %q: %w", route.Namespace, err) + } + for _, hostname := range routeHostnames { + if prev, ok := routeForDomain[hostname]; ok && prev != route.Name { + return nil, fmt.Errorf("domain %q used in several HTTPRoute: %q and %q", hostname, prev, route.Name) + } + routeForDomain[hostname] = route.Name + + matchedListeners := []types.ListenerInfo{} + for _, l := range listeners { + if len(sectionNames) > 0 { + if _, ok := sectionNames[l.Name]; !ok { + continue + } + } + if !isRouteNamespaceAllowed(l, gw.Namespace, route.Namespace, nsLabels) { + continue + } + if hostMatches(l.Hostname, hostname) { + matchedListeners = append(matchedListeners, l) + } + } + if len(matchedListeners) == 0 { + continue + } + // SSL/Ports + ssl := false + ports := []int32{} + for _, l := range matchedListeners { + if l.Protocol == "HTTPS" { + ssl = true + } + } + for _, l := range matchedListeners { + if ssl && l.Protocol == "HTTPS" { + ports = append(ports, l.Port) + } + if !ssl && l.Protocol == "HTTP" { + ports = append(ports, l.Port) + } + } + vh, exists := vhostMap[hostname] + if !exists { + vh = &types.VHostInfo{ + Host: hostname, + SSL: ssl, + Ports: ports, + } + vhostMap[hostname] = vh + } else { + existing := map[int32]struct{}{} + for _, p := range vh.Ports { + existing[p] = struct{}{} + } + for _, p := range ports { + if _, ok := existing[p]; !ok { + vh.Ports = append(vh.Ports, p) + } + } + if ssl { + vh.SSL = true + } + } + + // prepare paths + for _, rule := range route.Spec.Rules { + if len(rule.BackendRefs) == 0 { + continue + } + if len(rule.Filters) > 0 { + log.Info("HTTPRoute filters will be ignored", "http_route", route.Namespace+"/"+route.Name, "level", "warn") + + } + backend := rule.BackendRefs[0] + if backend.BackendObjectReference.Group != nil && *backend.BackendObjectReference.Group != "" { + return nil, fmt.Errorf("non-core backend groups not supported: %v", *backend.BackendObjectReference.Group) + } + svcName := string(backend.BackendObjectReference.Name) + ns := route.Namespace + if backend.BackendObjectReference.Namespace != nil { + ns = string(*backend.BackendObjectReference.Namespace) + } + var svc corev1.Service + if err := r.Get(ctx, client.ObjectKey{Namespace: ns, Name: svcName}, &svc); err != nil { + return nil, fmt.Errorf("failed to get service %s/%s: %w", ns, svcName, err) + } + var wantPort int32 = 0 + if backend.BackendObjectReference.Port != nil { + wantPort = int32(*backend.BackendObjectReference.Port) + } else if len(svc.Spec.Ports) > 0 { + wantPort = svc.Spec.Ports[0].Port + } + var nodePort int32 + found := false + for _, p := range svc.Spec.Ports { + if p.Port == wantPort { + if p.NodePort == 0 { + return nil, fmt.Errorf("service %s has no NodePort (only NodePort/LoadBalancer supported)", svc.Name) + } + nodePort = p.NodePort + found = true + break + } + } + if !found { + return nil, fmt.Errorf("service %s: port %d not found", svc.Name, wantPort) + } + paths := []string{} + if len(rule.Matches) == 0 { + paths = append(paths, "/") + } else { + for _, m := range rule.Matches { + if m.Path == nil || m.Path.Value == nil { + continue + } + pathType := gatewayv1.PathMatchPathPrefix + if m.Path.Type != nil { + pathType = *m.Path.Type + } + if pathType != gatewayv1.PathMatchPathPrefix { + log.Info("unsupported match type in rule, only PathPrefix is supported — skipping", "type", pathType, "http_route", route.Namespace+"/"+route.Name, "level", "warn") + continue + } + paths = append(paths, *m.Path.Value) + } + } + for _, path := range paths { + vh.Paths = append(vh.Paths, types.PathInfo{ + Path: path, + Service: &svc, + NodePort: int(nodePort), + NodeIps: nodeIps, + }) + } + } + } + } + gwInfo := &types.GatewayInfo{ + UID: string(gw.UID), + Name: gw.Name, + NS: gw.Namespace, + VHosts: vhostMap, + } + return gwInfo, nil +} + +// buildTLSInfo gathers tls info about each domain that can use tls. +func (r *GatewayReconciler) buildTLSInfo(ctx context.Context, gw *gatewayv1.Gateway) (map[string]types.TLSConfigInfo, error) { + var ( + result = make(map[string]types.TLSConfigInfo) + errs []error + ) + + for i, listener := range gw.Spec.Listeners { + if listener.Protocol != gatewayv1.HTTPSProtocolType { + continue + } + if err := validateHTTPSListener(listener); err != nil { + errs = append(errs, fmt.Errorf("listener[%d]: %w", i, err)) + continue + } + hostname := string(*listener.Hostname) + if listener.TLS.Options != nil { + optKey := gatewayv1.AnnotationKey(config.TLS_EXTERNAL_ID_KEY) + if id, ok := listener.TLS.Options[optKey]; ok && id != "" { + result[hostname] = types.TLSConfigInfo{ + ExternalID: string(id), + } + continue + } + } + var secretName string + var secretNS = gw.Namespace + for _, ref := range listener.TLS.CertificateRefs { + if (ref.Kind == nil || *ref.Kind == "Secret") && (ref.Group == nil || *ref.Group == "") { + secretName = string(ref.Name) + break + } + } + if secretName == "" { + errs = append(errs, fmt.Errorf("listener[%d]: no valid refs found", i)) + continue + } + var secret corev1.Secret + if err := r.Get(ctx, client.ObjectKey{Namespace: secretNS, Name: secretName}, &secret); err != nil { + return nil, fmt.Errorf("can't get secret %s/%s: %v", secretNS, secretName, err) + } + result[hostname] = types.TLSConfigInfo{ + Secret: &secret, + } + } + + if len(errs) > 0 { + return nil, fmt.Errorf("validation errors:\n%s", joinErrors(errs)) + } + return result, nil +} + +// getNodesIpList return node ips +func (r *GatewayReconciler) getNodesIpList(ctx context.Context) ([]string, error) { + var nodes corev1.NodeList + if err := r.List(ctx, &nodes); err != nil { + return nil, err + } + + var nodeIPs []string + for _, node := range nodes.Items { + for _, addr := range node.Status.Addresses { + if addr.Type == corev1.NodeExternalIP || addr.Type == corev1.NodeInternalIP { + nodeIPs = append(nodeIPs, addr.Address) + break + } + } + } + return nodeIPs, nil +} + +// getNamespaceLabels return namespace labels +func (r *GatewayReconciler) getNamespaceLabels(ctx context.Context, ns string) (map[string]string, error) { + var namespace corev1.Namespace + if err := r.Get(ctx, client.ObjectKey{Name: ns}, &namespace); err != nil { + return nil, err + } + return namespace.Labels, nil +} + +// setGatewayStatusCondition helper for set status condition +func (r *GatewayReconciler) setGatewayStatusCondition( + ctx context.Context, + gw *gatewayv1.Gateway, + condType, reason, message string, + status metav1.ConditionStatus, +) error { + orig := gw.DeepCopy() + cond := metav1.Condition{ + Type: condType, + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: gw.Generation, + } + meta.SetStatusCondition(&gw.Status.Conditions, cond) + return r.Status().Patch(ctx, gw, client.MergeFrom(orig)) +} diff --git a/internal/gateway/controller/gateway_class.go b/internal/gateway/controller/gateway_class.go new file mode 100644 index 0000000..632eb60 --- /dev/null +++ b/internal/gateway/controller/gateway_class.go @@ -0,0 +1,52 @@ +package controller + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +type GatewayClassReconciler struct { + client.Client + ControllerName string +} + +// Reconcile for gateway class ensures that class is managed by our controller and update gateway class status. +func (r *GatewayClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := ctrl.LoggerFrom(ctx) + var gc gatewayv1.GatewayClass + if err := r.Get(ctx, req.NamespacedName, &gc); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if string(gc.Spec.ControllerName) != r.ControllerName { + log.V(1).Info("ControllerName does not match, skipping", "found", gc.Spec.ControllerName, "expected", r.ControllerName) + return ctrl.Result{}, nil + } + + newCondition := metav1.Condition{ + Type: string(gatewayv1.GatewayClassConditionStatusAccepted), + Status: metav1.ConditionTrue, + Reason: "Accepted", + Message: "GatewayClass accepted by controller", + } + meta.SetStatusCondition(&gc.Status.Conditions, newCondition) + + if err := r.Status().Update(ctx, &gc); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update GatewayClass status: %w", err) + } + log.V(1).Info("Set Accepted=True for GatewayClass", "name", gc.Name) + return ctrl.Result{}, nil +} + +func (r *GatewayClassReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&gatewayv1.GatewayClass{}). + WithEventFilter(predicate.GenerationChangedPredicate{}). + Complete(r) +} diff --git a/internal/gateway/controller/gateway_class_test.go b/internal/gateway/controller/gateway_class_test.go new file mode 100644 index 0000000..f04a0a1 --- /dev/null +++ b/internal/gateway/controller/gateway_class_test.go @@ -0,0 +1,86 @@ +package controller + +import ( + "context" + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func Test_GatewayClassReconciler_Reconcile_SetsAccepted(t *testing.T) { + g := NewWithT(t) + scheme := setupScheme(t) + + gc := gatewayv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gc", + }, + Spec: gatewayv1.GatewayClassSpec{ + ControllerName: gatewayv1.GatewayController("example.com/controller"), + }, + } + + fakeCli := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&gatewayv1.GatewayClass{}). + WithObjects(&gc). + Build() + + r := &GatewayClassReconciler{ + Client: fakeCli, + ControllerName: "example.com/controller", + } + + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: gc.Name}} + + _, err := r.Reconcile(context.Background(), req) + g.Expect(err).To(BeNil()) + + var got gatewayv1.GatewayClass + g.Expect(fakeCli.Get(context.Background(), types.NamespacedName{Name: gc.Name}, &got)).To(Succeed()) + found := false + for _, c := range got.Status.Conditions { + if c.Reason == "Accepted" { + found = true + } + } + g.Expect(found).To(BeTrue()) +} + +func Test_GatewayClassReconciler_Reconcile_SkipsOtherController(t *testing.T) { + g := NewWithT(t) + scheme := setupScheme(t) + + gc := gatewayv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "another-gc", + }, + Spec: gatewayv1.GatewayClassSpec{ + ControllerName: gatewayv1.GatewayController("other/controller"), + }, + } + + fakeCli := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&gatewayv1.GatewayClass{}). + WithObjects(&gc). + Build() + + r := &GatewayClassReconciler{ + Client: fakeCli, + ControllerName: "example.com/controller", + } + + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: gc.Name}} + _, err := r.Reconcile(context.Background(), req) + g.Expect(err).To(BeNil()) + + var got gatewayv1.GatewayClass + g.Expect(fakeCli.Get(context.Background(), types.NamespacedName{Name: gc.Name}, &got)).To(Succeed()) + g.Expect(len(got.Status.Conditions)).To(Equal(0)) +} diff --git a/internal/gateway/controller/gateway_test.go b/internal/gateway/controller/gateway_test.go new file mode 100644 index 0000000..e636c75 --- /dev/null +++ b/internal/gateway/controller/gateway_test.go @@ -0,0 +1,601 @@ +package controller + +import ( + "context" + "testing" + + "github.com/serverscom/api-gateway-controller/internal/config" + "github.com/serverscom/api-gateway-controller/internal/mocks" + + . "github.com/onsi/gomega" + serverscom "github.com/serverscom/serverscom-go-client/pkg" + "go.uber.org/mock/gomock" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var ( + testGwNs = "test-gw-ns" + testGw = "test-gw" +) + +func setupScheme(t *testing.T) *runtime.Scheme { + g := NewWithT(t) + scheme := runtime.NewScheme() + g.Expect(clientgoscheme.AddToScheme(scheme)).To(BeNil()) + g.Expect(gatewayv1.Install(scheme)).To(BeNil()) + g.Expect(corev1.AddToScheme(scheme)).To(BeNil()) + return scheme +} + +func ptrHostname(s string) *gatewayv1.Hostname { + h := gatewayv1.Hostname(s) + return &h +} + +func TestReconcile(t *testing.T) { + s := setupScheme(t) + baseGC := &gatewayv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: config.DEFAULT_GATEWAY_CLASS, + }, + Spec: gatewayv1.GatewayClassSpec{ + ControllerName: gatewayv1.GatewayController(config.DEFAULT_CONTROLLER_NAME), + }, + } + baseGW := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: testGw, + Namespace: testGwNs, + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: gatewayv1.ObjectName(config.DEFAULT_GATEWAY_CLASS), + Listeners: []gatewayv1.Listener{{ + Name: "https", + Port: 443, + Protocol: gatewayv1.HTTPSProtocolType, + Hostname: ptrHostname("example.com"), + }}, + }, + } + tests := []struct { + name string + prepareObjs func() []client.Object + setupMocks func(tls *mocks.MockTLSManagerInterface, lb *mocks.MockLBManagerInterface) + checkStatus func(t *testing.T, cli client.Client) + expectError bool + }{ + { + name: "https listener valid tls", + prepareObjs: func() []client.Object { + gw := baseGW.DeepCopy() + mode := gatewayv1.TLSModeTerminate + gw.Spec.Listeners[0].TLS = &gatewayv1.GatewayTLSConfig{ + Mode: &mode, + Options: map[gatewayv1.AnnotationKey]gatewayv1.AnnotationValue{ + config.TLS_EXTERNAL_ID_KEY: "ext-cert-123", + }, + } + return []client.Object{baseGC.DeepCopy(), gw} + }, + setupMocks: func(tls *mocks.MockTLSManagerInterface, lb *mocks.MockLBManagerInterface) { + tls.EXPECT(). + EnsureTLS(gomock.Any(), gomock.Any()). + Return(map[string]string{"example.com": "ext-cert-123"}, nil) + lb.EXPECT(). + EnsureLB(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&serverscom.L7LoadBalancer{ID: "lb-1", Status: config.LB_ACTIVE_STATUS}, nil) + }, + checkStatus: func(t *testing.T, cli client.Client) { + var gw gatewayv1.Gateway + if err := cli.Get(context.Background(), types.NamespacedName{Name: testGw, Namespace: testGwNs}, &gw); err != nil { + t.Fatalf("get gw failed: %v", err) + } + progFound := false + for _, c := range gw.Status.Conditions { + if c.Type == "Programmed" && c.Status == metav1.ConditionTrue { + progFound = true + } + } + if !progFound { + t.Errorf("expected Programmed=True condition") + } + }, + }, + { + name: "http listener only", + prepareObjs: func() []client.Object { + gw := baseGW.DeepCopy() + gw.Spec.Listeners = []gatewayv1.Listener{{ + Name: "http", + Port: 80, + Protocol: gatewayv1.HTTPProtocolType, + Hostname: nil, + TLS: nil, + }} + return []client.Object{baseGC.DeepCopy(), gw} + }, + setupMocks: func(tls *mocks.MockTLSManagerInterface, lb *mocks.MockLBManagerInterface) { + lb.EXPECT(). + EnsureLB(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&serverscom.L7LoadBalancer{ID: "lb-2", Status: config.LB_ACTIVE_STATUS}, nil) + tls.EXPECT(). + EnsureTLS(gomock.Any(), gomock.Any()). + Return(map[string]string{}, nil) + }, + checkStatus: func(t *testing.T, cli client.Client) { + var gw gatewayv1.Gateway + if err := cli.Get(context.Background(), types.NamespacedName{Name: testGw, Namespace: testGwNs}, &gw); err != nil { + t.Fatalf("get gw failed: %v", err) + } + for _, c := range gw.Status.Conditions { + if c.Type == "Programmed" && c.Status == metav1.ConditionTrue { + return + } + } + t.Errorf("expected Programmed=True condition on HTTP listener") + }, + }, + { + name: "https missing tls", + prepareObjs: func() []client.Object { + gw := baseGW.DeepCopy() + return []client.Object{baseGC.DeepCopy(), gw} + }, + setupMocks: func(tls *mocks.MockTLSManagerInterface, lb *mocks.MockLBManagerInterface) {}, + checkStatus: func(t *testing.T, cli client.Client) { + var gw gatewayv1.Gateway + _ = cli.Get(context.Background(), types.NamespacedName{Name: testGw, Namespace: testGwNs}, &gw) + found := false + for _, c := range gw.Status.Conditions { + if c.Type == "Accepted" && c.Status == metav1.ConditionFalse { + found = true + } + } + if !found { + t.Errorf("expected Accepted=False on missing TLS") + } + }, + expectError: false, + }, + { + name: "https wrong tls mode", + prepareObjs: func() []client.Object { + gw := baseGW.DeepCopy() + mode := gatewayv1.TLSModePassthrough + gw.Spec.Listeners[0].TLS = &gatewayv1.GatewayTLSConfig{Mode: &mode} + return []client.Object{baseGC.DeepCopy(), gw} + }, + setupMocks: func(tls *mocks.MockTLSManagerInterface, lb *mocks.MockLBManagerInterface) {}, + checkStatus: func(t *testing.T, cli client.Client) { + var gw gatewayv1.Gateway + _ = cli.Get(context.Background(), types.NamespacedName{Name: testGw, Namespace: testGwNs}, &gw) + found := false + for _, c := range gw.Status.Conditions { + if c.Type == "Accepted" && c.Status == metav1.ConditionFalse { + found = true + } + } + if !found { + t.Errorf("expected Accepted=False on wrong TLS mode") + } + }, + expectError: false, + }, + { + name: "not managed gateway", + prepareObjs: func() []client.Object { + gw := baseGW.DeepCopy() + gw.Spec.GatewayClassName = "some-other-class" + return []client.Object{baseGC.DeepCopy(), gw} + }, + setupMocks: func(tls *mocks.MockTLSManagerInterface, lb *mocks.MockLBManagerInterface) { + lb.EXPECT(). + DeleteLB(gomock.Any(), gomock.Any()). + Return(nil) + }, + checkStatus: func(t *testing.T, cli client.Client) { + var gw gatewayv1.Gateway + _ = cli.Get(context.Background(), types.NamespacedName{Name: testGw, Namespace: testGwNs}, &gw) + noLonger := false + for _, c := range gw.Status.Conditions { + if c.Type == "Programmed" && c.Status == metav1.ConditionFalse && c.Reason == "NoLongerManaged" { + noLonger = true + } + } + if !noLonger { + t.Errorf("expected Programmed=False, Reason=NoLongerManaged") + } + }, + expectError: false, + }, + { + name: "http and https listeners", + prepareObjs: func() []client.Object { + gw := baseGW.DeepCopy() + term := gatewayv1.TLSModeTerminate + gw.Spec.Listeners = []gatewayv1.Listener{ + { + Name: "http", + Port: 80, + Protocol: gatewayv1.HTTPProtocolType, + }, + { + Name: "https", + Port: 443, + Protocol: gatewayv1.HTTPSProtocolType, + Hostname: ptrHostname("foo.com"), + TLS: &gatewayv1.GatewayTLSConfig{ + Mode: &term, + Options: map[gatewayv1.AnnotationKey]gatewayv1.AnnotationValue{ + config.TLS_EXTERNAL_ID_KEY: "ext-cert-123", + }, + }, + }, + } + return []client.Object{baseGC.DeepCopy(), gw} + }, + setupMocks: func(tls *mocks.MockTLSManagerInterface, lb *mocks.MockLBManagerInterface) { + tls.EXPECT(). + EnsureTLS(gomock.Any(), gomock.Any()). + Return(map[string]string{"foo.com": "ext-cert-123"}, nil) + lb.EXPECT(). + EnsureLB(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&serverscom.L7LoadBalancer{ID: "lb-4", Status: config.LB_ACTIVE_STATUS}, nil) + }, + checkStatus: func(t *testing.T, cli client.Client) { + var gw gatewayv1.Gateway + _ = cli.Get(context.Background(), types.NamespacedName{Name: testGw, Namespace: testGwNs}, &gw) + ok := false + for _, c := range gw.Status.Conditions { + if c.Type == "Programmed" && c.Status == metav1.ConditionTrue { + ok = true + } + } + if !ok { + t.Errorf("expected Programmed=True on HTTP + HTTPS listeners") + } + }, + expectError: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrlr := gomock.NewController(t) + defer ctrlr.Finish() + mockTLS := mocks.NewMockTLSManagerInterface(ctrlr) + mockLB := mocks.NewMockLBManagerInterface(ctrlr) + tt.setupMocks(mockTLS, mockLB) + fakeCli := fake.NewClientBuilder(). + WithScheme(s). + WithStatusSubresource(&gatewayv1.Gateway{}). + WithObjects(tt.prepareObjs()...). + Build() + recorder := record.NewFakeRecorder(16) + r := &GatewayReconciler{ + Client: fakeCli, + ControllerName: config.DEFAULT_CONTROLLER_NAME, + GatewayClassName: config.DEFAULT_GATEWAY_CLASS, + TLSMgr: mockTLS, + LBMgr: mockLB, + Recorder: recorder, + } + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: testGwNs, + Name: testGw, + }, + }) + if tt.expectError && err == nil { + t.Errorf("expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("unexpected error: %v", err) + } + tt.checkStatus(t, fakeCli) + }) + } +} + +func TestReconcile_LBBecomesActiveOnSecondPass(t *testing.T) { + s := setupScheme(t) + baseGC := &gatewayv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: config.DEFAULT_GATEWAY_CLASS, + }, + Spec: gatewayv1.GatewayClassSpec{ + ControllerName: gatewayv1.GatewayController(config.DEFAULT_CONTROLLER_NAME), + }, + } + baseGW := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: testGw, + Namespace: testGwNs, + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: gatewayv1.ObjectName(config.DEFAULT_GATEWAY_CLASS), + Listeners: []gatewayv1.Listener{{ + Name: "http", + Port: 80, + Protocol: gatewayv1.HTTPProtocolType, + Hostname: ptrHostname("example.com"), + }}, + }, + } + + ctrlr := gomock.NewController(t) + defer ctrlr.Finish() + + mockTLS := mocks.NewMockTLSManagerInterface(ctrlr) + mockLB := mocks.NewMockLBManagerInterface(ctrlr) + fakeCli := fake.NewClientBuilder(). + WithScheme(s). + WithStatusSubresource(&gatewayv1.Gateway{}). + WithObjects(baseGC.DeepCopy(), baseGW.DeepCopy()). + Build() + + recorder := record.NewFakeRecorder(8) + r := &GatewayReconciler{ + Client: fakeCli, + ControllerName: config.DEFAULT_CONTROLLER_NAME, + GatewayClassName: config.DEFAULT_GATEWAY_CLASS, + TLSMgr: mockTLS, + LBMgr: mockLB, + Recorder: recorder, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: testGwNs, + Name: testGw, + }, + } + + mockTLS.EXPECT(). + EnsureTLS(gomock.Any(), gomock.Any()). + Return(map[string]string{"example.com": "cert-id"}, nil) + mockLB.EXPECT(). + EnsureLB(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&serverscom.L7LoadBalancer{ID: "lb-5", Status: "pending"}, nil) + + res, err := r.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.RequeueAfter == 0 { + t.Errorf("expected RequeueAfter > 0 due to non-active LB status") + } + + mockTLS.EXPECT(). + EnsureTLS(gomock.Any(), gomock.Any()). + Return(map[string]string{"example.com": "cert-id"}, nil) + mockLB.EXPECT(). + EnsureLB(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&serverscom.L7LoadBalancer{ID: "lb-5", Status: config.LB_ACTIVE_STATUS}, nil) + + res, err = r.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error on second pass: %v", err) + } + if res.RequeueAfter != 0 && !res.Requeue { + t.Errorf("expected no requeue, got requeueAfter=%v", res.RequeueAfter) + } + + var gw gatewayv1.Gateway + if err := fakeCli.Get(context.Background(), req.NamespacedName, &gw); err != nil { + t.Fatalf("get gw failed: %v", err) + } + hasProgrammed := false + for _, c := range gw.Status.Conditions { + if c.Type == "Programmed" && c.Status == metav1.ConditionTrue { + hasProgrammed = true + } + } + if !hasProgrammed { + t.Errorf("expected Programmed=True condition after LB becomes Active") + } +} + +func Test_buildTLSInfo(t *testing.T) { + g := NewWithT(t) + scheme := setupScheme(t) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "s1", Namespace: testGwNs}, + Data: map[string][]byte{"tls.crt": []byte("x"), "tls.key": []byte("y")}, + } + + // gw1: with secret ref + gw1 := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gw1", Namespace: testGwNs}, + Spec: gatewayv1.GatewaySpec{ + Listeners: []gatewayv1.Listener{{ + Name: "https", + Protocol: gatewayv1.HTTPSProtocolType, + Hostname: func() *gatewayv1.Hostname { h := gatewayv1.Hostname("secret.com"); return &h }(), + TLS: &gatewayv1.GatewayTLSConfig{ + Mode: ptrTLSMode(gatewayv1.TLSModeTerminate), + CertificateRefs: []gatewayv1.SecretObjectReference{ + {Name: "s1"}, + }, + }, + Port: 443, + }}, + }, + } + + // gw2: with external cert id + gw2 := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gw2", Namespace: testGwNs}, + Spec: gatewayv1.GatewaySpec{ + Listeners: []gatewayv1.Listener{{ + Name: "https2", + Protocol: gatewayv1.HTTPSProtocolType, + Hostname: func() *gatewayv1.Hostname { h := gatewayv1.Hostname("external.com"); return &h }(), + TLS: &gatewayv1.GatewayTLSConfig{ + Mode: ptrTLSMode(gatewayv1.TLSModeTerminate), + Options: map[gatewayv1.AnnotationKey]gatewayv1.AnnotationValue{ + config.TLS_EXTERNAL_ID_KEY: "ext-cert-123", + }, + }, + Port: 443, + }}, + }, + } + + fakeCli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build() + r := &GatewayReconciler{Client: fakeCli} + + // case 1: secret ref + tlsMap1, err := r.buildTLSInfo(context.Background(), gw1) + g.Expect(err).To(BeNil()) + g.Expect(tlsMap1).To(HaveKey("secret.com")) + g.Expect(tlsMap1["secret.com"].Secret).ToNot(BeNil()) + g.Expect(tlsMap1["secret.com"].ExternalID).To(Equal("")) + + // case 2: external id + tlsMap2, err := r.buildTLSInfo(context.Background(), gw2) + g.Expect(err).To(BeNil()) + g.Expect(tlsMap2).To(HaveKey("external.com")) + g.Expect(tlsMap2["external.com"].ExternalID).To(Equal("ext-cert-123")) + g.Expect(tlsMap2["external.com"].Secret).To(BeNil()) +} + +func Test_buildGatewayInfo(t *testing.T) { + g := NewWithT(t) + scheme := setupScheme(t) + + node := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "n1"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{{Type: corev1.NodeInternalIP, Address: "10.0.0.10"}}, + }, + } + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testGwNs}} + + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: testGwNs}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{{ + Name: "http", + Port: 80, + NodePort: 30080, + }}, + }, + } + + // gw: HTTP listener + gw := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gw1", Namespace: testGwNs}, + Spec: gatewayv1.GatewaySpec{ + Listeners: []gatewayv1.Listener{{ + Name: "l1", + Protocol: gatewayv1.HTTPProtocolType, + Port: 80, + }}, + }, + } + + // gw with HTTPS + gwTLS := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gw2", Namespace: testGwNs}, + Spec: gatewayv1.GatewaySpec{ + Listeners: []gatewayv1.Listener{{ + Name: "l2", + Protocol: gatewayv1.HTTPSProtocolType, + Port: 443, + Hostname: func() *gatewayv1.Hostname { h := gatewayv1.Hostname("tls.com"); return &h }(), + TLS: &gatewayv1.GatewayTLSConfig{Mode: ptrTLSMode(gatewayv1.TLSModeTerminate)}, + }}, + }, + } + + // route matched gw + route := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "r1", Namespace: testGwNs}, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"example.com"}, + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{ + { + Name: gatewayv1.ObjectName("gw1"), + Namespace: func() *gatewayv1.Namespace { n := gatewayv1.Namespace(testGwNs); return &n }(), + }, + }, + }, + Rules: []gatewayv1.HTTPRouteRule{{ + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName("svc"), + }, + }, + }, + }, + }}, + }, + } + + // route with unmatched host + routeUnmatched := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "r2", Namespace: "routes-ns"}, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"no-match.com"}, + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{ + { + Name: gatewayv1.ObjectName("gw1"), + }, + }, + }, + }, + } + + // route with no backend + routeNoBackend := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "r3", Namespace: "routes-ns"}, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"example.com"}, + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{ + { + Name: gatewayv1.ObjectName("gw1"), + }, + }, + }, + }, + } + + fakeCli := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(node, ns, svc, gw, gwTLS, route, routeUnmatched, routeNoBackend). + Build() + + r := &GatewayReconciler{Client: fakeCli} + + // case 1: HTTP + gi1, err := r.buildGatewayInfo(context.Background(), gw) + g.Expect(err).To(BeNil()) + g.Expect(gi1.VHosts).To(HaveKey("example.com")) + g.Expect(gi1.VHosts["example.com"].SSL).To(BeFalse()) + + // case 2: HTTPS + gi2, err := r.buildGatewayInfo(context.Background(), gwTLS) + g.Expect(err).To(BeNil()) + g.Expect(gi2.VHosts).To(BeEmpty()) + + // case 3: unmatched host + gi3, err := r.buildGatewayInfo(context.Background(), gw) + g.Expect(err).To(BeNil()) + g.Expect(gi3.VHosts).To(HaveKey("example.com")) + g.Expect(gi3.VHosts).ToNot(HaveKey("no-match.com")) +} diff --git a/internal/gateway/controller/handlers.go b/internal/gateway/controller/handlers.go new file mode 100644 index 0000000..9373d77 --- /dev/null +++ b/internal/gateway/controller/handlers.go @@ -0,0 +1,211 @@ +package controller + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/tools/cache" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// findGatewaysForHTTPRoute returns reconcile requests with gateways that affected by changes in httpRoute +func (r *GatewayReconciler) findGatewaysForHTTPRoute(ctx context.Context, obj client.Object) []reconcile.Request { + route := obj.(*gatewayv1.HTTPRoute) + var requests []reconcile.Request + + parentKeys := r.getParentGatewayKeys(route) + for _, key := range parentKeys { + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + continue + } + + var gw gatewayv1.Gateway + if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, &gw); err != nil { + if !apierrors.IsNotFound(err) { + ctrl.LoggerFrom(ctx).V(1).Info("HTTPRoute parent gateway not found", "route", route.Name, "gateway", key, "error", err) + } + continue + } + + managed, err := r.isManagedGateway(ctx, &gw) + if err != nil { + ctrl.LoggerFrom(ctx).V(1).Info("Failed to check if gateway is managed", "route", route.Name, "gateway", key, "error", err) + continue + } + if !managed { + ctrl.LoggerFrom(ctx).V(1).Info("HTTPRoute parent gateway not managed", "route", route.Name, "gateway", key) + continue + } + + ctrl.LoggerFrom(ctx).V(3).Info("HTTPRoute change triggers Gateway reconcile", "route", route.Name, "gateway", key) + requests = append(requests, reconcile.Request{ + NamespacedName: client.ObjectKey{Namespace: namespace, Name: name}, + }) + } + + return requests +} + +// findGatewaysForService returns reconcile requests with gateways that affected by changes in Service +func (r *GatewayReconciler) findGatewaysForService(ctx context.Context, obj client.Object) []reconcile.Request { + service := obj.(*corev1.Service) + var requests []reconcile.Request + + var httpRoutes gatewayv1.HTTPRouteList + if err := r.List(ctx, &httpRoutes); err != nil { + ctrl.LoggerFrom(ctx).Error(err, "Failed to list HTTPRoutes for service change", "service", service.Name) + return nil + } + + processedGateways := make(map[string]bool) + + for _, route := range httpRoutes.Items { + if !r.routeReferencesService(&route, service) { + continue + } + + parentKeys := r.getParentGatewayKeys(&route) + for _, parent := range parentKeys { + if processedGateways[parent] { + continue + } + + namespace, name, err := cache.SplitMetaNamespaceKey(parent) + if err != nil { + continue + } + + var gw gatewayv1.Gateway + if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, &gw); err != nil { + if !apierrors.IsNotFound(err) { + ctrl.LoggerFrom(ctx).V(1).Info("Service parent gateway not found", "service", service.Name, "gateway", parent, "error", err) + } + continue + } + + managed, err := r.isManagedGateway(ctx, &gw) + if err != nil { + ctrl.LoggerFrom(ctx).V(1).Info("Failed to check if gateway is managed", "service", service.Name, "gateway", parent, "error", err) + continue + } + if !managed { + ctrl.LoggerFrom(ctx).V(1).Info("Service parent gateway not managed", "service", service.Name, "gateway", parent) + continue + } + + ctrl.LoggerFrom(ctx).V(3).Info("Service change triggers Gateway reconcile", "service", service.Name, "gateway", parent) + requests = append(requests, reconcile.Request{ + NamespacedName: client.ObjectKey{Namespace: namespace, Name: name}, + }) + processedGateways[parent] = true + } + } + + return requests +} + +// findGatewaysForService returns reconcile requests with gateways that affected by changes in Secret +func (r *GatewayReconciler) findGatewaysForSecret(ctx context.Context, obj client.Object) []reconcile.Request { + secret := obj.(*corev1.Secret) + var requests []reconcile.Request + + var gateways gatewayv1.GatewayList + if err := r.List(ctx, &gateways, client.InNamespace(secret.Namespace)); err != nil { + ctrl.LoggerFrom(ctx).Error(err, "Failed to list Gateways for secret change", "secret", secret.Name) + return nil + } + + for _, gw := range gateways.Items { + if !r.gatewayReferencesSecret(&gw, secret) { + continue + } + + managed, err := r.isManagedGateway(ctx, &gw) + if err != nil { + ctrl.LoggerFrom(ctx).V(1).Info("Failed to check if gateway is managed", "secret", secret.Name, "gateway", gw.Name, "error", err) + continue + } + if !managed { + ctrl.LoggerFrom(ctx).V(1).Info("Secret gateway not managed", "secret", secret.Name, "gateway", gw.Name) + continue + } + + ctrl.LoggerFrom(ctx).V(1).Info("Secret change triggers Gateway reconcile", "secret", secret.Name, "gateway", gw.Name) + requests = append(requests, reconcile.Request{ + NamespacedName: client.ObjectKey{Namespace: gw.Namespace, Name: gw.Name}, + }) + } + + return requests +} + +// getParentGatewayKeys returns gateways for HTTPRoute +func (r *GatewayReconciler) getParentGatewayKeys(route *gatewayv1.HTTPRoute) []string { + var keys []string + for _, parent := range route.Spec.ParentRefs { + if parent.Kind != nil && string(*parent.Kind) != "Gateway" { + continue + } + if parent.Group != nil && *parent.Group != gatewayv1.GroupName { + continue + } + ns := route.Namespace + if parent.Namespace != nil { + ns = string(*parent.Namespace) + } + name := string(parent.Name) + keys = append(keys, ns+"/"+name) + } + return keys +} + +// routeReferencesService returns true if the given HTTPRoute references the specified Service. +func (r *GatewayReconciler) routeReferencesService(route *gatewayv1.HTTPRoute, service *corev1.Service) bool { + for _, rule := range route.Spec.Rules { + for _, backendRef := range rule.BackendRefs { + // skip not core Services + if backendRef.BackendObjectReference.Group != nil && *backendRef.BackendObjectReference.Group != "" { + continue + } + if string(backendRef.BackendObjectReference.Name) != service.Name { + continue + } + + ns := route.Namespace + if backendRef.BackendObjectReference.Namespace != nil { + ns = string(*backendRef.BackendObjectReference.Namespace) + } + if ns == service.Namespace { + return true + } + } + } + return false +} + +// gatewayReferencesSecret returns true if the given Gateway references the specified Secret. +func (r *GatewayReconciler) gatewayReferencesSecret(gw *gatewayv1.Gateway, secret *corev1.Secret) bool { + for _, listener := range gw.Spec.Listeners { + if listener.TLS == nil { + continue + } + for _, certRef := range listener.TLS.CertificateRefs { + if string(certRef.Name) == secret.Name { + ns := gw.Namespace + if certRef.Namespace != nil { + ns = string(*certRef.Namespace) + } + if ns != secret.Namespace { + continue + } + return true + } + } + } + return false +} diff --git a/internal/gateway/controller/handlers_test.go b/internal/gateway/controller/handlers_test.go new file mode 100644 index 0000000..fdb7380 --- /dev/null +++ b/internal/gateway/controller/handlers_test.go @@ -0,0 +1,219 @@ +package controller + +import ( + "context" + "testing" + + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func Test_getParentGatewayKeys(t *testing.T) { + g := NewWithT(t) + route := &gatewayv1.HTTPRoute{} + route.ObjectMeta.SetNamespace("rns") + pr := gatewayv1.ParentReference{ + Name: gatewayv1.ObjectName("gw1"), + } + route.Spec.ParentRefs = []gatewayv1.ParentReference{pr} + r := &GatewayReconciler{} + keys := r.getParentGatewayKeys(route) + g.Expect(keys).To(ContainElement("rns/gw1")) + // explicit namespace + pr.Namespace = func() *gatewayv1.Namespace { n := gatewayv1.Namespace("gw-ns"); return &n }() + route.Spec.ParentRefs = []gatewayv1.ParentReference{pr} + keys = r.getParentGatewayKeys(route) + g.Expect(keys).To(ContainElement("gw-ns/gw1")) +} + +func Test_routeReferencesService(t *testing.T) { + g := NewWithT(t) + route := &gatewayv1.HTTPRoute{} + route.ObjectMeta.SetNamespace("ns") + route.Spec.Rules = []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName("svc"), + }, + }, + }, + }, + }, + } + svc := &corev1.Service{} + svc.ObjectMeta.SetNamespace("ns") + svc.ObjectMeta.SetName("svc") + gr := &GatewayReconciler{} + g.Expect(gr.routeReferencesService(route, svc)).To(BeTrue()) +} + +func Test_gatewayReferencesSecret(t *testing.T) { + g := NewWithT(t) + gw := &gatewayv1.Gateway{} + gw.ObjectMeta.SetNamespace("ns") + gw.Spec.Listeners = []gatewayv1.Listener{ + { + TLS: &gatewayv1.GatewayTLSConfig{ + CertificateRefs: []gatewayv1.SecretObjectReference{ + { + Name: gatewayv1.ObjectName("s1"), + }, + }, + }, + }, + } + secret := &corev1.Secret{} + secret.ObjectMeta.SetNamespace("ns") + secret.ObjectMeta.SetName("s1") + gr := &GatewayReconciler{} + g.Expect(gr.gatewayReferencesSecret(gw, secret)).To(BeTrue()) + secret.ObjectMeta.SetNamespace("other") + g.Expect(gr.gatewayReferencesSecret(gw, secret)).To(BeFalse()) +} + +func Test_findGatewaysForHTTPRoute(t *testing.T) { + g := NewWithT(t) + scheme := setupScheme(t) + // prepare GatewayClass, Gateway, HTTPRoute + gc := &gatewayv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "gc1"}, + Spec: gatewayv1.GatewayClassSpec{ + ControllerName: gatewayv1.GatewayController("example.com/controller"), + }, + } + gw := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gw1", Namespace: "gw-ns"}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: gatewayv1.ObjectName("gc1"), + }, + } + route := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "route1", Namespace: "gw-ns"}, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"example.com"}, + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{ + { + Name: gatewayv1.ObjectName("gw1"), + }, + }, + }, + }, + } + fakeCli := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(gc, gw, route). + Build() + r := &GatewayReconciler{ + Client: fakeCli, + ControllerName: "example.com/controller", + } + reqs := r.findGatewaysForHTTPRoute(context.Background(), route) + g.Expect(len(reqs)).To(Equal(1)) + g.Expect(reqs[0].NamespacedName.Name).To(Equal("gw1")) + g.Expect(reqs[0].NamespacedName.Namespace).To(Equal("gw-ns")) +} + +func Test_findGatewaysForService(t *testing.T) { + g := NewWithT(t) + scheme := setupScheme(t) + gc := &gatewayv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "gc1"}, + Spec: gatewayv1.GatewayClassSpec{ + ControllerName: gatewayv1.GatewayController("example.com/controller"), + }, + } + gw := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gw1", Namespace: "gw-ns"}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: gatewayv1.ObjectName("gc1"), + }, + } + routeWithBackend := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "route-backend", Namespace: "gw-ns"}, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName("svc"), + }, + }, + }, + }, + }, + }, + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{ + { + Name: gatewayv1.ObjectName("gw1"), + }, + }, + }, + }, + } + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "gw-ns"}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Port: 80, NodePort: 30080}}, + }, + } + fakeCli := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(gc, gw, routeWithBackend, svc). + Build() + r := &GatewayReconciler{ + Client: fakeCli, + ControllerName: "example.com/controller", + } + reqs := r.findGatewaysForService(context.Background(), svc) + g.Expect(len(reqs)).To(Equal(1)) + g.Expect(reqs[0].NamespacedName.Name).To(Equal("gw1")) +} + +func Test_findGatewaysForSecret(t *testing.T) { + g := NewWithT(t) + scheme := setupScheme(t) + gc := &gatewayv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "gc1"}, + Spec: gatewayv1.GatewayClassSpec{ + ControllerName: gatewayv1.GatewayController("example.com/controller"), + }, + } + gwWithSecret := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gw1", Namespace: "gw-ns"}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: gatewayv1.ObjectName("gc1"), + Listeners: []gatewayv1.Listener{ + { + TLS: &gatewayv1.GatewayTLSConfig{ + CertificateRefs: []gatewayv1.SecretObjectReference{ + {Name: gatewayv1.ObjectName("s1")}, + }, + }, + }, + }, + }, + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "s1", Namespace: "gw-ns"}, + } + fakeCli := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(gc, gwWithSecret, secret). + Build() + r := &GatewayReconciler{ + Client: fakeCli, + ControllerName: "example.com/controller", + } + reqs := r.findGatewaysForSecret(context.Background(), secret) + g.Expect(len(reqs)).To(Equal(1)) + g.Expect(reqs[0].NamespacedName.Name).To(Equal("gw1")) +} diff --git a/internal/gateway/controller/helpers.go b/internal/gateway/controller/helpers.go new file mode 100644 index 0000000..a7f930b --- /dev/null +++ b/internal/gateway/controller/helpers.go @@ -0,0 +1,95 @@ +package controller + +import ( + "fmt" + "strings" + + "github.com/serverscom/api-gateway-controller/internal/types" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// isRouteAttachedToGateway returns true if route is attached to Gateway +func isRouteAttachedToGateway(route *gatewayv1.HTTPRoute, gw *gatewayv1.Gateway) bool { + for _, parent := range route.Spec.ParentRefs { + if parent.Kind != nil && string(*parent.Kind) != "Gateway" { + continue + } + if parent.Group != nil && *parent.Group != gatewayv1.GroupName { + continue + } + if string(parent.Name) != gw.Name { + continue + } + + ns := route.Namespace + if parent.Namespace != nil { + ns = string(*parent.Namespace) + } + if ns == gw.Namespace { + return true + } + } + return false +} + +// isRouteNamespaceAllowed returns true if route's namespace is permitted by the listener policy. +func isRouteNamespaceAllowed(listener types.ListenerInfo, listenerNS, routeNS string, nsLabels map[string]string) bool { + switch listener.AllowedFrom { + case "All": + return true + case "Same": + return listenerNS == routeNS + case "Selector": + for k, v := range listener.Selector { + if nsLabels[k] != v { + return false + } + } + return true + } + return listenerNS == routeNS +} + +// validateHTTPSListener validates https listener +func validateHTTPSListener(listener gatewayv1.Listener) error { + if listener.Protocol != gatewayv1.HTTPSProtocolType { + return nil + } + if listener.Hostname == nil || *listener.Hostname == "" { + return fmt.Errorf("hostname must be specified for HTTPS protocol") + } + if listener.TLS == nil { + return fmt.Errorf("hostname=%q: missing TLS config", *listener.Hostname) + } + if listener.TLS.Mode == nil || *listener.TLS.Mode != gatewayv1.TLSModeTerminate { + return fmt.Errorf("hostname=%q: TLS mode must be 'Terminate'", *listener.Hostname) + } + return nil +} + +// joinErrors helpers to join errors +func joinErrors(errs []error) string { + var b strings.Builder + for _, err := range errs { + b.WriteString("- ") + b.WriteString(err.Error()) + b.WriteString("\n") + } + return b.String() +} + +// hostMatches reports whether routeHost matches listenerHost, supporting wildcards. +func hostMatches(listenerHost, routeHost string) bool { + if listenerHost == "" { + return true + } + if listenerHost == routeHost { + return true + } + if strings.HasPrefix(listenerHost, "*.") && len(listenerHost) > 2 { + suffix := listenerHost[1:] + return strings.HasSuffix(routeHost, suffix) + } + return false +} diff --git a/internal/gateway/controller/helpers_test.go b/internal/gateway/controller/helpers_test.go new file mode 100644 index 0000000..1fc2c9c --- /dev/null +++ b/internal/gateway/controller/helpers_test.go @@ -0,0 +1,125 @@ +package controller + +import ( + "errors" + "testing" + + "github.com/serverscom/api-gateway-controller/internal/types" + + . "github.com/onsi/gomega" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func ptrTLSMode(m gatewayv1.TLSModeType) *gatewayv1.TLSModeType { + x := m + return &x +} + +func Test_hostMatches(t *testing.T) { + g := NewWithT(t) + + g.Expect(hostMatches("", "a.example.com")).To(BeTrue()) + g.Expect(hostMatches("example.com", "example.com")).To(BeTrue()) + g.Expect(hostMatches("*.example.com", "sub.example.com")).To(BeTrue()) + g.Expect(hostMatches("*.example.com", "example.com")).To(BeFalse()) + g.Expect(hostMatches("foo.example.com", "bar.example.com")).To(BeFalse()) +} + +func Test_validateHTTPSListener(t *testing.T) { + g := NewWithT(t) + + // valid listener + listener := gatewayv1.Listener{ + Protocol: gatewayv1.HTTPSProtocolType, + Hostname: ptrHostname("example.com"), + TLS: &gatewayv1.GatewayTLSConfig{ + Mode: ptrTLSMode(gatewayv1.TLSModeTerminate), + }, + } + g.Expect(validateHTTPSListener(listener)).To(BeNil()) + + // missing hostname + l1 := listener + l1.Hostname = nil + g.Expect(validateHTTPSListener(l1)).ToNot(BeNil()) + + // missing TLS + l2 := listener + l2.TLS = nil + g.Expect(validateHTTPSListener(l2)).ToNot(BeNil()) + + // wrong mode + l3 := listener + l3.TLS = &gatewayv1.GatewayTLSConfig{Mode: ptrTLSMode(gatewayv1.TLSModePassthrough)} + g.Expect(validateHTTPSListener(l3)).ToNot(BeNil()) +} + +func Test_joinErrors(t *testing.T) { + g := NewWithT(t) + errs := []error{errors.New("one"), errors.New("two")} + out := joinErrors(errs) + g.Expect(out).To(ContainSubstring("one")) + g.Expect(out).To(ContainSubstring("two")) + g.Expect(out).To(ContainSubstring("- ")) +} + +func TestIsRouteAttachedToGateway(t *testing.T) { + g := NewWithT(t) + + route := &gatewayv1.HTTPRoute{} + route.ObjectMeta.SetNamespace("routes-ns") + route.Spec.ParentRefs = []gatewayv1.ParentReference{ + { + Name: gatewayv1.ObjectName("gw1"), + }, + } + gw := &gatewayv1.Gateway{} + gw.ObjectMeta.SetName("gw1") + gw.ObjectMeta.SetNamespace("routes-ns") + g.Expect(isRouteAttachedToGateway(route, gw)).To(BeTrue()) + + route2 := route.DeepCopy() + route2.Spec.ParentRefs = []gatewayv1.ParentReference{ + { + Name: gatewayv1.ObjectName("gw1"), + Namespace: func() *gatewayv1.Namespace { n := gatewayv1.Namespace("other-ns"); return &n }(), + }, + } + g.Expect(isRouteAttachedToGateway(route2, gw)).To(BeFalse()) +} + +func ListenerInfoForTest() types.ListenerInfo { + return types.ListenerInfo{ + Name: "l", + Hostname: "", + Protocol: "HTTP", + Port: 80, + AllowedFrom: "Same", + Selector: map[string]string{}, + } +} + +func TestIsRouteNamespaceAllowed(t *testing.T) { + g := NewWithT(t) + + // allowedFrom = All + listener := ListenerInfoForTest() + listener.AllowedFrom = "All" + g.Expect(isRouteNamespaceAllowed(listener, "a", "b", nil)).To(BeTrue()) + + // allowedFrom = Same + listener = ListenerInfoForTest() + listener.AllowedFrom = "Same" + g.Expect(isRouteNamespaceAllowed(listener, "ns1", "ns1", nil)).To(BeTrue()) + g.Expect(isRouteNamespaceAllowed(listener, "ns1", "ns2", nil)).To(BeFalse()) + + // allowedFrom = Selector + listener = ListenerInfoForTest() + listener.AllowedFrom = "Selector" + listener.Selector = map[string]string{"team": "alpha"} + nsLabels := map[string]string{"team": "alpha"} + g.Expect(isRouteNamespaceAllowed(listener, "x", "y", nsLabels)).To(BeTrue()) + + nsLabels = map[string]string{"team": "beta"} + g.Expect(isRouteNamespaceAllowed(listener, "x", "y", nsLabels)).To(BeFalse()) +} diff --git a/internal/mocks/collection.go b/internal/mocks/collection.go new file mode 100644 index 0000000..136cd4d --- /dev/null +++ b/internal/mocks/collection.go @@ -0,0 +1,258 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./vendor/github.com/serverscom/serverscom-go-client/pkg/collection.go +// +// Generated by this command: +// +// mockgen --destination ./internal/mocks/collection.go --package=mocks --source ./vendor/github.com/serverscom/serverscom-go-client/pkg/collection.go +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + serverscom "github.com/serverscom/serverscom-go-client/pkg" + + gomock "go.uber.org/mock/gomock" +) + +// MockCollection is a mock of Collection interface. +type MockCollection[K any] struct { + ctrl *gomock.Controller + recorder *MockCollectionMockRecorder[K] + isgomock struct{} +} + +// MockCollectionMockRecorder is the mock recorder for MockCollection. +type MockCollectionMockRecorder[K any] struct { + mock *MockCollection[K] +} + +// NewMockCollection creates a new mock instance. +func NewMockCollection[K any](ctrl *gomock.Controller) *MockCollection[K] { + mock := &MockCollection[K]{ctrl: ctrl} + mock.recorder = &MockCollectionMockRecorder[K]{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCollection[K]) EXPECT() *MockCollectionMockRecorder[K] { + return m.recorder +} + +// Collect mocks base method. +func (m *MockCollection[K]) Collect(ctx context.Context) ([]K, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Collect", ctx) + ret0, _ := ret[0].([]K) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Collect indicates an expected call of Collect. +func (mr *MockCollectionMockRecorder[K]) Collect(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Collect", reflect.TypeOf((*MockCollection[K])(nil).Collect), ctx) +} + +// FirstPage mocks base method. +func (m *MockCollection[K]) FirstPage(ctx context.Context) ([]K, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FirstPage", ctx) + ret0, _ := ret[0].([]K) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FirstPage indicates an expected call of FirstPage. +func (mr *MockCollectionMockRecorder[K]) FirstPage(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FirstPage", reflect.TypeOf((*MockCollection[K])(nil).FirstPage), ctx) +} + +// HasFirstPage mocks base method. +func (m *MockCollection[K]) HasFirstPage() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasFirstPage") + ret0, _ := ret[0].(bool) + return ret0 +} + +// HasFirstPage indicates an expected call of HasFirstPage. +func (mr *MockCollectionMockRecorder[K]) HasFirstPage() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasFirstPage", reflect.TypeOf((*MockCollection[K])(nil).HasFirstPage)) +} + +// HasLastPage mocks base method. +func (m *MockCollection[K]) HasLastPage() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasLastPage") + ret0, _ := ret[0].(bool) + return ret0 +} + +// HasLastPage indicates an expected call of HasLastPage. +func (mr *MockCollectionMockRecorder[K]) HasLastPage() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasLastPage", reflect.TypeOf((*MockCollection[K])(nil).HasLastPage)) +} + +// HasNextPage mocks base method. +func (m *MockCollection[K]) HasNextPage() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasNextPage") + ret0, _ := ret[0].(bool) + return ret0 +} + +// HasNextPage indicates an expected call of HasNextPage. +func (mr *MockCollectionMockRecorder[K]) HasNextPage() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasNextPage", reflect.TypeOf((*MockCollection[K])(nil).HasNextPage)) +} + +// HasPreviousPage mocks base method. +func (m *MockCollection[K]) HasPreviousPage() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasPreviousPage") + ret0, _ := ret[0].(bool) + return ret0 +} + +// HasPreviousPage indicates an expected call of HasPreviousPage. +func (mr *MockCollectionMockRecorder[K]) HasPreviousPage() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPreviousPage", reflect.TypeOf((*MockCollection[K])(nil).HasPreviousPage)) +} + +// IsClean mocks base method. +func (m *MockCollection[K]) IsClean() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsClean") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsClean indicates an expected call of IsClean. +func (mr *MockCollectionMockRecorder[K]) IsClean() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsClean", reflect.TypeOf((*MockCollection[K])(nil).IsClean)) +} + +// LastPage mocks base method. +func (m *MockCollection[K]) LastPage(ctx context.Context) ([]K, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LastPage", ctx) + ret0, _ := ret[0].([]K) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LastPage indicates an expected call of LastPage. +func (mr *MockCollectionMockRecorder[K]) LastPage(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LastPage", reflect.TypeOf((*MockCollection[K])(nil).LastPage), ctx) +} + +// List mocks base method. +func (m *MockCollection[K]) List(ctx context.Context) ([]K, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", ctx) + ret0, _ := ret[0].([]K) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockCollectionMockRecorder[K]) List(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockCollection[K])(nil).List), ctx) +} + +// NextPage mocks base method. +func (m *MockCollection[K]) NextPage(ctx context.Context) ([]K, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NextPage", ctx) + ret0, _ := ret[0].([]K) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NextPage indicates an expected call of NextPage. +func (mr *MockCollectionMockRecorder[K]) NextPage(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeOf((*MockCollection[K])(nil).NextPage), ctx) +} + +// PreviousPage mocks base method. +func (m *MockCollection[K]) PreviousPage(ctx context.Context) ([]K, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PreviousPage", ctx) + ret0, _ := ret[0].([]K) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PreviousPage indicates an expected call of PreviousPage. +func (mr *MockCollectionMockRecorder[K]) PreviousPage(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreviousPage", reflect.TypeOf((*MockCollection[K])(nil).PreviousPage), ctx) +} + +// Refresh mocks base method. +func (m *MockCollection[K]) Refresh(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Refresh", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// Refresh indicates an expected call of Refresh. +func (mr *MockCollectionMockRecorder[K]) Refresh(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Refresh", reflect.TypeOf((*MockCollection[K])(nil).Refresh), ctx) +} + +// SetPage mocks base method. +func (m *MockCollection[K]) SetPage(page int) serverscom.Collection[K] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetPage", page) + ret0, _ := ret[0].(serverscom.Collection[K]) + return ret0 +} + +// SetPage indicates an expected call of SetPage. +func (mr *MockCollectionMockRecorder[K]) SetPage(page any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPage", reflect.TypeOf((*MockCollection[K])(nil).SetPage), page) +} + +// SetParam mocks base method. +func (m *MockCollection[K]) SetParam(name, value string) serverscom.Collection[K] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetParam", name, value) + ret0, _ := ret[0].(serverscom.Collection[K]) + return ret0 +} + +// SetParam indicates an expected call of SetParam. +func (mr *MockCollectionMockRecorder[K]) SetParam(name, value any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetParam", reflect.TypeOf((*MockCollection[K])(nil).SetParam), name, value) +} + +// SetPerPage mocks base method. +func (m *MockCollection[K]) SetPerPage(perPage int) serverscom.Collection[K] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetPerPage", perPage) + ret0, _ := ret[0].(serverscom.Collection[K]) + return ret0 +} + +// SetPerPage indicates an expected call of SetPerPage. +func (mr *MockCollectionMockRecorder[K]) SetPerPage(perPage any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPerPage", reflect.TypeOf((*MockCollection[K])(nil).SetPerPage), perPage) +} diff --git a/internal/mocks/lb_manager.go b/internal/mocks/lb_manager.go new file mode 100644 index 0000000..e48724b --- /dev/null +++ b/internal/mocks/lb_manager.go @@ -0,0 +1,72 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: manager.go +// +// Generated by this command: +// +// mockgen --destination ../../mocks/lb_manager.go --package=mocks --source manager.go +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + types "github.com/serverscom/api-gateway-controller/internal/types" + serverscom "github.com/serverscom/serverscom-go-client/pkg" + gomock "go.uber.org/mock/gomock" +) + +// MockLBManagerInterface is a mock of LBManagerInterface interface. +type MockLBManagerInterface struct { + ctrl *gomock.Controller + recorder *MockLBManagerInterfaceMockRecorder + isgomock struct{} +} + +// MockLBManagerInterfaceMockRecorder is the mock recorder for MockLBManagerInterface. +type MockLBManagerInterfaceMockRecorder struct { + mock *MockLBManagerInterface +} + +// NewMockLBManagerInterface creates a new mock instance. +func NewMockLBManagerInterface(ctrl *gomock.Controller) *MockLBManagerInterface { + mock := &MockLBManagerInterface{ctrl: ctrl} + mock.recorder = &MockLBManagerInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockLBManagerInterface) EXPECT() *MockLBManagerInterfaceMockRecorder { + return m.recorder +} + +// DeleteLB mocks base method. +func (m *MockLBManagerInterface) DeleteLB(ctx context.Context, labelSelector string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteLB", ctx, labelSelector) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteLB indicates an expected call of DeleteLB. +func (mr *MockLBManagerInterfaceMockRecorder) DeleteLB(ctx, labelSelector any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteLB", reflect.TypeOf((*MockLBManagerInterface)(nil).DeleteLB), ctx, labelSelector) +} + +// EnsureLB mocks base method. +func (m *MockLBManagerInterface) EnsureLB(ctx context.Context, gwInfo *types.GatewayInfo, hostCertMap map[string]string) (*serverscom.L7LoadBalancer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EnsureLB", ctx, gwInfo, hostCertMap) + ret0, _ := ret[0].(*serverscom.L7LoadBalancer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// EnsureLB indicates an expected call of EnsureLB. +func (mr *MockLBManagerInterfaceMockRecorder) EnsureLB(ctx, gwInfo, hostCertMap any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnsureLB", reflect.TypeOf((*MockLBManagerInterface)(nil).EnsureLB), ctx, gwInfo, hostCertMap) +} diff --git a/internal/mocks/lb_service.go b/internal/mocks/lb_service.go new file mode 100644 index 0000000..cb28d4f --- /dev/null +++ b/internal/mocks/lb_service.go @@ -0,0 +1,174 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./vendor/github.com/serverscom/serverscom-go-client/pkg/load_balancers.go +// +// Generated by this command: +// +// mockgen --destination ./internal/mocks/lb_service.go --package=mocks --source ./vendor/github.com/serverscom/serverscom-go-client/pkg/load_balancers.go +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + serverscom "github.com/serverscom/serverscom-go-client/pkg" + + gomock "go.uber.org/mock/gomock" +) + +// MockLoadBalancersService is a mock of LoadBalancersService interface. +type MockLoadBalancersService struct { + ctrl *gomock.Controller + recorder *MockLoadBalancersServiceMockRecorder + isgomock struct{} +} + +// MockLoadBalancersServiceMockRecorder is the mock recorder for MockLoadBalancersService. +type MockLoadBalancersServiceMockRecorder struct { + mock *MockLoadBalancersService +} + +// NewMockLoadBalancersService creates a new mock instance. +func NewMockLoadBalancersService(ctrl *gomock.Controller) *MockLoadBalancersService { + mock := &MockLoadBalancersService{ctrl: ctrl} + mock.recorder = &MockLoadBalancersServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockLoadBalancersService) EXPECT() *MockLoadBalancersServiceMockRecorder { + return m.recorder +} + +// Collection mocks base method. +func (m *MockLoadBalancersService) Collection() serverscom.Collection[serverscom.LoadBalancer] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Collection") + ret0, _ := ret[0].(serverscom.Collection[serverscom.LoadBalancer]) + return ret0 +} + +// Collection indicates an expected call of Collection. +func (mr *MockLoadBalancersServiceMockRecorder) Collection() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Collection", reflect.TypeOf((*MockLoadBalancersService)(nil).Collection)) +} + +// CreateL4LoadBalancer mocks base method. +func (m *MockLoadBalancersService) CreateL4LoadBalancer(ctx context.Context, input serverscom.L4LoadBalancerCreateInput) (*serverscom.L4LoadBalancer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateL4LoadBalancer", ctx, input) + ret0, _ := ret[0].(*serverscom.L4LoadBalancer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateL4LoadBalancer indicates an expected call of CreateL4LoadBalancer. +func (mr *MockLoadBalancersServiceMockRecorder) CreateL4LoadBalancer(ctx, input any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateL4LoadBalancer", reflect.TypeOf((*MockLoadBalancersService)(nil).CreateL4LoadBalancer), ctx, input) +} + +// CreateL7LoadBalancer mocks base method. +func (m *MockLoadBalancersService) CreateL7LoadBalancer(ctx context.Context, input serverscom.L7LoadBalancerCreateInput) (*serverscom.L7LoadBalancer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateL7LoadBalancer", ctx, input) + ret0, _ := ret[0].(*serverscom.L7LoadBalancer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateL7LoadBalancer indicates an expected call of CreateL7LoadBalancer. +func (mr *MockLoadBalancersServiceMockRecorder) CreateL7LoadBalancer(ctx, input any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateL7LoadBalancer", reflect.TypeOf((*MockLoadBalancersService)(nil).CreateL7LoadBalancer), ctx, input) +} + +// DeleteL4LoadBalancer mocks base method. +func (m *MockLoadBalancersService) DeleteL4LoadBalancer(ctx context.Context, id string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteL4LoadBalancer", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteL4LoadBalancer indicates an expected call of DeleteL4LoadBalancer. +func (mr *MockLoadBalancersServiceMockRecorder) DeleteL4LoadBalancer(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteL4LoadBalancer", reflect.TypeOf((*MockLoadBalancersService)(nil).DeleteL4LoadBalancer), ctx, id) +} + +// DeleteL7LoadBalancer mocks base method. +func (m *MockLoadBalancersService) DeleteL7LoadBalancer(ctx context.Context, id string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteL7LoadBalancer", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteL7LoadBalancer indicates an expected call of DeleteL7LoadBalancer. +func (mr *MockLoadBalancersServiceMockRecorder) DeleteL7LoadBalancer(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteL7LoadBalancer", reflect.TypeOf((*MockLoadBalancersService)(nil).DeleteL7LoadBalancer), ctx, id) +} + +// GetL4LoadBalancer mocks base method. +func (m *MockLoadBalancersService) GetL4LoadBalancer(ctx context.Context, id string) (*serverscom.L4LoadBalancer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetL4LoadBalancer", ctx, id) + ret0, _ := ret[0].(*serverscom.L4LoadBalancer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetL4LoadBalancer indicates an expected call of GetL4LoadBalancer. +func (mr *MockLoadBalancersServiceMockRecorder) GetL4LoadBalancer(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetL4LoadBalancer", reflect.TypeOf((*MockLoadBalancersService)(nil).GetL4LoadBalancer), ctx, id) +} + +// GetL7LoadBalancer mocks base method. +func (m *MockLoadBalancersService) GetL7LoadBalancer(ctx context.Context, id string) (*serverscom.L7LoadBalancer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetL7LoadBalancer", ctx, id) + ret0, _ := ret[0].(*serverscom.L7LoadBalancer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetL7LoadBalancer indicates an expected call of GetL7LoadBalancer. +func (mr *MockLoadBalancersServiceMockRecorder) GetL7LoadBalancer(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetL7LoadBalancer", reflect.TypeOf((*MockLoadBalancersService)(nil).GetL7LoadBalancer), ctx, id) +} + +// UpdateL4LoadBalancer mocks base method. +func (m *MockLoadBalancersService) UpdateL4LoadBalancer(ctx context.Context, id string, input serverscom.L4LoadBalancerUpdateInput) (*serverscom.L4LoadBalancer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateL4LoadBalancer", ctx, id, input) + ret0, _ := ret[0].(*serverscom.L4LoadBalancer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateL4LoadBalancer indicates an expected call of UpdateL4LoadBalancer. +func (mr *MockLoadBalancersServiceMockRecorder) UpdateL4LoadBalancer(ctx, id, input any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateL4LoadBalancer", reflect.TypeOf((*MockLoadBalancersService)(nil).UpdateL4LoadBalancer), ctx, id, input) +} + +// UpdateL7LoadBalancer mocks base method. +func (m *MockLoadBalancersService) UpdateL7LoadBalancer(ctx context.Context, id string, input serverscom.L7LoadBalancerUpdateInput) (*serverscom.L7LoadBalancer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateL7LoadBalancer", ctx, id, input) + ret0, _ := ret[0].(*serverscom.L7LoadBalancer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateL7LoadBalancer indicates an expected call of UpdateL7LoadBalancer. +func (mr *MockLoadBalancersServiceMockRecorder) UpdateL7LoadBalancer(ctx, id, input any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateL7LoadBalancer", reflect.TypeOf((*MockLoadBalancersService)(nil).UpdateL7LoadBalancer), ctx, id, input) +} diff --git a/internal/mocks/ssl_service.go b/internal/mocks/ssl_service.go new file mode 100644 index 0000000..cd1b48c --- /dev/null +++ b/internal/mocks/ssl_service.go @@ -0,0 +1,159 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./vendor/github.com/serverscom/serverscom-go-client/pkg/ssl_certificates.go +// +// Generated by this command: +// +// mockgen --destination ./internal/mocks/ssl_service.go --package=mocks --source ./vendor/github.com/serverscom/serverscom-go-client/pkg/ssl_certificates.go +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + serverscom "github.com/serverscom/serverscom-go-client/pkg" + + gomock "go.uber.org/mock/gomock" +) + +// MockSSLCertificatesService is a mock of SSLCertificatesService interface. +type MockSSLCertificatesService struct { + ctrl *gomock.Controller + recorder *MockSSLCertificatesServiceMockRecorder + isgomock struct{} +} + +// MockSSLCertificatesServiceMockRecorder is the mock recorder for MockSSLCertificatesService. +type MockSSLCertificatesServiceMockRecorder struct { + mock *MockSSLCertificatesService +} + +// NewMockSSLCertificatesService creates a new mock instance. +func NewMockSSLCertificatesService(ctrl *gomock.Controller) *MockSSLCertificatesService { + mock := &MockSSLCertificatesService{ctrl: ctrl} + mock.recorder = &MockSSLCertificatesServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSSLCertificatesService) EXPECT() *MockSSLCertificatesServiceMockRecorder { + return m.recorder +} + +// Collection mocks base method. +func (m *MockSSLCertificatesService) Collection() serverscom.Collection[serverscom.SSLCertificate] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Collection") + ret0, _ := ret[0].(serverscom.Collection[serverscom.SSLCertificate]) + return ret0 +} + +// Collection indicates an expected call of Collection. +func (mr *MockSSLCertificatesServiceMockRecorder) Collection() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Collection", reflect.TypeOf((*MockSSLCertificatesService)(nil).Collection)) +} + +// CreateCustom mocks base method. +func (m *MockSSLCertificatesService) CreateCustom(ctx context.Context, input serverscom.SSLCertificateCreateCustomInput) (*serverscom.SSLCertificateCustom, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateCustom", ctx, input) + ret0, _ := ret[0].(*serverscom.SSLCertificateCustom) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateCustom indicates an expected call of CreateCustom. +func (mr *MockSSLCertificatesServiceMockRecorder) CreateCustom(ctx, input any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCustom", reflect.TypeOf((*MockSSLCertificatesService)(nil).CreateCustom), ctx, input) +} + +// DeleteCustom mocks base method. +func (m *MockSSLCertificatesService) DeleteCustom(ctx context.Context, id string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteCustom", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteCustom indicates an expected call of DeleteCustom. +func (mr *MockSSLCertificatesServiceMockRecorder) DeleteCustom(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCustom", reflect.TypeOf((*MockSSLCertificatesService)(nil).DeleteCustom), ctx, id) +} + +// DeleteLE mocks base method. +func (m *MockSSLCertificatesService) DeleteLE(ctx context.Context, id string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteLE", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteLE indicates an expected call of DeleteLE. +func (mr *MockSSLCertificatesServiceMockRecorder) DeleteLE(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteLE", reflect.TypeOf((*MockSSLCertificatesService)(nil).DeleteLE), ctx, id) +} + +// GetCustom mocks base method. +func (m *MockSSLCertificatesService) GetCustom(ctx context.Context, id string) (*serverscom.SSLCertificateCustom, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCustom", ctx, id) + ret0, _ := ret[0].(*serverscom.SSLCertificateCustom) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCustom indicates an expected call of GetCustom. +func (mr *MockSSLCertificatesServiceMockRecorder) GetCustom(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCustom", reflect.TypeOf((*MockSSLCertificatesService)(nil).GetCustom), ctx, id) +} + +// GetLE mocks base method. +func (m *MockSSLCertificatesService) GetLE(ctx context.Context, id string) (*serverscom.SSLCertificateLE, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLE", ctx, id) + ret0, _ := ret[0].(*serverscom.SSLCertificateLE) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLE indicates an expected call of GetLE. +func (mr *MockSSLCertificatesServiceMockRecorder) GetLE(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLE", reflect.TypeOf((*MockSSLCertificatesService)(nil).GetLE), ctx, id) +} + +// UpdateCustom mocks base method. +func (m *MockSSLCertificatesService) UpdateCustom(ctx context.Context, id string, input serverscom.SSLCertificateUpdateCustomInput) (*serverscom.SSLCertificateCustom, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCustom", ctx, id, input) + ret0, _ := ret[0].(*serverscom.SSLCertificateCustom) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateCustom indicates an expected call of UpdateCustom. +func (mr *MockSSLCertificatesServiceMockRecorder) UpdateCustom(ctx, id, input any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCustom", reflect.TypeOf((*MockSSLCertificatesService)(nil).UpdateCustom), ctx, id, input) +} + +// UpdateLE mocks base method. +func (m *MockSSLCertificatesService) UpdateLE(ctx context.Context, id string, input serverscom.SSLCertificateUpdateLEInput) (*serverscom.SSLCertificateLE, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateLE", ctx, id, input) + ret0, _ := ret[0].(*serverscom.SSLCertificateLE) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateLE indicates an expected call of UpdateLE. +func (mr *MockSSLCertificatesServiceMockRecorder) UpdateLE(ctx, id, input any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLE", reflect.TypeOf((*MockSSLCertificatesService)(nil).UpdateLE), ctx, id, input) +} diff --git a/internal/mocks/tls_manager.go b/internal/mocks/tls_manager.go new file mode 100644 index 0000000..5d3ac99 --- /dev/null +++ b/internal/mocks/tls_manager.go @@ -0,0 +1,57 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: manager.go +// +// Generated by this command: +// +// mockgen --destination ../../mocks/tls_manager.go --package=mocks --source manager.go +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + types "github.com/serverscom/api-gateway-controller/internal/types" + gomock "go.uber.org/mock/gomock" +) + +// MockTLSManagerInterface is a mock of TLSManagerInterface interface. +type MockTLSManagerInterface struct { + ctrl *gomock.Controller + recorder *MockTLSManagerInterfaceMockRecorder + isgomock struct{} +} + +// MockTLSManagerInterfaceMockRecorder is the mock recorder for MockTLSManagerInterface. +type MockTLSManagerInterfaceMockRecorder struct { + mock *MockTLSManagerInterface +} + +// NewMockTLSManagerInterface creates a new mock instance. +func NewMockTLSManagerInterface(ctrl *gomock.Controller) *MockTLSManagerInterface { + mock := &MockTLSManagerInterface{ctrl: ctrl} + mock.recorder = &MockTLSManagerInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTLSManagerInterface) EXPECT() *MockTLSManagerInterfaceMockRecorder { + return m.recorder +} + +// EnsureTLS mocks base method. +func (m *MockTLSManagerInterface) EnsureTLS(ctx context.Context, tlsInfo map[string]types.TLSConfigInfo) (map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EnsureTLS", ctx, tlsInfo) + ret0, _ := ret[0].(map[string]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// EnsureTLS indicates an expected call of EnsureTLS. +func (mr *MockTLSManagerInterfaceMockRecorder) EnsureTLS(ctx, tlsInfo any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnsureTLS", reflect.TypeOf((*MockTLSManagerInterface)(nil).EnsureTLS), ctx, tlsInfo) +} diff --git a/internal/service/lb/helpers.go b/internal/service/lb/helpers.go new file mode 100644 index 0000000..c3874e7 --- /dev/null +++ b/internal/service/lb/helpers.go @@ -0,0 +1,94 @@ +package lbsrv + +import ( + "fmt" + "strconv" + "strings" + + "github.com/serverscom/api-gateway-controller/internal/config" + "github.com/serverscom/api-gateway-controller/internal/types" + + serverscom "github.com/serverscom/serverscom-go-client/pkg" +) + +// translateGatewayToLBInput translates gateway based on gateway info and tlsInfo info into LB L7 create input +func translateGatewayToLBInput(gwInfo *types.GatewayInfo, tlsInfo map[string]string) (*serverscom.L7LoadBalancerCreateInput, error) { + upstreamMap := make(map[string]serverscom.L7UpstreamZoneInput) + var vhostZones []serverscom.L7VHostZoneInput + + for host, vh := range gwInfo.VHosts { + sslEnabled := vh.SSL + sslId := "" + if sslEnabled { + if id, ok := tlsInfo[host]; ok { + sslId = id + } + } + locationZones := []serverscom.L7LocationZoneInput{} + for _, p := range vh.Paths { + upstreamId := fmt.Sprintf("upstream-zone-%s-%d", p.Service.Name, p.NodePort) + locationZones = append(locationZones, serverscom.L7LocationZoneInput{ + Location: p.Path, + UpstreamID: upstreamId, + }) + if _, ok := upstreamMap[upstreamId]; !ok { + var ups []serverscom.L7UpstreamInput + for _, ip := range p.NodeIps { + ups = append(ups, serverscom.L7UpstreamInput{ + IP: ip, + Port: int32(p.NodePort), + Weight: 1, + }) + } + upstreamMap[upstreamId] = serverscom.L7UpstreamZoneInput{ + ID: upstreamId, + Upstreams: ups, + } + } + } + if len(vh.Ports) == 0 || len(locationZones) == 0 { + continue + } + vhostZones = append(vhostZones, serverscom.L7VHostZoneInput{ + ID: fmt.Sprintf("vhost-zone-%s", host), + Domains: []string{host}, + SSLCertID: sslId, + SSL: sslEnabled, + Ports: vh.Ports, + LocationZones: locationZones, + }) + } + + var upstreamZones []serverscom.L7UpstreamZoneInput + for _, u := range upstreamMap { + upstreamZones = append(upstreamZones, u) + } + if len(vhostZones) == 0 || len(upstreamZones) == 0 { + return nil, fmt.Errorf("vhost or upstream can't be empty, can't continue") + } + locIdStr := config.FetchEnv("SC_LOCATION_ID", "1") + locId, err := strconv.Atoi(locIdStr) + if err != nil { + locId = 1 + } + lbInput := &serverscom.L7LoadBalancerCreateInput{ + Name: getLoadBalancerName(gwInfo.UID), + LocationID: int64(locId), + UpstreamZones: upstreamZones, + VHostZones: vhostZones, + Labels: map[string]string{ + config.GW_LABEL_ID: gwInfo.UID, + }, + } + return lbInput, err +} + +// GetLoadBalancerName compose a load balancer name from uid +func getLoadBalancerName(uid string) string { + ret := "a" + uid + ret = strings.Replace(ret, "-", "", -1) + if len(ret) > 32 { + ret = ret[:32] + } + return fmt.Sprintf("gw-%s", ret) +} diff --git a/internal/service/lb/manager.go b/internal/service/lb/manager.go new file mode 100644 index 0000000..5c622cf --- /dev/null +++ b/internal/service/lb/manager.go @@ -0,0 +1,99 @@ +package lbsrv + +import ( + "context" + "fmt" + "strings" + + "github.com/serverscom/api-gateway-controller/internal/config" + "github.com/serverscom/api-gateway-controller/internal/types" + "github.com/serverscom/api-gateway-controller/internal/utils" + + serverscom "github.com/serverscom/serverscom-go-client/pkg" +) + +//go:generate mockgen --destination ../../mocks/lb_manager.go --package=mocks --source manager.go + +type LBManagerInterface interface { + EnsureLB(ctx context.Context, gwInfo *types.GatewayInfo, hostCertMap map[string]string) (*serverscom.L7LoadBalancer, error) + DeleteLB(ctx context.Context, labelSelector string) error +} + +type Manager struct { + scCli *serverscom.Client +} + +func NewManager(c *serverscom.Client) *Manager { + return &Manager{scCli: c} +} + +// EnsureLB ensures a load balancer exists for the given GatewayInfo. +// It creates, updates, or returns existing LB status. +// hostCertMap contains external cert id for specific hosts. +func (s *Manager) EnsureLB(ctx context.Context, gwInfo *types.GatewayInfo, hostCertMap map[string]string) (*serverscom.L7LoadBalancer, error) { + labelSelector := config.GW_LABEL_ID + "=" + gwInfo.UID + lbs, err := s.getL7LoadBalancersByLabel(ctx, labelSelector) + if err != nil { + return nil, err + } + if len(lbs) == 0 { + // create lb + lbInput, err := translateGatewayToLBInput(gwInfo, hostCertMap) + if err != nil { + return nil, err + } + return s.scCli.LoadBalancers.CreateL7LoadBalancer(ctx, *lbInput) + } + if len(lbs) > 1 { + return nil, fmt.Errorf("found more than one lb with same label") + } + // if not active yet, just return status to reconcile again + lb := lbs[0] + if !strings.EqualFold(lb.Status, config.LB_ACTIVE_STATUS) { + lbl7 := &serverscom.L7LoadBalancer{ + Status: lb.Status, + } + return lbl7, nil + } + // update lb + lbInput, err := translateGatewayToLBInput(gwInfo, hostCertMap) + if err != nil { + return nil, err + } + + lbUpdateInput := serverscom.L7LoadBalancerUpdateInput{ + Name: lbInput.Name, + StoreLogs: lbInput.StoreLogs, + StoreLogsRegionID: lbInput.StoreLogsRegionID, + Geoip: lbInput.Geoip, + VHostZones: lbInput.VHostZones, + UpstreamZones: lbInput.UpstreamZones, + ClusterID: lbInput.ClusterID, + } + if lbUpdateInput.ClusterID == nil { + lbUpdateInput.SharedCluster = utils.BoolPtr(true) + } + + return s.scCli.LoadBalancers.UpdateL7LoadBalancer(ctx, lb.ID, lbUpdateInput) +} + +// DeleteLB deletes a load balancer by its label selector. +// Returns error if multiple LBs are found. +func (s *Manager) DeleteLB(ctx context.Context, labelSelector string) error { + lbs, err := s.getL7LoadBalancersByLabel(ctx, labelSelector) + if err != nil { + return utils.IgnoreNotFound(err) + } + if len(lbs) > 1 { + return fmt.Errorf("found more than one lb with same label") + } + return s.scCli.LoadBalancers.DeleteL7LoadBalancer(ctx, lbs[0].ID) +} + +// getL7LoadBalancersByLabel retrieves all L7 load balancers from provider filtered by label selector. +func (s *Manager) getL7LoadBalancersByLabel(ctx context.Context, labelSelector string) ([]serverscom.LoadBalancer, error) { + return s.scCli.LoadBalancers.Collection(). + SetParam("type", "l7"). + SetParam("label_selector", labelSelector). + Collect(ctx) +} diff --git a/internal/service/lb/manager_test.go b/internal/service/lb/manager_test.go new file mode 100644 index 0000000..3d11e2c --- /dev/null +++ b/internal/service/lb/manager_test.go @@ -0,0 +1,476 @@ +package lbsrv + +import ( + "context" + "errors" + "testing" + + . "github.com/onsi/gomega" + serverscom "github.com/serverscom/serverscom-go-client/pkg" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/serverscom/api-gateway-controller/internal/config" + "github.com/serverscom/api-gateway-controller/internal/mocks" + "github.com/serverscom/api-gateway-controller/internal/types" + + "go.uber.org/mock/gomock" +) + +func TestEnsureLB(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + lbHandler := mocks.NewMockLoadBalancersService(mockCtrl) + collectionHandler := mocks.NewMockCollection[serverscom.LoadBalancer](mockCtrl) + + client := serverscom.NewClientWithEndpoint("", "") + client.LoadBalancers = lbHandler + manager := NewManager(client) + + gwInfo := &types.GatewayInfo{ + UID: "gw-uid", + Name: "gw-name", + NS: "default", + VHosts: map[string]*types.VHostInfo{ + "example.com": { + Host: "example.com", + SSL: true, + Ports: []int32{ + 443, + }, + Paths: []types.PathInfo{ + { + Path: "/", + Service: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "svc", + }, + }, + NodePort: 8080, + NodeIps: []string{"1.1.1.1"}, + }, + }, + }, + }, + } + + tests := []struct { + name string + setupMocks func() + wantErr bool + wantID string + wantStatus string + }{ + { + name: "error on list lbs", + setupMocks: func() { + lbHandler.EXPECT(). + Collection(). + Return(collectionHandler) + collectionHandler.EXPECT(). + SetParam("type", "l7"). + Return(collectionHandler) + collectionHandler.EXPECT(). + SetParam("label_selector", config.GW_LABEL_ID+"="+gwInfo.UID). + Return(collectionHandler) + collectionHandler.EXPECT(). + Collect(gomock.Any()). + Return(nil, errors.New("list error")) + }, + wantErr: true, + }, + { + name: "create new lb", + setupMocks: func() { + lbHandler.EXPECT(). + Collection(). + Return(collectionHandler) + collectionHandler.EXPECT(). + SetParam("type", "l7"). + Return(collectionHandler) + collectionHandler.EXPECT(). + SetParam("label_selector", config.GW_LABEL_ID+"="+gwInfo.UID). + Return(collectionHandler) + collectionHandler.EXPECT(). + Collect(gomock.Any()). + Return(nil, nil) + + lbHandler.EXPECT(). + CreateL7LoadBalancer(gomock.Any(), gomock.Any()). + Return(&serverscom.L7LoadBalancer{ID: "new-lb", Status: config.LB_ACTIVE_STATUS}, nil) + }, + wantID: "new-lb", + }, + { + name: "multiple lbs found", + setupMocks: func() { + lbHandler.EXPECT(). + Collection(). + Return(collectionHandler) + collectionHandler.EXPECT(). + SetParam(gomock.Any(), gomock.Any()). + AnyTimes(). + Return(collectionHandler) + collectionHandler.EXPECT(). + Collect(gomock.Any()). + Return([]serverscom.LoadBalancer{ + {ID: "lb1"}, {ID: "lb2"}, + }, nil) + }, + wantErr: true, + }, + { + name: "lb not active yet", + setupMocks: func() { + lbHandler.EXPECT(). + Collection(). + Return(collectionHandler) + collectionHandler.EXPECT(). + SetParam(gomock.Any(), gomock.Any()). + AnyTimes(). + Return(collectionHandler) + collectionHandler.EXPECT(). + Collect(gomock.Any()). + Return([]serverscom.LoadBalancer{ + {ID: "lb1", Status: "pending"}, + }, nil) + }, + wantStatus: "pending", + }, + { + name: "update existing lb", + setupMocks: func() { + lbHandler.EXPECT(). + Collection(). + Return(collectionHandler) + collectionHandler.EXPECT(). + SetParam(gomock.Any(), gomock.Any()). + AnyTimes(). + Return(collectionHandler) + collectionHandler.EXPECT(). + Collect(gomock.Any()). + Return([]serverscom.LoadBalancer{ + {ID: "lb1", Status: config.LB_ACTIVE_STATUS}, + }, nil) + + lbHandler.EXPECT(). + UpdateL7LoadBalancer(gomock.Any(), "lb1", gomock.Any()). + Return(&serverscom.L7LoadBalancer{ID: "lb1", Status: config.LB_ACTIVE_STATUS}, nil) + }, + wantID: "lb1", + wantStatus: config.LB_ACTIVE_STATUS, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + tt.setupMocks() + + res, err := manager.EnsureLB(context.Background(), gwInfo, map[string]string{"example.com": "cert-id"}) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).To(BeNil()) + + if tt.wantID != "" { + g.Expect(res.ID).To(Equal(tt.wantID)) + } + if tt.wantStatus != "" { + g.Expect(res.Status).To(Equal(tt.wantStatus)) + } + }) + } +} + +func TestDeleteLB(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + lbHandler := mocks.NewMockLoadBalancersService(mockCtrl) + collectionHandler := mocks.NewMockCollection[serverscom.LoadBalancer](mockCtrl) + + client := serverscom.NewClientWithEndpoint("", "") + client.LoadBalancers = lbHandler + manager := NewManager(client) + + label := "gw=uid" + + tests := []struct { + name string + setupMocks func() + wantErr bool + }{ + { + name: "lb not found, ignore error", + setupMocks: func() { + lbHandler.EXPECT(). + Collection(). + Return(collectionHandler) + collectionHandler.EXPECT(). + SetParam("type", "l7"). + Return(collectionHandler) + collectionHandler.EXPECT(). + SetParam("label_selector", label). + Return(collectionHandler) + collectionHandler.EXPECT(). + Collect(gomock.Any()). + Return(nil, &serverscom.NotFoundError{Message: "Not found"}) + }, + wantErr: false, + }, + { + name: "multiple lbs found", + setupMocks: func() { + lbHandler.EXPECT(). + Collection(). + Return(collectionHandler) + collectionHandler.EXPECT(). + SetParam("type", "l7"). + Return(collectionHandler) + collectionHandler.EXPECT(). + SetParam("label_selector", label). + Return(collectionHandler) + collectionHandler.EXPECT(). + Collect(gomock.Any()). + Return([]serverscom.LoadBalancer{ + {ID: "lb1"}, {ID: "lb2"}, + }, nil) + }, + wantErr: true, + }, + { + name: "delete single lb", + setupMocks: func() { + lbHandler.EXPECT(). + Collection(). + Return(collectionHandler) + collectionHandler.EXPECT(). + SetParam("type", "l7"). + Return(collectionHandler) + collectionHandler.EXPECT(). + SetParam("label_selector", label). + Return(collectionHandler) + collectionHandler.EXPECT(). + Collect(gomock.Any()). + Return([]serverscom.LoadBalancer{ + {ID: "lb1"}, + }, nil) + lbHandler.EXPECT(). + DeleteL7LoadBalancer(gomock.Any(), "lb1"). + Return(nil) + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + tt.setupMocks() + err := manager.DeleteLB(context.Background(), label) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + } else { + g.Expect(err).To(BeNil()) + } + }) + } +} + +func TestTranslateGatewayToLBInput(t *testing.T) { + g := NewWithT(t) + + certMap := map[string]string{ + "example.com": "cert-id", + } + + tests := []struct { + name string + gwInfo *types.GatewayInfo + hostCerts map[string]string + wantErr bool + verify func(lbInput *serverscom.L7LoadBalancerCreateInput) + }{ + { + name: "single vhost, single path, SSL on", + gwInfo: &types.GatewayInfo{ + UID: "gw1", + VHosts: map[string]*types.VHostInfo{ + "example.com": { + Host: "example.com", + SSL: true, + Ports: []int32{443}, + Paths: []types.PathInfo{ + { + Path: "/", + Service: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svc1"}, + }, + NodePort: 8080, + NodeIps: []string{"1.1.1.1"}, + }, + }, + }, + }, + }, + hostCerts: certMap, + verify: func(lbInput *serverscom.L7LoadBalancerCreateInput) { + g.Expect(len(lbInput.VHostZones)).To(Equal(1)) + vh := lbInput.VHostZones[0] + g.Expect(vh.SSL).To(BeTrue()) + g.Expect(vh.SSLCertID).To(Equal("cert-id")) + g.Expect(len(lbInput.UpstreamZones)).To(Equal(1)) + }, + }, + { + name: "single vhost, multiple paths", + gwInfo: &types.GatewayInfo{ + UID: "gw2", + VHosts: map[string]*types.VHostInfo{ + "example.org": { + Host: "example.org", + SSL: false, + Ports: []int32{80}, + Paths: []types.PathInfo{ + { + Path: "/api", + Service: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svc2"}, + }, + NodePort: 8081, + NodeIps: []string{"2.2.2.2"}, + }, + { + Path: "/web", + Service: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svc3"}, + }, + NodePort: 8082, + NodeIps: []string{"3.3.3.3"}, + }, + }, + }, + }, + }, + hostCerts: nil, + verify: func(lbInput *serverscom.L7LoadBalancerCreateInput) { + g.Expect(len(lbInput.VHostZones)).To(Equal(1)) + g.Expect(len(lbInput.VHostZones[0].LocationZones)).To(Equal(2)) + g.Expect(len(lbInput.UpstreamZones)).To(Equal(2)) + }, + }, + { + name: "multiple vhosts", + gwInfo: &types.GatewayInfo{ + UID: "gw3", + VHosts: map[string]*types.VHostInfo{ + "a.com": { + Host: "a.com", + SSL: true, + Ports: []int32{443}, + Paths: []types.PathInfo{ + { + Path: "/", + Service: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svcA"}, + }, + NodePort: 8080, + NodeIps: []string{"1.1.1.1"}, + }, + }, + }, + "b.com": { + Host: "b.com", + SSL: false, + Ports: []int32{80}, + Paths: []types.PathInfo{ + { + Path: "/", + Service: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svcB"}, + }, + NodePort: 8081, + NodeIps: []string{"2.2.2.2"}, + }, + }, + }, + }, + }, + hostCerts: map[string]string{"a.com": "cert-a"}, + verify: func(lbInput *serverscom.L7LoadBalancerCreateInput) { + g.Expect(len(lbInput.VHostZones)).To(Equal(2)) + for _, vh := range lbInput.VHostZones { + if vh.Domains[0] == "a.com" { + g.Expect(vh.SSLCertID).To(Equal("cert-a")) + } else { + g.Expect(vh.SSLCertID).To(Equal("")) + } + } + g.Expect(len(lbInput.UpstreamZones)).To(Equal(2)) + }, + }, + { + name: "empty ports/paths", + gwInfo: &types.GatewayInfo{ + UID: "gw4", + VHosts: map[string]*types.VHostInfo{ + "empty.com": { + Host: "empty.com", + SSL: false, + Ports: []int32{}, + Paths: []types.PathInfo{}, + }, + }, + }, + hostCerts: nil, + wantErr: true, + }, + { + name: "SSL enabled but no cert in hostCerts", + gwInfo: &types.GatewayInfo{ + UID: "gw5", + VHosts: map[string]*types.VHostInfo{ + "nocert.com": { + Host: "nocert.com", + SSL: true, + Ports: []int32{443}, + Paths: []types.PathInfo{ + { + Path: "/", + Service: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svc"}, + }, + NodePort: 8080, + NodeIps: []string{"1.1.1.1"}, + }, + }, + }, + }, + }, + hostCerts: map[string]string{}, + verify: func(lbInput *serverscom.L7LoadBalancerCreateInput) { + g.Expect(lbInput.VHostZones[0].SSL).To(BeTrue()) + g.Expect(lbInput.VHostZones[0].SSLCertID).To(Equal("")) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lbInput, err := translateGatewayToLBInput(tt.gwInfo, tt.hostCerts) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).To(BeNil()) + if tt.verify != nil { + tt.verify(lbInput) + } + g.Expect(lbInput.Labels[config.GW_LABEL_ID]).To(Equal(tt.gwInfo.UID)) + }) + } +} diff --git a/internal/service/tls/helpers.go b/internal/service/tls/helpers.go new file mode 100644 index 0000000..bf74db0 --- /dev/null +++ b/internal/service/tls/helpers.go @@ -0,0 +1,132 @@ +package tlssrv + +import ( + "crypto/sha1" + "crypto/x509" + "encoding/pem" + "fmt" + "strings" + + serverscom "github.com/serverscom/serverscom-go-client/pkg" +) + +// CustomToSSLCertificate converts a serverscom SSLCertificateCustom to serverscom SSLCertificate +func customToSSLCertificate(custom *serverscom.SSLCertificateCustom) *serverscom.SSLCertificate { + return &serverscom.SSLCertificate{ + ID: custom.ID, + Name: custom.Name, + Sha1Fingerprint: custom.Sha1Fingerprint, + Labels: custom.Labels, + Expires: custom.Expires, + Created: custom.Created, + Updated: custom.Updated, + } +} + +// GetPemFingerprint returns sha1 fingerprint from cert +func getPemFingerprint(crt []byte) string { + cert := findCertificate(stripSpaces(crt)) + + if cert != nil { + sha := sha1.Sum(cert) + return fmt.Sprintf("%x", sha) + } else { + return "" + } +} + +// ValidateCertificate validates that certificate is valid +func validateCertificate(crt []byte) error { + primary, _ := splitCerts(crt) + + if primary == nil { + return fmt.Errorf("can't find certificate, please verify your tls.crt section") + } + + certDERBlock, _ := pem.Decode(primary) + if certDERBlock == nil { + return fmt.Errorf("can't find certificate, please verify your tls.crt section") + } + + if certDERBlock.Type != "CERTIFICATE" { + return fmt.Errorf("can't find certificate, expected CERTIFICATE, got: %s", certDERBlock.Type) + } + + cert, err := x509.ParseCertificate(certDERBlock.Bytes) + if err != nil { + return fmt.Errorf("can't parse certificate: %s", err.Error()) + } + + if len(cert.DNSNames) == 0 { + return fmt.Errorf("can't find dns names for certificate") + } + + return nil +} + +// FindCertificate finds DER block from cert +func findCertificate(crt []byte) []byte { + certDERBlock, _ := pem.Decode(crt) + if certDERBlock == nil { + return nil + } + + if certDERBlock.Type == "CERTIFICATE" { + return certDERBlock.Bytes + } + + return nil +} + +// SplitCerts splits cert with bundle to cert and bundle +func splitCerts(crt []byte) ([]byte, []byte) { + var sanitizedCert = string(stripSpaces(crt)) + var primary []string + var chain []string + + var started = false + var iter = 0 + + for _, line := range strings.Split(sanitizedCert, "\n") { + if strings.HasPrefix(line, "-----BEGIN") { + started = true + } + + if !started { + continue + } + + if iter == 0 { + primary = append(primary, strings.TrimSpace(line)) + } else { + chain = append(chain, strings.TrimSpace(line)) + } + + if strings.HasPrefix(line, "-----END") { + started = false + iter = iter + 1 + } + } + + if len(primary) == 0 { + return nil, nil + } + + if len(chain) == 0 { + return []byte(strings.Join(primary, "\n")), nil + } + + return []byte(strings.Join(primary, "\n")), []byte(strings.Join(chain, "\n")) +} + +// StripSpaces removes strip spaces from str +func stripSpaces(b []byte) []byte { + var out []string + for line := range strings.SplitSeq(string(b), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + out = append(out, trimmed) + } + } + return []byte(strings.Join(out, "\n")) +} diff --git a/internal/service/tls/manager.go b/internal/service/tls/manager.go new file mode 100644 index 0000000..67f7a39 --- /dev/null +++ b/internal/service/tls/manager.go @@ -0,0 +1,158 @@ +package tlssrv + +import ( + "context" + "fmt" + + "github.com/serverscom/api-gateway-controller/internal/config" + "github.com/serverscom/api-gateway-controller/internal/types" + "github.com/serverscom/api-gateway-controller/internal/utils" + + corev1 "k8s.io/api/core/v1" + + serverscom "github.com/serverscom/serverscom-go-client/pkg" +) + +//go:generate mockgen --destination ../../mocks/tls_manager.go --package=mocks --source manager.go + +type TLSManagerInterface interface { + EnsureTLS(ctx context.Context, tlsInfo map[string]types.TLSConfigInfo) (map[string]string, error) +} + +type Manager struct { + scCli *serverscom.Client +} + +func NewManager(c *serverscom.Client) *Manager { + return &Manager{scCli: c} +} + +// EnsureTLS ensures all TLS certificates exist in the provider. +// It supports either a secret or an external certificate ID for each host. +// External ID overrides cert from secret. +// Returns a map of host to certificate external ID. +func (m *Manager) EnsureTLS(ctx context.Context, tlsInfo map[string]types.TLSConfigInfo) (map[string]string, error) { + res := make(map[string]string) + for host, info := range tlsInfo { + if info.ExternalID != "" { + cert, err := m.getByID(info.ExternalID) + if err != nil { + return nil, fmt.Errorf("provider certificate id %q for host %q not found: %w", info.ExternalID, host, err) + } + res[host] = cert.ID + continue + } + secret := info.Secret + if secret == nil { + return nil, fmt.Errorf("no secret or ExternalID for host %q", host) + } + certPEM, ok := secret.Data[corev1.TLSCertKey] + if !ok { + return nil, fmt.Errorf("secret for host %q has no tls.crt", host) + } + keyPEM, ok := secret.Data[corev1.TLSPrivateKeyKey] + if !ok { + return nil, fmt.Errorf("secret for host %q has no tls.key", host) + } + if err := validateCertificate(certPEM); err != nil { + return nil, fmt.Errorf("invalid certificate for host %q: %w", host, err) + } + primary, chain := splitCerts(certPEM) + fp := getPemFingerprint(primary) + certObj, err := m.ensureCertificateForSecret(ctx, fp, string(secret.UID), primary, keyPEM, chain) + if err != nil { + return nil, fmt.Errorf("findOrCreate tls for host %q failed: %w", host, err) + } + res[host] = certObj.ID + } + return res, nil +} + +// getByID gets cert by external ID +func (m *Manager) getByID(id string) (*serverscom.SSLCertificate, error) { + customCert, err := m.scCli.SSLCertificates.GetCustom(context.Background(), id) + if err != nil { + return nil, err + } + + return customToSSLCertificate(customCert), nil +} + +// ensureCertificateForSecret ensures a certificate exists for a given secret. +// It finds, updates, or creates the certificate as needed. +func (m *Manager) ensureCertificateForSecret( + ctx context.Context, + fingerprint, secretUID string, + cert, key, chain []byte, +) (*serverscom.SSLCertificate, error) { + foundCrt, err := m.findCertificate(ctx, fingerprint, secretUID) + if err != nil { + return nil, err + } + if foundCrt != nil && foundCrt.Sha1Fingerprint == fingerprint { + return foundCrt, nil + } + if foundCrt != nil && foundCrt.ID != "" { + return m.updateCertificateForSecret(ctx, foundCrt.ID, cert, key, chain) + } + return m.createCertificateForSecret(ctx, secretUID, cert, key, chain) +} + +// findCertificate searches for a certificate in provider by secret label. +// fingerprint is used to match same cert. +func (m *Manager) findCertificate(ctx context.Context, fingerprint, secretUID string) (*serverscom.SSLCertificate, error) { + labelSelector := config.SECRET_LABEL_ID + "=" + secretUID + certs, err := m.scCli.SSLCertificates.Collection(). + SetParam("label_selector", labelSelector). + SetParam("type", "custom"). + Collect(ctx) + if err != nil { + return nil, utils.IgnoreNotFound(err) + } + for _, c := range certs { + if c.Sha1Fingerprint == fingerprint { + return &c, nil + } + } + if len(certs) > 0 { + // Return first for update use + return &certs[0], nil + } + return nil, nil +} + +// updateCertificateForSecret updates certificate in provider. +func (m *Manager) updateCertificateForSecret(ctx context.Context, id string, cert, key, chain []byte) (*serverscom.SSLCertificate, error) { + in := serverscom.SSLCertificateUpdateCustomInput{ + PublicKey: string(cert), + PrivateKey: string(key), + } + if len(chain) > 0 { + in.ChainKey = string(chain) + } + out, err := m.scCli.SSLCertificates.UpdateCustom(ctx, id, in) + if err != nil { + return nil, err + } + return customToSSLCertificate(out), nil +} + +// createCertificateForSecret creates a new certificate in provider. +func (m *Manager) createCertificateForSecret(ctx context.Context, secretUID string, cert, key, chain []byte) (*serverscom.SSLCertificate, error) { + in := serverscom.SSLCertificateCreateCustomInput{ + Name: "gw-secret-" + secretUID, + PublicKey: string(cert), + PrivateKey: string(key), + Labels: map[string]string{ + config.SECRET_LABEL_ID: secretUID, + }, + } + if len(chain) > 0 { + in.ChainKey = string(chain) + } + out, err := m.scCli.SSLCertificates.CreateCustom(ctx, in) + if err != nil { + return nil, err + } + return customToSSLCertificate(out), nil +} diff --git a/internal/service/tls/manager_test.go b/internal/service/tls/manager_test.go new file mode 100644 index 0000000..e01ccc7 --- /dev/null +++ b/internal/service/tls/manager_test.go @@ -0,0 +1,158 @@ +package tlssrv + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "math/big" + "testing" + "time" + + . "github.com/onsi/gomega" + + serverscom "github.com/serverscom/serverscom-go-client/pkg" + corev1 "k8s.io/api/core/v1" + + "github.com/serverscom/api-gateway-controller/internal/mocks" + "github.com/serverscom/api-gateway-controller/internal/types" + + "go.uber.org/mock/gomock" +) + +func TestEnsureTLS(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + sslHandler := mocks.NewMockSSLCertificatesService(mockCtrl) + collectionHandler := mocks.NewMockCollection[serverscom.SSLCertificate](mockCtrl) + + sslHandler.EXPECT(). + Collection(). + Return(collectionHandler). + AnyTimes() + collectionHandler.EXPECT(). + SetParam(gomock.Any(), gomock.Any()). + Return(collectionHandler). + AnyTimes() + + client := serverscom.NewClientWithEndpoint("", "") + client.SSLCertificates = sslHandler + manager := NewManager(client) + + certPEM, keyPEM := generateCertAndKey(t) + secret := &corev1.Secret{ + Data: map[string][]byte{ + corev1.TLSCertKey: certPEM, + corev1.TLSPrivateKeyKey: keyPEM, + }, + } + + tests := []struct { + name string + tlsInfo map[string]types.TLSConfigInfo + mock func() + wantErr bool + wantResult map[string]string + }{ + { + name: "external ID success", + tlsInfo: map[string]types.TLSConfigInfo{ + "example.com": {ExternalID: "ext-id"}, + }, + mock: func() { + sslHandler.EXPECT(). + GetCustom(gomock.Any(), "ext-id"). + Return(&serverscom.SSLCertificateCustom{ID: "cert-id"}, nil) + }, + wantResult: map[string]string{"example.com": "cert-id"}, + }, + { + name: "secret creates new cert", + tlsInfo: map[string]types.TLSConfigInfo{ + "example.com": {Secret: secret}, + }, + mock: func() { + collectionHandler.EXPECT(). + Collect(gomock.Any()). + Return(nil, nil) + sslHandler.EXPECT(). + CreateCustom(gomock.Any(), gomock.Any()). + Return(&serverscom.SSLCertificateCustom{ID: "new-cert"}, nil) + }, + wantResult: map[string]string{"example.com": "new-cert"}, + }, + { + name: "secret missing key", + tlsInfo: map[string]types.TLSConfigInfo{ + "example.com": { + Secret: &corev1.Secret{ + Data: map[string][]byte{ + corev1.TLSCertKey: certPEM, + }, + }, + }, + }, + mock: func() {}, + wantErr: true, + }, + { + name: "external ID not found", + tlsInfo: map[string]types.TLSConfigInfo{ + "example.com": {ExternalID: "not-found-id"}, + }, + mock: func() { + sslHandler.EXPECT(). + GetCustom(gomock.Any(), "not-found-id"). + Return(nil, errors.New("not found")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + tt.mock() + + res, err := manager.EnsureTLS(context.Background(), tt.tlsInfo) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).To(BeNil()) + g.Expect(res).To(Equal(tt.wantResult)) + }) + } +} + +func generateCertAndKey(t *testing.T) ([]byte, []byte) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + DNSNames: []string{"example.com"}, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + if err != nil { + t.Fatalf("failed to create cert: %v", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + keyBytes, err := x509.MarshalECPrivateKey(priv) + if err != nil { + t.Fatalf("failed to marshal key: %v", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}) + + return certPEM, keyPEM +} diff --git a/internal/types/gateway.go b/internal/types/gateway.go new file mode 100644 index 0000000..d642a8b --- /dev/null +++ b/internal/types/gateway.go @@ -0,0 +1,46 @@ +package types + +import ( + corev1 "k8s.io/api/core/v1" +) + +// GatewayInfo represents gateway info. +// Gathering in Reconcile loop contains info to build input for our load balancer. +type GatewayInfo struct { + UID string + Name string + NS string + VHosts map[string]*VHostInfo +} + +type PathInfo struct { + Path string + Service *corev1.Service + NodePort int + NodeIps []string +} + +type VHostInfo struct { + Host string + SSL bool + Ports []int32 + Paths []PathInfo +} + +// TLSConfigInfo represents tls info. +// Gathering in Reconcile loop contains info about tls config. +type TLSConfigInfo struct { + ExternalID string + Secret *corev1.Secret +} + +// ListenerInfo represents listener info. +// Used in Reconcile to filter available listeners. +type ListenerInfo struct { + Name string + Hostname string + Protocol string + Port int32 + AllowedFrom string + Selector map[string]string +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..9abd560 --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,19 @@ +package utils + +import ( + "errors" + + serverscom "github.com/serverscom/serverscom-go-client/pkg" +) + +func BoolPtr(v bool) *bool { + return &v +} + +func IgnoreNotFound(err error) error { + var notFoundErr *serverscom.NotFoundError + if errors.As(err, ¬FoundErr) { + return nil + } + return err +}