diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 94fbaca5f..2e4ac4285 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -368,4 +368,25 @@ jobs:
run_check_generated_files: true
ko_build_path: "cmd/main.go"
coverage_threshold: 66
- lint_fail_on_issues: true
\ No newline at end of file
+ lint_fail_on_issues: true
+
+ sftp:
+ name: SFTP
+ uses: ./.github/workflows/reusable-go-ci.yaml
+ with:
+ name: sftp
+ module: sftp
+ run_check_generated_files: true
+ ko_build_path: "cmd/sftp-operator/main.go"
+ coverage_threshold: 66
+ lint_fail_on_issues: true
+
+ file:
+ name: File
+ uses: ./.github/workflows/reusable-go-ci.yaml
+ with:
+ name: file
+ module: file
+ run_check_generated_files: true
+ ko_build_path: "cmd/file-operator/main.go"
+ lint_fail_on_issues: true
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 9f6349ca0..a8f592c0b 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -217,6 +217,24 @@ builds:
goarch:
- amd64
- arm64
+ - id: sftp-operator
+ dir: sftp
+ main: cmd/sftp-operator/main.go
+ binary: sftp-operator
+ goos:
+ - linux
+ goarch:
+ - amd64
+ - arm64
+ - id: file-operator
+ dir: file
+ main: cmd/file-operator/main.go
+ binary: file-operator
+ goos:
+ - linux
+ goarch:
+ - amd64
+ - arm64
archives:
- id: rover-ctl
ids: [rover-ctl]
@@ -255,7 +273,7 @@ release:
sboms:
- artifacts: archive
-kos:
+kos:
- id: common-server
build: common-server
main: .
@@ -684,4 +702,4 @@ kos:
- latest
- "{{.Tag}}"
- "{{if not .Prerelease}}stable{{end}}"
- bare: true
\ No newline at end of file
+ bare: true
diff --git a/common/pkg/config/feature.go b/common/pkg/config/feature.go
index 9d72ca9f0..3fe3da163 100644
--- a/common/pkg/config/feature.go
+++ b/common/pkg/config/feature.go
@@ -49,6 +49,7 @@ var (
FeatureSecretManager Feature = NewFeature("secret_manager", true) // Secret Manager feature enabled by default
FeatureFileManager Feature = NewFeature("file_manager", true) // File Manager feature enabled by default
FeatureAiGateway Feature = NewFeature("ai_gateway", false) // AI Gateway (MCP) feature disabled by default
+ FeatureFile Feature = NewFeature("file", true)
)
// SetFeatureEnabled sets the enabled state for a feature. Intended for tests.
diff --git a/docs/docs/architecture/file.mdx b/docs/docs/architecture/file.mdx
new file mode 100644
index 000000000..9a08d9ed0
--- /dev/null
+++ b/docs/docs/architecture/file.mdx
@@ -0,0 +1,45 @@
+---
+sidebar_position: 12
+---
+
+
+
+# File Domain
+
+The File domain manages file-type exposure and subscription resources. It acts as the business-logic layer between Rover-style file declarations and runtime solutions such as SFTP.
+
+## Custom Resources
+
+
+
+## Reconciliation Flow
+
+```
+FileType registered
+ │
+ ├──▶ FileExposure selected as active provider
+ │ ├──▶ SFTP Instance created for the active provider zone
+ │ └──▶ Provider SSH public keys synced to the FileType SFTP User
+ │
+ ├──▶ SFTP User created for the active file type with an instanceRef
+ │
+ └──▶ FileSubscription created by a consumer
+ ├──▶ ApprovalRequest created from the active FileExposure approval strategy
+ └──▶ Subscription-owned SFTP User synced after approval is granted
+```
+
+FileExposure and FileSubscription resources reference a `FileType` by name with `spec.fileType`. SFTP-specific public keys are configured through `spec.sftp`, and `spec.zone` points at the ZoneServiceConfig used for provider-side SFTP access. ZoneServiceConfig resources hold the SFTP API managed-route configuration plus internal and external service endpoints, and they must have the same name and namespace as the corresponding admin Zone. The File operator reads the identity realm reference from that Zone, creates an identity client for the managed route, and projects the resolved endpoint, token endpoint, client ID, and client secret to the SFTP domain as an SFTPServiceConfig with the same name and namespace. The active FileExposure creates an SFTP Instance for that zone, and SFTP users reference that instance through `spec.instanceRef`.
+
+## Domain Interactions
+
+- **Rover domain** - File declarations can create FileType, FileExposure, and FileSubscription resources.
+- **SFTP domain** - The File operator creates User, Instance, and SFTPServiceConfig resources. Provider keys are stored on the FileType-owned SFTP User, while each granted FileSubscription owns a separate SFTP User with its subscriber keys.
+- **Approval domain** - FileExposure carries approval settings for subscription workflows. FileSubscription creates ApprovalRequest and Approval references, waits while approval is pending, provisions a subscriber SFTP User after approval is granted, and deletes it if approval is denied.
+
+## Related Pages
+
+- [Architecture: Rover Domain](./rover.mdx)
diff --git a/docs/docs/architecture/overview.md b/docs/docs/architecture/overview.md
index ed4cf1bbd..6f28dfb33 100644
--- a/docs/docs/architecture/overview.md
+++ b/docs/docs/architecture/overview.md
@@ -100,3 +100,4 @@ Each domain is described in detail on its own page:
| [PubSub](./pubsub.mdx) | Runtime layer for publish/subscribe messaging |
| [Secret Manager](./secret-manager.mdx) | Centralized secret storage, references, and retrieval |
| [ControlPlane API & Projector](./controlplane-api.mdx) | Read-only external access layer (CQRS) for the UI |
+| [SFTP](./sftp.mdx) | SFTP service provisioning and SSH public key synchronization |
diff --git a/docs/docs/architecture/rover.mdx b/docs/docs/architecture/rover.mdx
index 9cdcb38c7..aab77db7e 100644
--- a/docs/docs/architecture/rover.mdx
+++ b/docs/docs/architecture/rover.mdx
@@ -25,10 +25,26 @@ Users interact with the Rover domain through three paths:
- **Application domain** — Creates Application resources.
- **API domain** — Creates Api, ApiExposure, and ApiSubscription resources.
- **Event domain** — Creates EventExposure and EventSubscription resources.
+- **File domain (SFTP)** — Creates the SFTP user (shared space) from a `FileSpecification` and registers producer/consumer SSH public keys for `fileType` exposures and subscriptions. Gated behind the `file` feature flag.
- **Gateway domain** — Configures traffic management settings.
- **Identity domain** — Configures authentication settings.
- **Approval domain** — Integrates approval requirements for exposures.
+## File Types (SFTP)
+
+Rover can configure an external SFTP file-transfer service without changing the customer-facing Rover file:
+
+- A `FileSpecification` (`metadata.name` must equal `spec.type`) provisions an SFTP user with a default shared space.
+- A `fileType` **exposure** (producer) or **subscription** (consumer) with `variant: sftp` and one or more `publicKeys` registers those SSH keys on the matching SFTP user.
+
+Validation rules enforced by the Rover webhook:
+
+- `fileType` exposures/subscriptions are only allowed on the `cetus` and `canis` zones.
+- `variant: sftp` is optional.
+- At least one public key is required; both the key `label` and `key` value must be unique per `fileType`.
+
+Because file transfer happens directly over SFTP, a Rover that only exposes/subscribes to file types yields a **logical** Application (`needsClient` and `needsConsumer` are `false`). If the same Rover also has an API or event subscription, a client is still required.
+
## Related Pages
- [User Journey: Onboarding](../user-journey/onboarding.md)
diff --git a/docs/docs/architecture/sftp.mdx b/docs/docs/architecture/sftp.mdx
new file mode 100644
index 000000000..14cf2bced
--- /dev/null
+++ b/docs/docs/architecture/sftp.mdx
@@ -0,0 +1,52 @@
+---
+sidebar_position: 14
+---
+
+
+
+# SFTP Domain
+
+The SFTP domain manages SSH key-based access to the SFTP Service. It translates Kubernetes resources into SFTP Tardis API calls, provisions service users for SFTP instances, and keeps the public keys for those users synchronized from the declared `User` resources.
+
+## Custom Resources
+
+
+
+## Resource Model
+
+SFTP resources follow a simple dependency chain:
+
+```mermaid
+graph TD
+ SFTPServiceConfig["SFTPServiceConfig
SFTP Tardis API access"]
+ Instance["Instance
SFTP service user"]
+ User["User
SSH public keys"]
+
+ SFTPServiceConfig --> Instance
+ Instance --> User
+```
+
+- A **SFTPServiceConfig** defines the SFTP Tardis API endpoint and OAuth2 client credentials for a zone.
+- An **Instance** references an SFTPServiceConfig and represents one SFTP service user in the external service.
+- A **User** references an Instance and contributes one or more SSH public keys to that instance.
+
+## Reconciliation Flow
+
+The SFTPServiceConfig controller validates the configured API credentials by creating or refreshing an SFTP service client. The Instance controller then uses that client to create or update the SFTP service user in the external service.
+
+When User resources change, the Instance controller collects all Users that reference the Instance, canonicalizes their SSH public keys, deduplicates them by fingerprint, and updates the public key set for the SFTP service user.
+After a successful key sync, the Instance status records the observed per-User `Processing` condition. The User controller watches those Instance status changes, mirrors the relevant `Processing` condition onto each User, and derives the User `Ready` condition from it.
+
+## Domain Interactions
+
+- **Secret Manager** — SFTPServiceConfig can resolve SFTP Tardis client secrets from Secret Manager references during reconciliation.
+- **SFTP Tardis API** — The SFTP operator creates, updates, and deletes SFTP service users and synchronizes their public keys through this external API.
+
+## Related Pages
+
+- [Architecture Overview](./overview.md)
+- [Reference: API](../reference/api.md)
diff --git a/docs/docs/developer-journey/local-development.md b/docs/docs/developer-journey/local-development.md
index b2e7caa52..62cb04567 100644
--- a/docs/docs/developer-journey/local-development.md
+++ b/docs/docs/developer-journey/local-development.md
@@ -154,7 +154,7 @@ Every operator directory exposes a consistent set of Make targets:
| Target | Description |
|--------|-------------|
-| `manifests` | Generate CRDs, RBAC, and webhook manifests |
+| `manifests` | Generate CRDs, RBAC, and webhook manifests where configured |
| `generate` | Generate DeepCopy methods |
| `fmt` | Run `go fmt` |
| `vet` | Run `go vet` |
diff --git a/docs/docs/overview/components.md b/docs/docs/overview/components.md
index 9b4914e43..c45da9bdc 100644
--- a/docs/docs/overview/components.md
+++ b/docs/docs/overview/components.md
@@ -98,6 +98,7 @@ Domain operators are the core building blocks of the Control Plane. Each operato
| [Identity](../architecture/identity.mdx) | Manages identity providers, realms, and service-account clients through Keycloak. Provides authentication and authorization for all platform interactions. |
| [Event](../architecture/event.mdx) | Handles event publishing and subscribing, including cross-zone meshing. An optional feature that bridges user configuration (Rover) with the PubSub runtime. |
| [PubSub](../architecture/pubsub.mdx) | The runtime configuration layer for publish/subscribe messaging via Horizon. Managed exclusively by the Event domain. |
+| [SFTP](../architecture/sftp.mdx) | Manages SFTP service instances and SSH public keys, synchronizing Kubernetes resources with the external SFTP service. |
## Services
diff --git a/docs/docs/reference/api.md b/docs/docs/reference/api.md
index ccec46687..7d807cd7c 100644
--- a/docs/docs/reference/api.md
+++ b/docs/docs/reference/api.md
@@ -31,7 +31,8 @@ Most users do not create these resources directly. Instead, they write a single
| [Organization](#organization) | `organization.cp.ei.telekom.de/v1` | 2 |
| [PubSub](#pubsub) | `pubsub.cp.ei.telekom.de/v1` | 3 |
| [Rover](#rover) | `rover.cp.ei.telekom.de/v1` | 3 |
-| | **Total** | **34** |
+| [SFTP](#sftp) | `sftp.cp.ei.telekom.de/v1` | 3 |
+| | **Total** | **37** |
---
@@ -189,6 +190,20 @@ The primary user-facing interface for declarative application configuration.
---
+### SFTP
+
+**API Group:** `sftp.cp.ei.telekom.de/v1` · [Architecture →](../architecture/sftp.mdx)
+
+SFTP service provisioning and SSH public key synchronization.
+
+| Kind | Description |
+| ---- | ----------- |
+| **SFTPServiceConfig** | Configures zone-specific SFTP Tardis API access and OAuth2 client credentials. |
+| **Instance** | Represents an SFTP service instance and provisions a corresponding SFTP service user through the configured SFTPServiceConfig. |
+| **User** | Declares SSH public keys for an SFTP user and attaches them to an Instance. |
+
+---
+
## REST API
In addition to the Kubernetes CRDs, the Control Plane exposes two REST APIs.
diff --git a/docs/docs/reference/json-schemas.md b/docs/docs/reference/json-schemas.md
index f6c3fd0c9..36b462f51 100644
--- a/docs/docs/reference/json-schemas.md
+++ b/docs/docs/reference/json-schemas.md
@@ -177,6 +177,16 @@ The following tables list all available schemas, grouped by domain. Click a sche
---
+### SFTP
+
+| Kind | Schema |
+| ---- | ------ |
+| **Instance** | [`instance_v1.json`](pathname:///schemas/sftp.cp.ei.telekom.de/instance_v1.json) |
+| **User** | [`user_v1.json`](pathname:///schemas/sftp.cp.ei.telekom.de/user_v1.json) |
+| **SFTPServiceConfig** | [`sftpserviceconfig_v1.json`](pathname:///schemas/sftp.cp.ei.telekom.de/sftpserviceconfig_v1.json) |
+
+---
+
## Schema Index
A machine-readable index of all available schemas is published at [`schemas/index.json`](pathname:///schemas/index.json). This can be used by tooling to discover schemas programmatically.
diff --git a/docs/scripts/generate-crd-data.mjs b/docs/scripts/generate-crd-data.mjs
index 2103398cb..6353fe049 100644
--- a/docs/scripts/generate-crd-data.mjs
+++ b/docs/scripts/generate-crd-data.mjs
@@ -30,6 +30,7 @@ const DOMAINS = [
"organization",
"pubsub",
"rover",
+ "sftp",
];
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/docs/scripts/generate-crd-schemas.mjs b/docs/scripts/generate-crd-schemas.mjs
index d712e23ee..79c1c92ad 100644
--- a/docs/scripts/generate-crd-schemas.mjs
+++ b/docs/scripts/generate-crd-schemas.mjs
@@ -37,6 +37,7 @@ const DOMAINS = [
"organization",
"pubsub",
"rover",
+ "sftp",
];
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/file/.gitignore b/file/.gitignore
new file mode 100644
index 000000000..4a02d54dd
--- /dev/null
+++ b/file/.gitignore
@@ -0,0 +1,35 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+bin/*
+Dockerfile.cross
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Go workspace file
+go.work*
+
+# Kubernetes Generated files - skip generated files, except for vendored files
+!vendor/**/zz_generated.*
+
+# editor and IDE paraphernalia
+.idea
+.vscode
+*.swp
+*.swo
+*~
+
+# Test reports
+gotest.log
+ginkgo-junit.xml
diff --git a/file/Makefile b/file/Makefile
new file mode 100644
index 000000000..c3e294c1c
--- /dev/null
+++ b/file/Makefile
@@ -0,0 +1,145 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+IMG ?= controller:latest
+ENVTEST_K8S_VERSION = 1.32.0
+
+ifeq (,$(shell go env GOBIN))
+GOBIN=$(shell go env GOPATH)/bin
+else
+GOBIN=$(shell go env GOBIN)
+endif
+
+SHELL = /usr/bin/env bash -o pipefail
+.SHELLFLAGS = -ec
+
+.PHONY: all
+all: build
+
+##@ General
+
+.PHONY: help
+help: ## Display this help.
+ @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
+
+##@ Development
+
+.PHONY: manifests
+manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects.
+ $(CONTROLLER_GEN) rbac:roleName=manager-role,headerFile="../hack/boilerplate.yaml.txt" crd:headerFile="../hack/boilerplate.yaml.txt" webhook:headerFile="../hack/boilerplate.yaml.txt" paths="./..." output:crd:artifacts:config=config/crd/bases
+
+.PHONY: generate
+generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations.
+ $(CONTROLLER_GEN) object:headerFile="../hack/boilerplate.go.txt" paths="./..."
+
+.PHONY: fmt
+fmt: ## Run go fmt against code.
+ go fmt ./...
+
+.PHONY: vet
+vet: ## Run go vet against code.
+ go vet ./...
+
+TEST_PACKAGES = ./internal/...
+COVER_PACKAGES = ./internal/...
+
+.PHONY: test
+test: manifests generate fmt vet envtest ## Run tests.
+ KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" gotestsum --format pkgname --jsonfile gotest.log -- $(TEST_PACKAGES) -ginkgo.junit-report=ginkgo-junit.xml -coverprofile cover.out --coverpkg $(COVER_PACKAGES) -race
+
+.PHONY: lint
+lint: golangci-lint ## Run golangci-lint linter
+ $(GOLANGCI_LINT) run
+
+.PHONY: lint-fix
+lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes
+ $(GOLANGCI_LINT) run --fix
+
+##@ Build
+
+.PHONY: build
+build: manifests generate fmt vet ## Build manager binary.
+ go build -o bin/manager ./cmd/file-operator/main.go
+
+.PHONY: run
+run: manifests generate fmt vet ## Run a controller from your host.
+ go run ./cmd/file-operator/main.go
+
+.PHONY: build-installer
+build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment.
+ mkdir -p dist
+ cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG}
+ $(KUSTOMIZE) build config/default > dist/install.yaml
+
+##@ Deployment
+
+ifndef ignore-not-found
+ ignore-not-found = false
+endif
+
+.PHONY: install
+install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config.
+ $(KUSTOMIZE) build config/crd | $(KUBECTL) apply -f -
+
+.PHONY: uninstall
+uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
+ $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f -
+
+.PHONY: deploy
+deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config.
+ cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG}
+ $(KUSTOMIZE) build config/default | $(KUBECTL) apply -f -
+
+.PHONY: undeploy
+undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
+ $(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f -
+
+##@ Dependencies
+
+LOCALBIN ?= $(shell pwd)/bin
+$(LOCALBIN):
+ mkdir -p $(LOCALBIN)
+
+KUBECTL ?= kubectl
+KUSTOMIZE ?= $(LOCALBIN)/kustomize
+CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen
+ENVTEST ?= $(LOCALBIN)/setup-envtest
+GOLANGCI_LINT = $(LOCALBIN)/golangci-lint
+
+KUSTOMIZE_VERSION ?= v5.4.3
+CONTROLLER_TOOLS_VERSION ?= v0.20.1
+ENVTEST_VERSION ?= release-0.19
+GOLANGCI_LINT_VERSION ?= v2.11.4
+
+.PHONY: kustomize
+kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary.
+$(KUSTOMIZE): $(LOCALBIN)
+ $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION))
+
+.PHONY: controller-gen
+controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary.
+$(CONTROLLER_GEN): $(LOCALBIN)
+ $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION))
+
+.PHONY: envtest
+envtest: $(ENVTEST) ## Download setup-envtest locally if necessary.
+$(ENVTEST): $(LOCALBIN)
+ $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION))
+
+.PHONY: golangci-lint
+golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary.
+$(GOLANGCI_LINT): $(LOCALBIN)
+ $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION))
+
+define go-install-tool
+@[ -f "$(1)-$(3)" ] || { \
+set -e; \
+package=$(2)@$(3) ;\
+echo "Downloading $${package}" ;\
+rm -f $(1) || true ;\
+GOBIN=$(LOCALBIN) go install $${package} ;\
+mv $(1) $(1)-$(3) ;\
+} ;\
+ln -sf $(1)-$(3) $(1)
+endef
diff --git a/file/PROJECT b/file/PROJECT
new file mode 100644
index 000000000..693ca135a
--- /dev/null
+++ b/file/PROJECT
@@ -0,0 +1,47 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+domain: file.cp.ei.telekom.de
+layout:
+- go.kubebuilder.io/v4
+projectName: file
+repo: github.com/telekom/controlplane/file
+resources:
+- api:
+ crdVersion: v1
+ namespaced: true
+ controller: true
+ domain: file.cp.ei.telekom.de
+ group: file
+ kind: FileType
+ path: github.com/telekom/controlplane/file/api/v1
+ version: v1
+- api:
+ crdVersion: v1
+ namespaced: true
+ controller: true
+ domain: file.cp.ei.telekom.de
+ group: file
+ kind: FileExposure
+ path: github.com/telekom/controlplane/file/api/v1
+ version: v1
+- api:
+ crdVersion: v1
+ namespaced: true
+ controller: true
+ domain: file.cp.ei.telekom.de
+ group: file
+ kind: FileSubscription
+ path: github.com/telekom/controlplane/file/api/v1
+ version: v1
+- api:
+ crdVersion: v1
+ namespaced: true
+ controller: true
+ domain: file.cp.ei.telekom.de
+ group: file
+ kind: ZoneServiceConfig
+ path: github.com/telekom/controlplane/file/api/v1
+ version: v1
+version: "4"
diff --git a/file/api/go.mod b/file/api/go.mod
new file mode 100644
index 000000000..9fb69dbe4
--- /dev/null
+++ b/file/api/go.mod
@@ -0,0 +1,59 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+module github.com/telekom/controlplane/file/api
+
+go 1.26.5
+
+require (
+ github.com/telekom/controlplane/admin/api v0.0.0
+ github.com/telekom/controlplane/common v0.0.0
+ k8s.io/apimachinery v0.36.2
+ sigs.k8s.io/controller-runtime v0.24.1
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/emicklei/go-restful/v3 v3.13.0 // indirect
+ github.com/evanphx/json-patch/v5 v5.9.11 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-openapi/jsonpointer v0.21.0 // indirect
+ github.com/go-openapi/jsonreference v0.20.2 // indirect
+ github.com/go-openapi/swag v0.23.0 // indirect
+ github.com/google/gnostic-models 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/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/x448/float16 v0.8.4 // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/net v0.56.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/term v0.44.0 // indirect
+ golang.org/x/text v0.39.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ k8s.io/api v0.36.2 // indirect
+ k8s.io/client-go v0.36.2 // indirect
+ k8s.io/klog/v2 v2.140.0 // indirect
+ k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
+ k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
+ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
+ sigs.k8s.io/randfill v1.0.0 // indirect
+ sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
+ sigs.k8s.io/yaml v1.6.0 // indirect
+)
+
+replace (
+ github.com/telekom/controlplane/admin/api => ../../admin/api
+ github.com/telekom/controlplane/common => ../../common
+)
diff --git a/file/api/go.sum b/file/api/go.sum
new file mode 100644
index 000000000..36c18f882
--- /dev/null
+++ b/file/api/go.sum
@@ -0,0 +1,155 @@
+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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+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.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
+github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
+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/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.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
+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.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
+github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
+github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
+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-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/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/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
+github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
+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/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/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+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.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
+github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
+github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
+github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
+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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
+github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
+github.com/prometheus/procfs v0.21.0 h1:Qh/e6TlBjZf+XLLqNCqFGmCU6Kj/2Bu7kj3oAc0UnXc=
+github.com/prometheus/procfs v0.21.0/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
+github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+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.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+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/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
+golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
+golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
+golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+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.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
+gopkg.in/evanphx/json-patch.v4 v4.13.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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY=
+k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg=
+k8s.io/apiextensions-apiserver v0.36.2 h1:3O5gqOj/dt2XWWbpMe+TXWpE9yU6pjM/tXxtHHJT/K4=
+k8s.io/apiextensions-apiserver v0.36.2/go.mod h1:cL1tBWe8XSaP1H30iWKGo7hf6iAUUUJPEU70dskmAnA=
+k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ=
+k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4=
+k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI=
+k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0=
+k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
+k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
+k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
+sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4=
+sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/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.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.2/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/file/api/go.sum.license b/file/api/go.sum.license
new file mode 100644
index 000000000..be863cd5c
--- /dev/null
+++ b/file/api/go.sum.license
@@ -0,0 +1,3 @@
+Copyright 2026 Deutsche Telekom IT GmbH
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/file/api/v1/fileexposure_types.go b/file/api/v1/fileexposure_types.go
new file mode 100644
index 000000000..aec939816
--- /dev/null
+++ b/file/api/v1/fileexposure_types.go
@@ -0,0 +1,99 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import (
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/telekom/controlplane/common/pkg/types"
+)
+
+// FileExposureSpec defines a provider-side file exposure.
+type FileExposureSpec struct {
+ // Provider optionally identifies the providing application.
+ // +kubebuilder:validation:Optional
+ Provider string `json:"provider,omitempty"`
+
+ // +kubebuilder:validation:Required
+ FileType string `json:"fileType"`
+
+ // Zone identifies the zone where this file exposure is provided.
+ // +kubebuilder:validation:Required
+ Zone *types.ObjectRef `json:"zone"`
+
+ // SFTP configures provider-side SFTP access for this file exposure.
+ // +kubebuilder:validation:Optional
+ SFTP *FileSFTP `json:"sftp,omitempty"`
+
+ // Visibility defines who can subscribe to this file exposure.
+ // +kubebuilder:default=Enterprise
+ Visibility Visibility `json:"visibility"`
+
+ // Approval configures how subscriptions to this file exposure are approved.
+ // +kubebuilder:validation:Optional
+ Approval Approval `json:"approval,omitempty"`
+}
+
+// FileExposureStatus defines the observed state of FileExposure.
+type FileExposureStatus struct {
+ // +listType=map
+ // +listMapKey=type
+ // +patchStrategy=merge
+ // +patchMergeKey=type
+ // +optional
+ Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
+
+ // FileTypeRef references the FileType this exposure provides.
+ // +optional
+ FileTypeRef *types.ObjectRef `json:"fileTypeRef,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:printcolumn:name="FileType",type="string",JSONPath=".spec.fileType"
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
+
+// FileExposure is the Schema for the fileexposures API.
+type FileExposure struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec FileExposureSpec `json:"spec,omitempty"`
+ Status FileExposureStatus `json:"status,omitempty"`
+}
+
+var _ types.Object = &FileExposure{}
+
+func (r *FileExposure) GetConditions() []metav1.Condition {
+ return r.Status.Conditions
+}
+
+func (r *FileExposure) SetCondition(condition metav1.Condition) bool {
+ return meta.SetStatusCondition(&r.Status.Conditions, condition)
+}
+
+// +kubebuilder:object:root=true
+
+// FileExposureList contains a list of FileExposure.
+type FileExposureList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []FileExposure `json:"items"`
+}
+
+var _ types.ObjectList = &FileExposureList{}
+
+func (r *FileExposureList) GetItems() []types.Object {
+ items := make([]types.Object, len(r.Items))
+ for i := range r.Items {
+ items[i] = &r.Items[i]
+ }
+ return items
+}
+
+func init() {
+ SchemeBuilder.Register(&FileExposure{}, &FileExposureList{})
+}
diff --git a/file/api/v1/filesubscription_types.go b/file/api/v1/filesubscription_types.go
new file mode 100644
index 000000000..7d96dd63e
--- /dev/null
+++ b/file/api/v1/filesubscription_types.go
@@ -0,0 +1,96 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import (
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/telekom/controlplane/common/pkg/types"
+)
+
+// FileSubscriptionSpec defines a consumer-side subscription to a file type.
+type FileSubscriptionSpec struct {
+ // FileType references the FileType to subscribe to.
+ // +kubebuilder:validation:Required
+ FileType string `json:"fileType"`
+
+ // Zone identifies the zone where the subscriber is located.
+ // +kubebuilder:validation:Required
+ Zone *types.ObjectRef `json:"zone"`
+
+ // SFTP configures consumer-side SFTP access for this file subscription.
+ // +kubebuilder:validation:Optional
+ SFTP *FileSFTP `json:"sftp,omitempty"`
+}
+
+// FileSubscriptionStatus defines the observed state of FileSubscription.
+type FileSubscriptionStatus struct {
+ // +listType=map
+ // +listMapKey=type
+ // +patchStrategy=merge
+ // +patchMergeKey=type
+ // +optional
+ Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
+
+ // Approval references the Approval CR managing subscription approval.
+ // +optional
+ Approval *types.ObjectRef `json:"approval,omitempty"`
+
+ // ApprovalRequest references the ApprovalRequest CR for this subscription.
+ // +optional
+ ApprovalRequest *types.ObjectRef `json:"approvalRequest,omitempty"`
+
+ // FileTypeRef references the subscribed FileType.
+ // +optional
+ FileTypeRef *types.ObjectRef `json:"fileTypeRef,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:printcolumn:name="FileType",type="string",JSONPath=".spec.fileType"
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
+
+// FileSubscription is the Schema for the filesubscriptions API.
+type FileSubscription struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec FileSubscriptionSpec `json:"spec,omitempty"`
+ Status FileSubscriptionStatus `json:"status,omitempty"`
+}
+
+var _ types.Object = &FileSubscription{}
+
+func (r *FileSubscription) GetConditions() []metav1.Condition {
+ return r.Status.Conditions
+}
+
+func (r *FileSubscription) SetCondition(condition metav1.Condition) bool {
+ return meta.SetStatusCondition(&r.Status.Conditions, condition)
+}
+
+// +kubebuilder:object:root=true
+
+// FileSubscriptionList contains a list of FileSubscription.
+type FileSubscriptionList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []FileSubscription `json:"items"`
+}
+
+var _ types.ObjectList = &FileSubscriptionList{}
+
+func (r *FileSubscriptionList) GetItems() []types.Object {
+ items := make([]types.Object, len(r.Items))
+ for i := range r.Items {
+ items[i] = &r.Items[i]
+ }
+ return items
+}
+
+func init() {
+ SchemeBuilder.Register(&FileSubscription{}, &FileSubscriptionList{})
+}
diff --git a/file/api/v1/filetype_types.go b/file/api/v1/filetype_types.go
new file mode 100644
index 000000000..641f1447e
--- /dev/null
+++ b/file/api/v1/filetype_types.go
@@ -0,0 +1,83 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import (
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/telekom/controlplane/common/pkg/types"
+)
+
+// FileTypeSpec defines a logical file type that can be exposed and subscribed to.
+type FileTypeSpec struct {
+ // Description is a human-readable description of this file type.
+ // +kubebuilder:validation:Optional
+ Description string `json:"description,omitempty"`
+}
+
+// FileTypeStatus defines the observed state of FileType.
+type FileTypeStatus struct {
+ // +listType=map
+ // +listMapKey=type
+ // +patchStrategy=merge
+ // +patchMergeKey=type
+ // +optional
+ Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
+
+ // FileExposureRef references the active FileExposure for this file type.
+ // +optional
+ FileExposureRef *types.ObjectRef `json:"fileExposureRef,omitempty"`
+
+ // SFTPInstance references the projected SFTP instance for this file type.
+ // +optional
+ SFTPInstance *types.ObjectRef `json:"sftpInstance,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
+
+// FileType is the Schema for the filetypes API.
+type FileType struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec FileTypeSpec `json:"spec,omitempty"`
+ Status FileTypeStatus `json:"status,omitempty"`
+}
+
+var _ types.Object = &FileType{}
+
+func (r *FileType) GetConditions() []metav1.Condition {
+ return r.Status.Conditions
+}
+
+func (r *FileType) SetCondition(condition metav1.Condition) bool {
+ return meta.SetStatusCondition(&r.Status.Conditions, condition)
+}
+
+// +kubebuilder:object:root=true
+
+// FileTypeList contains a list of FileType.
+type FileTypeList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []FileType `json:"items"`
+}
+
+var _ types.ObjectList = &FileTypeList{}
+
+func (r *FileTypeList) GetItems() []types.Object {
+ items := make([]types.Object, len(r.Items))
+ for i := range r.Items {
+ items[i] = &r.Items[i]
+ }
+ return items
+}
+
+func init() {
+ SchemeBuilder.Register(&FileType{}, &FileTypeList{})
+}
diff --git a/file/api/v1/groupversion_info.go b/file/api/v1/groupversion_info.go
new file mode 100644
index 000000000..6c33549d7
--- /dev/null
+++ b/file/api/v1/groupversion_info.go
@@ -0,0 +1,24 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+// Package v1 contains API Schema definitions for the file v1 API group.
+// +kubebuilder:object:generate=true
+// +groupName=file.cp.ei.telekom.de
+package v1
+
+import (
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "sigs.k8s.io/controller-runtime/pkg/scheme"
+)
+
+var (
+ // GroupVersion is group version used to register these objects.
+ GroupVersion = schema.GroupVersion{Group: "file.cp.ei.telekom.de", Version: "v1"}
+
+ // SchemeBuilder is used to add go types to the GroupVersionKind scheme.
+ SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
+
+ // AddToScheme adds the types in this group-version to the given scheme.
+ AddToScheme = SchemeBuilder.AddToScheme
+)
diff --git a/file/api/v1/shared_types.go b/file/api/v1/shared_types.go
new file mode 100644
index 000000000..e443082f4
--- /dev/null
+++ b/file/api/v1/shared_types.go
@@ -0,0 +1,77 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+const (
+ // FileTypeNameLabelKey marks resources related to a FileType name.
+ FileTypeNameLabelKey = "filetype.file.ei.telekom.de/name"
+ // FileTypeNamespaceLabelKey marks resources related to a FileType namespace.
+ FileTypeNamespaceLabelKey = "filetype.file.ei.telekom.de/namespace"
+)
+
+// FileSolution defines the backing file transfer solution.
+// +kubebuilder:validation:Enum=sftp
+type FileSolution string
+
+const (
+ FileSolutionSFTP FileSolution = "sftp"
+)
+
+func (s FileSolution) OrDefault() FileSolution {
+ if s == "" {
+ return FileSolutionSFTP
+ }
+ return s
+}
+
+// Visibility defines who can see and subscribe to an exposed file type.
+// +kubebuilder:validation:Enum=World;Zone;Enterprise
+type Visibility string
+
+const (
+ VisibilityWorld Visibility = "World"
+ VisibilityZone Visibility = "Zone"
+ VisibilityEnterprise Visibility = "Enterprise"
+)
+
+// ApprovalStrategy defines the approval mode for subscriptions.
+// +kubebuilder:validation:Enum=Auto;Simple;FourEyes
+type ApprovalStrategy string
+
+const (
+ ApprovalStrategyAuto ApprovalStrategy = "Auto"
+ ApprovalStrategySimple ApprovalStrategy = "Simple"
+ ApprovalStrategyFourEyes ApprovalStrategy = "FourEyes"
+)
+
+// Approval configures how subscriptions to this file exposure are approved.
+type Approval struct {
+ // Strategy defines the approval mode.
+ // +kubebuilder:default=Simple
+ Strategy ApprovalStrategy `json:"strategy"`
+
+ // TrustedTeams identifies teams that are trusted for approving subscriptions.
+ // +kubebuilder:validation:Optional
+ // +kubebuilder:validation:MinItems=0
+ // +kubebuilder:validation:MaxItems=10
+ TrustedTeams []string `json:"trustedTeams,omitempty"`
+}
+
+// FileSFTP configures SFTP-specific settings for file exposures and subscriptions.
+type FileSFTP struct {
+ // PublicKeys contains SSH public keys for the SFTP user of the FileType.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinItems=1
+ // +kubebuilder:validation:uniqueItems=true
+ PublicKeys []SSHPublicKeySpec `json:"publicKeys,omitempty"`
+}
+
+// SSHPublicKeySpec carries an SSH public key.
+type SSHPublicKeySpec struct {
+ // Key is the SSH public key value.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ Key string `json:"key"`
+}
diff --git a/file/api/v1/zoneserviceconfig_types.go b/file/api/v1/zoneserviceconfig_types.go
new file mode 100644
index 000000000..6b2866ca2
--- /dev/null
+++ b/file/api/v1/zoneserviceconfig_types.go
@@ -0,0 +1,109 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import (
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ adminv1 "github.com/telekom/controlplane/admin/api/v1"
+ "github.com/telekom/controlplane/common/pkg/types"
+)
+
+// ZoneServiceConfigSpec defines the file-domain service configuration for one zone.
+// A ZoneServiceConfig must use the same name and namespace as its admin Zone.
+type ZoneServiceConfigSpec struct {
+ // +kubebuilder:validation:Required
+ API adminv1.ManagedRouteConfig `json:"api"`
+
+ // Service is the internal SFTP service endpoint.
+ // +kubebuilder:validation:Optional
+ Service *ServiceEndpoint `json:"service"`
+
+ // ServiceExternal is the externally reachable SFTP service endpoint.
+ // +kubebuilder:validation:Optional
+ ServiceExternal *ServiceEndpoint `json:"serviceExternal"`
+
+ // Zone identifies the zone where this file exposure is provided.
+ // +kubebuilder:validation:Required
+ Zone *types.ObjectRef `json:"zone"`
+}
+
+// ServiceEndpoint identifies an SFTP service endpoint.
+type ServiceEndpoint struct {
+ // Host is the hostname or IP address of the service.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MaxLength=253
+ // +kubebuilder:validation:XValidation:rule="!format.dns1123Subdomain().validate(self).hasValue()",message="hostname must be a valid DNS-1123 subdomain"
+ Host string `json:"host"`
+
+ // Port is the TCP port of the service.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:Minimum=1
+ // +kubebuilder:validation:Maximum=65535
+ Port int32 `json:"port"`
+}
+
+// ZoneServiceConfigStatus defines the observed state of ZoneServiceConfig.
+type ZoneServiceConfigStatus struct {
+ // +listType=map
+ // +listMapKey=type
+ // +patchStrategy=merge
+ // +patchMergeKey=type
+ // +optional
+ Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
+
+ // SFTPServiceConfigRef references the projected SFTPServiceConfig.
+ // +optional
+ SFTPServiceConfigRef *types.ObjectRef `json:"sftpServiceConfigRef,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:printcolumn:name="API URL",type="string",JSONPath=".spec.api.url"
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
+
+// ZoneServiceConfig is the Schema for the zoneserviceconfigs API.
+// It must use the same name and namespace as the admin Zone it configures.
+type ZoneServiceConfig struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec ZoneServiceConfigSpec `json:"spec,omitempty"`
+ Status ZoneServiceConfigStatus `json:"status,omitempty"`
+}
+
+var _ types.Object = &ZoneServiceConfig{}
+
+func (r *ZoneServiceConfig) GetConditions() []metav1.Condition {
+ return r.Status.Conditions
+}
+
+func (r *ZoneServiceConfig) SetCondition(condition metav1.Condition) bool {
+ return meta.SetStatusCondition(&r.Status.Conditions, condition)
+}
+
+// +kubebuilder:object:root=true
+
+// ZoneServiceConfigList contains a list of ZoneServiceConfig.
+type ZoneServiceConfigList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []ZoneServiceConfig `json:"items"`
+}
+
+var _ types.ObjectList = &ZoneServiceConfigList{}
+
+func (r *ZoneServiceConfigList) GetItems() []types.Object {
+ items := make([]types.Object, len(r.Items))
+ for i := range r.Items {
+ items[i] = &r.Items[i]
+ }
+ return items
+}
+
+func init() {
+ SchemeBuilder.Register(&ZoneServiceConfig{}, &ZoneServiceConfigList{})
+}
diff --git a/file/api/v1/zz_generated.deepcopy.go b/file/api/v1/zz_generated.deepcopy.go
new file mode 100644
index 000000000..adc447eb3
--- /dev/null
+++ b/file/api/v1/zz_generated.deepcopy.go
@@ -0,0 +1,530 @@
+//go:build !ignore_autogenerated
+
+// SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v1
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Approval) DeepCopyInto(out *Approval) {
+ *out = *in
+ if in.TrustedTeams != nil {
+ in, out := &in.TrustedTeams, &out.TrustedTeams
+ *out = make([]string, len(*in))
+ copy(*out, *in)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Approval.
+func (in *Approval) DeepCopy() *Approval {
+ if in == nil {
+ return nil
+ }
+ out := new(Approval)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileExposure) DeepCopyInto(out *FileExposure) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileExposure.
+func (in *FileExposure) DeepCopy() *FileExposure {
+ if in == nil {
+ return nil
+ }
+ out := new(FileExposure)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *FileExposure) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileExposureList) DeepCopyInto(out *FileExposureList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]FileExposure, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileExposureList.
+func (in *FileExposureList) DeepCopy() *FileExposureList {
+ if in == nil {
+ return nil
+ }
+ out := new(FileExposureList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *FileExposureList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileExposureSpec) DeepCopyInto(out *FileExposureSpec) {
+ *out = *in
+ if in.Zone != nil {
+ in, out := &in.Zone, &out.Zone
+ *out = (*in).DeepCopy()
+ }
+ if in.SFTP != nil {
+ in, out := &in.SFTP, &out.SFTP
+ *out = new(FileSFTP)
+ (*in).DeepCopyInto(*out)
+ }
+ in.Approval.DeepCopyInto(&out.Approval)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileExposureSpec.
+func (in *FileExposureSpec) DeepCopy() *FileExposureSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(FileExposureSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileExposureStatus) DeepCopyInto(out *FileExposureStatus) {
+ *out = *in
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]metav1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ if in.FileTypeRef != nil {
+ in, out := &in.FileTypeRef, &out.FileTypeRef
+ *out = (*in).DeepCopy()
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileExposureStatus.
+func (in *FileExposureStatus) DeepCopy() *FileExposureStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(FileExposureStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileSFTP) DeepCopyInto(out *FileSFTP) {
+ *out = *in
+ if in.PublicKeys != nil {
+ in, out := &in.PublicKeys, &out.PublicKeys
+ *out = make([]SSHPublicKeySpec, len(*in))
+ copy(*out, *in)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileSFTP.
+func (in *FileSFTP) DeepCopy() *FileSFTP {
+ if in == nil {
+ return nil
+ }
+ out := new(FileSFTP)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileSubscription) DeepCopyInto(out *FileSubscription) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileSubscription.
+func (in *FileSubscription) DeepCopy() *FileSubscription {
+ if in == nil {
+ return nil
+ }
+ out := new(FileSubscription)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *FileSubscription) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileSubscriptionList) DeepCopyInto(out *FileSubscriptionList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]FileSubscription, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileSubscriptionList.
+func (in *FileSubscriptionList) DeepCopy() *FileSubscriptionList {
+ if in == nil {
+ return nil
+ }
+ out := new(FileSubscriptionList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *FileSubscriptionList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileSubscriptionSpec) DeepCopyInto(out *FileSubscriptionSpec) {
+ *out = *in
+ if in.Zone != nil {
+ in, out := &in.Zone, &out.Zone
+ *out = (*in).DeepCopy()
+ }
+ if in.SFTP != nil {
+ in, out := &in.SFTP, &out.SFTP
+ *out = new(FileSFTP)
+ (*in).DeepCopyInto(*out)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileSubscriptionSpec.
+func (in *FileSubscriptionSpec) DeepCopy() *FileSubscriptionSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(FileSubscriptionSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileSubscriptionStatus) DeepCopyInto(out *FileSubscriptionStatus) {
+ *out = *in
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]metav1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ if in.Approval != nil {
+ in, out := &in.Approval, &out.Approval
+ *out = (*in).DeepCopy()
+ }
+ if in.ApprovalRequest != nil {
+ in, out := &in.ApprovalRequest, &out.ApprovalRequest
+ *out = (*in).DeepCopy()
+ }
+ if in.FileTypeRef != nil {
+ in, out := &in.FileTypeRef, &out.FileTypeRef
+ *out = (*in).DeepCopy()
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileSubscriptionStatus.
+func (in *FileSubscriptionStatus) DeepCopy() *FileSubscriptionStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(FileSubscriptionStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileType) DeepCopyInto(out *FileType) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ out.Spec = in.Spec
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileType.
+func (in *FileType) DeepCopy() *FileType {
+ if in == nil {
+ return nil
+ }
+ out := new(FileType)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *FileType) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileTypeList) DeepCopyInto(out *FileTypeList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]FileType, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileTypeList.
+func (in *FileTypeList) DeepCopy() *FileTypeList {
+ if in == nil {
+ return nil
+ }
+ out := new(FileTypeList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *FileTypeList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileTypeSpec) DeepCopyInto(out *FileTypeSpec) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileTypeSpec.
+func (in *FileTypeSpec) DeepCopy() *FileTypeSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(FileTypeSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileTypeStatus) DeepCopyInto(out *FileTypeStatus) {
+ *out = *in
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]metav1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ if in.FileExposureRef != nil {
+ in, out := &in.FileExposureRef, &out.FileExposureRef
+ *out = (*in).DeepCopy()
+ }
+ if in.SFTPInstance != nil {
+ in, out := &in.SFTPInstance, &out.SFTPInstance
+ *out = (*in).DeepCopy()
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileTypeStatus.
+func (in *FileTypeStatus) DeepCopy() *FileTypeStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(FileTypeStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SSHPublicKeySpec) DeepCopyInto(out *SSHPublicKeySpec) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SSHPublicKeySpec.
+func (in *SSHPublicKeySpec) DeepCopy() *SSHPublicKeySpec {
+ if in == nil {
+ return nil
+ }
+ out := new(SSHPublicKeySpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ServiceEndpoint) DeepCopyInto(out *ServiceEndpoint) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceEndpoint.
+func (in *ServiceEndpoint) DeepCopy() *ServiceEndpoint {
+ if in == nil {
+ return nil
+ }
+ out := new(ServiceEndpoint)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ZoneServiceConfig) DeepCopyInto(out *ZoneServiceConfig) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ZoneServiceConfig.
+func (in *ZoneServiceConfig) DeepCopy() *ZoneServiceConfig {
+ if in == nil {
+ return nil
+ }
+ out := new(ZoneServiceConfig)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ZoneServiceConfig) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ZoneServiceConfigList) DeepCopyInto(out *ZoneServiceConfigList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]ZoneServiceConfig, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ZoneServiceConfigList.
+func (in *ZoneServiceConfigList) DeepCopy() *ZoneServiceConfigList {
+ if in == nil {
+ return nil
+ }
+ out := new(ZoneServiceConfigList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ZoneServiceConfigList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ZoneServiceConfigSpec) DeepCopyInto(out *ZoneServiceConfigSpec) {
+ *out = *in
+ out.API = in.API
+ if in.Service != nil {
+ in, out := &in.Service, &out.Service
+ *out = new(ServiceEndpoint)
+ **out = **in
+ }
+ if in.ServiceExternal != nil {
+ in, out := &in.ServiceExternal, &out.ServiceExternal
+ *out = new(ServiceEndpoint)
+ **out = **in
+ }
+ if in.Zone != nil {
+ in, out := &in.Zone, &out.Zone
+ *out = (*in).DeepCopy()
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ZoneServiceConfigSpec.
+func (in *ZoneServiceConfigSpec) DeepCopy() *ZoneServiceConfigSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(ZoneServiceConfigSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ZoneServiceConfigStatus) DeepCopyInto(out *ZoneServiceConfigStatus) {
+ *out = *in
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]metav1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ if in.SFTPServiceConfigRef != nil {
+ in, out := &in.SFTPServiceConfigRef, &out.SFTPServiceConfigRef
+ *out = (*in).DeepCopy()
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ZoneServiceConfigStatus.
+func (in *ZoneServiceConfigStatus) DeepCopy() *ZoneServiceConfigStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(ZoneServiceConfigStatus)
+ in.DeepCopyInto(out)
+ return out
+}
diff --git a/file/cmd/file-operator/main.go b/file/cmd/file-operator/main.go
new file mode 100644
index 000000000..08fa75106
--- /dev/null
+++ b/file/cmd/file-operator/main.go
@@ -0,0 +1,185 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "crypto/tls"
+ "flag"
+ "os"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/healthz"
+ "sigs.k8s.io/controller-runtime/pkg/log/zap"
+ "sigs.k8s.io/controller-runtime/pkg/metrics"
+ "sigs.k8s.io/controller-runtime/pkg/metrics/filters"
+ metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
+ "sigs.k8s.io/controller-runtime/pkg/webhook"
+
+ "github.com/telekom/controlplane/file/internal/controller"
+ "github.com/telekom/controlplane/file/internal/index"
+ webhookv1 "github.com/telekom/controlplane/file/internal/webhook/v1"
+ secretmetrics "github.com/telekom/controlplane/secret-manager/api/metrics"
+
+ _ "k8s.io/client-go/plugin/pkg/client/auth"
+)
+
+var (
+ scheme = runtime.NewScheme()
+ setupLog = ctrl.Log.WithName("setup")
+)
+
+func init() {
+ controller.RegisterSchemesOrDie(scheme)
+ // +kubebuilder:scaffold:scheme
+}
+
+func main() {
+ var metricsAddr string
+ var enableLeaderElection bool
+ var probeAddr string
+ var secureMetrics bool
+ var enableHTTP2 bool
+ var metricsCertPath, metricsCertName, metricsCertKey string
+ var webhookCertPath, webhookCertName, webhookCertKey string
+ var tlsOpts []func(*tls.Config)
+ flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
+ flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
+ flag.BoolVar(&enableLeaderElection, "leader-elect", false,
+ "Enable leader election for controller manager. "+
+ "Enabling this will ensure there is only one active controller manager.")
+ flag.BoolVar(&secureMetrics, "metrics-secure", true,
+ "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.")
+ flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.")
+ flag.StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.")
+ flag.StringVar(&webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.")
+ flag.StringVar(&metricsCertPath, "metrics-cert-path", "",
+ "The directory that contains the metrics server certificate.")
+ flag.StringVar(&metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.")
+ flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.")
+ flag.BoolVar(&enableHTTP2, "enable-http2", false,
+ "If set, HTTP/2 will be enabled for the metrics and webhook servers")
+ opts := zap.Options{
+ Development: true,
+ }
+ opts.BindFlags(flag.CommandLine)
+ flag.Parse()
+
+ ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
+
+ secretmetrics.RegisterPrometheusMetrics(metrics.Registry)
+
+ disableHTTP2 := func(c *tls.Config) {
+ setupLog.Info("disabling http/2")
+ c.NextProtos = []string{"http/1.1"}
+ }
+
+ if !enableHTTP2 {
+ tlsOpts = append(tlsOpts, disableHTTP2)
+ }
+
+ webhookServerOptions := webhook.Options{
+ TLSOpts: tlsOpts,
+ }
+
+ if webhookCertPath != "" {
+ setupLog.Info("Initializing webhook certificate watcher using provided certificates",
+ "webhook-cert-path", webhookCertPath, "webhook-cert-name", webhookCertName, "webhook-cert-key", webhookCertKey)
+
+ webhookServerOptions.CertDir = webhookCertPath
+ webhookServerOptions.CertName = webhookCertName
+ webhookServerOptions.KeyName = webhookCertKey
+ }
+
+ webhookServer := webhook.NewServer(webhookServerOptions)
+
+ metricsServerOptions := metricsserver.Options{
+ BindAddress: metricsAddr,
+ SecureServing: secureMetrics,
+ TLSOpts: tlsOpts,
+ }
+
+ if secureMetrics {
+ metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization
+ }
+
+ if metricsCertPath != "" {
+ setupLog.Info("Initializing metrics certificate watcher using provided certificates",
+ "metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey)
+
+ metricsServerOptions.CertDir = metricsCertPath
+ metricsServerOptions.CertName = metricsCertName
+ metricsServerOptions.KeyName = metricsCertKey
+ }
+
+ mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
+ Scheme: scheme,
+ Metrics: metricsServerOptions,
+ WebhookServer: webhookServer,
+ HealthProbeBindAddress: probeAddr,
+ LeaderElection: enableLeaderElection,
+ LeaderElectionID: "file.cp.ei.telekom.de",
+ })
+ if err != nil {
+ setupLog.Error(err, "unable to start manager")
+ os.Exit(1)
+ }
+
+ rootCtx := ctrl.SetupSignalHandler()
+ index.RegisterIndicesOrDie(rootCtx, mgr)
+
+ if err = (&controller.ZoneServiceConfigReconciler{
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "ZoneServiceConfig")
+ os.Exit(1)
+ }
+ if err = (&controller.FileTypeReconciler{
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "FileType")
+ os.Exit(1)
+ }
+ if err = (&controller.FileExposureReconciler{
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "FileExposure")
+ os.Exit(1)
+ }
+ if err = (&controller.FileSubscriptionReconciler{
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "FileSubscription")
+ os.Exit(1)
+ }
+ // +kubebuilder:scaffold:builder
+
+ 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)
+ }
+
+ if os.Getenv("ENABLE_WEBHOOKS") != "false" {
+ if err := webhookv1.SetupZoneServiceConfigWebhookWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create webhook", "webhook", "ZoneServiceConfig")
+ os.Exit(1)
+ }
+ }
+
+ setupLog.Info("starting manager")
+ if err := mgr.Start(rootCtx); err != nil {
+ setupLog.Error(err, "problem running manager")
+ os.Exit(1)
+ }
+}
diff --git a/file/config/certmanager/certificate-webhook.yaml b/file/config/certmanager/certificate-webhook.yaml
new file mode 100644
index 000000000..52192987b
--- /dev/null
+++ b/file/config/certmanager/certificate-webhook.yaml
@@ -0,0 +1,24 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# The following manifests contain a self-signed issuer CR and a certificate CR.
+# More document can be found at https://docs.cert-manager.io
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+ labels:
+ app.kubernetes.io/name: file
+ app.kubernetes.io/managed-by: kustomize
+ name: serving-cert # this name should match the one appeared in kustomizeconfig.yaml
+ namespace: system
+spec:
+ # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize
+ # replacements in the config/default/kustomization.yaml file.
+ dnsNames:
+ - SERVICE_NAME.SERVICE_NAMESPACE.svc
+ - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local
+ issuerRef:
+ kind: Issuer
+ name: controlplane-issuer
+ secretName: file-webhook-server-cert # this secret will not be prefixed, since it's not managed by kustomize
diff --git a/file/config/certmanager/kustomization.yaml b/file/config/certmanager/kustomization.yaml
new file mode 100644
index 000000000..99be5329e
--- /dev/null
+++ b/file/config/certmanager/kustomization.yaml
@@ -0,0 +1,9 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+resources:
+- certificate-webhook.yaml
+
+configurations:
+- kustomizeconfig.yaml
diff --git a/file/config/certmanager/kustomizeconfig.yaml b/file/config/certmanager/kustomizeconfig.yaml
new file mode 100644
index 000000000..8394a1147
--- /dev/null
+++ b/file/config/certmanager/kustomizeconfig.yaml
@@ -0,0 +1,12 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# This configuration is for teaching kustomize how to update name ref substitution
+nameReference:
+- kind: Issuer
+ group: cert-manager.io
+ fieldSpecs:
+ - kind: Certificate
+ group: cert-manager.io
+ path: spec/issuerRef/name
diff --git a/file/config/crd/bases/file.cp.ei.telekom.de_fileexposures.yaml b/file/config/crd/bases/file.cp.ei.telekom.de_fileexposures.yaml
new file mode 100644
index 000000000..76584173b
--- /dev/null
+++ b/file/config/crd/bases/file.cp.ei.telekom.de_fileexposures.yaml
@@ -0,0 +1,217 @@
+# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.20.1
+ name: fileexposures.file.cp.ei.telekom.de
+spec:
+ group: file.cp.ei.telekom.de
+ names:
+ kind: FileExposure
+ listKind: FileExposureList
+ plural: fileexposures
+ singular: fileexposure
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .spec.fileType
+ name: FileType
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1
+ schema:
+ openAPIV3Schema:
+ description: FileExposure is the Schema for the fileexposures API.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: FileExposureSpec defines a provider-side file exposure.
+ properties:
+ approval:
+ description: Approval configures how subscriptions to this file exposure
+ are approved.
+ properties:
+ strategy:
+ default: Simple
+ description: Strategy defines the approval mode.
+ enum:
+ - Auto
+ - Simple
+ - FourEyes
+ type: string
+ trustedTeams:
+ description: TrustedTeams identifies teams that are trusted for
+ approving subscriptions.
+ items:
+ type: string
+ maxItems: 10
+ minItems: 0
+ type: array
+ required:
+ - strategy
+ type: object
+ fileType:
+ type: string
+ provider:
+ description: Provider optionally identifies the providing application.
+ type: string
+ sftp:
+ description: SFTP configures provider-side SFTP access for this file
+ exposure.
+ properties:
+ publicKeys:
+ description: PublicKeys contains SSH public keys for the SFTP
+ user of the FileType.
+ items:
+ description: SSHPublicKeySpec carries an SSH public key.
+ properties:
+ key:
+ description: Key is the SSH public key value.
+ minLength: 1
+ type: string
+ required:
+ - key
+ type: object
+ minItems: 1
+ type: array
+ required:
+ - publicKeys
+ type: object
+ visibility:
+ default: Enterprise
+ description: Visibility defines who can subscribe to this file exposure.
+ enum:
+ - World
+ - Zone
+ - Enterprise
+ type: string
+ zone:
+ description: Zone identifies the zone where this file exposure is
+ provided.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ required:
+ - fileType
+ - visibility
+ - zone
+ type: object
+ status:
+ description: FileExposureStatus defines the observed state of FileExposure.
+ properties:
+ conditions:
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ fileTypeRef:
+ description: FileTypeRef references the FileType this exposure provides.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/file/config/crd/bases/file.cp.ei.telekom.de_filesubscriptions.yaml b/file/config/crd/bases/file.cp.ei.telekom.de_filesubscriptions.yaml
new file mode 100644
index 000000000..97fe1d78a
--- /dev/null
+++ b/file/config/crd/bases/file.cp.ei.telekom.de_filesubscriptions.yaml
@@ -0,0 +1,219 @@
+# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.20.1
+ name: filesubscriptions.file.cp.ei.telekom.de
+spec:
+ group: file.cp.ei.telekom.de
+ names:
+ kind: FileSubscription
+ listKind: FileSubscriptionList
+ plural: filesubscriptions
+ singular: filesubscription
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .spec.fileType
+ name: FileType
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1
+ schema:
+ openAPIV3Schema:
+ description: FileSubscription is the Schema for the filesubscriptions API.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: FileSubscriptionSpec defines a consumer-side subscription
+ to a file type.
+ properties:
+ fileType:
+ description: FileType references the FileType to subscribe to.
+ type: string
+ sftp:
+ description: SFTP configures consumer-side SFTP access for this file
+ subscription.
+ properties:
+ publicKeys:
+ description: PublicKeys contains SSH public keys for the SFTP
+ user of the FileType.
+ items:
+ description: SSHPublicKeySpec carries an SSH public key.
+ properties:
+ key:
+ description: Key is the SSH public key value.
+ minLength: 1
+ type: string
+ required:
+ - key
+ type: object
+ minItems: 1
+ type: array
+ required:
+ - publicKeys
+ type: object
+ zone:
+ description: Zone identifies the zone where the subscriber is located.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ required:
+ - fileType
+ - zone
+ type: object
+ status:
+ description: FileSubscriptionStatus defines the observed state of FileSubscription.
+ properties:
+ approval:
+ description: Approval references the Approval CR managing subscription
+ approval.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ approvalRequest:
+ description: ApprovalRequest references the ApprovalRequest CR for
+ this subscription.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ conditions:
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ fileTypeRef:
+ description: FileTypeRef references the subscribed FileType.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/file/config/crd/bases/file.cp.ei.telekom.de_filetypes.yaml b/file/config/crd/bases/file.cp.ei.telekom.de_filetypes.yaml
new file mode 100644
index 000000000..e931aa3b9
--- /dev/null
+++ b/file/config/crd/bases/file.cp.ei.telekom.de_filetypes.yaml
@@ -0,0 +1,158 @@
+# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.20.1
+ name: filetypes.file.cp.ei.telekom.de
+spec:
+ group: file.cp.ei.telekom.de
+ names:
+ kind: FileType
+ listKind: FileTypeList
+ plural: filetypes
+ singular: filetype
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1
+ schema:
+ openAPIV3Schema:
+ description: FileType is the Schema for the filetypes API.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: FileTypeSpec defines a logical file type that can be exposed
+ and subscribed to.
+ properties:
+ description:
+ description: Description is a human-readable description of this file
+ type.
+ type: string
+ type: object
+ status:
+ description: FileTypeStatus defines the observed state of FileType.
+ properties:
+ conditions:
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ fileExposureRef:
+ description: FileExposureRef references the active FileExposure for
+ this file type.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ sftpInstance:
+ description: SFTPInstance references the projected SFTP instance for
+ this file type.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/file/config/crd/bases/file.cp.ei.telekom.de_zoneserviceconfigs.yaml b/file/config/crd/bases/file.cp.ei.telekom.de_zoneserviceconfigs.yaml
new file mode 100644
index 000000000..26d969473
--- /dev/null
+++ b/file/config/crd/bases/file.cp.ei.telekom.de_zoneserviceconfigs.yaml
@@ -0,0 +1,231 @@
+# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.20.1
+ name: zoneserviceconfigs.file.cp.ei.telekom.de
+spec:
+ group: file.cp.ei.telekom.de
+ names:
+ kind: ZoneServiceConfig
+ listKind: ZoneServiceConfigList
+ plural: zoneserviceconfigs
+ singular: zoneserviceconfig
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .spec.api.url
+ name: API URL
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1
+ schema:
+ openAPIV3Schema:
+ description: |-
+ ZoneServiceConfig is the Schema for the zoneserviceconfigs API.
+ It must use the same name and namespace as the admin Zone it configures.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: |-
+ ZoneServiceConfigSpec defines the file-domain service configuration for one zone.
+ A ZoneServiceConfig must use the same name and namespace as its admin Zone.
+ properties:
+ api:
+ properties:
+ name:
+ description: Name is the name of the created route. It must be
+ unique within the zone.
+ pattern: ^[a-z0-9]+(-?[a-z0-9]+)*$
+ type: string
+ path:
+ description: Path is the path of the route exposed on the gateway.
+ pattern: ^/.*$
+ type: string
+ type:
+ description: 'Type selects the route behavior: TeamAPI (authenticated,
+ no ACL) or Proxy (passthrough reverse proxy).'
+ enum:
+ - TeamAPI
+ - Proxy
+ type: string
+ url:
+ description: Url is the upstream URL of the route.
+ format: uri
+ type: string
+ required:
+ - name
+ - path
+ - type
+ - url
+ type: object
+ service:
+ description: Service is the internal SFTP service endpoint.
+ properties:
+ host:
+ description: Host is the hostname or IP address of the service.
+ maxLength: 253
+ type: string
+ x-kubernetes-validations:
+ - message: hostname must be a valid DNS-1123 subdomain
+ rule: '!format.dns1123Subdomain().validate(self).hasValue()'
+ port:
+ description: Port is the TCP port of the service.
+ format: int32
+ maximum: 65535
+ minimum: 1
+ type: integer
+ required:
+ - host
+ - port
+ type: object
+ serviceExternal:
+ description: ServiceExternal is the externally reachable SFTP service
+ endpoint.
+ properties:
+ host:
+ description: Host is the hostname or IP address of the service.
+ maxLength: 253
+ type: string
+ x-kubernetes-validations:
+ - message: hostname must be a valid DNS-1123 subdomain
+ rule: '!format.dns1123Subdomain().validate(self).hasValue()'
+ port:
+ description: Port is the TCP port of the service.
+ format: int32
+ maximum: 65535
+ minimum: 1
+ type: integer
+ required:
+ - host
+ - port
+ type: object
+ zone:
+ description: Zone identifies the zone where this file exposure is
+ provided.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ required:
+ - api
+ - zone
+ type: object
+ status:
+ description: ZoneServiceConfigStatus defines the observed state of ZoneServiceConfig.
+ properties:
+ conditions:
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ sftpServiceConfigRef:
+ description: SFTPServiceConfigRef references the projected SFTPServiceConfig.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/file/config/crd/kustomization.yaml b/file/config/crd/kustomization.yaml
new file mode 100644
index 000000000..3f99922b5
--- /dev/null
+++ b/file/config/crd/kustomization.yaml
@@ -0,0 +1,9 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+resources:
+- bases/file.cp.ei.telekom.de_filetypes.yaml
+- bases/file.cp.ei.telekom.de_fileexposures.yaml
+- bases/file.cp.ei.telekom.de_filesubscriptions.yaml
+- bases/file.cp.ei.telekom.de_zoneserviceconfigs.yaml
diff --git a/file/config/default/deployment_patch.yaml b/file/config/default/deployment_patch.yaml
new file mode 100644
index 000000000..719fa24e8
--- /dev/null
+++ b/file/config/default/deployment_patch.yaml
@@ -0,0 +1,32 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: controller-manager
+ namespace: system
+spec:
+ template:
+ spec:
+ containers:
+ - name: manager
+ volumeMounts:
+ - name: secretmgr-token
+ mountPath: /var/run/secrets/secretmgr
+ readOnly: true
+ - name: controlplane-trust-bundle
+ mountPath: /var/run/secrets/trust-bundle
+ readOnly: true
+ volumes:
+ - name: secretmgr-token
+ projected:
+ sources:
+ - serviceAccountToken:
+ path: token
+ expirationSeconds: 600
+ audience: secret-manager
+ - name: controlplane-trust-bundle
+ configMap:
+ name: controlplane-trust-bundle
diff --git a/file/config/default/kustomization.yaml b/file/config/default/kustomization.yaml
new file mode 100644
index 000000000..356cf00ae
--- /dev/null
+++ b/file/config/default/kustomization.yaml
@@ -0,0 +1,250 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# Adds namespace to all resources.
+namespace: controlplane-system
+
+# Value of this field is prepended to the
+# names of all resources, e.g. a deployment named
+# "wordpress" becomes "alices-wordpress".
+# Note that it should also match with the prefix (text before '-') of the namespace
+# field above.
+namePrefix: file-
+
+# Labels to add to all resources and selectors.
+labels:
+- includeSelectors: true
+ pairs:
+ domain: file
+ fields:
+ - path: spec/selector/matchLabels
+ create: true
+ kind: ServiceMonitor
+ group: monitoring.coreos.com
+resources:
+- ../crd
+- ../rbac
+- ../manager
+# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
+# crd/kustomization.yaml
+- ../webhook
+# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required.
+- ../certmanager
+# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.
+- ../prometheus
+# [METRICS] Expose the controller manager metrics service.
+- metrics_service.yaml
+# [NETWORK POLICY] Protect the /metrics endpoint and Webhook Server with NetworkPolicy.
+# Only Pod(s) running a namespace labeled with 'metrics: enabled' will be able to gather the metrics.
+# Only CR(s) which requires webhooks and are applied on namespaces labeled with 'webhooks: enabled' will
+# be able to communicate with the Webhook Server.
+#- ../network-policy
+
+# Uncomment the patches line if you enable Metrics
+patches:
+# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443.
+# More info: https://book.kubebuilder.io/reference/metrics
+- path: manager_metrics_patch.yaml
+ target:
+ kind: Deployment
+
+# [SECRET_MANAGER] The following patch will add the secret manager to the deployment.
+- path: deployment_patch.yaml
+ target:
+ kind: Deployment
+- path: namespace_patch.yaml
+ target:
+ kind: Namespace
+
+# Uncomment the patches line if you enable Metrics and CertManager
+# [METRICS-WITH-CERTS] To enable metrics protected with certManager, uncomment the following line.
+# This patch will protect the metrics with certManager self-signed certs.
+#- path: cert_metrics_manager_patch.yaml
+# target:
+# kind: Deployment
+
+# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
+# crd/kustomization.yaml
+- path: manager_webhook_patch.yaml
+ target:
+ kind: Deployment
+
+# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix.
+# Uncomment the following replacements to add the cert-manager CA injection annotations
+replacements:
+# - source: # Uncomment the following block to enable certificates for metrics
+# kind: Service
+# version: v1
+# name: controller-manager-metrics-service
+# fieldPath: metadata.name
+# targets:
+# - select:
+# kind: Certificate
+# group: cert-manager.io
+# version: v1
+# name: metrics-certs
+# fieldPaths:
+# - spec.dnsNames.0
+# - spec.dnsNames.1
+# options:
+# delimiter: '.'
+# index: 0
+# create: true
+# - select: # Uncomment the following to set the Service name for TLS config in Prometheus ServiceMonitor
+# kind: ServiceMonitor
+# group: monitoring.coreos.com
+# version: v1
+# name: controller-manager-metrics-monitor
+# fieldPaths:
+# - spec.endpoints.0.tlsConfig.serverName
+# options:
+# delimiter: '.'
+# index: 0
+# create: true
+
+# - source:
+# kind: Service
+# version: v1
+# name: controller-manager-metrics-service
+# fieldPath: metadata.namespace
+# targets:
+# - select:
+# kind: Certificate
+# group: cert-manager.io
+# version: v1
+# name: metrics-certs
+# fieldPaths:
+# - spec.dnsNames.0
+# - spec.dnsNames.1
+# options:
+# delimiter: '.'
+# index: 1
+# create: true
+# - select: # Uncomment the following to set the Service namespace for TLS in Prometheus ServiceMonitor
+# kind: ServiceMonitor
+# group: monitoring.coreos.com
+# version: v1
+# name: controller-manager-metrics-monitor
+# fieldPaths:
+# - spec.endpoints.0.tlsConfig.serverName
+# options:
+# delimiter: '.'
+# index: 1
+# create: true
+
+ - source: # Uncomment the following block if you have any webhook
+ kind: Service
+ version: v1
+ name: webhook-service
+ fieldPath: .metadata.name # Name of the service
+ targets:
+ - select:
+ kind: Certificate
+ group: cert-manager.io
+ version: v1
+ name: serving-cert
+ fieldPaths:
+ - .spec.dnsNames.0
+ - .spec.dnsNames.1
+ options:
+ delimiter: '.'
+ index: 0
+ create: true
+ - source:
+ kind: Service
+ version: v1
+ name: webhook-service
+ fieldPath: .metadata.namespace # Namespace of the service
+ targets:
+ - select:
+ kind: Certificate
+ group: cert-manager.io
+ version: v1
+ name: serving-cert
+ fieldPaths:
+ - .spec.dnsNames.0
+ - .spec.dnsNames.1
+ options:
+ delimiter: '.'
+ index: 1
+ create: true
+
+ - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation)
+ kind: Certificate
+ group: cert-manager.io
+ version: v1
+ name: serving-cert # This name should match the one in certificate.yaml
+ fieldPath: .metadata.namespace # Namespace of the certificate CR
+ targets:
+ - select:
+ kind: ValidatingWebhookConfiguration
+ fieldPaths:
+ - .metadata.annotations.[cert-manager.io/inject-ca-from]
+ options:
+ delimiter: '/'
+ index: 0
+ create: true
+ - source:
+ kind: Certificate
+ group: cert-manager.io
+ version: v1
+ name: serving-cert
+ fieldPath: .metadata.name
+ targets:
+ - select:
+ kind: ValidatingWebhookConfiguration
+ fieldPaths:
+ - .metadata.annotations.[cert-manager.io/inject-ca-from]
+ options:
+ delimiter: '/'
+ index: 1
+ create: true
+
+# - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting )
+# kind: Certificate
+# group: cert-manager.io
+# version: v1
+# name: serving-cert
+# fieldPath: .metadata.namespace # Namespace of the certificate CR
+# targets:
+# - select:
+# kind: MutatingWebhookConfiguration
+# fieldPaths:
+# - .metadata.annotations.[cert-manager.io/inject-ca-from]
+# options:
+# delimiter: '/'
+# index: 0
+# create: true
+ - source:
+ kind: Certificate
+ group: cert-manager.io
+ version: v1
+ name: serving-cert
+ fieldPath: .metadata.name
+ targets:
+ - select:
+ kind: MutatingWebhookConfiguration
+ fieldPaths:
+ - .metadata.annotations.[cert-manager.io/inject-ca-from]
+ options:
+ delimiter: '/'
+ index: 1
+ create: true
+
+# - source: # Uncomment the following block if you have a ConversionWebhook (--conversion)
+# kind: Certificate
+# group: cert-manager.io
+# version: v1
+# name: serving-cert
+# fieldPath: .metadata.namespace # Namespace of the certificate CR
+# targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD.
+# +kubebuilder:scaffold:crdkustomizecainjectionns
+# - source:
+# kind: Certificate
+# group: cert-manager.io
+# version: v1
+# name: serving-cert
+# fieldPath: .metadata.name
+# targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD.
+# +kubebuilder:scaffold:crdkustomizecainjectionname
diff --git a/file/config/default/manager_metrics_patch.yaml b/file/config/default/manager_metrics_patch.yaml
new file mode 100644
index 000000000..c0899178d
--- /dev/null
+++ b/file/config/default/manager_metrics_patch.yaml
@@ -0,0 +1,8 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# This patch adds the args to allow exposing the metrics endpoint using HTTPS
+- op: add
+ path: /spec/template/spec/containers/0/args/0
+ value: --metrics-bind-address=:8443
diff --git a/file/config/default/manager_webhook_patch.yaml b/file/config/default/manager_webhook_patch.yaml
new file mode 100644
index 000000000..b7191eb03
--- /dev/null
+++ b/file/config/default/manager_webhook_patch.yaml
@@ -0,0 +1,35 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# This patch ensures the webhook certificates are properly mounted in the manager container.
+# It configures the necessary arguments, volumes, volume mounts, and container ports.
+
+# Add the --webhook-cert-path argument for configuring the webhook certificate path
+- op: add
+ path: /spec/template/spec/containers/0/args/-
+ value: --webhook-cert-path=/tmp/k8s-webhook-server/serving-certs
+
+# Add the volumeMount for the webhook certificates
+- op: add
+ path: /spec/template/spec/containers/0/volumeMounts/-
+ value:
+ mountPath: /tmp/k8s-webhook-server/serving-certs
+ name: webhook-certs
+ readOnly: true
+
+# Add the port configuration for the webhook server
+- op: add
+ path: /spec/template/spec/containers/0/ports/-
+ value:
+ containerPort: 9443
+ name: webhook-server
+ protocol: TCP
+
+# Add the volume configuration for the webhook certificates
+- op: add
+ path: /spec/template/spec/volumes/-
+ value:
+ name: webhook-certs
+ secret:
+ secretName: file-webhook-server-cert
diff --git a/file/config/default/metrics_service.yaml b/file/config/default/metrics_service.yaml
new file mode 100644
index 000000000..136af397d
--- /dev/null
+++ b/file/config/default/metrics_service.yaml
@@ -0,0 +1,22 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: v1
+kind: Service
+metadata:
+ labels:
+ control-plane: controller-manager
+ app.kubernetes.io/name: file
+ app.kubernetes.io/managed-by: kustomize
+ name: controller-manager-metrics-service
+ namespace: system
+spec:
+ ports:
+ - name: https
+ port: 8443
+ protocol: TCP
+ targetPort: 8443
+ selector:
+ control-plane: controller-manager
+ app.kubernetes.io/name: file
diff --git a/file/config/default/namespace_patch.yaml b/file/config/default/namespace_patch.yaml
new file mode 100644
index 000000000..09305459f
--- /dev/null
+++ b/file/config/default/namespace_patch.yaml
@@ -0,0 +1,7 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+- op: add
+ path: /metadata/labels/cp.ei.telekom.de~1secret-manager
+ value: "enabled"
diff --git a/file/config/manager/kustomization.yaml b/file/config/manager/kustomization.yaml
new file mode 100644
index 000000000..bbe5b667a
--- /dev/null
+++ b/file/config/manager/kustomization.yaml
@@ -0,0 +1,11 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+resources:
+- manager.yaml
+
+images:
+- name: controller
+ newName: controller
+ newTag: latest
diff --git a/file/config/manager/manager.yaml b/file/config/manager/manager.yaml
new file mode 100644
index 000000000..f1fe2b1c1
--- /dev/null
+++ b/file/config/manager/manager.yaml
@@ -0,0 +1,66 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: controller-manager
+ namespace: system
+ labels:
+ control-plane: controller-manager
+ app.kubernetes.io/name: file
+ app.kubernetes.io/managed-by: kustomize
+spec:
+ selector:
+ matchLabels:
+ control-plane: controller-manager
+ replicas: 1
+ template:
+ metadata:
+ annotations:
+ kubectl.kubernetes.io/default-container: manager
+ labels:
+ app.kubernetes.io/name: file
+ control-plane: controller-manager
+ spec:
+ securityContext:
+ runAsNonRoot: true
+ seccompProfile:
+ type: RuntimeDefault
+ containers:
+ - args:
+ - --leader-elect
+ - --health-probe-bind-address=:8081
+ image: ghcr.io/telekom/controlplane/file:stable
+ name: manager
+ ports:
+ - containerPort: 8081
+ name: health
+ protocol: TCP
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop:
+ - ALL
+ livenessProbe:
+ httpGet:
+ path: /healthz
+ port: 8081
+ initialDelaySeconds: 15
+ periodSeconds: 20
+ readinessProbe:
+ httpGet:
+ path: /readyz
+ port: 8081
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ resources:
+ limits:
+ cpu: 500m
+ memory: 128Mi
+ requests:
+ cpu: 30m
+ memory: 64Mi
+ serviceAccountName: controller-manager
+ terminationGracePeriodSeconds: 10
diff --git a/file/config/prometheus/kustomization.yaml b/file/config/prometheus/kustomization.yaml
new file mode 100644
index 000000000..94fd0a049
--- /dev/null
+++ b/file/config/prometheus/kustomization.yaml
@@ -0,0 +1,15 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+resources:
+- monitor.yaml
+
+# [PROMETHEUS-WITH-CERTS] The following patch configures the ServiceMonitor in ../prometheus
+# to securely reference certificates created and managed by cert-manager.
+# Additionally, ensure that you uncomment the [METRICS WITH CERTMANAGER] patch under config/default/kustomization.yaml
+# to mount the "metrics-server-cert" secret in the Manager Deployment.
+#patches:
+# - path: monitor_tls_patch.yaml
+# target:
+# kind: ServiceMonitor
diff --git a/file/config/prometheus/monitor.yaml b/file/config/prometheus/monitor.yaml
new file mode 100644
index 000000000..c70649b60
--- /dev/null
+++ b/file/config/prometheus/monitor.yaml
@@ -0,0 +1,31 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# Prometheus Monitor Service (Metrics)
+apiVersion: monitoring.coreos.com/v1
+kind: ServiceMonitor
+metadata:
+ labels:
+ control-plane: controller-manager
+ app.kubernetes.io/name: file
+ app.kubernetes.io/managed-by: kustomize
+ name: controller-manager-metrics-monitor
+ namespace: system
+spec:
+ endpoints:
+ - path: /metrics
+ port: https # Ensure this is the name of the port that exposes HTTPS metrics
+ scheme: https
+ bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
+ tlsConfig:
+ # TODO(user): The option insecureSkipVerify: true is not recommended for production since it disables
+ # certificate verification, exposing the system to potential man-in-the-middle attacks.
+ # For production environments, it is recommended to use cert-manager for automatic TLS certificate management.
+ # To apply this configuration, enable cert-manager and use the patch located at config/prometheus/servicemonitor_tls_patch.yaml,
+ # which securely references the certificate from the 'metrics-server-cert' secret.
+ insecureSkipVerify: true
+ selector:
+ matchLabels:
+ control-plane: controller-manager
+ app.kubernetes.io/name: file
diff --git a/file/config/prometheus/monitor_tls_patch.yaml b/file/config/prometheus/monitor_tls_patch.yaml
new file mode 100644
index 000000000..5bc0d4081
--- /dev/null
+++ b/file/config/prometheus/monitor_tls_patch.yaml
@@ -0,0 +1,23 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# Patch for Prometheus ServiceMonitor to enable secure TLS configuration
+# using certificates managed by cert-manager
+- op: replace
+ path: /spec/endpoints/0/tlsConfig
+ value:
+ # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize
+ serverName: SERVICE_NAME.SERVICE_NAMESPACE.svc
+ insecureSkipVerify: false
+ ca:
+ secret:
+ name: metrics-server-cert
+ key: ca.crt
+ cert:
+ secret:
+ name: metrics-server-cert
+ key: tls.crt
+ keySecret:
+ name: metrics-server-cert
+ key: tls.key
diff --git a/file/config/rbac/kustomization.yaml b/file/config/rbac/kustomization.yaml
new file mode 100644
index 000000000..66dc7e133
--- /dev/null
+++ b/file/config/rbac/kustomization.yaml
@@ -0,0 +1,13 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+resources:
+- service_account.yaml
+- role.yaml
+- role_binding.yaml
+- leader_election_role.yaml
+- leader_election_role_binding.yaml
+- metrics_auth_role.yaml
+- metrics_auth_role_binding.yaml
+- metrics_reader_role.yaml
diff --git a/file/config/rbac/leader_election_role.yaml b/file/config/rbac/leader_election_role.yaml
new file mode 100644
index 000000000..cc98c259f
--- /dev/null
+++ b/file/config/rbac/leader_election_role.yaml
@@ -0,0 +1,43 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+ labels:
+ app.kubernetes.io/name: file
+ app.kubernetes.io/managed-by: kustomize
+ name: leader-election-role
+rules:
+- apiGroups:
+ - ""
+ resources:
+ - configmaps
+ verbs:
+ - get
+ - list
+ - watch
+ - create
+ - update
+ - patch
+ - delete
+- apiGroups:
+ - coordination.k8s.io
+ resources:
+ - leases
+ verbs:
+ - get
+ - list
+ - watch
+ - create
+ - update
+ - patch
+ - delete
+- apiGroups:
+ - ""
+ resources:
+ - events
+ verbs:
+ - create
+ - patch
diff --git a/file/config/rbac/leader_election_role_binding.yaml b/file/config/rbac/leader_election_role_binding.yaml
new file mode 100644
index 000000000..ebf680bbe
--- /dev/null
+++ b/file/config/rbac/leader_election_role_binding.yaml
@@ -0,0 +1,19 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ labels:
+ app.kubernetes.io/name: file
+ app.kubernetes.io/managed-by: kustomize
+ name: leader-election-rolebinding
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: Role
+ name: leader-election-role
+subjects:
+- kind: ServiceAccount
+ name: controller-manager
+ namespace: system
diff --git a/file/config/rbac/metrics_auth_role.yaml b/file/config/rbac/metrics_auth_role.yaml
new file mode 100644
index 000000000..67bd0ffc1
--- /dev/null
+++ b/file/config/rbac/metrics_auth_role.yaml
@@ -0,0 +1,21 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: metrics-auth-role
+rules:
+- apiGroups:
+ - authentication.k8s.io
+ resources:
+ - tokenreviews
+ verbs:
+ - create
+- apiGroups:
+ - authorization.k8s.io
+ resources:
+ - subjectaccessreviews
+ verbs:
+ - create
diff --git a/file/config/rbac/metrics_auth_role_binding.yaml b/file/config/rbac/metrics_auth_role_binding.yaml
new file mode 100644
index 000000000..a2f150c9e
--- /dev/null
+++ b/file/config/rbac/metrics_auth_role_binding.yaml
@@ -0,0 +1,16 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: metrics-auth-rolebinding
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: metrics-auth-role
+subjects:
+- kind: ServiceAccount
+ name: controller-manager
+ namespace: system
diff --git a/file/config/rbac/metrics_reader_role.yaml b/file/config/rbac/metrics_reader_role.yaml
new file mode 100644
index 000000000..8665de98c
--- /dev/null
+++ b/file/config/rbac/metrics_reader_role.yaml
@@ -0,0 +1,13 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: metrics-reader
+rules:
+- nonResourceURLs:
+ - /metrics
+ verbs:
+ - get
diff --git a/file/config/rbac/role.yaml b/file/config/rbac/role.yaml
new file mode 100644
index 000000000..2dcfe67a0
--- /dev/null
+++ b/file/config/rbac/role.yaml
@@ -0,0 +1,139 @@
+# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: manager-role
+rules:
+- apiGroups:
+ - ""
+ resources:
+ - events
+ verbs:
+ - create
+ - patch
+- apiGroups:
+ - admin.cp.ei.telekom.de
+ resources:
+ - zones
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - admin.cp.ei.telekom.de
+ resources:
+ - zones/status
+ verbs:
+ - get
+- apiGroups:
+ - approval.cp.ei.telekom.de
+ resources:
+ - approvalrequests
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - approval.cp.ei.telekom.de
+ resources:
+ - approvals
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - file.cp.ei.telekom.de
+ resources:
+ - fileexposures
+ - filesubscriptions
+ - filetypes
+ - zoneserviceconfigs
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - file.cp.ei.telekom.de
+ resources:
+ - fileexposures/finalizers
+ - filesubscriptions/finalizers
+ - filetypes/finalizers
+ - zoneserviceconfigs/finalizers
+ verbs:
+ - update
+- apiGroups:
+ - file.cp.ei.telekom.de
+ resources:
+ - fileexposures/status
+ - filesubscriptions/status
+ - filetypes/status
+ - zoneserviceconfigs/status
+ verbs:
+ - get
+ - patch
+ - update
+- apiGroups:
+ - gateway.cp.ei.telekom.de
+ resources:
+ - consumers
+ - routes
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - gateway.cp.ei.telekom.de
+ resources:
+ - consumers/status
+ - routes/status
+ verbs:
+ - get
+- apiGroups:
+ - identity.cp.ei.telekom.de
+ resources:
+ - clients
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - instances
+ - sftpserviceconfigs
+ - users
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - instances/status
+ - sftpserviceconfigs/status
+ - users/status
+ verbs:
+ - get
diff --git a/file/config/rbac/role_binding.yaml b/file/config/rbac/role_binding.yaml
new file mode 100644
index 000000000..936d56ca6
--- /dev/null
+++ b/file/config/rbac/role_binding.yaml
@@ -0,0 +1,19 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ labels:
+ app.kubernetes.io/name: file
+ app.kubernetes.io/managed-by: kustomize
+ name: manager-rolebinding
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: manager-role
+subjects:
+- kind: ServiceAccount
+ name: controller-manager
+ namespace: system
diff --git a/file/config/rbac/service_account.yaml b/file/config/rbac/service_account.yaml
new file mode 100644
index 000000000..723658ba8
--- /dev/null
+++ b/file/config/rbac/service_account.yaml
@@ -0,0 +1,12 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ labels:
+ app.kubernetes.io/name: file
+ app.kubernetes.io/managed-by: kustomize
+ name: controller-manager
+ namespace: system
diff --git a/file/config/samples/file_v1_fileexposure.yaml b/file/config/samples/file_v1_fileexposure.yaml
new file mode 100644
index 000000000..91c0a5882
--- /dev/null
+++ b/file/config/samples/file_v1_fileexposure.yaml
@@ -0,0 +1,20 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: file.cp.ei.telekom.de/v1
+kind: FileExposure
+metadata:
+ labels:
+ cp.ei.telekom.de/environment: poc
+ name: sample-fileexposure
+spec:
+ fileType: sample-filetype
+ zone:
+ name: sample-zone
+ namespace: default
+ visibility: Enterprise
+ approval:
+ strategy: Simple
+ sftp:
+ publicKeys: []
diff --git a/file/config/samples/file_v1_filesubscription.yaml b/file/config/samples/file_v1_filesubscription.yaml
new file mode 100644
index 000000000..4a5dcdb0c
--- /dev/null
+++ b/file/config/samples/file_v1_filesubscription.yaml
@@ -0,0 +1,17 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: file.cp.ei.telekom.de/v1
+kind: FileSubscription
+metadata:
+ labels:
+ cp.ei.telekom.de/environment: poc
+ name: sample-filesubscription
+spec:
+ fileType: sample-filetype
+ zone:
+ name: sample-zone
+ namespace: default
+ sftp:
+ publicKeys: []
diff --git a/file/config/samples/file_v1_filetype.yaml b/file/config/samples/file_v1_filetype.yaml
new file mode 100644
index 000000000..2dcaae855
--- /dev/null
+++ b/file/config/samples/file_v1_filetype.yaml
@@ -0,0 +1,12 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: file.cp.ei.telekom.de/v1
+kind: FileType
+metadata:
+ labels:
+ cp.ei.telekom.de/environment: poc
+ name: sample-filetype
+spec:
+ description: Sample file type
diff --git a/file/config/samples/file_v1_zoneserviceconfig.yaml b/file/config/samples/file_v1_zoneserviceconfig.yaml
new file mode 100644
index 000000000..5ebe68d46
--- /dev/null
+++ b/file/config/samples/file_v1_zoneserviceconfig.yaml
@@ -0,0 +1,26 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: file.cp.ei.telekom.de/v1
+kind: ZoneServiceConfig
+metadata:
+ labels:
+ cp.ei.telekom.de/environment: poc
+ name: dataplane1
+ namespace: poc--dataplane1
+spec:
+ api:
+ name: sftp-api
+ path: /sftp/api
+ url: https://sftp-api.example.com/base-path/
+ type: TeamAPI
+ service:
+ host: sftp.default.svc.cluster.local
+ port: 3022
+ serviceExternal:
+ host: sftp.example.com
+ port: 3022
+ zone:
+ name: dataplane1
+ namespace: poc
diff --git a/file/config/webhook/kustomization.yaml b/file/config/webhook/kustomization.yaml
new file mode 100644
index 000000000..fc1b1358a
--- /dev/null
+++ b/file/config/webhook/kustomization.yaml
@@ -0,0 +1,10 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+resources:
+- manifests.yaml
+- service.yaml
+
+configurations:
+- kustomizeconfig.yaml
diff --git a/file/config/webhook/kustomizeconfig.yaml b/file/config/webhook/kustomizeconfig.yaml
new file mode 100644
index 000000000..1c0cf3cc4
--- /dev/null
+++ b/file/config/webhook/kustomizeconfig.yaml
@@ -0,0 +1,19 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# the following config is for teaching kustomize where to look at when substituting nameReference.
+# It requires kustomize v2.1.0 or newer to work properly.
+nameReference:
+- kind: Service
+ version: v1
+ fieldSpecs:
+ - kind: ValidatingWebhookConfiguration
+ group: admissionregistration.k8s.io
+ path: webhooks/clientConfig/service/name
+
+namespace:
+- kind: ValidatingWebhookConfiguration
+ group: admissionregistration.k8s.io
+ path: webhooks/clientConfig/service/namespace
+ create: true
diff --git a/file/config/webhook/manifests.yaml b/file/config/webhook/manifests.yaml
new file mode 100644
index 000000000..52e29d318
--- /dev/null
+++ b/file/config/webhook/manifests.yaml
@@ -0,0 +1,29 @@
+# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+---
+apiVersion: admissionregistration.k8s.io/v1
+kind: ValidatingWebhookConfiguration
+metadata:
+ name: validating-webhook-configuration
+webhooks:
+- admissionReviewVersions:
+ - v1
+ clientConfig:
+ service:
+ name: webhook-service
+ namespace: system
+ path: /validate-file-cp-ei-telekom-de-v1-zoneserviceconfig
+ failurePolicy: Fail
+ name: vzoneserviceconfig-v1.kb.io
+ rules:
+ - apiGroups:
+ - file.cp.ei.telekom.de
+ apiVersions:
+ - v1
+ operations:
+ - CREATE
+ - UPDATE
+ resources:
+ - zoneserviceconfigs
+ sideEffects: None
diff --git a/file/config/webhook/service.yaml b/file/config/webhook/service.yaml
new file mode 100644
index 000000000..a66365b43
--- /dev/null
+++ b/file/config/webhook/service.yaml
@@ -0,0 +1,20 @@
+# Copyright 2026 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: v1
+kind: Service
+metadata:
+ labels:
+ app.kubernetes.io/name: file
+ app.kubernetes.io/managed-by: kustomize
+ name: webhook-service
+ namespace: system
+spec:
+ ports:
+ - port: 443
+ protocol: TCP
+ targetPort: 9443
+ selector:
+ control-plane: controller-manager
+ app.kubernetes.io/name: file
diff --git a/file/go.mod b/file/go.mod
new file mode 100644
index 000000000..de6b09e16
--- /dev/null
+++ b/file/go.mod
@@ -0,0 +1,154 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+module github.com/telekom/controlplane/file
+
+go 1.26.5
+
+require (
+ github.com/telekom/controlplane/admin/api v0.0.0
+ github.com/telekom/controlplane/approval/api v0.0.0
+ github.com/telekom/controlplane/common v0.0.0
+ github.com/telekom/controlplane/file/api v0.0.0
+ github.com/telekom/controlplane/gateway/api v0.0.0
+ github.com/telekom/controlplane/identity/api v0.0.0
+ github.com/telekom/controlplane/secret-manager v0.0.0
+ github.com/telekom/controlplane/sftp/api v0.0.0
+)
+
+replace (
+ github.com/telekom/controlplane/admin => ../admin
+ github.com/telekom/controlplane/admin/api => ../admin/api
+ github.com/telekom/controlplane/approval/api => ../approval/api
+ github.com/telekom/controlplane/common => ../common
+ github.com/telekom/controlplane/common-server => ../common-server
+ github.com/telekom/controlplane/file/api => ./api
+ github.com/telekom/controlplane/gateway/api => ../gateway/api
+ github.com/telekom/controlplane/identity/api => ../identity/api
+ github.com/telekom/controlplane/secret-manager => ../secret-manager
+ github.com/telekom/controlplane/sftp/api => ../sftp/api
+)
+
+require (
+ github.com/onsi/ginkgo/v2 v2.32.0
+ github.com/onsi/gomega v1.42.1
+ github.com/pkg/errors v0.9.1
+ github.com/stretchr/testify v1.11.1
+ k8s.io/apimachinery v0.36.3
+ k8s.io/client-go v0.36.3
+ sigs.k8s.io/controller-runtime v0.24.1
+)
+
+require (
+ cel.dev/expr v0.25.2 // indirect
+ github.com/Masterminds/semver/v3 v3.4.0 // indirect
+ github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
+ github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/blang/semver/v4 v4.0.0 // indirect
+ github.com/cenkalti/backoff/v5 v5.0.3 // 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.13.0 // indirect
+ github.com/evanphx/json-patch/v5 v5.9.11 // indirect
+ github.com/felixge/httpsnoop v1.1.0 // indirect
+ github.com/fsnotify/fsnotify v1.10.1 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.2 // indirect
+ github.com/go-logr/logr v1.4.4 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/go-logr/zapr v1.3.0 // indirect
+ github.com/go-openapi/jsonpointer v1.0.0 // indirect
+ github.com/go-openapi/jsonreference v1.0.0 // indirect
+ github.com/go-openapi/swag v0.28.0 // indirect
+ github.com/go-openapi/swag/cmdutils v0.28.0 // indirect
+ github.com/go-openapi/swag/conv v0.28.0 // indirect
+ github.com/go-openapi/swag/fileutils v0.28.0 // indirect
+ github.com/go-openapi/swag/jsonname v0.28.0 // indirect
+ github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
+ github.com/go-openapi/swag/loading v0.28.0 // indirect
+ github.com/go-openapi/swag/mangling v0.28.0 // indirect
+ github.com/go-openapi/swag/netutils v0.28.0 // indirect
+ github.com/go-openapi/swag/pools v0.28.0 // indirect
+ github.com/go-openapi/swag/stringutils v0.28.0 // indirect
+ github.com/go-openapi/swag/typeutils v0.28.0 // indirect
+ github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
+ github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+ github.com/google/cel-go v0.30.0 // indirect
+ github.com/google/gnostic-models v0.7.1 // indirect
+ github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/mailru/easyjson v0.9.2 // 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/oapi-codegen/runtime v1.6.0 // indirect
+ github.com/pelletier/go-toml/v2 v2.4.3 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/prometheus/client_golang v1.24.1 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.70.1 // indirect
+ github.com/prometheus/procfs v0.21.1 // indirect
+ github.com/sagikazarmark/locafero v0.12.0 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spf13/cobra v1.10.2 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/spf13/viper v1.21.0 // indirect
+ github.com/stoewer/go-strcase v1.3.1 // indirect
+ github.com/stretchr/objx v0.5.3 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ github.com/telekom/controlplane/common-server v0.0.1 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
+ go.opentelemetry.io/otel v1.44.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
+ go.opentelemetry.io/otel/metric v1.44.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.44.0 // indirect
+ go.opentelemetry.io/otel/trace v1.44.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.11.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.28.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
+ go.yaml.in/yaml/v3 v3.0.5 // indirect
+ golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
+ golang.org/x/mod v0.38.0 // indirect
+ golang.org/x/net v0.57.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/term v0.45.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ golang.org/x/tools v0.48.0 // indirect
+ gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect
+ google.golang.org/grpc v1.83.0 // indirect
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+ gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ k8s.io/api v0.36.3 // indirect
+ k8s.io/apiextensions-apiserver v0.36.3 // indirect
+ k8s.io/apiserver v0.36.3 // indirect
+ k8s.io/component-base v0.36.3 // indirect
+ k8s.io/klog/v2 v2.140.0 // indirect
+ k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
+ k8s.io/streaming v0.36.3 // indirect
+ k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect
+ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0 // indirect
+ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
+ sigs.k8s.io/randfill v1.0.0 // indirect
+ sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
+ sigs.k8s.io/yaml v1.6.0 // indirect
+)
diff --git a/file/go.sum b/file/go.sum
new file mode 100644
index 000000000..d42c533ba
--- /dev/null
+++ b/file/go.sum
@@ -0,0 +1,410 @@
+cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
+cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
+cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
+cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
+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/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
+github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
+github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
+github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
+github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
+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/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
+github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
+github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
+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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+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.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
+github.com/emicklei/go-restful/v3 v3.13.0/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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
+github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
+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/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
+github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
+github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
+github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
+github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
+github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
+github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+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/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
+github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+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.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
+github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
+github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
+github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
+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/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
+github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
+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-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw=
+github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg=
+github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q=
+github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
+github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8=
+github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k=
+github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU=
+github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
+github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
+github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
+github.com/go-openapi/swag/jsonname v0.28.0 h1:IteWYOZSFQVvBZEMPAddN5AI/KSaJ9DVcFyVeMeza4Q=
+github.com/go-openapi/swag/jsonname v0.28.0/go.mod h1:rtHNjjwBhdavc6eybmd5Fj60cIgstqQHcToaK/+4WwQ=
+github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4=
+github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg=
+github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU=
+github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k=
+github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM=
+github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
+github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k=
+github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
+github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU=
+github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
+github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU=
+github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
+github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY=
+github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
+github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY=
+github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs=
+github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
+github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
+github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
+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/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI=
+github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
+github.com/google/cel-go v0.30.0 h1:ll54AkzKunWkBn9wSoiUXbFZXYZTkdJGNXTBXUoolGo=
+github.com/google/cel-go v0.30.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
+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/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
+github.com/google/gnostic-models v0.7.1/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-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
+github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
+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/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+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/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
+github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
+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/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
+github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
+github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
+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/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
+github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
+github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
+github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
+github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
+github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
+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/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
+github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
+github.com/oapi-codegen/runtime v1.5.0 h1:aiil4QnH+eiWYSO60eaYZ4aur7sJH3rz6BvT5EBFnxc=
+github.com/oapi-codegen/runtime v1.5.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
+github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU=
+github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
+github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
+github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
+github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
+github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
+github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
+github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
+github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
+github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
+github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
+github.com/prometheus/procfs v0.21.0 h1:Qh/e6TlBjZf+XLLqNCqFGmCU6Kj/2Bu7kj3oAc0UnXc=
+github.com/prometheus/procfs v0.21.0/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
+github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
+github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
+github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
+github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
+github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
+github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
+github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
+github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
+go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
+go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
+go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
+go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
+go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
+go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
+go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
+go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
+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/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.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
+golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
+golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
+golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
+golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
+golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
+golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
+golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
+golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
+gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
+gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
+gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0=
+gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
+google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
+google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0 h1:ybvH/ZpOcpCrjtkb7oW/fdlzbEmRVeumw19SRQmNFKU=
+google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:HJ9MpJLeDSstBkx1LILTpd5f41ADSMZcTPypw02qEGw=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 h1:mJiOtnGp0k/BcSgdu03G2NwnscCfCH+h2QKUBZr18KI=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
+google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
+google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
+google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+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.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
+gopkg.in/evanphx/json-patch.v4 v4.13.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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY=
+k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg=
+k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w=
+k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg=
+k8s.io/apiextensions-apiserver v0.36.2 h1:3O5gqOj/dt2XWWbpMe+TXWpE9yU6pjM/tXxtHHJT/K4=
+k8s.io/apiextensions-apiserver v0.36.2/go.mod h1:cL1tBWe8XSaP1H30iWKGo7hf6iAUUUJPEU70dskmAnA=
+k8s.io/apiextensions-apiserver v0.36.3 h1:dPmOAPhwTtqb1bTxbFPsy18KHPhktQeO3WUPXunZIB0=
+k8s.io/apiextensions-apiserver v0.36.3/go.mod h1:KTXFqgXiuw2pRoL+Wpmttqc+up9Xt/GohadPWeLLOa4=
+k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ=
+k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4=
+k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM=
+k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE=
+k8s.io/apiserver v0.36.2 h1:6vMnkmHZPeBloNkHUhmZYq7Ylv8WIB8xjyEl+eSt26E=
+k8s.io/apiserver v0.36.2/go.mod h1:9PoQ2ikCytrZyZg11mGhLEF5m8Rgsb5FJmYJ4Wvnl1k=
+k8s.io/apiserver v0.36.3 h1:MGSg2SkdfuytiDEcRylT5mQFmmSsbx90XFUO67Y4bsQ=
+k8s.io/apiserver v0.36.3/go.mod h1:fVH7zv9EUNUA7Fl7LtDKh8aB9W7u1VQPSGtWV5SjUxg=
+k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI=
+k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0=
+k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg=
+k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30=
+k8s.io/component-base v0.36.2 h1:Z0VH80O7Ng0HDZnZj3WRR3urEGa0kTwmO8CwEwjVK1w=
+k8s.io/component-base v0.36.2/go.mod h1:mGfFOA7Gwpdm1VW2cwSQYbiDIlz8GD2WGwH88QSeCyA=
+k8s.io/component-base v0.36.3 h1:vc/UFvPCkW0irPz84LAodAL1j3f4xktPM6dDJIEheAY=
+k8s.io/component-base v0.36.3/go.mod h1:hZbNFG+gCMl9EbykDGEu73feKP9/Cq6JsV4pTo9GTO8=
+k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
+k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
+k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
+k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
+k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
+k8s.io/streaming v0.36.2 h1:NSKthPPg9UFSKsRauVJUVGH2Dvn8fhKmY4qrMkw/p98=
+k8s.io/streaming v0.36.2/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
+k8s.io/streaming v0.36.3 h1:9rAaqBk0C0Pc7+/fqGekj07NV+/Xrew58p647A0JT8w=
+k8s.io/streaming v0.36.3/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
+k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
+k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
+sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec=
+sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
+sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0 h1:/YpDJ4vReG7ZmzSpBGxduXgywWkJU9zHubgJG03MT+Y=
+sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0/go.mod h1:tJo1aepTXyR+8Xs3sUsGBDk4Ub2AM5dPAPKJx0mpm5c=
+sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4=
+sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/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.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
+sigs.k8s.io/structured-merge-diff/v6 v6.4.2/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/file/go.sum.license b/file/go.sum.license
new file mode 100644
index 000000000..be863cd5c
--- /dev/null
+++ b/file/go.sum.license
@@ -0,0 +1,3 @@
+Copyright 2026 Deutsche Telekom IT GmbH
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/file/internal/controller/fileexposure_controller.go b/file/internal/controller/fileexposure_controller.go
new file mode 100644
index 000000000..437ed4eaa
--- /dev/null
+++ b/file/internal/controller/fileexposure_controller.go
@@ -0,0 +1,116 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ "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/handler"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ cconfig "github.com/telekom/controlplane/common/pkg/config"
+ cc "github.com/telekom/controlplane/common/pkg/controller"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ fileexposure_handler "github.com/telekom/controlplane/file/internal/handler/fileexposure"
+ "github.com/telekom/controlplane/file/internal/index"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+)
+
+// FileExposureReconciler reconciles a FileExposure object.
+type FileExposureReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+ Recorder record.EventRecorder
+
+ cc.Controller[*filev1.FileExposure]
+}
+
+// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=fileexposures,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=fileexposures/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=fileexposures/finalizers,verbs=update
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=filetypes,verbs=get;list;watch
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=zoneserviceconfigs,verbs=get;list;watch
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=instances,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=users,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=users/status,verbs=get
+
+func (r *FileExposureReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ return r.Controller.Reconcile(ctx, req, &filev1.FileExposure{})
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *FileExposureReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ r.Recorder = mgr.GetEventRecorderFor("fileexposure-controller")
+ r.Controller = cc.NewController(&fileexposure_handler.FileExposureHandler{}, r.Client, r.Recorder)
+
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&filev1.FileExposure{}).
+ Owns(&sftpv1.Instance{}).
+ Owns(&sftpv1.User{}).
+ Watches(&filev1.FileType{},
+ handler.EnqueueRequestsFromMapFunc(r.MapFileTypeToFileExposure),
+ builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
+ ).
+ Watches(&filev1.ZoneServiceConfig{},
+ handler.EnqueueRequestsFromMapFunc(r.MapZoneServiceConfigToFileExposure),
+ builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
+ ).
+ WithOptions(controller.Options{
+ MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles,
+ RateLimiter: cc.NewRateLimiter(),
+ }).
+ Complete(r)
+}
+
+func (r *FileExposureReconciler) MapFileTypeToFileExposure(ctx context.Context, obj client.Object) []reconcile.Request {
+ fileType, ok := obj.(*filev1.FileType)
+ if !ok {
+ return nil
+ }
+
+ list := &filev1.FileExposureList{}
+ err := r.List(ctx, list,
+ client.InNamespace(fileType.Namespace),
+ client.MatchingFields{index.FieldSpecFileTypeOnExposure: fileType.Name},
+ )
+ if err != nil {
+ return nil
+ }
+
+ reqs := make([]reconcile.Request, 0, len(list.Items))
+ for i := range list.Items {
+ reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])})
+ }
+ return reqs
+}
+
+func (r *FileExposureReconciler) MapZoneServiceConfigToFileExposure(ctx context.Context, obj client.Object) []reconcile.Request {
+ zoneServiceConfig, ok := obj.(*filev1.ZoneServiceConfig)
+ if !ok {
+ return nil
+ }
+
+ list := &filev1.FileExposureList{}
+ if err := r.List(ctx, list,
+ client.InNamespace(zoneServiceConfig.Labels[cconfig.EnvironmentLabelKey]),
+ client.MatchingFields{index.FieldSpecZoneOnExposure: zoneServiceConfig.Spec.Zone.String()},
+ ); err != nil {
+ return nil
+ }
+
+ reqs := make([]reconcile.Request, 0, len(list.Items))
+ for i := range list.Items {
+ reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])})
+ }
+ return reqs
+}
diff --git a/file/internal/controller/fileexposure_controller_test.go b/file/internal/controller/fileexposure_controller_test.go
new file mode 100644
index 000000000..92b5ae0f3
--- /dev/null
+++ b/file/internal/controller/fileexposure_controller_test.go
@@ -0,0 +1,82 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/tools/record"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ cc "github.com/telekom/controlplane/common/pkg/controller"
+ ctypes "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ fileexposure_handler "github.com/telekom/controlplane/file/internal/handler/fileexposure"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("FileExposure Controller", func() {
+ Context("When reconciling a resource", func() {
+ const resourceName = "test-resource"
+
+ ctx := context.Background()
+
+ typeNamespacedName := types.NamespacedName{
+ Name: resourceName,
+ Namespace: "default",
+ }
+ fileexposureObj := &filev1.FileExposure{}
+
+ BeforeEach(func() {
+ By("creating the custom resource for the Kind FileExposure")
+ err := k8sClient.Get(ctx, typeNamespacedName, fileexposureObj)
+ if err != nil && errors.IsNotFound(err) {
+ resource := &filev1.FileExposure{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: resourceName,
+ Namespace: "default",
+ },
+ Spec: filev1.FileExposureSpec{
+ FileType: "test-filetype",
+ Visibility: filev1.VisibilityEnterprise,
+ Approval: filev1.Approval{Strategy: filev1.ApprovalStrategyAuto},
+ Zone: &ctypes.ObjectRef{Name: "test-zone", Namespace: "default"},
+ },
+ }
+ Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+ }
+ })
+
+ AfterEach(func() {
+ resource := &filev1.FileExposure{}
+ err := k8sClient.Get(ctx, typeNamespacedName, resource)
+ Expect(err).NotTo(HaveOccurred())
+
+ By("Cleanup the specific resource instance FileExposure")
+ Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
+ })
+
+ It("should successfully reconcile the resource", func() {
+ By("Reconciling the created resource")
+ recorder := record.NewFakeRecorder(10)
+ controllerReconciler := &FileExposureReconciler{
+ Client: k8sClient,
+ Scheme: k8sClient.Scheme(),
+ Recorder: recorder,
+ }
+ controllerReconciler.Controller = cc.NewController(&fileexposure_handler.FileExposureHandler{}, k8sClient, recorder)
+
+ _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
+ NamespacedName: typeNamespacedName,
+ })
+ Expect(err).NotTo(HaveOccurred())
+ })
+ })
+})
diff --git a/file/internal/controller/filesubscription_controller.go b/file/internal/controller/filesubscription_controller.go
new file mode 100644
index 000000000..44a626458
--- /dev/null
+++ b/file/internal/controller/filesubscription_controller.go
@@ -0,0 +1,93 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ "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/handler"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ approvalv1 "github.com/telekom/controlplane/approval/api/v1"
+ cconfig "github.com/telekom/controlplane/common/pkg/config"
+ cc "github.com/telekom/controlplane/common/pkg/controller"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ filesubscription_handler "github.com/telekom/controlplane/file/internal/handler/filesubscription"
+ "github.com/telekom/controlplane/file/internal/index"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+)
+
+// FileSubscriptionReconciler reconciles a FileSubscription object.
+type FileSubscriptionReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+ Recorder record.EventRecorder
+
+ cc.Controller[*filev1.FileSubscription]
+}
+
+// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=filesubscriptions,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=filesubscriptions/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=filesubscriptions/finalizers,verbs=update
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=filetypes,verbs=get;list;watch
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=fileexposures,verbs=get;list;watch
+// +kubebuilder:rbac:groups=approval.cp.ei.telekom.de,resources=approvalrequests,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=approval.cp.ei.telekom.de,resources=approvals,verbs=get;list;watch
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=users,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=users/status,verbs=get
+
+func (r *FileSubscriptionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ return r.Controller.Reconcile(ctx, req, &filev1.FileSubscription{})
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *FileSubscriptionReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ r.Recorder = mgr.GetEventRecorderFor("filesubscription-controller")
+ r.Controller = cc.NewController(&filesubscription_handler.FileSubscriptionHandler{}, r.Client, r.Recorder)
+
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&filev1.FileSubscription{}).
+ Owns(&approvalv1.ApprovalRequest{}).
+ Owns(&approvalv1.Approval{}).
+ Owns(&sftpv1.User{}).
+ Watches(&filev1.FileType{},
+ handler.EnqueueRequestsFromMapFunc(r.MapFileTypeToFileSubscription),
+ builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
+ ).
+ WithOptions(controller.Options{
+ MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles,
+ RateLimiter: cc.NewRateLimiter(),
+ }).
+ Complete(r)
+}
+
+func (r *FileSubscriptionReconciler) MapFileTypeToFileSubscription(ctx context.Context, obj client.Object) []reconcile.Request {
+ fileType, ok := obj.(*filev1.FileType)
+ if !ok {
+ return nil
+ }
+
+ list := &filev1.FileSubscriptionList{}
+ err := r.List(ctx, list,
+ client.MatchingFields{index.FieldSpecFileTypeOnSubscription: fileType.Name},
+ )
+ if err != nil {
+ return nil
+ }
+
+ reqs := make([]reconcile.Request, 0, len(list.Items))
+ for i := range list.Items {
+ reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])})
+ }
+ return reqs
+}
diff --git a/file/internal/controller/filesubscription_controller_test.go b/file/internal/controller/filesubscription_controller_test.go
new file mode 100644
index 000000000..35aed489f
--- /dev/null
+++ b/file/internal/controller/filesubscription_controller_test.go
@@ -0,0 +1,80 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/tools/record"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ cc "github.com/telekom/controlplane/common/pkg/controller"
+ ctypes "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ filesubscription_handler "github.com/telekom/controlplane/file/internal/handler/filesubscription"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("FileSubscription Controller", func() {
+ Context("When reconciling a resource", func() {
+ const resourceName = "test-resource"
+
+ ctx := context.Background()
+
+ typeNamespacedName := types.NamespacedName{
+ Name: resourceName,
+ Namespace: "default",
+ }
+ filesubscriptionObj := &filev1.FileSubscription{}
+
+ BeforeEach(func() {
+ By("creating the custom resource for the Kind FileSubscription")
+ err := k8sClient.Get(ctx, typeNamespacedName, filesubscriptionObj)
+ if err != nil && errors.IsNotFound(err) {
+ resource := &filev1.FileSubscription{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: resourceName,
+ Namespace: "default",
+ },
+ Spec: filev1.FileSubscriptionSpec{
+ FileType: "test-filetype",
+ Zone: &ctypes.ObjectRef{Name: "test-zone", Namespace: "default"},
+ },
+ }
+ Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+ }
+ })
+
+ AfterEach(func() {
+ resource := &filev1.FileSubscription{}
+ err := k8sClient.Get(ctx, typeNamespacedName, resource)
+ Expect(err).NotTo(HaveOccurred())
+
+ By("Cleanup the specific resource instance FileSubscription")
+ Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
+ })
+
+ It("should successfully reconcile the resource", func() {
+ By("Reconciling the created resource")
+ recorder := record.NewFakeRecorder(10)
+ controllerReconciler := &FileSubscriptionReconciler{
+ Client: k8sClient,
+ Scheme: k8sClient.Scheme(),
+ Recorder: recorder,
+ }
+ controllerReconciler.Controller = cc.NewController(&filesubscription_handler.FileSubscriptionHandler{}, k8sClient, recorder)
+
+ _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
+ NamespacedName: typeNamespacedName,
+ })
+ Expect(err).NotTo(HaveOccurred())
+ })
+ })
+})
diff --git a/file/internal/controller/filetype_controller.go b/file/internal/controller/filetype_controller.go
new file mode 100644
index 000000000..cdc15e847
--- /dev/null
+++ b/file/internal/controller/filetype_controller.go
@@ -0,0 +1,79 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ "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/handler"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ cconfig "github.com/telekom/controlplane/common/pkg/config"
+ cc "github.com/telekom/controlplane/common/pkg/controller"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ filetype_handler "github.com/telekom/controlplane/file/internal/handler/filetype"
+)
+
+// FileTypeReconciler reconciles a FileType object.
+type FileTypeReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+ Recorder record.EventRecorder
+
+ cc.Controller[*filev1.FileType]
+}
+
+// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=filetypes,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=filetypes/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=filetypes/finalizers,verbs=update
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=fileexposures,verbs=get;list;watch
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=zoneserviceconfigs,verbs=get;list;watch
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=users,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=instances,verbs=get;list;watch
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=instances/status,verbs=get
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=users/status,verbs=get
+
+func (r *FileTypeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ return r.Controller.Reconcile(ctx, req, &filev1.FileType{})
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *FileTypeReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ r.Recorder = mgr.GetEventRecorderFor("filetype-controller")
+ r.Controller = cc.NewController(&filetype_handler.FileTypeHandler{}, r.Client, r.Recorder)
+
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&filev1.FileType{}).
+ Watches(&filev1.FileExposure{},
+ handler.EnqueueRequestsFromMapFunc(r.MapFileExposureToFileType),
+ builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
+ ).
+ WithOptions(controller.Options{
+ MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles,
+ RateLimiter: cc.NewRateLimiter(),
+ }).
+ Complete(r)
+}
+
+func (r *FileTypeReconciler) MapFileExposureToFileType(ctx context.Context, obj client.Object) []reconcile.Request {
+ exposure, ok := obj.(*filev1.FileExposure)
+ if !ok {
+ return nil
+ }
+
+ key := client.ObjectKeyFromObject(obj)
+ key.Name = exposure.Spec.FileType
+ return []reconcile.Request{{
+ NamespacedName: key,
+ }}
+}
diff --git a/file/internal/controller/filetype_controller_test.go b/file/internal/controller/filetype_controller_test.go
new file mode 100644
index 000000000..86825477b
--- /dev/null
+++ b/file/internal/controller/filetype_controller_test.go
@@ -0,0 +1,78 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/tools/record"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ cc "github.com/telekom/controlplane/common/pkg/controller"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ filetype_handler "github.com/telekom/controlplane/file/internal/handler/filetype"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("FileType Controller", func() {
+ Context("When reconciling a resource", func() {
+ const resourceName = "test-resource"
+
+ ctx := context.Background()
+
+ typeNamespacedName := types.NamespacedName{
+ Name: resourceName,
+ Namespace: "default",
+ }
+ filetypeObj := &filev1.FileType{}
+
+ BeforeEach(func() {
+ By("creating the custom resource for the Kind FileType")
+ err := k8sClient.Get(ctx, typeNamespacedName, filetypeObj)
+ if err != nil && errors.IsNotFound(err) {
+ resource := &filev1.FileType{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: resourceName,
+ Namespace: "default",
+ },
+ Spec: filev1.FileTypeSpec{
+ Description: "test file type",
+ },
+ }
+ Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+ }
+ })
+
+ AfterEach(func() {
+ resource := &filev1.FileType{}
+ err := k8sClient.Get(ctx, typeNamespacedName, resource)
+ Expect(err).NotTo(HaveOccurred())
+
+ By("Cleanup the specific resource instance FileType")
+ Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
+ })
+
+ It("should successfully reconcile the resource", func() {
+ By("Reconciling the created resource")
+ recorder := record.NewFakeRecorder(10)
+ controllerReconciler := &FileTypeReconciler{
+ Client: k8sClient,
+ Scheme: k8sClient.Scheme(),
+ Recorder: recorder,
+ }
+ controllerReconciler.Controller = cc.NewController(&filetype_handler.FileTypeHandler{}, k8sClient, recorder)
+
+ _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
+ NamespacedName: typeNamespacedName,
+ })
+ Expect(err).NotTo(HaveOccurred())
+ })
+ })
+})
diff --git a/file/internal/controller/schema.go b/file/internal/controller/schema.go
new file mode 100644
index 000000000..3063fdbfb
--- /dev/null
+++ b/file/internal/controller/schema.go
@@ -0,0 +1,28 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "k8s.io/apimachinery/pkg/runtime"
+ utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+
+ adminv1 "github.com/telekom/controlplane/admin/api/v1"
+ approvalv1 "github.com/telekom/controlplane/approval/api/v1"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ gateway "github.com/telekom/controlplane/gateway/api/v1"
+ identityv1 "github.com/telekom/controlplane/identity/api/v1"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+)
+
+func RegisterSchemesOrDie(scheme *runtime.Scheme) {
+ utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+ utilruntime.Must(adminv1.AddToScheme(scheme))
+ utilruntime.Must(approvalv1.AddToScheme(scheme))
+ utilruntime.Must(filev1.AddToScheme(scheme))
+ utilruntime.Must(identityv1.AddToScheme(scheme))
+ utilruntime.Must(sftpv1.AddToScheme(scheme))
+ utilruntime.Must(gateway.AddToScheme(scheme))
+}
diff --git a/file/internal/controller/suite_test.go b/file/internal/controller/suite_test.go
new file mode 100644
index 000000000..072e8512e
--- /dev/null
+++ b/file/internal/controller/suite_test.go
@@ -0,0 +1,104 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "k8s.io/client-go/kubernetes/scheme"
+ "k8s.io/client-go/rest"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/envtest"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/log/zap"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ // +kubebuilder:scaffold:imports
+)
+
+var (
+ ctx context.Context
+ cancel context.CancelFunc
+ testEnv *envtest.Environment
+ cfg *rest.Config
+ k8sClient client.Client
+)
+
+func TestControllers(t *testing.T) {
+ RegisterFailHandler(Fail)
+
+ RunSpecs(t, "Controller Suite")
+}
+
+var _ = BeforeSuite(func() {
+ logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
+
+ ctx, cancel = context.WithCancel(context.TODO())
+
+ RegisterSchemesOrDie(scheme.Scheme)
+
+ // +kubebuilder:scaffold:scheme
+
+ By("bootstrapping test environment")
+ testEnv = &envtest.Environment{
+ CRDDirectoryPaths: []string{
+ filepath.Join("..", "..", "config", "crd", "bases"),
+ filepath.Join("..", "..", "..", "sftp", "config", "crd", "bases"),
+ filepath.Join("..", "..", "..", "admin", "config", "crd", "bases"),
+ filepath.Join("..", "..", "..", "approval", "config", "crd", "bases"),
+ filepath.Join("..", "..", "..", "gateway", "config", "crd", "bases"),
+ filepath.Join("..", "..", "..", "identity", "config", "crd", "bases"),
+ },
+ ErrorIfCRDPathMissing: true,
+ }
+
+ // Retrieve the first found binary directory to allow running tests from IDEs
+ if getFirstFoundEnvTestBinaryDir() != "" {
+ testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir()
+ }
+
+ var err error
+ cfg, err = testEnv.Start()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(cfg).NotTo(BeNil())
+
+ k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(k8sClient).NotTo(BeNil())
+})
+
+var _ = AfterSuite(func() {
+ By("tearing down the test environment")
+ cancel()
+ err := testEnv.Stop()
+ Expect(err).NotTo(HaveOccurred())
+})
+
+// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path.
+// ENVTEST-based tests depend on specific binaries, usually located in paths set by
+// controller-runtime. When running tests directly (e.g., via an IDE) without using
+// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured.
+//
+// This function streamlines the process by finding the required binaries, similar to
+// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are
+// properly set up, run 'make setup-envtest' beforehand.
+func getFirstFoundEnvTestBinaryDir() string {
+ basePath := filepath.Join("..", "..", "bin", "k8s")
+ entries, err := os.ReadDir(basePath)
+ if err != nil {
+ logf.Log.Error(err, "Failed to read directory", "path", basePath)
+ return ""
+ }
+ for _, entry := range entries {
+ if entry.IsDir() {
+ return filepath.Join(basePath, entry.Name())
+ }
+ }
+ return ""
+}
diff --git a/file/internal/controller/zoneserviceconfig_controller.go b/file/internal/controller/zoneserviceconfig_controller.go
new file mode 100644
index 000000000..b2e9a23e5
--- /dev/null
+++ b/file/internal/controller/zoneserviceconfig_controller.go
@@ -0,0 +1,86 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ "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/handler"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ adminv1 "github.com/telekom/controlplane/admin/api/v1"
+ cconfig "github.com/telekom/controlplane/common/pkg/config"
+ cc "github.com/telekom/controlplane/common/pkg/controller"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ zoneserviceconfig_handler "github.com/telekom/controlplane/file/internal/handler/zoneserviceconfig"
+ gatewayapi "github.com/telekom/controlplane/gateway/api/v1"
+ identityv1 "github.com/telekom/controlplane/identity/api/v1"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+)
+
+// ZoneServiceConfigReconciler reconciles a ZoneServiceConfig object.
+type ZoneServiceConfigReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+ Recorder record.EventRecorder
+
+ cc.Controller[*filev1.ZoneServiceConfig]
+}
+
+// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=zoneserviceconfigs,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=zoneserviceconfigs/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=zoneserviceconfigs/finalizers,verbs=update
+// +kubebuilder:rbac:groups=admin.cp.ei.telekom.de,resources=zones,verbs=get;list;watch
+// +kubebuilder:rbac:groups=admin.cp.ei.telekom.de,resources=zones/status,verbs=get
+// +kubebuilder:rbac:groups=identity.cp.ei.telekom.de,resources=clients,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=sftpserviceconfigs,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=sftpserviceconfigs/status,verbs=get
+// +kubebuilder:rbac:groups=gateway.cp.ei.telekom.de,resources=routes,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=gateway.cp.ei.telekom.de,resources=routes/status,verbs=get
+// +kubebuilder:rbac:groups=gateway.cp.ei.telekom.de,resources=consumers,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=gateway.cp.ei.telekom.de,resources=consumers/status,verbs=get
+
+func (r *ZoneServiceConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ return r.Controller.Reconcile(ctx, req, &filev1.ZoneServiceConfig{})
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *ZoneServiceConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ r.Recorder = mgr.GetEventRecorderFor("zoneserviceconfig-controller")
+ r.Controller = cc.NewController(&zoneserviceconfig_handler.ZoneServiceConfigHandler{}, r.Client, r.Recorder)
+
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&filev1.ZoneServiceConfig{}).
+ Owns(&identityv1.Client{}).
+ Owns(&sftpv1.SFTPServiceConfig{}).
+ Owns(&gatewayapi.Consumer{}).
+ Owns(&gatewayapi.Route{}).
+ Watches(&adminv1.Zone{},
+ handler.EnqueueRequestsFromMapFunc(r.MapZoneToZoneServiceConfig),
+ builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
+ ).
+ WithOptions(controller.Options{
+ MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles,
+ RateLimiter: cc.NewRateLimiter(),
+ }).
+ Complete(r)
+}
+
+func (r *ZoneServiceConfigReconciler) MapZoneToZoneServiceConfig(ctx context.Context, obj client.Object) []reconcile.Request {
+ zone, ok := obj.(*adminv1.Zone)
+ if !ok {
+ return nil
+ }
+
+ return []reconcile.Request{{NamespacedName: client.ObjectKeyFromObject(zone)}}
+}
diff --git a/file/internal/controller/zoneserviceconfig_controller_test.go b/file/internal/controller/zoneserviceconfig_controller_test.go
new file mode 100644
index 000000000..8a5914fc7
--- /dev/null
+++ b/file/internal/controller/zoneserviceconfig_controller_test.go
@@ -0,0 +1,94 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/tools/record"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ adminv1 "github.com/telekom/controlplane/admin/api/v1"
+ cc "github.com/telekom/controlplane/common/pkg/controller"
+ ctypes "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ zoneserviceconfig_handler "github.com/telekom/controlplane/file/internal/handler/zoneserviceconfig"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("ZoneServiceConfig Controller", func() {
+ Context("When reconciling a resource", func() {
+ const resourceName = "test-resource"
+
+ ctx := context.Background()
+
+ typeNamespacedName := types.NamespacedName{
+ Name: resourceName,
+ Namespace: "default",
+ }
+ zoneserviceconfigObj := &filev1.ZoneServiceConfig{}
+
+ BeforeEach(func() {
+ By("creating the custom resource for the Kind ZoneServiceConfig")
+ err := k8sClient.Get(ctx, typeNamespacedName, zoneserviceconfigObj)
+ if err != nil && errors.IsNotFound(err) {
+ resource := &filev1.ZoneServiceConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: resourceName,
+ Namespace: "default",
+ },
+ Spec: filev1.ZoneServiceConfigSpec{
+ Zone: &ctypes.ObjectRef{Name: "test-zone", Namespace: "default"},
+ API: adminv1.ManagedRouteConfig{
+ Name: "test-api",
+ Path: "/test",
+ Url: "https://sftp.example.com",
+ Type: adminv1.ManagedRouteTypeProxy,
+ },
+ Service: &filev1.ServiceEndpoint{
+ Host: "sftp.internal",
+ Port: 22,
+ },
+ ServiceExternal: &filev1.ServiceEndpoint{
+ Host: "sftp.external",
+ Port: 2222,
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+ }
+ })
+
+ AfterEach(func() {
+ resource := &filev1.ZoneServiceConfig{}
+ err := k8sClient.Get(ctx, typeNamespacedName, resource)
+ Expect(err).NotTo(HaveOccurred())
+
+ By("Cleanup the specific resource instance ZoneServiceConfig")
+ Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
+ })
+
+ It("should successfully reconcile the resource", func() {
+ By("Reconciling the created resource")
+ recorder := record.NewFakeRecorder(10)
+ controllerReconciler := &ZoneServiceConfigReconciler{
+ Client: k8sClient,
+ Scheme: k8sClient.Scheme(),
+ Recorder: recorder,
+ }
+ controllerReconciler.Controller = cc.NewController(&zoneserviceconfig_handler.ZoneServiceConfigHandler{}, k8sClient, recorder)
+
+ _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
+ NamespacedName: typeNamespacedName,
+ })
+ Expect(err).NotTo(HaveOccurred())
+ })
+ })
+})
diff --git a/file/internal/handler/fileexposure/handler.go b/file/internal/handler/fileexposure/handler.go
new file mode 100644
index 000000000..60d30efe3
--- /dev/null
+++ b/file/internal/handler/fileexposure/handler.go
@@ -0,0 +1,140 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package fileexposure
+
+import (
+ "context"
+ "fmt"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+ cclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/condition"
+ "github.com/telekom/controlplane/common/pkg/handler"
+ "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ "github.com/telekom/controlplane/file/internal/handler/util"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+)
+
+var _ handler.Handler[*filev1.FileExposure] = &FileExposureHandler{}
+
+type FileExposureHandler struct{}
+
+func (h *FileExposureHandler) CreateOrUpdate(ctx context.Context, obj *filev1.FileExposure) error {
+ c := cclient.ClientFromContextOrDie(ctx)
+
+ fileTypeRef := types.ObjectRef{Namespace: obj.Namespace, Name: obj.Spec.FileType}
+
+ activeExposure, found, err := util.FindActiveFileExposure(ctx, &fileTypeRef)
+ if err != nil {
+ return err
+ }
+
+ if found && activeExposure.UID != obj.UID {
+ obj.Status.FileTypeRef = &fileTypeRef
+ obj.SetCondition(condition.NewNotReadyCondition("FileExposureAlreadyExists", "Another FileExposure already provides this FileType"))
+ obj.SetCondition(condition.NewBlockedCondition("FileExposure will be processed when the active FileExposure is deleted"))
+ return nil
+ }
+
+ fileType, err := util.GetFileType(ctx, fileTypeRef)
+ if err != nil {
+ return err
+ }
+
+ zoneServiceConfig, err := util.GetZoneServiceConfig(ctx, obj.Spec.Zone)
+ if err != nil {
+ return err
+ }
+
+ err = h.createOrUpdateInstance(ctx, obj, fileType, zoneServiceConfig)
+ if err != nil {
+ return err
+ }
+
+ err = h.createOrUpdateProviderUser(ctx, obj, fileType)
+ if err != nil {
+ return err
+ }
+
+ obj.Status.FileTypeRef = types.ObjectRefFromObject(fileType)
+
+ if !c.AllReady() {
+ obj.SetCondition(condition.NewNotReadyCondition("ChildResourcesNotReady", "One or more child resources are not yet ready"))
+ obj.SetCondition(condition.NewProcessingCondition("ChildResourcesNotReady", "Waiting for child resources"))
+ return nil
+ }
+
+ obj.SetCondition(condition.NewReadyCondition("FileExposureProvisioned", "FileExposure has been provisioned"))
+ obj.SetCondition(condition.NewDoneProcessingCondition("FileExposure has been provisioned"))
+ return nil
+}
+
+func (h *FileExposureHandler) Delete(ctx context.Context, obj *filev1.FileExposure) error {
+ fileTypeRef := types.ObjectRef{Namespace: obj.Namespace, Name: obj.Spec.FileType}
+
+ activeExposure, found, err := util.FindActiveFileExposure(ctx, &fileTypeRef)
+ if err != nil {
+ return err
+ }
+
+ if !found || activeExposure.UID != obj.UID {
+ return nil
+ }
+
+ if err := util.DeleteSFTPUser(ctx, util.SFTPUserRefForFileExposure(obj)); err != nil {
+ return fmt.Errorf("failed to delete provider SFTP User: %w", err)
+ }
+
+ if err := util.DeleteSFTPInstance(ctx, util.SFTPInstanceRefForFileExposure(obj)); err != nil {
+ return fmt.Errorf("failed to delete SFTP Instance: %w", err)
+ }
+
+ return nil
+}
+
+func (h *FileExposureHandler) createOrUpdateProviderUser(ctx context.Context, obj *filev1.FileExposure, fileType *filev1.FileType) error {
+ _, err := util.SyncSFTPUser(
+ ctx,
+ util.SFTPUserRefForFileExposure(obj),
+ obj,
+ *types.ObjectRefFromObject(fileType),
+ util.GetPublicKeysFromSFTP(obj.Spec.SFTP),
+ util.SFTPInstanceRefForFileExposure(obj),
+ )
+ if err != nil {
+ return fmt.Errorf("failed to sync provider SFTP User: %w", err)
+ }
+ return nil
+}
+
+func (h *FileExposureHandler) createOrUpdateInstance(ctx context.Context, obj *filev1.FileExposure, fileType *filev1.FileType, zoneServiceConfig *filev1.ZoneServiceConfig) error {
+ c := cclient.ClientFromContextOrDie(ctx)
+ instanceRef := util.SFTPInstanceRefForFileExposure(obj)
+ instance := &sftpv1.Instance{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: instanceRef.Name,
+ Namespace: instanceRef.Namespace,
+ },
+ }
+
+ mutator := func() error {
+ if err := controllerutil.SetControllerReference(obj, instance, c.Scheme()); err != nil {
+ return fmt.Errorf("failed to set controller reference: %w", err)
+ }
+
+ instance.Labels = util.ChildLabels(*types.ObjectRefFromObject(fileType))
+ instance.Spec.Description = fileType.Spec.Description
+ instance.Spec.SFTPServiceConfigRef = util.GetChildResourceRef(zoneServiceConfig)
+ return nil
+ }
+
+ if _, err := c.CreateOrUpdate(ctx, instance, mutator); err != nil {
+ return fmt.Errorf("failed to create or update SFTP Instance %q: %w", instance.Name, err)
+ }
+ return nil
+}
diff --git a/file/internal/handler/fileexposure/handler_suite_test.go b/file/internal/handler/fileexposure/handler_suite_test.go
new file mode 100644
index 000000000..b724445a7
--- /dev/null
+++ b/file/internal/handler/fileexposure/handler_suite_test.go
@@ -0,0 +1,17 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package fileexposure
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestFileExposureHandler(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "FileExposure Handler Suite")
+}
diff --git a/file/internal/handler/fileexposure/handler_test.go b/file/internal/handler/fileexposure/handler_test.go
new file mode 100644
index 000000000..aef22c3e9
--- /dev/null
+++ b/file/internal/handler/fileexposure/handler_test.go
@@ -0,0 +1,334 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package fileexposure
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/stretchr/testify/mock"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ k8smeta "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ k8stypes "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+ cclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/client/fake"
+ "github.com/telekom/controlplane/common/pkg/condition"
+ "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+const (
+ testNamespace = "test"
+ testFileTypeName = "test-filetype"
+ testExposureName = "test-exposure"
+ testZoneServiceConfigName = "test-zone"
+)
+
+func newTestContext() (context.Context, *fake.MockJanitorClient) {
+ mockClient := fake.NewMockJanitorClient(GinkgoT())
+ ctx := cclient.WithClient(context.Background(), mockClient)
+ return ctx, mockClient
+}
+
+func testFileType() *filev1.FileType {
+ return &filev1.FileType{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: filev1.GroupVersion.String(),
+ Kind: "FileType",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testFileTypeName,
+ Namespace: testNamespace,
+ },
+ }
+}
+
+func testFileExposure() *filev1.FileExposure {
+ return &filev1.FileExposure{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: filev1.GroupVersion.String(),
+ Kind: "FileExposure",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testExposureName,
+ Namespace: testNamespace,
+ UID: "uid-active",
+ },
+ Spec: filev1.FileExposureSpec{
+ FileType: testFileTypeName,
+ Zone: &types.ObjectRef{Name: testZoneServiceConfigName, Namespace: testNamespace},
+ },
+ }
+}
+
+func testZoneServiceConfig() *filev1.ZoneServiceConfig {
+ return &filev1.ZoneServiceConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testZoneServiceConfigName,
+ Namespace: testNamespace,
+ },
+ }
+}
+
+// mockListExposures sets up c.List to return the given FileExposures.
+func mockListExposures(mockClient *fake.MockJanitorClient, exposures []filev1.FileExposure) {
+ mockClient.EXPECT().
+ List(mock.Anything, mock.AnythingOfType("*v1.FileExposureList"), mock.Anything, mock.Anything).
+ Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) {
+ *list.(*filev1.FileExposureList) = filev1.FileExposureList{Items: exposures}
+ }).
+ Return(nil).Once()
+}
+
+// mockGetFileType sets up c.Get to return the given FileType.
+func mockGetFileType(mockClient *fake.MockJanitorClient, ft *filev1.FileType) {
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testFileTypeName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileType")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileType) = *ft
+ }).
+ Return(nil).Once()
+}
+
+// mockListZoneServiceConfigs sets up c.List to return the given ZoneServiceConfigs.
+func mockListZoneServiceConfigs(mockClient *fake.MockJanitorClient, configs []filev1.ZoneServiceConfig) {
+ mockClient.EXPECT().
+ List(mock.Anything, mock.AnythingOfType("*v1.ZoneServiceConfigList"), mock.Anything, mock.Anything).
+ Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) {
+ *list.(*filev1.ZoneServiceConfigList) = filev1.ZoneServiceConfigList{Items: configs}
+ }).
+ Return(nil).Once()
+}
+
+var _ = Describe("FileExposureHandler", func() {
+ var handler *FileExposureHandler
+
+ BeforeEach(func() {
+ handler = &FileExposureHandler{}
+ })
+
+ Describe("CreateOrUpdate", func() {
+ It("blocks when another FileExposure is already active", func() {
+ exposure := testFileExposure()
+ exposure.UID = "uid-mine"
+ ctx, mockClient := newTestContext()
+
+ // active exposure belongs to a different UID
+ otherExposure := testFileExposure()
+ otherExposure.Name = "other-exposure"
+ otherExposure.UID = "uid-other"
+ mockListExposures(mockClient, []filev1.FileExposure{*otherExposure})
+
+ err := handler.CreateOrUpdate(ctx, exposure)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(exposure.Status.FileTypeRef).NotTo(BeNil())
+ Expect(k8smeta.IsStatusConditionFalse(exposure.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ ready := k8smeta.FindStatusCondition(exposure.Status.Conditions, condition.ConditionTypeReady)
+ Expect(ready.Reason).To(Equal("FileExposureAlreadyExists"))
+ })
+
+ It("blocks when FileType is not found", func() {
+ exposure := testFileExposure()
+ ctx, mockClient := newTestContext()
+
+ mockListExposures(mockClient, []filev1.FileExposure{*exposure})
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testFileTypeName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileType")).
+ Return(apierrors.NewNotFound(schema.GroupResource{Group: filev1.GroupVersion.Group, Resource: "filetypes"}, testFileTypeName)).
+ Once()
+
+ err := handler.CreateOrUpdate(ctx, exposure)
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("FileType"))
+ })
+
+ It("returns error when exposure list fails", func() {
+ exposure := testFileExposure()
+ ctx, mockClient := newTestContext()
+
+ mockClient.EXPECT().
+ List(mock.Anything, mock.AnythingOfType("*v1.FileExposureList"), mock.Anything, mock.Anything).
+ Return(fmt.Errorf("storage error")).Once()
+
+ err := handler.CreateOrUpdate(ctx, exposure)
+
+ Expect(err).To(MatchError(ContainSubstring("storage error")))
+ })
+
+ It("returns error when ZoneServiceConfig is not found", func() {
+ exposure := testFileExposure()
+ ctx, mockClient := newTestContext()
+
+ mockListExposures(mockClient, []filev1.FileExposure{*exposure})
+ mockGetFileType(mockClient, testFileType())
+ // empty ZoneServiceConfig list → "expected exactly one" error
+ mockListZoneServiceConfigs(mockClient, nil)
+
+ err := handler.CreateOrUpdate(ctx, exposure)
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("ZoneServiceConfig"))
+ })
+
+ It("creates SFTP Instance and provider User while children not ready", func() {
+ exposure := testFileExposure()
+ ctx, mockClient := newTestContext()
+
+ mockListExposures(mockClient, []filev1.FileExposure{*exposure})
+ mockGetFileType(mockClient, testFileType())
+ mockListZoneServiceConfigs(mockClient, []filev1.ZoneServiceConfig{*testZoneServiceConfig()})
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Instance"), mock.Anything).
+ Return(controllerutil.OperationResultCreated, nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.User"), mock.Anything).
+ Return(controllerutil.OperationResultCreated, nil).Once()
+ mockClient.EXPECT().AllReady().Return(false).Once()
+
+ err := handler.CreateOrUpdate(ctx, exposure)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(exposure.Status.FileTypeRef).NotTo(BeNil())
+ Expect(k8smeta.IsStatusConditionFalse(exposure.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ })
+
+ It("sets Ready condition when all child resources are ready", func() {
+ exposure := testFileExposure()
+ ctx, mockClient := newTestContext()
+
+ mockListExposures(mockClient, []filev1.FileExposure{*exposure})
+ mockGetFileType(mockClient, testFileType())
+ mockListZoneServiceConfigs(mockClient, []filev1.ZoneServiceConfig{*testZoneServiceConfig()})
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Instance"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.User"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().AllReady().Return(true).Once()
+
+ err := handler.CreateOrUpdate(ctx, exposure)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(k8smeta.IsStatusConditionTrue(exposure.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ })
+
+ It("returns error when Instance creation fails", func() {
+ exposure := testFileExposure()
+ ctx, mockClient := newTestContext()
+
+ mockListExposures(mockClient, []filev1.FileExposure{*exposure})
+ mockGetFileType(mockClient, testFileType())
+ mockListZoneServiceConfigs(mockClient, []filev1.ZoneServiceConfig{*testZoneServiceConfig()})
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Instance"), mock.Anything).
+ Return(controllerutil.OperationResultNone, fmt.Errorf("instance creation failed")).Once()
+
+ err := handler.CreateOrUpdate(ctx, exposure)
+
+ Expect(err).To(MatchError(ContainSubstring("instance creation failed")))
+ })
+ })
+
+ Describe("Delete", func() {
+ It("skips deletion when no active FileExposure exists", func() {
+ exposure := testFileExposure()
+ ctx, mockClient := newTestContext()
+
+ mockListExposures(mockClient, nil)
+
+ err := handler.Delete(ctx, exposure)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("skips deletion when the active FileExposure has a different UID", func() {
+ exposure := testFileExposure()
+ exposure.UID = "uid-mine"
+ ctx, mockClient := newTestContext()
+
+ otherExposure := testFileExposure()
+ otherExposure.UID = "uid-other"
+ mockListExposures(mockClient, []filev1.FileExposure{*otherExposure})
+
+ err := handler.Delete(ctx, exposure)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("deletes provider User and Instance when this exposure is active", func() {
+ exposure := testFileExposure()
+ ctx, mockClient := newTestContext()
+
+ mockListExposures(mockClient, []filev1.FileExposure{*exposure})
+ mockClient.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.User")).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.Instance")).
+ Return(nil).Once()
+
+ err := handler.Delete(ctx, exposure)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("returns error when exposure list fails during delete", func() {
+ exposure := testFileExposure()
+ ctx, mockClient := newTestContext()
+
+ mockClient.EXPECT().
+ List(mock.Anything, mock.AnythingOfType("*v1.FileExposureList"), mock.Anything, mock.Anything).
+ Return(fmt.Errorf("list error on delete")).Once()
+
+ err := handler.Delete(ctx, exposure)
+
+ Expect(err).To(MatchError(ContainSubstring("list error on delete")))
+ })
+
+ It("returns error when provider User deletion fails", func() {
+ exposure := testFileExposure()
+ ctx, mockClient := newTestContext()
+
+ mockListExposures(mockClient, []filev1.FileExposure{*exposure})
+ mockClient.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.User")).
+ Return(fmt.Errorf("user delete failed")).Once()
+
+ err := handler.Delete(ctx, exposure)
+
+ Expect(err).To(MatchError(ContainSubstring("user delete failed")))
+ })
+
+ It("tolerates NotFound when deleting provider User", func() {
+ exposure := testFileExposure()
+ ctx, mockClient := newTestContext()
+
+ mockListExposures(mockClient, []filev1.FileExposure{*exposure})
+ mockClient.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.User")).
+ Return(apierrors.NewNotFound(schema.GroupResource{Group: sftpv1.GroupVersion.Group, Resource: "users"}, "any")).
+ Once()
+ mockClient.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.Instance")).
+ Return(nil).Once()
+
+ err := handler.Delete(ctx, exposure)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+ })
+})
diff --git a/file/internal/handler/filesubscription/handler.go b/file/internal/handler/filesubscription/handler.go
new file mode 100644
index 000000000..71af8797b
--- /dev/null
+++ b/file/internal/handler/filesubscription/handler.go
@@ -0,0 +1,214 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package filesubscription
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/pkg/errors"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+
+ approvalapi "github.com/telekom/controlplane/approval/api/v1"
+ "github.com/telekom/controlplane/approval/api/v1/builder"
+ cclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/condition"
+ "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors"
+ "github.com/telekom/controlplane/common/pkg/handler"
+ "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ "github.com/telekom/controlplane/file/internal/handler/util"
+)
+
+var _ handler.Handler[*filev1.FileSubscription] = &FileSubscriptionHandler{}
+
+type FileSubscriptionHandler struct{}
+
+func (h *FileSubscriptionHandler) CreateOrUpdate(ctx context.Context, obj *filev1.FileSubscription) error {
+ logger := log.FromContext(ctx)
+ c := cclient.ClientFromContextOrDie(ctx)
+
+ fileType, err := util.GetFileType(ctx, types.ObjectRef{Namespace: obj.Namespace, Name: obj.Spec.FileType})
+ if err != nil {
+ return err
+ }
+
+ if fileType.Status.FileExposureRef == nil {
+ obj.SetCondition(condition.NewNotReadyCondition("FileExposureNotFound", "No active FileExposure found for this FileType"))
+ obj.SetCondition(condition.NewBlockedCondition("FileSubscription will be processed when a FileExposure is registered"))
+ return nil
+ }
+
+ activeExposure := &filev1.FileExposure{}
+ if err = c.Get(ctx, fileType.Status.FileExposureRef.K8s(), activeExposure); err != nil {
+ if apierrors.IsNotFound(errors.Cause(err)) {
+ obj.SetCondition(condition.NewNotReadyCondition("FileExposureNotFound", "No active FileExposure found for this FileType"))
+ obj.SetCondition(condition.NewBlockedCondition("FileSubscription will be processed when a FileExposure is registered"))
+ return nil
+ }
+ return err
+ }
+
+ if !visibilityAllowsSubscription(activeExposure, obj) {
+ obj.SetCondition(condition.NewNotReadyCondition("VisibilityConstraintViolation", "FileExposure and FileSubscription visibility combination is not allowed"))
+ return ctrlerrors.BlockedErrorf("FileSubscription is blocked by FileExposure visibility")
+ }
+
+ obj.Status.FileTypeRef = types.ObjectRefFromObject(fileType)
+
+ res, err := h.ensureApproval(ctx, obj, fileType, activeExposure)
+ if err != nil {
+ return err
+ }
+ switch res {
+ case builder.ApprovalResultRequestDenied:
+ logger.Info("ApprovalRequest was denied - deleting subscriber SFTP User")
+ obj.SetCondition(condition.NewNotReadyCondition("ApprovalRequestDenied", "ApprovalRequest has been denied"))
+ obj.SetCondition(condition.NewDoneProcessingCondition("ApprovalRequest has been denied"))
+ return h.deleteSubscriberUser(ctx, obj)
+ case builder.ApprovalResultPending:
+ logger.Info("Approval is pending - waiting for approval")
+ obj.SetCondition(condition.NewNotReadyCondition("ApprovalPending", "Waiting for approval decision"))
+ obj.SetCondition(condition.NewBlockedCondition("Waiting for approval decision"))
+ return h.deleteSubscriberUser(ctx, obj)
+ case builder.ApprovalResultDenied:
+ logger.Info("Approval was denied - deleting subscriber SFTP User")
+ obj.SetCondition(condition.NewNotReadyCondition("ApprovalDenied", "Approval has been denied"))
+ obj.SetCondition(condition.NewDoneProcessingCondition("Approval has been denied"))
+ if cleanupErr := h.deleteSubscriberUser(ctx, obj); cleanupErr != nil {
+ return fmt.Errorf("unable to cleanup SFTP User for FileSubscription %q in namespace %q: %w",
+ obj.Name, obj.Namespace, cleanupErr)
+ }
+ return nil
+ case builder.ApprovalResultGranted:
+ logger.Info("Approval is granted - continuing with provisioning")
+ default:
+ return errors.Errorf("unknown approval-builder result %q", res)
+ }
+
+ err = h.syncSubscriberUser(ctx, obj, fileType, activeExposure)
+ if err != nil {
+ return err
+ }
+
+ if !c.AllReady() {
+ obj.SetCondition(condition.NewNotReadyCondition("ChildResourcesNotReady", "One or more child resources are not yet ready"))
+ obj.SetCondition(condition.NewProcessingCondition("ChildResourcesNotReady", "Waiting for child resources"))
+ return nil
+ }
+
+ obj.SetCondition(condition.NewReadyCondition("FileSubscriptionProvisioned", "FileSubscription has been provisioned"))
+ obj.SetCondition(condition.NewDoneProcessingCondition("FileSubscription has been provisioned"))
+ return nil
+}
+
+func (h *FileSubscriptionHandler) Delete(ctx context.Context, obj *filev1.FileSubscription) error {
+ return h.deleteSubscriberUser(ctx, obj)
+}
+
+func (h *FileSubscriptionHandler) ensureApproval(ctx context.Context, obj *filev1.FileSubscription, fileType *filev1.FileType, activeExposure *filev1.FileExposure) (builder.ApprovalResult, error) {
+ c := cclient.ClientFromContextOrDie(ctx)
+
+ properties := approvalProperties(obj)
+
+ requester := &approvalapi.Requester{
+ TeamName: teamNameFromNamespace(obj.Namespace),
+ ApplicationRef: types.TypedObjectRefFromObject(obj, c.Scheme()),
+ Reason: fmt.Sprintf("Team %s requested subscription to file type %s from zone %s",
+ obj.Namespace, fileType.Name, subscriptionZoneName(obj)),
+ }
+
+ err := requester.SetProperties(properties)
+ if err != nil {
+ return builder.ApprovalResultNone, fmt.Errorf("unable to set approvalRequest properties for FileSubscription %q in namespace %q: %w",
+ obj.Name, obj.Namespace, err)
+ }
+
+ decider := &approvalapi.Decider{
+ TeamName: teamNameFromNamespace(activeExposure.Namespace),
+ ApplicationRef: types.TypedObjectRefFromObject(activeExposure, c.Scheme()),
+ }
+
+ approvalBuilder := builder.NewApprovalBuilder(c, obj)
+ approvalBuilder.WithAction("subscribe")
+ approvalBuilder.WithHashValue(requester.Properties)
+ approvalBuilder.WithRequester(requester)
+ approvalBuilder.WithDecider(decider)
+ approvalBuilder.WithStrategy(approvalapi.ApprovalStrategy(activeExposure.Spec.Approval.Strategy))
+ if len(activeExposure.Spec.Approval.TrustedTeams) > 0 {
+ approvalBuilder.WithTrustedRequesters(activeExposure.Spec.Approval.TrustedTeams)
+ }
+
+ res, err := approvalBuilder.Build(ctx)
+ if err != nil {
+ return builder.ApprovalResultNone, err
+ }
+ obj.Status.ApprovalRequest = types.ObjectRefFromObject(approvalBuilder.GetApprovalRequest())
+ obj.Status.Approval = types.ObjectRefFromObject(approvalBuilder.GetApproval())
+
+ return res, nil
+}
+
+func (h *FileSubscriptionHandler) syncSubscriberUser(ctx context.Context, obj *filev1.FileSubscription, fileType *filev1.FileType, activeExposure *filev1.FileExposure) error {
+ _, err := util.SyncSFTPUser(
+ ctx,
+ util.SFTPUserRefForFileSubscription(obj),
+ obj,
+ *types.ObjectRefFromObject(fileType),
+ util.GetPublicKeysFromSFTP(obj.Spec.SFTP),
+ util.SFTPInstanceRefForFileExposure(activeExposure),
+ )
+ if err != nil {
+ return fmt.Errorf("failed to sync subscriber SFTP User: %w", err)
+ }
+ return nil
+}
+
+func (h *FileSubscriptionHandler) deleteSubscriberUser(ctx context.Context, obj *filev1.FileSubscription) error {
+ err := util.DeleteSFTPUser(ctx, util.SFTPUserRefForFileSubscription(obj))
+ if err != nil {
+ return fmt.Errorf("failed to delete subscriber SFTP User: %w", err)
+ }
+ return nil
+}
+
+func visibilityAllowsSubscription(exposure *filev1.FileExposure, subscription *filev1.FileSubscription) bool {
+ if exposure.Spec.Visibility != filev1.VisibilityZone {
+ return true
+ }
+
+ return exposure.Spec.Zone.Equals(subscription.Spec.Zone)
+}
+
+func approvalProperties(subscription *filev1.FileSubscription) map[string]any {
+ return map[string]any{
+ "fileType": subscription.Spec.FileType,
+ "zone": subscriptionZoneName(subscription),
+ }
+}
+
+func subscriptionZoneName(subscription *filev1.FileSubscription) string {
+ if subscription.Spec.Zone == nil {
+ return ""
+ }
+ return subscription.Spec.Zone.Name
+}
+
+// teamNameFromNamespace extracts the composite team name from a namespace
+// following the convention "----".
+//
+// The Team CR metadata.name is "--" (e.g. "eni--narvi-regr"),
+// so we drop the first segment (environment) and return everything after
+// the first "--" separator. If no "--" is found the full namespace is
+// returned as-is.
+// TODO: this can be part of common
+func teamNameFromNamespace(namespace string) string {
+ if idx := strings.Index(namespace, "--"); idx >= 0 {
+ return namespace[idx+2:]
+ }
+ return namespace
+}
diff --git a/file/internal/handler/filesubscription/handler_suite_test.go b/file/internal/handler/filesubscription/handler_suite_test.go
new file mode 100644
index 000000000..0dac24796
--- /dev/null
+++ b/file/internal/handler/filesubscription/handler_suite_test.go
@@ -0,0 +1,17 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package filesubscription
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestFileSubscriptionHandler(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "FileSubscription Handler Suite")
+}
diff --git a/file/internal/handler/filesubscription/handler_test.go b/file/internal/handler/filesubscription/handler_test.go
new file mode 100644
index 000000000..c99e660dd
--- /dev/null
+++ b/file/internal/handler/filesubscription/handler_test.go
@@ -0,0 +1,582 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package filesubscription
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/stretchr/testify/mock"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ k8smeta "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ k8stypes "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+ approvalv1 "github.com/telekom/controlplane/approval/api/v1"
+ cclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/client/fake"
+ "github.com/telekom/controlplane/common/pkg/condition"
+ ctrlerrors "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors"
+ "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+const (
+ testNamespace = "test"
+ testFileTypeName = "test-filetype"
+ testExposureName = "test-exposure"
+ testSubscriptionName = "test-subscription"
+ testZoneName = "test-zone"
+)
+
+// buildScheme builds a runtime.Scheme with all types used by the handler.
+func buildScheme() *runtime.Scheme {
+ s := runtime.NewScheme()
+ _ = filev1.AddToScheme(s)
+ _ = approvalv1.AddToScheme(s)
+ _ = sftpv1.AddToScheme(s)
+ return s
+}
+
+func newTestContext() (context.Context, *fake.MockJanitorClient) {
+ mockClient := fake.NewMockJanitorClient(GinkgoT())
+ ctx := cclient.WithClient(context.Background(), mockClient)
+ return ctx, mockClient
+}
+
+func testFileType() *filev1.FileType {
+ ref := types.ObjectRef{Name: testExposureName, Namespace: testNamespace}
+ return &filev1.FileType{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: filev1.GroupVersion.String(),
+ Kind: "FileType",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testFileTypeName,
+ Namespace: testNamespace,
+ },
+ Status: filev1.FileTypeStatus{
+ FileExposureRef: &ref,
+ },
+ }
+}
+
+func testFileExposure() *filev1.FileExposure {
+ return &filev1.FileExposure{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: filev1.GroupVersion.String(),
+ Kind: "FileExposure",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testExposureName,
+ Namespace: testNamespace,
+ },
+ Spec: filev1.FileExposureSpec{
+ FileType: testFileTypeName,
+ Zone: &types.ObjectRef{Name: testZoneName, Namespace: testNamespace},
+ Visibility: filev1.VisibilityEnterprise,
+ Approval: filev1.Approval{
+ Strategy: filev1.ApprovalStrategyAuto,
+ },
+ },
+ }
+}
+
+func testSubscription() *filev1.FileSubscription {
+ return &filev1.FileSubscription{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: filev1.GroupVersion.String(),
+ Kind: "FileSubscription",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testSubscriptionName,
+ Namespace: testNamespace,
+ Generation: 1,
+ UID: "sub-uid-1234",
+ },
+ Spec: filev1.FileSubscriptionSpec{
+ FileType: testFileTypeName,
+ Zone: &types.ObjectRef{Name: testZoneName, Namespace: testNamespace},
+ },
+ }
+}
+
+var _ = Describe("FileSubscriptionHandler", func() {
+ var handler *FileSubscriptionHandler
+
+ BeforeEach(func() {
+ handler = &FileSubscriptionHandler{}
+ })
+
+ Describe("CreateOrUpdate", func() {
+ It("blocks when the FileType is not found", func() {
+ sub := testSubscription()
+ ctx, mockClient := newTestContext()
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testFileTypeName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileType")).
+ Return(apierrors.NewNotFound(schema.GroupResource{Group: filev1.GroupVersion.Group, Resource: "filetypes"}, testFileTypeName)).
+ Once()
+
+ err := handler.CreateOrUpdate(ctx, sub)
+
+ var blocked ctrlerrors.BlockedError
+ Expect(errors.As(err, &blocked)).To(BeTrue())
+ Expect(err.Error()).To(ContainSubstring("FileType"))
+ })
+
+ It("blocks when FileType has no active FileExposure", func() {
+ sub := testSubscription()
+ ctx, mockClient := newTestContext()
+
+ ftNoExposure := testFileType()
+ ftNoExposure.Status.FileExposureRef = nil
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testFileTypeName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileType")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileType) = *ftNoExposure
+ }).
+ Return(nil).Once()
+
+ err := handler.CreateOrUpdate(ctx, sub)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(k8smeta.IsStatusConditionFalse(sub.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ ready := k8smeta.FindStatusCondition(sub.Status.Conditions, condition.ConditionTypeReady)
+ Expect(ready.Reason).To(Equal("FileExposureNotFound"))
+ })
+
+ It("blocks when the active FileExposure is not found", func() {
+ sub := testSubscription()
+ ctx, mockClient := newTestContext()
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testFileTypeName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileType")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileType) = *testFileType()
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testExposureName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileExposure")).
+ Return(apierrors.NewNotFound(schema.GroupResource{Group: filev1.GroupVersion.Group, Resource: "fileexposures"}, testExposureName)).
+ Once()
+
+ err := handler.CreateOrUpdate(ctx, sub)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(k8smeta.IsStatusConditionFalse(sub.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ })
+
+ It("blocks when visibility constraints prevent subscription", func() {
+ sub := testSubscription()
+ sub.Spec.Zone = &types.ObjectRef{Name: "zone-b", Namespace: testNamespace}
+ ctx, mockClient := newTestContext()
+
+ exposureZoneA := testFileExposure()
+ exposureZoneA.Spec.Visibility = filev1.VisibilityZone
+ exposureZoneA.Spec.Zone = &types.ObjectRef{Name: "zone-a", Namespace: testNamespace}
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testFileTypeName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileType")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileType) = *testFileType()
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testExposureName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileExposure")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileExposure) = *exposureZoneA
+ }).
+ Return(nil).Once()
+
+ err := handler.CreateOrUpdate(ctx, sub)
+
+ var blocked ctrlerrors.BlockedError
+ Expect(errors.As(err, &blocked)).To(BeTrue())
+ Expect(k8smeta.IsStatusConditionFalse(sub.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ })
+
+ It("waits for approval when approval is pending (Approval not yet created)", func() {
+ sub := testSubscription()
+ ctx, mockClient := newTestContext()
+ testScheme := buildScheme()
+
+ exposure := testFileExposure()
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testFileTypeName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileType")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileType) = *testFileType()
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testExposureName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileExposure")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileExposure) = *exposure
+ }).
+ Return(nil).Once()
+ // Scheme is needed by the approval builder's setWithHash()
+ mockClient.EXPECT().Scheme().Return(testScheme).Maybe()
+ // Approval builder creates the ApprovalRequest
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.ApprovalRequest"), mock.Anything).
+ Return(controllerutil.OperationResultCreated, nil).Once()
+ // Approval builder cleans up old requests
+ mockClient.EXPECT().
+ Cleanup(mock.Anything, mock.AnythingOfType("*v1.ApprovalRequestList"), mock.Anything).
+ Return(0, nil).Once()
+ // Approval does not exist yet → Pending
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Approval")).
+ Return(apierrors.NewNotFound(schema.GroupResource{Group: approvalv1.GroupVersion.Group, Resource: "approvals"}, "any")).
+ Once()
+ // On Pending: deleteSubscriberUser is called → Delete on User (tolerates NotFound)
+ mockClient.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.User")).
+ Return(apierrors.NewNotFound(schema.GroupResource{Group: sftpv1.GroupVersion.Group, Resource: "users"}, "any")).
+ Once()
+
+ err := handler.CreateOrUpdate(ctx, sub)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(k8smeta.IsStatusConditionFalse(sub.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ ready := k8smeta.FindStatusCondition(sub.Status.Conditions, condition.ConditionTypeReady)
+ Expect(ready.Reason).To(Equal("ApprovalPending"))
+ })
+
+ It("provisions subscriber User when approval is granted", func() {
+ sub := testSubscription()
+ ctx, mockClient := newTestContext()
+ testScheme := buildScheme()
+
+ exposure := testFileExposure()
+ approval := &approvalv1.Approval{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "filesubscription--" + testSubscriptionName,
+ Namespace: testNamespace,
+ },
+ Spec: approvalv1.ApprovalSpec{
+ State: approvalv1.ApprovalStateGranted,
+ },
+ }
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testFileTypeName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileType")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileType) = *testFileType()
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testExposureName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileExposure")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileExposure) = *exposure
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().Scheme().Return(testScheme).Maybe()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.ApprovalRequest"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().
+ Cleanup(mock.Anything, mock.AnythingOfType("*v1.ApprovalRequestList"), mock.Anything).
+ Return(0, nil).Once()
+ // Approval exists and is granted
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Approval")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*approvalv1.Approval) = *approval
+ }).
+ Return(nil).Once()
+ // syncSubscriberUser → SyncSFTPUser → CreateOrUpdate for sftpv1.User
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.User"), mock.Anything).
+ Return(controllerutil.OperationResultCreated, nil).Once()
+ mockClient.EXPECT().AllReady().Return(true).Once()
+
+ err := handler.CreateOrUpdate(ctx, sub)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(k8smeta.IsStatusConditionTrue(sub.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ })
+
+ It("returns error when active FileExposure Get fails with unexpected error", func() {
+ sub := testSubscription()
+ ctx, mockClient := newTestContext()
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testFileTypeName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileType")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileType) = *testFileType()
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testExposureName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileExposure")).
+ Return(fmt.Errorf("api server error")).Once()
+
+ err := handler.CreateOrUpdate(ctx, sub)
+
+ Expect(err).To(MatchError(ContainSubstring("api server error")))
+ })
+
+ It("sets NotReady condition and deletes subscriber User when approval is denied", func() {
+ sub := testSubscription()
+ ctx, mockClient := newTestContext()
+ testScheme := buildScheme()
+
+ exposure := testFileExposure()
+ deniedApproval := &approvalv1.Approval{
+ ObjectMeta: metav1.ObjectMeta{Name: "any", Namespace: testNamespace},
+ Spec: approvalv1.ApprovalSpec{State: approvalv1.ApprovalStateRejected},
+ }
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testFileTypeName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileType")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileType) = *testFileType()
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testExposureName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileExposure")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileExposure) = *exposure
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().Scheme().Return(testScheme).Maybe()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.ApprovalRequest"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().
+ Cleanup(mock.Anything, mock.AnythingOfType("*v1.ApprovalRequestList"), mock.Anything).
+ Return(0, nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Approval")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*approvalv1.Approval) = *deniedApproval
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.User")).
+ Return(nil).Once()
+
+ err := handler.CreateOrUpdate(ctx, sub)
+
+ Expect(err).NotTo(HaveOccurred())
+ ready := k8smeta.FindStatusCondition(sub.Status.Conditions, condition.ConditionTypeReady)
+ Expect(ready).NotTo(BeNil())
+ Expect(ready.Reason).To(Equal("ApprovalDenied"))
+ })
+
+ It("returns error when subscriber User cleanup fails after approval denial", func() {
+ sub := testSubscription()
+ ctx, mockClient := newTestContext()
+ testScheme := buildScheme()
+
+ exposure := testFileExposure()
+ deniedApproval := &approvalv1.Approval{
+ ObjectMeta: metav1.ObjectMeta{Name: "any", Namespace: testNamespace},
+ Spec: approvalv1.ApprovalSpec{State: approvalv1.ApprovalStateRejected},
+ }
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testFileTypeName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileType")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileType) = *testFileType()
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testExposureName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileExposure")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileExposure) = *exposure
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().Scheme().Return(testScheme).Maybe()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.ApprovalRequest"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().
+ Cleanup(mock.Anything, mock.AnythingOfType("*v1.ApprovalRequestList"), mock.Anything).
+ Return(0, nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Approval")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*approvalv1.Approval) = *deniedApproval
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.User")).
+ Return(fmt.Errorf("delete failed")).Once()
+
+ err := handler.CreateOrUpdate(ctx, sub)
+
+ Expect(err).To(MatchError(ContainSubstring("delete failed")))
+ })
+
+ It("sets Processing condition when child resources are not yet ready after sync", func() {
+ sub := testSubscription()
+ ctx, mockClient := newTestContext()
+ testScheme := buildScheme()
+
+ exposure := testFileExposure()
+ grantedApproval := &approvalv1.Approval{
+ ObjectMeta: metav1.ObjectMeta{Name: "any", Namespace: testNamespace},
+ Spec: approvalv1.ApprovalSpec{State: approvalv1.ApprovalStateGranted},
+ }
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testFileTypeName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileType")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileType) = *testFileType()
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testExposureName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileExposure")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileExposure) = *exposure
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().Scheme().Return(testScheme).Maybe()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.ApprovalRequest"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().
+ Cleanup(mock.Anything, mock.AnythingOfType("*v1.ApprovalRequestList"), mock.Anything).
+ Return(0, nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Approval")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*approvalv1.Approval) = *grantedApproval
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.User"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().AllReady().Return(false).Once()
+
+ err := handler.CreateOrUpdate(ctx, sub)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(k8smeta.IsStatusConditionTrue(sub.Status.Conditions, condition.ConditionTypeProcessing)).To(BeTrue())
+ })
+
+ It("returns error when syncSubscriberUser fails", func() {
+ sub := testSubscription()
+ ctx, mockClient := newTestContext()
+ testScheme := buildScheme()
+
+ exposure := testFileExposure()
+ grantedApproval := &approvalv1.Approval{
+ ObjectMeta: metav1.ObjectMeta{Name: "any", Namespace: testNamespace},
+ Spec: approvalv1.ApprovalSpec{State: approvalv1.ApprovalStateGranted},
+ }
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testFileTypeName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileType")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileType) = *testFileType()
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testExposureName, Namespace: testNamespace}, mock.AnythingOfType("*v1.FileExposure")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*filev1.FileExposure) = *exposure
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().Scheme().Return(testScheme).Maybe()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.ApprovalRequest"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().
+ Cleanup(mock.Anything, mock.AnythingOfType("*v1.ApprovalRequestList"), mock.Anything).
+ Return(0, nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Approval")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*approvalv1.Approval) = *grantedApproval
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.User"), mock.Anything).
+ Return(controllerutil.OperationResultNone, fmt.Errorf("sync failed")).Once()
+
+ err := handler.CreateOrUpdate(ctx, sub)
+
+ Expect(err).To(MatchError(ContainSubstring("sync failed")))
+ })
+ })
+
+ Describe("Delete", func() {
+ It("deletes subscriber SFTP User", func() {
+ sub := testSubscription()
+ ctx, mockClient := newTestContext()
+
+ mockClient.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.User")).
+ Return(nil).Once()
+
+ err := handler.Delete(ctx, sub)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("tolerates NotFound when deleting subscriber SFTP User", func() {
+ sub := testSubscription()
+ ctx, mockClient := newTestContext()
+
+ mockClient.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.User")).
+ Return(apierrors.NewNotFound(schema.GroupResource{Group: sftpv1.GroupVersion.Group, Resource: "users"}, "any")).
+ Once()
+
+ err := handler.Delete(ctx, sub)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("returns wrapped error when SFTP User deletion fails", func() {
+ sub := testSubscription()
+ ctx, mockClient := newTestContext()
+
+ mockClient.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.User")).
+ Return(fmt.Errorf("delete error")).Once()
+
+ err := handler.Delete(ctx, sub)
+
+ Expect(err).To(MatchError(ContainSubstring("delete error")))
+ })
+ })
+})
+
+var _ = Describe("filesubscription helpers", func() {
+ Describe("subscriptionZoneName", func() {
+ It("returns empty string when Zone is nil", func() {
+ sub := &filev1.FileSubscription{}
+ Expect(subscriptionZoneName(sub)).To(Equal(""))
+ })
+
+ It("returns the zone name when Zone is set", func() {
+ sub := testSubscription()
+ Expect(subscriptionZoneName(sub)).To(Equal(testZoneName))
+ })
+ })
+
+ Describe("teamNameFromNamespace", func() {
+ It("returns the full namespace when no '--' separator is present", func() {
+ Expect(teamNameFromNamespace("myteam")).To(Equal("myteam"))
+ })
+
+ It("strips the environment prefix when '--' is present", func() {
+ Expect(teamNameFromNamespace("env--group--team")).To(Equal("group--team"))
+ })
+ })
+})
diff --git a/file/internal/handler/filetype/handler.go b/file/internal/handler/filetype/handler.go
new file mode 100644
index 000000000..c604cd79e
--- /dev/null
+++ b/file/internal/handler/filetype/handler.go
@@ -0,0 +1,60 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package filetype
+
+import (
+ "context"
+
+ cclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/condition"
+ "github.com/telekom/controlplane/common/pkg/handler"
+ "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ "github.com/telekom/controlplane/file/internal/handler/util"
+)
+
+var _ handler.Handler[*filev1.FileType] = &FileTypeHandler{}
+
+type FileTypeHandler struct{}
+
+func (h *FileTypeHandler) CreateOrUpdate(ctx context.Context, obj *filev1.FileType) error {
+ c := cclient.ClientFromContextOrDie(ctx)
+
+ activeExposure, found, err := util.FindActiveFileExposure(ctx, types.ObjectRefFromObject(obj))
+ if err != nil {
+ return err
+ }
+ if !found {
+ obj.Status.FileExposureRef = nil
+ obj.SetCondition(condition.NewNotReadyCondition("FileExposureNotFound", "No FileExposure found for this FileType"))
+ obj.SetCondition(condition.NewBlockedCondition("FileType will be processed when a FileExposure is registered"))
+ return nil
+ }
+
+ _, err = util.GetZoneServiceConfig(ctx, activeExposure.Spec.Zone)
+ if err != nil {
+ return err
+ }
+
+ obj.Status.FileExposureRef = types.ObjectRefFromObject(activeExposure)
+ obj.Status.SFTPInstance = &types.ObjectRef{
+ Name: obj.Name,
+ Namespace: obj.Namespace,
+ }
+
+ if !c.AllReady() {
+ obj.SetCondition(condition.NewNotReadyCondition("ChildResourcesNotReady", "One or more child resources are not yet ready"))
+ obj.SetCondition(condition.NewProcessingCondition("ChildResourcesNotReady", "Waiting for child resources"))
+ return nil
+ }
+
+ obj.SetCondition(condition.NewReadyCondition("FileTypeProvisioned", "FileType has been provisioned"))
+ obj.SetCondition(condition.NewDoneProcessingCondition("FileType has been provisioned"))
+ return nil
+}
+
+func (h *FileTypeHandler) Delete(ctx context.Context, obj *filev1.FileType) error {
+ return nil
+}
diff --git a/file/internal/handler/filetype/handler_suite_test.go b/file/internal/handler/filetype/handler_suite_test.go
new file mode 100644
index 000000000..7034c663e
--- /dev/null
+++ b/file/internal/handler/filetype/handler_suite_test.go
@@ -0,0 +1,17 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package filetype
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestFileTypeHandler(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "FileType Handler Suite")
+}
diff --git a/file/internal/handler/filetype/handler_test.go b/file/internal/handler/filetype/handler_test.go
new file mode 100644
index 000000000..a3f82520a
--- /dev/null
+++ b/file/internal/handler/filetype/handler_test.go
@@ -0,0 +1,196 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package filetype
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/stretchr/testify/mock"
+ k8smeta "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ cclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/client/fake"
+ "github.com/telekom/controlplane/common/pkg/condition"
+ "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+const (
+ testNamespace = "test"
+ testFileTypeName = "test-filetype"
+ testExposureName = "test-exposure"
+ testZoneServiceConfigName = "test-zone"
+)
+
+func newTestContext() (context.Context, *fake.MockJanitorClient) {
+ mockClient := fake.NewMockJanitorClient(GinkgoT())
+ ctx := cclient.WithClient(context.Background(), mockClient)
+ return ctx, mockClient
+}
+
+func testFileType() *filev1.FileType {
+ return &filev1.FileType{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: filev1.GroupVersion.String(),
+ Kind: "FileType",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testFileTypeName,
+ Namespace: testNamespace,
+ Generation: 1,
+ },
+ }
+}
+
+func testFileExposure() *filev1.FileExposure {
+ return &filev1.FileExposure{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: filev1.GroupVersion.String(),
+ Kind: "FileExposure",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testExposureName,
+ Namespace: testNamespace,
+ },
+ Spec: filev1.FileExposureSpec{
+ FileType: testFileTypeName,
+ Zone: &types.ObjectRef{Name: testZoneServiceConfigName, Namespace: testNamespace},
+ },
+ }
+}
+
+func testZoneServiceConfig() *filev1.ZoneServiceConfig {
+ return &filev1.ZoneServiceConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testZoneServiceConfigName,
+ Namespace: testNamespace,
+ },
+ }
+}
+
+// mockListExposures sets up c.List to return the given exposures.
+func mockListExposures(mockClient *fake.MockJanitorClient, exposures []filev1.FileExposure) {
+ mockClient.EXPECT().
+ List(mock.Anything, mock.AnythingOfType("*v1.FileExposureList"), mock.Anything, mock.Anything).
+ Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) {
+ *list.(*filev1.FileExposureList) = filev1.FileExposureList{Items: exposures}
+ }).
+ Return(nil).Once()
+}
+
+// mockListZoneServiceConfigs sets up c.List to return the given configs.
+func mockListZoneServiceConfigs(mockClient *fake.MockJanitorClient, configs []filev1.ZoneServiceConfig) {
+ mockClient.EXPECT().
+ List(mock.Anything, mock.AnythingOfType("*v1.ZoneServiceConfigList"), mock.Anything, mock.Anything).
+ Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) {
+ *list.(*filev1.ZoneServiceConfigList) = filev1.ZoneServiceConfigList{Items: configs}
+ }).
+ Return(nil).Once()
+}
+
+var _ = Describe("FileTypeHandler", func() {
+ var handler *FileTypeHandler
+
+ BeforeEach(func() {
+ handler = &FileTypeHandler{}
+ })
+
+ Describe("CreateOrUpdate", func() {
+ It("blocks when no active FileExposure exists", func() {
+ fileType := testFileType()
+ ctx, mockClient := newTestContext()
+
+ mockListExposures(mockClient, nil)
+
+ err := handler.CreateOrUpdate(ctx, fileType)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(fileType.Status.FileExposureRef).To(BeNil())
+ Expect(k8smeta.IsStatusConditionFalse(fileType.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ ready := k8smeta.FindStatusCondition(fileType.Status.Conditions, condition.ConditionTypeReady)
+ Expect(ready.Reason).To(Equal("FileExposureNotFound"))
+ })
+
+ It("returns error when exposure list fails", func() {
+ fileType := testFileType()
+ ctx, mockClient := newTestContext()
+
+ mockClient.EXPECT().
+ List(mock.Anything, mock.AnythingOfType("*v1.FileExposureList"), mock.Anything, mock.Anything).
+ Return(fmt.Errorf("storage unavailable")).Once()
+
+ err := handler.CreateOrUpdate(ctx, fileType)
+
+ Expect(err).To(MatchError(ContainSubstring("storage unavailable")))
+ })
+
+ It("sets FileExposureRef and SFTPInstance while children not ready", func() {
+ fileType := testFileType()
+ exposure := testFileExposure()
+ ctx, mockClient := newTestContext()
+
+ mockListExposures(mockClient, []filev1.FileExposure{*exposure})
+ mockListZoneServiceConfigs(mockClient, []filev1.ZoneServiceConfig{*testZoneServiceConfig()})
+ mockClient.EXPECT().AllReady().Return(false).Once()
+
+ err := handler.CreateOrUpdate(ctx, fileType)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(fileType.Status.FileExposureRef).NotTo(BeNil())
+ Expect(fileType.Status.FileExposureRef.Name).To(Equal(testExposureName))
+ Expect(fileType.Status.SFTPInstance).NotTo(BeNil())
+ Expect(fileType.Status.SFTPInstance.Name).To(Equal(testFileTypeName))
+ Expect(k8smeta.IsStatusConditionFalse(fileType.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ })
+
+ It("sets Ready condition when all child resources are ready", func() {
+ fileType := testFileType()
+ exposure := testFileExposure()
+ ctx, mockClient := newTestContext()
+
+ mockListExposures(mockClient, []filev1.FileExposure{*exposure})
+ mockListZoneServiceConfigs(mockClient, []filev1.ZoneServiceConfig{*testZoneServiceConfig()})
+ mockClient.EXPECT().AllReady().Return(true).Once()
+
+ err := handler.CreateOrUpdate(ctx, fileType)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(k8smeta.IsStatusConditionTrue(fileType.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ Expect(k8smeta.IsStatusConditionFalse(fileType.Status.Conditions, condition.ConditionTypeProcessing)).To(BeTrue())
+ })
+
+ It("returns error when ZoneServiceConfig list fails", func() {
+ fileType := testFileType()
+ exposure := testFileExposure()
+ ctx, mockClient := newTestContext()
+
+ mockListExposures(mockClient, []filev1.FileExposure{*exposure})
+ mockClient.EXPECT().
+ List(mock.Anything, mock.AnythingOfType("*v1.ZoneServiceConfigList"), mock.Anything, mock.Anything).
+ Return(fmt.Errorf("zone config unavailable")).Once()
+
+ err := handler.CreateOrUpdate(ctx, fileType)
+
+ Expect(err).To(MatchError(ContainSubstring("zone config unavailable")))
+ })
+ })
+
+ Describe("Delete", func() {
+ It("returns nil without calling the client", func() {
+ fileType := testFileType()
+ ctx, _ := newTestContext()
+
+ err := handler.Delete(ctx, fileType)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+ })
+})
diff --git a/file/internal/handler/suite_test.go b/file/internal/handler/suite_test.go
new file mode 100644
index 000000000..f3bfa3b4a
--- /dev/null
+++ b/file/internal/handler/suite_test.go
@@ -0,0 +1,17 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package handler_test
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestHandlers(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Handler Suite")
+}
diff --git a/file/internal/handler/util/getters.go b/file/internal/handler/util/getters.go
new file mode 100644
index 000000000..ad9fb544b
--- /dev/null
+++ b/file/internal/handler/util/getters.go
@@ -0,0 +1,110 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package util
+
+import (
+ "context"
+ "fmt"
+ "slices"
+ "strings"
+
+ "github.com/pkg/errors"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ cclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors"
+ "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ "github.com/telekom/controlplane/file/internal/index"
+)
+
+const (
+ identityClientNamePrefix = "sftp-api"
+)
+
+func GetFileType(ctx context.Context, ref types.ObjectRef) (*filev1.FileType, error) {
+ c := cclient.ClientFromContextOrDie(ctx)
+ fileType := &filev1.FileType{}
+ if err := c.Get(ctx, ref.K8s(), fileType); err != nil {
+ if apierrors.IsNotFound(errors.Cause(err)) {
+ return nil, ctrlerrors.BlockedErrorf("FileType %q not found", ref.String())
+ }
+ return nil, fmt.Errorf("failed to get FileType %q: %w", ref.String(), err)
+ }
+ return fileType, nil
+}
+
+func GetChildResourceRef(obj *filev1.ZoneServiceConfig) types.ObjectRef {
+ return types.ObjectRef{
+ Name: identityClientNamePrefix + "--" + obj.Name,
+ Namespace: obj.Namespace,
+ }
+}
+
+func GetZoneServiceConfig(ctx context.Context, ref *types.ObjectRef) (*filev1.ZoneServiceConfig, error) {
+ c := cclient.ClientFromContextOrDie(ctx)
+ list := &filev1.ZoneServiceConfigList{}
+ err := c.List(ctx, list, client.InNamespace(GetZoneNamespace(ref)), client.MatchingFields{index.FieldSpecZoneOnZoneServiceConfig: ref.String()})
+ if err != nil {
+ if apierrors.IsNotFound(errors.Cause(err)) {
+ return nil, ctrlerrors.BlockedErrorf("ZoneServiceConfig %q not found", ref.String())
+ }
+ return nil, fmt.Errorf("failed to get ZoneServiceConfig %q: %w", ref.String(), err)
+ }
+
+ if len(list.Items) != 1 {
+ return nil, ctrlerrors.BlockedErrorf("expected exactly one ZoneServiceConfig for zone %q, but found %d", ref.String(), len(list.Items))
+ }
+
+ zoneServiceConfig := &list.Items[0]
+ return zoneServiceConfig, nil
+}
+
+func FindFileExposuresForFileType(ctx context.Context, fileType *types.ObjectRef) ([]filev1.FileExposure, error) {
+ c := cclient.ClientFromContextOrDie(ctx)
+ list := &filev1.FileExposureList{}
+ if err := c.List(ctx, list,
+ client.InNamespace(fileType.Namespace),
+ client.MatchingFields{index.FieldSpecFileTypeOnExposure: fileType.Name},
+ ); err != nil {
+ return nil, fmt.Errorf("failed to list FileExposures for FileType %q: %w", fileType.Name, err)
+ }
+
+ exposures := make([]filev1.FileExposure, len(list.Items))
+ copy(exposures, list.Items)
+
+ slices.SortFunc(exposures, func(i, j filev1.FileExposure) int {
+ cmp := i.CreationTimestamp.Compare(j.CreationTimestamp.Time)
+ if cmp == 0 {
+ return strings.Compare(i.Name, j.Name)
+ }
+ return cmp
+ })
+
+ return exposures, nil
+}
+
+func FindActiveFileExposure(ctx context.Context, fileType *types.ObjectRef) (*filev1.FileExposure, bool, error) {
+ exposures, err := FindFileExposuresForFileType(ctx, fileType)
+ if err != nil {
+ return nil, false, err
+ }
+ if len(exposures) == 0 {
+ return nil, false, nil
+ }
+ return &exposures[0], true, nil
+}
+
+func GetPublicKeysFromSFTP(sftp *filev1.FileSFTP) []filev1.SSHPublicKeySpec {
+ if sftp == nil {
+ return nil
+ }
+ return sftp.PublicKeys
+}
+
+func GetZoneNamespace(ref *types.ObjectRef) string {
+ return ref.Namespace + "--" + ref.Name
+}
diff --git a/file/internal/handler/util/labels.go b/file/internal/handler/util/labels.go
new file mode 100644
index 000000000..4e2c32519
--- /dev/null
+++ b/file/internal/handler/util/labels.go
@@ -0,0 +1,31 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package util
+
+import (
+ "k8s.io/apimachinery/pkg/labels"
+
+ "github.com/telekom/controlplane/common/pkg/config"
+ "github.com/telekom/controlplane/common/pkg/types"
+ "github.com/telekom/controlplane/common/pkg/util/labelutil"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+)
+
+func ChildLabels(fileTypeRef types.ObjectRef) map[string]string {
+ return map[string]string{
+ config.DomainLabelKey: "file",
+ filev1.FileTypeNameLabelKey: labelutil.NormalizeLabelValue(fileTypeRef.Name),
+ filev1.FileTypeNamespaceLabelKey: labelutil.NormalizeLabelValue(fileTypeRef.Namespace),
+ config.BuildLabelKey("file.exposure"): labelutil.NormalizeLabelValue(fileTypeRef.Name),
+ config.BuildLabelKey("managed.by"): "file-operator",
+ }
+}
+
+// FileTypeLabelSelector returns a selector matching resources labeled for the given FileType name.
+func FileTypeLabelSelector(fileTypeName string) labels.Selector {
+ return labels.SelectorFromSet(labels.Set{
+ filev1.FileTypeNameLabelKey: labelutil.NormalizeLabelValue(fileTypeName),
+ })
+}
diff --git a/file/internal/handler/util/publickeys.go b/file/internal/handler/util/publickeys.go
new file mode 100644
index 000000000..305cfa636
--- /dev/null
+++ b/file/internal/handler/util/publickeys.go
@@ -0,0 +1,113 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package util
+
+import (
+ "context"
+ "fmt"
+ "slices"
+
+ "github.com/pkg/errors"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+ cclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+)
+
+func SyncSFTPUser(
+ ctx context.Context,
+ userRef types.ObjectRef,
+ owner client.Object,
+ fileTypeRef types.ObjectRef,
+ publicKeys []filev1.SSHPublicKeySpec,
+ instanceRef types.ObjectRef,
+) (*sftpv1.User, error) {
+ c := cclient.ClientFromContextOrDie(ctx)
+ user := &sftpv1.User{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: userRef.Name,
+ Namespace: userRef.Namespace,
+ },
+ }
+
+ keys, err := CanonicalSSHPublicKeys(publicKeys)
+ if err != nil {
+ return nil, err
+ }
+
+ mutator := func() error {
+ if err := controllerutil.SetControllerReference(owner, user, c.Scheme()); err != nil {
+ return fmt.Errorf("failed to set controller reference: %w", err)
+ }
+
+ user.Labels = ChildLabels(fileTypeRef)
+ user.Spec.InstanceRef = instanceRef
+ user.Spec.SSHPublicKeys = keys
+ return nil
+ }
+
+ if _, err := c.CreateOrUpdate(ctx, user, mutator); err != nil {
+ return nil, fmt.Errorf("failed to sync SFTP User %q: %w", userRef.String(), err)
+ }
+ return user, nil
+}
+
+func DeleteSFTPUser(ctx context.Context, userRef types.ObjectRef) error {
+ c := cclient.ClientFromContextOrDie(ctx)
+ user := &sftpv1.User{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: userRef.Name,
+ Namespace: userRef.Namespace,
+ },
+ }
+
+ if err := c.Delete(ctx, user); err != nil {
+ if apierrors.IsNotFound(errors.Cause(err)) {
+ return nil
+ }
+ return fmt.Errorf("failed to delete SFTP User %q: %w", userRef.String(), err)
+ }
+ return nil
+}
+
+func DeleteSFTPInstance(ctx context.Context, instanceRef types.ObjectRef) error {
+ c := cclient.ClientFromContextOrDie(ctx)
+ instance := &sftpv1.Instance{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: instanceRef.Name,
+ Namespace: instanceRef.Namespace,
+ },
+ }
+
+ if err := c.Delete(ctx, instance); err != nil {
+ if apierrors.IsNotFound(errors.Cause(err)) {
+ return nil
+ }
+ return fmt.Errorf("failed to delete SFTP Instance %q: %w", instanceRef.String(), err)
+ }
+ return nil
+}
+
+func CanonicalSSHPublicKeys(publicKeys []filev1.SSHPublicKeySpec) ([]string, error) {
+ canonicalKeys := make([]string, 0, len(publicKeys))
+
+ for i := range publicKeys {
+ canonicalKey, err := sftpv1.CanonicalPublicKey(publicKeys[i].Key)
+ if err != nil {
+ return nil, fmt.Errorf("canonicalizing SSH public key: %w", err)
+ }
+
+ canonicalKeys = append(canonicalKeys, canonicalKey)
+ }
+
+ slices.Sort(canonicalKeys)
+
+ return canonicalKeys, nil
+}
diff --git a/file/internal/handler/util/publickeys_test.go b/file/internal/handler/util/publickeys_test.go
new file mode 100644
index 000000000..51006788f
--- /dev/null
+++ b/file/internal/handler/util/publickeys_test.go
@@ -0,0 +1,358 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package util
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/stretchr/testify/mock"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ k8stypes "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+ cclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/client/fake"
+ "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors"
+ "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// validKey is a minimal SSH public key that passes both parse and fingerprint steps.
+// The base64 payload decodes to arbitrary bytes valid for SHA256.
+const (
+ validKey = "ssh-rsa cHJvdmlkZXI="
+ validKey2 = "ssh-rsa c3Vic2NyaWJlcg=="
+)
+
+var _ = Describe("CanonicalSSHPublicKeys", func() {
+ It("returns nil for an empty input slice", func() {
+ keys, err := CanonicalSSHPublicKeys(nil)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(keys).To(BeEmpty())
+ })
+
+ It("returns an error for a key that cannot be parsed", func() {
+ _, err := CanonicalSSHPublicKeys([]filev1.SSHPublicKeySpec{{Key: "invalidkey"}})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("canonicalizing SSH public key"))
+ })
+
+ It("strips the comment and returns the canonical form of a valid key", func() {
+ keys, err := CanonicalSSHPublicKeys([]filev1.SSHPublicKeySpec{
+ {Key: validKey + " some-comment"},
+ })
+ Expect(err).NotTo(HaveOccurred())
+ Expect(keys).To(HaveLen(1))
+ Expect(keys[0]).To(Equal(validKey))
+ })
+
+ It("returns multiple distinct keys sorted by fingerprint", func() {
+ keys, err := CanonicalSSHPublicKeys([]filev1.SSHPublicKeySpec{
+ {Key: validKey},
+ {Key: validKey2},
+ })
+ Expect(err).NotTo(HaveOccurred())
+ Expect(keys).To(HaveLen(2))
+ })
+})
+
+var _ = Describe("SyncSFTPUser / DeleteSFTPUser / DeleteSFTPInstance", func() {
+ const (
+ testNS = "test"
+ testName = "test-user"
+ )
+
+ newCtx := func() (context.Context, *fake.MockJanitorClient) {
+ mc := fake.NewMockJanitorClient(GinkgoT())
+ return cclient.WithClient(context.Background(), mc), mc
+ }
+
+ userRef := types.ObjectRef{Name: testName, Namespace: testNS}
+ ftRef := types.ObjectRef{Name: "ft", Namespace: testNS}
+ instanceRef := types.ObjectRef{Name: "instance", Namespace: testNS}
+ owner := &filev1.FileExposure{
+ ObjectMeta: metav1.ObjectMeta{Name: "owner", Namespace: testNS},
+ }
+
+ Describe("DeleteSFTPUser", func() {
+ It("calls c.Delete for the user", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.User")).
+ Return(nil).Once()
+ Expect(DeleteSFTPUser(ctx, userRef)).To(Succeed())
+ })
+
+ It("tolerates NotFound", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.User")).
+ Return(apierrors.NewNotFound(schema.GroupResource{Group: sftpv1.GroupVersion.Group, Resource: "users"}, testName)).
+ Once()
+ Expect(DeleteSFTPUser(ctx, userRef)).To(Succeed())
+ })
+
+ It("returns wrapped error on unexpected delete failure", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.User")).
+ Return(fmt.Errorf("network error")).Once()
+ err := DeleteSFTPUser(ctx, userRef)
+ Expect(err).To(MatchError(ContainSubstring("network error")))
+ })
+ })
+
+ Describe("DeleteSFTPInstance", func() {
+ It("calls c.Delete for the instance", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.Instance")).
+ Return(nil).Once()
+ Expect(DeleteSFTPInstance(ctx, instanceRef)).To(Succeed())
+ })
+
+ It("tolerates NotFound", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.Instance")).
+ Return(apierrors.NewNotFound(schema.GroupResource{}, "instance")).
+ Once()
+ Expect(DeleteSFTPInstance(ctx, instanceRef)).To(Succeed())
+ })
+
+ It("returns wrapped error when Delete fails unexpectedly", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ Delete(mock.Anything, mock.AnythingOfType("*v1.Instance")).
+ Return(fmt.Errorf("network timeout")).Once()
+ err := DeleteSFTPInstance(ctx, instanceRef)
+ Expect(err).To(MatchError(ContainSubstring("network timeout")))
+ })
+ })
+
+ Describe("SyncSFTPUser", func() {
+ It("calls c.CreateOrUpdate for the user", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.User"), mock.Anything).
+ Return(controllerutil.OperationResultCreated, nil).Once()
+
+ user, err := SyncSFTPUser(ctx, userRef, owner, ftRef, nil, instanceRef)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(user).NotTo(BeNil())
+ })
+
+ It("returns wrapped error when CreateOrUpdate fails", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.User"), mock.Anything).
+ Return(controllerutil.OperationResultNone, fmt.Errorf("create failed")).Once()
+
+ _, err := SyncSFTPUser(ctx, userRef, owner, ftRef, nil, instanceRef)
+
+ Expect(err).To(MatchError(ContainSubstring("create failed")))
+ })
+
+ It("returns error when a provided SSH key cannot be canonicalized", func() {
+ ctx, mc := newCtx()
+ _ = mc // no calls expected
+
+ invalidKeys := []filev1.SSHPublicKeySpec{{Key: "notvalidkey"}}
+ _, err := SyncSFTPUser(ctx, userRef, owner, ftRef, invalidKeys, instanceRef)
+
+ Expect(err).To(HaveOccurred())
+ })
+ })
+})
+
+var _ = Describe("Getters", func() {
+ const (
+ testNS = "test"
+ testFTName = "my-ft"
+ testZoneName = "my-zone"
+ testZoneNS = "test--my-zone"
+ )
+
+ newCtx := func() (context.Context, *fake.MockJanitorClient) {
+ mc := fake.NewMockJanitorClient(GinkgoT())
+ return cclient.WithClient(context.Background(), mc), mc
+ }
+
+ Describe("GetFileType", func() {
+ ref := types.ObjectRef{Name: testFTName, Namespace: testNS}
+
+ It("returns the FileType on success", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ Get(mock.Anything, ref.K8s(), mock.AnythingOfType("*v1.FileType")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ out.(*filev1.FileType).Name = testFTName
+ }).
+ Return(nil).Once()
+
+ ft, err := GetFileType(ctx, ref)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(ft.Name).To(Equal(testFTName))
+ })
+
+ It("returns a BlockedError when the FileType is not found", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ Get(mock.Anything, ref.K8s(), mock.AnythingOfType("*v1.FileType")).
+ Return(apierrors.NewNotFound(schema.GroupResource{Group: filev1.GroupVersion.Group, Resource: "filetypes"}, testFTName)).
+ Once()
+
+ _, err := GetFileType(ctx, ref)
+ var blocked ctrlerrors.BlockedError
+ Expect(errors.As(err, &blocked)).To(BeTrue())
+ })
+
+ It("returns a wrapped error on unexpected Get failure", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ Get(mock.Anything, ref.K8s(), mock.AnythingOfType("*v1.FileType")).
+ Return(fmt.Errorf("timeout")).Once()
+
+ _, err := GetFileType(ctx, ref)
+ Expect(err).To(MatchError(ContainSubstring("timeout")))
+ })
+ })
+
+ Describe("GetZoneServiceConfig", func() {
+ zoneRef := &types.ObjectRef{Name: testZoneName, Namespace: testNS}
+
+ It("returns a BlockedError when no ZoneServiceConfig is found", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ List(mock.Anything, mock.AnythingOfType("*v1.ZoneServiceConfigList"), mock.Anything, mock.Anything).
+ Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) {
+ // leave list empty
+ }).
+ Return(nil).Once()
+
+ _, err := GetZoneServiceConfig(ctx, zoneRef)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("ZoneServiceConfig"))
+ })
+
+ It("returns error when list has more than one result", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ List(mock.Anything, mock.AnythingOfType("*v1.ZoneServiceConfigList"), mock.Anything, mock.Anything).
+ Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) {
+ *list.(*filev1.ZoneServiceConfigList) = filev1.ZoneServiceConfigList{
+ Items: []filev1.ZoneServiceConfig{{}, {}},
+ }
+ }).
+ Return(nil).Once()
+
+ _, err := GetZoneServiceConfig(ctx, zoneRef)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("expected exactly one"))
+ })
+
+ It("returns the ZoneServiceConfig when exactly one is found", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ List(mock.Anything, mock.AnythingOfType("*v1.ZoneServiceConfigList"), mock.Anything, mock.Anything).
+ Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) {
+ *list.(*filev1.ZoneServiceConfigList) = filev1.ZoneServiceConfigList{
+ Items: []filev1.ZoneServiceConfig{{
+ ObjectMeta: metav1.ObjectMeta{Name: "zsc", Namespace: testNS},
+ }},
+ }
+ }).
+ Return(nil).Once()
+
+ zsc, err := GetZoneServiceConfig(ctx, zoneRef)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(zsc.Name).To(Equal("zsc"))
+ })
+ })
+
+ Describe("FindActiveFileExposure", func() {
+ ftRef := &types.ObjectRef{Name: testFTName, Namespace: testNS}
+
+ It("returns false when no exposures exist", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ List(mock.Anything, mock.AnythingOfType("*v1.FileExposureList"), mock.Anything, mock.Anything).
+ Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) {
+ // leave empty
+ }).
+ Return(nil).Once()
+
+ _, found, err := FindActiveFileExposure(ctx, ftRef)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(found).To(BeFalse())
+ })
+
+ It("returns the first exposure sorted by creation time when multiple exist", func() {
+ ctx, mc := newCtx()
+ older := filev1.FileExposure{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "older",
+ Namespace: testNS,
+ CreationTimestamp: metav1.Time{},
+ },
+ }
+ newer := filev1.FileExposure{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "newer",
+ Namespace: testNS,
+ CreationTimestamp: metav1.Now(),
+ },
+ }
+ mc.EXPECT().
+ List(mock.Anything, mock.AnythingOfType("*v1.FileExposureList"), mock.Anything, mock.Anything).
+ Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) {
+ *list.(*filev1.FileExposureList) = filev1.FileExposureList{
+ Items: []filev1.FileExposure{newer, older},
+ }
+ }).
+ Return(nil).Once()
+
+ active, found, err := FindActiveFileExposure(ctx, ftRef)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(found).To(BeTrue())
+ Expect(active.Name).To(Equal("older"))
+ })
+
+ It("returns error when list fails", func() {
+ ctx, mc := newCtx()
+ mc.EXPECT().
+ List(mock.Anything, mock.AnythingOfType("*v1.FileExposureList"), mock.Anything, mock.Anything).
+ Return(fmt.Errorf("list failed")).Once()
+
+ _, _, err := FindActiveFileExposure(ctx, ftRef)
+ Expect(err).To(MatchError(ContainSubstring("list failed")))
+ })
+ })
+
+ Describe("GetPublicKeysFromSFTP", func() {
+ It("returns nil when SFTP spec is nil", func() {
+ Expect(GetPublicKeysFromSFTP(nil)).To(BeNil())
+ })
+
+ It("returns the public keys from the SFTP spec", func() {
+ sftp := &filev1.FileSFTP{
+ PublicKeys: []filev1.SSHPublicKeySpec{{Key: validKey}},
+ }
+ keys := GetPublicKeysFromSFTP(sftp)
+ Expect(keys).To(HaveLen(1))
+ Expect(keys[0].Key).To(Equal(validKey))
+ })
+ })
+})
diff --git a/file/internal/handler/util/refs.go b/file/internal/handler/util/refs.go
new file mode 100644
index 000000000..127426b99
--- /dev/null
+++ b/file/internal/handler/util/refs.go
@@ -0,0 +1,60 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package util
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/telekom/controlplane/common/pkg/types"
+ "github.com/telekom/controlplane/common/pkg/util/labelutil"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+)
+
+func SFTPUserRefForFileSubscription(subscription *filev1.FileSubscription) types.ObjectRef {
+ return types.ObjectRef{
+ Name: labelutil.NormalizeNameValue("filesubscription-" + subscription.Name),
+ Namespace: subscription.Namespace,
+ }
+}
+
+func SFTPUserRefForFileExposure(exposure *filev1.FileExposure) types.ObjectRef {
+ return types.ObjectRef{
+ Name: labelutil.NormalizeNameValue("fileexposure-" + exposure.Name),
+ Namespace: exposure.Namespace,
+ }
+}
+
+func SFTPInstanceRefForFileExposure(exposure *filev1.FileExposure) types.ObjectRef {
+ return types.ObjectRef{
+ Name: exposure.Spec.FileType,
+ Namespace: exposure.Namespace,
+ }
+}
+
+func FileExposureSourceRef(exposure *filev1.FileExposure) types.TypedObjectRef {
+ return types.TypedObjectRef{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: filev1.GroupVersion.String(),
+ Kind: "FileExposure",
+ },
+ ObjectRef: types.ObjectRef{
+ Name: exposure.Name,
+ Namespace: exposure.Namespace,
+ },
+ }
+}
+
+func FileSubscriptionSourceRef(subscription *filev1.FileSubscription) types.TypedObjectRef {
+ return types.TypedObjectRef{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: filev1.GroupVersion.String(),
+ Kind: "FileSubscription",
+ },
+ ObjectRef: types.ObjectRef{
+ Name: subscription.Name,
+ Namespace: subscription.Namespace,
+ },
+ }
+}
diff --git a/file/internal/handler/util/refs_test.go b/file/internal/handler/util/refs_test.go
new file mode 100644
index 000000000..40b39efba
--- /dev/null
+++ b/file/internal/handler/util/refs_test.go
@@ -0,0 +1,115 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package util
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/telekom/controlplane/common/pkg/config"
+ "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Refs", func() {
+ Describe("SFTPUserRefForFileSubscription", func() {
+ It("prefixes name with 'filesubscription-' and normalizes it", func() {
+ sub := &filev1.FileSubscription{
+ ObjectMeta: metav1.ObjectMeta{Name: "my-sub", Namespace: "ns"},
+ }
+ ref := SFTPUserRefForFileSubscription(sub)
+ Expect(ref.Namespace).To(Equal("ns"))
+ Expect(ref.Name).To(HavePrefix("filesubscription-"))
+ })
+
+ It("normalizes names with uppercase characters", func() {
+ sub := &filev1.FileSubscription{
+ ObjectMeta: metav1.ObjectMeta{Name: "My-Sub", Namespace: "ns"},
+ }
+ ref := SFTPUserRefForFileSubscription(sub)
+ Expect(ref.Name).To(Equal("filesubscription-my-sub"))
+ })
+ })
+
+ Describe("SFTPUserRefForFileExposure", func() {
+ It("prefixes name with 'fileexposure-' and normalizes it", func() {
+ exposure := &filev1.FileExposure{
+ ObjectMeta: metav1.ObjectMeta{Name: "my-exposure", Namespace: "ns"},
+ }
+ ref := SFTPUserRefForFileExposure(exposure)
+ Expect(ref.Namespace).To(Equal("ns"))
+ Expect(ref.Name).To(Equal("fileexposure-my-exposure"))
+ })
+ })
+
+ Describe("SFTPInstanceRefForFileExposure", func() {
+ It("uses the FileType name as the instance name", func() {
+ exposure := &filev1.FileExposure{
+ ObjectMeta: metav1.ObjectMeta{Name: "my-exposure", Namespace: "ns"},
+ Spec: filev1.FileExposureSpec{FileType: "my-filetype"},
+ }
+ ref := SFTPInstanceRefForFileExposure(exposure)
+ Expect(ref.Name).To(Equal("my-filetype"))
+ Expect(ref.Namespace).To(Equal("ns"))
+ })
+ })
+
+ Describe("FileExposureSourceRef", func() {
+ It("returns a TypedObjectRef with FileExposure GVK", func() {
+ exposure := &filev1.FileExposure{
+ ObjectMeta: metav1.ObjectMeta{Name: "exp", Namespace: "ns"},
+ }
+ ref := FileExposureSourceRef(exposure)
+ Expect(ref.Kind).To(Equal("FileExposure"))
+ Expect(ref.APIVersion).To(Equal(filev1.GroupVersion.String()))
+ Expect(ref.Name).To(Equal("exp"))
+ })
+ })
+
+ Describe("FileSubscriptionSourceRef", func() {
+ It("returns a TypedObjectRef with FileSubscription GVK", func() {
+ sub := &filev1.FileSubscription{
+ ObjectMeta: metav1.ObjectMeta{Name: "sub", Namespace: "ns"},
+ }
+ ref := FileSubscriptionSourceRef(sub)
+ Expect(ref.Kind).To(Equal("FileSubscription"))
+ Expect(ref.APIVersion).To(Equal(filev1.GroupVersion.String()))
+ })
+ })
+
+ Describe("GetChildResourceRef", func() {
+ It("prefixes name with 'sftp-api--'", func() {
+ obj := &filev1.ZoneServiceConfig{
+ ObjectMeta: metav1.ObjectMeta{Name: "my-zone", Namespace: "ns"},
+ }
+ ref := GetChildResourceRef(obj)
+ Expect(ref.Name).To(Equal("sftp-api--my-zone"))
+ Expect(ref.Namespace).To(Equal("ns"))
+ })
+ })
+
+ Describe("Labels", func() {
+ Describe("ChildLabels", func() {
+ It("returns all expected label keys", func() {
+ ft := types.ObjectRef{Name: "my-ft", Namespace: "ns"}
+ labels := ChildLabels(ft)
+ Expect(labels).To(HaveKey(config.DomainLabelKey))
+ Expect(labels[config.DomainLabelKey]).To(Equal("file"))
+ Expect(labels).To(HaveKey(filev1.FileTypeNameLabelKey))
+ Expect(labels[filev1.FileTypeNameLabelKey]).To(Equal("my-ft"))
+ })
+ })
+
+ Describe("FileTypeLabelSelector", func() {
+ It("returns a non-nil selector", func() {
+ sel := FileTypeLabelSelector("my-ft")
+ Expect(sel).NotTo(BeNil())
+ Expect(sel.String()).To(ContainSubstring("my-ft"))
+ })
+ })
+ })
+})
diff --git a/file/internal/handler/util/util_suite_test.go b/file/internal/handler/util/util_suite_test.go
new file mode 100644
index 000000000..843576bf3
--- /dev/null
+++ b/file/internal/handler/util/util_suite_test.go
@@ -0,0 +1,17 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package util
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestUtil(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Util Suite")
+}
diff --git a/file/internal/handler/zoneserviceconfig/handler.go b/file/internal/handler/zoneserviceconfig/handler.go
new file mode 100644
index 000000000..8d9e41bae
--- /dev/null
+++ b/file/internal/handler/zoneserviceconfig/handler.go
@@ -0,0 +1,356 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package zoneserviceconfig
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/pkg/errors"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+ adminv1 "github.com/telekom/controlplane/admin/api/v1"
+ cclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/condition"
+ cconfig "github.com/telekom/controlplane/common/pkg/config"
+ "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors"
+ "github.com/telekom/controlplane/common/pkg/handler"
+ "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ "github.com/telekom/controlplane/file/internal/handler/util"
+ gatewayapi "github.com/telekom/controlplane/gateway/api/v1"
+ identityv1 "github.com/telekom/controlplane/identity/api/v1"
+ secretsapi "github.com/telekom/controlplane/secret-manager/api"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+)
+
+const (
+ tokenEndpointPath = "protocol/openid-connect/token"
+)
+
+var _ handler.Handler[*filev1.ZoneServiceConfig] = &ZoneServiceConfigHandler{}
+
+type ZoneServiceConfigHandler struct{}
+
+func (h *ZoneServiceConfigHandler) CreateOrUpdate(ctx context.Context, obj *filev1.ZoneServiceConfig) error {
+ c := cclient.ClientFromContextOrDie(ctx)
+
+ zone, err := getZoneForZoneServiceConfig(ctx, obj)
+ if err != nil {
+ return err
+ }
+
+ if !condition.IsReady(zone) {
+ obj.SetCondition(condition.NewNotReadyCondition("ZoneNotReady", "Zone is not ready"))
+ obj.SetCondition(condition.NewBlockedCondition("Waiting for Zone to be ready"))
+ return nil
+ }
+
+ apiClient, err := createOrUpdateSFTPAPIClient(ctx, obj, zone)
+ if err != nil {
+ return err
+ }
+
+ _, err = createConsumer(ctx, obj, zone)
+ if err != nil {
+ return err
+ }
+
+ route, err := createManagedRoute(ctx, zone, obj)
+ if err != nil {
+ return err
+ }
+
+ if !c.AllReady() {
+ obj.SetCondition(condition.NewNotReadyCondition("ChildResourcesNotReady", "One or more child resources are not yet ready"))
+ obj.SetCondition(condition.NewProcessingCondition("ChildResourcesNotReady", "Waiting for child resources"))
+ return nil
+ }
+
+ apiEndpoint, err := sftpAPIEndpointFromManagedRoute(route, zone, apiClient)
+ if err != nil {
+ return err
+ }
+
+ childResourceRef := util.GetChildResourceRef(obj)
+
+ sftpConfig := &sftpv1.SFTPServiceConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: childResourceRef.Name,
+ Namespace: childResourceRef.Namespace,
+ },
+ }
+
+ mutator := func() error {
+ locErr := controllerutil.SetControllerReference(obj, sftpConfig, c.Scheme())
+ if locErr != nil {
+ return fmt.Errorf("failed to set controller reference for sftpConfig: %w", locErr)
+ }
+ sftpConfig.Spec.API = apiEndpoint
+ return nil
+ }
+
+ if _, err := c.CreateOrUpdate(ctx, sftpConfig, mutator); err != nil {
+ return fmt.Errorf("failed to create or update SFTPServiceConfig %q: %w", obj.Name, err)
+ }
+
+ obj.Status.SFTPServiceConfigRef = types.ObjectRefFromObject(sftpConfig)
+
+ if !c.AllReady() {
+ obj.SetCondition(condition.NewNotReadyCondition("ChildResourcesNotReady", "One or more child resources are not yet ready"))
+ obj.SetCondition(condition.NewProcessingCondition("ChildResourcesNotReady", "Waiting for child resources"))
+ return nil
+ }
+
+ obj.SetCondition(condition.NewReadyCondition("ZoneServiceConfigProvisioned", "ZoneServiceConfig has been provisioned"))
+ obj.SetCondition(condition.NewDoneProcessingCondition("ZoneServiceConfig has been provisioned"))
+ return nil
+}
+
+func (h *ZoneServiceConfigHandler) Delete(ctx context.Context, obj *filev1.ZoneServiceConfig) error {
+ // TODO: add removing of secrets from secret-manager when deleting the SFTP API Client
+ return nil
+}
+
+func getZoneForZoneServiceConfig(ctx context.Context, obj *filev1.ZoneServiceConfig) (*adminv1.Zone, error) {
+ c := cclient.ClientFromContextOrDie(ctx)
+ ref := types.ObjectRef{
+ Name: obj.Name,
+ Namespace: obj.Labels[cconfig.EnvironmentLabelKey],
+ }
+
+ zone := &adminv1.Zone{}
+ if err := c.Get(ctx, ref.K8s(), zone); err != nil {
+ if apierrors.IsNotFound(errors.Cause(err)) {
+ return nil, ctrlerrors.BlockedErrorf("Zone %q not found", ref.String())
+ }
+ return nil, fmt.Errorf("failed to get Zone %q: %w", ref.String(), err)
+ }
+ return zone, nil
+}
+
+// createOrUpdateSFTPAPIClient creates or updates an identity Client for the SFTP API.
+func createOrUpdateSFTPAPIClient(ctx context.Context, obj *filev1.ZoneServiceConfig, zone *adminv1.Zone) (*identityv1.Client, error) {
+ cc := cclient.ClientFromContextOrDie(ctx)
+
+ if zone.Status.InternalIdentityRealm == nil {
+ return nil, ctrlerrors.BlockedErrorf("zone %q has no internal identity realm", zone.Name)
+ }
+
+ apiClientRef := util.GetChildResourceRef(obj)
+
+ apiClient := &identityv1.Client{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: apiClientRef.Name,
+ Namespace: apiClientRef.Namespace,
+ },
+ }
+ err := cc.Get(ctx, client.ObjectKeyFromObject(apiClient), apiClient)
+ found := true
+ if err != nil {
+ if !apierrors.IsNotFound(errors.Cause(err)) {
+ return nil, fmt.Errorf("failed to get identity Client %q: %w", apiClient.Name, err)
+ }
+
+ found = false
+ }
+
+ if found {
+ if !condition.IsReady(apiClient) {
+ return apiClient, nil
+ }
+
+ if apiClient.Status.SecretExpiresAt == nil {
+ return apiClient, nil
+ }
+
+ weekBeforeExp := apiClient.Status.SecretExpiresAt.Add(-7 * 24 * time.Hour)
+ if time.Now().Before(weekBeforeExp) {
+ return apiClient, nil
+ }
+ }
+
+ clientSecretPath := fmt.Sprintf("zones/%s/file/%s/%s/clientSecret", zone.Name, obj.Namespace, obj.Name)
+
+ secretValue, err := secretsapi.GenerateSecret()
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate secret for SFTP API Client: %w", err)
+ }
+
+ options := []secretsapi.OnboardingOption{
+ secretsapi.WithMergeStrategy(),
+ secretsapi.WithSecretValue(clientSecretPath, secretValue),
+ }
+
+ availableSecret, err := secretsapi.API().UpsertEnvironment(ctx, zone.Labels[cconfig.EnvironmentLabelKey], options...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to onboard secrets for SFTP API Client: %w", err)
+ }
+
+ ref, found := secretsapi.FindSecretId(availableSecret, clientSecretPath)
+ if !found {
+ return nil, fmt.Errorf("failed to find secret ID for SFTP API Client at path %q", clientSecretPath)
+ }
+
+ mutator := func() error {
+ locErr := controllerutil.SetControllerReference(obj, apiClient, cc.Scheme())
+ if locErr != nil {
+ return fmt.Errorf("failed to set controller reference for apiClient: %w", locErr)
+ }
+
+ apiClient.Spec.ClientId = apiClient.Name
+ apiClient.Spec.ClientSecret = ref
+ apiClient.Spec.Realm = zone.Status.InternalIdentityRealm
+
+ return nil
+ }
+
+ if _, err := cc.CreateOrUpdate(ctx, apiClient, mutator); err != nil {
+ return nil, fmt.Errorf("failed to create or update identity Client %q: %w", apiClient.Name, err)
+ }
+
+ return apiClient, nil
+}
+
+func createConsumer(ctx context.Context, obj *filev1.ZoneServiceConfig, zone *adminv1.Zone) (*gatewayapi.Consumer, error) {
+ cc := cclient.ClientFromContextOrDie(ctx)
+
+ consumerRef := util.GetChildResourceRef(obj)
+
+ consumer := &gatewayapi.Consumer{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: consumerRef.Name,
+ Namespace: consumerRef.Namespace,
+ },
+ }
+
+ mutator := func() error {
+ locErr := controllerutil.SetControllerReference(obj, consumer, cc.Scheme())
+ if locErr != nil {
+ return fmt.Errorf("failed to set controller reference for consumer: %w", locErr)
+ }
+
+ consumer.Spec = gatewayapi.ConsumerSpec{
+ Gateway: *zone.Status.Gateway,
+ Name: consumerRef.Name,
+ }
+
+ return nil
+ }
+
+ _, err := cc.CreateOrUpdate(ctx, consumer, mutator)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create or update consumer %q: %w", consumer.Name, err)
+ }
+
+ return consumer, nil
+}
+
+func sftpAPIEndpointFromManagedRoute(route *gatewayapi.Route, zone *adminv1.Zone, apiClient *identityv1.Client) (sftpv1.APIEndpoint, error) {
+ if len(route.Spec.Hostnames) == 0 {
+ return sftpv1.APIEndpoint{}, fmt.Errorf("route %s doesn't have any hostname", types.ObjectRefFromObject(route).String())
+ }
+
+ if len(route.Spec.Paths) == 0 {
+ return sftpv1.APIEndpoint{}, fmt.Errorf("route %s doesn't have any path", types.ObjectRefFromObject(route).String())
+ }
+
+ endpoint := "https://" + route.Spec.Hostnames[0] + route.Spec.Paths[0]
+
+ tokenEndpoint, err := tokenEndpointFromIssuer(zone.Status.Links.InternalIssuer)
+ if err != nil {
+ return sftpv1.APIEndpoint{}, err
+ }
+
+ return sftpv1.APIEndpoint{
+ Endpoint: endpoint,
+ Issuer: tokenEndpoint,
+ ClientID: apiClient.Spec.ClientId,
+ ClientSecret: apiClient.Spec.ClientSecret,
+ }, nil
+}
+
+func tokenEndpointFromIssuer(rawIssuer string) (string, error) {
+ if strings.HasSuffix(strings.TrimRight(rawIssuer, "/"), tokenEndpointPath) {
+ return rawIssuer, nil
+ }
+ tokenEndpoint, err := url.JoinPath(rawIssuer, tokenEndpointPath)
+ if err != nil {
+ return "", fmt.Errorf("building token endpoint from issuer URL %q: %w", rawIssuer, err)
+ }
+ return tokenEndpoint, nil
+}
+
+// createManagedRoute creates a single gateway route for a managed route configuration.
+func createManagedRoute(ctx context.Context, zone *adminv1.Zone, obj *filev1.ZoneServiceConfig) (*gatewayapi.Route, error) {
+ cc := cclient.ClientFromContextOrDie(ctx)
+
+ preset, err := zone.Spec.Gateway.GetDefaultPreset()
+ if err != nil {
+ return nil, ctrlerrors.BlockedErrorf("managed routes require a default preset but none was found: %s", err)
+ }
+
+ routeRef := util.GetChildResourceRef(obj)
+
+ route := &gatewayapi.Route{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: routeRef.Name,
+ Namespace: routeRef.Namespace,
+ },
+ }
+
+ routeConfig := &obj.Spec.API
+
+ mutator := func() error {
+ locErr := controllerutil.SetControllerReference(obj, route, cc.Scheme())
+ if locErr != nil {
+ return fmt.Errorf("failed to set controller reference for route: %w", locErr)
+ }
+
+ upstreamUrl, locErr := url.Parse(routeConfig.Url)
+ if locErr != nil {
+ return ctrlerrors.BlockedErrorf("cannot parse upstream url of internal route %s: %s", routeConfig.Url, locErr)
+ }
+
+ upstream := gatewayapi.Upstream{
+ Scheme: upstreamUrl.Scheme,
+ Hostname: upstreamUrl.Hostname(),
+ Port: gatewayapi.GetPortOrDefaultFromScheme(upstreamUrl),
+ Path: upstreamUrl.Path,
+ }
+
+ hostnames, paths := preset.ResolveHostnamesAndPaths(routeConfig.Path)
+
+ route.Spec = gatewayapi.RouteSpec{
+ Type: gatewayapi.RouteTypePrimary,
+ GatewayRef: *zone.Status.Gateway,
+ Backend: gatewayapi.Backend{Upstreams: []gatewayapi.Upstream{upstream}},
+ Hostnames: hostnames,
+ Paths: paths,
+ PassThrough: false,
+ Traffic: gatewayapi.Traffic{},
+ Security: gatewayapi.Security{
+ DisableAccessControl: false,
+ TrustedIssuers: []string{zone.Status.Links.InternalIssuer},
+ RealmName: zone.Status.InternalIdentityRealm.Name,
+ DefaultConsumers: []string{util.GetChildResourceRef(obj).Name},
+ },
+ }
+
+ return nil
+ }
+
+ _, err = cc.CreateOrUpdate(ctx, route, mutator)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create or update Gateway route %s in zone %s: %w", route.GetName(), zone.Name, err)
+ }
+ return route, nil
+}
diff --git a/file/internal/handler/zoneserviceconfig/handler_suite_test.go b/file/internal/handler/zoneserviceconfig/handler_suite_test.go
new file mode 100644
index 000000000..b65b02f1e
--- /dev/null
+++ b/file/internal/handler/zoneserviceconfig/handler_suite_test.go
@@ -0,0 +1,17 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package zoneserviceconfig
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestZoneServiceConfigHandler(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "ZoneServiceConfig Handler Suite")
+}
diff --git a/file/internal/handler/zoneserviceconfig/handler_test.go b/file/internal/handler/zoneserviceconfig/handler_test.go
new file mode 100644
index 000000000..2dc889ed9
--- /dev/null
+++ b/file/internal/handler/zoneserviceconfig/handler_test.go
@@ -0,0 +1,733 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package zoneserviceconfig
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/stretchr/testify/mock"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ k8smeta "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ k8stypes "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+ adminv1 "github.com/telekom/controlplane/admin/api/v1"
+ cclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/client/fake"
+ "github.com/telekom/controlplane/common/pkg/condition"
+ cconfig "github.com/telekom/controlplane/common/pkg/config"
+ "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors"
+ "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ gatewayapi "github.com/telekom/controlplane/gateway/api/v1"
+ identityv1 "github.com/telekom/controlplane/identity/api/v1"
+ secretsapi "github.com/telekom/controlplane/secret-manager/api"
+ fakesecrets "github.com/telekom/controlplane/secret-manager/api/fake"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+const (
+ testNamespace = "test"
+ testEnv = "test-env"
+ testZoneName = "test-zsc"
+ testZoneNS = testEnv
+ testAPIURL = "https://sftp-api.internal/v1"
+ testAPIPath = "/sftp/v1"
+ testGatewayHost = "gateway.example.com"
+ testIssuerURL = "https://idp.example.com/auth/realms/test"
+)
+
+// buildScheme builds a runtime.Scheme with all types used by the handler.
+func buildScheme() *runtime.Scheme {
+ s := runtime.NewScheme()
+ _ = filev1.AddToScheme(s)
+ _ = adminv1.AddToScheme(s)
+ _ = identityv1.AddToScheme(s)
+ _ = gatewayapi.AddToScheme(s)
+ _ = sftpv1.AddToScheme(s)
+ return s
+}
+
+func newTestContext() (context.Context, *fake.MockJanitorClient) {
+ mockClient := fake.NewMockJanitorClient(GinkgoT())
+ ctx := cclient.WithClient(context.Background(), mockClient)
+ return ctx, mockClient
+}
+
+func testZoneServiceConfig() *filev1.ZoneServiceConfig {
+ return &filev1.ZoneServiceConfig{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: filev1.GroupVersion.String(),
+ Kind: "ZoneServiceConfig",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testZoneName,
+ Namespace: testNamespace,
+ Labels: map[string]string{cconfig.EnvironmentLabelKey: testEnv},
+ },
+ Spec: filev1.ZoneServiceConfigSpec{
+ API: adminv1.ManagedRouteConfig{
+ Name: "sftp-api",
+ Path: testAPIPath,
+ Url: testAPIURL,
+ Type: adminv1.ManagedRouteTypeTeamAPI,
+ },
+ Zone: &types.ObjectRef{Name: testZoneName, Namespace: testEnv},
+ },
+ }
+}
+
+func testReadyZone() *adminv1.Zone {
+ z := &adminv1.Zone{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: adminv1.GroupVersion.String(),
+ Kind: "Zone",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testZoneName,
+ Namespace: testEnv,
+ Labels: map[string]string{cconfig.EnvironmentLabelKey: testEnv},
+ },
+ Spec: adminv1.ZoneSpec{
+ Gateway: adminv1.GatewayConfig{
+ Presets: []adminv1.GatewayConfigPreset{{
+ Name: "default",
+ Default: true,
+ Urls: []adminv1.UrlConfig{{
+ Hostname: testGatewayHost,
+ BasePath: "/",
+ }},
+ }},
+ },
+ },
+ Status: adminv1.ZoneStatus{
+ Gateway: &types.ObjectRef{Name: "gw", Namespace: testEnv},
+ InternalIdentityRealm: &types.ObjectRef{
+ Name: "internal-realm",
+ Namespace: testEnv,
+ },
+ Links: adminv1.Links{
+ InternalIssuer: testIssuerURL,
+ },
+ },
+ }
+ k8smeta.SetStatusCondition(&z.Status.Conditions, metav1.Condition{
+ Type: condition.ConditionTypeReady,
+ Status: metav1.ConditionTrue,
+ Reason: "Ready",
+ })
+ return z
+}
+
+var _ = Describe("ZoneServiceConfigHandler", func() {
+ var handler *ZoneServiceConfigHandler
+
+ BeforeEach(func() {
+ handler = &ZoneServiceConfigHandler{}
+ })
+
+ Describe("CreateOrUpdate", func() {
+ It("blocks when the Zone is not found", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Return(apierrors.NewNotFound(schema.GroupResource{Group: adminv1.GroupVersion.Group, Resource: "zones"}, testZoneName)).
+ Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ var blocked ctrlerrors.BlockedError
+ Expect(errors.As(err, &blocked)).To(BeTrue())
+ Expect(err.Error()).To(ContainSubstring("Zone"))
+ })
+
+ It("returns error when Zone Get fails", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Return(fmt.Errorf("api server unavailable")).
+ Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ Expect(err).To(MatchError(ContainSubstring("api server unavailable")))
+ })
+
+ It("blocks when Zone is not ready", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+
+ notReadyZone := testReadyZone()
+ notReadyZone.Status.Conditions = nil
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *notReadyZone
+ }).
+ Return(nil).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(k8smeta.IsStatusConditionFalse(obj.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ ready := k8smeta.FindStatusCondition(obj.Status.Conditions, condition.ConditionTypeReady)
+ Expect(ready.Reason).To(Equal("ZoneNotReady"))
+ })
+
+ It("blocks when Zone has no InternalIdentityRealm", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+
+ zoneNoRealm := testReadyZone()
+ zoneNoRealm.Status.InternalIdentityRealm = nil
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *zoneNoRealm
+ }).
+ Return(nil).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ var blocked2 ctrlerrors.BlockedError
+ Expect(errors.As(err, &blocked2)).To(BeTrue())
+ Expect(err.Error()).To(ContainSubstring("internal identity realm"))
+ })
+
+ It("provisions all child resources and sets Ready condition", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+ testScheme := buildScheme()
+
+ zone := testReadyZone()
+
+ // replace secretsapi.API with a mock so UpsertEnvironment works without a real server
+ origAPI := secretsapi.API
+ DeferCleanup(func() { secretsapi.API = origAPI })
+ mockSecretsManager := fakesecrets.NewMockSecretManager(GinkgoT())
+ secretsapi.API = func() secretsapi.SecretManager { return mockSecretsManager }
+ mockSecretsManager.EXPECT().
+ UpsertEnvironment(mock.Anything, testEnv, mock.Anything, mock.Anything).
+ Return(map[string]string{"zones/" + testZoneName + "/file/" + testNamespace + "/" + testZoneName + "/clientSecret": "secret-id::v1"}, nil).
+ Once()
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *zone
+ }).
+ Return(nil).Once()
+ // identity Client Get → not found → will be created
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Client")).
+ Return(apierrors.NewNotFound(schema.GroupResource{}, "sftp-api--test-zsc")).
+ Once()
+ mockClient.EXPECT().Scheme().Return(testScheme).Maybe()
+ // CreateOrUpdate for identity Client
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Client"), mock.Anything).
+ Return(controllerutil.OperationResultCreated, nil).Once()
+ // CreateOrUpdate for gateway Consumer
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Consumer"), mock.Anything).
+ Return(controllerutil.OperationResultCreated, nil).Once()
+ // CreateOrUpdate for gateway Route
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Route"), mock.Anything).
+ Return(controllerutil.OperationResultCreated, nil).Once()
+ // First AllReady (after identity client + consumer + route) → false
+ mockClient.EXPECT().AllReady().Return(false).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(k8smeta.IsStatusConditionFalse(obj.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ })
+
+ It("returns error when identity Client Get fails with unexpected error", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+ zone := testReadyZone()
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *zone
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Client")).
+ Return(fmt.Errorf("connection refused")).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ Expect(err).To(MatchError(ContainSubstring("connection refused")))
+ })
+
+ It("continues without secret rotation when identity Client is found but not ready", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+ zone := testReadyZone()
+ notReadyClient := identityv1.Client{
+ ObjectMeta: metav1.ObjectMeta{Name: "sftp-api--" + testZoneName, Namespace: testNamespace},
+ }
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *zone
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Client")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*identityv1.Client) = notReadyClient
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Consumer"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Route"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().AllReady().Return(false).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("continues without secret rotation when Client is found, ready, and SecretExpiresAt is nil", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+ zone := testReadyZone()
+ readyClient := identityv1.Client{
+ ObjectMeta: metav1.ObjectMeta{Name: "sftp-api--" + testZoneName, Namespace: testNamespace},
+ }
+ k8smeta.SetStatusCondition(&readyClient.Status.Conditions, metav1.Condition{
+ Type: condition.ConditionTypeReady,
+ Status: metav1.ConditionTrue,
+ Reason: "Ready",
+ })
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *zone
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Client")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*identityv1.Client) = readyClient
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Consumer"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Route"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().AllReady().Return(false).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("continues without secret rotation when Client is found, ready, and secret is far from expiry", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+ zone := testReadyZone()
+ farExpiry := metav1.Time{Time: time.Now().Add(30 * 24 * time.Hour)}
+ readyClient := identityv1.Client{
+ ObjectMeta: metav1.ObjectMeta{Name: "sftp-api--" + testZoneName, Namespace: testNamespace},
+ Status: identityv1.ClientStatus{SecretExpiresAt: &farExpiry},
+ }
+ k8smeta.SetStatusCondition(&readyClient.Status.Conditions, metav1.Condition{
+ Type: condition.ConditionTypeReady,
+ Status: metav1.ConditionTrue,
+ Reason: "Ready",
+ })
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *zone
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Client")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*identityv1.Client) = readyClient
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Consumer"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Route"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().AllReady().Return(false).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("rotates the Client secret when expiry is within 7 days", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+ zone := testReadyZone()
+ nearExpiry := metav1.Time{Time: time.Now().Add(3 * 24 * time.Hour)}
+ readyClient := identityv1.Client{
+ ObjectMeta: metav1.ObjectMeta{Name: "sftp-api--" + testZoneName, Namespace: testNamespace},
+ Status: identityv1.ClientStatus{SecretExpiresAt: &nearExpiry},
+ }
+ k8smeta.SetStatusCondition(&readyClient.Status.Conditions, metav1.Condition{
+ Type: condition.ConditionTypeReady,
+ Status: metav1.ConditionTrue,
+ Reason: "Ready",
+ })
+
+ origAPI := secretsapi.API
+ DeferCleanup(func() { secretsapi.API = origAPI })
+ mockSecretsManager := fakesecrets.NewMockSecretManager(GinkgoT())
+ secretsapi.API = func() secretsapi.SecretManager { return mockSecretsManager }
+ secretPath := "zones/" + testZoneName + "/file/" + testNamespace + "/" + testZoneName + "/clientSecret"
+ mockSecretsManager.EXPECT().
+ UpsertEnvironment(mock.Anything, testEnv, mock.Anything, mock.Anything).
+ Return(map[string]string{secretPath: "secret-id::v2"}, nil).Once()
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *zone
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Client")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*identityv1.Client) = readyClient
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Client"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Consumer"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Route"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().AllReady().Return(false).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("returns error when UpsertEnvironment fails", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+ zone := testReadyZone()
+
+ origAPI := secretsapi.API
+ DeferCleanup(func() { secretsapi.API = origAPI })
+ mockSecretsManager := fakesecrets.NewMockSecretManager(GinkgoT())
+ secretsapi.API = func() secretsapi.SecretManager { return mockSecretsManager }
+ mockSecretsManager.EXPECT().
+ UpsertEnvironment(mock.Anything, testEnv, mock.Anything, mock.Anything).
+ Return(nil, fmt.Errorf("secret manager unavailable")).Once()
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *zone
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Client")).
+ Return(apierrors.NewNotFound(schema.GroupResource{}, "sftp-api--"+testZoneName)).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ Expect(err).To(MatchError(ContainSubstring("secret manager unavailable")))
+ })
+
+ It("returns error when the secret ID is not present in the UpsertEnvironment response", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+ zone := testReadyZone()
+
+ origAPI := secretsapi.API
+ DeferCleanup(func() { secretsapi.API = origAPI })
+ mockSecretsManager := fakesecrets.NewMockSecretManager(GinkgoT())
+ secretsapi.API = func() secretsapi.SecretManager { return mockSecretsManager }
+ mockSecretsManager.EXPECT().
+ UpsertEnvironment(mock.Anything, testEnv, mock.Anything, mock.Anything).
+ Return(map[string]string{"unrelated/key": "some-id"}, nil).Once()
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *zone
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Client")).
+ Return(apierrors.NewNotFound(schema.GroupResource{}, "sftp-api--"+testZoneName)).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("failed to find secret ID"))
+ })
+
+ It("returns error when identity Client CreateOrUpdate fails", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+ zone := testReadyZone()
+
+ origAPI := secretsapi.API
+ DeferCleanup(func() { secretsapi.API = origAPI })
+ mockSecretsManager := fakesecrets.NewMockSecretManager(GinkgoT())
+ secretsapi.API = func() secretsapi.SecretManager { return mockSecretsManager }
+ secretPath := "zones/" + testZoneName + "/file/" + testNamespace + "/" + testZoneName + "/clientSecret"
+ mockSecretsManager.EXPECT().
+ UpsertEnvironment(mock.Anything, testEnv, mock.Anything, mock.Anything).
+ Return(map[string]string{secretPath: "secret-id::v1"}, nil).Once()
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *zone
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Client")).
+ Return(apierrors.NewNotFound(schema.GroupResource{}, "sftp-api--"+testZoneName)).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Client"), mock.Anything).
+ Return(controllerutil.OperationResultNone, fmt.Errorf("client creation failed")).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ Expect(err).To(MatchError(ContainSubstring("client creation failed")))
+ })
+
+ It("returns error when createConsumer fails", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+ zone := testReadyZone()
+ notReadyClient := identityv1.Client{
+ ObjectMeta: metav1.ObjectMeta{Name: "sftp-api--" + testZoneName, Namespace: testNamespace},
+ }
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *zone
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Client")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*identityv1.Client) = notReadyClient
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Consumer"), mock.Anything).
+ Return(controllerutil.OperationResultNone, fmt.Errorf("consumer creation failed")).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ Expect(err).To(MatchError(ContainSubstring("consumer creation failed")))
+ })
+
+ It("returns a BlockedError when Zone has no default Gateway preset", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+ zoneNoPreset := testReadyZone()
+ zoneNoPreset.Spec.Gateway.Presets = nil
+ notReadyClient := identityv1.Client{
+ ObjectMeta: metav1.ObjectMeta{Name: "sftp-api--" + testZoneName, Namespace: testNamespace},
+ }
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *zoneNoPreset
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Client")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*identityv1.Client) = notReadyClient
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Consumer"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ var blocked ctrlerrors.BlockedError
+ Expect(errors.As(err, &blocked)).To(BeTrue())
+ Expect(err.Error()).To(ContainSubstring("preset"))
+ })
+
+ It("returns error when Route CreateOrUpdate fails", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+ zone := testReadyZone()
+ notReadyClient := identityv1.Client{
+ ObjectMeta: metav1.ObjectMeta{Name: "sftp-api--" + testZoneName, Namespace: testNamespace},
+ }
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *zone
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Client")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*identityv1.Client) = notReadyClient
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Consumer"), mock.Anything).
+ Return(controllerutil.OperationResultNone, nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Route"), mock.Anything).
+ Return(controllerutil.OperationResultNone, fmt.Errorf("route creation failed")).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ Expect(err).To(MatchError(ContainSubstring("route creation failed")))
+ })
+
+ It("sets Ready condition when all child resources are fully provisioned", func() {
+ obj := testZoneServiceConfig()
+ ctx, mockClient := newTestContext()
+ zone := testReadyZone()
+ notReadyClient := identityv1.Client{
+ ObjectMeta: metav1.ObjectMeta{Name: "sftp-api--" + testZoneName, Namespace: testNamespace},
+ }
+
+ mockClient.EXPECT().
+ Get(mock.Anything, k8stypes.NamespacedName{Name: testZoneName, Namespace: testEnv}, mock.AnythingOfType("*v1.Zone")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*adminv1.Zone) = *zone
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1.Client")).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*identityv1.Client) = notReadyClient
+ }).
+ Return(nil).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Consumer"), mock.Anything).
+ Return(controllerutil.OperationResultCreated, nil).Once()
+ // Populate Hostnames and Paths so sftpAPIEndpointFromManagedRoute can succeed.
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.Route"), mock.Anything).
+ RunAndReturn(func(_ context.Context, obj client.Object, _ controllerutil.MutateFn) (controllerutil.OperationResult, error) {
+ obj.(*gatewayapi.Route).Spec.Hostnames = []string{testGatewayHost}
+ obj.(*gatewayapi.Route).Spec.Paths = []string{testAPIPath}
+ return controllerutil.OperationResultCreated, nil
+ }).Once()
+ mockClient.EXPECT().AllReady().Return(true).Once()
+ mockClient.EXPECT().
+ CreateOrUpdate(mock.Anything, mock.AnythingOfType("*v1.SFTPServiceConfig"), mock.Anything).
+ Return(controllerutil.OperationResultCreated, nil).Once()
+ mockClient.EXPECT().AllReady().Return(true).Once()
+
+ err := handler.CreateOrUpdate(ctx, obj)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(k8smeta.IsStatusConditionTrue(obj.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ Expect(obj.Status.SFTPServiceConfigRef).NotTo(BeNil())
+ })
+ })
+
+ Describe("Delete", func() {
+ It("returns nil (secret cleanup is a TODO)", func() {
+ obj := testZoneServiceConfig()
+ ctx, _ := newTestContext()
+
+ err := handler.Delete(ctx, obj)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+ })
+})
+
+var _ = Describe("tokenEndpointFromIssuer", func() {
+ It("returns the issuer unchanged when it already ends with the token endpoint path", func() {
+ issuer := "https://idp.example.com/auth/realms/test/" + tokenEndpointPath
+ result, err := tokenEndpointFromIssuer(issuer)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result).To(Equal(issuer))
+ })
+
+ It("appends the token endpoint path to the issuer", func() {
+ result, err := tokenEndpointFromIssuer(testIssuerURL)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result).To(HaveSuffix(tokenEndpointPath))
+ Expect(result).To(HavePrefix("https://"))
+ })
+})
+
+var _ = Describe("sftpAPIEndpointFromManagedRoute", func() {
+ It("returns an error when the route has no hostnames", func() {
+ route := &gatewayapi.Route{Spec: gatewayapi.RouteSpec{Paths: []string{testAPIPath}}}
+ _, err := sftpAPIEndpointFromManagedRoute(route, testReadyZone(), &identityv1.Client{})
+ Expect(err).To(MatchError(ContainSubstring("hostname")))
+ })
+
+ It("returns an error when the route has no paths", func() {
+ route := &gatewayapi.Route{Spec: gatewayapi.RouteSpec{Hostnames: []string{testGatewayHost}}}
+ _, err := sftpAPIEndpointFromManagedRoute(route, testReadyZone(), &identityv1.Client{})
+ Expect(err).To(MatchError(ContainSubstring("path")))
+ })
+
+ It("builds the API endpoint from the route and zone", func() {
+ route := &gatewayapi.Route{
+ ObjectMeta: metav1.ObjectMeta{Name: "r", Namespace: testNamespace},
+ Spec: gatewayapi.RouteSpec{
+ Hostnames: []string{testGatewayHost},
+ Paths: []string{testAPIPath},
+ },
+ }
+ apiClient := &identityv1.Client{Spec: identityv1.ClientSpec{ClientId: "my-client"}}
+
+ ep, err := sftpAPIEndpointFromManagedRoute(route, testReadyZone(), apiClient)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(ep.Endpoint).To(Equal("https://" + testGatewayHost + testAPIPath))
+ Expect(ep.ClientID).To(Equal("my-client"))
+ Expect(ep.Issuer).NotTo(BeEmpty())
+ })
+})
diff --git a/file/internal/index/index.go b/file/internal/index/index.go
new file mode 100644
index 000000000..d4e9cbdb4
--- /dev/null
+++ b/file/internal/index/index.go
@@ -0,0 +1,79 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package index
+
+import (
+ "context"
+ "os"
+
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ approvalv1 "github.com/telekom/controlplane/approval/api/v1"
+ "github.com/telekom/controlplane/common/pkg/controller/index"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+)
+
+const (
+ // FieldSpecFileTypeOnExposure indexes FileExposures by their spec.fileType field.
+ FieldSpecFileTypeOnExposure = "spec.fileType.exposure"
+ // FieldSpecZoneOnExposure indexes FileExposures by their spec.zone (namespace/name).
+ FieldSpecZoneOnExposure = "spec.zone.exposure"
+ // FieldSpecFileTypeOnSubscription indexes FileSubscriptions by their spec.fileType field.
+ FieldSpecFileTypeOnSubscription = "spec.fileType.subscription"
+ // FieldSpecZoneOnZoneServiceConfig indexes ZoneServiceConfigs by their spec.zone (namespace/name).
+ FieldSpecZoneOnZoneServiceConfig = "spec.zone.zoneserviceconfig"
+)
+
+func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) {
+ if err := index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &approvalv1.ApprovalRequest{}); err != nil {
+ ctrl.Log.Error(err, "unable to create field-indexer")
+ os.Exit(1)
+ }
+
+ if err := mgr.GetFieldIndexer().IndexField(ctx, &filev1.FileExposure{}, FieldSpecFileTypeOnExposure, func(obj client.Object) []string {
+ exposure, ok := obj.(*filev1.FileExposure)
+ if !ok {
+ return nil
+ }
+ return []string{exposure.Spec.FileType}
+ }); err != nil {
+ ctrl.Log.Error(err, "unable to create fieldIndex for FileExposure", "field", FieldSpecFileTypeOnExposure)
+ os.Exit(1)
+ }
+
+ if err := mgr.GetFieldIndexer().IndexField(ctx, &filev1.FileExposure{}, FieldSpecZoneOnExposure, func(obj client.Object) []string {
+ exposure, ok := obj.(*filev1.FileExposure)
+ if !ok || exposure.Spec.Zone == nil {
+ return nil
+ }
+ return []string{exposure.Spec.Zone.String()}
+ }); err != nil {
+ ctrl.Log.Error(err, "unable to create fieldIndex for FileExposure", "field", FieldSpecZoneOnExposure)
+ os.Exit(1)
+ }
+
+ if err := mgr.GetFieldIndexer().IndexField(ctx, &filev1.FileSubscription{}, FieldSpecFileTypeOnSubscription, func(obj client.Object) []string {
+ subscription, ok := obj.(*filev1.FileSubscription)
+ if !ok {
+ return nil
+ }
+ return []string{subscription.Spec.FileType}
+ }); err != nil {
+ ctrl.Log.Error(err, "unable to create fieldIndex for FileSubscription", "field", FieldSpecFileTypeOnSubscription)
+ os.Exit(1)
+ }
+
+ if err := mgr.GetFieldIndexer().IndexField(ctx, &filev1.ZoneServiceConfig{}, FieldSpecZoneOnZoneServiceConfig, func(obj client.Object) []string {
+ config, ok := obj.(*filev1.ZoneServiceConfig)
+ if !ok || config.Spec.Zone == nil {
+ return nil
+ }
+ return []string{config.Spec.Zone.String()}
+ }); err != nil {
+ ctrl.Log.Error(err, "unable to create fieldIndex for ZoneServiceConfig", "field", FieldSpecZoneOnZoneServiceConfig)
+ os.Exit(1)
+ }
+}
diff --git a/file/internal/webhook/v1/webhook_suite_test.go b/file/internal/webhook/v1/webhook_suite_test.go
new file mode 100644
index 000000000..62c28b642
--- /dev/null
+++ b/file/internal/webhook/v1/webhook_suite_test.go
@@ -0,0 +1,150 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "k8s.io/client-go/kubernetes/scheme"
+ "k8s.io/client-go/rest"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/envtest"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/log/zap"
+ metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
+ "sigs.k8s.io/controller-runtime/pkg/webhook"
+
+ adminv1 "github.com/telekom/controlplane/admin/api/v1"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// These tests use Ginkgo (BDD-style Go testing framework). Refer to
+// http://onsi.github.io/ginkgo/ to learn more about Ginkgo.
+
+var (
+ ctx context.Context
+ cancel context.CancelFunc
+ cfg *rest.Config
+ envTest *envtest.Environment
+ k8sClient client.Client
+)
+
+func TestAPIs(t *testing.T) {
+ RegisterFailHandler(Fail)
+
+ RunSpecs(t, "File Webhook Suite")
+}
+
+var _ = BeforeSuite(func() {
+ logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
+
+ ctx, cancel = context.WithCancel(context.TODO())
+
+ var err error
+ err = adminv1.AddToScheme(scheme.Scheme)
+ Expect(err).NotTo(HaveOccurred())
+
+ err = filev1.AddToScheme(scheme.Scheme)
+ Expect(err).NotTo(HaveOccurred())
+
+ By("bootstrapping test environment")
+ envTest = &envtest.Environment{
+ CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")},
+ ErrorIfCRDPathMissing: false,
+
+ WebhookInstallOptions: envtest.WebhookInstallOptions{
+ Paths: []string{filepath.Join("..", "..", "..", "config", "webhook")},
+ },
+ }
+
+ // Retrieve the first found binary directory to allow running tests from IDEs
+ if getFirstFoundEnvTestBinaryDir() != "" {
+ envTest.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir()
+ }
+
+ cfg, err = envTest.Start()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(cfg).NotTo(BeNil())
+
+ k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(k8sClient).NotTo(BeNil())
+
+ // start webhook server using Manager.
+ webhookInstallOptions := &envTest.WebhookInstallOptions
+ mgr, err := ctrl.NewManager(cfg, ctrl.Options{
+ Scheme: scheme.Scheme,
+ WebhookServer: webhook.NewServer(webhook.Options{
+ Host: webhookInstallOptions.LocalServingHost,
+ Port: webhookInstallOptions.LocalServingPort,
+ CertDir: webhookInstallOptions.LocalServingCertDir,
+ }),
+ LeaderElection: false,
+ Metrics: metricsserver.Options{BindAddress: "0"},
+ })
+ Expect(err).NotTo(HaveOccurred())
+
+ err = SetupZoneServiceConfigWebhookWithManager(mgr)
+ Expect(err).NotTo(HaveOccurred())
+
+ go func() {
+ defer GinkgoRecover()
+ err = mgr.Start(ctx)
+ Expect(err).NotTo(HaveOccurred())
+ }()
+
+ // wait for the webhook server to get ready.
+ dialer := &net.Dialer{Timeout: time.Second}
+ addrPort := fmt.Sprintf("%s:%d", webhookInstallOptions.LocalServingHost, webhookInstallOptions.LocalServingPort)
+ Eventually(func() error {
+ conn, err := tls.DialWithDialer(dialer, "tcp", addrPort, &tls.Config{InsecureSkipVerify: true})
+ if err != nil {
+ return err
+ }
+
+ return conn.Close()
+ }).Should(Succeed())
+})
+
+var _ = AfterSuite(func() {
+ By("tearing down the test environment")
+ cancel()
+ err := envTest.Stop()
+ Expect(err).NotTo(HaveOccurred())
+})
+
+// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path.
+// ENVTEST-based tests depend on specific binaries, usually located in paths set by
+// controller-runtime. When running tests directly (e.g., via an IDE) without using
+// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured.
+//
+// This function streamlines the process by finding the required binaries, similar to
+// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are
+// properly set up, run 'make setup-envtest' beforehand.
+func getFirstFoundEnvTestBinaryDir() string {
+ basePath := filepath.Join("..", "..", "..", "bin", "k8s")
+ entries, err := os.ReadDir(basePath)
+ if err != nil {
+ logf.Log.Error(err, "Failed to read directory", "path", basePath)
+ return ""
+ }
+ for _, entry := range entries {
+ if entry.IsDir() {
+ return filepath.Join(basePath, entry.Name())
+ }
+ }
+ return ""
+}
diff --git a/file/internal/webhook/v1/zoneserviceconfig_webhook.go b/file/internal/webhook/v1/zoneserviceconfig_webhook.go
new file mode 100644
index 000000000..0dc188938
--- /dev/null
+++ b/file/internal/webhook/v1/zoneserviceconfig_webhook.go
@@ -0,0 +1,94 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import (
+ "context"
+ "fmt"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+ adminv1 "github.com/telekom/controlplane/admin/api/v1"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ "github.com/telekom/controlplane/file/internal/handler/util"
+)
+
+var zoneserviceconfiglog = logf.Log.WithName("zoneserviceconfig-resource")
+
+// SetupZoneServiceConfigWebhookWithManager registers the webhook for ZoneServiceConfig in the manager.
+func SetupZoneServiceConfigWebhookWithManager(mgr ctrl.Manager) error {
+ return ctrl.NewWebhookManagedBy(mgr, &filev1.ZoneServiceConfig{}).
+ WithValidator(&ZoneServiceConfigValidator{
+ client: mgr.GetClient(),
+ }).
+ Complete()
+}
+
+// +kubebuilder:rbac:groups=admin.cp.ei.telekom.de,resources=zones,verbs=get;list;watch
+// +kubebuilder:webhook:path=/validate-file-cp-ei-telekom-de-v1-zoneserviceconfig,mutating=false,failurePolicy=fail,sideEffects=None,groups=file.cp.ei.telekom.de,resources=zoneserviceconfigs,verbs=create;update,versions=v1,name=vzoneserviceconfig-v1.kb.io,admissionReviewVersions=v1
+
+// ZoneServiceConfigValidator struct is responsible for validating the ZoneServiceConfig resource.
+//
+// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods,
+// as it is used only for temporary operations and does not need to be deeply copied.
+//
+// +kubebuilder:object:generate=false
+type ZoneServiceConfigValidator struct {
+ client client.Client
+}
+
+var _ admission.Validator[*filev1.ZoneServiceConfig] = &ZoneServiceConfigValidator{}
+
+func (v *ZoneServiceConfigValidator) ValidateCreate(ctx context.Context, obj *filev1.ZoneServiceConfig) (admission.Warnings, error) {
+ zoneserviceconfiglog.V(1).Info("validate create", "name", obj.GetName(), "namespace", obj.GetNamespace())
+ return v.ValidateCreateOrUpdate(ctx, obj)
+}
+
+func (v *ZoneServiceConfigValidator) ValidateUpdate(ctx context.Context, _, obj *filev1.ZoneServiceConfig) (admission.Warnings, error) {
+ zoneserviceconfiglog.V(1).Info("validate update", "name", obj.GetName(), "namespace", obj.GetNamespace())
+ return v.ValidateCreateOrUpdate(ctx, obj)
+}
+
+func (v *ZoneServiceConfigValidator) ValidateDelete(ctx context.Context, obj *filev1.ZoneServiceConfig) (admission.Warnings, error) {
+ zoneserviceconfiglog.V(1).Info("validate delete", "name", obj.GetName(), "namespace", obj.GetNamespace())
+ return nil, nil
+}
+
+func (v *ZoneServiceConfigValidator) ValidateCreateOrUpdate(ctx context.Context, obj *filev1.ZoneServiceConfig) (admission.Warnings, error) {
+ var allErrs field.ErrorList
+ var warnings admission.Warnings
+
+ if obj.Name != obj.Spec.Zone.Name || obj.Namespace != util.GetZoneNamespace(obj.Spec.Zone) {
+ allErrs = append(allErrs, field.Invalid(
+ field.NewPath("metadata").Child("name"),
+ obj.GetName(),
+ fmt.Sprintf("ZoneServiceConfig name and namespace must match the admin Zone it configures. Expected name: %q, namespace: %q", obj.Spec.Zone.Name, obj.Spec.Zone.Namespace),
+ ))
+ }
+
+ // Validate that a Zone with the same name and namespace exists
+ zone := &adminv1.Zone{}
+ if err := v.client.Get(ctx, obj.Spec.Zone.K8s(), zone); err != nil {
+ if apierrors.IsNotFound(err) {
+ allErrs = append(allErrs, field.Required(
+ field.NewPath("metadata").Child("name"),
+ fmt.Sprintf("Zone with name %q not found in namespace %q. ZoneServiceConfig must use the same name and namespace as the admin Zone it configures", obj.GetName(), obj.GetNamespace()),
+ ))
+ } else {
+ return nil, apierrors.NewInternalError(fmt.Errorf("failed to get Zone: %w", err))
+ }
+ }
+
+ if len(allErrs) == 0 {
+ return warnings, nil
+ }
+
+ return warnings, apierrors.NewInvalid(filev1.GroupVersion.WithKind("ZoneServiceConfig").GroupKind(), obj.Name, allErrs)
+}
diff --git a/file/internal/webhook/v1/zoneserviceconfig_webhook_test.go b/file/internal/webhook/v1/zoneserviceconfig_webhook_test.go
new file mode 100644
index 000000000..aa6493c45
--- /dev/null
+++ b/file/internal/webhook/v1/zoneserviceconfig_webhook_test.go
@@ -0,0 +1,204 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+//nolint:unparam // params can be used in the future for more complex validation logic
+package v1
+
+import (
+ "strings"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/kubernetes/scheme"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ adminv1 "github.com/telekom/controlplane/admin/api/v1"
+ "github.com/telekom/controlplane/common/pkg/config"
+ "github.com/telekom/controlplane/common/pkg/types"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ "github.com/telekom/controlplane/file/internal/handler/util"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func newValidZoneServiceConfig(name, namespace string) *filev1.ZoneServiceConfig {
+ return &filev1.ZoneServiceConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ Labels: map[string]string{
+ config.EnvironmentLabelKey: "test-env",
+ },
+ },
+ Spec: filev1.ZoneServiceConfigSpec{
+ API: adminv1.ManagedRouteConfig{
+ Name: "test-api",
+ Path: "/api/v1",
+ Url: "http://test-api:8080",
+ Type: adminv1.ManagedRouteTypeTeamAPI,
+ },
+ Zone: &types.ObjectRef{
+ Name: name,
+ Namespace: strings.Split(namespace, "--")[0],
+ },
+ },
+ }
+}
+
+func newValidZone(name, namespace string) *adminv1.Zone {
+ return &adminv1.Zone{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ },
+ Spec: adminv1.ZoneSpec{
+ IdentityProvider: adminv1.IdentityProviderConfig{},
+ Gateway: adminv1.GatewayConfig{},
+ Visibility: adminv1.ZoneVisibilityWorld,
+ },
+ }
+}
+
+var _ = Describe("ZoneServiceConfig Webhook Validator", func() {
+ Describe("ValidateCreate", func() {
+ It("accepts a valid ZoneServiceConfig with a matching Zone", func() {
+ zone := newValidZone("test-zone", "default")
+ fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(zone).Build()
+
+ validator := &ZoneServiceConfigValidator{client: fakeClient}
+ cfg := newValidZoneServiceConfig("test-zone", util.GetZoneNamespace(types.ObjectRefFromObject(zone)))
+
+ _, err := validator.ValidateCreate(ctx, cfg)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("rejects a ZoneServiceConfig when no matching Zone exists", func() {
+ fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build()
+
+ validator := &ZoneServiceConfigValidator{client: fakeClient}
+ cfg := newValidZoneServiceConfig("nonexistent-zone", "default")
+
+ _, err := validator.ValidateCreate(ctx, cfg)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("Zone with name"))
+ })
+
+ It("rejects a ZoneServiceConfig when the name does not match the Zone", func() {
+ zone := newValidZone("test-zone", "default")
+ fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(zone).Build()
+
+ validator := &ZoneServiceConfigValidator{client: fakeClient}
+ cfg := newValidZoneServiceConfig("mismatched-name", util.GetZoneNamespace(types.ObjectRefFromObject(zone)))
+
+ _, err := validator.ValidateCreate(ctx, cfg)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("must match the admin Zone it configures"))
+ })
+
+ It("rejects a ZoneServiceConfig when the namespace does not match the Zone", func() {
+ zone := newValidZone("test-zone", "default")
+ fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(zone).Build()
+
+ validator := &ZoneServiceConfigValidator{client: fakeClient}
+ cfg := newValidZoneServiceConfig(zone.Name, "wrong--namespace")
+
+ _, err := validator.ValidateCreate(ctx, cfg)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("must match the admin Zone it configures"))
+ })
+
+ It("accepts with valid service endpoint configuration", func() {
+ zone := newValidZone("test-zone-ep", "default")
+ fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(zone).Build()
+
+ validator := &ZoneServiceConfigValidator{client: fakeClient}
+ cfg := newValidZoneServiceConfig("test-zone-ep", util.GetZoneNamespace(types.ObjectRefFromObject((zone))))
+ cfg.Spec.Service = &filev1.ServiceEndpoint{
+ Host: "sftp.example.com",
+ Port: 22,
+ }
+
+ _, err := validator.ValidateCreate(ctx, cfg)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("does not reject empty host (validated by CRD)", func() {
+ zone := newValidZone("test-zone-bad-host", "default")
+ fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(zone).Build()
+
+ validator := &ZoneServiceConfigValidator{client: fakeClient}
+ cfg := newValidZoneServiceConfig("test-zone-bad-host", util.GetZoneNamespace(types.ObjectRefFromObject(zone)))
+ cfg.Spec.Service = &filev1.ServiceEndpoint{
+ Host: "",
+ Port: 22,
+ }
+
+ _, err := validator.ValidateCreate(ctx, cfg)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("does not reject out-of-range port (validated by CRD)", func() {
+ zone := newValidZone("test-zone-bad-port", "default")
+ fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(zone).Build()
+
+ validator := &ZoneServiceConfigValidator{client: fakeClient}
+ cfg := newValidZoneServiceConfig("test-zone-bad-port", util.GetZoneNamespace(types.ObjectRefFromObject(zone)))
+ cfg.Spec.Service = &filev1.ServiceEndpoint{
+ Host: "sftp.example.com",
+ Port: 99999,
+ }
+
+ _, err := validator.ValidateCreate(ctx, cfg)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("accepts valid IP address in service endpoint", func() {
+ zone := newValidZone("test-zone-ip", "default")
+ fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(zone).Build()
+
+ validator := &ZoneServiceConfigValidator{client: fakeClient}
+ cfg := newValidZoneServiceConfig("test-zone-ip", util.GetZoneNamespace(types.ObjectRefFromObject(zone)))
+ cfg.Spec.Service = &filev1.ServiceEndpoint{
+ Host: "192.168.1.100",
+ Port: 22,
+ }
+
+ _, err := validator.ValidateCreate(ctx, cfg)
+ Expect(err).NotTo(HaveOccurred())
+ })
+ })
+
+ Describe("ValidateUpdate", func() {
+ It("accepts a valid update", func() {
+ zone := newValidZone("update-zone", "default")
+ fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(zone).Build()
+
+ validator := &ZoneServiceConfigValidator{client: fakeClient}
+ cfg := newValidZoneServiceConfig("update-zone", util.GetZoneNamespace(types.ObjectRefFromObject(zone)))
+
+ _, err := validator.ValidateUpdate(ctx, cfg, cfg)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("rejects an update when Zone no longer exists", func() {
+ fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build()
+
+ validator := &ZoneServiceConfigValidator{client: fakeClient}
+ cfg := newValidZoneServiceConfig("missing-zone-update", "default")
+
+ _, err := validator.ValidateUpdate(ctx, cfg, cfg)
+ Expect(err).To(HaveOccurred())
+ })
+ })
+
+ Describe("ValidateDelete", func() {
+ It("accepts deletion", func() {
+ validator := &ZoneServiceConfigValidator{client: nil}
+ cfg := newValidZoneServiceConfig("test-zone", "default")
+
+ _, err := validator.ValidateDelete(ctx, cfg)
+ Expect(err).NotTo(HaveOccurred())
+ })
+ })
+})
diff --git a/rover-ctl/pkg/handlers/v0/rover.go b/rover-ctl/pkg/handlers/v0/rover.go
index a3d50bbb0..527abaa5c 100644
--- a/rover-ctl/pkg/handlers/v0/rover.go
+++ b/rover-ctl/pkg/handlers/v0/rover.go
@@ -121,7 +121,9 @@ func PatchExposures(exposures []any) []map[string]any {
exposuresMaps[i]["type"] = "api"
} else if _, exist := exposure["eventType"]; exist {
exposuresMaps[i]["type"] = "event"
- }
+ } else if _, exist := exposure["fileType"]; exist {
+ exposuresMaps[i]["type"] = "file"
+ } // TODO: add more types as needed
}
security, exist := exposure["security"]
if exist {
@@ -150,7 +152,9 @@ func PatchSubscriptions(subscriptions []any) []map[string]any {
subscriptionsMaps[i]["type"] = "api"
} else if _, exist := subscription["eventType"]; exist {
subscriptionsMaps[i]["type"] = "event"
- }
+ } else if _, exist := subscription["fileType"]; exist {
+ subscriptionsMaps[i]["type"] = "file"
+ } // TODO: add more types as needed
}
security, exist := subscription["security"]
if exist {
diff --git a/rover-ctl/pkg/handlers/v0/rover_test.go b/rover-ctl/pkg/handlers/v0/rover_test.go
index 7045b572d..5b1daac39 100644
--- a/rover-ctl/pkg/handlers/v0/rover_test.go
+++ b/rover-ctl/pkg/handlers/v0/rover_test.go
@@ -226,6 +226,34 @@ var _ = Describe("Rover Handler", func() {
Expect(exposures).To(HaveLen(1))
Expect(exposures[0]).To(HaveKeyWithValue("type", "ai"))
})
+
+ It("should patch file exposures", func() {
+ obj := &types.UnstructuredObject{
+ Content: map[string]any{
+ "spec": map[string]any{
+ "exposures": []any{
+ map[string]any{
+ "fileType": "demo-sftp-spec-v1",
+ "variant": "sftp",
+ },
+ },
+ },
+ },
+ }
+
+ err := v0.PatchRoverRequest(context.Background(), obj)
+
+ Expect(err).NotTo(HaveOccurred())
+
+ content := obj.GetContent()
+ Expect(content).To(HaveKey("exposures"))
+
+ exposures := content["exposures"].([]map[string]any)
+ Expect(exposures).To(HaveLen(1))
+
+ exposure := exposures[0]
+ Expect(exposure).To(HaveKeyWithValue("type", "file"))
+ })
})
Context("when processing invalid rover spec", func() {
@@ -343,6 +371,20 @@ var _ = Describe("Rover Handler", func() {
Expect(result[0]["security"]).To(HaveKeyWithValue("type", "basicAuth"))
})
+ It("should patch file subscriptions correctly", func() {
+ subscriptions := []any{
+ map[string]any{
+ "fileType": "demo-sftp-spec-v1",
+ "variant": "sftp",
+ },
+ }
+
+ result := v0.PatchSubscriptions(subscriptions)
+
+ Expect(result).To(HaveLen(1))
+ Expect(result[0]).To(HaveKeyWithValue("type", "file"))
+ })
+
It("should handle nil subscriptions", func() {
// Test with nil
result := v0.PatchSubscriptions(nil)
diff --git a/rover-server/README.md b/rover-server/README.md
index e5852d402..083cc9d31 100644
--- a/rover-server/README.md
+++ b/rover-server/README.md
@@ -52,5 +52,3 @@ Please review the official viper documentation for more details: https://github.
## Installation
See [kustomize](./config/default/kustomization.yaml) for the default installation configuration. And [installation](../install/overlays/default/kustomization.yaml) for more details on how to deploy it with the entire Controlplane.
-
-
diff --git a/rover-server/api/openapi.yaml b/rover-server/api/openapi.yaml
index be76755bd..b2d84d44c 100644
--- a/rover-server/api/openapi.yaml
+++ b/rover-server/api/openapi.yaml
@@ -308,7 +308,7 @@ paths:
description: >
**Important:** The deletion is done asynchronously to ensure that the
resource was actually deleted, use this or the GET resource until you
- receive a 404 response.
+ receive a 404 response.
Delete Rover - will remove your API / Subscriptions / Exposures
operationId: deleteRover
@@ -740,7 +740,7 @@ paths:
description: >
**Important:** The deletion is done asynchronously to ensure that the
resource was actually deleted, use this or the GET resource until you
- receive a 404 response.
+ receive a 404 response.
Delete an ApiSpecification
operationId: deleteApiSpecification
@@ -976,7 +976,7 @@ paths:
description: >
**Important:** The deletion is done asynchronously to ensure that the
resource was actually deleted, use this or the GET resource until you
- receive a 404 response.
+ receive a 404 response.
Delete an EventSpecification
operationId: deleteEventSpecification
@@ -2255,6 +2255,40 @@ components:
type: array
items:
$ref: '#/components/schemas/EventScope'
+ FileExposure:
+ type: object
+ required:
+ - type
+ - fileType
+ - publicKeys
+ properties:
+ type:
+ type: string
+ fileType:
+ type: string
+ variant:
+ type: string
+ enum:
+ - sftp
+ description: File-transfer backend. Optional; currently only "sftp" is supported.
+ visibility:
+ $ref: '#/components/schemas/Visibility'
+ publicKeys:
+ type: array
+ items:
+ $ref: '#/components/schemas/PublicKey'
+ PublicKey:
+ type: object
+ required:
+ - label
+ - key
+ properties:
+ label:
+ type: string
+ description: Human-readable identifier for the key. Must be unique per fileType.
+ key:
+ type: string
+ description: SSH public key value. Must be unique per fileType.
Exposure:
type: object
discriminator:
@@ -2263,10 +2297,12 @@ components:
api: '#/components/schemas/ApiExposure'
event: '#/components/schemas/EventExposure'
ai: '#/components/schemas/AiExposure'
+ file: '#/components/schemas/FileExposure'
oneOf:
- $ref: '#/components/schemas/ApiExposure'
- $ref: '#/components/schemas/EventExposure'
- $ref: '#/components/schemas/AiExposure'
+ - $ref: '#/components/schemas/FileExposure'
ApiExposureInfo:
@@ -2296,6 +2332,15 @@ components:
type: string
enum:
- ai
+ FileExposureInfo:
+ type: object
+ allOf:
+ - $ref: '#/components/schemas/FileExposure'
+ - properties:
+ type:
+ type: string
+ enum:
+ - file
ExposureInfo:
discriminator:
propertyName: type
@@ -2303,10 +2348,12 @@ components:
api: '#/components/schemas/ApiExposureInfo'
event: '#/components/schemas/EventExposureInfo'
ai: '#/components/schemas/AiExposureInfo'
+ file: '#/components/schemas/FileExposureInfo'
oneOf:
- $ref: '#/components/schemas/ApiExposureInfo'
- $ref: '#/components/schemas/EventExposureInfo'
- $ref: '#/components/schemas/AiExposureInfo'
+ - $ref: '#/components/schemas/FileExposureInfo'
ApplicationInfo:
type: object
@@ -2586,7 +2633,7 @@ components:
- id
allOf:
- $ref: '#/components/schemas/RateLimit'
- - properties:
+ - properties:
id:
type: string
description: The unique ID of this consumer (their clientId)
@@ -3001,6 +3048,21 @@ components:
$ref: '#/components/schemas/Failover'
security:
$ref: '#/components/schemas/Security'
+ FileSubscription:
+ type: object
+ required:
+ - type
+ - fileType
+ - publicKeys
+ properties:
+ type:
+ type: string
+ fileType:
+ type: string
+ publicKeys:
+ type: array
+ items:
+ $ref: '#/components/schemas/PublicKey'
Subscription:
type: object
discriminator:
@@ -3009,10 +3071,12 @@ components:
api: '#/components/schemas/ApiSubscription'
event: '#/components/schemas/EventSubscription'
ai: '#/components/schemas/AiSubscription'
+ file: '#/components/schemas/FileSubscription'
oneOf:
- $ref: '#/components/schemas/ApiSubscription'
- $ref: '#/components/schemas/EventSubscription'
- $ref: '#/components/schemas/AiSubscription'
+ - $ref: '#/components/schemas/FileSubscription'
ApiSubscriptionInfo:
type: object
@@ -3049,6 +3113,15 @@ components:
type: string
enum:
- ai
+ FileSubscriptionInfo:
+ type: object
+ allOf:
+ - $ref: '#/components/schemas/FileSubscription'
+ - properties:
+ type:
+ type: string
+ enum:
+ - file
SubscriptionInfo:
readOnly: true
discriminator:
@@ -3057,10 +3130,12 @@ components:
api: '#/components/schemas/ApiSubscriptionInfo'
event: '#/components/schemas/EventSubscriptionInfo'
ai: '#/components/schemas/AiSubscriptionInfo'
+ file: '#/components/schemas/FileSubscriptionInfo'
oneOf:
- $ref: '#/components/schemas/ApiSubscriptionInfo'
- $ref: '#/components/schemas/EventSubscriptionInfo'
- $ref: '#/components/schemas/AiSubscriptionInfo'
+ - $ref: '#/components/schemas/FileSubscriptionInfo'
EventTrigger:
type: object
diff --git a/rover-server/internal/api/server.gen.go b/rover-server/internal/api/server.gen.go
index d244e7d53..43b04262f 100644
--- a/rover-server/internal/api/server.gen.go
+++ b/rover-server/internal/api/server.gen.go
@@ -267,6 +267,66 @@ func (e EventTriggerResponseFilterMode) Valid() bool {
}
}
+// Defines values for FileExposureVariant.
+const (
+ FileExposureVariantSftp FileExposureVariant = "sftp"
+)
+
+// Valid indicates whether the value is a known member of the FileExposureVariant enum.
+func (e FileExposureVariant) Valid() bool {
+ switch e {
+ case FileExposureVariantSftp:
+ return true
+ default:
+ return false
+ }
+}
+
+// Defines values for FileExposureInfoType.
+const (
+ FileExposureInfoTypeFile FileExposureInfoType = "file"
+)
+
+// Valid indicates whether the value is a known member of the FileExposureInfoType enum.
+func (e FileExposureInfoType) Valid() bool {
+ switch e {
+ case FileExposureInfoTypeFile:
+ return true
+ default:
+ return false
+ }
+}
+
+// Defines values for FileExposureInfoVariant.
+const (
+ FileExposureInfoVariantSftp FileExposureInfoVariant = "sftp"
+)
+
+// Valid indicates whether the value is a known member of the FileExposureInfoVariant enum.
+func (e FileExposureInfoVariant) Valid() bool {
+ switch e {
+ case FileExposureInfoVariantSftp:
+ return true
+ default:
+ return false
+ }
+}
+
+// Defines values for FileSubscriptionInfoType.
+const (
+ FileSubscriptionInfoTypeFile FileSubscriptionInfoType = "file"
+)
+
+// Valid indicates whether the value is a known member of the FileSubscriptionInfoType enum.
+func (e FileSubscriptionInfoType) Valid() bool {
+ switch e {
+ case FileSubscriptionInfoTypeFile:
+ return true
+ default:
+ return false
+ }
+}
+
// Defines values for GrantType.
const (
CLIENTCREDENTIALS GrantType = "CLIENT_CREDENTIALS"
@@ -1180,6 +1240,54 @@ type FieldProblem struct {
Title string `json:"title"`
}
+// FileExposure defines model for FileExposure.
+type FileExposure struct {
+ FileType string `json:"fileType"`
+ PublicKeys []PublicKey `json:"publicKeys"`
+ Type string `json:"type"`
+
+ // Variant File-transfer backend. Optional; currently only "sftp" is supported.
+ Variant FileExposureVariant `json:"variant,omitempty,omitzero"`
+ Visibility Visibility `json:"visibility,omitempty,omitzero"`
+}
+
+// FileExposureVariant File-transfer backend. Optional; currently only "sftp" is supported.
+type FileExposureVariant string
+
+// FileExposureInfo defines model for FileExposureInfo.
+type FileExposureInfo struct {
+ FileType string `json:"fileType"`
+ PublicKeys []PublicKey `json:"publicKeys"`
+ Type FileExposureInfoType `json:"type"`
+
+ // Variant File-transfer backend. Optional; currently only "sftp" is supported.
+ Variant FileExposureInfoVariant `json:"variant,omitempty,omitzero"`
+ Visibility Visibility `json:"visibility,omitempty,omitzero"`
+}
+
+// FileExposureInfoType defines model for FileExposureInfo.Type.
+type FileExposureInfoType string
+
+// FileExposureInfoVariant File-transfer backend. Optional; currently only "sftp" is supported.
+type FileExposureInfoVariant string
+
+// FileSubscription defines model for FileSubscription.
+type FileSubscription struct {
+ FileType string `json:"fileType"`
+ PublicKeys []PublicKey `json:"publicKeys"`
+ Type string `json:"type"`
+}
+
+// FileSubscriptionInfo defines model for FileSubscriptionInfo.
+type FileSubscriptionInfo struct {
+ FileType string `json:"fileType"`
+ PublicKeys []PublicKey `json:"publicKeys"`
+ Type FileSubscriptionInfoType `json:"type"`
+}
+
+// FileSubscriptionInfoType defines model for FileSubscriptionInfo.Type.
+type FileSubscriptionInfoType string
+
// GrantType defines model for GrantType.
type GrantType string
@@ -1279,6 +1387,15 @@ type Problem struct {
// ProcessingState defines model for ProcessingState.
type ProcessingState string
+// PublicKey defines model for PublicKey.
+type PublicKey struct {
+ // Key SSH public key value. Must be unique per fileType.
+ Key string `json:"key"`
+
+ // Label Human-readable identifier for the key. Must be unique per fileType.
+ Label string `json:"label"`
+}
+
// RateLimit defines model for RateLimit.
type RateLimit struct {
FaultTolerant bool `json:"faultTolerant,omitempty,omitzero"`
@@ -1817,6 +1934,34 @@ func (t *Exposure) MergeAiExposure(v AiExposure) error {
return err
}
+// AsFileExposure returns the union data inside the Exposure as a FileExposure
+func (t Exposure) AsFileExposure() (FileExposure, error) {
+ var body FileExposure
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromFileExposure overwrites any union data inside the Exposure as the provided FileExposure
+func (t *Exposure) FromFileExposure(v FileExposure) error {
+ v.Type = "file"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeFileExposure performs a merge with any union data inside the Exposure, using the provided FileExposure
+func (t *Exposure) MergeFileExposure(v FileExposure) error {
+ v.Type = "file"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
func (t Exposure) Discriminator() (string, error) {
var discriminator struct {
Discriminator string `json:"type"`
@@ -1837,6 +1982,8 @@ func (t Exposure) ValueByDiscriminator() (interface{}, error) {
return t.AsApiExposure()
case "event":
return t.AsEventExposure()
+ case "file":
+ return t.AsFileExposure()
default:
return nil, errors.New("unknown discriminator value: " + discriminator)
}
@@ -1936,6 +2083,34 @@ func (t *ExposureInfo) MergeAiExposureInfo(v AiExposureInfo) error {
return err
}
+// AsFileExposureInfo returns the union data inside the ExposureInfo as a FileExposureInfo
+func (t ExposureInfo) AsFileExposureInfo() (FileExposureInfo, error) {
+ var body FileExposureInfo
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromFileExposureInfo overwrites any union data inside the ExposureInfo as the provided FileExposureInfo
+func (t *ExposureInfo) FromFileExposureInfo(v FileExposureInfo) error {
+ v.Type = "file"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeFileExposureInfo performs a merge with any union data inside the ExposureInfo, using the provided FileExposureInfo
+func (t *ExposureInfo) MergeFileExposureInfo(v FileExposureInfo) error {
+ v.Type = "file"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
func (t ExposureInfo) Discriminator() (string, error) {
var discriminator struct {
Discriminator string `json:"type"`
@@ -1956,6 +2131,8 @@ func (t ExposureInfo) ValueByDiscriminator() (interface{}, error) {
return t.AsApiExposureInfo()
case "event":
return t.AsEventExposureInfo()
+ case "file":
+ return t.AsFileExposureInfo()
default:
return nil, errors.New("unknown discriminator value: " + discriminator)
}
@@ -2144,6 +2321,34 @@ func (t *Subscription) MergeAiSubscription(v AiSubscription) error {
return err
}
+// AsFileSubscription returns the union data inside the Subscription as a FileSubscription
+func (t Subscription) AsFileSubscription() (FileSubscription, error) {
+ var body FileSubscription
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromFileSubscription overwrites any union data inside the Subscription as the provided FileSubscription
+func (t *Subscription) FromFileSubscription(v FileSubscription) error {
+ v.Type = "file"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeFileSubscription performs a merge with any union data inside the Subscription, using the provided FileSubscription
+func (t *Subscription) MergeFileSubscription(v FileSubscription) error {
+ v.Type = "file"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
func (t Subscription) Discriminator() (string, error) {
var discriminator struct {
Discriminator string `json:"type"`
@@ -2164,6 +2369,8 @@ func (t Subscription) ValueByDiscriminator() (interface{}, error) {
return t.AsApiSubscription()
case "event":
return t.AsEventSubscription()
+ case "file":
+ return t.AsFileSubscription()
default:
return nil, errors.New("unknown discriminator value: " + discriminator)
}
@@ -2263,6 +2470,34 @@ func (t *SubscriptionInfo) MergeAiSubscriptionInfo(v AiSubscriptionInfo) error {
return err
}
+// AsFileSubscriptionInfo returns the union data inside the SubscriptionInfo as a FileSubscriptionInfo
+func (t SubscriptionInfo) AsFileSubscriptionInfo() (FileSubscriptionInfo, error) {
+ var body FileSubscriptionInfo
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromFileSubscriptionInfo overwrites any union data inside the SubscriptionInfo as the provided FileSubscriptionInfo
+func (t *SubscriptionInfo) FromFileSubscriptionInfo(v FileSubscriptionInfo) error {
+ v.Type = "file"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeFileSubscriptionInfo performs a merge with any union data inside the SubscriptionInfo, using the provided FileSubscriptionInfo
+func (t *SubscriptionInfo) MergeFileSubscriptionInfo(v FileSubscriptionInfo) error {
+ v.Type = "file"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
func (t SubscriptionInfo) Discriminator() (string, error) {
var discriminator struct {
Discriminator string `json:"type"`
@@ -2283,6 +2518,8 @@ func (t SubscriptionInfo) ValueByDiscriminator() (interface{}, error) {
return t.AsApiSubscriptionInfo()
case "event":
return t.AsEventSubscriptionInfo()
+ case "file":
+ return t.AsFileSubscriptionInfo()
default:
return nil, errors.New("unknown discriminator value: " + discriminator)
}
@@ -2301,172 +2538,176 @@ func (t *SubscriptionInfo) UnmarshalJSON(b []byte) error {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
- "H4sIAAAAAAAC/+y9a3PbOLIw/FdQPKdqk11KcjKzu+96v7yOLSd6NrF9JGVm9kzypCCyJWFDAhwAlKOZ",
- "8n9/ChfeQYmSL3EcfYoj4trobvQdf3gBixNGgUrhHf/hLQGHwPWfv4xBpJE8ZSmV6v8hiICTRBJGvWPv",
- "JJApjhBN4xlwxOaISIgF4iBTTiFEhCK5BMRBJIwKQDMWrtGccSSXRKAEL6Dv+Z4IlhBjNXpMKInT2Ds+",
- "8j25TsA79giVsADu3dz43i9TJnHUshb9rbGUGMtgSegCBZxI4ATvMOGN7yWY4xikBcZJQk6XmC4gYotR",
- "6FjCElBKyW8pIBKqNehtchAs5QGgOYsidq0Wo4CSYCmBU7TgLE16PQk47vVmWMAVlkvP94gaMTF/Uxyr",
- "peHq/L7H4beUcAi9Y8lTKG/MbkZITuhCA+8kIWOGwxgnX2npxew7L3ySQEDmJMBqtfez/KzthVpv2xbq",
- "69htI6cpF4w3Fx/o35FkaAYoFRBqEknwglA9UR+N5ogyiRLOViSE0NfbmBMupCYidE2iSHXOCK+PFEDs",
- "uEQgjMwykFxiiRJGqBRqPmy6szkKsdSkoff9Wwp8XWzcjONt3txbEhMHVerxBfldre23FISEEM3Wev0B",
- "oyKNgf8TxamQeoMzQBHmC1AMAhveEeMvJMYRygdqWWSk5y+vMYQ5TiPpHb888r054zGWhrx/eOn5nh5X",
- "Ub76mrOBF06+M7a4ccVhTr40N3mp/1CL1A3QnEQS9IFSzDm7VoiYRhnIhUUipBAPMW7wsI/eKSjMAAFV",
- "3BgLUQIVjiLgf1IDfAaaTfMM+os+whThMCYUXRO5zD598ICuer0PHgowzUCf/VrB+w8emqUSYbMaM36A",
- "qTqNrBtGIZnPgQOVutWfhJ3meR+da7geI6ArwhmNgcraBC3nZUbYglRjtgL+teid28l3I/LJEqu5R3TO",
- "mssezZEeQS9LSCxTgTBV96TBT8Ko2gyOIiTSWb5GkRM4i4mUzYu1jy5pZFClPBSesVTqX3GSRJZtISIF",
- "RHMnzyB2JkEWVPM5KqM14hCmAVRvck3SaumSxNBHZ4bWFK+Z40hAGysRJeg4aVX3zolwxlgEmBrAMu7g",
- "LxERUoFsTiAKRZ2HCsYloYvK+mjpwOurU1OUlwVUMYVfzfePfuO4bxRuGIhoAeEVDseGaNT/AkYlGFGl",
- "BP9Bwtksgvgv/xFqB3+UpvtvDnPv2PuvQSGODcxXMRhyzqxQUoXAq5OzT+Ph/7wfTqbeje+dMz4jYQj0",
- "4VZwfjl+NTo7G16o+S+YPGcpDR9u+ovL6afzy/cXZ2r6CfAVcNP2wVYwGY5/Go4/Dcfjy7FaxHuKU7lk",
- "nPwODwiH9xcn76dvLsej/x2emVWINEkYlxC+g5DgqUbeh1vN5P3V1eV4Ojz79G54NjqZ/vtqqAnZdtUC",
- "9QKorAhV6teEswS4JIamRP0zDkNi7turUkPDmS2Bstl/IJCeoc+Md/9aG+pjo7nvWNApByyhRNWPanVv",
- "iZBjy4Gai/sUEfpZbDvCt7rRje9pjUm1zv/Y1K25mHwhN/naMed43dipGd/P1tdtq+3bDLCEBeNrx4Xs",
- "eyR0/mw4vuPDbQ7U98ydvg10mTw50a0LsNXBFGZXU31Z+UTdQPc+CR8PDpPhl4SJlDsOEidKw8HRVtSz",
- "7SaSq6Nfq3Fz9dN1qAHhQUrkKw74M/Btw59WW9/43hyTSEmD23qeZ+1ufC9iOHyFI0wDtYhtNFhprICK",
- "JeQq1UZcyhqeMioxoWZyDjFbwZvCjlOz2yghTKA1S5XEZA5tjTDKpCljANKikjA6YyEtqoFDNIM540rQ",
- "5teYh5mknWkMkun/vju9QkJfyF6JuzROp8orfE9AkHIi19u2PsnaqTF4qnTLKeAdWNi06ORah7QXZmPB",
- "aSIkV51cH1eYE+w0UCk5XNEmCRBYGkBZaz+XNd+dXnm+Nx2+HZ5eXkyHv0zNDyevhxdThwzqeysiyIxE",
- "HQD2U9GyTrh6VL+gwMqwJeoq9reZuDP9B0fR5dw7/nULQRdM4cavc4XsGDIAYeIUxd3rmaSz0hnUh97I",
- "NPYh+r1Q141m7gPKV7x9u7seQQVUd3sMJbvl5kNoUszJ1QiVsA++4DiJ1PADoGQgQcgeTshg9cJzkEbO",
- "BarjvrVsLsgWhVbAhdKNgUquluV3lIHKBlkJmovEhI5M3xdbxKCyxVT32Aa7hiza8WjL4HccrOVkddgr",
- "XV5p1UppZjRaF2r1Mp31RMASCBEOAhBa71ZqBWeRZvwS8wVIa+CaZ5YDLfNokxGCFfC11IZ5UAo/IlJb",
- "qmaAyIIyBSDfQRA16HyswUcfQQO/lOzT3N8YIsACkPqKGEcJcMLCCoa9PHr5Y+/oh96Lv7pwqzJcffSz",
- "4n8FogljtiEiQ7fKbBlLQHPyBYxZKAGujTk0AERixZkh1j4a1y1gh2wsZQIx1ndOhuPGRVKZ+0X/Zf8H",
- "z/espcw79v7vhw/hXz586Jf++e8N077n0Qaz6PvxW4Uh3IKcMqlJrJh/KWUijgcD+0s/YPHAthaDVba6",
- "3IabcuJEjzJx6VMvwFI9r22E9jXVqtIyNipUGzdQXvydMAniMMK+bzXAVg63YnbFCelZlYYDDi9ptK5p",
- "Fk0drWbuwTFkTCVn4JUJd5mjm8JmFDUnC2pX2ZrsvXZIDbVsn5Oygx6Uqu9FqbJOwO9KpXpIDaflbthT",
- "sUl20mySnWRq61B/TBI1N0vSXqGIUNhDoM7CBO5NnLYT7C9MZ3D/hkTpkqi11pIu3UG+Lp9IR+l6mp3/",
- "BvH6f14gJWHvIVtLTCIIUVgVsnW0AIlASEarEsiIEklwlAug2lMeaFYLWKYcnBK1JDJybO1dNgUyDcoT",
- "vfvpClnFonXE7aKywotYra7ky90qLserZE8ROdtGBwHZYsLXFY/tInb0Nmze0V7ycjsfuIW0HK97iif0",
- "4rWVmON1z3LVu5GZi8Eqk+4wx61l5gro95eCc/h/dESJ3dLNURPt0ApHJFRXZ68yjGLazIpqRjxbpCTU",
- "rE9r+ywBihOiOKC4xosFcPQshIRDgCWEz5Gl1ztwq9S2v//tVoXi93LHPRqvam0pd8Pl9nCkVo/31H7J",
- "eZcSVcsgf/3p5GrkFBh3YoV9dGqOVSOPZCgkHAIZrREOQ65RRzXPQ+i6sVCgpDdnrLf6wdvPzKCJKVSb",
- "Rs9EOhOghV0SPq/Mu3mab4n97MrjfW8FNGT8JCFNSP68BLkEnuONDkilyPSwiOSI92ozq+RIWp50Nwd5",
- "jSD2v4FqvDLjJd+Vzym5jdMp2eZ1ygCixu3ORc9LvTaZIfZTvPOYqWyzNYObjf3CbvXlCnhMhLbE8zQC",
- "oW7COVmk3N6WlkzySTqrzuVp2zYeLGHFGS3twWHHKseKhkAlmRPgJia9WPtvKSjFHj2D31IcCUQ4EacR",
- "ASpHmi02zYVmaqf6Yx0EOhyzNIloA8hmNcf3gHPGu6PLlQl9cwEsc9R3H6xiKHKMmGH0kOJZBI7bcURD",
- "tVPFwecoa62YJpgebUCp81DfIzsRzQYolA/XHWWVN5hAwMFGP2QXjftWO8u/H6P3ApDQPRXY+kFpKESo",
- "kICz+GQjY9Ytp4QijOap1CEVxuXTd0ojnIiREClwi4fOFlP2GeiQhjpRoq1hawQZm5mYl7aOiSAuoehq",
- "MkJiLSTEWRJTifiwlDhYGpHIcfSFDHI1GfVeHP3w13/8rerOU7//etT7x8c//nbjdOQV0O9wj2QtjaDA",
- "F1jCZqhmra7SWUTEcriCdsBmbTd87yCaXK6A4ygqJBRRumi6E0XjbnNQh46ImUU78IgzLLFrpGvMKaGL",
- "O6HZ3xltveKrCn75yrdC1u/GhGZBXWMADnKvU1YLHdVPocxfy3DMOXgJJBk7a7mTq76rckqBNxm9u3o7",
- "LIVYnbyfXnp+8fv55fvx8N/DiTO8St2rOmirxaxQosSJUnLdvjINLDXUO5BLFlYXOMOCBF6dP55mcoFA",
- "pjvClaVUhf/x+Sn6299//Ecf/UzkEukhlbCv53juKx1GsxN1V2OuuC2VWRrJm+n0ClXkB+us+qcxlc5Y",
- "uN48QuajUi37JUhfXF4oAF9dTqae7706mYxOlUDJQutyIYET5gkT8i1bsFSOwWiA73lUpYqtLq9EsZrA",
- "YGmpQ+l65HuO7XS+N6SvJqIETdazfReFMNTdhlBeSyFutrGvXId2rYezqIWLbAZBbdq7AcZdrfSVQjy1",
- "3Oa6EizENePhhvUUasMsH8eFxKkA3iIkuPWsvINfLMPF7U43xbufNrzyhSpW3Sq0CaDnEV5o4VNR9r8Y",
- "XSDr6kczM2rmNkFiydIozOw1Lj2+HiCRTercV4SJw7R4ggShiwhQoL6jFY5S6KPhF6ztQoxqM43+FTFu",
- "/jjnLDZZpTPFp6RiSdXN62auuSIileBQmcwZ+JTN4xokUaxyTiiEyGYkKtSNjKRqLTW1CTKcurLu/dJt",
- "e2oTZUs/vWqq5JsQXkNWuHLmdbqn/oquOZESaLHCJMJSaVq9GFO8gBC9fTcxGaJ9lC2zJ0gI2t57jJZM",
- "m2KRupqodjNnt/ufBMqsFCbfcVG0RGWRoNTQ5jQayxcz10y+APSMMmkuJ0wRfFFiLo7Q6OzKfM+EDoUR",
- "5ipUd+fzJh7gNNwauaLR0g1XezLjcmBJN9NH0aWb+6iU8To6y+2mWQ41eiaXQLgVFJwa+I0jVuljlz0V",
- "MTBNg7Fte5aJMt33nPd1oKVqh3RCt7EA5ObevE85WXUGKAau8FPjg8ITK/Ygng9U7iDxZ1AkGih5JgCk",
- "9euWbh8621+auNBJcNCqQAOwrcplzrc6UH2eBVmD7vlp7+//39HfteGJ8RiZHkae07S7Rlr8bhBLqD3v",
- "znXpDqcsdK/aZOd2N9+p5pvMEVRITFvkgEI9tJ9svGvZo996s3e6p80wLtrReu2GULzC4m/0YOCjcBcZ",
- "yPcMC7ARMUp9u/FvFeEHasVlgWIjimftso7TtnAu7e7bwWamBjPak8ta+5CBaA8Ra1bAbisS7WZPr+Lf",
- "9oAzvZDOlu/SKXVnWJKTxWK7Q0OPPbVt3dxstT1XuOzA7AaxEkpv9Xk6VqBdPhujlU6UkMxlJVhJ3Wlr",
- "lmq/tgnnLwx4J9T8ihQArKkX0G8pCT4Libk0Hr47djDm4Uf/Z3J5gTRswOUVzPCn2vvf+VbMop8JSDDX",
- "HlN9I4dMiqqnNIS+hAg+s7gPlPSL3fXdUYel5IZy0sJR/2hrkJMlxe55AM1T3jOWwoUu30M0RXPfXzGe",
- "ormY3ZIqNvS/W0Rwyfx1wlJMtkldWCyhRl+NCIgQepbgdDBEQXA9TXBfJU2iQZklz36FSJ3HsKfTvo2H",
- "m983Ou4DHEUzHHzukAxxmcjLVDrr/2i+Xy7HEzIQuvgVBQiNZaBsbVFUmplb2By9YZz8zqiv0wwU0bJE",
- "IpaaeBSJZmskQEqTOkA0a1AnatSYpgU0hIgoZtAqx4HSEAJ4DfKNlImFtjFhnzP+BnAkl6dLMEDZUOto",
- "qVuiQDXNtTab3pYhMkavh9Pckmy9frocEnozPDnLvrRsRZPJGCRQNf+UuGJ6RroAUMKEILMIFHAidl3X",
- "/bIhdAy7mv7vKMRr0UcTAPRryALx8VkWgBvCCiKFI/l1FsJANRkIHgwk5iERn4JUSBYD/7TENJwx9nmw",
- "NIc4sOfaUwroisD14FQ3/ZRv45Pax+D5B+p0cpcl8JYAq9yBaC+Cgpm4RkzwOmI4bMUGDhZhCIgr4BMI",
- "GG0xVdjyagWwi8KM5VEUDiChx9Gb3FSITc0v+RrPIls4Q6mbG3IRtFfDFtkKVFOTdIO5KT1HQuAQonzM",
- "mqrfnL2RU9PUcbZasPeShndUTwu8qBF49YDbZZ+9w3maTLR5z1ncr0zitrY7Wra5hO9Ar5kWJ1NX2leY",
- "BhBOIALttDjX1f1K6yhGyoqBFU12cm6U+r6zppTCSUhoEKVa8s92WfrlS/bX6OL07fuzoed7w1/MXx+d",
- "zv7GXtzKQvu6S5HKTYCWLCAhUacXE4qlMUbFOEls8h4mHQpC+Do6q0N+lcX8rrpxdszrC1P8Te/ixvcY",
- "hT0Su3bTyrvWwvi4Abh5hcFbAtjW4usGZNu4O6Azp+NdAduOtwPAO/WogUOLhOelWMwqS/idUdfFY30i",
- "pZRP3RBdL0mw1AKbunuwFT1SYdT7POSzdPm4kt4sBbpNgBtDw81yXbhUiZNsbLMtFKqLtqAjeNS57dg1",
- "i1rZ0vCmVWcpba5sRW7mrbXbspO26Nw2+3H9Hm61D7/muBDZco/fyWTy8+X4zPO907ej4cX00+l4eDa8",
- "mI5O3k483xsPz8fDyZtP08t/DS+c7NzI4c09KjG1JdTYxbZHyRjU98I1X7sJo4hd3zZQ4m2m7Fdp583J",
- "dHh5MkHaFlCrf9zwQ1D44lCulHz7fjzKbWaqlSkbrH4pyg8XA/s6Dzb3LhZNtO/H+GY7RJsKiObblxOk",
- "XFfQ3biiXVP49NS+AYgL4d7W8+VrCJIjzibmaNFL75SvnB67JuuzTcucrpq839GtZgp6bk1/yVbmAsK7",
- "IHlMNSbry3lMFSbra/uKlrv6Uu6yumTr2IfakptTZ+qAe0yVJS9xKpcvHUeYB79sjfIQRaxmi1JqPv4L",
- "1hu+FiHwjQaLsgCwaTmFpHDjbw5K4zDnIJZaUOvqi91upyiLfToPZ8sdqDuU8KBWg8ocHuRRZbVI1hBL",
- "XAom05EINgipEtuzIthGpgrEeBZ8ql8VyT0dZgmFlmyDTU03z/deXZ792/O9N8OTs+HYKU7VrQnMINYd",
- "xPg50bYSqN4EnTFgNUrcYxrqOvFECl3XYIWprBaNr1gKdBiVEaQCEMJcwHNdR8HzvVnEgs/6rwRoaD4q",
- "pIxAVwgITTQ4Vf+4oNAqZAc4FS0B0YxKK8U5qj8o0dyNqDEIgRdt5skiPLQLixzDvHFM2fi+XbvrxK5y",
- "GKrDqaAKNZAqoOiGd9gGyUogWT0TLo3klEXAcVtA85KEYIIES0WKHM1Yyis0nb2J0bR5xoSmZocdGovc",
- "Hry1sUszcAecdXTjtwetNU2QeQWl7uFqbsNhhkhfUUqq4PIWwcguZFN5nPJwjkwH8lPhiG/g7mdCd5SH",
- "dCmsBLcEciWtmuumBJbSIu2ScjmnmG7T1muCk6NwpH0CI2fFxr4TGIwTLS9wiBIfhx3ZeI2pmmz0E+ku",
- "u4Oul0Crk1xjkeWwlxVMJbr1JIlhcwql27tivhuXinneBkIFiUhXKLM8r1Hn5NaJiFlGo3tR+vNDr4nV",
- "L/Cd0tLswnY+zwgLifLOnY81ad5dW7ZdaW7VEOgSCQD1jDb3mWUtHvbY6tqFNLWQ6uCpn66TcbiNxLiR",
- "N7YtZ6fU+sZvppHfXRb4/knNeyU0lxLZFBqfTU+LtOY540VUtzuJOZCsGvilfum9ePnDj3+t5rjq33WS",
- "q7tWrVHXS4FN6ezHl6Yw6cteL17jpFP5I9Iwk26CXM2o+njzf3cs+HH7dNpNSaulYq7XYqsV9PeqOF0j",
- "zT3D98a2vkZbpGs52spgTlOR/KaD++pBWzYnuPYWVg3YiuW0i8IlrN2lJFK14IaLoRWvz7lDCdKZ2+zh",
- "rji6RcQsz2bGtiP51Q22Aulr6gtq/s6m1C4aQ2XAh6OuG+2j1cMYe9uYSQ33kyCARELYBcA1a2WLCcZW",
- "J0lYFOXeHG5nQ3mK/G5+m3Zxomx+bMZVZV8z4neWAHGaS2r+tjTGtKewXPujbbuSvkJ0zUwliWVb9fwd",
- "6SQo8hULs8oGq7zjMJvKWN22e4fAChoFTBoDWh/eMwrXz7OsfFM5I/cWNoc1nYZfEsJBuMT8nzMJPxvf",
- "DqkDJUH304piYSHVZ7KD2H8nOsotlAa73tOtEE44rAhLBXpmuzjB7Nu00JRKEmXAKADsu7LDa23aD8E2",
- "Rc9YFD7PphWSJQLNQN2r2HKYjtDvTiV7ax97BkO3MeFthNAuv8RkwQ1fTFKeMGEfssgifvO8X7kEfk1E",
- "NTC5ytonlYI0uy2woKRviEa/cxppYPakVNduU5BbUYWhBc2Lcg9+5stp43zm623j1fJhNjcr1uW0LtvI",
- "hwbybyyVfw1ksZSuAOaaBJKP4py6xa9R+JEKj1HuJ3L5M/RALTTc6htq9/FsYaVZx7YtpQ6hb8dSccWG",
- "Nlknbz/U17TT3Xb1W06pectl1jfNG5yHV0vM2TfmtTLOtpjXWuPtMa9148ZtY14bkfS7x97v8uLbNsDf",
- "NuC4MdYuB9A58NhVsO0uD6J7APLOvRwgMjaYMi3d+F45td2Rb2zT5Y2NiIj8af8l1ulPOorTWBIzE1PF",
- "t0DkUumA8EXpSkSiUhK7O3V1S9xD2x3zUyXRvshzGF5Mh+Or8WhSrthW+fHny/HbM8/3/vfyYugugJPV",
- "kJnoTGa92suTLDqolvqsf7cC1KdyjbN5xK6V+BKx65LQeVq0MK9kZ8E1JtvrGIcxocc4itRZqL9Lhjwc",
- "Rajs16t0YbN5Kmy5TNuz+KnrIApX8u7qP9s7LtNZtlx3U5OCZ81cRZ/Kei+3rrRlFLvg8YalOnqKNAG+",
- "IoLxbIBKWevdoaewtAsUrJmv3GsvODjG6QyJat9UALcrr8JgxyEq26iMtN+e9KCuw+m4wSymTCcOOF88",
- "UUL2gAOOYmH+GQwSziQLWDRgCVAS9gJGKQRyoEeqaBlWQr+xUlv2Lj4OtOwMsc4N8M7fvfo0PRmfjSaf",
- "JuYt/f+/SPTMbOHHnmni+V5aWeye2aH21f5B4yEc7+RqlJmyzJS5ayCJMAVb+lH7h06uRkWOsKnfZVKP",
- "tc4vfFSvXm5+sk9qCB+dkwiqDbR628yfFv28JM6xsUYgU5E9L9rg/WArNXzRz/8VIbXeiM45FpKngbTO",
- "RVux3nayb1IuNYMd6MeTK3Gg+veF0ZMVoHGWTui9BnkSRc2n0HU4BeY4Bqnjo1ou/6LJ4DTlgnF7Exs7",
- "pJ725dFRhjXW7VAyLw7+I4ykau723d/Sr7gINJrWrq1/KWj9aNbgGjpf6+AVDjPLkO7yYnuX9zTzPkNo",
- "Ov2wvdM54zMShkBNjx+397hg8pyl1Ezx4q9d1mWJA8J3EBKcBcj+tQscjD5timLdVOrm/1qIB78273HX",
- "Fdu4PZuXWvNucfD7jwqtRBrHWNPDa5CaIWpMR6KOuBIrBe1Xxwv/ukhCwoSDEoz709HFyGkg5CsWru8R",
- "lav+15ubm5sGJb28x+k3UdHJ6enwajo8O9DSQ9NSg25yKqmRhMEehKmLKLbRxI3vvDUGf2QSxyi8MSqB",
- "tmA1aOdM/95COxUM/tHxggxDpxalD+j1WNHLnPCe6OW3yx4dUOboKzG9g+jwZEWHPbG4JhC3Fp51PNiF",
- "5iyK2HX2VrGNOEOVN7+zthcmDJuoURPzUKtVoAp27JWtR8Z2XNBA3dDz0feS1EGAxh36FUWeqj/2IPIc",
- "eMAud5LBnnsUeQZFpFP3C2ySBTrd2zXWmrp6uMI2oO9N/RaoZpt8d3eCJoCEBEtMFxCxRRnNa+8cEJt8",
- "c5KQ07y5sUFOjTHTaVQqt/a2QCrQ1qNmQQu9Yf3eWbFj09bbcuPdnxRZ2tfB/PQ9ypBugijzitLvFZNT",
- "LeHD6uyIwnVlrAZFWeNUtcm9yGilKR7WIFWa+CCXPQlTlAutWyikcRkN/sClFg3zU/2FxMw4sZGIrJWq",
- "2mQ3J8dJZVEuZ4fDwHU2fDs8YOy3Yd3qhq7+JkGJIvhChK4tvBEdlZR0v7h49ODc+SDvPF15px2v28We",
- "tF3qYRyluQJf7olSncccQhBhRS4rQFfvp/0G/Vjr1Z2T0P0KVQ9r8uoqVGVR6AfifeRCVSvV3Ea2atq5",
- "drjXdLgLB5lyqqtz5Il+G2+73Er2eO+8g5ntcOvtjvWbiZDbuLFOtrYsyKyLpS1r+7TsbHZXByvb925l",
- "K6F3icDsrx0sbLQ0SLk4TouVLRv43sRBO8FDW9jstAf72hMJ9aqgqpMwaveOFv3s112MasUsLSa1osHO",
- "Al2+nIM57Sma07Yg6A6mtDYkNKrFfWHg0QMz4IMw870oE1spw2k+K6JeutBGbiS7Q/K4T4nooc1jB4no",
- "aUWC7UBfm0WjfWximYKxi0XM9tnfHvZAl9fBGna4wHbF901k15qf2GYPuxqhWp5lB7PYLXMabVVzv3Py",
- "4/aWE8blvYuZu6ZIZs8+6LX80hvr4sK9U5aatbhmsx0Gv5jGpu2N7/3SmzKJo46ddVvb9+bANR4B12gU",
- "WWgtJrALo6kVQujEgFrLBzRz+h3Z9K1Z+xusjJPWRNLat5rFMUvaz6JuN9ggK8P0kf6m64DR0Dj21H+K",
- "PH8iEBZrGiw5oywVvq2hTwS6Xq7LEcxEoAVZAUVY6FBmKvSDzQxhdHU5mQ6u3udPRvtIEBoAIrVXnpdY",
- "VkucSIZmkBXER0tsnt9eg0QzAFoUVUe2o34I2o5gSv+TvJ5+H43m5fXiuQRuPhJGVUNiaoQC+lB6OOSD",
- "51fXFKdCTY9+S4GT7EXs18OpWqwAXQReLoGrzZWq+SOR6kIK8zSK1ojxvJmpajXIa6tjDuiaEymBZq/m",
- "mAXn68+WPLBuWL3ygHEOgTRrLdVit5vNyvoJ0M/12JpgfXSZlfdTv36wz5588PrIPmbz8ugFigFT4ZvT",
- "uYa8Xlz5ER+NPGoo0OunDIm1LtFAArVXATGm6m+zWR/NTOlSDuZh9CBIzeuauvK43bU5n2IrfTTVK8h2",
- "smTXNQghoh9tzxdlC4lylnCiAJWXX243uz9IElJtmoc2wR8SkL5htbN582wyzU/aMkmat4lTKNYaaaXl",
- "FpP9n/88ihXUMJXHf/4zmurn+iPIOGzIKFSuk2itGA9QkXIX/1cMFAcyxYppmjlDX79SqdNYbIVnxXzz",
- "LqaIpmEDAZAVIIx+PPoxvxH6CH2gFfNwZX+6ymmLh6EOzZ0V5RokD96GJ0uF7Qi2TabrbOqpj+oy7dxS",
- "+eyGskdf5XI62HgO2trX09Y2k+Jmpa27S+Wgqh1UtYOq1uJOvCdp7P71vYd2MB70vacsaXa4Oe5K8dvH",
- "IVkZYye3pLukxeMWYA8uyqcjvj60lLqrnNnF5+midV2YfVffp6OY7Fbnp6PP9+H+bG784AA98KSDSr3V",
- "AermGBk/a37d0wnaHOigWx9064NubUnEQWf3oxg3J3pQV2hz+oNy/GSdoU6k3niztMjKgz+g0fqpOUWb",
- "8Gh1izrheouKhO4Kgy6Q71p/9uBofdqO1t0pvKuz1TlyQ+P9Zinh6CvdqAcT2EHdfBQe3H04R1cv7kHX",
- "POiaB12z1Y/7LV2aD6EAP6hv+KAAf4/e4fvWhHf2Ejfn7O4nbvZt8xQ/cdH74Hk+iN3fvNi91aXdyp7i",
- "INnpudF3QfINPDZaX+WhCt33/NTou9Or9odG66iy9ZnRRof7EXHr0zyoh6c++UG8fSJV5xqksJkSnDfE",
- "jk+LOunl8LDok6oVtyNatT8quh1Zjr4KkzsICE/4QdGdsfdpPib6lQSbB7XcHQSbJ1o87s4Fmw4PiNbH",
- "PTwferiw7v/Carx9+v1cX4pqc29vq518rO1gAunosMWCw0LxiAQvQG1nzFbA/UZSiO+wqvnIlr7zK+9H",
- "+NraVocs4jAHDjQAoZ9ogC84kNFar0LBpa8zPpBkn4EKFOM1YjGRBnLGJQk4/mfp/xqbUICjCLgwntyE",
- "sxUJAc2YXPZ1OJx9E4IIxBKszsw6N1FE1Oia04BAOOBMCG0MyU/yM6Gh6LekoIxzKD9E4onrBQsNiI0P",
- "WLR0lCax5ut6E751g+NTYZjqHCpob4gTI4slGZ8s8N3IBlxxiQ7pXZqbbM/oMs0eQxJXttErDnPy5XGk",
- "fWnoHDK97kSsOrj0vqnErZwx5IxI/bBXepYZ6xAkdwiSOwTJWaIwxHQ/Ziw99oM65fSMB4PVE820Qhmy",
- "1m+CQhwdEDpnG9ReHCLVQkujUYRKmLZNQi21HKk5Hk21Ab8peUeKi8zW5e0hpfqJlqcQs28FkREJsXBo",
- "hn72A+Ycr42GuW2VSxxF7FoD7f5lZDXNQUY+yMjfhYzcnZ9t4Jh/6H+fVJqp3iXqGbmJQ8xWoHpy/czG",
- "AE3SWb43gQZo+CVhaqGiNRM1u3h2Y/mGIR1K8T79DNFWyaRrGmjWvyF33DnmHT2csH1wsB2uxUcRDd5O",
- "nV1TLQ+Go4Ph6GA4KodC3cXFdE8mpwcNlzqYnL6T3MYOxqdcldrFDJUHppS1ty0mqL0sUDntfYM2mwPt",
- "HOTIr2xeaaPTTvxAQMANK0iwDJZNnjCiRKp7V6iJdGPEmTTS1tzaQ0oMwIQ25S2IQEtMwwhCNDNypjor",
- "IdS3a5gtGftsRdEQAhKCyCUqyVACfM54jDBacBzAPI2KgZ+xKMzXAzEmVKAVjkiIwpRrQdh0UoMQFj5H",
- "OnKEMtprjjXDSvhkRgj5nVFAAaNzskgNm+s7rC9jEFYFnhgA3qEifMcigFng2G72xMp9GwWDTDbsNU6c",
- "WGwInxrjO/rHBqgnnM0iiP+yY6UJy+Qa8D1ldB6RQKKeg6aUphYpbrBWYnHC2YKDEE9fsNEIatS+ICJA",
- "pcFbE/JFyyxmN9a2rTJDFnEqTUQmByobZ2IVk+ZSfKWzRWmoGE7GuFzcyqil2WH6Ze1SqcMBoyvgCwj9",
- "PAaUQwQrTCWCLwnha60TC4njRLjY0esKM8pofc/3Ax7ORuda7nclbz0+qnRIJW1x7F2JpDO97lhCxThT",
- "OldNMRj3mEnikCBy0Du+Qfv11gImOdXvAHczsz40V6bLydVIXaLcGDKFyO252lK4grCPJgZZBIpZ3qwn",
- "ApaAzpxIeeQde0spE3E8GPyxZELeGJ40WP3g+d4Kc4JnkSHzZR7POcdpJL1jDyekLyGCzyzuh+D5jkyc",
- "icRcp6+o3r62Kv8askB8fJbNGsIKIsWoSkMNVJOB4MHAwORTkArJYuCflCI1Y+zzQA38Gkv4NKQrwhmN",
- "gcpPlyuFcHA9+C9h5+1hGvYIJ+K55iGNZCHAcU/BUZuEYQVKygAc6/yWVADCqWQxliTAUbQ21l6RwZTR",
- "yLTuGcAqtY8I7cbWAqxgagit9GmY9gIZbYE6TsgTBPvHnAzq8H+HKV6A8/17G/3UeMumeYh2EPcbEnYY",
- "R/2g1oFcJYLsMI10tNZBThYbVtP82D5QnrZV6p//1t4tD8y2XQz/aV9uQmyuWBX29kdHx5MsMy0sHEqR",
- "YYmlWfO0lJuPN/8vAAD//7zol4rDSgEA",
+ "H4sIAAAAAAAC/+y9a3fbuLUw/FeweM5azbSU5GSm7Vv3y+vYSuKnie0jKe30TPJkQeSWhIYEOAAoRzPL",
+ "//1ZuPAOSqR8iePoUxwR1429N/Ydv3sBixNGgUrhHf/urQCHwPWfP09ApJE8ZSmV6v8hiICTRBJGvWPv",
+ "JJApjhBN4zlwxBaISIgF4iBTTiFEhCK5AsRBJIwKQHMWbtCCcSRXRKAEL2Ho+Z4IVhBjNXpMKInT2Ds+",
+ "8j25ScA79giVsATu3dz43s8zJnHUshb9rbGUGMtgRegSBZxI4AT3mPDG9xLMcQzSAuMkIacrTJcQseV5",
+ "6FjCClBKya8pIBKqNehtchAs5QGgBYsidq0Wo4CSYCmBU7TkLE0GAwk4HgzmWMAVlivP94gaMTF/Uxyr",
+ "peHq/L7H4deUcAi9Y8lTKG/MbkZITuhSA+8kIROGwxgnX2npxey9Fz5NICALEmC12vtZftb2Qq23bQv1",
+ "dfTbyGnKBePNxQf6dyQZmgNKBYSaRBK8JFRPNETnC0SZRAlnaxJC6OttLAgXUhMRuiZRpDpnhDdECiB2",
+ "XCIQRmYZSK6wRAkjVAo1Hzbd2QKFWGrS0Pv+NQW+KTZuxvG2b+4tiYmDKvX4gvym1vZrCkJCiOYbvf6A",
+ "UZHGwP+O4lRIvcE5oAjzJSgGgQ3viPEXEuMI5QO1LDLS85fXGMICp5H0jl8c+d6C8RhLQ94/vvB8T4+r",
+ "KF99zdnAcyffmVjcuOKwIF+am7zUf6hF6gZoQSIJ+kAp5pxdK0RMowzkwiIRUoiHGDd4OETvFBTmgIAq",
+ "boyFKIEKRxHwP6gBPgPNpnkGw+UQYYpwGBOKrolcZZ8+eEDXg8EHDwWYZqDPfq3g/QcPzVOJsFmNGT/A",
+ "VJ1G1g2jkCwWwIFK3eoPwk7zwxC90nA9RkDXhDMaA5W1CVrOy4ywA6kmbA38a9E7t5P3I/LpCqu5z+mC",
+ "NZd9vkB6BL0sIbFMBcJU3ZMGPwmjajM4ipBI5/kaRU7gLCZSNi/WIbqkkUGV8lB4zlKpf8VJElm2hYgU",
+ "EC2cPIPYmQRZUs3nqIw2iEOYBlC9yTVJq6VLEsMQnRlaU7xmgSMBbaxElKDjpFXdOyfCOWMRYGoAy7iD",
+ "v0RESAWyBYEoFHUeKhiXhC4r66OlA6+vTk1RXhZQxRR+Md8/+o3jvlG4YSCiBYSXOJwYolH/CxiVYESV",
+ "EvxHCWfzCOI//UeoHfxemu6/OSy8Y++/RoU4NjJfxWjMObNCSRUCL0/OPk3G//N+PJ15N773ivE5CUOg",
+ "D7eCV5eTl+dnZ+MLNf8Fk69YSsOHm/7icvbp1eX7izM1/RT4Grhp+2ArmI4n/xxPPo0nk8uJWsR7ilO5",
+ "Ypz8Bg8Ih/cXJ+9nby4n5/87PjOrEGmSMC4hfAchwTONvA+3mun7q6vLyWx89und+Oz8ZPbvq7EmZNtV",
+ "C9RLoLIiVKlfE84S4JIYmhL1zzgMiblvr0oNDWe2BMrm/4FAeoY+M979S22oj43mvmNBpxywhBJVP6rV",
+ "vSVCTiwHai7uU0ToZ7HrCN/qRje+pzUm1Tr/Y1u35mLyhdzka8ec401jp2Z8P1tft622bzPAEpaMbxwX",
+ "su+R0Pmz4fiOD7c5UN8zd/ou0GXy5FS3LsBWB1OYXU31ZeUTdQPd+yR8PDhMxl8SJlLuOEicKA0HRztR",
+ "z7abSq6OfqPGzdVP16EGhAcpkS854M/Adw1/Wm1943sLTCIlDe7q+Sprd+N7EcPhSxxhGqhF7KLBSmMF",
+ "VCwhV6m24lLW8JRRiQk1k3OI2RreFHacmt1GCWECbViqJCZzaBuEUSZNGQOQFpWE0RkLaVENHKI5LBhX",
+ "gja/xjzMJO1MY5BM//fd6RUS+kL2StylcTpVXuF7AoKUE7nZtfVp1k6NwVOlW84A92Bhs6KTax3SXpiN",
+ "BaeJkFx1cn1cY06w00Cl5HBFmyRAYGkAZa39XNZ8d3rl+d5s/HZ8enkxG/88Mz+cvB5fzBwyqO+tiSBz",
+ "EnUA2D+LlnXC1aP6BQVWhi1RV7G/7cSd6T84ii4X3vEvOwi6YAo3fp0rZMeQAQgTpyjuXs80nZfOoD70",
+ "VqaxD9HvhbpuNHMfUL7i3dvtewQVUN3tMZTsltsPoUkxJ1fnqIR98AXHSaSGHwElIwlCDnBCRuvnnoM0",
+ "ci5QHfetZXNBtii0Bi6UbgxUcrUsv6MMVDbIStBcJCb03PR9vkMMKltMdY9dsGvIoh2Ptgx+x8FaTlaH",
+ "vdLllVatlGZGo02hVq/S+UAELIEQ4SAAofVupVZwFmnGLzFfgrQGrkVmOdAyjzYZIVgD30htmAel8CMi",
+ "taVqDogsKVMA8h0EUYPOxxp89BE08EvJPs39TSACLACpr4hxlAAnLKxg2IujFz8Njn4cPP+zC7cqw9VH",
+ "Pyv+VyCaMGYbIjJ0q8yWsQS0IF/AmIUS4NqYQwNAJFacGWLto3HdAnbIxlKmEGN952Q4blwklbmfD18M",
+ "f/R8z1rKvGPv/374EP7pw4dh6Z//3jLtex5tMYu+n7xVGMItyCmTmsSK+VdSJuJ4NLK/DAMWj2xrMVpn",
+ "q8ttuCknTvQoE5c+9QIs1fPaRWhfU60qLWOrQrV1A+XF3wmTIA4j7PtWA2zlcCtmV5yQgVVpOODwkkab",
+ "mmbR1NFq5h4cQ8ZUcgZembDPHN0UNqOoOVlQu8rWZO+1Q2qoZfuclB30oFR9L0qVdQJ+VyrVQ2o4LXfD",
+ "nopN0kuzSXrJ1Nah/pgkam6WpL1CEaGwh0CdhQncmzhtJ9hfmM7g/g2J0iVRa6MlXdpDvi6fSEfpepad",
+ "/xbx+n+eIyVh7yFbS0wiCFFYFbJ1tACJQEhGqxLIOSWS4CgXQLWnPNCsFrBMOTglaklk5Njau2wKZBqU",
+ "J3r3zytkFYvWEXeLygovYrW6ki93p7gcr5M9ReRsGx0EZIsJX1c8tovo6W3YvqO95OV2PnALaTneDBRP",
+ "GMQbKzHHm4HlqncjMxeDVSbtMcetZeYK6PeXgnP4f3REid3SzVET7dAaRyRUV+egMoxi2syKakY8W6Yk",
+ "1KxPa/ssAYoTojiguMbLJXD0LISEQ4AlhD8gS6934FapbX//260Kxe/ljns0XtXaUu6Gy+3hSK0e76n9",
+ "kvMuJaqWQf7608nVuVNg7MUKh+jUHKtGHslQSDgEMtogHIZco45qnofQdWOhQMlgwdhg/aO3n5lBE1Oo",
+ "No2eiXQuQAu7JPyhMu/2ab4l9tOXx/veGmjI+ElCmpD81wrkCniONzoglSLTwyKSI96rzaySI2l50n4O",
+ "8hpB7H8D1Xhlxku+K59TchunU7LL65QBRI3bnYu+KvXaZobYT/HOY6ayzdYMbjb2C7vVlyvgMRHaEs/T",
+ "CIS6CRdkmXJ7W1oyySfprDqXp23beLCCNWe0tAeHHascKxoClWRBgJuY9GLtv6agFHv0DH5NcSQQ4USc",
+ "RgSoPNdssWkuNFM71R/rINDhmKVJRBtAtqs5vgecM94dXa5M6JsLYJmjvvtgFUORY8QMo8cUzyNw3I7n",
+ "NFQ7VRx8gbLWimmC6dEGlDoP9T3Si2i2QKF8uO4oq7zBFAIONvohu2jct9pZ/v0YvReAhO6pwDYMSkMh",
+ "QoUEnMUnGxmzbjklFGG0SKUOqTAun6FTGuFEnAuRArd46GwxY5+BjmmoEyXaGrZGkLG5iXlp65gI4hKK",
+ "rqbnSGyEhDhLYioRH5YSBysjEjmOvpBBrqbng+dHP/75b3+puvPU778cDf728fe/3DgdeQX0O9wjWUsj",
+ "KPAllrAdqlmrq3QeEbEar6EdsFnbLd87iCaXa+A4igoJRZQumu5E0bjbHNShI2LmUQ8ecYYldo10jTkl",
+ "dHknNPsbo61XfFXBL1/5Vsj6zZjQLKhrDMBB7nXKaqGj+imU+WsZjjkHL4EkY2ctd3LVd1VOKfCm5++u",
+ "3o5LIVYn72eXnl/8/ury/WT87/HUGV6l7lUdtNViVihR4lQpuW5fmQaWGuodyBULqwucY0ECr84fTzO5",
+ "QCDTHeHKUqrC/+TVKfrLX3/62xD9i8gV0kMqYV/P8YOvdBjNTtRdjbnitlRmaSRvZrMrVJEfrLPq78ZU",
+ "OmfhZvsImY9KtRyWIH1xeaEAfHU5nXm+9/Jken6qBEoWWpcLCZwwT5iQb9mSpXICRgN8z6MqVex0eSWK",
+ "1QQGS0sdStcj33Nsp/O9IX01ESVosp7duyiEoe42hPJaCnGzjX3lOrRrPZxFLVxkOwhq094NMO5qpS8V",
+ "4qnlNteVYCGuGQ+3rKdQG+b5OC4kTgXwFiHBrWflHfxiGS5ud7ot3v204ZUvVLHqVqFNAH0V4aUWPhVl",
+ "/4PRJbKufjQ3o2ZuEyRWLI3CzF7j0uPrARLZpM59RZg4TIsnSBC6jAAF6jta4yiFIRp/wdouxKg20+hf",
+ "EePmj1ecxSardK74lFQsqbp53cw1V0SkEhwqkzkDn7J5XIMkilUuCIUQ2YxEhbqRkVStpaY2QYZTV9a9",
+ "X7ptT22ibOmnl02VfBvCa8gKV868TvfUX9E1J1ICLVaYRFgqTWsQY4qXEKK376YmQ3SIsmUOBAlB23uP",
+ "0YppUyxSVxPVbubsdv+DQJmVwuQ7LouWqCwSlBranEZj+WLmmskXgJ5RJs3lhCmCL0rMxRE6P7sy3zOh",
+ "Q2GEuQrV3flDEw9wGu6MXNFo6YarPZlJObCkm+mj6NLNfVTKeD0/y+2mWQ41eiZXQLgVFJwa+I0jVulj",
+ "lz0VMTBNg7Fte5aJMt33nPd1oKVqh3RCt7EA5ObevE85WXUOKAau8FPjg8ITK/Ygng9U7iDxZ1AkGih5",
+ "JgCk9euWbh8621+auNBJcNCqQAOwrcplzrc6UH2eBVmD7qvTwV//v6O/asMT4zEyPYw8p2l3g7T43SCW",
+ "UHvenevSHU5Z6F61yc7tbr5TzbeZI6iQmLbIAYV6aD/ZeNeyR7/1Zu90T5thXLSj9dotoXiFxd/owcDP",
+ "wz4ykO8ZFmAjYpT6duPfKsIP1IrLAsVWFM/aZR1nbeFc2t3Xw2amBjPak8ta+5CBaA8Ra1bAbicS9bOn",
+ "V/Fvd8CZXkhny3fplLozLMnJcrnboaHHntm2bm623p0rXHZgdoNYCaV3+jwdK9Aun63RSidKSOayEqyk",
+ "7rQNS7Vf24TzFwa8E2p+RQoA1tQL6NeUBJ+FxFwaD98dOxjz8KP/M728QBo24PIKZvhT7f3vfCtm0c8E",
+ "JJhrj6m+kUMmRdVTGsJQQgSfWTwESobF7obuqMNSckM5aeFoeLQzyMmSYvc8gOYp7xlL4UKX7yGaornv",
+ "rxhP0VxMv6SKLf3vFhFcMn+dsBSTbVIXFiuo0VcjAiKEgSU4HQxRENxAE9xXSZNoUGbJs18hUucx7Om0",
+ "b+Ph5vetjvsAR9EcB587JENcJvIylc76P5rvl8vxhAyELn5FAUJjGShbWxSVZuYWtkBvGCe/MerrNANF",
+ "tCyRiKUmHkWi+QYJkNKkDhDNGtSJGjWmaQENISKKGbTKcaA0hABeg3wjZWKhbUzYrxh/AziSq9MVGKBs",
+ "qXW00i1RoJrmWptNb8sQGaPX41luSbZeP10OCb0Zn5xlX1q2oslkAhKomn9GXDE957oAUMKEIPMIFHAi",
+ "dl3X/bIhdAy7mv6vKMQbMURTAPRLyALx8VkWgBvCGiKFI/l1FsJINRkJHowk5iERn4JUSBYD/7TCNJwz",
+ "9nm0Moc4suc6UAromsD16FQ3/ZRv45Pax+iHD9Tp5C5L4C0BVrkD0V4EBTNxjZjgTcRw2IoNHCzCEBBX",
+ "wKcQMNpiqrDl1QpgF4UZy6MoHEBCj6M3ua0Qm5pf8g2eR7ZwhlI3t+QiaK+GLbIVqKYm6QZzU3qOhMAh",
+ "RPmYNVW/OXsjp6ap4+y0YO8lDfdUTwu8qBF49YDbZZ+9w3maTLR5z1ncr0zitrY7Wra5hO9Ar5kVJ1NX",
+ "2teYBhBOIQLttHilq/uV1lGMlBUDK5r0cm6U+r6zppTCSUhoEKVa8s92WfrlS/bX+cXp2/dnY8/3xj+b",
+ "vz46nf2NvbiVhfZ1lyKVmwAtWUBCok4vJhRLY4yKcZLY5D1MOhSE8HV0Vof8Kov53XRj31sQLSq1GKAi",
+ "KKnRGUZsLkydOL3hG99jFPbIAeunwPcom7HdpFbe0ccth5ZXLrzlwdkaf90OzzbufoC2Q9dDzHyfd3WQ",
+ "drweh9mpx0m/CZo7VKLsq1IMaZWV/cao68K0vpxSqqpuiK5XJFhpQVPdmdiKTKkwZok8VLV0abqS9Szn",
+ "cJsut4a0m+W6cLUS39nYZlsIVxctR0ceqYPu2TWLttnR8KZV1yptrmz9bubbtdvgk7ao4ja7d11+aLVr",
+ "V7hHY0mKCFtlRhOG8Q/Y9Ahqyrr0M9y21V1Six9IjqlYAEdKgwMaDlFm8/o7ClLOgRpncrRBHzyxkMkH",
+ "T+kKeQXFsotWfb73Skw5UCsg3HU4/eS12jW3y1qsmW1XoUqNvV2ffgxocxew319ebsDojs/gNceFhpgH",
+ "GJxMp/+6nJx5vnf69nx8Mft0OhmfjS9m5ydvp57vTcavJuPpm0+zy3+ML5xobtT+5oEqrbgls8ElJZ4n",
+ "E1Dfi0igmuAdRez6tnFZbzPbYpUnvDmZjS9PpkibHmvl1htuTwpfHGxFqdPvJ+e5iV61MlXK1S9FtfNi",
+ "YF+n3efBDEUT7Wo2oSAdgtsFRIvdy7FMbfuK+mYM66l9AxAXwr2tl+eoIUiOONuowqKX3ilfOwMEmhKL",
+ "bVoWUKq1Qjp68U394J3ZdtnKXEB4FySPqaRtfTmPqaBtfW1f0VFQX8pdFrNtHftQynZ7pl4dcI+pkO0l",
+ "TuXqheMI81i7nUFloggNb7GBmY9Kpmn/WmTcNBosywLAtuUUksKNvz0GlsOCg1hp/apr6Mdus2hZW9Oy",
+ "4Y47UHco4UGt5J05PMiDWGuB8yGWuBS7qgOfbMxjJZRwTbANhBeI8SzWXT9ilDtWzRIK5cDGtptunu+9",
+ "vDz7t+d7b8YnZ+OJU5yqi3jMINYdhBQ70baSF9MEnbGXN17UwDTUz1IQKXQZlTWmsvpGRcUwqaM2jSAV",
+ "gBDmAl7osi2e780jFnzWfyVAQ/NRIWUEuiBJaJJPqPrHBYVW3TjAqWjJv2BUWinOUWxGadRuRI1BCLxs",
+ "84YU0ehdWOQEFo1jysb37dpdJ3aVw1AdTgVVqIFUAUU3vMNWSOYqUwOWn8FRBmA6fYOMRoQ+wyYLxc4e",
+ "rrHhqQlwlGlQztDpCM/BkQT6Jo0xHXDAoTY11VJPFbV9hk3f2WrwNlP7encuWFcCeeuZyGkkZywCjtsS",
+ "SlYkBBOkXSoS52jGUl5hctmbRE2fU0xoao68Q2OR++N2Nr7ZtvdKwG/HMKr2oOGmTptXsOseLuzWbzPK",
+ "+opiY4W4d0iKdiHbypOVh3NkmpF/FoFQDbL6TGhPAVGXIkxwSyBt0qrKb0sgLC3SLikX/Irptm29Jkk6",
+ "CvfaJ4jyu8nYqQODcaLlBSRRutig571Wu2VMNZAT6S57hq5XQKuTXGOR1RApa9xKlh1IEsP2FHa3d9t8",
+ "Ny5t87wYhAoSka4QaS+BRp2pWyeCZxnl7kXpzw+9JlaXaHqlBduF9T7PCAuJ8s6djzVpXuY7tl1pbvUy",
+ "6BKJBfWMYveZZS0e9tjq6pY0tejq4KmfrpNxuJ1duJG3uytnstT6xm+W8bi7Khz7F5XYq6BEKZFYofHZ",
+ "7LQoK6FEqyITx1lEIpCsGnirfhk8f/HjT3+u1hjQv+siA+5a4cZ+UQosTec/vTCFoV8MBvEGJ53Kz5GG",
+ "3Xgb5GpW5sdbf6FnwaXblzPYVjSgVEz7WuyUqn+r6hc10twzfHpi6xu1ZRqUo10N5jQ16286uLoeNGtr",
+ "MtTeIqwBW7GcdlG4hLV9StJVCx65GFrx+qc7lCudu+1A7orPO0TM8mxmbDuSX91gK5C+pr6g5u9sW+6i",
+ "MVQGfDjqutGxJnoYY4CcMKnhfhIEkEgIuwC4Zr5tsUnZ6lAJi6LcFMDtbCgvUdLPkdUuTpTtsc241uxr",
+ "RvzOEkxO+9FWY4dtV9JXiK5ZrCSxbKue35NOgiJfvLAzbXFTOA6zqYzVjd13CKygUUCqMaB1aj6jcP1D",
+ "VhXFVC7K3afNYU2n8ZeEcBAuMf9fmYSfjW+H1IHqoPtpRbEwGesz6SH234mOcgulwa73dCeEEw5rwlKB",
+ "ntkuTjD7Ni0/pZJEGTAKAPuu6hy1Nu2HYJuiZywKf8imFZIlAs1B3avYcpiO0O9OJXtrH3smo7Qx4V2E",
+ "0C6/xGTJDV9MUp4wYR8SyjIu8roLcgX8mohqYkiVtU8rBcH6LbCgpG+IRr9zGmlg9rRUV3RbMHBRBacF",
+ "zYtyO37m3GrjfObrbQN182G2NyvW5bQu21CQBvJvfarkGshyJV0JJDUJJB/FOXWLo6dwrBUutNxx5nLw",
+ "6IFaaLjVWdbu9NrBSrOObVtKHUJfz1KdxYa2WSdvP9TXtNPddvU7Tql5y2XWN80bnIdXC+TcNzegMs6u",
+ "3IBa4925AbUOu3ID6raQ2+YGNCI5+6dK9Xygs19s6a6DvW3iR2OsPgfcOQHE0anPQd9VIohjzJ4H3jEh",
+ "pHcX944/1nnCje+VS6Q46lbYsivG1kUEwuYv7SZQ/40idm0sopmprOIjIXKldFn4onQ+IlGpGIq7BMKO",
+ "gJa2u/KflaD7Il9ufDEbT64m59Ny5c/Kj/+6nLw983zvfy8vxu5CalktsqmuiKFXe3mShX3VSmjo360g",
+ "+KlcK3MRsWuFpRG7LgnPp0ULbfvIo6ZM1vAxDmNCj3EUqbNQf5cMkjiKUNk/WenC5otU2LLLtmfxU9dB",
+ "FK7k3dV/dndcpfNsue6mJpXbmuuKPpX1Xu5cacsodsGTLUt19BRpAnxNBOPZAJXnEfpDT2FpFyhYc2W5",
+ "115wcIzTGRLVvqkAbldehUHPISrbqIy03570oK7D6bjBLFhQJ3I5X85SysKIA45iYf4ZjRLOJAtYNGIJ",
+ "UBIOAkYpBHKkR6poS1bTuLHSpy2KJ3GgdQCIda6W9+rdy0+zk8nZ+fTT1GQU/f9FwYDMpn/smSae76WV",
+ "xe5ZZcDmLo0aD6p5J1fnmUnOTJm7OJIIU7AlhLWf6+TqvKg1YepAmhIW2nYhfFR/BcP8ZJ9mEj7S11Gl",
+ "gVbTm3U4xDAvrXZsrCrIvOyRF//xfrQVf77oZ2SLWGnvnC44FpKngbROUvvyie1k3zZeaQY70o/wVwJ8",
+ "9e9Lo+8rQOMsLd17DfIkik6WjdXqETmOQeo4rxaJoWgyOk25YNzexMaeqqd9cXSUYY11n5TMpKP/CCNx",
+ "m9t9p6tmubX0jkbT2rX1DwWtn8waXEPnax29xGFm4dJdnu/u8p5mXnQITacfd3d6xfichCFQ0+On3T0u",
+ "mHzFUmqmeP7nLuvKE/veQUhwFvn85y5wMHYBU1zxpvL+yi+FePBL8x53XbGN27N5qTXvFge//6jQSqRx",
+ "jDU9vAapGaLGdCTqiCuxUjR/8ZrooovtJEw4KMG4cR1djJwGQr5k4eYeUbnqR765ublpUNKLe5x+GxWd",
+ "nJ6Or2bjswMtPTQtNegmp5IaSRjsQZi6iGIXTdz4zltj9HsmcZyHN0Yl0Ja4Bu2c6d9baKeCwT85XiJj",
+ "6NSi9AG9Hit6mRPeE738dtmjA8ocfSWmdxAdnqzosCcW1wTi1gLmjocf0YJFEbvO3ry3kXNoyVmaDGwR",
+ "w6zthQknJ2rUxDz4bRWogh17ZeuRsYEXNFA39Hz0vSR1EKBx635FkafqVz6IPAce0OdOMthzjyLPqIjY",
+ "6n6BTbOArXu7xlpzkg9X2Bb0vanfAtWsme/uTtAEkJBghekSIrYso3m9wJBNIjpJyGne3NggZ8aY6TQq",
+ "lVt7OyAVaOtRs1KJ3rB+N7PYsWnr7bjx7k+KLO3rYH76HmVIN0GUeUXp94rJqZa4YnV2ROG6MlaDoqxx",
+ "qtrkXmS00hQPa5AqTXyQy56EKcqF1i0U0riMRr/jUouG+an+0m5mnNhKRNZKVW3Sz8lxUlmUy9nhMHCd",
+ "jd+ODxj7bVi3uqGrv01Qogi+EKFr1G9FRyUl3S8uHj04dz7IO09X3mnH63axJ22XehhHaa7Al3uiVOdj",
+ "hxBEWJHLGtDV+9mwQT/WenXnJHS/QtXDmry6ClVZNP2BeB+5UNVKNbeRrZp2rh73mg534SBTTnWVkTxh",
+ "cettl1vJHu+ddzCzHW69/li/nQi5jRvrZGvLgsy6WNqytk/LzmZ3dbCyfe9WthJ6lwjM/trBwkZLg5SL",
+ "/LRY2bKB700ctBM8tIXNTnuwrz2RUK8KqjoJo3bvaNHPfu1jVCtmaTGpFQ16C3T5cg7mtKdoTtuBoD1M",
+ "aW1IaFSL+8LAowdmwAdh5ntRJnZShtN8VkS9dKGN3Eh2h+RxnxLRQ5vHDhLR04oE60Ff20WjfWximYLR",
+ "xyJm++xvD3ugy+tgDTtcYH3xfRvZteYnttnDrs5RLc+yg1nsljmNtjq73zn5cXfLKePy3sXMvimS2Xse",
+ "ei0/Dya6SPLglKVmLa7ZbIfRz6axaXvjez8PZkziqGNn3db2vTlwjUfANRpFFlqLCfRhNLVCCJ0YUGv5",
+ "gGZOvyObvjVrf4uVcdqaSFr7VrM4Zkn7WdTtFhtkZZgh0t90PTMaGsee+k+R508EwmJDgxVnlKXCt28B",
+ "EIGuV5tyBDMRaEnWQBEWOpSZCv3wP0MYXV1OZ6Or97PsMR8fCUIDQESXOckfsNd1TyolTiRDc8gK+6MV",
+ "FogyiTYg0RyAFsXhke0oSQzZCOYJA5K/CzBE54vyevFCAjcfCaOqITG1TgF9KL0I88Hzq2uK7esov6bA",
+ "CYS6DAt6PZ6pxQrQxezlCrjaXOlVAiRSXUhhkUbRBjGeNzPVuUZ5jXjMAV1zIiXQ7Dkks+B8/dmSR9YN",
+ "q1ceMM4hkGatpZrydrNZeUIB+h0mW9tsiC6zMoXq1w/2PZsP3hDZV4peHD1HMWAqfHM615DXvSu/zqSR",
+ "Rw0Fev2UIbHRJRpIoPYqIMZU/W0266O5KcHKQRc9YEGQmteOdQV1u2tzPsVWhmimV5DtZMWuaxBCZKEL",
+ "KWSLsgVROUs4UYDKy0i3m90fJAmpNs1Dm+APCUjfsNrZvHm2meanbZkkzdvEKRRrjbTScofJ/o9/PI8V",
+ "1DCVx3/8I5qtAOmmlsOGjELlOok2ivEAFSl38X/FQHEgU6yYppkz9PXzozqNxVaqVsw372KKgRo2EABZ",
+ "A8Lop6Of8hth+IFWjMOV3elarS3+hTose6vJNTgefA1PlgbbEWyXRNfZ0FMf1WXYuaXq2Q1lj77K1XSw",
+ "8Bx0ta+nq20nxe0qW3eHykFROyhqB0WtxZl4T9LY/Wt7D+1ePGh7T1nS7HBz3JXat487sjJGL6eku6DF",
+ "4xZgDw7KpyO+PrSU2lfO7OLxdNG6Lv/e1/PpKCW70/Xp6PN9OD+bGz+4Pw886aBS73R/ujlGxs+aX/d0",
+ "gTYHOujWB936oFtbEnHQ2f0oxs2JHtQR2pz+oBw/WVeoE6m33iwtsvLod2i0flou0SY0Wp2iTqjeohqh",
+ "u7qgC+B9a88e3KxP283an767ulqdIzf03W+WEo6+0n16MIAdlM1H4b/dh3N09eEeNM2DpnnQNFu9uN/S",
+ "pfkQ6u+DeoYP6u/36Bu+bz24t4+4OWd3L3Gzb5uf+ImL3ge/80Hs/ubF7p0O7Vb2FAdJr6dG3wXJN/DQ",
+ "aH2Vhwp03/Mzo+9Or9ofGa2jys4nRhsd7kfErU/zoP6d+uQH8faJVJxrkMJ2SnDeED2fFXXSy+FR0SdV",
+ "J64nWrU/KLobWY6+CpM7CAhP+DHR3tj7NB8S/UqCzYNa7g6CzRMtHHfngk2Hx0Pr4x6eDj1cWPd/YTXe",
+ "Pf1+ri9Ftbm3t9VOPtF2MIF0bNhyyWGpeESCl6C2M2Fr4H4jJcR3WNV8ZMve+ZW3I3xtbatDFnFYAAca",
+ "gNDPM8AXHMhoo1eh4DLU+R5Iss9ABYrxBrGYSAM545IEHP+99H+NTSjAUQRcGE9uwtmahIDmTK6GOhjO",
+ "vgdBBGIJVmdmnZsoImp0zWlAIBxwJoQ2huQn+ZnQUAxbElAmOZQfIu3E9XqFBsTWxytaOkqTVvN1vQnf",
+ "usHxqTBMdQ4VtDfEiZHFkoxPFvhuZAOuuESH5C7NTXbnc5lmjyGFK9voFYcF+fI4kr40dA55XnciVh1c",
+ "et9U2lbOGHJGpH7YKznLjHUIkjsEyR2C5CxRGGK6HzOWHvtBnXJ6xoPB6onmWaEMWes3QSGOjghdsC1q",
+ "Lw6RaqGl0ShCJUzbJaGWWp6rOR5NrQG/KXlHiovMN+XtIaX6iZZnELNvBZERCbFwaIZ+9gPmHG+Mhrlr",
+ "lSscRexaA+3+ZWQ1zUFGPsjI34WM3J2fbeGYv+t/n1CSqd4jGhipiUPM1qD6cf3AxghN03m+M4FGaPwl",
+ "YWqZojUPNbt2+jF8w44OZXiffn5oq1zSNQk069+QOu4c844eTtQ+uNcOl+KjiAVvp86uiZYHs9HBbHQw",
+ "G5UDoe7iYrong9ODBksdDE7fSWZjB9NTrkj1MULlYSll3W2HAWov+1NOe9+gxeZAOwc58isbV9rotBM/",
+ "EBBwwwoSLINVkyecUyLVvSvURLox4kwaaWthrSElBmACm/IWRKAVpmEEIZobOVOdlRDq2zXMV4x9tqJo",
+ "CAEJQeQSlWQoAb5gPEYYLTkOYJFGxcDPWBTm64EYEyrQGkckRGHKtSBsOqlBCAt/QDpuhDI6aI41x0r4",
+ "ZEYI+Y1RQAGjC7JMDZsbOqwvExBWBZ4aAN6hInzHIoBZ4MRu9sTKfVsFg0w2HDROnFhsCJ8a4zv62xao",
+ "J5zNI4j/1LPOhGVyDfieMrqISCDRwEFTSlOLFDfYKLE44WzJQYinL9hoBDVqXxARoNLgrQn4omUW04+1",
+ "7arLkMWbShOPyYHKxplYxaS5FF/pbFEaKoaTMS4XtzJqaXaYflm7VOpwwOga+BJCP48A5RDBGlOJ4EtC",
+ "+EbrxELiOBEudvS6wowyWt/z7YCHs9G5lvtdyVuPjyodUklbFHtXIulMrz0LqBhnSueaKQbjHjNJHNJD",
+ "DnrHN2i/3lm+JKf6HnA3M+tDc+W5nFydq0uUG0OmELk9V1sK1xAO0dQgi0Axy5sNRMAS0HkTKY+8Y28l",
+ "ZSKOR6PfV0zIG8OTRusfPd9bY07wPDJkvsqjORc4jaR37OGEDCVE8JnFwxA835GHM5WY6+QV1dvXVuVf",
+ "QhaIj8+yWUNYQ6QYVWmokWoyEjwYGZh8ClIhWQz8k1Kk5ox9HqmBX2MJn8Z0TTijMVD56XKtEA6uR/8l",
+ "7LwDTMMB4UT8oHlII1UIcDxQcNQmYViDkjIAxzq7JRWAcCpZjCUJcBRtjLVXZDBlNDKtBwawSu0jQrux",
+ "tQArmBpCK30apoNARjugjhPyBMH+MSeDOvzfYYqX4Hz53sY+Nd6xaR6iHcT9foQdxlE9qHUgV4EgO0wj",
+ "Ga11kJPlltU0P7YPlCdtlfrnv7V3y8OybRfDf9qXmxCbKVaFvf3R0fEky0sLC4dSZFhiadY8KeXm483/",
+ "CwAA//9bXVwDBVEBAA==",
}
// GetSwagger returns the content of the embedded swagger specification file
diff --git a/rover-server/internal/mapper/rover/in/__snapshots__/exposure_test.snap b/rover-server/internal/mapper/rover/in/__snapshots__/exposure_test.snap
index ed8538ab9..8480888b4 100755
--- a/rover-server/internal/mapper/rover/in/__snapshots__/exposure_test.snap
+++ b/rover-server/internal/mapper/rover/in/__snapshots__/exposure_test.snap
@@ -47,6 +47,7 @@
},
Event: (*v1.EventExposure)(nil),
Agentic: (*v1.AgenticExposure)(nil),
+ File: (*v1.FileExposure)(nil),
}
---
@@ -64,9 +65,26 @@
AdditionalPublisherIds: nil,
},
Agentic: (*v1.AgenticExposure)(nil),
+ File: (*v1.FileExposure)(nil),
}
---
[Exposure Mapper mapExposure must return an error for unknown exposure type - 1]
&v1.Exposure{}
---
+
+[Exposure Mapper mapExposure must map a FileExposure correctly - 1]
+&v1.Exposure{
+ Api: (*v1.ApiExposure)(nil),
+ Event: (*v1.EventExposure)(nil),
+ Agentic: (*v1.AgenticExposure)(nil),
+ File: &v1.FileExposure{
+ FileType: "demo-sftp-spec-v1",
+ Visibility: "World",
+ Approval: v1.Approval{},
+ PublicKeys: {
+ {Label:"provider-key", Key:"ssh-ed25519 AAAA-provider"},
+ },
+ },
+}
+---
diff --git a/rover-server/internal/mapper/rover/in/__snapshots__/rover_test.snap b/rover-server/internal/mapper/rover/in/__snapshots__/rover_test.snap
index 04a4b7b51..63e08e7dc 100644
--- a/rover-server/internal/mapper/rover/in/__snapshots__/rover_test.snap
+++ b/rover-server/internal/mapper/rover/in/__snapshots__/rover_test.snap
@@ -26,6 +26,7 @@
},
Event: (*v1.EventExposure)(nil),
Agentic: (*v1.AgenticExposure)(nil),
+ File: (*v1.FileExposure)(nil),
},
},
Subscriptions: {
@@ -39,6 +40,7 @@
},
Event: (*v1.EventSubscription)(nil),
Agentic: (*v1.AgenticSubscription)(nil),
+ File: (*v1.FileSubscription)(nil),
},
},
Permissions: nil,
@@ -75,6 +77,7 @@
},
Event: (*v1.EventExposure)(nil),
Agentic: (*v1.AgenticExposure)(nil),
+ File: (*v1.FileExposure)(nil),
},
},
Subscriptions: nil,
@@ -106,6 +109,7 @@
},
Event: (*v1.EventSubscription)(nil),
Agentic: (*v1.AgenticSubscription)(nil),
+ File: (*v1.FileSubscription)(nil),
},
},
Permissions: nil,
@@ -158,6 +162,7 @@
},
Event: (*v1.EventExposure)(nil),
Agentic: (*v1.AgenticExposure)(nil),
+ File: (*v1.FileExposure)(nil),
},
},
Subscriptions: {
@@ -171,6 +176,7 @@
},
Event: (*v1.EventSubscription)(nil),
Agentic: (*v1.AgenticSubscription)(nil),
+ File: (*v1.FileSubscription)(nil),
},
},
Permissions: nil,
@@ -248,6 +254,7 @@
},
Event: (*v1.EventExposure)(nil),
Agentic: (*v1.AgenticExposure)(nil),
+ File: (*v1.FileExposure)(nil),
},
},
Subscriptions: {
@@ -261,6 +268,7 @@
},
Event: (*v1.EventSubscription)(nil),
Agentic: (*v1.AgenticSubscription)(nil),
+ File: (*v1.FileSubscription)(nil),
},
},
Permissions: nil,
@@ -313,6 +321,7 @@
},
Event: (*v1.EventExposure)(nil),
Agentic: (*v1.AgenticExposure)(nil),
+ File: (*v1.FileExposure)(nil),
},
},
Subscriptions: {
@@ -326,6 +335,7 @@
},
Event: (*v1.EventSubscription)(nil),
Agentic: (*v1.AgenticSubscription)(nil),
+ File: (*v1.FileSubscription)(nil),
},
},
Permissions: nil,
diff --git a/rover-server/internal/mapper/rover/in/__snapshots__/subscription_test.snap b/rover-server/internal/mapper/rover/in/__snapshots__/subscription_test.snap
index 25f971bc3..0fdc2b128 100755
--- a/rover-server/internal/mapper/rover/in/__snapshots__/subscription_test.snap
+++ b/rover-server/internal/mapper/rover/in/__snapshots__/subscription_test.snap
@@ -24,6 +24,7 @@
},
Event: (*v1.EventSubscription)(nil),
Agentic: (*v1.AgenticSubscription)(nil),
+ File: (*v1.FileSubscription)(nil),
}
---
@@ -37,9 +38,24 @@
Scopes: nil,
},
Agentic: (*v1.AgenticSubscription)(nil),
+ File: (*v1.FileSubscription)(nil),
}
---
[Subscription Mapper mapSubscription must return an error if Discriminator fails - 1]
&v1.Subscription{}
---
+
+[Subscription Mapper mapSubscription must map a FileSubscription correctly - 1]
+&v1.Subscription{
+ Api: (*v1.ApiSubscription)(nil),
+ Event: (*v1.EventSubscription)(nil),
+ Agentic: (*v1.AgenticSubscription)(nil),
+ File: &v1.FileSubscription{
+ FileType: "demo-sftp-spec-v1",
+ PublicKeys: {
+ {Label:"consumer-key", Key:"ssh-ed25519 AAAA-consumer"},
+ },
+ },
+}
+---
diff --git a/rover-server/internal/mapper/rover/in/exposure.go b/rover-server/internal/mapper/rover/in/exposure.go
index e7db799e1..245af177d 100644
--- a/rover-server/internal/mapper/rover/in/exposure.go
+++ b/rover-server/internal/mapper/rover/in/exposure.go
@@ -62,6 +62,14 @@ func mapExposure(in *api.Exposure, out *roverv1.Exposure) error {
out.Agentic = mapAiExposure(aiExp)
+ case "file":
+ fileExp, err := in.AsFileExposure()
+ if err != nil {
+ return errors.Wrap(err, "failed to convert to FileExposure")
+ }
+
+ out.File = mapFileExposure(fileExp)
+
default:
return errors.Errorf("unknown exposure type: %s", expType)
}
@@ -69,6 +77,30 @@ func mapExposure(in *api.Exposure, out *roverv1.Exposure) error {
return nil
}
+func mapFileExposure(in api.FileExposure) *roverv1.FileExposure {
+ out := &roverv1.FileExposure{
+ FileType: in.FileType,
+ Visibility: toRoverVisibility(in.Visibility),
+ PublicKeys: mapPublicKeys(in.PublicKeys),
+ }
+
+ return out
+}
+
+func mapPublicKeys(in []api.PublicKey) []roverv1.PublicKey {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make([]roverv1.PublicKey, len(in))
+ for i, key := range in {
+ out[i] = roverv1.PublicKey{
+ Label: key.Label,
+ Key: key.Key,
+ }
+ }
+ return out
+}
+
func mapApiExposure(in api.ApiExposure) *roverv1.ApiExposure {
out := &roverv1.ApiExposure{}
out.BasePath = in.BasePath
diff --git a/rover-server/internal/mapper/rover/in/exposure_test.go b/rover-server/internal/mapper/rover/in/exposure_test.go
index 555e07d94..89ddd9c2f 100644
--- a/rover-server/internal/mapper/rover/in/exposure_test.go
+++ b/rover-server/internal/mapper/rover/in/exposure_test.go
@@ -97,6 +97,16 @@ var _ = Describe("Exposure Mapper", func() {
snaps.MatchSnapshot(GinkgoT(), output)
})
+ It("must map a FileExposure correctly", func() {
+ input := GetFileExposure(fileExposure)
+ output := &roverv1.Exposure{}
+
+ err := mapExposure(&input, output)
+
+ Expect(err).To(BeNil())
+ snaps.MatchSnapshot(GinkgoT(), output)
+ })
+
It("must return an error for unknown exposure type", func() {
input := &api.Exposure{}
output := &roverv1.Exposure{}
diff --git a/rover-server/internal/mapper/rover/in/file_test.go b/rover-server/internal/mapper/rover/in/file_test.go
new file mode 100644
index 000000000..94233db43
--- /dev/null
+++ b/rover-server/internal/mapper/rover/in/file_test.go
@@ -0,0 +1,168 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package in
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ roverv1 "github.com/telekom/controlplane/rover/api/v1"
+
+ "github.com/telekom/controlplane/rover-server/internal/api"
+)
+
+var _ = Describe("File Type (SFTP) Mapper", func() {
+
+ Context("mapFileExposure", func() {
+ It("must map a FileExposure correctly", func() {
+ input := api.FileExposure{
+ Type: "file",
+ FileType: "demo-sftp-spec-v1",
+ Visibility: api.WORLD,
+ PublicKeys: []api.PublicKey{
+ {Label: "provider-key", Key: "ssh-ed25519 AAAA1"},
+ },
+ }
+
+ output := mapFileExposure(input)
+
+ Expect(output).ToNot(BeNil())
+ Expect(output.FileType).To(Equal("demo-sftp-spec-v1"))
+ Expect(output.Visibility).To(Equal(roverv1.VisibilityWorld))
+ Expect(output.PublicKeys).To(HaveLen(1))
+ Expect(output.PublicKeys[0].Label).To(Equal("provider-key"))
+ Expect(output.PublicKeys[0].Key).To(Equal("ssh-ed25519 AAAA1"))
+ })
+
+ It("must default visibility to Enterprise when omitted", func() {
+ // The mapper defaults visibility via the shared toRoverVisibility,
+ // which maps an empty value to Enterprise — consistent with
+ // mapApiExposure/mapEventExposure and the CRD's
+ // +kubebuilder:default=Enterprise.
+ input := api.FileExposure{
+ Type: "file",
+ FileType: "demo-sftp-spec-v1",
+ PublicKeys: []api.PublicKey{
+ {Label: "provider-key", Key: "ssh-ed25519 AAAA1"},
+ },
+ }
+
+ output := mapFileExposure(input)
+
+ Expect(output.Visibility).To(Equal(roverv1.VisibilityEnterprise))
+ })
+
+ It("must map ZONE visibility to the CRD Zone visibility", func() {
+ input := api.FileExposure{
+ Type: "file",
+ FileType: "demo-sftp-spec-v1",
+ Visibility: api.ZONE,
+ PublicKeys: []api.PublicKey{
+ {Label: "provider-key", Key: "ssh-ed25519 AAAA1"},
+ },
+ }
+
+ output := mapFileExposure(input)
+
+ Expect(output.Visibility).To(Equal(roverv1.VisibilityZone))
+ })
+
+ It("must map ENTERPRISE visibility to the CRD Enterprise visibility", func() {
+ input := api.FileExposure{
+ Type: "file",
+ FileType: "demo-sftp-spec-v1",
+ Visibility: api.ENTERPRISE,
+ PublicKeys: []api.PublicKey{
+ {Label: "provider-key", Key: "ssh-ed25519 AAAA1"},
+ },
+ }
+
+ output := mapFileExposure(input)
+
+ Expect(output.Visibility).To(Equal(roverv1.VisibilityEnterprise))
+ })
+ })
+
+ Context("mapFileSubscription", func() {
+ It("must map a FileSubscription correctly", func() {
+ input := api.FileSubscription{
+ Type: "file",
+ FileType: "demo-sftp-spec-v1",
+ PublicKeys: []api.PublicKey{
+ {Label: "consumer-key", Key: "ssh-ed25519 AAAA2"},
+ },
+ }
+
+ output := mapFileSubscription(input)
+
+ Expect(output).ToNot(BeNil())
+ Expect(output.FileType).To(Equal("demo-sftp-spec-v1"))
+ Expect(output.PublicKeys).To(HaveLen(1))
+ Expect(output.PublicKeys[0].Label).To(Equal("consumer-key"))
+ Expect(output.PublicKeys[0].Key).To(Equal("ssh-ed25519 AAAA2"))
+ })
+ })
+
+ Context("mapPublicKeys", func() {
+ It("must return nil for an empty list", func() {
+ Expect(mapPublicKeys(nil)).To(BeNil())
+ Expect(mapPublicKeys([]api.PublicKey{})).To(BeNil())
+ })
+
+ It("must preserve order and values", func() {
+ output := mapPublicKeys([]api.PublicKey{
+ {Label: "a", Key: "k1"},
+ {Label: "b", Key: "k2"},
+ })
+
+ Expect(output).To(HaveLen(2))
+ Expect(output[0]).To(Equal(roverv1.PublicKey{Label: "a", Key: "k1"}))
+ Expect(output[1]).To(Equal(roverv1.PublicKey{Label: "b", Key: "k2"}))
+ })
+ })
+
+ Context("mapExposure dispatch", func() {
+ It("must map a FileExposure via the discriminator", func() {
+ exposure := &api.Exposure{}
+ Expect(exposure.FromFileExposure(api.FileExposure{
+ Type: "file",
+ FileType: "demo-sftp-spec-v1",
+ PublicKeys: []api.PublicKey{
+ {Label: "provider-key", Key: "ssh-ed25519 AAAA1"},
+ },
+ })).To(Succeed())
+
+ output := &roverv1.Exposure{}
+ err := mapExposure(exposure, output)
+
+ Expect(err).To(BeNil())
+ Expect(output.File).ToNot(BeNil())
+ Expect(output.Api).To(BeNil())
+ Expect(output.Event).To(BeNil())
+ Expect(output.File.FileType).To(Equal("demo-sftp-spec-v1"))
+ })
+ })
+
+ Context("mapSubscription dispatch", func() {
+ It("must map a FileSubscription via the discriminator", func() {
+ subscription := &api.Subscription{}
+ Expect(subscription.FromFileSubscription(api.FileSubscription{
+ Type: "file",
+ FileType: "demo-sftp-spec-v1",
+ PublicKeys: []api.PublicKey{
+ {Label: "consumer-key", Key: "ssh-ed25519 AAAA2"},
+ },
+ })).To(Succeed())
+
+ output := &roverv1.Subscription{}
+ err := mapSubscription(subscription, output)
+
+ Expect(err).To(BeNil())
+ Expect(output.File).ToNot(BeNil())
+ Expect(output.Api).To(BeNil())
+ Expect(output.Event).To(BeNil())
+ Expect(output.File.FileType).To(Equal("demo-sftp-spec-v1"))
+ })
+ })
+})
diff --git a/rover-server/internal/mapper/rover/in/subscription.go b/rover-server/internal/mapper/rover/in/subscription.go
index 7ac2ed243..8f57d9225 100644
--- a/rover-server/internal/mapper/rover/in/subscription.go
+++ b/rover-server/internal/mapper/rover/in/subscription.go
@@ -91,6 +91,14 @@ func mapSubscription(in *api.Subscription, out *roverv1.Subscription) error {
out.Agentic = mapAiSubscription(aiSub)
+ case "file":
+ fileSub, err := in.AsFileSubscription()
+ if err != nil {
+ return errors.Wrap(err, "failed to convert to FileSubscription")
+ }
+
+ out.File = mapFileSubscription(fileSub)
+
default:
return errors.Errorf("unknown subscription type: %s", subType)
@@ -99,6 +107,13 @@ func mapSubscription(in *api.Subscription, out *roverv1.Subscription) error {
return nil
}
+func mapFileSubscription(in api.FileSubscription) *roverv1.FileSubscription {
+ return &roverv1.FileSubscription{
+ FileType: in.FileType,
+ PublicKeys: mapPublicKeys(in.PublicKeys),
+ }
+}
+
func mapApiSubscription(in api.ApiSubscription) *roverv1.ApiSubscription {
out := &roverv1.ApiSubscription{}
out.BasePath = in.BasePath
diff --git a/rover-server/internal/mapper/rover/in/subscription_test.go b/rover-server/internal/mapper/rover/in/subscription_test.go
index 2c0b34721..d2246aa44 100644
--- a/rover-server/internal/mapper/rover/in/subscription_test.go
+++ b/rover-server/internal/mapper/rover/in/subscription_test.go
@@ -89,6 +89,16 @@ var _ = Describe("Subscription Mapper", func() {
snaps.MatchSnapshot(GinkgoT(), output)
})
+ It("must map a FileSubscription correctly", func() {
+ input := GetFileSubscription(fileSubscription)
+ output := &roverv1.Subscription{}
+
+ err := mapSubscription(&input, output)
+
+ Expect(err).To(BeNil())
+ snaps.MatchSnapshot(GinkgoT(), output)
+ })
+
It("must return an error if Discriminator fails", func() {
input := &api.Subscription{}
output := &roverv1.Subscription{}
diff --git a/rover-server/internal/mapper/rover/in/suite_rover_in_test.go b/rover-server/internal/mapper/rover/in/suite_rover_in_test.go
index b45d52726..26a542323 100644
--- a/rover-server/internal/mapper/rover/in/suite_rover_in_test.go
+++ b/rover-server/internal/mapper/rover/in/suite_rover_in_test.go
@@ -40,6 +40,23 @@ var (
EventType: "test-event",
}
+ fileExposure = api.FileExposure{
+ Type: "file",
+ FileType: "demo-sftp-spec-v1",
+ Visibility: "World",
+ PublicKeys: []api.PublicKey{
+ {Label: "provider-key", Key: "ssh-ed25519 AAAA-provider"},
+ },
+ }
+
+ fileSubscription = api.FileSubscription{
+ Type: "file",
+ FileType: "demo-sftp-spec-v1",
+ PublicKeys: []api.PublicKey{
+ {Label: "consumer-key", Key: "ssh-ed25519 AAAA-consumer"},
+ },
+ }
+
resourceIdInfo = mapper.ResourceIdInfo{
Name: "rover-local-sub",
Environment: "poc",
@@ -93,3 +110,17 @@ func GetEventSubscription(eventSubscription api.EventSubscription) api.Subscript
Expect(err).To(BeNil())
return sub
}
+
+func GetFileExposure(fileExposure api.FileExposure) api.Exposure {
+ var exp api.Exposure
+ err := (&exp).FromFileExposure(fileExposure)
+ Expect(err).To(BeNil())
+ return exp
+}
+
+func GetFileSubscription(fileSubscription api.FileSubscription) api.Subscription {
+ var sub api.Subscription
+ err := (&sub).FromFileSubscription(fileSubscription)
+ Expect(err).To(BeNil())
+ return sub
+}
diff --git a/rover-server/internal/mapper/rover/out/exposure.go b/rover-server/internal/mapper/rover/out/exposure.go
index ff3e795ea..a46246415 100644
--- a/rover-server/internal/mapper/rover/out/exposure.go
+++ b/rover-server/internal/mapper/rover/out/exposure.go
@@ -52,12 +52,39 @@ func mapExposure(in *roverv1.Exposure, out *api.Exposure) error {
return errors.Wrap(err, "failed to map ai exposure")
}
+ } else if in.File != nil {
+ if err := out.FromFileExposure(mapFileExposure(in.File)); err != nil {
+ return errors.Wrap(err, "failed to map file exposure")
+ }
+
} else {
return errors.Errorf("unknown exposure type: %s", in.Type())
}
return nil
}
+func mapFileExposure(in *roverv1.FileExposure) api.FileExposure {
+ return api.FileExposure{
+ FileType: in.FileType,
+ Visibility: toApiVisibility(in.Visibility),
+ PublicKeys: mapPublicKeys(in.PublicKeys),
+ }
+}
+
+func mapPublicKeys(in []roverv1.PublicKey) []api.PublicKey {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make([]api.PublicKey, len(in))
+ for i, key := range in {
+ out[i] = api.PublicKey{
+ Label: key.Label,
+ Key: key.Key,
+ }
+ }
+ return out
+}
+
func mapApiExposure(in *roverv1.ApiExposure) (api.ApiExposure, error) {
apiExposure := api.ApiExposure{
BasePath: in.BasePath,
diff --git a/rover-server/internal/mapper/rover/out/file_test.go b/rover-server/internal/mapper/rover/out/file_test.go
new file mode 100644
index 000000000..c7fb4a6f0
--- /dev/null
+++ b/rover-server/internal/mapper/rover/out/file_test.go
@@ -0,0 +1,92 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package out
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ roverv1 "github.com/telekom/controlplane/rover/api/v1"
+
+ "github.com/telekom/controlplane/rover-server/internal/api"
+)
+
+var _ = Describe("File Type (SFTP) Exposure Mapper", func() {
+
+ Context("mapFileExposure", func() {
+ It("must map a FileExposure correctly", func() {
+ input := &roverv1.FileExposure{
+ FileType: "demo-sftp-spec-v1",
+ Visibility: roverv1.VisibilityWorld,
+ PublicKeys: []roverv1.PublicKey{
+ {Label: "provider-key", Key: "ssh-ed25519 AAAA1"},
+ },
+ }
+
+ output := mapFileExposure(input)
+
+ Expect(output.FileType).To(Equal("demo-sftp-spec-v1"))
+ Expect(output.Visibility).To(Equal(api.WORLD))
+ Expect(output.PublicKeys).To(HaveLen(1))
+ Expect(output.PublicKeys[0].Label).To(Equal("provider-key"))
+ Expect(output.PublicKeys[0].Key).To(Equal("ssh-ed25519 AAAA1"))
+ })
+
+ DescribeTable("must map visibility to the API visibility",
+ func(in roverv1.Visibility, expected api.Visibility) {
+ output := mapFileExposure(&roverv1.FileExposure{
+ FileType: "demo-sftp-spec-v1",
+ Visibility: in,
+ PublicKeys: []roverv1.PublicKey{{Label: "provider-key", Key: "ssh-ed25519 AAAA1"}},
+ })
+ Expect(output.Visibility).To(Equal(expected))
+ },
+ Entry("WORLD", roverv1.VisibilityWorld, api.WORLD),
+ Entry("ZONE", roverv1.VisibilityZone, api.ZONE),
+ Entry("ENTERPRISE", roverv1.VisibilityEnterprise, api.ENTERPRISE),
+ )
+ })
+
+ Context("mapPublicKeys", func() {
+ It("must return nil for an empty list", func() {
+ Expect(mapPublicKeys(nil)).To(BeNil())
+ Expect(mapPublicKeys([]roverv1.PublicKey{})).To(BeNil())
+ })
+
+ It("must preserve order and values", func() {
+ output := mapPublicKeys([]roverv1.PublicKey{
+ {Label: "a", Key: "k1"},
+ {Label: "b", Key: "k2"},
+ })
+
+ Expect(output).To(HaveLen(2))
+ Expect(output[0]).To(Equal(api.PublicKey{Label: "a", Key: "k1"}))
+ Expect(output[1]).To(Equal(api.PublicKey{Label: "b", Key: "k2"}))
+ })
+ })
+
+ Context("mapExposure dispatch", func() {
+ It("must map a FileExposure via the discriminator", func() {
+ input := &roverv1.Exposure{
+ File: &roverv1.FileExposure{
+ FileType: "demo-sftp-spec-v1",
+ Visibility: roverv1.VisibilityWorld,
+ PublicKeys: []roverv1.PublicKey{
+ {Label: "provider-key", Key: "ssh-ed25519 AAAA1"},
+ },
+ },
+ }
+ output := &api.Exposure{}
+
+ err := mapExposure(input, output)
+ Expect(err).To(BeNil())
+
+ fileExposure, err := output.AsFileExposure()
+ Expect(err).To(BeNil())
+ Expect(fileExposure.FileType).To(Equal("demo-sftp-spec-v1"))
+ Expect(fileExposure.Visibility).To(Equal(api.WORLD))
+ Expect(fileExposure.PublicKeys).To(HaveLen(1))
+ })
+ })
+})
diff --git a/rover/PROJECT b/rover/PROJECT
index 562e1c9df..1cba46690 100644
--- a/rover/PROJECT
+++ b/rover/PROJECT
@@ -34,4 +34,16 @@ resources:
webhooks:
validation: true
webhookVersion: v1
+- api:
+ crdVersion: v1
+ namespaced: true
+ controller: true
+ domain: cp.ei.telekom.de
+ group: rover
+ kind: FileSpecification
+ path: github.com/telekom/controlplane/rover/api/v1
+ version: v1
+ webhooks:
+ validation: true
+ webhookVersion: v1
version: "3"
diff --git a/rover/api/v1/filespecification_types.go b/rover/api/v1/filespecification_types.go
new file mode 100644
index 000000000..6861aa13e
--- /dev/null
+++ b/rover/api/v1/filespecification_types.go
@@ -0,0 +1,119 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import (
+ "strings"
+
+ "github.com/telekom/controlplane/common/pkg/types"
+ "github.com/telekom/controlplane/common/pkg/util/labelutil"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func MakeFileTypeName(fileType string) string {
+ return labelutil.NormalizeNameValue(strings.ReplaceAll(fileType, ".", "-"))
+}
+
+// MakeFileSpecificationName derives the FileType name from a FileSpecification.
+func MakeFileSpecificationName(fileSpec *FileSpecification) string {
+ return MakeFileTypeName(fileSpec.Name)
+}
+
+// FileStorageType selects the file-transfer backend used to store/exchange files
+// for a file type. Currently only SFTP is supported
+// +kubebuilder:validation:Enum=sftp
+type FileStorageType string
+
+const (
+ // FileStorageTypeSFTP indicates the file type is handled via the SFTP backend
+ FileStorageTypeSFTP FileStorageType = "sftp"
+)
+
+func (t FileStorageType) String() string {
+ return string(t)
+}
+
+// FileSpecificationSpec defines the desired state of FileSpecification.
+// It mirrors the internal Rover-domain form from spec_dcp: only description and the
+// backend selector are stored; the file type identifier lives in metadata.name.
+type FileSpecificationSpec struct {
+ // Description provides a human-readable summary of this file type.
+ // +optional
+ Description string `json:"description,omitempty"`
+
+ // Specification contains the file ID reference from the file manager for the
+ // optional document that describes this file type.
+ // +optional
+ Specification string `json:"specification,omitempty"`
+
+ // StorageType selects the file-transfer backend.
+ // +kubebuilder:validation:Optional
+ // +kubebuilder:default=sftp
+ StorageType FileStorageType `json:"storageType,omitempty"`
+}
+
+// FileSpecificationStatus defines the observed state of FileSpecification.
+type FileSpecificationStatus struct {
+ // +listType=map
+ // +listMapKey=type
+ // +patchStrategy=merge
+ // +patchMergeKey=type
+ // +optional
+ Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
+
+ // FileType references the file-domain FileType created from this specification.
+ // It is populated by the FileSpecification reconciler
+ // (rover/internal/controller/filespecification_controller.go), mirroring how
+ // ApiSpecification creates Api and EventSpecification creates EventType.
+ FileType types.ObjectRef `json:"fileType,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+
+// FileSpecification is the Schema for the filespecifications API.
+// It defines a file type's metadata and creates the corresponding file-domain
+// FileType, analogous to how ApiSpecification creates Api resources and
+// EventSpecification creates EventType resources.
+type FileSpecification struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec FileSpecificationSpec `json:"spec,omitempty"`
+ Status FileSpecificationStatus `json:"status,omitempty"`
+}
+
+var _ types.Object = &FileSpecification{}
+
+func (r *FileSpecification) GetConditions() []metav1.Condition {
+ return r.Status.Conditions
+}
+
+func (r *FileSpecification) SetCondition(condition metav1.Condition) bool {
+ return meta.SetStatusCondition(&r.Status.Conditions, condition)
+}
+
+// +kubebuilder:object:root=true
+
+type FileSpecificationList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []FileSpecification `json:"items"`
+}
+
+var _ types.ObjectList = &FileSpecificationList{}
+
+func (r *FileSpecificationList) GetItems() []types.Object {
+ items := make([]types.Object, len(r.Items))
+ for i := range r.Items {
+ items[i] = &r.Items[i]
+ }
+ return items
+}
+
+func init() {
+ SchemeBuilder.Register(&FileSpecification{}, &FileSpecificationList{})
+}
diff --git a/rover/api/v1/filespecification_types_test.go b/rover/api/v1/filespecification_types_test.go
new file mode 100644
index 000000000..bf4a47c38
--- /dev/null
+++ b/rover/api/v1/filespecification_types_test.go
@@ -0,0 +1,104 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1_test
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ v1 "github.com/telekom/controlplane/rover/api/v1"
+)
+
+var _ = Describe("FileSpecification Types", func() {
+ Context("MakeFileSpecificationName", func() {
+ DescribeTable("normalizes the FileSpecification name",
+ func(name, expected string) {
+ fileSpec := &v1.FileSpecification{
+ ObjectMeta: metav1.ObjectMeta{Name: name},
+ }
+ Expect(v1.MakeFileSpecificationName(fileSpec)).To(Equal(expected))
+ },
+ Entry("dotted name is hyphenated", "de.telekom.foo.v1", "de-telekom-foo-v1"),
+ Entry("mixed case is lowercased", "De.Telekom.V1", "de-telekom-v1"),
+ Entry("already hyphenated name is unchanged", "demo-sftp-spec-v1", "demo-sftp-spec-v1"),
+ )
+ })
+
+ Context("FileStorageType", func() {
+ It("should stringify the sftp storage type", func() {
+ Expect(v1.FileStorageTypeSFTP.String()).To(Equal("sftp"))
+ })
+ })
+
+ Context("FileSpecification conditions", func() {
+ It("should set and get conditions", func() {
+ fileSpec := &v1.FileSpecification{}
+ Expect(fileSpec.GetConditions()).To(BeEmpty())
+
+ changed := fileSpec.SetCondition(metav1.Condition{
+ Type: "Ready",
+ Status: metav1.ConditionTrue,
+ Reason: "Provisioned",
+ Message: "FileType is ready",
+ })
+ Expect(changed).To(BeTrue())
+
+ conditions := fileSpec.GetConditions()
+ Expect(conditions).To(HaveLen(1))
+ Expect(conditions[0].Type).To(Equal("Ready"))
+ Expect(conditions[0].Status).To(Equal(metav1.ConditionTrue))
+
+ // Setting the same condition again reports no change.
+ changed = fileSpec.SetCondition(metav1.Condition{
+ Type: "Ready",
+ Status: metav1.ConditionTrue,
+ Reason: "Provisioned",
+ Message: "FileType is ready",
+ })
+ Expect(changed).To(BeFalse())
+ })
+ })
+
+ Context("FileSpecificationList", func() {
+ It("should return its items as types.Object", func() {
+ list := &v1.FileSpecificationList{
+ Items: []v1.FileSpecification{
+ {ObjectMeta: metav1.ObjectMeta{Name: "spec-a"}},
+ {ObjectMeta: metav1.ObjectMeta{Name: "spec-b"}},
+ },
+ }
+
+ items := list.GetItems()
+ Expect(items).To(HaveLen(2))
+ Expect(items[0].GetName()).To(Equal("spec-a"))
+ Expect(items[1].GetName()).To(Equal("spec-b"))
+ })
+
+ It("should return an empty slice for an empty list", func() {
+ list := &v1.FileSpecificationList{}
+ Expect(list.GetItems()).To(BeEmpty())
+ })
+ })
+
+ Context("SSHKeyType", func() {
+ It("should stringify the supported key types", func() {
+ Expect(v1.SSHKeyTypeRSA.String()).To(Equal("ssh-rsa"))
+ Expect(v1.SSHKeyTypeECDSANistP521.String()).To(Equal("ecdsa-sha2-nistp521"))
+ Expect(v1.SSHKeyTypeED25519.String()).To(Equal("ssh-ed25519"))
+ })
+
+ DescribeTable("reports validity of a key type",
+ func(keyType v1.SSHKeyType, valid bool) {
+ Expect(keyType.IsValid()).To(Equal(valid))
+ },
+ Entry("ssh-rsa is valid", v1.SSHKeyTypeRSA, true),
+ Entry("ecdsa-sha2-nistp521 is valid", v1.SSHKeyTypeECDSANistP521, true),
+ Entry("ssh-ed25519 is valid", v1.SSHKeyTypeED25519, true),
+ Entry("unsupported ecdsa-sha2-nistp256 is invalid", v1.SSHKeyType("ecdsa-sha2-nistp256"), false),
+ Entry("empty is invalid", v1.SSHKeyType(""), false),
+ )
+ })
+})
diff --git a/rover/api/v1/rover_types.go b/rover/api/v1/rover_types.go
index 163e34144..0fd9186cd 100644
--- a/rover/api/v1/rover_types.go
+++ b/rover/api/v1/rover_types.go
@@ -32,6 +32,18 @@ type RoverStatus struct {
EventExposures []types.ObjectRef `json:"eventExposures,omitempty"`
// EventSubscriptions are references to EventSubscription resources created by this Rover
EventSubscriptions []types.ObjectRef `json:"eventSubscriptions,omitempty"`
+ // FileExposures are references to FileExposure resources created by this Rover in the file domain.
+ //
+ // TODO(DHEI-20903): today RoverHandler.CreateOrUpdate only initialises this slice
+ // (make(..., 0)); it is populated (append of the created file-domain resource refs)
+ // by the file handler dispatch once the file domain module is available.
+ // Populated from: rover/internal/handler/rover/handler.go, case roverv1.TypeFile.
+ FileExposures []types.ObjectRef `json:"fileExposures,omitempty"`
+ // FileSubscriptions are references to FileSubscription resources created by this Rover in the file domain.
+ //
+ // TODO(DHEI-20903): see FileExposures — populated by the file handler dispatch
+ // (rover/internal/handler/rover/handler.go, case roverv1.TypeFile) once delivered.
+ FileSubscriptions []types.ObjectRef `json:"fileSubscriptions,omitempty"`
// PermissionSets are references to PermissionSet resources created by this Rover
PermissionSets []types.ObjectRef `json:"permissionSets,omitempty"`
// AgenticExposures are references to AgenticExposure resources created by this Rover
@@ -214,6 +226,8 @@ const (
TypeEvent Type = "event"
// TypeAgentic represents an Agentic type resource (MCP, A2A)
TypeAgentic Type = "agentic"
+ // TypeFile represents a File type resource (SFTP integration)
+ TypeFile Type = "file"
)
// ApprovalStrategy defines the approval workflow for API exposure
@@ -277,6 +291,9 @@ type Exposure struct {
// Agentic defines an Agentic(MCP or agent) server exposure configuration
// +kubebuilder:validation:Optional
Agentic *AgenticExposure `json:"agentic,omitempty"`
+ // File defines a File-based (SFTP) service exposure configuration
+ // +kubebuilder:validation:Optional
+ File *FileExposure `json:"file,omitempty"`
}
func (e *Exposure) Type() Type {
@@ -289,6 +306,9 @@ func (e *Exposure) Type() Type {
if e.Agentic != nil {
return TypeAgentic
}
+ if e.File != nil {
+ return TypeFile
+ }
return ""
}
@@ -305,6 +325,9 @@ type Subscription struct {
// Agentic defines an Agentic(MCP or agent) server subscription configuration
// +kubebuilder:validation:Optional
Agentic *AgenticSubscription `json:"agentic,omitempty"`
+ // File defines a File-based (SFTP) service subscription configuration
+ // +kubebuilder:validation:Optional
+ File *FileSubscription `json:"file,omitempty"`
}
func (s *Subscription) Type() Type {
@@ -317,6 +340,9 @@ func (s *Subscription) Type() Type {
if s.Agentic != nil {
return TypeAgentic
}
+ if s.File != nil {
+ return TypeFile
+ }
return ""
}
@@ -512,6 +538,90 @@ type AgenticSubscription struct {
Security *SubscriberSecurity `json:"security,omitempty"`
}
+// FileExposure defines a file type that is exposed by this Rover via SFTP.
+// Applying it registers the provider's SSH public keys on the corresponding
+// SFTP user (shared space) created from the matching FileSpecification.
+type FileExposure struct {
+ // FileType identifies the file type that is exposed. It must match the
+ // name (and spec.type) of an applied FileSpecification.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ FileType string `json:"fileType"`
+
+ // Visibility defines who can see and subscribe to this file type
+ // +kubebuilder:validation:Enum=World;Zone;Enterprise
+ // +kubebuilder:default=Enterprise
+ Visibility Visibility `json:"visibility"`
+
+ // Approval defines the approval workflow required for subscriptions to this file type
+ // +kubebuilder:validation:Required
+ Approval Approval `json:"approval"`
+
+ // PublicKeys are the SSH public keys registered for the producer's SFTP user.
+ // At least one key is required. Both label and key value must be unique per fileType.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinItems=1
+ PublicKeys []PublicKey `json:"publicKeys"`
+}
+
+// FileSubscription defines a file type that this Rover consumes via SFTP.
+// Applying it registers the consumer's SSH public keys on the corresponding
+// SFTP user (shared space) created from the matching FileSpecification.
+type FileSubscription struct {
+ // FileType identifies the file type to consume. It must match the
+ // name of an applied FileSpecification.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ FileType string `json:"fileType"`
+
+ // PublicKeys are the SSH public keys registered for the consumer's SFTP user.
+ // At least one key is required. Both label and key value must be unique per fileType.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinItems=1
+ PublicKeys []PublicKey `json:"publicKeys"`
+}
+
+// PublicKey is a labelled SSH public key registered on a SFTP user.
+type PublicKey struct {
+ // Label is a human-readable identifier for the key. It must be unique per fileType.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ Label string `json:"label"`
+
+ // Key is the SSH public key value. It must be unique per fileType.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ Key string `json:"key"`
+}
+
+// SSHKeyType identifies the algorithm prefix of an SSH public key registered on
+// a SFTP user. Only these algorithms are accepted for file exposures and subscriptions.
+type SSHKeyType string
+
+const (
+ SSHKeyTypeRSA SSHKeyType = "ssh-rsa"
+ SSHKeyTypeECDSANistP521 SSHKeyType = "ecdsa-sha2-nistp521"
+ SSHKeyTypeED25519 SSHKeyType = "ssh-ed25519"
+)
+
+var AllSSHKeyTypes = []SSHKeyType{
+ SSHKeyTypeRSA,
+ SSHKeyTypeECDSANistP521,
+ SSHKeyTypeED25519,
+}
+
+func (t SSHKeyType) String() string {
+ return string(t)
+}
+
+func (t SSHKeyType) IsValid() bool {
+ switch t {
+ case SSHKeyTypeRSA, SSHKeyTypeECDSANistP521, SSHKeyTypeED25519:
+ return true
+ }
+ return false
+}
+
// Approval defines the approval workflow for API exposure
type Approval struct {
// Strategy defines the approval process required for this API
diff --git a/rover/api/v1/rover_types_test.go b/rover/api/v1/rover_types_test.go
index 35b0855e8..86186345f 100644
--- a/rover/api/v1/rover_types_test.go
+++ b/rover/api/v1/rover_types_test.go
@@ -168,7 +168,7 @@ var _ = Describe("Rover V1 Test Suite", func() {
Expect(len(statusErr.Status().Details.Causes)).To(Equal(2))
Expect(statusErr.Status().Details.Causes).To(ContainElement(metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
- Message: "Invalid value: \"object\": Only one of api or event can be specified (XOR relationship)",
+ Message: "Invalid value: \"object\": Only one of api, event or file can be specified (XOR relationship)",
Field: "spec.exposures[0]",
}))
@@ -390,4 +390,119 @@ var _ = Describe("Rover V1 Test Suite", func() {
Expect(len(statusErr.Status().Details.Causes)).To(Equal(2))
})
})
+
+ Context("File Types (SFTP)", func() {
+ It("should report the exposure and subscription type as file", func() {
+ exp := v1.Exposure{File: &v1.FileExposure{FileType: "demo-sftp-spec-v1"}}
+ Expect(exp.Type()).To(Equal(v1.TypeFile))
+
+ sub := v1.Subscription{File: &v1.FileSubscription{FileType: "demo-sftp-spec-v1"}}
+ Expect(sub.Type()).To(Equal(v1.TypeFile))
+ })
+
+ It("should accept a Rover with a file type exposure and subscription", func() {
+ rover := new(v1.Rover)
+ rover.Name = "file-rover"
+ rover.Namespace = "default"
+ rover.Spec = v1.RoverSpec{
+ Zone: "cetus",
+ ClientSecret: "topsecret",
+ Exposures: []v1.Exposure{
+ {
+ File: &v1.FileExposure{
+ FileType: "demo-sftp-spec-v1",
+ Visibility: v1.VisibilityWorld,
+ PublicKeys: []v1.PublicKey{
+ {Label: "demo-provider-key", Key: "ssh-ed25519 AAAAprovider"},
+ },
+ },
+ },
+ },
+ Subscriptions: []v1.Subscription{
+ {
+ File: &v1.FileSubscription{
+ FileType: "demo-sftp-spec-v1",
+ PublicKeys: []v1.PublicKey{
+ {Label: "demo-consumer-key", Key: "ssh-ed25519 AAAAconsumer"},
+ },
+ },
+ },
+ },
+ }
+ rover.Status = v1.RoverStatus{}
+
+ err := k8sClient.Create(ctx, rover)
+ Expect(err).NotTo(HaveOccurred())
+
+ err = k8sClient.Delete(ctx, rover)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("should reject a file type exposure without any public keys", func() {
+ rover := new(v1.Rover)
+ rover.Name = "invalid-file-rover"
+ rover.Namespace = "default"
+ rover.Spec = v1.RoverSpec{
+ Zone: "cetus",
+ ClientSecret: "topsecret",
+ Exposures: []v1.Exposure{
+ {
+ File: &v1.FileExposure{
+ FileType: "demo-sftp-spec-v1",
+ Visibility: v1.VisibilityWorld,
+ PublicKeys: []v1.PublicKey{},
+ },
+ },
+ },
+ }
+ rover.Status = v1.RoverStatus{}
+
+ err := k8sClient.Create(ctx, rover)
+ Expect(err).To(HaveOccurred())
+ Expect(apierrors.IsInvalid(err)).To(BeTrue())
+ })
+
+ It("should reject a file type exposure combined with an api exposure in the same entry", func() {
+ rover := new(v1.Rover)
+ rover.Name = "invalid-file-rover"
+ rover.Namespace = "default"
+ rover.Spec = v1.RoverSpec{
+ Zone: "cetus",
+ ClientSecret: "topsecret",
+ Exposures: []v1.Exposure{
+ {
+ Api: &v1.ApiExposure{
+ BasePath: "/api",
+ Upstreams: []v1.Upstream{
+ {URL: "http://example.com"},
+ },
+ Visibility: v1.VisibilityEnterprise,
+ Approval: v1.Approval{
+ Strategy: v1.ApprovalStrategyAuto,
+ },
+ },
+ File: &v1.FileExposure{
+ FileType: "demo-sftp-spec-v1",
+ Visibility: v1.VisibilityWorld,
+ PublicKeys: []v1.PublicKey{
+ {Label: "demo-provider-key", Key: "ssh-ed25519 AAAAprovider"},
+ },
+ },
+ },
+ },
+ }
+ rover.Status = v1.RoverStatus{}
+
+ err := k8sClient.Create(ctx, rover)
+ Expect(err).To(HaveOccurred())
+ Expect(apierrors.IsInvalid(err)).To(BeTrue())
+ statusErr, ok := err.(apierrors.APIStatus)
+ Expect(ok).To(BeTrue())
+ Expect(statusErr.Status().Details.Causes).To(ContainElement(metav1.StatusCause{
+ Type: metav1.CauseTypeFieldValueInvalid,
+ Message: "Invalid value: \"object\": Only one of api, event or file can be specified (XOR relationship)",
+ Field: "spec.exposures[0]",
+ }))
+ })
+ })
})
diff --git a/rover/api/v1/zz_generated.deepcopy.go b/rover/api/v1/zz_generated.deepcopy.go
index b8da58f9e..5201ccb30 100644
--- a/rover/api/v1/zz_generated.deepcopy.go
+++ b/rover/api/v1/zz_generated.deepcopy.go
@@ -884,6 +884,11 @@ func (in *Exposure) DeepCopyInto(out *Exposure) {
*out = new(AgenticExposure)
(*in).DeepCopyInto(*out)
}
+ if in.File != nil {
+ in, out := &in.File, &out.File
+ *out = new(FileExposure)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Exposure.
@@ -936,6 +941,144 @@ func (in *ExternalIdentityProvider) DeepCopy() *ExternalIdentityProvider {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileExposure) DeepCopyInto(out *FileExposure) {
+ *out = *in
+ in.Approval.DeepCopyInto(&out.Approval)
+ if in.PublicKeys != nil {
+ in, out := &in.PublicKeys, &out.PublicKeys
+ *out = make([]PublicKey, len(*in))
+ copy(*out, *in)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileExposure.
+func (in *FileExposure) DeepCopy() *FileExposure {
+ if in == nil {
+ return nil
+ }
+ out := new(FileExposure)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileSpecification) DeepCopyInto(out *FileSpecification) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ out.Spec = in.Spec
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileSpecification.
+func (in *FileSpecification) DeepCopy() *FileSpecification {
+ if in == nil {
+ return nil
+ }
+ out := new(FileSpecification)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *FileSpecification) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileSpecificationList) DeepCopyInto(out *FileSpecificationList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]FileSpecification, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileSpecificationList.
+func (in *FileSpecificationList) DeepCopy() *FileSpecificationList {
+ if in == nil {
+ return nil
+ }
+ out := new(FileSpecificationList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *FileSpecificationList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileSpecificationSpec) DeepCopyInto(out *FileSpecificationSpec) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileSpecificationSpec.
+func (in *FileSpecificationSpec) DeepCopy() *FileSpecificationSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(FileSpecificationSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileSpecificationStatus) DeepCopyInto(out *FileSpecificationStatus) {
+ *out = *in
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]metav1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ in.FileType.DeepCopyInto(&out.FileType)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileSpecificationStatus.
+func (in *FileSpecificationStatus) DeepCopy() *FileSpecificationStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(FileSpecificationStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FileSubscription) DeepCopyInto(out *FileSubscription) {
+ *out = *in
+ if in.PublicKeys != nil {
+ in, out := &in.PublicKeys, &out.PublicKeys
+ *out = make([]PublicKey, len(*in))
+ copy(*out, *in)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileSubscription.
+func (in *FileSubscription) DeepCopy() *FileSubscription {
+ if in == nil {
+ return nil
+ }
+ out := new(FileSubscription)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HeaderTransformation) DeepCopyInto(out *HeaderTransformation) {
*out = *in
@@ -1250,6 +1393,21 @@ func (in *ProviderFailover) DeepCopy() *ProviderFailover {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PublicKey) DeepCopyInto(out *PublicKey) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublicKey.
+func (in *PublicKey) DeepCopy() *PublicKey {
+ if in == nil {
+ return nil
+ }
+ out := new(PublicKey)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *RateLimit) DeepCopyInto(out *RateLimit) {
*out = *in
@@ -1626,6 +1784,20 @@ func (in *RoverStatus) DeepCopyInto(out *RoverStatus) {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
+ if in.FileExposures != nil {
+ in, out := &in.FileExposures, &out.FileExposures
+ *out = make([]types.ObjectRef, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ if in.FileSubscriptions != nil {
+ in, out := &in.FileSubscriptions, &out.FileSubscriptions
+ *out = make([]types.ObjectRef, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
if in.PermissionSets != nil {
in, out := &in.PermissionSets, &out.PermissionSets
*out = make([]types.ObjectRef, len(*in))
@@ -1782,6 +1954,11 @@ func (in *Subscription) DeepCopyInto(out *Subscription) {
*out = new(AgenticSubscription)
(*in).DeepCopyInto(*out)
}
+ if in.File != nil {
+ in, out := &in.File, &out.File
+ *out = new(FileSubscription)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Subscription.
diff --git a/rover/cmd/main.go b/rover/cmd/main.go
index 8052f71ea..0f07c61b5 100644
--- a/rover/cmd/main.go
+++ b/rover/cmd/main.go
@@ -10,6 +10,12 @@ import (
"fmt"
"os"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+
+ // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
+ // to ensure that exec-entrypoint and run can make use of them.
+ _ "k8s.io/client-go/plugin/pkg/client/auth"
+
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
@@ -35,10 +41,6 @@ import (
webhookv1 "github.com/telekom/controlplane/rover/internal/webhook/v1"
secretsapi "github.com/telekom/controlplane/secret-manager/api"
secretmetrics "github.com/telekom/controlplane/secret-manager/api/metrics"
-
- // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
- // to ensure that exec-entrypoint and run can make use of them.
- _ "k8s.io/client-go/plugin/pkg/client/auth"
)
var (
@@ -66,6 +68,10 @@ func init() {
utilruntime.Must(agenticv1.AddToScheme(scheme))
}
// +kubebuilder:scaffold:scheme
+ if cconfig.FeatureFile.IsEnabled() {
+ utilruntime.Must(filev1.AddToScheme(scheme))
+ }
+ // +kubebuilder:scaffold:scheme
}
func main() {
@@ -200,6 +206,16 @@ func main() {
os.Exit(1)
}
+ if cconfig.FeatureFile.IsEnabled() {
+ if err = (&controller.FileSpecificationReconciler{
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "FileSpecification")
+ os.Exit(1)
+ }
+ }
+
if err = (&controller.ApiChangelogReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
diff --git a/rover/config/crd/bases/rover.cp.ei.telekom.de_filespecifications.yaml b/rover/config/crd/bases/rover.cp.ei.telekom.de_filespecifications.yaml
new file mode 100644
index 000000000..8f7d89251
--- /dev/null
+++ b/rover/config/crd/bases/rover.cp.ei.telekom.de_filespecifications.yaml
@@ -0,0 +1,156 @@
+# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.20.1
+ name: filespecifications.rover.cp.ei.telekom.de
+spec:
+ group: rover.cp.ei.telekom.de
+ names:
+ kind: FileSpecification
+ listKind: FileSpecificationList
+ plural: filespecifications
+ singular: filespecification
+ scope: Namespaced
+ versions:
+ - name: v1
+ schema:
+ openAPIV3Schema:
+ description: |-
+ FileSpecification is the Schema for the filespecifications API.
+ It defines a file type's metadata and creates the corresponding file-domain
+ FileType, analogous to how ApiSpecification creates Api resources and
+ EventSpecification creates EventType resources.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: |-
+ FileSpecificationSpec defines the desired state of FileSpecification.
+ It mirrors the internal Rover-domain form from spec_dcp: only description and the
+ backend selector are stored; the file type identifier lives in metadata.name.
+ properties:
+ description:
+ description: Description provides a human-readable summary of this
+ file type.
+ type: string
+ specification:
+ description: |-
+ Specification contains the file ID reference from the file manager for the
+ optional document that describes this file type.
+ type: string
+ storageType:
+ default: sftp
+ description: StorageType selects the file-transfer backend.
+ enum:
+ - sftp
+ type: string
+ type: object
+ status:
+ description: FileSpecificationStatus defines the observed state of FileSpecification.
+ properties:
+ conditions:
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ fileType:
+ description: |-
+ FileType references the file-domain FileType created from this specification.
+ It is populated by the FileSpecification reconciler
+ (rover/internal/controller/filespecification_controller.go), mirroring how
+ ApiSpecification creates Api and EventSpecification creates EventType.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/rover/config/crd/bases/rover.cp.ei.telekom.de_rovers.yaml b/rover/config/crd/bases/rover.cp.ei.telekom.de_rovers.yaml
index 924062fc6..807a17df7 100644
--- a/rover/config/crd/bases/rover.cp.ei.telekom.de_rovers.yaml
+++ b/rover/config/crd/bases/rover.cp.ei.telekom.de_rovers.yaml
@@ -1114,6 +1114,96 @@ spec:
- eventType
- visibility
type: object
+ file:
+ description: File defines a File-based (SFTP) service exposure
+ configuration
+ properties:
+ approval:
+ description: Approval defines the approval workflow required
+ for subscriptions to this file type
+ properties:
+ strategy:
+ default: Simple
+ description: Strategy defines the approval process required
+ for this API
+ enum:
+ - Auto
+ - Simple
+ - FourEyes
+ type: string
+ trustedTeams:
+ description: |-
+ TrustedTeams identifies teams that are trusted for approving this API
+ Per default your own team is trusted
+ items:
+ description: TrustedTeam identifies a team that is
+ trusted for approvals
+ properties:
+ group:
+ description: Group identifies the organizational
+ group for this trusted team
+ minLength: 1
+ type: string
+ team:
+ description: Team identifies the specific team
+ within the group
+ minLength: 1
+ type: string
+ required:
+ - group
+ - team
+ type: object
+ maxItems: 10
+ minItems: 0
+ type: array
+ required:
+ - strategy
+ type: object
+ fileType:
+ description: |-
+ FileType identifies the file type that is exposed. It must match the
+ name (and spec.type) of an applied FileSpecification.
+ minLength: 1
+ type: string
+ publicKeys:
+ description: |-
+ PublicKeys are the SSH public keys registered for the producer's SFTP user.
+ At least one key is required. Both label and key value must be unique per fileType.
+ items:
+ description: PublicKey is a labelled SSH public key registered
+ on a SFTP user.
+ properties:
+ key:
+ description: Key is the SSH public key value. It must
+ be unique per fileType.
+ minLength: 1
+ type: string
+ label:
+ description: Label is a human-readable identifier
+ for the key. It must be unique per fileType.
+ minLength: 1
+ type: string
+ required:
+ - key
+ - label
+ type: object
+ minItems: 1
+ type: array
+ visibility:
+ default: Enterprise
+ description: Visibility defines who can see and subscribe
+ to this file type
+ enum:
+ - World
+ - Zone
+ - Enterprise
+ type: string
+ required:
+ - approval
+ - fileType
+ - publicKeys
+ - visibility
+ type: object
type: object
maxItems: 150
type: array
@@ -1653,6 +1743,44 @@ spec:
- delivery
- eventType
type: object
+ file:
+ description: File defines a File-based (SFTP) service subscription
+ configuration
+ properties:
+ fileType:
+ description: |-
+ FileType identifies the file type to consume. It must match the
+ name of an applied FileSpecification.
+ minLength: 1
+ type: string
+ publicKeys:
+ description: |-
+ PublicKeys are the SSH public keys registered for the consumer's SFTP user.
+ At least one key is required. Both label and key value must be unique per fileType.
+ items:
+ description: PublicKey is a labelled SSH public key registered
+ on a SFTP user.
+ properties:
+ key:
+ description: Key is the SSH public key value. It must
+ be unique per fileType.
+ minLength: 1
+ type: string
+ label:
+ description: Label is a human-readable identifier
+ for the key. It must be unique per fileType.
+ minLength: 1
+ type: string
+ required:
+ - key
+ - label
+ type: object
+ minItems: 1
+ type: array
+ required:
+ - fileType
+ - publicKeys
+ type: object
type: object
maxItems: 150
type: array
@@ -1883,6 +2011,58 @@ spec:
- namespace
type: object
type: array
+ fileExposures:
+ description: |-
+ FileExposures are references to FileExposure resources created by this Rover in the file domain.
+
+ (make(..., 0)); it is populated (append of the created file-domain resource refs)
+ by the file handler dispatch once the file domain module is available.
+ Populated from: rover/internal/handler/rover/handler.go, case roverv1.TypeFile.
+ items:
+ description: |-
+ ObjectRef is a reference to a Kubernetes object
+ It is similar to types.NamespacedName but has the required json tags for serialization
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ type: array
+ fileSubscriptions:
+ description: |-
+ FileSubscriptions are references to FileSubscription resources created by this Rover in the file domain.
+
+ (rover/internal/handler/rover/handler.go, case roverv1.TypeFile) once delivered.
+ items:
+ description: |-
+ ObjectRef is a reference to a Kubernetes object
+ It is similar to types.NamespacedName but has the required json tags for serialization
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ type: array
permissionSets:
description: PermissionSets are references to PermissionSet resources
created by this Rover
diff --git a/rover/config/crd/kustomization.yaml b/rover/config/crd/kustomization.yaml
index 87cbd68fe..a4035781d 100644
--- a/rover/config/crd/kustomization.yaml
+++ b/rover/config/crd/kustomization.yaml
@@ -9,6 +9,7 @@ resources:
- bases/rover.cp.ei.telekom.de_rovers.yaml
- bases/rover.cp.ei.telekom.de_apispecifications.yaml
- bases/rover.cp.ei.telekom.de_eventspecifications.yaml
+- bases/rover.cp.ei.telekom.de_filespecifications.yaml
- bases/rover.cp.ei.telekom.de_apichangelogs.yaml
- bases/rover.cp.ei.telekom.de_roadmaps.yaml
- bases/rover.cp.ei.telekom.de_mcpspecifications.yaml
diff --git a/rover/config/rbac/role.yaml b/rover/config/rbac/role.yaml
index a73ab6218..7380acd17 100644
--- a/rover/config/rbac/role.yaml
+++ b/rover/config/rbac/role.yaml
@@ -93,6 +93,20 @@ rules:
- patch
- update
- watch
+- apiGroups:
+ - file.cp.ei.telekom.de
+ resources:
+ - fileexposures
+ - filesubscriptions
+ - filetypes
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
- apiGroups:
- organization.cp.ei.telekom.de
resources:
@@ -120,6 +134,7 @@ rules:
- apichangelogs
- apispecifications
- eventspecifications
+ - filespecifications
- mcpspecifications
- roadmaps
- rovers
@@ -138,6 +153,7 @@ rules:
- apichangelogs/finalizers
- apispecifications/finalizers
- eventspecifications/finalizers
+ - filespecifications/finalizers
- mcpspecifications/finalizers
- roadmaps/finalizers
- rovers/finalizers
@@ -150,6 +166,7 @@ rules:
- apichangelogs/status
- apispecifications/status
- eventspecifications/status
+ - filespecifications/status
- mcpspecifications/status
- roadmaps/status
- rovers/status
diff --git a/rover/config/webhook/manifests.yaml b/rover/config/webhook/manifests.yaml
index bd7bbccd5..af7bf915b 100644
--- a/rover/config/webhook/manifests.yaml
+++ b/rover/config/webhook/manifests.yaml
@@ -73,6 +73,26 @@ webhooks:
resources:
- apispecifications
sideEffects: None
+- admissionReviewVersions:
+ - v1
+ clientConfig:
+ service:
+ name: webhook-service
+ namespace: system
+ path: /validate-rover-cp-ei-telekom-de-v1-filespecification
+ failurePolicy: Fail
+ name: vfilespecification-v1.kb.io
+ rules:
+ - apiGroups:
+ - rover.cp.ei.telekom.de
+ apiVersions:
+ - v1
+ operations:
+ - CREATE
+ - UPDATE
+ resources:
+ - filespecifications
+ sideEffects: None
- admissionReviewVersions:
- v1
clientConfig:
diff --git a/rover/go.mod b/rover/go.mod
index 5b5232b0a..ae503132b 100644
--- a/rover/go.mod
+++ b/rover/go.mod
@@ -26,6 +26,8 @@ require (
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.12.1
github.com/telekom/controlplane/agentic/api v0.0.0-00010101000000-000000000000
+ github.com/telekom/controlplane/file/api v0.0.0-00010101000000-000000000000
+ golang.org/x/crypto v0.55.0
k8s.io/api v0.36.4
k8s.io/apimachinery v0.36.4
k8s.io/client-go v0.36.3
@@ -41,6 +43,7 @@ replace (
github.com/telekom/controlplane/common => ../common
github.com/telekom/controlplane/common-server => ../common-server
github.com/telekom/controlplane/event/api => ../event/api
+ github.com/telekom/controlplane/file/api => ../file/api
github.com/telekom/controlplane/organization/api => ../organization/api
github.com/telekom/controlplane/permission/api => ../permission/api
github.com/telekom/controlplane/rover/api => ./api
diff --git a/rover/go.sum b/rover/go.sum
index d48523d13..ad0440ad5 100644
--- a/rover/go.sum
+++ b/rover/go.sum
@@ -210,6 +210,8 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
diff --git a/rover/internal/controller/filespecification_controller.go b/rover/internal/controller/filespecification_controller.go
new file mode 100644
index 000000000..5c7062983
--- /dev/null
+++ b/rover/internal/controller/filespecification_controller.go
@@ -0,0 +1,57 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+//nolint:dupl // Single-resource controller scaffolds are intentionally kept parallel for clarity.
+package controller
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ "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/controller"
+
+ cconfig "github.com/telekom/controlplane/common/pkg/config"
+ cc "github.com/telekom/controlplane/common/pkg/controller"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ rover "github.com/telekom/controlplane/rover/api/v1"
+ filespec_handler "github.com/telekom/controlplane/rover/internal/handler/filespecification"
+)
+
+// FileSpecificationReconciler reconciles a FileSpecification object
+type FileSpecificationReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+ Recorder record.EventRecorder
+
+ cc.Controller[*rover.FileSpecification]
+}
+
+// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
+
+// +kubebuilder:rbac:groups=rover.cp.ei.telekom.de,resources=filespecifications,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=rover.cp.ei.telekom.de,resources=filespecifications/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=rover.cp.ei.telekom.de,resources=filespecifications/finalizers,verbs=update
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=filetypes,verbs=get;list;watch;create;update;patch;delete
+
+func (r *FileSpecificationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ return r.Controller.Reconcile(ctx, req, &rover.FileSpecification{})
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *FileSpecificationReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ r.Recorder = mgr.GetEventRecorderFor("filespecification-controller")
+ r.Controller = cc.NewController(&filespec_handler.FileSpecificationHandler{}, r.Client, r.Recorder)
+
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&rover.FileSpecification{}).
+ Owns(&filev1.FileType{}).
+ WithOptions(controller.Options{
+ MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles,
+ RateLimiter: cc.NewRateLimiter(),
+ }).
+ Complete(r)
+}
diff --git a/rover/internal/controller/index.go b/rover/internal/controller/index.go
index ed0bb3a3e..7f494669d 100644
--- a/rover/internal/controller/index.go
+++ b/rover/internal/controller/index.go
@@ -16,6 +16,7 @@ import (
cconfig "github.com/telekom/controlplane/common/pkg/config"
"github.com/telekom/controlplane/common/pkg/controller/index"
eventv1 "github.com/telekom/controlplane/event/api/v1"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
permissionv1 "github.com/telekom/controlplane/permission/api/v1"
)
@@ -80,4 +81,17 @@ func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) {
os.Exit(1)
}
}
+
+ if cconfig.FeatureFile.IsEnabled() {
+ err = index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &filev1.FileExposure{})
+ if err != nil {
+ ctrl.Log.Error(err, "unable to create ownerIndex for FileExposure")
+ os.Exit(1)
+ }
+ err = index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &filev1.FileSubscription{})
+ if err != nil {
+ ctrl.Log.Error(err, "unable to create ownerIndex for FileSubscription")
+ os.Exit(1)
+ }
+ }
}
diff --git a/rover/internal/controller/rover_controller.go b/rover/internal/controller/rover_controller.go
index 1f5a95e75..361df82e9 100644
--- a/rover/internal/controller/rover_controller.go
+++ b/rover/internal/controller/rover_controller.go
@@ -24,6 +24,7 @@ import (
cconfig "github.com/telekom/controlplane/common/pkg/config"
cc "github.com/telekom/controlplane/common/pkg/controller"
eventv1 "github.com/telekom/controlplane/event/api/v1"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
organizationv1 "github.com/telekom/controlplane/organization/api/v1"
permissionv1 "github.com/telekom/controlplane/permission/api/v1"
rover "github.com/telekom/controlplane/rover/api/v1"
@@ -57,6 +58,9 @@ type RoverReconciler struct {
// +kubebuilder:rbac:groups=agentic.cp.ei.telekom.de,resources=agenticexposures,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=agentic.cp.ei.telekom.de,resources=agenticsubscriptions,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=fileexposures,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=file.cp.ei.telekom.de,resources=filesubscriptions,verbs=get;list;watch;create;update;patch;delete
+
// +kubebuilder:rbac:groups=permission.cp.ei.telekom.de,resources=permissionsets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=application.cp.ei.telekom.de,resources=applications,verbs=get;list;watch;create;update;patch;delete
@@ -92,6 +96,11 @@ func (r *RoverReconciler) SetupWithManager(mgr ctrl.Manager) error {
Owns(&agenticv1.AgenticSubscription{}, owns)
}
+ if cconfig.FeatureFile.IsEnabled() {
+ b = b.Owns(&filev1.FileExposure{}).
+ Owns(&filev1.FileSubscription{})
+ }
+
b = b.Watches(&organizationv1.Team{},
handler.EnqueueRequestsFromMapFunc(r.MapTeamToRovers),
builder.WithPredicates(cc.Count("rover", cc.RoleWatches, predicate.GenerationChangedPredicate{})),
diff --git a/rover/internal/controller/suite_test.go b/rover/internal/controller/suite_test.go
index 81aaf841f..ee35bec6b 100644
--- a/rover/internal/controller/suite_test.go
+++ b/rover/internal/controller/suite_test.go
@@ -28,6 +28,7 @@ import (
apiapi "github.com/telekom/controlplane/api/api/v1"
applicationv1 "github.com/telekom/controlplane/application/api/v1"
"github.com/telekom/controlplane/common/pkg/test/mock"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
organizationv1 "github.com/telekom/controlplane/organization/api/v1"
roverv1 "github.com/telekom/controlplane/rover/api/v1"
secretsapi "github.com/telekom/controlplane/secret-manager/api"
@@ -81,6 +82,7 @@ var _ = BeforeSuite(func() {
filepath.Join("..", "..", "..", "api", "config", "crd", "bases"),
filepath.Join("..", "..", "..", "application", "config", "crd", "bases"),
filepath.Join("..", "..", "..", "organization", "config", "crd", "bases"),
+ filepath.Join("..", "..", "..", "file", "config", "crd", "bases"),
),
// CRDDirectoryPaths: append(
// testutil.GetCrdPathsOrDie("github.com/telekom/controlplane/(api|application|organization)/api"),
@@ -108,6 +110,9 @@ var _ = BeforeSuite(func() {
err = organizationv1.AddToScheme(scheme.Scheme)
Expect(err).NotTo(HaveOccurred())
+ err = filev1.AddToScheme(scheme.Scheme)
+ Expect(err).NotTo(HaveOccurred())
+
// +kubebuilder:scaffold:scheme
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
diff --git a/rover/internal/handler/filespecification/handler.go b/rover/internal/handler/filespecification/handler.go
new file mode 100644
index 000000000..89cf39dae
--- /dev/null
+++ b/rover/internal/handler/filespecification/handler.go
@@ -0,0 +1,76 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package filespecification
+
+import (
+ "context"
+
+ "github.com/pkg/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+ "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/condition"
+ "github.com/telekom/controlplane/common/pkg/handler"
+ "github.com/telekom/controlplane/common/pkg/types"
+ "github.com/telekom/controlplane/common/pkg/util/labelutil"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ roverv1 "github.com/telekom/controlplane/rover/api/v1"
+)
+
+var _ handler.Handler[*roverv1.FileSpecification] = (*FileSpecificationHandler)(nil)
+
+// FileSpecificationHandler reconciles a rover-domain FileSpecification into a
+// file-domain FileType (mirrors EventSpecificationHandler -> EventType).
+type FileSpecificationHandler struct{}
+
+func (h *FileSpecificationHandler) CreateOrUpdate(ctx context.Context, fileSpec *roverv1.FileSpecification) error {
+ c := client.ClientFromContextOrDie(ctx)
+
+ name := roverv1.MakeFileSpecificationName(fileSpec)
+
+ fileType := &filev1.FileType{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: fileSpec.Namespace,
+ },
+ }
+
+ fileSpec.Status.FileType = *types.ObjectRefFromObject(fileType)
+
+ mutator := func() error {
+ if err := controllerutil.SetControllerReference(fileSpec, fileType, c.Scheme()); err != nil {
+ return errors.Wrap(err, "failed to set controller reference")
+ }
+
+ fileType.Labels = map[string]string{
+ filev1.FileTypeNameLabelKey: labelutil.NormalizeLabelValue(name),
+ filev1.FileTypeNamespaceLabelKey: labelutil.NormalizeLabelValue(fileSpec.Namespace),
+ }
+
+ fileType.Spec = filev1.FileTypeSpec{
+ Description: fileSpec.Spec.Description,
+ }
+ return nil
+ }
+
+ if _, err := c.CreateOrUpdate(ctx, fileType, mutator); err != nil {
+ return errors.Wrap(err, "failed to create or update FileType")
+ }
+
+ if c.AnyChanged() {
+ fileSpec.SetCondition(condition.NewProcessingCondition("Provisioning", "FileType updated"))
+ fileSpec.SetCondition(condition.NewNotReadyCondition("Provisioning", "FileType is not ready"))
+ } else {
+ fileSpec.SetCondition(condition.NewDoneProcessingCondition("FileType created"))
+ fileSpec.SetCondition(condition.NewReadyCondition("Provisioned", "FileType is ready"))
+ }
+
+ return nil
+}
+
+func (h *FileSpecificationHandler) Delete(ctx context.Context, obj *roverv1.FileSpecification) error {
+ return nil
+}
diff --git a/rover/internal/handler/filespecification/handler_test.go b/rover/internal/handler/filespecification/handler_test.go
new file mode 100644
index 000000000..9580bf15b
--- /dev/null
+++ b/rover/internal/handler/filespecification/handler_test.go
@@ -0,0 +1,125 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package filespecification
+
+import (
+ "context"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ commonclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/config"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ roverv1 "github.com/telekom/controlplane/rover/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestFileSpecificationHandler(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "FileSpecification Handler Suite")
+}
+
+const testEnvironment = "test"
+
+var _ = Describe("FileSpecificationHandler", func() {
+ var (
+ ctx context.Context
+ fakeClient ctrlclient.Client
+ handler *FileSpecificationHandler
+ )
+
+ newFileSpec := func(name string) *roverv1.FileSpecification {
+ return &roverv1.FileSpecification{
+ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
+ Spec: roverv1.FileSpecificationSpec{
+ Description: "demo file type",
+ Specification: "file-id-123",
+ StorageType: roverv1.FileStorageTypeSFTP,
+ },
+ }
+ }
+
+ // newContext returns a context carrying a fresh JanitorClient over the shared
+ // fake client, so AnyChanged() reflects only the operations of one reconcile.
+ newContext := func() context.Context {
+ scoped := commonclient.NewScopedClient(fakeClient, testEnvironment)
+ janitor := commonclient.NewJanitorClient(scoped)
+ return commonclient.WithClient(logr.NewContext(ctx, logr.Discard()), janitor)
+ }
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ scheme := runtime.NewScheme()
+ Expect(roverv1.AddToScheme(scheme)).To(Succeed())
+ Expect(filev1.AddToScheme(scheme)).To(Succeed())
+
+ fakeClient = fake.NewClientBuilder().WithScheme(scheme).Build()
+ handler = &FileSpecificationHandler{}
+ })
+
+ getFileType := func(name string) *filev1.FileType {
+ fileType := &filev1.FileType{}
+ Expect(fakeClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, fileType)).To(Succeed())
+ return fileType
+ }
+
+ It("should create a FileType from the FileSpecification and mark it provisioning", func() {
+ fileSpec := newFileSpec("demo-sftp-spec-v1")
+
+ Expect(handler.CreateOrUpdate(newContext(), fileSpec)).To(Succeed())
+
+ fileType := getFileType("demo-sftp-spec-v1")
+ Expect(fileType.Spec.Description).To(Equal("demo file type"))
+ Expect(fileType.Labels).To(HaveKey(filev1.FileTypeNameLabelKey))
+ Expect(fileType.Labels).To(HaveKeyWithValue(config.EnvironmentLabelKey, testEnvironment))
+ Expect(fileType.OwnerReferences).To(HaveLen(1))
+ Expect(fileType.OwnerReferences[0].Name).To(Equal("demo-sftp-spec-v1"))
+
+ // Status references the created FileType.
+ Expect(fileSpec.Status.FileType.Name).To(Equal("demo-sftp-spec-v1"))
+ Expect(fileSpec.Status.FileType.Namespace).To(Equal("default"))
+
+ // First reconcile changed the cluster, so it is not yet ready.
+ ready := meta.FindStatusCondition(fileSpec.Status.Conditions, "Ready")
+ Expect(ready).NotTo(BeNil())
+ Expect(ready.Status).To(Equal(metav1.ConditionFalse))
+ })
+
+ It("should mark the FileSpecification ready when nothing changed (idempotent)", func() {
+ fileSpec := newFileSpec("demo-sftp-spec-v1")
+ Expect(handler.CreateOrUpdate(newContext(), fileSpec)).To(Succeed())
+
+ // Second reconcile with a fresh janitor client: no change expected.
+ fileSpec = newFileSpec("demo-sftp-spec-v1")
+ Expect(handler.CreateOrUpdate(newContext(), fileSpec)).To(Succeed())
+
+ ready := meta.FindStatusCondition(fileSpec.Status.Conditions, "Ready")
+ Expect(ready).NotTo(BeNil())
+ Expect(ready.Status).To(Equal(metav1.ConditionTrue))
+ })
+
+ It("should normalize the FileType name derived from the specification name", func() {
+ fileSpec := newFileSpec("De.Telekom.Foo.v1")
+
+ Expect(handler.CreateOrUpdate(newContext(), fileSpec)).To(Succeed())
+
+ // dots -> hyphens and lower-cased.
+ getFileType("de-telekom-foo-v1")
+ Expect(fileSpec.Status.FileType.Name).To(Equal("de-telekom-foo-v1"))
+ })
+
+ It("should return nil on Delete", func() {
+ Expect(handler.Delete(newContext(), newFileSpec("demo-sftp-spec-v1"))).To(Succeed())
+ })
+})
diff --git a/rover/internal/handler/rover/application/application.go b/rover/internal/handler/rover/application/application.go
index 215e11cff..588fafcd7 100644
--- a/rover/internal/handler/rover/application/application.go
+++ b/rover/internal/handler/rover/application/application.go
@@ -46,13 +46,7 @@ func HandleApplication(ctx context.Context, c client.JanitorClient, owner *rover
return err
}
- // If the Application publishes any events, we need to create a client for it, even if it doesn't have any subscriptions.
- // This is because the client is needed to access the publish-route
- hasAnyEventExposures := slices.ContainsFunc(owner.Spec.Exposures, func(ex roverv1.Exposure) bool {
- return ex.Type() == roverv1.TypeEvent
- })
-
- needsClient := len(owner.Spec.Subscriptions) > 0 || hasAnyEventExposures
+ needsClient := isClientNeeded(owner)
var hasAnySubscriptionFailoverEnabled bool
if needsClient {
@@ -131,3 +125,17 @@ func HandleApplication(ctx context.Context, c client.JanitorClient, owner *rover
return err
}
+
+// isClientNeeded reports whether the derived Application requires an Identity
+// client (and Gateway consumer). Non-file subscriptions and event exposures
+// (which need client access to the publish-route) require one; file-type (SFTP)
+// exposures and subscriptions never do, as they are realized in the file domain.
+func isClientNeeded(owner *roverv1.Rover) bool {
+ hasAnyEventExposures := slices.ContainsFunc(owner.Spec.Exposures, func(ex roverv1.Exposure) bool {
+ return ex.Type() == roverv1.TypeEvent
+ })
+ hasNonFileSubscriptions := slices.ContainsFunc(owner.Spec.Subscriptions, func(sub roverv1.Subscription) bool {
+ return sub.Type() != roverv1.TypeFile
+ })
+ return hasNonFileSubscriptions || hasAnyEventExposures
+}
diff --git a/rover/internal/handler/rover/application/application_test.go b/rover/internal/handler/rover/application/application_test.go
new file mode 100644
index 000000000..121e7cab8
--- /dev/null
+++ b/rover/internal/handler/rover/application/application_test.go
@@ -0,0 +1,56 @@
+// Copyright 2025 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package application
+
+import (
+ "testing"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ roverv1 "github.com/telekom/controlplane/rover/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestApplication(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Application Handler Suite")
+}
+
+var _ = Describe("RoverNeedsClient", func() {
+ newRover := func(exps []roverv1.Exposure, subs []roverv1.Subscription) *roverv1.Rover {
+ return &roverv1.Rover{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-rover", Namespace: "test--eni--hyperion"},
+ Spec: roverv1.RoverSpec{Exposures: exps, Subscriptions: subs},
+ }
+ }
+
+ // The concrete field values are irrelevant: Subscription.Type()/Exposure.Type()
+ // dispatch only on which pointer is non-nil.
+ apiSub := roverv1.Subscription{Api: &roverv1.ApiSubscription{}}
+ eventSub := roverv1.Subscription{Event: &roverv1.EventSubscription{}}
+ fileSub := roverv1.Subscription{File: &roverv1.FileSubscription{}}
+ eventExp := roverv1.Exposure{Event: &roverv1.EventExposure{}}
+ fileExp := roverv1.Exposure{File: &roverv1.FileExposure{}}
+
+ DescribeTable("decides whether the derived Application requires an Identity client",
+ func(exps []roverv1.Exposure, subs []roverv1.Subscription, expected bool) {
+ Expect(isClientNeeded(newRover(exps, subs))).To(Equal(expected))
+ },
+ // Logical Application (file-only or empty) => no client/consumer.
+ Entry("empty rover", nil, nil, false),
+ Entry("file-only subscription", nil, []roverv1.Subscription{fileSub}, false),
+ Entry("file-only exposure", []roverv1.Exposure{fileExp}, nil, false),
+ Entry("file exposure + file subscription", []roverv1.Exposure{fileExp}, []roverv1.Subscription{fileSub}, false),
+ // Non-file subscription or any event exposure => needs client.
+ Entry("api subscription", nil, []roverv1.Subscription{apiSub}, true),
+ Entry("event subscription", nil, []roverv1.Subscription{eventSub}, true),
+ Entry("event exposure", []roverv1.Exposure{eventExp}, nil, true),
+ // Mixed: file plus a non-file entry still forces a client (story edge case).
+ Entry("mixed file + api subscription", nil, []roverv1.Subscription{fileSub, apiSub}, true),
+ Entry("file subscription + event exposure", []roverv1.Exposure{eventExp}, []roverv1.Subscription{fileSub}, true),
+ )
+})
diff --git a/rover/internal/handler/rover/file/exposure.go b/rover/internal/handler/rover/file/exposure.go
new file mode 100644
index 000000000..0879fd4e0
--- /dev/null
+++ b/rover/internal/handler/rover/file/exposure.go
@@ -0,0 +1,77 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package file
+
+import (
+ "context"
+
+ "github.com/pkg/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+
+ "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/config"
+ "github.com/telekom/controlplane/common/pkg/types"
+ "github.com/telekom/controlplane/common/pkg/util/contextutil"
+ "github.com/telekom/controlplane/common/pkg/util/labelutil"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ roverv1 "github.com/telekom/controlplane/rover/api/v1"
+)
+
+// HandleExposure creates or updates a file-domain FileExposure owned by the Rover.
+func HandleExposure(ctx context.Context, c client.JanitorClient, owner *roverv1.Rover, exp *roverv1.FileExposure) error {
+ logger := log.FromContext(ctx)
+ logger.V(1).Info("Handle FileExposure", "fileType", exp.FileType)
+
+ name := MakeName(exp.FileType, owner.Name)
+
+ fileExposure := &filev1.FileExposure{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: labelutil.NormalizeNameValue(name),
+ Namespace: owner.Namespace,
+ },
+ }
+
+ environment := contextutil.EnvFromContextOrDie(ctx)
+ zoneRef := types.ObjectRef{
+ Name: owner.Spec.Zone,
+ Namespace: environment,
+ }
+
+ mutator := func() error {
+ if err := controllerutil.SetControllerReference(owner, fileExposure, c.Scheme()); err != nil {
+ return errors.Wrap(err, "failed to set controller reference")
+ }
+
+ fileExposure.Labels = map[string]string{
+ filev1.FileTypeNameLabelKey: labelutil.NormalizeLabelValue(FileTypeRefName(exp.FileType)),
+ filev1.FileTypeNamespaceLabelKey: labelutil.NormalizeLabelValue(owner.Namespace),
+ config.BuildLabelKey("zone"): labelutil.NormalizeLabelValue(zoneRef.Name),
+ config.BuildLabelKey("application"): labelutil.NormalizeLabelValue(owner.Name),
+ }
+
+ fileExposure.Spec = filev1.FileExposureSpec{
+ Approval: filev1.Approval{Strategy: filev1.ApprovalStrategy(exp.Approval.Strategy)},
+ Visibility: filev1.Visibility(exp.Visibility.String()),
+ FileType: FileTypeRefName(exp.FileType),
+ SFTP: &filev1.FileSFTP{
+ PublicKeys: mapPublicKeys(exp.PublicKeys),
+ },
+ Zone: &zoneRef,
+ }
+ return nil
+ }
+
+ if _, err := c.CreateOrUpdate(ctx, fileExposure, mutator); err != nil {
+ return errors.Wrap(err, "failed to create or update FileExposure")
+ }
+
+ owner.Status.FileExposures = append(owner.Status.FileExposures, types.ObjectRef{
+ Name: fileExposure.Name,
+ Namespace: fileExposure.Namespace,
+ })
+ return nil
+}
diff --git a/rover/internal/handler/rover/file/handlers_test.go b/rover/internal/handler/rover/file/handlers_test.go
new file mode 100644
index 000000000..20a57a0ff
--- /dev/null
+++ b/rover/internal/handler/rover/file/handlers_test.go
@@ -0,0 +1,134 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package file
+
+import (
+ "context"
+ "testing"
+
+ "github.com/go-logr/logr"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ commonclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/config"
+ "github.com/telekom/controlplane/common/pkg/util/contextutil"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ roverv1 "github.com/telekom/controlplane/rover/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestFileHandlers(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "File Handler Suite")
+}
+
+const (
+ testEnvironment = "test"
+ testZone = "cetus"
+)
+
+var _ = Describe("File Exposure/Subscription Handlers", func() {
+ var (
+ ctx context.Context
+ fakeClient ctrlclient.Client
+ )
+
+ newOwner := func() *roverv1.Rover {
+ return &roverv1.Rover{
+ ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "default"},
+ Spec: roverv1.RoverSpec{Zone: testZone},
+ }
+ }
+
+ newJanitor := func() commonclient.JanitorClient {
+ scoped := commonclient.NewScopedClient(fakeClient, testEnvironment)
+ return commonclient.NewJanitorClient(scoped)
+ }
+
+ BeforeEach(func() {
+ scheme := runtime.NewScheme()
+ Expect(roverv1.AddToScheme(scheme)).To(Succeed())
+ Expect(filev1.AddToScheme(scheme)).To(Succeed())
+
+ fakeClient = fake.NewClientBuilder().WithScheme(scheme).Build()
+ // Env is required by the exposure handler (zone namespace resolution).
+ ctx = contextutil.WithEnv(logr.NewContext(context.Background(), logr.Discard()), testEnvironment)
+ })
+
+ Context("HandleExposure", func() {
+ It("should create a file-domain FileExposure owned by the Rover", func() {
+ owner := newOwner()
+ exp := &roverv1.FileExposure{
+ FileType: "demo-sftp-spec-v1",
+ Visibility: roverv1.VisibilityWorld,
+ Approval: roverv1.Approval{Strategy: roverv1.ApprovalStrategyAuto},
+ PublicKeys: []roverv1.PublicKey{{Label: "provider-key", Key: "ssh-ed25519 AAAAprovider"}},
+ }
+
+ Expect(HandleExposure(ctx, newJanitor(), owner, exp)).To(Succeed())
+
+ name := MakeName(exp.FileType, owner.Name)
+ fileExposure := &filev1.FileExposure{}
+ Expect(fakeClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, fileExposure)).To(Succeed())
+
+ Expect(fileExposure.Spec.FileType).To(Equal("demo-sftp-spec-v1"))
+ Expect(fileExposure.Spec.Visibility).To(Equal(filev1.Visibility("World")))
+ Expect(fileExposure.Spec.Approval.Strategy).To(Equal(filev1.ApprovalStrategy("Auto")))
+ Expect(fileExposure.Spec.SFTP).NotTo(BeNil())
+ Expect(fileExposure.Spec.SFTP.PublicKeys).To(HaveLen(1))
+ Expect(fileExposure.Spec.SFTP.PublicKeys[0].Key).To(Equal("ssh-ed25519 AAAAprovider"))
+ Expect(fileExposure.Spec.Zone).NotTo(BeNil())
+ Expect(fileExposure.Spec.Zone.Name).To(Equal(testZone))
+ Expect(fileExposure.Spec.Zone.Namespace).To(Equal(testEnvironment))
+
+ Expect(fileExposure.Labels).To(HaveKeyWithValue(filev1.FileTypeNameLabelKey, "demo-sftp-spec-v1"))
+ Expect(fileExposure.Labels).To(HaveKeyWithValue(config.BuildLabelKey("application"), "my-app"))
+ Expect(fileExposure.Labels).To(HaveKeyWithValue(config.EnvironmentLabelKey, testEnvironment))
+ Expect(fileExposure.OwnerReferences).To(HaveLen(1))
+ Expect(fileExposure.OwnerReferences[0].Name).To(Equal("my-app"))
+
+ Expect(owner.Status.FileExposures).To(HaveLen(1))
+ Expect(owner.Status.FileExposures[0].Name).To(Equal(name))
+ })
+ })
+
+ Context("HandleSubscription", func() {
+ It("should create a file-domain FileSubscription owned by the Rover", func() {
+ owner := newOwner()
+ sub := &roverv1.FileSubscription{
+ FileType: "demo-sftp-spec-v1",
+ PublicKeys: []roverv1.PublicKey{{Label: "consumer-key", Key: "ssh-ed25519 AAAAconsumer"}},
+ }
+
+ Expect(HandleSubscription(ctx, newJanitor(), owner, sub)).To(Succeed())
+
+ name := MakeName(sub.FileType, owner.Name)
+ fileSubscription := &filev1.FileSubscription{}
+ Expect(fakeClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, fileSubscription)).To(Succeed())
+
+ Expect(fileSubscription.Spec.FileType).To(Equal("demo-sftp-spec-v1"))
+ Expect(fileSubscription.Spec.Zone).NotTo(BeNil())
+ Expect(fileSubscription.Spec.Zone.Name).To(Equal(testZone))
+ Expect(fileSubscription.Spec.Zone.Namespace).To(Equal(testEnvironment))
+ Expect(fileSubscription.Spec.SFTP).NotTo(BeNil())
+ Expect(fileSubscription.Spec.SFTP.PublicKeys).To(HaveLen(1))
+ Expect(fileSubscription.Spec.SFTP.PublicKeys[0].Key).To(Equal("ssh-ed25519 AAAAconsumer"))
+
+ Expect(fileSubscription.Labels).To(HaveKeyWithValue(filev1.FileTypeNameLabelKey, "demo-sftp-spec-v1"))
+ Expect(fileSubscription.Labels).To(HaveKeyWithValue(config.BuildLabelKey("zone"), testZone))
+ Expect(fileSubscription.OwnerReferences).To(HaveLen(1))
+ Expect(fileSubscription.OwnerReferences[0].Name).To(Equal("my-app"))
+
+ Expect(owner.Status.FileSubscriptions).To(HaveLen(1))
+ Expect(owner.Status.FileSubscriptions[0].Name).To(Equal(name))
+ })
+ })
+})
diff --git a/rover/internal/handler/rover/file/subscription.go b/rover/internal/handler/rover/file/subscription.go
new file mode 100644
index 000000000..b95ee71fe
--- /dev/null
+++ b/rover/internal/handler/rover/file/subscription.go
@@ -0,0 +1,75 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package file
+
+import (
+ "context"
+
+ "github.com/pkg/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+
+ "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/config"
+ "github.com/telekom/controlplane/common/pkg/types"
+ "github.com/telekom/controlplane/common/pkg/util/contextutil"
+ "github.com/telekom/controlplane/common/pkg/util/labelutil"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ roverv1 "github.com/telekom/controlplane/rover/api/v1"
+)
+
+// HandleSubscription creates or updates a file-domain FileSubscription owned by the Rover.
+func HandleSubscription(ctx context.Context, c client.JanitorClient, owner *roverv1.Rover, sub *roverv1.FileSubscription) error {
+ logger := log.FromContext(ctx)
+ logger.V(1).Info("Handle FileSubscription", "fileType", sub.FileType)
+
+ name := MakeName(sub.FileType, owner.Name)
+
+ fileSubscription := &filev1.FileSubscription{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: labelutil.NormalizeNameValue(name),
+ Namespace: owner.Namespace,
+ },
+ }
+
+ environment := contextutil.EnvFromContextOrDie(ctx)
+ zoneRef := types.ObjectRef{
+ Name: owner.Spec.Zone,
+ Namespace: environment,
+ }
+
+ mutator := func() error {
+ if err := controllerutil.SetControllerReference(owner, fileSubscription, c.Scheme()); err != nil {
+ return errors.Wrap(err, "failed to set controller reference")
+ }
+
+ fileSubscription.Labels = map[string]string{
+ filev1.FileTypeNameLabelKey: labelutil.NormalizeLabelValue(FileTypeRefName(sub.FileType)),
+ filev1.FileTypeNamespaceLabelKey: labelutil.NormalizeLabelValue(owner.Namespace),
+ config.BuildLabelKey("zone"): labelutil.NormalizeLabelValue(owner.Spec.Zone),
+ config.BuildLabelKey("application"): labelutil.NormalizeLabelValue(owner.Name),
+ }
+
+ fileSubscription.Spec = filev1.FileSubscriptionSpec{
+ FileType: FileTypeRefName(sub.FileType),
+ Zone: &zoneRef,
+ SFTP: &filev1.FileSFTP{
+ PublicKeys: mapPublicKeys(sub.PublicKeys),
+ },
+ }
+ return nil
+ }
+
+ if _, err := c.CreateOrUpdate(ctx, fileSubscription, mutator); err != nil {
+ return errors.Wrap(err, "failed to create or update FileSubscription")
+ }
+
+ owner.Status.FileSubscriptions = append(owner.Status.FileSubscriptions, types.ObjectRef{
+ Name: fileSubscription.Name,
+ Namespace: fileSubscription.Namespace,
+ })
+ return nil
+}
diff --git a/rover/internal/handler/rover/file/util.go b/rover/internal/handler/rover/file/util.go
new file mode 100644
index 000000000..346f3e3d4
--- /dev/null
+++ b/rover/internal/handler/rover/file/util.go
@@ -0,0 +1,30 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package file
+
+import (
+ "github.com/telekom/controlplane/common/pkg/util/labelutil"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
+ roverv1 "github.com/telekom/controlplane/rover/api/v1"
+)
+
+func FileTypeRefName(fileType string) string {
+ return roverv1.MakeFileTypeName(fileType)
+}
+
+func MakeName(fileType, ownerName string) string {
+ return FileTypeRefName(fileType) + "--" + labelutil.NormalizeValue(ownerName)
+}
+
+func mapPublicKeys(in []roverv1.PublicKey) []filev1.SSHPublicKeySpec {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make([]filev1.SSHPublicKeySpec, len(in))
+ for i, k := range in {
+ out[i] = filev1.SSHPublicKeySpec{Key: k.Key}
+ }
+ return out
+}
diff --git a/rover/internal/handler/rover/file/util_test.go b/rover/internal/handler/rover/file/util_test.go
new file mode 100644
index 000000000..0a23dba5c
--- /dev/null
+++ b/rover/internal/handler/rover/file/util_test.go
@@ -0,0 +1,45 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package file
+
+import (
+ roverv1 "github.com/telekom/controlplane/rover/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("MakeName", func() {
+ DescribeTable("normalizes file type and owner into a resource name",
+ func(fileType, owner, want string) {
+ Expect(MakeName(fileType, owner)).To(Equal(want))
+ },
+ Entry("hyphenated file type", "de-telekom-eni-foo-v1", "provider", "de-telekom-eni-foo-v1--provider"),
+ Entry("dotted file type is normalized", "de.telekom.foo.v1", "consumer", "de-telekom-foo-v1--consumer"),
+ Entry("mixed case is lowercased", "De.Telekom.V1", "app", "de-telekom-v1--app"),
+ Entry("owner name is normalized", "de.telekom.foo.v1", "My_App", "de-telekom-foo-v1--my-app"),
+ )
+})
+
+var _ = Describe("mapPublicKeys", func() {
+ It("yields nil for nil input", func() {
+ Expect(mapPublicKeys(nil)).To(BeNil())
+ })
+
+ It("yields nil for an empty slice", func() {
+ Expect(mapPublicKeys([]roverv1.PublicKey{})).To(BeNil())
+ })
+
+ It("maps keys preserving order (label is dropped: file domain tracks only the key)", func() {
+ in := []roverv1.PublicKey{
+ {Label: "provider-key", Key: "ssh-ed25519 AAAA"},
+ {Label: "consumer-key", Key: "ssh-ed25519 BBBB"},
+ }
+ got := mapPublicKeys(in)
+ Expect(got).To(HaveLen(2))
+ Expect(got[0].Key).To(Equal("ssh-ed25519 AAAA"))
+ Expect(got[1].Key).To(Equal("ssh-ed25519 BBBB"))
+ })
+})
diff --git a/rover/internal/handler/rover/handler.go b/rover/internal/handler/rover/handler.go
index a8fd739f5..f88ff969b 100644
--- a/rover/internal/handler/rover/handler.go
+++ b/rover/internal/handler/rover/handler.go
@@ -21,12 +21,14 @@ import (
"github.com/telekom/controlplane/common/pkg/types"
"github.com/telekom/controlplane/common/pkg/util/contextutil"
eventv1 "github.com/telekom/controlplane/event/api/v1"
+ filev1 "github.com/telekom/controlplane/file/api/v1"
permissionv1 "github.com/telekom/controlplane/permission/api/v1"
roverv1 "github.com/telekom/controlplane/rover/api/v1"
"github.com/telekom/controlplane/rover/internal/handler/rover/agentic"
"github.com/telekom/controlplane/rover/internal/handler/rover/api"
"github.com/telekom/controlplane/rover/internal/handler/rover/application"
"github.com/telekom/controlplane/rover/internal/handler/rover/event"
+ "github.com/telekom/controlplane/rover/internal/handler/rover/file"
"github.com/telekom/controlplane/rover/internal/handler/rover/permission"
secretsapi "github.com/telekom/controlplane/secret-manager/api"
)
@@ -76,6 +78,10 @@ func addKnownTypes(c client.JanitorClient) {
if config.FeaturePermission.IsEnabled() {
c.AddKnownTypeToState(&permissionv1.PermissionSet{})
}
+ if config.FeatureFile.IsEnabled() {
+ c.AddKnownTypeToState(&filev1.FileExposure{})
+ c.AddKnownTypeToState(&filev1.FileSubscription{})
+ }
if config.FeatureAiGateway.IsEnabled() {
c.AddKnownTypeToState(&agenticv1.AgenticExposure{})
c.AddKnownTypeToState(&agenticv1.AgenticSubscription{})
@@ -86,6 +92,7 @@ func (h *RoverHandler) handleExposures(ctx context.Context, c client.JanitorClie
roverObj.Status.ApiExposures = make([]types.ObjectRef, 0, len(roverObj.Spec.Exposures))
roverObj.Status.EventExposures = make([]types.ObjectRef, 0, len(roverObj.Spec.Exposures))
roverObj.Status.AgenticExposures = make([]types.ObjectRef, 0, len(roverObj.Spec.Exposures))
+ roverObj.Status.FileExposures = make([]types.ObjectRef, 0, len(roverObj.Spec.Exposures))
seenDiscriminators := make(map[string]struct{})
for _, exp := range roverObj.Spec.Exposures {
@@ -128,10 +135,19 @@ func (h *RoverHandler) handleExposure(ctx context.Context, c client.JanitorClien
if err := agentic.HandleExposure(ctx, c, roverObj, exp.Agentic); err != nil {
return errors.Wrap(err, "failed to handle AI exposure")
}
+ case roverv1.TypeFile:
+ // Duplicate file types are rejected by the Rover admission webhook
+ if !config.FeatureFile.IsEnabled() {
+ logger.Info("file exposure skipped, feature has not been enabled")
+ return nil
+ }
+ if err := file.HandleExposure(ctx, c, roverObj, exp.File); err != nil {
+ return errors.Wrap(err, "failed to handle file exposure")
+ }
+
default:
return errors.New("unknown exposure type: " + exp.Type().String())
}
-
return nil
}
@@ -140,6 +156,7 @@ func (h *RoverHandler) handleSubscriptions(ctx context.Context, c client.Janitor
roverObj.Status.EventSubscriptions = make([]types.ObjectRef, 0, len(roverObj.Spec.Subscriptions))
roverObj.Status.AgenticSubscriptions = make([]types.ObjectRef, 0, len(roverObj.Spec.Subscriptions))
+ roverObj.Status.FileSubscriptions = make([]types.ObjectRef, 0, len(roverObj.Spec.Subscriptions))
for _, sub := range roverObj.Spec.Subscriptions {
if err := h.handleSubscription(ctx, c, roverObj, sub, logger); err != nil {
return err
@@ -171,10 +188,19 @@ func (h *RoverHandler) handleSubscription(ctx context.Context, c client.JanitorC
if err := agentic.HandleSubscription(ctx, c, roverObj, sub.Agentic); err != nil {
return errors.Wrap(err, "failed to handle AI subscription")
}
+
+ case roverv1.TypeFile:
+ if !config.FeatureFile.IsEnabled() {
+ logger.Info("file subscription skipped, feature has not been enabled")
+ return nil
+ }
+ if err := file.HandleSubscription(ctx, c, roverObj, sub.File); err != nil {
+ return errors.Wrap(err, "failed to handle file subscription")
+ }
+
default:
return errors.New("unknown subscription type: " + sub.Type().String())
}
-
return nil
}
diff --git a/rover/internal/webhook/v1/filespecification_webhook.go b/rover/internal/webhook/v1/filespecification_webhook.go
new file mode 100644
index 000000000..e24aabd55
--- /dev/null
+++ b/rover/internal/webhook/v1/filespecification_webhook.go
@@ -0,0 +1,71 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import (
+ "context"
+ "fmt"
+
+ "k8s.io/apimachinery/pkg/util/validation/field"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+ "github.com/telekom/controlplane/common/pkg/controller"
+ cerrors "github.com/telekom/controlplane/common/pkg/errors"
+ roverv1 "github.com/telekom/controlplane/rover/api/v1"
+)
+
+// SetupFileSpecificationWebhookWithManager registers the webhook for FileSpecification in the manager.
+func SetupFileSpecificationWebhookWithManager(mgr ctrl.Manager) error {
+ return ctrl.NewWebhookManagedBy(mgr, &roverv1.FileSpecification{}).
+ WithValidator(&FileSpecificationCustomValidator{client: mgr.GetClient()}).
+ Complete()
+}
+
+// +kubebuilder:webhook:path=/validate-rover-cp-ei-telekom-de-v1-filespecification,mutating=false,failurePolicy=fail,sideEffects=None,groups=rover.cp.ei.telekom.de,resources=filespecifications,verbs=create;update,versions=v1,name=vfilespecification-v1.kb.io,admissionReviewVersions=v1
+
+type FileSpecificationCustomValidator struct {
+ client client.Client
+}
+
+var _ admission.Validator[*roverv1.FileSpecification] = &FileSpecificationCustomValidator{}
+
+// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type FileSpecification.
+func (v *FileSpecificationCustomValidator) ValidateCreate(ctx context.Context, filespecification *roverv1.FileSpecification) (admission.Warnings, error) {
+ return v.ValidateCreateOrUpdate(ctx, filespecification)
+}
+
+// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type FileSpecification.
+func (v *FileSpecificationCustomValidator) ValidateUpdate(ctx context.Context, _, filespecification *roverv1.FileSpecification) (admission.Warnings, error) {
+ return v.ValidateCreateOrUpdate(ctx, filespecification)
+}
+
+// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type FileSpecification.
+func (v *FileSpecificationCustomValidator) ValidateDelete(ctx context.Context, filespecification *roverv1.FileSpecification) (admission.Warnings, error) {
+ return nil, nil
+}
+
+func (v *FileSpecificationCustomValidator) ValidateCreateOrUpdate(ctx context.Context, filespecification *roverv1.FileSpecification) (admission.Warnings, error) {
+ if controller.IsBeingDeleted(filespecification) {
+ return nil, nil
+ }
+
+ valErr := cerrors.NewValidationError(roverv1.GroupVersion.WithKind("FileSpecification").GroupKind(), filespecification)
+
+ // storageType, when set, must be a supported backend (currently only "sftp").
+ // The file type identifier lives in metadata.name (no spec.type field in the
+ // internal CRD, per spec_dcp); the client-side name==type rule is enforced by
+ // rover-server / roverctl.
+ if st := filespecification.Spec.StorageType; st != "" && st != roverv1.FileStorageTypeSFTP {
+ valErr.AddInvalidError(
+ field.NewPath("spec").Child("storageType"),
+ string(st),
+ fmt.Sprintf("spec.storageType must be %q", roverv1.FileStorageTypeSFTP),
+ )
+ }
+
+ return valErr.BuildWarnings(), valErr.BuildError()
+}
diff --git a/rover/internal/webhook/v1/filespecification_webhook_test.go b/rover/internal/webhook/v1/filespecification_webhook_test.go
new file mode 100644
index 000000000..0351a7147
--- /dev/null
+++ b/rover/internal/webhook/v1/filespecification_webhook_test.go
@@ -0,0 +1,275 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/rsa"
+ "strings"
+
+ "golang.org/x/crypto/ssh"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+
+ cerrors "github.com/telekom/controlplane/common/pkg/errors"
+ roverv1 "github.com/telekom/controlplane/rover/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("File Type (SFTP) Validation", func() {
+ newValErr := func() *cerrors.ValidationError {
+ return cerrors.NewValidationError(roverv1.GroupVersion.WithKind("Rover").GroupKind(), NewRover(testZone))
+ }
+
+ Context("validateFilePublicKeys", func() {
+ filePath := field.NewPath("spec").Child("exposures").Index(0).Child("file")
+
+ It("should require at least one public key", func() {
+ valErr := newValErr()
+ validateFilePublicKeys(valErr, nil, filePath)
+ err := valErr.BuildError()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("at least one public key must be specified"))
+ })
+
+ It("should accept unique labels and key values", func() {
+ valErr := newValErr()
+ keys := []roverv1.PublicKey{
+ {Label: "provider-key", Key: newED25519Key()},
+ {Label: "consumer-key", Key: newED25519Key()},
+ }
+ validateFilePublicKeys(valErr, keys, filePath)
+ Expect(valErr.BuildError()).NotTo(HaveOccurred())
+ })
+
+ It("should reject duplicate public key labels per fileType", func() {
+ valErr := newValErr()
+ keys := []roverv1.PublicKey{
+ {Label: "dup", Key: newED25519Key()},
+ {Label: "dup", Key: newED25519Key()},
+ }
+ validateFilePublicKeys(valErr, keys, filePath)
+ err := valErr.BuildError()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("labels must be unique per fileType"))
+ })
+
+ It("should reject duplicate public key values per fileType", func() {
+ valErr := newValErr()
+ sameKey := newED25519Key()
+ keys := []roverv1.PublicKey{
+ {Label: "key-a", Key: sameKey},
+ {Label: "key-b", Key: sameKey},
+ }
+ validateFilePublicKeys(valErr, keys, filePath)
+ err := valErr.BuildError()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("key values must be unique per fileType"))
+ })
+
+ It("should accept all supported SSH key types", func() {
+ valErr := newValErr()
+ keys := []roverv1.PublicKey{
+ {Label: "rsa-key", Key: newRSAKey()},
+ {Label: "ed25519-key", Key: newED25519Key()},
+ {Label: "ecdsa-key", Key: newECDSAKey(elliptic.P521())},
+ }
+ validateFilePublicKeys(valErr, keys, filePath)
+ Expect(valErr.BuildError()).NotTo(HaveOccurred())
+ })
+
+ It("should reject a malformed key that cannot be parsed", func() {
+ valErr := newValErr()
+ keys := []roverv1.PublicKey{
+ {Label: "bad-key", Key: "ssh-ed25519 not-valid-base64!!"},
+ }
+ validateFilePublicKeys(valErr, keys, filePath)
+ err := valErr.BuildError()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid SSH public key for label 'bad-key'"))
+ })
+
+ It("should reject a well-formed key of an unsupported type", func() {
+ valErr := newValErr()
+ keys := []roverv1.PublicKey{
+ // A valid ECDSA P-256 key parses fine but is not in the allowlist
+ // (only ecdsa-sha2-nistp521 is supported).
+ {Label: "bad-type", Key: newECDSAKey(elliptic.P256())},
+ }
+ validateFilePublicKeys(valErr, keys, filePath)
+ err := valErr.BuildError()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("unsupported key type 'ecdsa-sha2-nistp256' for key labelled 'bad-type'"))
+ })
+ })
+
+ Context("MustNotHaveDuplicates for file types", func() {
+ It("should reject two subscriptions to the same fileType", func() {
+ valErr := newValErr()
+ subs := []roverv1.Subscription{
+ {File: &roverv1.FileSubscription{FileType: "demo-sftp-spec-v1"}},
+ {File: &roverv1.FileSubscription{FileType: "demo-sftp-spec-v1"}},
+ }
+ Expect(MustNotHaveDuplicates(valErr, subs, nil)).To(Succeed())
+ err := valErr.BuildError()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("duplicate subscription for file-type demo-sftp-spec-v1"))
+ })
+
+ It("should reject two exposures of the same fileType", func() {
+ valErr := newValErr()
+ exps := []roverv1.Exposure{
+ {File: &roverv1.FileExposure{FileType: "demo-sftp-spec-v1"}},
+ {File: &roverv1.FileExposure{FileType: "demo-sftp-spec-v1"}},
+ }
+ Expect(MustNotHaveDuplicates(valErr, nil, exps)).To(Succeed())
+ err := valErr.BuildError()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("duplicate exposure for file-type demo-sftp-spec-v1"))
+ })
+ })
+
+ Context("file type zone restriction (Rover webhook)", func() {
+ var validator RoverValidator
+
+ BeforeEach(func() {
+ validator = RoverValidator{client: k8sClient}
+ })
+
+ fileExposure := func() roverv1.Exposure {
+ return roverv1.Exposure{File: &roverv1.FileExposure{
+ FileType: "demo-sftp-spec-v1",
+ PublicKeys: []roverv1.PublicKey{{Label: "provider-key", Key: newED25519Key()}},
+ }}
+ }
+
+ It("should accept a file exposure on a supported zone (cetus)", func() {
+ cetus := NewZone("cetus", testZone.Namespace)
+ CreateZone(ctx, cetus)
+ rover := NewRover(cetus)
+ rover.Spec.Exposures = []roverv1.Exposure{fileExposure()}
+ warnings, err := validator.ValidateCreate(ctx, rover)
+ Expect(warnings).To(BeNil())
+ Expect(err).NotTo(HaveOccurred())
+ })
+ })
+
+ Context("file validation via ValidateCreate (Rover webhook dispatch)", func() {
+ var validator RoverValidator
+
+ BeforeEach(func() {
+ validator = RoverValidator{client: k8sClient}
+ })
+
+ validKey := func(label string) roverv1.PublicKey {
+ return roverv1.PublicKey{Label: label, Key: newED25519Key()}
+ }
+
+ It("should reject a file exposure that has no public keys", func() {
+ rover := NewRover(testZone)
+ rover.Spec.Exposures = []roverv1.Exposure{
+ {File: &roverv1.FileExposure{FileType: "demo-sftp-spec-v1"}},
+ }
+ warnings, err := validator.ValidateCreate(ctx, rover)
+ assertValidationFailedWith(warnings, err, "at least one public key must be specified")
+ })
+
+ It("should reject a file subscription that has no public keys", func() {
+ rover := NewRover(testZone)
+ rover.Spec.Subscriptions = []roverv1.Subscription{
+ {File: &roverv1.FileSubscription{FileType: "demo-sftp-spec-v1"}},
+ }
+ warnings, err := validator.ValidateCreate(ctx, rover)
+ assertValidationFailedWith(warnings, err, "at least one public key must be specified")
+ })
+
+ It("should accept a file exposure and subscription that share the same fileType", func() {
+ rover := NewRover(testZone)
+ rover.Spec.Exposures = []roverv1.Exposure{
+ {File: &roverv1.FileExposure{
+ FileType: "demo-sftp-spec-v1",
+ PublicKeys: []roverv1.PublicKey{validKey("provider-key")},
+ }},
+ }
+ rover.Spec.Subscriptions = []roverv1.Subscription{
+ {File: &roverv1.FileSubscription{
+ FileType: "demo-sftp-spec-v1",
+ PublicKeys: []roverv1.PublicKey{validKey("consumer-key")},
+ }},
+ }
+ warnings, err := validator.ValidateCreate(ctx, rover)
+ Expect(warnings).To(BeNil())
+ Expect(err).NotTo(HaveOccurred())
+ })
+ })
+
+ Context("FileSpecificationCustomValidator", func() {
+ var validator *FileSpecificationCustomValidator
+
+ BeforeEach(func() {
+ validator = &FileSpecificationCustomValidator{client: k8sClient}
+ })
+
+ newFileSpec := func(name string, storageType roverv1.FileStorageType) *roverv1.FileSpecification {
+ return &roverv1.FileSpecification{
+ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
+ Spec: roverv1.FileSpecificationSpec{Description: "demo", StorageType: storageType},
+ }
+ }
+
+ It("should accept a FileSpecification with the sftp storageType", func() {
+ warnings, err := validator.ValidateCreate(ctx, newFileSpec("demo-sftp-spec-v1", roverv1.FileStorageTypeSFTP))
+ Expect(warnings).To(BeNil())
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("should accept a FileSpecification with an empty storageType (defaulted by CRD)", func() {
+ warnings, err := validator.ValidateCreate(ctx, newFileSpec("demo-sftp-spec-v1", ""))
+ Expect(warnings).To(BeNil())
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("should reject a FileSpecification with an unsupported storageType", func() {
+ _, err := validator.ValidateCreate(ctx, newFileSpec("demo-sftp-spec-v1", "s3"))
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("spec.storageType must be"))
+ })
+ })
+})
+
+// mustAuthorizedKey marshals a crypto public key into an SSH authorized-keys line.
+func mustAuthorizedKey(pub crypto.PublicKey) string {
+ sshPub, err := ssh.NewPublicKey(pub)
+ Expect(err).NotTo(HaveOccurred())
+ return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub)))
+}
+
+// newED25519Key generates a fresh, valid ssh-ed25519 authorized-keys entry.
+func newED25519Key() string {
+ pub, _, err := ed25519.GenerateKey(rand.Reader)
+ Expect(err).NotTo(HaveOccurred())
+ return mustAuthorizedKey(pub)
+}
+
+// newRSAKey generates a fresh, valid ssh-rsa authorized-keys entry.
+func newRSAKey() string {
+ priv, err := rsa.GenerateKey(rand.Reader, 2048)
+ Expect(err).NotTo(HaveOccurred())
+ return mustAuthorizedKey(priv.Public())
+}
+
+// newECDSAKey generates a fresh, valid ecdsa-sha2-* authorized-keys entry for the
+// given curve (e.g. elliptic.P521() -> ecdsa-sha2-nistp521).
+func newECDSAKey(curve elliptic.Curve) string {
+ priv, err := ecdsa.GenerateKey(curve, rand.Reader)
+ Expect(err).NotTo(HaveOccurred())
+ return mustAuthorizedKey(priv.Public())
+}
diff --git a/rover/internal/webhook/v1/rover_webhook.go b/rover/internal/webhook/v1/rover_webhook.go
index c6e0829f1..26d09efaa 100644
--- a/rover/internal/webhook/v1/rover_webhook.go
+++ b/rover/internal/webhook/v1/rover_webhook.go
@@ -14,6 +14,7 @@ import (
"github.com/go-logr/logr"
"github.com/pkg/errors"
+ "golang.org/x/crypto/ssh"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/util/validation/field"
ctrl "sigs.k8s.io/controller-runtime"
@@ -330,6 +331,8 @@ func (r *RoverValidator) ValidateExposure(ctx context.Context, valErr *cerrors.V
return r.ValidateEventExposure(ctx, valErr, environment, exposure, zoneRef, idx)
case roverv1.TypeAgentic:
return r.ValidateAiExposure(ctx, valErr, environment, exposure, zoneRef, idx)
+ case roverv1.TypeFile:
+ return r.ValidateFileExposure(valErr, exposure, idx)
default:
valErr.AddInvalidError(
field.NewPath("spec").Child("exposures").Index(idx).Child("type"),
@@ -456,6 +459,8 @@ func CheckWeightSetOnAllOrNone(upstreams []roverv1.Upstream) (allSet, noneSet bo
}
// MustNotHaveDuplicates checks if there are no duplicates in the subscriptions and exposures
+//
+//nolint:dupl // subscription and exposure loops mirror each other but operate on different types
func MustNotHaveDuplicates(valErr *cerrors.ValidationError, subs []roverv1.Subscription, exps []roverv1.Exposure) error {
if len(subs) == 0 && len(exps) == 0 {
return nil // No subscriptions or exposures, no duplicates to check
@@ -496,6 +501,16 @@ func MustNotHaveDuplicates(valErr *cerrors.ValidationError, subs []roverv1.Subsc
fmt.Sprintf("duplicate subscription for agentic base path %s", sub.Agentic.BasePath),
)
}
+
+ if sub.File != nil {
+ if _, exists := existingSubs[sub.File.FileType]; exists {
+ valErr.AddInvalidError(
+ field.NewPath("spec").Child("subscriptions").Index(idx).Child("file").Child("fileType"),
+ sub.File.FileType, fmt.Sprintf("duplicate subscription for file-type %s", sub.File.FileType),
+ )
+ }
+ existingSubs[sub.File.FileType] = true
+ }
}
existingExps := make(map[string]bool)
@@ -526,6 +541,16 @@ func MustNotHaveDuplicates(valErr *cerrors.ValidationError, subs []roverv1.Subsc
fmt.Sprintf("duplicate exposure for agentic base path %s", exposure.Agentic.BasePath),
)
}
+
+ if exposure.File != nil {
+ if _, exists := existingExps[exposure.File.FileType]; exists {
+ valErr.AddInvalidError(
+ field.NewPath("spec").Child("exposures").Index(idx).Child("file").Child("fileType"),
+ exposure.File.FileType, fmt.Sprintf("duplicate exposure for file-type %s", exposure.File.FileType),
+ )
+ }
+ existingExps[exposure.File.FileType] = true
+ }
}
return nil
@@ -774,7 +799,85 @@ func (r *RoverValidator) ValidateSubscription(ctx context.Context, valErr *cerro
return nil
case roverv1.TypeAgentic:
return nil // AI subscriptions have no special validation at this time
+
+ case roverv1.TypeFile:
+ return r.ValidateFileSubscription(valErr, sub, idx)
}
return nil
}
+
+func (r *RoverValidator) ValidateFileExposure(valErr *cerrors.ValidationError, exposure roverv1.Exposure, idx int) error {
+ if exposure.File == nil {
+ return nil
+ }
+ validateFilePublicKeys(valErr, exposure.File.PublicKeys, field.NewPath("spec").Child("exposures").Index(idx).Child("file"))
+ return nil
+}
+
+func (r *RoverValidator) ValidateFileSubscription(valErr *cerrors.ValidationError, sub roverv1.Subscription, idx int) error {
+ if sub.File == nil {
+ return nil
+ }
+
+ validateFilePublicKeys(valErr, sub.File.PublicKeys, field.NewPath("spec").Child("subscriptions").Index(idx).Child("file"))
+ return nil
+}
+
+func validateFilePublicKeys(valErr *cerrors.ValidationError, keys []roverv1.PublicKey, filePath *field.Path) {
+ if len(keys) == 0 {
+ valErr.AddRequiredError(filePath.Child("publicKeys"), "at least one public key must be specified")
+ return
+ }
+
+ seenLabels := make(map[string]struct{}, len(keys))
+ seenKeys := make(map[string]struct{}, len(keys))
+ for i, key := range keys {
+ keyPath := filePath.Child("publicKeys").Index(i)
+ if _, exists := seenLabels[key.Label]; exists {
+ valErr.AddInvalidError(
+ keyPath.Child("label"),
+ key.Label,
+ fmt.Sprintf("duplicate public key label '%s'; labels must be unique per fileType", key.Label),
+ )
+ }
+ seenLabels[key.Label] = struct{}{}
+
+ if _, exists := seenKeys[key.Key]; exists {
+ valErr.AddInvalidError(
+ keyPath.Child("key"),
+ key.Label,
+ fmt.Sprintf("duplicate public key value for label '%s'; key values must be unique per fileType", key.Label),
+ )
+ }
+ seenKeys[key.Key] = struct{}{}
+
+ validateSSHPublicKeyFormat(valErr, key, keyPath)
+ }
+}
+
+// validateSSHPublicKeyFormat verifies that a public key value is a well-formed
+// SSH authorized-keys entry (" [comment]") whose algorithm is
+// one of the supported SSHKeyTypes.
+func validateSSHPublicKeyFormat(valErr *cerrors.ValidationError, key roverv1.PublicKey, keyPath *field.Path) {
+ pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(key.Key)))
+ if err != nil {
+ valErr.AddInvalidError(
+ keyPath.Child("key"),
+ key.Key,
+ fmt.Sprintf("invalid SSH public key for label '%s': %v", key.Label, err),
+ )
+ return
+ }
+
+ if !roverv1.SSHKeyType(pub.Type()).IsValid() {
+ valErr.AddInvalidError(
+ keyPath.Child("key"),
+ key.Key,
+ fmt.Sprintf(
+ "unsupported key type '%s' for key labelled '%s'; must be one of %v",
+ pub.Type(), key.Label, roverv1.AllSSHKeyTypes,
+ ),
+ )
+ }
+}
diff --git a/sftp/Makefile b/sftp/Makefile
new file mode 100644
index 000000000..4e0f9e670
--- /dev/null
+++ b/sftp/Makefile
@@ -0,0 +1,260 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# Image URL to use all building/pushing image targets
+IMG ?= controller:latest
+
+# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set)
+ifeq (,$(shell go env GOBIN))
+GOBIN=$(shell go env GOPATH)/bin
+else
+GOBIN=$(shell go env GOBIN)
+endif
+
+# CONTAINER_TOOL defines the container tool to be used for building images.
+# Be aware that the target commands are only tested with Docker which is
+# scaffolded by default. However, you might want to replace it to use other
+# tools. (i.e. podman)
+CONTAINER_TOOL ?= docker
+
+# Setting SHELL to bash allows bash commands to be executed by recipes.
+# Options are set to exit when a recipe line exits non-zero or a piped command fails.
+SHELL = /usr/bin/env bash -o pipefail
+.SHELLFLAGS = -ec
+
+.PHONY: all
+all: build
+
+##@ General
+
+# The help target prints out all targets with their descriptions organized
+# beneath their categories. The categories are represented by '##@' and the
+# target descriptions by '##'. The awk command is responsible for reading the
+# entire set of makefiles included in this invocation, looking for lines of the
+# file as xyz: ## something, and then pretty-format the target and help. Then,
+# if there's a line with ##@ something, that gets pretty-printed as a category.
+# More info on the usage of ANSI control characters for terminal formatting:
+# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters
+# More info on the awk command:
+# http://linuxcommand.org/lc3_adv_awk.php
+
+.PHONY: help
+help: ## Display this help.
+ @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
+
+##@ Development
+
+.PHONY: manifests
+manifests: controller-gen ## Generate ClusterRole and CustomResourceDefinition objects.
+ $(CONTROLLER_GEN) rbac:roleName=manager-role,headerFile="../hack/boilerplate.yaml.txt" crd:headerFile="../hack/boilerplate.yaml.txt" paths="./..." output:crd:artifacts:config=config/crd/bases
+
+.PHONY: generate
+generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations.
+ $(CONTROLLER_GEN) object:headerFile="../hack/boilerplate.go.txt" paths="./..."
+ go generate ./...
+
+.PHONY: fmt
+fmt: ## Run go fmt against code.
+ go fmt ./...
+
+.PHONY: vet
+vet: ## Run go vet against code.
+ go vet ./...
+
+# Packages to test and measure coverage for
+TEST_PACKAGES = ./internal/...
+COVER_PACKAGES = ./internal/...
+
+.PHONY: test
+test: manifests generate fmt vet setup-envtest ## Run tests.
+ KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" gotestsum --format pkgname --jsonfile gotest.log -- $(TEST_PACKAGES) -ginkgo.junit-report=ginkgo-junit.xml -coverprofile cover.out --coverpkg $(COVER_PACKAGES) -race
+
+# TODO(user): To use a different vendor for e2e tests, modify the setup under 'tests/e2e'.
+# The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally.
+# CertManager is installed by default; skip with:
+# - CERT_MANAGER_INSTALL_SKIP=true
+KIND_CLUSTER ?= sftp-test-e2e
+
+.PHONY: setup-test-e2e
+setup-test-e2e: ## Set up a Kind cluster for e2e tests if it does not exist
+ @command -v $(KIND) >/dev/null 2>&1 || { \
+ echo "Kind is not installed. Please install Kind manually."; \
+ exit 1; \
+ }
+ @case "$$($(KIND) get clusters)" in \
+ *"$(KIND_CLUSTER)"*) \
+ echo "Kind cluster '$(KIND_CLUSTER)' already exists. Skipping creation." ;; \
+ *) \
+ echo "Creating Kind cluster '$(KIND_CLUSTER)'..."; \
+ $(KIND) create cluster --name $(KIND_CLUSTER) ;; \
+ esac
+
+.PHONY: test-e2e
+test-e2e: setup-test-e2e manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind.
+ KIND=$(KIND) KIND_CLUSTER=$(KIND_CLUSTER) go test -tags=e2e ./test/e2e/ -v -ginkgo.v
+ $(MAKE) cleanup-test-e2e
+
+.PHONY: cleanup-test-e2e
+cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests
+ @$(KIND) delete cluster --name $(KIND_CLUSTER)
+
+.PHONY: lint
+lint: golangci-lint ## Run golangci-lint linter
+ "$(GOLANGCI_LINT)" run
+
+.PHONY: lint-fix
+lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes
+ "$(GOLANGCI_LINT)" run --fix
+
+.PHONY: lint-config
+lint-config: golangci-lint ## Verify golangci-lint linter configuration
+ "$(GOLANGCI_LINT)" config verify
+
+##@ Build
+
+.PHONY: build
+build: manifests generate fmt vet ## Build manager binary.
+ CGO_ENABLED=0 go build -o bin/manager ./cmd/sftp-operator/main.go
+
+.PHONY: run
+run: manifests generate fmt vet ## Run a controller from your host.
+ go run ./cmd/sftp-operator/main.go
+
+# If you wish to build the manager image targeting other platforms you can use the --platform flag.
+# (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it.
+# More info: https://docs.docker.com/develop/develop-images/build_enhancements/
+.PHONY: docker-build
+docker-build: ## Build docker image with the manager.
+ $(CONTAINER_TOOL) build -t ${IMG} .
+
+.PHONY: docker-push
+docker-push: ## Push docker image with the manager.
+ $(CONTAINER_TOOL) push ${IMG}
+
+# PLATFORMS defines the target platforms for the manager image be built to provide support to multiple
+# architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to:
+# - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/
+# - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/
+# - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=> then the export will fail)
+# To adequately provide solutions that are compatible with multiple platforms, you should consider using this option.
+PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le
+.PHONY: docker-buildx
+docker-buildx: ## Build and push docker image for the manager for cross-platform support
+ # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile
+ sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross
+ - $(CONTAINER_TOOL) buildx create --name sftp-builder
+ $(CONTAINER_TOOL) buildx use sftp-builder
+ - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross .
+ - $(CONTAINER_TOOL) buildx rm sftp-builder
+ rm Dockerfile.cross
+
+.PHONY: build-installer
+build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment.
+ mkdir -p dist
+ cd config/manager && "$(KUSTOMIZE)" edit set image controller=${IMG}
+ "$(KUSTOMIZE)" build config/default > dist/install.yaml
+
+##@ Deployment
+
+ifndef ignore-not-found
+ ignore-not-found = false
+endif
+
+.PHONY: install
+install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config.
+ @out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \
+ if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" apply -f -; else echo "No CRDs to install; skipping."; fi
+
+.PHONY: uninstall
+uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
+ @out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \
+ if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f -; else echo "No CRDs to delete; skipping."; fi
+
+.PHONY: deploy
+deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config.
+ cd config/manager && "$(KUSTOMIZE)" edit set image controller=${IMG}
+ "$(KUSTOMIZE)" build config/default | "$(KUBECTL)" apply -f -
+
+.PHONY: undeploy
+undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
+ "$(KUSTOMIZE)" build config/default | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f -
+
+##@ Dependencies
+
+## Location to install dependencies to
+LOCALBIN ?= $(shell pwd)/bin
+$(LOCALBIN):
+ mkdir -p "$(LOCALBIN)"
+
+## Tool Binaries
+KUBECTL ?= kubectl
+KIND ?= kind
+KUSTOMIZE ?= $(LOCALBIN)/kustomize
+CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen
+ENVTEST ?= $(LOCALBIN)/setup-envtest
+GOLANGCI_LINT = $(LOCALBIN)/golangci-lint
+
+## Tool Versions
+KUSTOMIZE_VERSION ?= v5.7.1
+CONTROLLER_TOOLS_VERSION ?= v0.20.1
+
+#ENVTEST_VERSION is the version of controller-runtime release branch to fetch the envtest setup script (i.e. release-0.20)
+ENVTEST_VERSION ?= $(shell v='$(call gomodver,sigs.k8s.io/controller-runtime)'; \
+ [ -n "$$v" ] || { echo "Set ENVTEST_VERSION manually (controller-runtime replace has no tag)" >&2; exit 1; }; \
+ printf '%s\n' "$$v" | sed -E 's/^v?([0-9]+)\.([0-9]+).*/release-\1.\2/')
+
+#ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31)
+ENVTEST_K8S_VERSION ?= $(shell v='$(call gomodver,k8s.io/api)'; \
+ [ -n "$$v" ] || { echo "Set ENVTEST_K8S_VERSION manually (k8s.io/api replace has no tag)" >&2; exit 1; }; \
+ printf '%s\n' "$$v" | sed -E 's/^v?[0-9]+\.([0-9]+).*/1.\1/')
+
+GOLANGCI_LINT_VERSION ?= v2.11.4
+
+.PHONY: kustomize
+kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary.
+$(KUSTOMIZE): $(LOCALBIN)
+ $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION))
+
+.PHONY: controller-gen
+controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary.
+$(CONTROLLER_GEN): $(LOCALBIN)
+ $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION))
+
+.PHONY: setup-envtest
+setup-envtest: envtest ## Download the binaries required for ENVTEST in the local bin directory.
+ @echo "Setting up envtest binaries for Kubernetes version $(ENVTEST_K8S_VERSION)..."
+ @"$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path || { \
+ echo "Error: Failed to set up envtest binaries for version $(ENVTEST_K8S_VERSION)."; \
+ exit 1; \
+ }
+
+.PHONY: envtest
+envtest: $(ENVTEST) ## Download setup-envtest locally if necessary.
+$(ENVTEST): $(LOCALBIN)
+ $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION))
+
+.PHONY: golangci-lint
+golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary.
+$(GOLANGCI_LINT): $(LOCALBIN)
+ $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION))
+
+# go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist
+# $1 - target path with name of binary
+# $2 - package url which can be installed
+# $3 - specific version of the package
+define go-install-tool
+@[ -f "$(1)-$(3)" ] && [ "$$(readlink -- "$(1)" 2>/dev/null)" = "$(1)-$(3)" ] || { \
+set -e; \
+package=$(2)@$(3) ;\
+echo "Downloading $${package}" ;\
+rm -f "$(1)" ;\
+GOBIN="$(LOCALBIN)" go install $${package} ;\
+mv "$(LOCALBIN)/$$(basename "$(1)")" "$(1)-$(3)" ;\
+} ;\
+ln -sf "$$(realpath "$(1)-$(3)")" "$(1)"
+endef
+
+define gomodver
+$(shell go list -m -f '{{if .Replace}}{{.Replace.Version}}{{else}}{{.Version}}{{end}}' $(1) 2>/dev/null)
+endef
diff --git a/sftp/PROJECT b/sftp/PROJECT
new file mode 100644
index 000000000..1cb1b3eea
--- /dev/null
+++ b/sftp/PROJECT
@@ -0,0 +1,39 @@
+# Code generated by tool. DO NOT EDIT.
+# This file is used to track the info used to scaffold your project
+# and allow the plugins properly work.
+# More info: https://book.kubebuilder.io/reference/project-config.html
+cliVersion: 4.9.0
+domain: ei.telekom.de
+layout:
+- go.kubebuilder.io/v4
+projectName: sftp
+repo: github.com/telekom/controlplane/sftp
+resources:
+- api:
+ crdVersion: v1
+ namespaced: true
+ controller: true
+ domain: cp.ei.telekom.de
+ group: sftp
+ kind: Instance
+ path: github.com/telekom/controlplane/sftp/api/v1
+ version: v1
+- api:
+ crdVersion: v1
+ namespaced: true
+ controller: true
+ domain: cp.ei.telekom.de
+ group: sftp
+ kind: User
+ path: github.com/telekom/controlplane/sftp/api/v1
+ version: v1
+- api:
+ crdVersion: v1
+ namespaced: true
+ controller: true
+ domain: cp.ei.telekom.de
+ group: sftp
+ kind: SFTPServiceConfig
+ path: github.com/telekom/controlplane/sftp/api/v1
+ version: v1
+version: "3"
diff --git a/sftp/PROJECT.license b/sftp/PROJECT.license
new file mode 100644
index 000000000..e60f97577
--- /dev/null
+++ b/sftp/PROJECT.license
@@ -0,0 +1,3 @@
+Copyright 2025 Deutsche Telekom IT GmbH
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/sftp/README.md b/sftp/README.md
new file mode 100644
index 000000000..91384aa20
--- /dev/null
+++ b/sftp/README.md
@@ -0,0 +1,82 @@
+
+
+# SFTP Operator
+
+Kubernetes operator for the **SFTP Service** — manages SSH key-based SFTP access and user provisioning.
+
+## API Group
+
+`sftp.cp.ei.telekom.de/v1`
+
+## Resources
+
+| Kind | Description |
+| ------------------- | --------------------------------------------------------------------------------------- |
+| `Instance` | Represents an SFTP service instance backed by an SFTP service configuration. |
+| `User` | Represents an SFTP user and its SSH public keys. Name pattern: `----`. |
+| `SFTPServiceConfig` | Namespaced configuration for SFTP Tardis API access in a zone. |
+
+## Architecture
+
+The sftp-operator manages three main CRD types:
+
+> [!NOTE]
+> For a detailed architecture diagram, see [docs](./docs/sftp-domain-architecture.md).
+
+### User
+
+- Represents an SFTP user in the system
+- Scoped to a specific Kubernetes namespace
+- References its SFTP instance via `spec.instanceRef`
+- Manages SSH public keys under `spec.sshPublicKeys[]`
+- Public keys are synchronized by the User controller using a per-User client ID
+- Status is set directly by User reconciliation after key synchronization
+
+### Instance
+
+- Represents an SFTP instance used by one or more users
+- References its `SFTPServiceConfig` via `spec.sftpServiceConfigRef`
+- Carries the service-instance description and readiness conditions
+
+### SFTPServiceConfig
+
+- Namespaced configuration resource
+- Provides zone-specific SFTP Tardis API endpoint and OAuth2 client credentials
+- Referenced by Instance resources
+
+## Resource Relationships
+
+```plain
+SFTPServiceConfig (configuration namespace)
+ │
+ └── referenced by Instance.spec.sftpServiceConfigRef
+ │
+ └── referenced by User.spec.instanceRef
+```
+
+## Build & Test
+
+```bash
+# From this directory:
+make build # generate + build
+make test # envtest-based integration tests
+make lint # golangci-lint
+make install # install CRDs into current cluster
+make deploy # deploy controller
+```
+
+## CRD Generation
+
+CRDs are generated from Go type annotations via `controller-gen`. Run `make manifests` to regenerate them into `config/crd/bases/`.
+
+## Sample Manifests
+
+See `config/samples/` for example resources:
+
+- `sftp_v1_instance.yaml` — Example Instance resource
+- `sftp_v1_user.yaml` — Example User resource
+- `sftp_v1_sftpserviceconfig.yaml` — Example SFTPServiceConfig resource
diff --git a/sftp/api/go.mod b/sftp/api/go.mod
new file mode 100644
index 000000000..4cb3d819e
--- /dev/null
+++ b/sftp/api/go.mod
@@ -0,0 +1,72 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+module github.com/telekom/controlplane/sftp/api
+
+go 1.26.5
+
+require (
+ github.com/telekom/controlplane/common v0.0.0
+ k8s.io/apimachinery v0.36.3
+ sigs.k8s.io/controller-runtime v0.24.1
+)
+
+replace github.com/telekom/controlplane/common => ../../common
+
+require (
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/emicklei/go-restful/v3 v3.13.0 // indirect
+ github.com/evanphx/json-patch/v5 v5.9.11 // indirect
+ github.com/fsnotify/fsnotify v1.10.1 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.2 // indirect
+ github.com/go-logr/logr v1.4.4 // indirect
+ github.com/go-openapi/jsonpointer v1.0.0 // indirect
+ github.com/go-openapi/jsonreference v1.0.0 // indirect
+ github.com/go-openapi/swag v0.28.0 // indirect
+ github.com/go-openapi/swag/cmdutils v0.28.0 // indirect
+ github.com/go-openapi/swag/conv v0.28.0 // indirect
+ github.com/go-openapi/swag/fileutils v0.28.0 // indirect
+ github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
+ github.com/go-openapi/swag/loading v0.28.0 // indirect
+ github.com/go-openapi/swag/mangling v0.28.0 // indirect
+ github.com/go-openapi/swag/netutils v0.28.0 // indirect
+ github.com/go-openapi/swag/pools v0.28.0 // indirect
+ github.com/go-openapi/swag/stringutils v0.28.0 // indirect
+ github.com/go-openapi/swag/typeutils v0.28.0 // indirect
+ github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+ github.com/google/gnostic-models v0.7.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/json-iterator/go v1.1.12 // 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/pelletier/go-toml/v2 v2.4.3 // indirect
+ github.com/sagikazarmark/locafero v0.12.0 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/spf13/viper v1.21.0 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
+ go.yaml.in/yaml/v3 v3.0.5 // indirect
+ golang.org/x/net v0.57.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/term v0.45.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
+ k8s.io/api v0.36.3 // indirect
+ k8s.io/client-go v0.36.3 // indirect
+ k8s.io/klog/v2 v2.140.0 // indirect
+ k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
+ k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect
+ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
+ sigs.k8s.io/randfill v1.0.0 // indirect
+ sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
+ sigs.k8s.io/yaml v1.6.0 // indirect
+)
diff --git a/sftp/api/go.sum b/sftp/api/go.sum
new file mode 100644
index 000000000..f5ee51f82
--- /dev/null
+++ b/sftp/api/go.sum
@@ -0,0 +1,182 @@
+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.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
+github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
+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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
+github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
+github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
+github.com/go-logr/logr v1.4.4/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 v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
+github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
+github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
+github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
+github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw=
+github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg=
+github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q=
+github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
+github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8=
+github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k=
+github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU=
+github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
+github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4=
+github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
+github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU=
+github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k=
+github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM=
+github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
+github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k=
+github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
+github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU=
+github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
+github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU=
+github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
+github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY=
+github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
+github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY=
+github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs=
+github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
+github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
+github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
+github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
+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/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
+github.com/google/gnostic-models v0.7.1/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/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
+github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
+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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+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/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.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
+github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
+github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
+github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
+github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
+github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
+github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
+github.com/prometheus/procfs v0.21.0 h1:Qh/e6TlBjZf+XLLqNCqFGmCU6Kj/2Bu7kj3oAc0UnXc=
+github.com/prometheus/procfs v0.21.0/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
+github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+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.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
+gopkg.in/evanphx/json-patch.v4 v4.13.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.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w=
+k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg=
+k8s.io/apiextensions-apiserver v0.36.2 h1:3O5gqOj/dt2XWWbpMe+TXWpE9yU6pjM/tXxtHHJT/K4=
+k8s.io/apiextensions-apiserver v0.36.2/go.mod h1:cL1tBWe8XSaP1H30iWKGo7hf6iAUUUJPEU70dskmAnA=
+k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM=
+k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE=
+k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg=
+k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30=
+k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
+k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
+k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
+k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
+k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
+sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4=
+sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/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.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
+sigs.k8s.io/structured-merge-diff/v6 v6.4.2/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/sftp/api/go.sum.license b/sftp/api/go.sum.license
new file mode 100644
index 000000000..be863cd5c
--- /dev/null
+++ b/sftp/api/go.sum.license
@@ -0,0 +1,3 @@
+Copyright 2026 Deutsche Telekom IT GmbH
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/sftp/api/service/api.yaml b/sftp/api/service/api.yaml
new file mode 100644
index 000000000..59a942cfc
--- /dev/null
+++ b/sftp/api/service/api.yaml
@@ -0,0 +1,275 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+openapi: "3.0.1"
+info:
+ title: "SFTP Tardis API"
+ description: >-
+ The SFTP Tardis API provides comprehensive SFTP user and public key
+ management capabilities for the SFTP platform, enabling secure file transfer
+ operations and user lifecycle management.
+ contact:
+ name: "SFTP Team"
+ url: "https://sftp.telekom.de/contact"
+ email: "SFTP_Support@telekom.de"
+ version: "1.0.0"
+ x-api-category: "other"
+ x-vendor: false
+servers:
+ - url: "https://serverRoot/d17/sftp-rover-api/v1"
+ description: "SFTP Tardis API"
+tags:
+ - name: "SFTP User Management"
+ description: >-
+ APIs for managing SFTP users, including creating, updating, and deleting
+ SFTP user configurations.
+paths:
+ /sftp-user:
+ post:
+ tags:
+ - "SFTP User Management"
+ summary: "Create or update an SFTP user"
+ description: >-
+ Creates a new SFTP user or updates an existing one based on the
+ provided information.
+ operationId: "createOrUpdateSftpUser"
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/RoverSftpUserModel"
+ required: true
+ responses:
+ "200":
+ description: "SFTP user updated successfully"
+ content:
+ application/json: {}
+ "201":
+ description: "SFTP user created successfully"
+ content:
+ application/json: {}
+ "400":
+ description: "Bad Request - Invalid SFTP user data"
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiErrorResponse"
+ "500":
+ description: "Internal Server Error - An unexpected error occurred"
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiErrorResponse"
+ /sftp-user/{sftpUserName}/keys:
+ post:
+ tags:
+ - "SFTP User Management"
+ summary: "Update public keys for an SFTP user"
+ description: >-
+ Updates public keys for the specified SFTP user. The SFTP user
+ must exist in the system.
+ operationId: "updatePublicKeysForSftpUser"
+ parameters:
+ - name: "sftpUserName"
+ in: "path"
+ description: "The SFTP username to create keys for"
+ required: true
+ schema:
+ maxLength: 100
+ minLength: 0
+ type: "string"
+ example: "john_doe"
+ - name: "clientId"
+ in: "query"
+ description: "The client ID associated with the public keys"
+ required: true
+ schema:
+ maxLength: 128
+ minLength: 1
+ type: "string"
+ example: "client123"
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ClientPublicKeyMap"
+ required: true
+ responses:
+ "200":
+ description: "Public keys updated successfully"
+ content:
+ application/json: {}
+ "400":
+ description: "Bad Request - Invalid public key data"
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiErrorResponse"
+ "404":
+ description: "Not Found - The specified SFTP user does not exist"
+ content:
+ application/json: {}
+ "500":
+ description: "Internal Server Error - An unexpected error occurred"
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiErrorResponse"
+ /sftp-user/{sftpUserName}:
+ delete:
+ tags:
+ - "SFTP User Management"
+ summary: "Delete an SFTP user"
+ description: "Deletes an existing SFTP user based on the provided username."
+ operationId: "deleteSftpUser"
+ parameters:
+ - name: "sftpUserName"
+ in: "path"
+ description: "The SFTP username to delete"
+ required: true
+ schema:
+ type: "string"
+ responses:
+ "200":
+ description: "SFTP user deleted successfully or no user can be found"
+ "400":
+ description: "Bad Request - Invalid SFTP username"
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiErrorResponse"
+ "500":
+ description: "Internal Server Error - An unexpected error occurred"
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiErrorResponse"
+components:
+ schemas:
+ RoverSftpUserModel:
+ required:
+ - "sftpUserName"
+ type: "object"
+ properties:
+ sftpUserName:
+ pattern: "^[a-zA-Z0-9_-]+$"
+ type: "string"
+ description: "The SFTP username"
+ example: "john_doe"
+ description:
+ type: "string"
+ description: "Description of the SFTP user"
+ example: "This is the SFTP user for project X"
+ horizonNotificationEvents:
+ type: "array"
+ description: "List of horizon events for notification"
+ example:
+ - "upload"
+ - "delete"
+ items:
+ type: "string"
+ description: "Event type for notification"
+ enum:
+ - "upload"
+ - "delete"
+ - "download"
+ - "rename"
+ description: "Model containing the SFTP user data"
+ ApiErrorResponse:
+ type: "object"
+ properties:
+ type:
+ type: "string"
+ description: "Type of the error, typically used to categorize the error"
+ example: "about:blank"
+ title:
+ type: "string"
+ description: "Title or short summary of the error"
+ example: "Required parameters are missing or contain invalid values"
+ detail:
+ type: "string"
+ description: "Detailed description of the error"
+ example: "One or more fields contain invalid values"
+ timestamp:
+ type: "string"
+ description: "Timestamp when the error occurred, as returned by the SFTP Tardis API"
+ example: "2026-06-18T11:15:02.096673893"
+ status:
+ type: "integer"
+ description: "HTTP status code associated with the error"
+ format: "int32"
+ example: 400
+ errors:
+ type: "array"
+ description: "Detailed information about the individual errors, if applicable"
+ items:
+ $ref: "#/components/schemas/ErrorDetail"
+ description:
+ >-
+ API Error Response model representing the structure of error responses
+ returned by the API
+ ErrorDetail:
+ type: "object"
+ properties:
+ fieldName:
+ type: "string"
+ description: >-
+ The field in which the error occurred, the value is the error
+ that occurred for the field
+ example: "sftpUserName"
+ error:
+ type: "string"
+ description: "The detailed error message for die field"
+ example: "Field contains invalid characters"
+ description: "Detailed information about an individual error"
+ RoverPublicKeyModel:
+ required:
+ - "publicKey"
+ - "sftpUserName"
+ type: "object"
+ properties:
+ publicKey:
+ maxLength: 4000
+ minLength: 0
+ type: "string"
+ description: "The public key in SSH format"
+ example: "ssh-rsa AAAAB3... user@example.com"
+ description:
+ maxLength: 256
+ minLength: 0
+ type: "string"
+ description: "Description or label for the public key"
+ example: "Key for backup server access"
+ email:
+ maxLength: 256
+ minLength: 0
+ type: "string"
+ description: "Email associated with the public key owner"
+ example: "user@example.com"
+ sftpUserName:
+ maxLength: 100
+ minLength: 0
+ type: "string"
+ description: "The SFTP username associated with this public key"
+ example: "john_doe"
+ description: >-
+ Model representing the public key details associated with an SFTP
+ user
+ ClientPublicKeyMap:
+ type: "object"
+ additionalProperties:
+ type: "array"
+ items:
+ $ref: "#/components/schemas/RoverPublicKeyModel"
+ description: "Map of client IDs to lists of public keys"
+ example:
+ items:
+ - publicKey: "ssh-rsa AAAAB3... user@example.com"
+ description: "Key for backup server access"
+ email: "user@example.com"
+ sftpUserName: "john_doe"
+ - publicKey: "ssh-rsa AAAAB3... user@example.com"
+ description: "Key for backup server access"
+ email: "user@example.com"
+ sftpUserName: "john_doe"
diff --git a/sftp/api/v1/condition.go b/sftp/api/v1/condition.go
new file mode 100644
index 000000000..e67930110
--- /dev/null
+++ b/sftp/api/v1/condition.go
@@ -0,0 +1,30 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+const (
+ ConditionTypePublicKeysUpdatedInService = "PublicKeysUpdatedInService"
+ ConditionReadyReasonSSHPublicKeyProvided = "SSHPublicKeyProvided"
+)
+
+func NewPublicKeysUpdatedInServiceCondition() metav1.Condition {
+ return metav1.Condition{
+ Type: ConditionTypePublicKeysUpdatedInService,
+ Status: metav1.ConditionTrue,
+ Reason: "Updated",
+ Message: "SFTP public keys have been updated in service",
+ }
+}
+
+func NewPublicKeysNotUpdatedInServiceCondition(message string) metav1.Condition {
+ return metav1.Condition{
+ Type: ConditionTypePublicKeysUpdatedInService,
+ Status: metav1.ConditionFalse,
+ Reason: "UpdateFailed",
+ Message: message,
+ }
+}
diff --git a/sftp/api/v1/groupversion_info.go b/sftp/api/v1/groupversion_info.go
new file mode 100644
index 000000000..71b8de973
--- /dev/null
+++ b/sftp/api/v1/groupversion_info.go
@@ -0,0 +1,24 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+// Package v1 contains API schema definitions for the sftp v1 API group.
+// +kubebuilder:object:generate=true
+// +groupName=sftp.cp.ei.telekom.de
+package v1
+
+import (
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "sigs.k8s.io/controller-runtime/pkg/scheme"
+)
+
+var (
+ // GroupVersion is group version used to register these objects
+ GroupVersion = schema.GroupVersion{Group: "sftp.cp.ei.telekom.de", Version: "v1"}
+
+ // SchemeBuilder is used to add go types to the GroupVersionKind scheme
+ SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
+
+ // AddToScheme adds the types in this group-version to the given scheme.
+ AddToScheme = SchemeBuilder.AddToScheme
+)
diff --git a/sftp/api/v1/instance_types.go b/sftp/api/v1/instance_types.go
new file mode 100644
index 000000000..7570bb010
--- /dev/null
+++ b/sftp/api/v1/instance_types.go
@@ -0,0 +1,96 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import (
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/telekom/controlplane/common/pkg/types"
+)
+
+// IndexFieldSpecSFTPServiceConfigRef is the field index key for Instances by spec.sftpServiceConfigRef.
+const IndexFieldSpecSFTPServiceConfigRef = "spec.sftpServiceConfigRef"
+
+// InstanceSpec defines the desired state of Instance.
+type InstanceSpec struct {
+ // Description is a human-readable description of this Instance.
+ // +kubebuilder:validation:Optional
+ Description string `json:"description,omitempty"`
+
+ // SFTPServiceConfigRef references the SFTPServiceConfig used by this Instance.
+ // +kubebuilder:validation:Required
+ SFTPServiceConfigRef types.ObjectRef `json:"sftpServiceConfigRef"`
+}
+
+// InstanceStatus defines the observed state of Instance.
+type InstanceStatus struct {
+ // +listType=map
+ // +listMapKey=type
+ // +patchStrategy=merge
+ // +patchMergeKey=type
+ // +optional
+ Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
+}
+
+// InstanceUserStatus contains the Instance-observed status for a User.
+type InstanceUserStatus struct {
+ // Namespace is the namespace of the User.
+ Namespace string `json:"namespace"`
+
+ // Name is the name of the User.
+ Name string `json:"name"`
+
+ // ProcessingCondition is the User Processing condition observed by the Instance reconciliation.
+ ProcessingCondition metav1.Condition `json:"processingCondition"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:printcolumn:name="SFTPServiceConfig",type="string",JSONPath=".spec.sftpServiceConfigRef.name"
+// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status"
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
+
+// Instance is the Schema for the instances API.
+type Instance struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec InstanceSpec `json:"spec,omitempty"`
+ Status InstanceStatus `json:"status,omitempty"`
+}
+
+var _ types.Object = &Instance{}
+
+func (i *Instance) GetConditions() []metav1.Condition {
+ return i.Status.Conditions
+}
+
+func (i *Instance) SetCondition(condition metav1.Condition) bool {
+ return meta.SetStatusCondition(&i.Status.Conditions, condition)
+}
+
+// +kubebuilder:object:root=true
+
+// InstanceList contains a list of Instance.
+type InstanceList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []Instance `json:"items"`
+}
+
+var _ types.ObjectList = &InstanceList{}
+
+func (il *InstanceList) GetItems() []types.Object {
+ items := make([]types.Object, len(il.Items))
+ for i := range il.Items {
+ items[i] = &il.Items[i]
+ }
+ return items
+}
+
+func init() {
+ SchemeBuilder.Register(&Instance{}, &InstanceList{})
+}
diff --git a/sftp/api/v1/labels.go b/sftp/api/v1/labels.go
new file mode 100644
index 000000000..7e44dda3e
--- /dev/null
+++ b/sftp/api/v1/labels.go
@@ -0,0 +1,17 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import (
+ "github.com/telekom/controlplane/common/pkg/config"
+)
+
+// Label keys for filtering ApprovalRequest and Approval resources.
+var (
+ InstanceNameKey = config.BuildLabelKey("instance.name")
+ InstanceNamespaceKey = config.BuildLabelKey("instance.namespace")
+ SFTPServiceConfigNameKey = config.BuildLabelKey("sftpserviceconfig.name")
+ SFTPServiceConfigNamespaceKey = config.BuildLabelKey("sftpserviceconfig.namespace")
+)
diff --git a/sftp/api/v1/publickey.go b/sftp/api/v1/publickey.go
new file mode 100644
index 000000000..d48287b65
--- /dev/null
+++ b/sftp/api/v1/publickey.go
@@ -0,0 +1,71 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "strings"
+
+ "github.com/telekom/controlplane/common/pkg/types"
+)
+
+const sshFingerprintPrefix = "SHA256:"
+
+// FingerprintForKey returns the OpenSSH-style SHA256 fingerprint for an authorized_keys public key.
+func FingerprintForKey(key string) (string, error) {
+ _, key, err := publicKeyFields(key)
+ if err != nil {
+ return "", err
+ }
+
+ blob, err := decodeAuthorizedKeyBlob(key)
+ if err != nil {
+ return "", fmt.Errorf("decoding SSH public key: %w", err)
+ }
+
+ sum := sha256.Sum256(blob)
+ builder := &strings.Builder{}
+ builder.Grow(len(sshFingerprintPrefix) + sha256.Size*2)
+ builder.WriteString(sshFingerprintPrefix)
+ hexWriter := hex.NewEncoder(builder)
+ _, err = hexWriter.Write(sum[:])
+ if err != nil {
+ return "", fmt.Errorf("converting hash to hex failed: %w", err)
+ }
+
+ return builder.String(), nil
+}
+
+// CanonicalPublicKey returns the authorized_keys public key without any trailing comment.
+func CanonicalPublicKey(key string) (string, error) {
+ keyType, encoded, err := publicKeyFields(key)
+ if err != nil {
+ return "", err
+ }
+ return keyType + " " + encoded, nil
+}
+
+func publicKeyFields(key string) (string, string, error) {
+ fields := strings.Fields(key)
+ if len(fields) < 2 {
+ return "", "", fmt.Errorf("invalid SSH public key")
+ }
+ return fields[0], fields[1], nil
+}
+
+func decodeAuthorizedKeyBlob(encoded string) ([]byte, error) {
+ if blob, err := base64.StdEncoding.DecodeString(encoded); err == nil {
+ return blob, nil
+ }
+ return base64.RawStdEncoding.DecodeString(encoded)
+}
+
+// SourceForTypedObjectRef returns a stable claim source string for a typed object reference.
+func SourceForTypedObjectRef(ref types.TypedObjectRef) string {
+ return strings.ToLower(ref.Kind + "." + ref.APIVersion + "/" + ref.Namespace + "/" + ref.Name)
+}
diff --git a/sftp/api/v1/sftpserviceconfig_types.go b/sftp/api/v1/sftpserviceconfig_types.go
new file mode 100644
index 000000000..e1de27b5b
--- /dev/null
+++ b/sftp/api/v1/sftpserviceconfig_types.go
@@ -0,0 +1,101 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import (
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/telekom/controlplane/common/pkg/types"
+)
+
+// SFTPServiceConfigSpec defines the desired state of SFTPServiceConfig
+type SFTPServiceConfigSpec struct {
+ // API contains authentication configuration for API service access.
+ // +kubebuilder:validation:Required
+ API APIEndpoint `json:"api"`
+}
+
+type APIEndpoint struct {
+ // Endpoint is the SFTP Tardis API base URL.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:Format=uri
+ Endpoint string `json:"endpoint"`
+
+ // Issuer is the OAuth2 token endpoint used for client credentials authentication.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:Format=uri
+ Issuer string `json:"issuer"`
+
+ // ClientID is the OAuth2 client ID used for client credentials authentication.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ ClientID string `json:"clientID"`
+
+ // ClientSecret is the OAuth2 client secret used for client credentials authentication.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ ClientSecret string `json:"clientSecret"`
+}
+
+// SFTPServiceConfigStatus defines the observed state of SFTPServiceConfig
+type SFTPServiceConfigStatus struct {
+ // +listType=map
+ // +listMapKey=type
+ // +patchStrategy=merge
+ // +patchMergeKey=type
+ // +optional
+ Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:printcolumn:name="API Endpoint",type="string",JSONPath=".spec.api.endpoint"
+// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status"
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
+
+// SFTPServiceConfig is the Schema for the sftpserviceconfigs API
+type SFTPServiceConfig struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec SFTPServiceConfigSpec `json:"spec,omitempty"`
+ Status SFTPServiceConfigStatus `json:"status,omitempty"`
+}
+
+var _ types.Object = &SFTPServiceConfig{}
+
+func (z *SFTPServiceConfig) GetConditions() []metav1.Condition {
+ return z.Status.Conditions
+}
+
+func (z *SFTPServiceConfig) SetCondition(condition metav1.Condition) bool {
+ return meta.SetStatusCondition(&z.Status.Conditions, condition)
+}
+
+// +kubebuilder:object:root=true
+
+// SFTPServiceConfigList contains a list of SFTPServiceConfig
+type SFTPServiceConfigList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []SFTPServiceConfig `json:"items"`
+}
+
+var _ types.ObjectList = &SFTPServiceConfigList{}
+
+func (zl *SFTPServiceConfigList) GetItems() []types.Object {
+ items := make([]types.Object, len(zl.Items))
+ for i := range zl.Items {
+ items[i] = &zl.Items[i]
+ }
+ return items
+}
+
+func init() {
+ SchemeBuilder.Register(&SFTPServiceConfig{}, &SFTPServiceConfigList{})
+}
diff --git a/sftp/api/v1/user_types.go b/sftp/api/v1/user_types.go
new file mode 100644
index 000000000..c5757a004
--- /dev/null
+++ b/sftp/api/v1/user_types.go
@@ -0,0 +1,86 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package v1
+
+import (
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/telekom/controlplane/common/pkg/types"
+)
+
+// IndexFieldSpecInstanceRef is the field index key for Users by spec.instanceRef.
+const IndexFieldSpecInstanceRef = "spec.instanceRef"
+
+// UserSpec defines the desired state of User
+type UserSpec struct {
+ // InstanceRef references the SFTP Instance used by this User.
+ // +kubebuilder:validation:Required
+ InstanceRef types.ObjectRef `json:"instanceRef"`
+
+ // SSHPublicKeys contains the unique SSH public keys that should be assigned to this User.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinItems=1
+ // +kubebuilder:validation:uniqueItems=true
+ // +kubebuilder:validation:MinItems=1
+ SSHPublicKeys []string `json:"sshPublicKeys"`
+}
+
+// UserStatus defines the observed state of User
+type UserStatus struct {
+ // +listType=map
+ // +listMapKey=type
+ // +patchStrategy=merge
+ // +patchMergeKey=type
+ // +optional
+ Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status"
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
+
+// User is the Schema for the users API
+type User struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec UserSpec `json:"spec,omitempty"`
+ Status UserStatus `json:"status,omitempty"`
+}
+
+var _ types.Object = &User{}
+
+func (u *User) GetConditions() []metav1.Condition {
+ return u.Status.Conditions
+}
+
+func (u *User) SetCondition(condition metav1.Condition) bool {
+ return meta.SetStatusCondition(&u.Status.Conditions, condition)
+}
+
+// +kubebuilder:object:root=true
+
+// UserList contains a list of User
+type UserList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []User `json:"items"`
+}
+
+var _ types.ObjectList = &UserList{}
+
+func (ul *UserList) GetItems() []types.Object {
+ items := make([]types.Object, len(ul.Items))
+ for i := range ul.Items {
+ items[i] = &ul.Items[i]
+ }
+ return items
+}
+
+func init() {
+ SchemeBuilder.Register(&User{}, &UserList{})
+}
diff --git a/sftp/api/v1/zz_generated.deepcopy.go b/sftp/api/v1/zz_generated.deepcopy.go
new file mode 100644
index 000000000..96b1b5fac
--- /dev/null
+++ b/sftp/api/v1/zz_generated.deepcopy.go
@@ -0,0 +1,341 @@
+//go:build !ignore_autogenerated
+
+// SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v1
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *APIEndpoint) DeepCopyInto(out *APIEndpoint) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIEndpoint.
+func (in *APIEndpoint) DeepCopy() *APIEndpoint {
+ if in == nil {
+ return nil
+ }
+ out := new(APIEndpoint)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Instance) DeepCopyInto(out *Instance) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ out.Spec = in.Spec
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Instance.
+func (in *Instance) DeepCopy() *Instance {
+ if in == nil {
+ return nil
+ }
+ out := new(Instance)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Instance) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *InstanceList) DeepCopyInto(out *InstanceList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]Instance, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceList.
+func (in *InstanceList) DeepCopy() *InstanceList {
+ if in == nil {
+ return nil
+ }
+ out := new(InstanceList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *InstanceList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *InstanceSpec) DeepCopyInto(out *InstanceSpec) {
+ *out = *in
+ in.SFTPServiceConfigRef.DeepCopyInto(&out.SFTPServiceConfigRef)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceSpec.
+func (in *InstanceSpec) DeepCopy() *InstanceSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(InstanceSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *InstanceStatus) DeepCopyInto(out *InstanceStatus) {
+ *out = *in
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]metav1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceStatus.
+func (in *InstanceStatus) DeepCopy() *InstanceStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(InstanceStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *InstanceUserStatus) DeepCopyInto(out *InstanceUserStatus) {
+ *out = *in
+ in.ProcessingCondition.DeepCopyInto(&out.ProcessingCondition)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceUserStatus.
+func (in *InstanceUserStatus) DeepCopy() *InstanceUserStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(InstanceUserStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SFTPServiceConfig) DeepCopyInto(out *SFTPServiceConfig) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ out.Spec = in.Spec
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SFTPServiceConfig.
+func (in *SFTPServiceConfig) DeepCopy() *SFTPServiceConfig {
+ if in == nil {
+ return nil
+ }
+ out := new(SFTPServiceConfig)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *SFTPServiceConfig) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SFTPServiceConfigList) DeepCopyInto(out *SFTPServiceConfigList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]SFTPServiceConfig, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SFTPServiceConfigList.
+func (in *SFTPServiceConfigList) DeepCopy() *SFTPServiceConfigList {
+ if in == nil {
+ return nil
+ }
+ out := new(SFTPServiceConfigList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *SFTPServiceConfigList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SFTPServiceConfigSpec) DeepCopyInto(out *SFTPServiceConfigSpec) {
+ *out = *in
+ out.API = in.API
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SFTPServiceConfigSpec.
+func (in *SFTPServiceConfigSpec) DeepCopy() *SFTPServiceConfigSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(SFTPServiceConfigSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SFTPServiceConfigStatus) DeepCopyInto(out *SFTPServiceConfigStatus) {
+ *out = *in
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]metav1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SFTPServiceConfigStatus.
+func (in *SFTPServiceConfigStatus) DeepCopy() *SFTPServiceConfigStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(SFTPServiceConfigStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *User) DeepCopyInto(out *User) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new User.
+func (in *User) DeepCopy() *User {
+ if in == nil {
+ return nil
+ }
+ out := new(User)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *User) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *UserList) DeepCopyInto(out *UserList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]User, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserList.
+func (in *UserList) DeepCopy() *UserList {
+ if in == nil {
+ return nil
+ }
+ out := new(UserList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *UserList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *UserSpec) DeepCopyInto(out *UserSpec) {
+ *out = *in
+ in.InstanceRef.DeepCopyInto(&out.InstanceRef)
+ if in.SSHPublicKeys != nil {
+ in, out := &in.SSHPublicKeys, &out.SSHPublicKeys
+ *out = make([]string, len(*in))
+ copy(*out, *in)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserSpec.
+func (in *UserSpec) DeepCopy() *UserSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(UserSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *UserStatus) DeepCopyInto(out *UserStatus) {
+ *out = *in
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]metav1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserStatus.
+func (in *UserStatus) DeepCopy() *UserStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(UserStatus)
+ in.DeepCopyInto(out)
+ return out
+}
diff --git a/sftp/cmd/sftp-operator/main.go b/sftp/cmd/sftp-operator/main.go
new file mode 100644
index 000000000..94a8fc0ad
--- /dev/null
+++ b/sftp/cmd/sftp-operator/main.go
@@ -0,0 +1,138 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "crypto/tls"
+ "flag"
+ "os"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/healthz"
+ "sigs.k8s.io/controller-runtime/pkg/log/zap"
+ "sigs.k8s.io/controller-runtime/pkg/metrics/filters"
+ metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
+
+ "github.com/telekom/controlplane/sftp/internal/controller"
+ "github.com/telekom/controlplane/sftp/internal/service"
+
+ // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
+ // to ensure that exec-entrypoint and run can make use of them.
+ _ "k8s.io/client-go/plugin/pkg/client/auth"
+)
+
+var (
+ scheme = runtime.NewScheme()
+ setupLog = ctrl.Log.WithName("setup")
+)
+
+func init() {
+ controller.RegisterSchemesOrDie(scheme)
+ // +kubebuilder:scaffold:scheme
+}
+
+func main() {
+ var metricsAddr string
+ var enableLeaderElection bool
+ var probeAddr string
+ var secureMetrics bool
+ var enableHTTP2 bool
+ var tlsOpts []func(*tls.Config)
+ flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
+ flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
+ flag.BoolVar(&enableLeaderElection, "leader-elect", false,
+ "Enable leader election for controller manager. "+
+ "Enabling this will ensure there is only one active controller manager.")
+ flag.BoolVar(&secureMetrics, "metrics-secure", true,
+ "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.")
+ flag.BoolVar(&enableHTTP2, "enable-http2", false,
+ "If set, HTTP/2 will be enabled for the metrics server")
+ opts := zap.Options{
+ Development: true,
+ }
+ opts.BindFlags(flag.CommandLine)
+ flag.Parse()
+
+ ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
+
+ // disabling http/2 prevents HTTP/2 Stream Cancellation and Rapid Reset CVEs.
+ disableHTTP2 := func(c *tls.Config) {
+ setupLog.Info("disabling http/2")
+ c.NextProtos = []string{"http/1.1"}
+ }
+
+ if !enableHTTP2 {
+ tlsOpts = append(tlsOpts, disableHTTP2)
+ }
+
+ metricsServerOptions := metricsserver.Options{
+ BindAddress: metricsAddr,
+ SecureServing: secureMetrics,
+ TLSOpts: tlsOpts,
+ }
+
+ if secureMetrics {
+ metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization
+ }
+
+ mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
+ Scheme: scheme,
+ Metrics: metricsServerOptions,
+ HealthProbeBindAddress: probeAddr,
+ LeaderElection: enableLeaderElection,
+ LeaderElectionID: "sftp.cp.ei.telekom.de",
+ })
+ if err != nil {
+ setupLog.Error(err, "unable to start manager")
+ os.Exit(1)
+ }
+
+ ctx := ctrl.SetupSignalHandler()
+ controller.RegisterIndexesOrDie(ctx, mgr)
+
+ clientManager := service.NewHTTPServiceFactory()
+
+ if err = (&controller.InstanceReconciler{
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ ServiceFactory: clientManager,
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "Instance")
+ os.Exit(1)
+ }
+ if err = (&controller.UserReconciler{
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ ServiceFactory: clientManager,
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "User")
+ os.Exit(1)
+ }
+ if err = (&controller.SFTPServiceConfigReconciler{
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ ClientManager: clientManager,
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "SFTPServiceConfig")
+ os.Exit(1)
+ }
+
+ 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")
+ if err := mgr.Start(ctx); err != nil {
+ setupLog.Error(err, "problem running manager")
+ os.Exit(1)
+ }
+}
diff --git a/sftp/config/crd/bases/sftp.cp.ei.telekom.de_instances.yaml b/sftp/config/crd/bases/sftp.cp.ei.telekom.de_instances.yaml
new file mode 100644
index 000000000..ee4cdef1d
--- /dev/null
+++ b/sftp/config/crd/bases/sftp.cp.ei.telekom.de_instances.yaml
@@ -0,0 +1,146 @@
+# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.20.1
+ name: instances.sftp.cp.ei.telekom.de
+spec:
+ group: sftp.cp.ei.telekom.de
+ names:
+ kind: Instance
+ listKind: InstanceList
+ plural: instances
+ singular: instance
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .spec.sftpServiceConfigRef.name
+ name: SFTPServiceConfig
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Ready")].status
+ name: Ready
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1
+ schema:
+ openAPIV3Schema:
+ description: Instance is the Schema for the instances API.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: InstanceSpec defines the desired state of Instance.
+ properties:
+ description:
+ description: Description is a human-readable description of this Instance.
+ type: string
+ sftpServiceConfigRef:
+ description: SFTPServiceConfigRef references the SFTPServiceConfig
+ used by this Instance.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ required:
+ - sftpServiceConfigRef
+ type: object
+ status:
+ description: InstanceStatus defines the observed state of Instance.
+ properties:
+ conditions:
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/sftp/config/crd/bases/sftp.cp.ei.telekom.de_sftpserviceconfigs.yaml b/sftp/config/crd/bases/sftp.cp.ei.telekom.de_sftpserviceconfigs.yaml
new file mode 100644
index 000000000..b2590905c
--- /dev/null
+++ b/sftp/config/crd/bases/sftp.cp.ei.telekom.de_sftpserviceconfigs.yaml
@@ -0,0 +1,156 @@
+# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.20.1
+ name: sftpserviceconfigs.sftp.cp.ei.telekom.de
+spec:
+ group: sftp.cp.ei.telekom.de
+ names:
+ kind: SFTPServiceConfig
+ listKind: SFTPServiceConfigList
+ plural: sftpserviceconfigs
+ singular: sftpserviceconfig
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .spec.api.endpoint
+ name: API Endpoint
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Ready")].status
+ name: Ready
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1
+ schema:
+ openAPIV3Schema:
+ description: SFTPServiceConfig is the Schema for the sftpserviceconfigs API
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: SFTPServiceConfigSpec defines the desired state of SFTPServiceConfig
+ properties:
+ api:
+ description: API contains authentication configuration for API service
+ access.
+ properties:
+ clientID:
+ description: ClientID is the OAuth2 client ID used for client
+ credentials authentication.
+ minLength: 1
+ type: string
+ clientSecret:
+ description: ClientSecret is the OAuth2 client secret used for
+ client credentials authentication.
+ minLength: 1
+ type: string
+ endpoint:
+ description: Endpoint is the SFTP Tardis API base URL.
+ format: uri
+ minLength: 1
+ type: string
+ issuer:
+ description: Issuer is the OAuth2 token endpoint used for client
+ credentials authentication.
+ format: uri
+ minLength: 1
+ type: string
+ required:
+ - clientID
+ - clientSecret
+ - endpoint
+ - issuer
+ type: object
+ required:
+ - api
+ type: object
+ status:
+ description: SFTPServiceConfigStatus defines the observed state of SFTPServiceConfig
+ properties:
+ conditions:
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/sftp/config/crd/bases/sftp.cp.ei.telekom.de_users.yaml b/sftp/config/crd/bases/sftp.cp.ei.telekom.de_users.yaml
new file mode 100644
index 000000000..080dcc780
--- /dev/null
+++ b/sftp/config/crd/bases/sftp.cp.ei.telekom.de_users.yaml
@@ -0,0 +1,148 @@
+# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.20.1
+ name: users.sftp.cp.ei.telekom.de
+spec:
+ group: sftp.cp.ei.telekom.de
+ names:
+ kind: User
+ listKind: UserList
+ plural: users
+ singular: user
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .status.conditions[?(@.type=="Ready")].status
+ name: Ready
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1
+ schema:
+ openAPIV3Schema:
+ description: User is the Schema for the users API
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: UserSpec defines the desired state of User
+ properties:
+ instanceRef:
+ description: InstanceRef references the SFTP Instance used by this
+ User.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ uid:
+ description: |-
+ UID is a type that holds unique ID values, including UUIDs. Because we
+ don't ONLY use UUIDs, this is an alias to string. Being a type captures
+ intent and helps make sure that UIDs and names do not get conflated.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ sshPublicKeys:
+ description: SSHPublicKeys contains the unique SSH public keys that
+ should be assigned to this User.
+ items:
+ type: string
+ minItems: 1
+ type: array
+ required:
+ - instanceRef
+ - sshPublicKeys
+ type: object
+ status:
+ description: UserStatus defines the observed state of User
+ properties:
+ conditions:
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/sftp/config/crd/kustomization.yaml b/sftp/config/crd/kustomization.yaml
new file mode 100644
index 000000000..ff254fcc5
--- /dev/null
+++ b/sftp/config/crd/kustomization.yaml
@@ -0,0 +1,12 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# This kustomization.yaml is not intended to be run by itself,
+# since it depends on service name and namespace that are out of this kustomize package.
+# It should be run by config/default
+resources:
+- bases/sftp.cp.ei.telekom.de_instances.yaml
+- bases/sftp.cp.ei.telekom.de_users.yaml
+- bases/sftp.cp.ei.telekom.de_sftpserviceconfigs.yaml
+# +kubebuilder:scaffold:crdkustomizeresource
diff --git a/sftp/config/default/deployment_patch.yaml b/sftp/config/default/deployment_patch.yaml
new file mode 100644
index 000000000..e28a9c3d6
--- /dev/null
+++ b/sftp/config/default/deployment_patch.yaml
@@ -0,0 +1,32 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: controller-manager
+ namespace: system
+spec:
+ template:
+ spec:
+ containers:
+ - name: manager
+ volumeMounts:
+ - name: secretmgr-token
+ mountPath: /var/run/secrets/secretmgr
+ readOnly: true
+ - name: trust-bundle
+ mountPath: /var/run/secrets/trust-bundle
+ readOnly: true
+ volumes:
+ - name: secretmgr-token
+ projected:
+ sources:
+ - serviceAccountToken:
+ path: token
+ expirationSeconds: 600
+ audience: secret-manager
+ - name: trust-bundle
+ configMap:
+ name: secret-manager-trust-bundle
diff --git a/sftp/config/default/kustomization.yaml b/sftp/config/default/kustomization.yaml
new file mode 100644
index 000000000..aca101a2b
--- /dev/null
+++ b/sftp/config/default/kustomization.yaml
@@ -0,0 +1,30 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# Adds namespace to all resources.
+namespace: controlplane-system
+
+# Value of this field is prepended to the
+# names of all resources, e.g. a deployment named
+# "wordpress" becomes "alices-wordpress".
+namePrefix: sftp-
+
+# Labels to add to all resources and selectors.
+labels:
+- includeSelectors: true
+ pairs:
+ domain: sftp
+
+resources:
+- ../crd
+- ../rbac
+- ../manager
+
+patches:
+- path: deployment_patch.yaml
+ target:
+ kind: Deployment
+- path: namespace_patch.yaml
+ target:
+ kind: Namespace
diff --git a/sftp/config/default/namespace_patch.yaml b/sftp/config/default/namespace_patch.yaml
new file mode 100644
index 000000000..a9f7b44ff
--- /dev/null
+++ b/sftp/config/default/namespace_patch.yaml
@@ -0,0 +1,7 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+- op: add
+ path: /metadata/labels/cp.ei.telekom.de~1secret-manager
+ value: "enabled"
diff --git a/sftp/config/manager/kustomization.yaml b/sftp/config/manager/kustomization.yaml
new file mode 100644
index 000000000..f636f38c1
--- /dev/null
+++ b/sftp/config/manager/kustomization.yaml
@@ -0,0 +1,6 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+resources:
+- manager.yaml
diff --git a/sftp/config/manager/manager.yaml b/sftp/config/manager/manager.yaml
new file mode 100644
index 000000000..9b8ff4d81
--- /dev/null
+++ b/sftp/config/manager/manager.yaml
@@ -0,0 +1,65 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: controller-manager
+ namespace: system
+ labels:
+ control-plane: controller-manager
+ app.kubernetes.io/name: sftp
+ app.kubernetes.io/managed-by: kustomize
+spec:
+ selector:
+ matchLabels:
+ control-plane: controller-manager
+ replicas: 1
+ template:
+ metadata:
+ annotations:
+ kubectl.kubernetes.io/default-container: manager
+ labels:
+ control-plane: controller-manager
+ spec:
+ securityContext:
+ runAsNonRoot: true
+ seccompProfile:
+ type: RuntimeDefault
+ containers:
+ - args:
+ - --leader-elect
+ - --health-probe-bind-address=:8081
+ image: ghcr.io/telekom/controlplane/sftp:stable
+ name: manager
+ ports:
+ - containerPort: 8081
+ name: health
+ protocol: TCP
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop:
+ - ALL
+ livenessProbe:
+ httpGet:
+ path: /healthz
+ port: 8081
+ initialDelaySeconds: 15
+ periodSeconds: 20
+ readinessProbe:
+ httpGet:
+ path: /readyz
+ port: 8081
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ resources:
+ limits:
+ cpu: 500m
+ memory: 128Mi
+ requests:
+ cpu: 10m
+ memory: 64Mi
+ serviceAccountName: controller-manager
+ terminationGracePeriodSeconds: 10
diff --git a/sftp/config/rbac/instance_editor_role.yaml b/sftp/config/rbac/instance_editor_role.yaml
new file mode 100644
index 000000000..7b41dd5ad
--- /dev/null
+++ b/sftp/config/rbac/instance_editor_role.yaml
@@ -0,0 +1,31 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# permissions for end users to edit instances.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: sftp
+ app.kubernetes.io/managed-by: kustomize
+ name: instance-editor-role
+rules:
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - instances
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - instances/status
+ verbs:
+ - get
diff --git a/sftp/config/rbac/instance_viewer_role.yaml b/sftp/config/rbac/instance_viewer_role.yaml
new file mode 100644
index 000000000..bd73c9300
--- /dev/null
+++ b/sftp/config/rbac/instance_viewer_role.yaml
@@ -0,0 +1,27 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# permissions for end users to view instances.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: sftp
+ app.kubernetes.io/managed-by: kustomize
+ name: instance-viewer-role
+rules:
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - instances
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - instances/status
+ verbs:
+ - get
diff --git a/sftp/config/rbac/kustomization.yaml b/sftp/config/rbac/kustomization.yaml
new file mode 100644
index 000000000..212f66944
--- /dev/null
+++ b/sftp/config/rbac/kustomization.yaml
@@ -0,0 +1,19 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+resources:
+- service_account.yaml
+- role.yaml
+- role_binding.yaml
+- leader_election_role.yaml
+- leader_election_role_binding.yaml
+- metrics_auth_role.yaml
+- metrics_auth_role_binding.yaml
+- metrics_reader_role.yaml
+- instance_editor_role.yaml
+- instance_viewer_role.yaml
+- user_editor_role.yaml
+- user_viewer_role.yaml
+- sftpserviceconfig_editor_role.yaml
+- sftpserviceconfig_viewer_role.yaml
diff --git a/sftp/config/rbac/leader_election_role.yaml b/sftp/config/rbac/leader_election_role.yaml
new file mode 100644
index 000000000..0ab13d72a
--- /dev/null
+++ b/sftp/config/rbac/leader_election_role.yaml
@@ -0,0 +1,44 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# permissions to do leader election.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+ labels:
+ app.kubernetes.io/name: sftp
+ app.kubernetes.io/managed-by: kustomize
+ name: leader-election-role
+rules:
+- apiGroups:
+ - ""
+ resources:
+ - configmaps
+ verbs:
+ - get
+ - list
+ - watch
+ - create
+ - update
+ - patch
+ - delete
+- apiGroups:
+ - coordination.k8s.io
+ resources:
+ - leases
+ verbs:
+ - get
+ - list
+ - watch
+ - create
+ - update
+ - patch
+ - delete
+- apiGroups:
+ - ""
+ resources:
+ - events
+ verbs:
+ - create
+ - patch
diff --git a/sftp/config/rbac/leader_election_role_binding.yaml b/sftp/config/rbac/leader_election_role_binding.yaml
new file mode 100644
index 000000000..15e6438ec
--- /dev/null
+++ b/sftp/config/rbac/leader_election_role_binding.yaml
@@ -0,0 +1,19 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ labels:
+ app.kubernetes.io/name: sftp
+ app.kubernetes.io/managed-by: kustomize
+ name: leader-election-rolebinding
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: Role
+ name: leader-election-role
+subjects:
+- kind: ServiceAccount
+ name: controller-manager
+ namespace: system
diff --git a/sftp/config/rbac/metrics_auth_role.yaml b/sftp/config/rbac/metrics_auth_role.yaml
new file mode 100644
index 000000000..75413a917
--- /dev/null
+++ b/sftp/config/rbac/metrics_auth_role.yaml
@@ -0,0 +1,22 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# This ClusterRole is used to protect the metrics endpoint with authentication/authorization.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: metrics-auth-role
+rules:
+- apiGroups:
+ - authentication.k8s.io
+ resources:
+ - tokenreviews
+ verbs:
+ - create
+- apiGroups:
+ - authorization.k8s.io
+ resources:
+ - subjectaccessreviews
+ verbs:
+ - create
diff --git a/sftp/config/rbac/metrics_auth_role_binding.yaml b/sftp/config/rbac/metrics_auth_role_binding.yaml
new file mode 100644
index 000000000..99b11c6db
--- /dev/null
+++ b/sftp/config/rbac/metrics_auth_role_binding.yaml
@@ -0,0 +1,16 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: metrics-auth-rolebinding
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: metrics-auth-role
+subjects:
+- kind: ServiceAccount
+ name: controller-manager
+ namespace: system
diff --git a/sftp/config/rbac/metrics_reader_role.yaml b/sftp/config/rbac/metrics_reader_role.yaml
new file mode 100644
index 000000000..d813748ca
--- /dev/null
+++ b/sftp/config/rbac/metrics_reader_role.yaml
@@ -0,0 +1,14 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# This ClusterRole is used to allow Prometheus to scrape metrics endpoints.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: metrics-reader
+rules:
+- nonResourceURLs:
+ - /metrics
+ verbs:
+ - get
diff --git a/sftp/config/rbac/role.yaml b/sftp/config/rbac/role.yaml
new file mode 100644
index 000000000..f71c9f8ee
--- /dev/null
+++ b/sftp/config/rbac/role.yaml
@@ -0,0 +1,57 @@
+# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: manager-role
+rules:
+- apiGroups:
+ - ""
+ resources:
+ - events
+ verbs:
+ - create
+ - patch
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - instances
+ - sftpserviceconfigs
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - instances/finalizers
+ - sftpserviceconfigs/finalizers
+ - users/finalizers
+ verbs:
+ - update
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - instances/status
+ - sftpserviceconfigs/status
+ - users/status
+ verbs:
+ - get
+ - patch
+ - update
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - users
+ verbs:
+ - get
+ - list
+ - patch
+ - update
+ - watch
diff --git a/sftp/config/rbac/role_binding.yaml b/sftp/config/rbac/role_binding.yaml
new file mode 100644
index 000000000..de12821c2
--- /dev/null
+++ b/sftp/config/rbac/role_binding.yaml
@@ -0,0 +1,19 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ labels:
+ app.kubernetes.io/name: sftp
+ app.kubernetes.io/managed-by: kustomize
+ name: manager-rolebinding
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: manager-role
+subjects:
+- kind: ServiceAccount
+ name: controller-manager
+ namespace: system
diff --git a/sftp/config/rbac/service_account.yaml b/sftp/config/rbac/service_account.yaml
new file mode 100644
index 000000000..2447e95c5
--- /dev/null
+++ b/sftp/config/rbac/service_account.yaml
@@ -0,0 +1,12 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ labels:
+ app.kubernetes.io/name: sftp
+ app.kubernetes.io/managed-by: kustomize
+ name: controller-manager
+ namespace: system
diff --git a/sftp/config/rbac/sftpserviceconfig_editor_role.yaml b/sftp/config/rbac/sftpserviceconfig_editor_role.yaml
new file mode 100644
index 000000000..c127b83dd
--- /dev/null
+++ b/sftp/config/rbac/sftpserviceconfig_editor_role.yaml
@@ -0,0 +1,31 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# permissions for end users to edit sftpserviceconfigs.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: sftp
+ app.kubernetes.io/managed-by: kustomize
+ name: sftpserviceconfig-editor-role
+rules:
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - sftpserviceconfigs
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - sftpserviceconfigs/status
+ verbs:
+ - get
diff --git a/sftp/config/rbac/sftpserviceconfig_viewer_role.yaml b/sftp/config/rbac/sftpserviceconfig_viewer_role.yaml
new file mode 100644
index 000000000..c07b0e26d
--- /dev/null
+++ b/sftp/config/rbac/sftpserviceconfig_viewer_role.yaml
@@ -0,0 +1,27 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# permissions for end users to view sftpserviceconfigs.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: sftp
+ app.kubernetes.io/managed-by: kustomize
+ name: sftpserviceconfig-viewer-role
+rules:
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - sftpserviceconfigs
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - sftpserviceconfigs/status
+ verbs:
+ - get
diff --git a/sftp/config/rbac/user_editor_role.yaml b/sftp/config/rbac/user_editor_role.yaml
new file mode 100644
index 000000000..af4eae9fa
--- /dev/null
+++ b/sftp/config/rbac/user_editor_role.yaml
@@ -0,0 +1,31 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# permissions for end users to edit users.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: sftp
+ app.kubernetes.io/managed-by: kustomize
+ name: user-editor-role
+rules:
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - users
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - users/status
+ verbs:
+ - get
diff --git a/sftp/config/rbac/user_viewer_role.yaml b/sftp/config/rbac/user_viewer_role.yaml
new file mode 100644
index 000000000..15f758394
--- /dev/null
+++ b/sftp/config/rbac/user_viewer_role.yaml
@@ -0,0 +1,27 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# permissions for end users to view users.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: sftp
+ app.kubernetes.io/managed-by: kustomize
+ name: user-viewer-role
+rules:
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - users
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - sftp.cp.ei.telekom.de
+ resources:
+ - users/status
+ verbs:
+ - get
diff --git a/sftp/config/samples/sftp_v1_instance.yaml b/sftp/config/samples/sftp_v1_instance.yaml
new file mode 100644
index 000000000..773af0c60
--- /dev/null
+++ b/sftp/config/samples/sftp_v1_instance.yaml
@@ -0,0 +1,16 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: sftp.cp.ei.telekom.de/v1
+kind: Instance
+metadata:
+ labels:
+ cp.ei.telekom.de/environment: poc
+ namespace: default
+ name: example
+spec:
+ description: dCP TEST
+ sftpServiceConfigRef:
+ name: example
+ namespace: default
diff --git a/sftp/config/samples/sftp_v1_sftpserviceconfig.yaml b/sftp/config/samples/sftp_v1_sftpserviceconfig.yaml
new file mode 100644
index 000000000..d14e9d505
--- /dev/null
+++ b/sftp/config/samples/sftp_v1_sftpserviceconfig.yaml
@@ -0,0 +1,17 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: sftp.cp.ei.telekom.de/v1
+kind: SFTPServiceConfig
+metadata:
+ labels:
+ cp.ei.telekom.de/environment: poc
+ namespace: poc
+ name: example
+spec:
+ api:
+ clientID: example
+ clientSecret:
+ endpoint: https://stargate.example.com/v1
+ issuer: https://iris.example.com/auth/realms/default/protocol/openid-connect/token
diff --git a/sftp/config/samples/sftp_v1_user.yaml b/sftp/config/samples/sftp_v1_user.yaml
new file mode 100644
index 000000000..8a057c037
--- /dev/null
+++ b/sftp/config/samples/sftp_v1_user.yaml
@@ -0,0 +1,19 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+apiVersion: sftp.cp.ei.telekom.de/v1
+kind: User
+metadata:
+ labels:
+ cp.ei.telekom.de/environment: poc
+ cp.ei.telekom.de/instance.name: example
+ cp.ei.telekom.de/instance.namespace: default
+ namespace: default
+ name: example
+spec:
+ instanceRef:
+ name: example
+ namespace: default
+ sshPublicKeys:
+ - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC0Z7+6k5g8J9z4F5G1lY...
diff --git a/sftp/docs/sftp-domain-architecture.md b/sftp/docs/sftp-domain-architecture.md
new file mode 100644
index 000000000..47795c4dd
--- /dev/null
+++ b/sftp/docs/sftp-domain-architecture.md
@@ -0,0 +1,101 @@
+
+
+# SFTP Domain -- Architecture Overview
+
+This document describes how the **SFTP domain** (`sftp.cp.ei.telekom.de/v1`) reconciles SFTP resources and interacts with external services.
+
+## Domain Interaction Diagram
+
+```mermaid
+flowchart TB
+ %% Styling
+ classDef sftpCls fill:#4a90d9,color:#fff,stroke:#2c5f8a,stroke-width:2px
+ classDef serviceCls fill:#50b86c,color:#fff,stroke:#2e7d42,stroke-width:2px
+ classDef secretCls fill:#ab47bc,color:#fff,stroke:#7b1fa2,stroke-width:2px
+
+ %% SFTP Domain
+ subgraph sftp["SFTP Domain"]
+ direction TB
+ SFTPServiceConfig["SFTPServiceConfig"]:::sftpCls
+ Instance["Instance"]:::sftpCls
+ User["User"]:::sftpCls
+
+ SFTPServiceConfig -. "referenced by" .-> Instance
+ Instance -. "referenced by" .-> User
+ Instance -. "triggers reconcile" .-> User
+ end
+
+ %% External services
+ subgraph external["External Services"]
+ direction TB
+ SecretManager["Secret Manager"]:::secretCls
+ TokenIssuer["OAuth2 Token Issuer"]:::serviceCls
+ SftpTardis["SFTP Tardis API"]:::serviceCls
+ end
+
+ %% SFTPServiceConfig interactions
+ SFTPServiceConfig -. "resolves client secret" .-> SecretManager
+ SFTPServiceConfig -. "fetches access token" .-> TokenIssuer
+ SFTPServiceConfig -- "creates cached API client" --> SftpTardis
+
+ %% Instance interactions
+ Instance -- "creates/updates service user" --> SftpTardis
+ Instance -- "updates public keys" --> SftpTardis
+ Instance -- "deletes service user" --> SftpTardis
+```
+
+### Legend
+
+| Arrow style | Meaning |
+|---|---|
+| **Solid line** (`--creates-->`) | The SFTP controller performs an external operation or prepares a cached client for that service |
+| **Dashed line** (`-.reads.->`) | The SFTP controller reads or resolves data during reconciliation |
+| **Dashed line** (`-.triggers reconcile.->`) | The watched resource change enqueues reconciliation of another resource |
+
+## Interaction Details
+
+### SFTPServiceConfig Controller
+
+The SFTP service configuration controller prepares the HTTP client used to call the SFTP Tardis API.
+A `SFTPServiceConfig` contains the SFTP Tardis API endpoint, OAuth2 issuer URL, client ID, and client secret.
+
+| Target | Relationship | Purpose |
+|---|---|---|
+| **Secret Manager** | resolves | Resolves `spec.api.clientSecret` when the value is a Secret Manager reference |
+| **OAuth2 Token Issuer** | reads | Fetches a client credentials access token to validate the API configuration |
+| **SFTP Tardis API** | caches client for | Creates or refreshes the generated HTTP API client for the referenced endpoint |
+
+The controller stores the observed generation in status and marks the resource ready once the client has been created or refreshed. On deletion, it removes the cached client for that SFTPServiceConfig.
+
+### Instance Controller
+
+The instance controller provisions and maintains the external SFTP service user represented by an `Instance`.
+
+| Target | Relationship | Purpose |
+|---|---|---|
+| **SFTPServiceConfig** | watches/uses | Uses `spec.sftpServiceConfigRef` to resolve the cached SFTP Tardis client |
+| **SFTP Tardis API** | creates/updates/deletes | Creates or updates the SFTP service user and deletes it during finalization |
+
+When an Instance spec changes, the controller creates or updates the SFTP user in the external service.
+
+### User Resource
+
+The User controller watches User resources and Instance status changes.
+A User manages its own SSH public keys for the referenced Instance.
+On reconciliation, the controller canonicalizes only the keys from that User and updates the external service using a per-User client ID. It does not aggregate keys from other Users.
+
+Invalid SSH public keys are skipped during payload generation. Valid keys are sent with the target SFTP user name set to the Instance name and a description based on the User namespace/name.
+
+## Registered Schemes
+
+The SFTP operator registers API types from **1 domain**:
+
+| Domain | API Group | Resources Used |
+|---|---|---|
+| **SFTP** | `sftp.cp.ei.telekom.de` | SFTPServiceConfig, Instance, User |
+
+The operator also calls the Secret Manager API and the SFTP Tardis API as external services, but it does not register Kubernetes API types from those domains.
diff --git a/sftp/go.mod b/sftp/go.mod
new file mode 100644
index 000000000..25e3475ed
--- /dev/null
+++ b/sftp/go.mod
@@ -0,0 +1,162 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+module github.com/telekom/controlplane/sftp
+
+go 1.26.5
+
+require (
+ github.com/telekom/controlplane/common v0.0.0
+ github.com/telekom/controlplane/common-server v0.0.1 // indirect
+ github.com/telekom/controlplane/secret-manager v0.0.0
+ github.com/telekom/controlplane/sftp/api v0.0.0
+)
+
+replace (
+ github.com/telekom/controlplane/common => ../common
+ github.com/telekom/controlplane/common-server => ../common-server
+ github.com/telekom/controlplane/secret-manager => ../secret-manager
+ github.com/telekom/controlplane/sftp/api => ./api
+)
+
+require (
+ github.com/oapi-codegen/runtime v1.6.0
+ github.com/onsi/ginkgo/v2 v2.32.0
+ github.com/onsi/gomega v1.42.1
+ github.com/pkg/errors v0.9.1
+ github.com/stretchr/testify v1.11.1
+ golang.org/x/oauth2 v0.36.0
+ k8s.io/api v0.36.3
+ k8s.io/apimachinery v0.36.3
+ k8s.io/client-go v0.36.3
+ sigs.k8s.io/controller-runtime v0.24.1
+)
+
+require (
+ cel.dev/expr v0.25.2 // indirect
+ github.com/Masterminds/semver/v3 v3.4.0 // indirect
+ github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
+ github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/blang/semver/v4 v4.0.0 // indirect
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/chigopher/pathlib v0.19.1 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
+ github.com/emicklei/go-restful/v3 v3.13.0 // indirect
+ github.com/evanphx/json-patch/v5 v5.9.11 // indirect
+ github.com/felixge/httpsnoop v1.1.0 // indirect
+ github.com/fsnotify/fsnotify v1.10.1 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.2 // indirect
+ github.com/getkin/kin-openapi v0.144.0 // indirect
+ github.com/go-logr/logr v1.4.4 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/go-logr/zapr v1.3.0 // indirect
+ github.com/go-openapi/jsonpointer v1.0.0 // indirect
+ github.com/go-openapi/jsonreference v1.0.0 // indirect
+ github.com/go-openapi/swag v0.28.0 // indirect
+ github.com/go-openapi/swag/cmdutils v0.28.0 // indirect
+ github.com/go-openapi/swag/conv v0.28.0 // indirect
+ github.com/go-openapi/swag/fileutils v0.28.0 // indirect
+ github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
+ github.com/go-openapi/swag/loading v0.28.0 // indirect
+ github.com/go-openapi/swag/mangling v0.28.0 // indirect
+ github.com/go-openapi/swag/netutils v0.28.0 // indirect
+ github.com/go-openapi/swag/pools v0.28.0 // indirect
+ github.com/go-openapi/swag/stringutils v0.28.0 // indirect
+ github.com/go-openapi/swag/typeutils v0.28.0 // indirect
+ github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
+ github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+ github.com/google/cel-go v0.30.0 // indirect
+ github.com/google/gnostic-models v0.7.1 // indirect
+ github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
+ github.com/huandu/xstrings v1.4.0 // indirect
+ github.com/iancoleman/strcase v0.3.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/jinzhu/copier v0.4.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mitchellh/go-homedir v1.1.0 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // 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/oapi-codegen/oapi-codegen/v2 v2.7.1 // indirect
+ github.com/oasdiff/yaml v0.1.1 // indirect
+ github.com/oasdiff/yaml3 v0.0.14 // indirect
+ github.com/pelletier/go-toml/v2 v2.4.3 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/prometheus/client_golang v1.24.1 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.70.1 // indirect
+ github.com/prometheus/procfs v0.21.1 // indirect
+ github.com/rs/zerolog v1.35.1 // indirect
+ github.com/sagikazarmark/locafero v0.12.0 // indirect
+ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
+ github.com/speakeasy-api/jsonpath v0.6.3 // indirect
+ github.com/speakeasy-api/openapi v1.19.2 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spf13/cobra v1.10.2 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/spf13/viper v1.21.0 // indirect
+ github.com/stretchr/objx v0.5.3 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ github.com/vektra/mockery/v2 v2.53.6 // indirect
+ github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
+ go.opentelemetry.io/otel v1.44.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
+ go.opentelemetry.io/otel/metric v1.44.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.44.0 // indirect
+ go.opentelemetry.io/otel/trace v1.44.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.11.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.28.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
+ go.yaml.in/yaml/v3 v3.0.5 // indirect
+ golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
+ golang.org/x/mod v0.38.0 // indirect
+ golang.org/x/net v0.57.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/term v0.45.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ golang.org/x/tools v0.48.0 // indirect
+ gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect
+ google.golang.org/grpc v1.83.0 // indirect
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+ gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ k8s.io/apiextensions-apiserver v0.36.3 // indirect
+ k8s.io/apiserver v0.36.3 // indirect
+ k8s.io/component-base v0.36.3 // indirect
+ k8s.io/klog/v2 v2.140.0 // indirect
+ k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
+ k8s.io/streaming v0.36.3 // indirect
+ k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect
+ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0 // indirect
+ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
+ sigs.k8s.io/randfill v1.0.0 // indirect
+ sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
+ sigs.k8s.io/yaml v1.6.0 // indirect
+)
+
+tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
+
+tool github.com/vektra/mockery/v2
diff --git a/sftp/go.sum b/sftp/go.sum
new file mode 100644
index 000000000..bea950948
--- /dev/null
+++ b/sftp/go.sum
@@ -0,0 +1,444 @@
+cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
+cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
+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/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
+github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
+github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
+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/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
+github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
+github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
+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/chigopher/pathlib v0.19.1 h1:RoLlUJc0CqBGwq239cilyhxPNLXTK+HXoASGyGznx5A=
+github.com/chigopher/pathlib v0.19.1/go.mod h1:tzC1dZLW8o33UQpWkNkhvPwL5n4yyFRFm/jL1YGWFvY=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
+github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
+github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
+github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
+github.com/emicklei/go-restful/v3 v3.13.0/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/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
+github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
+github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
+github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y=
+github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
+github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
+github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
+github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
+github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
+github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
+github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
+github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+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 v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
+github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
+github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
+github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
+github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw=
+github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg=
+github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q=
+github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
+github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8=
+github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k=
+github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU=
+github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
+github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4=
+github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
+github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU=
+github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k=
+github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM=
+github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
+github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k=
+github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
+github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU=
+github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
+github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU=
+github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
+github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY=
+github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
+github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY=
+github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs=
+github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
+github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
+github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
+github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
+github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
+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/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/cel-go v0.30.0 h1:ll54AkzKunWkBn9wSoiUXbFZXYZTkdJGNXTBXUoolGo=
+github.com/google/cel-go v0.30.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
+github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
+github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+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-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
+github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
+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/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
+github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
+github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
+github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
+github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
+github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
+github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
+github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
+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/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
+github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
+github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+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/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
+github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
+github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+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/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
+github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
+github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
+github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
+github.com/oapi-codegen/oapi-codegen/v2 v2.7.1 h1:a7Ab7YlpqkVG5HKrTaeFstm32Z5QOnyjnbsCO0jiMYM=
+github.com/oapi-codegen/oapi-codegen/v2 v2.7.1/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw=
+github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU=
+github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
+github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
+github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
+github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
+github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
+github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
+github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
+github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
+github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
+github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
+github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
+github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
+github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
+github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
+github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
+github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+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.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
+github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
+github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
+github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
+github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
+github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
+github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
+github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU=
+github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI=
+github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M=
+github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
+github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
+github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+github.com/vektra/mockery/v2 v2.53.6 h1:qfUB6saauu652ZlMF/mEdlj7B/A0fw2XR0XBACBrf7Y=
+github.com/vektra/mockery/v2 v2.53.6/go.mod h1:fjxC+mskIZqf67+z34pHxRRyyZnPnWNA36Cirf01Pkg=
+github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
+github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
+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.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
+go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
+go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
+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/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.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+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/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
+golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+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-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
+golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+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-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
+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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+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-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
+golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
+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.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0=
+gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0 h1:ybvH/ZpOcpCrjtkb7oW/fdlzbEmRVeumw19SRQmNFKU=
+google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:HJ9MpJLeDSstBkx1LILTpd5f41ADSMZcTPypw02qEGw=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 h1:mJiOtnGp0k/BcSgdu03G2NwnscCfCH+h2QKUBZr18KI=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
+google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
+gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w=
+k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg=
+k8s.io/apiextensions-apiserver v0.36.3 h1:dPmOAPhwTtqb1bTxbFPsy18KHPhktQeO3WUPXunZIB0=
+k8s.io/apiextensions-apiserver v0.36.3/go.mod h1:KTXFqgXiuw2pRoL+Wpmttqc+up9Xt/GohadPWeLLOa4=
+k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM=
+k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE=
+k8s.io/apiserver v0.36.3 h1:MGSg2SkdfuytiDEcRylT5mQFmmSsbx90XFUO67Y4bsQ=
+k8s.io/apiserver v0.36.3/go.mod h1:fVH7zv9EUNUA7Fl7LtDKh8aB9W7u1VQPSGtWV5SjUxg=
+k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg=
+k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30=
+k8s.io/component-base v0.36.3 h1:vc/UFvPCkW0irPz84LAodAL1j3f4xktPM6dDJIEheAY=
+k8s.io/component-base v0.36.3/go.mod h1:hZbNFG+gCMl9EbykDGEu73feKP9/Cq6JsV4pTo9GTO8=
+k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
+k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
+k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
+k8s.io/streaming v0.36.3 h1:9rAaqBk0C0Pc7+/fqGekj07NV+/Xrew58p647A0JT8w=
+k8s.io/streaming v0.36.3/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
+k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
+k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
+sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0 h1:/YpDJ4vReG7ZmzSpBGxduXgywWkJU9zHubgJG03MT+Y=
+sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0/go.mod h1:tJo1aepTXyR+8Xs3sUsGBDk4Ub2AM5dPAPKJx0mpm5c=
+sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4=
+sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/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.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
+sigs.k8s.io/structured-merge-diff/v6 v6.4.2/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/sftp/go.sum.license b/sftp/go.sum.license
new file mode 100644
index 000000000..be863cd5c
--- /dev/null
+++ b/sftp/go.sum.license
@@ -0,0 +1,3 @@
+Copyright 2026 Deutsche Telekom IT GmbH
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/sftp/internal/controller/index.go b/sftp/internal/controller/index.go
new file mode 100644
index 000000000..5036f1374
--- /dev/null
+++ b/sftp/internal/controller/index.go
@@ -0,0 +1,45 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+ "os"
+
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+)
+
+func RegisterIndexesOrDie(ctx context.Context, mgr ctrl.Manager) {
+ filterSFTPServiceConfigOnInstance := func(obj client.Object) []string {
+ instance, ok := obj.(*sftpv1.Instance)
+ if !ok || instance.Spec.SFTPServiceConfigRef.IsEmpty() {
+ return nil
+ }
+ return []string{instance.Spec.SFTPServiceConfigRef.String()}
+ }
+
+ err := mgr.GetFieldIndexer().IndexField(ctx, &sftpv1.Instance{}, sftpv1.IndexFieldSpecSFTPServiceConfigRef, filterSFTPServiceConfigOnInstance)
+ if err != nil {
+ ctrl.Log.Error(err, "unable to create fieldIndex for Instance", "FieldIndex", sftpv1.IndexFieldSpecSFTPServiceConfigRef)
+ os.Exit(1)
+ }
+
+ filterInstanceOnUser := func(obj client.Object) []string {
+ user, ok := obj.(*sftpv1.User)
+ if !ok || user.Spec.InstanceRef.IsEmpty() {
+ return nil
+ }
+ return []string{user.Spec.InstanceRef.String()}
+ }
+
+ err = mgr.GetFieldIndexer().IndexField(ctx, &sftpv1.User{}, sftpv1.IndexFieldSpecInstanceRef, filterInstanceOnUser)
+ if err != nil {
+ ctrl.Log.Error(err, "unable to create fieldIndex for User", "FieldIndex", sftpv1.IndexFieldSpecInstanceRef)
+ os.Exit(1)
+ }
+}
diff --git a/sftp/internal/controller/instance_controller.go b/sftp/internal/controller/instance_controller.go
new file mode 100644
index 000000000..18bc56e19
--- /dev/null
+++ b/sftp/internal/controller/instance_controller.go
@@ -0,0 +1,93 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+//nolint:dupl // kubebuilder controller scaffolding is structurally identical across CRD types
+package controller
+
+import (
+ "context"
+ "fmt"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ "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/handler"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ cconfig "github.com/telekom/controlplane/common/pkg/config"
+ cc "github.com/telekom/controlplane/common/pkg/controller"
+ commontypes "github.com/telekom/controlplane/common/pkg/types"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+ instance_handler "github.com/telekom/controlplane/sftp/internal/handler/instance"
+ "github.com/telekom/controlplane/sftp/internal/service"
+)
+
+// InstanceReconciler reconciles an Instance object.
+type InstanceReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+ Recorder record.EventRecorder
+ ServiceFactory service.Factory
+
+ cc.Controller[*sftpv1.Instance]
+}
+
+// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
+
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=instances,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=instances/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=instances/finalizers,verbs=update
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=sftpserviceconfigs,verbs=get;list;watch
+
+func (r *InstanceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ return r.Controller.Reconcile(ctx, req, &sftpv1.Instance{})
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *InstanceReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ instanceHandler, err := instance_handler.New(r.ServiceFactory)
+ if err != nil {
+ return fmt.Errorf("creating instance handler: %w", err)
+ }
+ r.Recorder = mgr.GetEventRecorderFor("instance-controller")
+
+ r.Controller = cc.NewController(instanceHandler, r.Client, r.Recorder)
+
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&sftpv1.Instance{}).
+ Watches(&sftpv1.SFTPServiceConfig{},
+ handler.EnqueueRequestsFromMapFunc(r.MapSFTPServiceConfigToInstance),
+ builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
+ ).
+ WithOptions(controller.Options{
+ MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles,
+ RateLimiter: cc.NewRateLimiter(),
+ }).
+ Complete(r)
+}
+
+func (r *InstanceReconciler) MapSFTPServiceConfigToInstance(ctx context.Context, obj client.Object) []reconcile.Request {
+ sftpServiceConfig, ok := obj.(*sftpv1.SFTPServiceConfig)
+ if !ok {
+ return nil
+ }
+
+ list := &sftpv1.InstanceList{}
+ err := r.List(ctx, list, client.MatchingFields{
+ sftpv1.IndexFieldSpecSFTPServiceConfigRef: commontypes.ObjectRefFromObject(sftpServiceConfig).String(),
+ })
+ if err != nil {
+ return nil
+ }
+
+ reqs := make([]reconcile.Request, 0, len(list.Items))
+ for i := range list.Items {
+ reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])})
+ }
+ return reqs
+}
diff --git a/sftp/internal/controller/instance_controller_test.go b/sftp/internal/controller/instance_controller_test.go
new file mode 100644
index 000000000..9107d406e
--- /dev/null
+++ b/sftp/internal/controller/instance_controller_test.go
@@ -0,0 +1,177 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ "github.com/telekom/controlplane/common/pkg/condition"
+ config "github.com/telekom/controlplane/common/pkg/config"
+ commontypes "github.com/telekom/controlplane/common/pkg/types"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Instance Controller", func() {
+ Context("When reconciling a resource", func() {
+ ctx := context.Background()
+ const instanceName = "test-instance"
+ const sftpServiceConfigName = "test-sftpserviceconfig-for-instance"
+
+ instanceKey := client.ObjectKey{Name: instanceName, Namespace: testNamespace}
+ sftpServiceConfigKey := client.ObjectKey{Name: sftpServiceConfigName, Namespace: testNamespace}
+
+ testSFTPServiceConfig := &sftpv1.SFTPServiceConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: sftpServiceConfigName, Namespace: testNamespace,
+ Labels: map[string]string{config.EnvironmentLabelKey: "test"},
+ },
+ Spec: sftpv1.SFTPServiceConfigSpec{
+ API: sftpv1.APIEndpoint{
+ ClientID: "client-id",
+ ClientSecret: "secret-manager://path/to/secret",
+ Endpoint: "https://example.de/base-path/",
+ Issuer: "https://issuer.example.de/auth/realms/default/protocol/openid-connect/token",
+ },
+ },
+ }
+
+ testInstance := &sftpv1.Instance{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: instanceName, Namespace: testNamespace,
+ Labels: map[string]string{config.EnvironmentLabelKey: "test"},
+ },
+ Spec: sftpv1.InstanceSpec{
+ Description: "Test instance for controller test",
+ SFTPServiceConfigRef: commontypes.ObjectRef{
+ Name: sftpServiceConfigName,
+ Namespace: testNamespace,
+ },
+ },
+ }
+
+ BeforeEach(func() {
+ By("creating required SFTPServiceConfig")
+ resource := &sftpv1.SFTPServiceConfig{}
+ err := k8sClient.Get(ctx, sftpServiceConfigKey, resource)
+ if err != nil && errors.IsNotFound(err) {
+ Expect(k8sClient.Create(ctx, testSFTPServiceConfig)).To(Succeed())
+ }
+
+ By("creating the custom resource for the Kind Instance")
+ instance := &sftpv1.Instance{}
+ err = k8sClient.Get(ctx, instanceKey, instance)
+ if err != nil && errors.IsNotFound(err) {
+ Expect(k8sClient.Create(ctx, testInstance)).To(Succeed())
+ }
+ })
+
+ AfterEach(func() {
+ instance := &sftpv1.Instance{}
+ err := k8sClient.Get(ctx, instanceKey, instance)
+ Expect(err).NotTo(HaveOccurred())
+ By("cleaning up the Instance resource")
+ Expect(k8sClient.Delete(ctx, instance)).To(Succeed())
+
+ sftpServiceConfig := &sftpv1.SFTPServiceConfig{}
+ err = k8sClient.Get(ctx, sftpServiceConfigKey, sftpServiceConfig)
+ Expect(err).NotTo(HaveOccurred())
+ By("cleaning up the SFTPServiceConfig resource")
+ Expect(k8sClient.Delete(ctx, sftpServiceConfig)).To(Succeed())
+ })
+
+ It("should successfully reconcile the resource", func() {
+ Eventually(func(g Gomega) {
+ VerifyInstance(ctx, g, instanceKey)
+ }, timeout, interval).Should(Succeed())
+ })
+ })
+
+ Context("When mapping SFTPServiceConfig changes", func() {
+ It("uses the SFTPServiceConfig reference field index to find Instances", func() {
+ scheme := runtime.NewScheme()
+ Expect(clientgoscheme.AddToScheme(scheme)).To(Succeed())
+ Expect(sftpv1.AddToScheme(scheme)).To(Succeed())
+
+ const (
+ sftpServiceConfigName = "indexed-sftpserviceconfig"
+ matchingInstanceName = "matching-instance"
+ otherInstanceName = "other-instance"
+ )
+
+ sftpServiceConfig := &sftpv1.SFTPServiceConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: sftpServiceConfigName,
+ Namespace: testNamespace,
+ },
+ }
+ matchingInstance := &sftpv1.Instance{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: matchingInstanceName,
+ Namespace: testNamespace,
+ },
+ Spec: sftpv1.InstanceSpec{
+ SFTPServiceConfigRef: commontypes.ObjectRef{
+ Name: sftpServiceConfigName,
+ Namespace: testNamespace,
+ },
+ },
+ }
+ otherInstance := &sftpv1.Instance{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: otherInstanceName,
+ Namespace: testNamespace,
+ },
+ Spec: sftpv1.InstanceSpec{
+ SFTPServiceConfigRef: commontypes.ObjectRef{
+ Name: "other-sftpserviceconfig",
+ Namespace: testNamespace,
+ },
+ },
+ }
+
+ k8sClient := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(matchingInstance, otherInstance).
+ WithIndex(&sftpv1.Instance{}, sftpv1.IndexFieldSpecSFTPServiceConfigRef, func(obj client.Object) []string {
+ instance, ok := obj.(*sftpv1.Instance)
+ if !ok || instance.Spec.SFTPServiceConfigRef.IsEmpty() {
+ return nil
+ }
+ return []string{instance.Spec.SFTPServiceConfigRef.String()}
+ }).
+ Build()
+ reconciler := &InstanceReconciler{Client: k8sClient}
+
+ reqs := reconciler.MapSFTPServiceConfigToInstance(context.Background(), sftpServiceConfig)
+
+ Expect(reqs).To(HaveLen(1))
+ Expect(reqs[0].NamespacedName).To(Equal(client.ObjectKeyFromObject(matchingInstance)))
+ })
+ })
+})
+
+func VerifyInstance(ctx context.Context, g Gomega, namespacedName client.ObjectKey) {
+ By("checking if the Instance is created and all conditions are set")
+ instance := &sftpv1.Instance{}
+ err := k8sClient.Get(ctx, namespacedName, instance)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ ready := meta.FindStatusCondition(instance.Status.Conditions, condition.ConditionTypeReady)
+ g.Expect(ready).NotTo(BeNil())
+ g.Expect(ready.ObservedGeneration).To(Equal(instance.Generation))
+ g.Expect(meta.IsStatusConditionTrue(instance.Status.Conditions, condition.ConditionTypeProcessing)).To(BeFalse())
+ g.Expect(meta.IsStatusConditionTrue(instance.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+}
diff --git a/sftp/internal/controller/schema.go b/sftp/internal/controller/schema.go
new file mode 100644
index 000000000..a71dd6d3c
--- /dev/null
+++ b/sftp/internal/controller/schema.go
@@ -0,0 +1,18 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "k8s.io/apimachinery/pkg/runtime"
+ utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+)
+
+func RegisterSchemesOrDie(scheme *runtime.Scheme) {
+ utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+ utilruntime.Must(sftpv1.AddToScheme(scheme))
+}
diff --git a/sftp/internal/controller/sftpserviceconfig_controller.go b/sftp/internal/controller/sftpserviceconfig_controller.go
new file mode 100644
index 000000000..51a35c480
--- /dev/null
+++ b/sftp/internal/controller/sftpserviceconfig_controller.go
@@ -0,0 +1,60 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+ "fmt"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ "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/controller"
+
+ cconfig "github.com/telekom/controlplane/common/pkg/config"
+ cc "github.com/telekom/controlplane/common/pkg/controller"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+ sftpserviceconfig_handler "github.com/telekom/controlplane/sftp/internal/handler/sftpserviceconfig"
+ "github.com/telekom/controlplane/sftp/internal/service"
+)
+
+// SFTPServiceConfigReconciler reconciles an SFTPServiceConfig object
+type SFTPServiceConfigReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+ Recorder record.EventRecorder
+ ClientManager service.ClientManager
+
+ cc.Controller[*sftpv1.SFTPServiceConfig]
+}
+
+// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
+
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=sftpserviceconfigs,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=sftpserviceconfigs/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=sftpserviceconfigs/finalizers,verbs=update
+
+func (r *SFTPServiceConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ return r.Controller.Reconcile(ctx, req, &sftpv1.SFTPServiceConfig{})
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *SFTPServiceConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ sftpserviceconfigHandler, err := sftpserviceconfig_handler.New(r.ClientManager)
+ if err != nil {
+ return fmt.Errorf("creating SFTPServiceConfig handler: %w", err)
+ }
+ r.Recorder = mgr.GetEventRecorderFor("sftpserviceconfig-controller")
+ r.Controller = cc.NewController(sftpserviceconfigHandler, r.Client, r.Recorder)
+
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&sftpv1.SFTPServiceConfig{}).
+ WithOptions(controller.Options{
+ MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles,
+ RateLimiter: cc.NewRateLimiter(),
+ }).
+ Complete(r)
+}
diff --git a/sftp/internal/controller/sftpserviceconfig_controller_test.go b/sftp/internal/controller/sftpserviceconfig_controller_test.go
new file mode 100644
index 000000000..311ee1d35
--- /dev/null
+++ b/sftp/internal/controller/sftpserviceconfig_controller_test.go
@@ -0,0 +1,88 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ "github.com/telekom/controlplane/common/pkg/condition"
+ config "github.com/telekom/controlplane/common/pkg/config"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("SFTPServiceConfig Controller", func() {
+ Context("When reconciling a resource", func() {
+ ctx := context.Background()
+ const resourceName = "test-sftpserviceconfig"
+
+ typeNamespacedName := client.ObjectKey{
+ Name: resourceName,
+ Namespace: testNamespace,
+ }
+ testSFTPServiceConfig := &sftpv1.SFTPServiceConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: resourceName,
+ Namespace: testNamespace,
+ Labels: map[string]string{
+ config.EnvironmentLabelKey: "test",
+ },
+ },
+ Spec: sftpv1.SFTPServiceConfigSpec{
+ API: sftpv1.APIEndpoint{
+ ClientID: "client-id",
+ ClientSecret: "secret-manager://path/to/secret",
+ Endpoint: "https://issuer.example.de/oauth/token",
+ Issuer: "https://issuer.example.de",
+ },
+ },
+ }
+
+ BeforeEach(func() {
+ By("creating the custom resource for the Kind SFTPServiceConfig")
+ resource := &sftpv1.SFTPServiceConfig{}
+ err := k8sClient.Get(ctx, typeNamespacedName, resource)
+ if err != nil && errors.IsNotFound(err) {
+ Expect(k8sClient.Create(ctx, testSFTPServiceConfig)).To(Succeed())
+ }
+ })
+
+ AfterEach(func() {
+ resource := &sftpv1.SFTPServiceConfig{}
+ err := k8sClient.Get(ctx, typeNamespacedName, resource)
+ Expect(err).NotTo(HaveOccurred())
+
+ By("Cleanup the specific resource instance SFTPServiceConfig")
+ Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
+ })
+
+ It("should successfully reconcile the resource", func() {
+ Eventually(func(g Gomega) {
+ VerifySFTPServiceConfig(ctx, g, typeNamespacedName)
+ }, timeout, interval).Should(Succeed())
+ })
+ })
+})
+
+func VerifySFTPServiceConfig(ctx context.Context, g Gomega, namespacedName client.ObjectKey) {
+ By("Checking if the SFTPServiceConfig is created and all conditions are set")
+ sftpServiceConfig := &sftpv1.SFTPServiceConfig{}
+ err := k8sClient.Get(ctx, namespacedName, sftpServiceConfig)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ ready := meta.FindStatusCondition(sftpServiceConfig.Status.Conditions, condition.ConditionTypeReady)
+ g.Expect(ready).NotTo(BeNil())
+ g.Expect(ready.ObservedGeneration).To(Equal(sftpServiceConfig.Generation))
+ g.Expect(sftpServiceConfig.Status.Conditions).To(HaveLen(2))
+ g.Expect(meta.IsStatusConditionTrue(sftpServiceConfig.Status.Conditions, condition.ConditionTypeProcessing)).To(BeFalse())
+ g.Expect(meta.IsStatusConditionTrue(sftpServiceConfig.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+}
diff --git a/sftp/internal/controller/suite_test.go b/sftp/internal/controller/suite_test.go
new file mode 100644
index 000000000..686f79c46
--- /dev/null
+++ b/sftp/internal/controller/suite_test.go
@@ -0,0 +1,135 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+ "time"
+
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/kubernetes/scheme"
+ "k8s.io/client-go/rest"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/envtest"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/log/zap"
+ "sigs.k8s.io/controller-runtime/pkg/metrics/server"
+
+ "github.com/telekom/controlplane/sftp/internal/service"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ // +kubebuilder:scaffold:imports
+)
+
+const (
+ timeout = 2 * time.Second
+ interval = 200 * time.Millisecond
+ testNamespace = "default"
+)
+
+var (
+ cfg *rest.Config
+ k8sClient client.Client
+ testEnv *envtest.Environment
+ ctx context.Context
+ cancel context.CancelFunc
+)
+
+func TestControllers(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Controller Suite")
+}
+
+var _ = BeforeSuite(func() {
+ logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
+
+ ctx, cancel = context.WithCancel(context.TODO())
+
+ By("bootstrapping test environment")
+ testEnv = &envtest.Environment{
+ CRDDirectoryPaths: []string{
+ filepath.Join("..", "..", "config", "crd", "bases"),
+ },
+ ErrorIfCRDPathMissing: true,
+
+ BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s",
+ fmt.Sprintf("%s-%s-%s", os.Getenv("ENVTEST_K8S_VERSION"), runtime.GOOS, runtime.GOARCH)),
+ }
+
+ var err error
+ cfg, err = testEnv.Start()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(cfg).NotTo(BeNil())
+
+ RegisterSchemesOrDie(scheme.Scheme)
+ // +kubebuilder:scaffold:scheme
+
+ k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(k8sClient).NotTo(BeNil())
+
+ k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{
+ Scheme: scheme.Scheme,
+ Metrics: server.Options{
+ BindAddress: "0",
+ },
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ RegisterIndexesOrDie(ctx, k8sManager)
+
+ clientManager := service.NewNopClientManager()
+
+ err = (&InstanceReconciler{
+ Client: k8sManager.GetClient(),
+ Scheme: k8sManager.GetScheme(),
+ ServiceFactory: clientManager,
+ }).SetupWithManager(k8sManager)
+ Expect(err).ToNot(HaveOccurred())
+
+ err = (&UserReconciler{
+ Client: k8sManager.GetClient(),
+ Scheme: k8sManager.GetScheme(),
+ ServiceFactory: clientManager,
+ }).SetupWithManager(k8sManager)
+ Expect(err).ToNot(HaveOccurred())
+
+ err = (&SFTPServiceConfigReconciler{
+ Client: k8sManager.GetClient(),
+ Scheme: k8sManager.GetScheme(),
+ ClientManager: clientManager,
+ }).SetupWithManager(k8sManager)
+ Expect(err).ToNot(HaveOccurred())
+
+ go func() {
+ defer GinkgoRecover()
+ err = k8sManager.Start(ctx)
+ Expect(err).ToNot(HaveOccurred(), "failed to run manager")
+ }()
+})
+
+var _ = AfterSuite(func() {
+ By("tearing down the test environment")
+ cancel()
+ err := testEnv.Stop()
+ Expect(err).NotTo(HaveOccurred())
+})
+
+func CreateNamespace(name string) {
+ ns := &corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ },
+ }
+ Expect(k8sClient.Create(ctx, ns)).To(Succeed())
+}
diff --git a/sftp/internal/controller/user_controller.go b/sftp/internal/controller/user_controller.go
new file mode 100644
index 000000000..bf16a12d8
--- /dev/null
+++ b/sftp/internal/controller/user_controller.go
@@ -0,0 +1,91 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+//nolint:dupl // kubebuilder controller scaffolding is structurally identical across CRD types
+package controller
+
+import (
+ "context"
+ "fmt"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ "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/handler"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ cconfig "github.com/telekom/controlplane/common/pkg/config"
+ cc "github.com/telekom/controlplane/common/pkg/controller"
+ commontypes "github.com/telekom/controlplane/common/pkg/types"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+ user_handler "github.com/telekom/controlplane/sftp/internal/handler/user"
+ "github.com/telekom/controlplane/sftp/internal/service"
+)
+
+// UserReconciler reconciles a User object.
+type UserReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+ Recorder record.EventRecorder
+ ServiceFactory service.Factory
+
+ cc.Controller[*sftpv1.User]
+}
+
+// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
+
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=users,verbs=get;list;watch;update;patch
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=users/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=users/finalizers,verbs=update
+// +kubebuilder:rbac:groups=sftp.cp.ei.telekom.de,resources=instances,verbs=get;list;watch
+
+func (r *UserReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ return r.Controller.Reconcile(ctx, req, &sftpv1.User{})
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *UserReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ userHandler, err := user_handler.New(r.ServiceFactory)
+ if err != nil {
+ return fmt.Errorf("creating user handler: %w", err)
+ }
+
+ r.Recorder = mgr.GetEventRecorderFor("user-controller")
+ r.Controller = cc.NewController(userHandler, r.Client, r.Recorder)
+
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&sftpv1.User{}).
+ Watches(&sftpv1.Instance{},
+ handler.EnqueueRequestsFromMapFunc(r.MapInstanceToUsers),
+ builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
+ ).
+ WithOptions(controller.Options{
+ MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles,
+ RateLimiter: cc.NewRateLimiter(),
+ }).
+ Complete(r)
+}
+
+func (r *UserReconciler) MapInstanceToUsers(ctx context.Context, obj client.Object) []reconcile.Request {
+ instance, ok := obj.(*sftpv1.Instance)
+ if !ok {
+ return nil
+ }
+
+ list := &sftpv1.UserList{}
+ err := r.List(ctx, list, client.MatchingFields{sftpv1.IndexFieldSpecInstanceRef: commontypes.ObjectRefFromObject(instance).String()})
+ if err != nil {
+ return nil
+ }
+
+ reqs := make([]reconcile.Request, 0, len(list.Items))
+ for i := range list.Items {
+ reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])})
+ }
+ return reqs
+}
diff --git a/sftp/internal/controller/user_controller_test.go b/sftp/internal/controller/user_controller_test.go
new file mode 100644
index 000000000..80549e43f
--- /dev/null
+++ b/sftp/internal/controller/user_controller_test.go
@@ -0,0 +1,145 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package controller
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ "github.com/telekom/controlplane/common/pkg/condition"
+ config "github.com/telekom/controlplane/common/pkg/config"
+ commontypes "github.com/telekom/controlplane/common/pkg/types"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("User Controller", func() {
+ Context("When reconciling a User", func() {
+ ctx := context.Background()
+ const (
+ instanceName = "test-instance-for-user"
+ sftpServiceConfigName = "test-sftpserviceconfig-for-user"
+ userName = "test-user"
+ )
+
+ instanceKey := client.ObjectKey{Name: instanceName, Namespace: testNamespace}
+ sftpServiceConfigKey := client.ObjectKey{Name: sftpServiceConfigName, Namespace: testNamespace}
+ userKey := client.ObjectKey{Name: userName, Namespace: testNamespace}
+
+ testSFTPServiceConfig := &sftpv1.SFTPServiceConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: sftpServiceConfigName, Namespace: testNamespace,
+ Labels: map[string]string{config.EnvironmentLabelKey: "test"},
+ },
+ Spec: sftpv1.SFTPServiceConfigSpec{
+ API: sftpv1.APIEndpoint{
+ ClientID: "client-id",
+ ClientSecret: "secret-manager://path/to/secret",
+ Endpoint: "https://example.de/base-path/",
+ Issuer: "https://issuer.example.de/auth/realms/default/protocol/openid-connect/token",
+ },
+ },
+ }
+
+ testInstance := &sftpv1.Instance{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: instanceName, Namespace: testNamespace,
+ Labels: map[string]string{config.EnvironmentLabelKey: "test"},
+ },
+ Spec: sftpv1.InstanceSpec{
+ Description: "Test instance for user controller test",
+ SFTPServiceConfigRef: commontypes.ObjectRef{
+ Name: sftpServiceConfigName,
+ Namespace: testNamespace,
+ },
+ },
+ }
+
+ testUser := &sftpv1.User{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: userName, Namespace: testNamespace,
+ Labels: map[string]string{config.EnvironmentLabelKey: "test"},
+ },
+ Spec: sftpv1.UserSpec{
+ InstanceRef: commontypes.ObjectRef{
+ Name: instanceName,
+ Namespace: testNamespace,
+ },
+ SSHPublicKeys: []string{"ssh-rsa cHJvdmlkZXI= provider@example.com"},
+ },
+ }
+
+ BeforeEach(func() {
+ By("creating required SFTPServiceConfig")
+ resource := &sftpv1.SFTPServiceConfig{}
+ err := k8sClient.Get(ctx, sftpServiceConfigKey, resource)
+ if err != nil && errors.IsNotFound(err) {
+ Expect(k8sClient.Create(ctx, testSFTPServiceConfig.DeepCopy())).To(Succeed())
+ }
+
+ By("creating the Instance resource")
+ instance := &sftpv1.Instance{}
+ err = k8sClient.Get(ctx, instanceKey, instance)
+ if err != nil && errors.IsNotFound(err) {
+ Expect(k8sClient.Create(ctx, testInstance.DeepCopy())).To(Succeed())
+ }
+
+ By("creating the User resource")
+ user := &sftpv1.User{}
+ err = k8sClient.Get(ctx, userKey, user)
+ if err != nil && errors.IsNotFound(err) {
+ Expect(k8sClient.Create(ctx, testUser.DeepCopy())).To(Succeed())
+ }
+ })
+
+ AfterEach(func() {
+ user := &sftpv1.User{}
+ err := k8sClient.Get(ctx, userKey, user)
+ if err == nil {
+ By("cleaning up the User resource")
+ Expect(k8sClient.Delete(ctx, user)).To(Succeed())
+ }
+
+ instance := &sftpv1.Instance{}
+ err = k8sClient.Get(ctx, instanceKey, instance)
+ if err == nil {
+ By("cleaning up the Instance resource")
+ Expect(k8sClient.Delete(ctx, instance)).To(Succeed())
+ }
+
+ sftpServiceConfig := &sftpv1.SFTPServiceConfig{}
+ err = k8sClient.Get(ctx, sftpServiceConfigKey, sftpServiceConfig)
+ if err == nil {
+ By("cleaning up the SFTPServiceConfig resource")
+ Expect(k8sClient.Delete(ctx, sftpServiceConfig)).To(Succeed())
+ }
+ })
+
+ It("projects Instance user processing status onto User status", func() {
+ Eventually(func(g Gomega) {
+ VerifyUser(ctx, g, userKey)
+ }, timeout, interval).Should(Succeed())
+ })
+ })
+})
+
+func VerifyUser(ctx context.Context, g Gomega, namespacedName client.ObjectKey) {
+ By("checking if the User status is projected from the Instance")
+ user := &sftpv1.User{}
+ err := k8sClient.Get(ctx, namespacedName, user)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ ready := meta.FindStatusCondition(user.Status.Conditions, condition.ConditionTypeReady)
+ g.Expect(ready).NotTo(BeNil())
+ g.Expect(ready.ObservedGeneration).To(Equal(user.Generation))
+ g.Expect(meta.IsStatusConditionTrue(user.Status.Conditions, condition.ConditionTypeProcessing)).To(BeFalse())
+ g.Expect(meta.IsStatusConditionTrue(user.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+}
diff --git a/sftp/internal/handler/instance/handler.go b/sftp/internal/handler/instance/handler.go
new file mode 100644
index 000000000..89fb1c1e1
--- /dev/null
+++ b/sftp/internal/handler/instance/handler.go
@@ -0,0 +1,100 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package instance
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/pkg/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+
+ "github.com/telekom/controlplane/common/pkg/condition"
+ "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors"
+ "github.com/telekom/controlplane/common/pkg/handler"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+ "github.com/telekom/controlplane/sftp/internal/service"
+)
+
+var _ handler.Handler[*sftpv1.Instance] = &InstanceHandler{}
+
+type InstanceHandler struct {
+ serviceFactory service.Factory
+}
+
+func New(serviceFactory service.Factory) (*InstanceHandler, error) {
+ if serviceFactory == nil {
+ return nil, errors.New("service factory is required")
+ }
+
+ return &InstanceHandler{
+ serviceFactory: serviceFactory,
+ }, nil
+}
+
+func (h *InstanceHandler) CreateOrUpdate(ctx context.Context, obj *sftpv1.Instance) error {
+ if obj.Spec.SFTPServiceConfigRef.IsEmpty() {
+ return ctrlerrors.BlockedErrorf("SFTPServiceConfig reference is required")
+ }
+
+ log := logf.FromContext(ctx)
+
+ sftpService, err := h.serviceFactory.ServiceFor(ctx, obj.Spec.SFTPServiceConfigRef.K8s())
+ if err != nil {
+ return err
+ }
+
+ conditionReady := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady)
+ if conditionReady == nil || conditionReady.ObservedGeneration != obj.Generation || conditionReady.Status != v1.ConditionTrue {
+ log.Info("Instance spec has changed, provisioning SFTP user in external service")
+ obj.SetCondition(condition.NewProcessingCondition("Provisioning", "Instance is being provided"))
+
+ err = h.createOrUpdateServiceUser(ctx, sftpService, obj)
+ if err != nil {
+ return err
+ }
+ }
+ obj.SetCondition(condition.NewReadyCondition("InstanceProvided", "Instance has been provided"))
+ obj.SetCondition(condition.NewDoneProcessingCondition("Instance has been provided"))
+ return nil
+}
+
+func (h *InstanceHandler) Delete(ctx context.Context, obj *sftpv1.Instance) error {
+ if obj.Spec.SFTPServiceConfigRef.IsEmpty() {
+ return nil
+ }
+
+ sftpService, err := h.serviceFactory.ServiceFor(ctx, obj.Spec.SFTPServiceConfigRef.K8s())
+ if err != nil {
+ return err
+ }
+
+ err = sftpService.DeleteSFTPUser(ctx, obj.Name)
+ if err != nil {
+ return fmt.Errorf("deleting SFTP user %q: %w", obj.Name, err)
+ }
+
+ return nil
+}
+
+func (h *InstanceHandler) createOrUpdateServiceUser(ctx context.Context, sftpService service.Service, instance *sftpv1.Instance) error {
+ events := []service.RoverSftpUserModelHorizonNotificationEvents{}
+
+ sftpUser := service.RoverSftpUserModel{
+ SftpUserName: instance.Name,
+ // nil creates invalid user in an external service
+ HorizonNotificationEvents: &events,
+ }
+
+ sftpUser.Description = &instance.Spec.Description
+
+ if err := sftpService.CreateOrUpdateSFTPUser(ctx, sftpUser); err != nil {
+ return fmt.Errorf("creating or updating SFTP user %q: %w", instance.Name, err)
+ }
+
+ return nil
+}
diff --git a/sftp/internal/handler/instance/handler_suite_test.go b/sftp/internal/handler/instance/handler_suite_test.go
new file mode 100644
index 000000000..5064ac2da
--- /dev/null
+++ b/sftp/internal/handler/instance/handler_suite_test.go
@@ -0,0 +1,17 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package instance
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestInstanceHandler(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Instance Handler Suite")
+}
diff --git a/sftp/internal/handler/instance/handler_test.go b/sftp/internal/handler/instance/handler_test.go
new file mode 100644
index 000000000..b229612c0
--- /dev/null
+++ b/sftp/internal/handler/instance/handler_test.go
@@ -0,0 +1,213 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package instance
+
+import (
+ "context"
+ "errors"
+
+ "github.com/stretchr/testify/mock"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ cclient "github.com/telekom/controlplane/common/pkg/client"
+ fakeclient "github.com/telekom/controlplane/common/pkg/client/fake"
+ "github.com/telekom/controlplane/common/pkg/condition"
+ "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors"
+ "github.com/telekom/controlplane/common/pkg/types"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+ "github.com/telekom/controlplane/sftp/internal/service"
+ sftpmocks "github.com/telekom/controlplane/sftp/test/mocks"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+const (
+ instanceHandlerTestNamespace = "test"
+ instanceHandlerTestName = "test-instance"
+ instanceHandlerTestSFTPServiceConfigName = "test-sftpServiceConfig"
+)
+
+var _ = Describe("InstanceHandler", func() {
+ It("requires a service factory", func() {
+ handler, err := New(nil)
+
+ Expect(err).To(MatchError("service factory is required"))
+ Expect(handler).To(BeNil())
+ })
+
+ It("marks an Instance ready when its SFTPServiceConfig exists", func() {
+ handler, ctx, instance, mockService := newTestHandler()
+ expectCreateOrUpdateSFTPUser(mockService, nil, nil)
+
+ Expect(handler.CreateOrUpdate(ctx, instance)).To(Succeed())
+ Expect(meta.IsStatusConditionTrue(instance.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ Expect(meta.IsStatusConditionFalse(instance.Status.Conditions, condition.ConditionTypeProcessing)).To(BeTrue())
+ })
+
+ It("blocks when SFTPServiceConfig reference is missing", func() {
+ handler, ctx, instance, _ := newTestHandler()
+ instance.Spec.SFTPServiceConfigRef = types.ObjectRef{}
+
+ err := handler.CreateOrUpdate(ctx, instance)
+
+ var blocked ctrlerrors.BlockedError
+ Expect(errors.As(err, &blocked)).To(BeTrue())
+ Expect(err).To(MatchError(ContainSubstring("SFTPServiceConfig reference is required")))
+ })
+
+ It("retries when the referenced SFTPServiceConfig service is unavailable", func() {
+ handler, ctx, instance, _ := newTestHandlerWithFactory(recordingFactory{
+ err: ctrlerrors.RetryableErrorf("SFTP client for SFTPServiceConfig %q is not initialized", "test/test-sftpServiceConfig"),
+ })
+
+ err := handler.CreateOrUpdate(ctx, instance)
+
+ var retryable ctrlerrors.RetryableError
+ Expect(errors.As(err, &retryable)).To(BeTrue())
+ Expect(err).To(MatchError(ContainSubstring("SFTPServiceConfig")))
+ Expect(err).To(MatchError(ContainSubstring("not initialized")))
+ })
+
+ It("creates a service user with description and empty Horizon notification events", func() {
+ handler, ctx, instance, mockService := newTestHandler()
+ instance.Spec.Description = "Team transfer user"
+ createdModel := service.RoverSftpUserModel{}
+ expectCreateOrUpdateSFTPUser(mockService, &createdModel, nil)
+
+ Expect(handler.CreateOrUpdate(ctx, instance)).To(Succeed())
+
+ Expect(createdModel.SftpUserName).To(Equal(instanceHandlerTestName))
+ Expect(createdModel.Description).NotTo(BeNil())
+ Expect(*createdModel.Description).To(Equal("Team transfer user"))
+ Expect(createdModel.HorizonNotificationEvents).NotTo(BeNil())
+ Expect(*createdModel.HorizonNotificationEvents).To(BeEmpty())
+ })
+
+ It("skips service user provisioning when the Ready condition observed generation is current", func() {
+ handler, ctx, instance, _ := newTestHandler()
+ ready := condition.NewReadyCondition("InstanceProvided", "Instance has been provided")
+ ready.ObservedGeneration = instance.Generation
+ instance.SetCondition(ready)
+
+ Expect(handler.CreateOrUpdate(ctx, instance)).To(Succeed())
+ })
+
+ It("provisions the service user when the Ready condition observed generation is stale", func() {
+ handler, ctx, instance, mockService := newTestHandler()
+ ready := condition.NewReadyCondition("InstanceProvided", "Instance has been provided")
+ ready.ObservedGeneration = instance.Generation - 1
+ instance.SetCondition(ready)
+ expectCreateOrUpdateSFTPUser(mockService, nil, nil)
+
+ Expect(handler.CreateOrUpdate(ctx, instance)).To(Succeed())
+ })
+
+ It("returns service user creation errors", func() {
+ handler, ctx, instance, mockService := newTestHandler()
+ expectCreateOrUpdateSFTPUser(mockService, nil, errors.New("create failed"))
+
+ err := handler.CreateOrUpdate(ctx, instance)
+
+ Expect(err).To(MatchError(ContainSubstring("creating or updating SFTP user")))
+ Expect(err).To(MatchError(ContainSubstring("create failed")))
+ })
+
+ It("deletes the instance SFTP user", func() {
+ handler, ctx, instance, mockService := newTestHandler()
+ expectDeleteSFTPUser(mockService, nil)
+
+ Expect(handler.Delete(ctx, instance)).To(Succeed())
+ })
+
+ It("does not delete a service user when SFTPServiceConfig reference is missing", func() {
+ handler, ctx, instance, _ := newTestHandler()
+ instance.Spec.SFTPServiceConfigRef = types.ObjectRef{}
+
+ Expect(handler.Delete(ctx, instance)).To(Succeed())
+ })
+
+ It("returns service lookup errors during deletion", func() {
+ handler, ctx, instance, _ := newTestHandlerWithFactory(recordingFactory{err: errors.New("service unavailable")})
+
+ err := handler.Delete(ctx, instance)
+
+ Expect(err).To(MatchError("service unavailable"))
+ })
+
+ It("wraps service user deletion errors", func() {
+ handler, ctx, instance, mockService := newTestHandler()
+ expectDeleteSFTPUser(mockService, errors.New("delete failed"))
+
+ err := handler.Delete(ctx, instance)
+
+ Expect(err).To(MatchError(ContainSubstring("deleting SFTP user")))
+ Expect(err).To(MatchError(ContainSubstring("delete failed")))
+ })
+})
+
+func newTestHandler() (*InstanceHandler, context.Context, *sftpv1.Instance, *sftpmocks.MockService) {
+ return newTestHandlerWithFactory(nil)
+}
+
+func newTestHandlerWithFactory(factory service.Factory) (*InstanceHandler, context.Context, *sftpv1.Instance, *sftpmocks.MockService) {
+ instance := testInstance()
+ mockService := sftpmocks.NewMockService(GinkgoT())
+
+ mockClient := fakeclient.NewMockJanitorClient(GinkgoT())
+
+ if factory == nil {
+ factory = recordingFactory{svc: mockService}
+ }
+ handler, err := New(factory)
+ Expect(err).NotTo(HaveOccurred())
+ ctx := cclient.WithClient(context.Background(), mockClient)
+ return handler, ctx, instance, mockService
+}
+
+func testInstance() *sftpv1.Instance {
+ return &sftpv1.Instance{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: sftpv1.GroupVersion.String(),
+ Kind: "Instance",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: instanceHandlerTestName,
+ Namespace: instanceHandlerTestNamespace,
+ Generation: 1,
+ },
+ Spec: sftpv1.InstanceSpec{
+ SFTPServiceConfigRef: types.ObjectRef{
+ Name: instanceHandlerTestSFTPServiceConfigName,
+ Namespace: instanceHandlerTestNamespace,
+ },
+ },
+ }
+}
+
+func expectCreateOrUpdateSFTPUser(mockService *sftpmocks.MockService, createdModel *service.RoverSftpUserModel, err error) {
+ call := mockService.EXPECT().CreateOrUpdateSFTPUser(mock.Anything, mock.Anything)
+ if createdModel != nil {
+ call.Run(func(_ context.Context, user service.RoverSftpUserModel) {
+ *createdModel = user
+ })
+ }
+ call.Return(err).Once()
+}
+
+func expectDeleteSFTPUser(mockService *sftpmocks.MockService, err error) {
+ mockService.EXPECT().DeleteSFTPUser(mock.Anything, instanceHandlerTestName).Return(err).Once()
+}
+
+type recordingFactory struct {
+ svc service.Service
+ err error
+}
+
+func (f recordingFactory) ServiceFor(context.Context, client.ObjectKey) (service.Service, error) {
+ return f.svc, f.err
+}
diff --git a/sftp/internal/handler/sftpserviceconfig/handler.go b/sftp/internal/handler/sftpserviceconfig/handler.go
new file mode 100644
index 000000000..008e0337e
--- /dev/null
+++ b/sftp/internal/handler/sftpserviceconfig/handler.go
@@ -0,0 +1,62 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package sftpserviceconfig
+
+import (
+ "context"
+ "errors"
+
+ "k8s.io/apimachinery/pkg/api/meta"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+
+ "github.com/telekom/controlplane/common/pkg/condition"
+ "github.com/telekom/controlplane/common/pkg/handler"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+ "github.com/telekom/controlplane/sftp/internal/service"
+)
+
+var _ handler.Handler[*sftpv1.SFTPServiceConfig] = &SFTPServiceConfigHandler{}
+
+type SFTPServiceConfigHandler struct {
+ clientManager service.ClientManager
+}
+
+func New(clientManager service.ClientManager) (*SFTPServiceConfigHandler, error) {
+ if clientManager == nil {
+ return nil, errors.New("client manager is required")
+ }
+
+ return &SFTPServiceConfigHandler{
+ clientManager: clientManager,
+ }, nil
+}
+
+func (h *SFTPServiceConfigHandler) CreateOrUpdate(ctx context.Context, obj *sftpv1.SFTPServiceConfig) error {
+ log := logf.FromContext(ctx)
+ existClient := h.clientManager.ExistClient(client.ObjectKeyFromObject(obj))
+
+ conditionReady := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady)
+ if existClient && conditionReady != nil && conditionReady.ObservedGeneration == obj.Generation && conditionReady.Status == v1.ConditionTrue {
+ log.V(1).Info("SFTPServiceConfig already reconciled")
+ return nil
+ }
+
+ err := h.clientManager.CreateOrUpdate(ctx, obj)
+ if err != nil {
+ return err
+ }
+
+ obj.SetCondition(condition.NewReadyCondition("SFTPServiceConfigProvided", "SFTPServiceConfig has been provided"))
+ obj.SetCondition(condition.NewDoneProcessingCondition("SFTPServiceConfig has been provided"))
+ return nil
+}
+
+func (h *SFTPServiceConfigHandler) Delete(ctx context.Context, obj *sftpv1.SFTPServiceConfig) error {
+ h.clientManager.Delete(obj)
+
+ return nil
+}
diff --git a/sftp/internal/handler/sftpserviceconfig/handler_suite_test.go b/sftp/internal/handler/sftpserviceconfig/handler_suite_test.go
new file mode 100644
index 000000000..c5c7ac8af
--- /dev/null
+++ b/sftp/internal/handler/sftpserviceconfig/handler_suite_test.go
@@ -0,0 +1,17 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package sftpserviceconfig
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestSFTPServiceConfigHandler(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "SFTPServiceConfig Handler Suite")
+}
diff --git a/sftp/internal/handler/sftpserviceconfig/handler_test.go b/sftp/internal/handler/sftpserviceconfig/handler_test.go
new file mode 100644
index 000000000..6e9043b38
--- /dev/null
+++ b/sftp/internal/handler/sftpserviceconfig/handler_test.go
@@ -0,0 +1,116 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package sftpserviceconfig
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ "github.com/telekom/controlplane/common/pkg/condition"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+ "github.com/telekom/controlplane/sftp/internal/service"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("SFTPServiceConfigHandler", func() {
+ const (
+ testName = "test-sftpServiceConfig"
+ testNamespace = "test"
+ )
+
+ var (
+ ctx context.Context
+ obj *sftpv1.SFTPServiceConfig
+ )
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ obj = &sftpv1.SFTPServiceConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testName,
+ Namespace: testNamespace,
+ Generation: 2,
+ },
+ }
+ })
+
+ It("requires a client manager", func() {
+ handler, err := New(nil)
+
+ Expect(err).To(MatchError("client manager is required"))
+ Expect(handler).To(BeNil())
+ })
+
+ It("recreates the service when the cache is empty", func() {
+ manager := &recordingClientManager{serviceCached: false}
+ handler := newTestHandler(manager)
+
+ Expect(handler.CreateOrUpdate(ctx, obj)).To(Succeed())
+
+ Expect(manager.cacheChecks).To(Equal([]client.ObjectKey{{Namespace: testNamespace, Name: testName}}))
+ Expect(manager.createOrUpdateCalls).To(Equal(1))
+ Expect(meta.IsStatusConditionTrue(obj.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ Expect(meta.IsStatusConditionFalse(obj.Status.Conditions, condition.ConditionTypeProcessing)).To(BeTrue())
+ })
+
+ It("recreates the service when the Ready condition observed generation is stale", func() {
+ manager := &recordingClientManager{serviceCached: true}
+ handler := newTestHandler(manager)
+ ready := condition.NewReadyCondition("SFTPServiceConfigProvided", "SFTPServiceConfig has been provided")
+ ready.ObservedGeneration = obj.Generation - 1
+ obj.SetCondition(ready)
+
+ Expect(handler.CreateOrUpdate(ctx, obj)).To(Succeed())
+
+ Expect(manager.cacheChecks).To(Equal([]client.ObjectKey{{Namespace: testNamespace, Name: testName}}))
+ Expect(manager.createOrUpdateCalls).To(Equal(1))
+ })
+
+ It("skips reconciliation when the Ready condition observed generation is current and the service is cached", func() {
+ manager := &recordingClientManager{serviceCached: true}
+ handler := newTestHandler(manager)
+ ready := condition.NewReadyCondition("SFTPServiceConfigProvided", "SFTPServiceConfig has been provided")
+ ready.ObservedGeneration = obj.Generation
+ obj.SetCondition(ready)
+
+ Expect(handler.CreateOrUpdate(ctx, obj)).To(Succeed())
+
+ Expect(manager.cacheChecks).To(Equal([]client.ObjectKey{{Namespace: testNamespace, Name: testName}}))
+ Expect(manager.createOrUpdateCalls).To(Equal(0))
+ })
+})
+
+func newTestHandler(manager service.ClientManager) *SFTPServiceConfigHandler {
+ handler, err := New(manager)
+ Expect(err).NotTo(HaveOccurred())
+ return handler
+}
+
+type recordingClientManager struct {
+ serviceCached bool
+ cacheChecks []client.ObjectKey
+ createOrUpdateCalls int
+}
+
+func (m *recordingClientManager) ServiceFor(context.Context, client.ObjectKey) (service.Service, error) {
+ return service.NopService{}, nil
+}
+
+func (m *recordingClientManager) ExistClient(key client.ObjectKey) bool {
+ m.cacheChecks = append(m.cacheChecks, key)
+ return m.serviceCached
+}
+
+func (m *recordingClientManager) CreateOrUpdate(context.Context, *sftpv1.SFTPServiceConfig) error {
+ m.createOrUpdateCalls++
+ return nil
+}
+
+func (m *recordingClientManager) Delete(*sftpv1.SFTPServiceConfig) {}
diff --git a/sftp/internal/handler/user/handler.go b/sftp/internal/handler/user/handler.go
new file mode 100644
index 000000000..3c7bd082d
--- /dev/null
+++ b/sftp/internal/handler/user/handler.go
@@ -0,0 +1,141 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package user
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+
+ cclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/condition"
+ "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors"
+ "github.com/telekom/controlplane/common/pkg/handler"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+ "github.com/telekom/controlplane/sftp/internal/service"
+)
+
+var _ handler.Handler[*sftpv1.User] = &UserHandler{}
+
+type UserHandler struct {
+ serviceFactory service.Factory
+}
+
+func New(serviceFactory service.Factory) (*UserHandler, error) {
+ if serviceFactory == nil {
+ return nil, fmt.Errorf("service factory is required")
+ }
+
+ return &UserHandler{serviceFactory: serviceFactory}, nil
+}
+
+func (h *UserHandler) CreateOrUpdate(ctx context.Context, obj *sftpv1.User) error {
+ if obj.Spec.InstanceRef.IsEmpty() {
+ return ctrlerrors.BlockedErrorf("Instance reference is required")
+ }
+
+ instance := &sftpv1.Instance{}
+ if err := cclient.ClientFromContextOrDie(ctx).Get(ctx, obj.Spec.InstanceRef.K8s(), instance); err != nil {
+ return fmt.Errorf("getting Instance %q for User %q: %w", obj.Spec.InstanceRef.String(), obj.Name, err)
+ }
+
+ if instance.Spec.SFTPServiceConfigRef.IsEmpty() {
+ return ctrlerrors.BlockedErrorf("SFTPServiceConfig reference is required")
+ }
+
+ if !condition.IsReady(instance) {
+ obj.SetCondition(condition.NewNotReadyCondition("WaitingForInstance", "Waiting for Instance to be ready"))
+ return nil
+ }
+
+ log := logf.FromContext(ctx)
+ conditionReady := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady)
+ if conditionReady != nil && conditionReady.ObservedGeneration == obj.Generation && conditionReady.Status == v1.ConditionTrue {
+ log.Info("User spec didn't change, skipping updating of SSH Public keys in external service")
+ return nil
+ }
+
+ log.Info("User spec has changed, updating SSH Public keys in external service")
+
+ obj.SetCondition(condition.NewProcessingCondition("UpdatingPublicKeys", "Updating SSH public keys in external service"))
+
+ sftpService, err := h.serviceFactory.ServiceFor(ctx, instance.Spec.SFTPServiceConfigRef.K8s())
+ if err != nil {
+ return err
+ }
+
+ sshPublicKeys := getRoverPublicKeys(obj.Spec.SSHPublicKeys, instance.Name, userClientID(obj))
+
+ err = sftpService.UpdatePublicKeysForSFTPUser(ctx, instance.Name, userClientID(obj), sshPublicKeys)
+ if err != nil {
+ return fmt.Errorf("updating public keys for User %q on SFTP user %q: %w", obj.Name, instance.Name, err)
+ }
+
+ processing := condition.NewDoneProcessingCondition("SSH public keys were processed")
+ obj.SetCondition(processing)
+ obj.SetCondition(condition.NewReadyCondition("SSHPublicKeysUpdated", "SSH public keys have been updated in service"))
+ return nil
+}
+
+func (h *UserHandler) Delete(ctx context.Context, obj *sftpv1.User) error {
+ if obj.Spec.InstanceRef.IsEmpty() {
+ return nil
+ }
+
+ instance := &sftpv1.Instance{}
+ err := cclient.ClientFromContextOrDie(ctx).Get(ctx, obj.Spec.InstanceRef.K8s(), instance)
+ if err != nil {
+ if apierrors.IsNotFound(err) {
+ return nil
+ }
+ return fmt.Errorf("getting Instance %q for User %q: %w", obj.Spec.InstanceRef.String(), obj.Name, err)
+ }
+
+ if instance.Spec.SFTPServiceConfigRef.IsEmpty() {
+ return nil
+ }
+
+ sftpService, err := h.serviceFactory.ServiceFor(ctx, instance.Spec.SFTPServiceConfigRef.K8s())
+ if err != nil {
+ return err
+ }
+
+ clientID := userClientID(obj)
+
+ err = sftpService.UpdatePublicKeysForSFTPUser(ctx, instance.Name, clientID, getRoverPublicKeys(nil, instance.Name, clientID))
+ if err != nil {
+ return fmt.Errorf("removing public keys for User %q on SFTP user %q: %w", obj.Name, instance.Name, err)
+ }
+
+ return nil
+}
+
+func getRoverPublicKeys(keys []string, instanceName, clientID string) service.ClientPublicKeyMap {
+ const keyItems string = "items"
+ if len(keys) == 0 {
+ return service.ClientPublicKeyMap{keyItems: []service.RoverPublicKeyModel{}}
+ }
+
+ publicKeys := make([]service.RoverPublicKeyModel, 0, len(keys))
+ for index, sshPublicKey := range keys {
+ description := clientID + "/" + strconv.FormatInt(int64(index), 10)
+ publicKeys = append(publicKeys, service.RoverPublicKeyModel{
+ PublicKey: sshPublicKey,
+ SftpUserName: instanceName,
+ Description: &description,
+ })
+ }
+
+ return service.ClientPublicKeyMap{keyItems: publicKeys}
+}
+
+func userClientID(user *sftpv1.User) string {
+ return user.Namespace + "/" + user.Name
+}
diff --git a/sftp/internal/handler/user/handler_suite_test.go b/sftp/internal/handler/user/handler_suite_test.go
new file mode 100644
index 000000000..4c9271b63
--- /dev/null
+++ b/sftp/internal/handler/user/handler_suite_test.go
@@ -0,0 +1,17 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package user
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestUserHandler(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "User Handler Suite")
+}
diff --git a/sftp/internal/handler/user/handler_test.go b/sftp/internal/handler/user/handler_test.go
new file mode 100644
index 000000000..c3e5afc50
--- /dev/null
+++ b/sftp/internal/handler/user/handler_test.go
@@ -0,0 +1,291 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package user
+
+import (
+ "context"
+ "errors"
+
+ "github.com/stretchr/testify/mock"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ k8stypes "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ cclient "github.com/telekom/controlplane/common/pkg/client"
+ "github.com/telekom/controlplane/common/pkg/client/fake"
+ "github.com/telekom/controlplane/common/pkg/condition"
+ "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors"
+ "github.com/telekom/controlplane/common/pkg/types"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+ "github.com/telekom/controlplane/sftp/internal/service"
+ sftpmocks "github.com/telekom/controlplane/sftp/test/mocks"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+const (
+ userHandlerTestEnvironment = "test"
+ userHandlerTestNamespace = "test"
+ userHandlerTestInstance = "test-instance"
+ userHandlerTestName = "test-user"
+ userHandlerTestSFTPServiceConfigName = "test-sftpserviceconfig"
+)
+
+var _ = Describe("UserHandler", func() {
+ It("requires a service factory", func() {
+ handler, err := New(nil)
+
+ Expect(err).To(MatchError("service factory is required"))
+ Expect(handler).To(BeNil())
+ })
+
+ It("blocks when the Instance reference is missing", func() {
+ user := testUser()
+ user.Spec.InstanceRef = types.ObjectRef{}
+ handler, ctx, _, _ := newTestHandler()
+
+ err := handler.CreateOrUpdate(ctx, user)
+
+ var blocked ctrlerrors.BlockedError
+ Expect(errors.As(err, &blocked)).To(BeTrue())
+ Expect(err).To(MatchError(ContainSubstring("Instance reference is required")))
+ })
+
+ It("keeps the User processing while the Instance is not ready", func() {
+ instance := testInstance()
+ user := testUser()
+ handler, ctx, _, mockClient := newTestHandler()
+
+ mockClient.EXPECT().
+ Get(ctx, k8stypes.NamespacedName{Name: userHandlerTestInstance, Namespace: userHandlerTestNamespace}, &sftpv1.Instance{}).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*sftpv1.Instance) = *instance
+ }).
+ Return(nil).
+ Once()
+
+ Expect(handler.CreateOrUpdate(ctx, user)).To(Succeed())
+
+ processing := meta.FindStatusCondition(user.Status.Conditions, condition.ConditionTypeProcessing)
+ Expect(processing).To(BeNil())
+ ready := meta.FindStatusCondition(user.Status.Conditions, condition.ConditionTypeReady)
+ Expect(ready).NotTo(BeNil())
+ Expect(ready.Status).To(Equal(metav1.ConditionFalse))
+ Expect(ready.Reason).To(Equal("WaitingForInstance"))
+ Expect(meta.IsStatusConditionFalse(user.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ })
+
+ It("syncs only current User SSH public keys", func() {
+ instance := testInstanceWithReadyStatus()
+ user := testUser()
+ user.Spec.SSHPublicKeys = []string{"ssh-rsa cHJvdmlkZXI= provider@example.com"}
+ handler, ctx, mockService, mockClient := newTestHandler()
+
+ mockClient.EXPECT().
+ Get(ctx, k8stypes.NamespacedName{Name: userHandlerTestInstance, Namespace: userHandlerTestNamespace}, &sftpv1.Instance{}).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*sftpv1.Instance) = *instance
+ }).
+ Return(nil).
+ Once()
+
+ var capturedClientID string
+ var capturedKeys service.ClientPublicKeyMap
+ mockService.EXPECT().UpdatePublicKeysForSFTPUser(mock.Anything, instance.Name, mock.Anything, mock.Anything).
+ Run(func(_ context.Context, _, clientID string, keys service.ClientPublicKeyMap) {
+ capturedClientID = clientID
+ capturedKeys = keys
+ }).
+ Return(nil).
+ Once()
+
+ Expect(handler.CreateOrUpdate(ctx, user)).To(Succeed())
+
+ Expect(capturedClientID).To(Equal(user.Namespace + "/" + user.Name))
+ Expect(capturedKeys).To(HaveLen(1))
+ Expect(capturedKeys).To(HaveKey("items"))
+ Expect(capturedKeys["items"]).To(ConsistOf(service.RoverPublicKeyModel{
+ PublicKey: "ssh-rsa cHJvdmlkZXI= provider@example.com",
+ SftpUserName: instance.Name,
+ Description: ptrTo(user.Namespace + "/" + user.Name + "/0"),
+ }))
+ Expect(meta.IsStatusConditionFalse(user.Status.Conditions, condition.ConditionTypeProcessing)).To(BeTrue())
+ Expect(meta.IsStatusConditionTrue(user.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ ready := meta.FindStatusCondition(user.Status.Conditions, condition.ConditionTypeReady)
+ Expect(ready.Reason).To(Equal("SSHPublicKeysUpdated"))
+ })
+
+ It("accepts SSH keys as provided and marks the User ready", func() {
+ instance := testInstanceWithReadyStatus()
+ user := testUser()
+ user.Spec.SSHPublicKeys = []string{
+ "invalid",
+ "ssh-rsa cHJvdmlkZXI= provider@example.com",
+ }
+ handler, ctx, mockService, mockClient := newTestHandler()
+
+ mockClient.EXPECT().
+ Get(ctx, k8stypes.NamespacedName{Name: userHandlerTestInstance, Namespace: userHandlerTestNamespace}, &sftpv1.Instance{}).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*sftpv1.Instance) = *instance
+ }).
+ Return(nil).
+ Once()
+
+ var capturedKeys service.ClientPublicKeyMap
+ mockService.EXPECT().UpdatePublicKeysForSFTPUser(mock.Anything, instance.Name, user.Namespace+"/"+user.Name, mock.Anything).
+ Run(func(_ context.Context, _, _ string, keys service.ClientPublicKeyMap) {
+ capturedKeys = keys
+ }).
+ Return(nil).
+ Once()
+
+ Expect(handler.CreateOrUpdate(ctx, user)).To(Succeed())
+
+ processing := meta.FindStatusCondition(user.Status.Conditions, condition.ConditionTypeProcessing)
+ Expect(processing).NotTo(BeNil())
+ Expect(processing.Status).To(Equal(metav1.ConditionFalse))
+ Expect(processing.Reason).To(Equal("Done"))
+ Expect(meta.IsStatusConditionTrue(user.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue())
+ Expect(capturedKeys).To(HaveKey("items"))
+ Expect(capturedKeys["items"]).To(HaveLen(2))
+ })
+
+ It("returns synchronization errors", func() {
+ instance := testInstanceWithReadyStatus()
+ user := testUser()
+ user.Spec.SSHPublicKeys = []string{"ssh-rsa cHJvdmlkZXI= provider@example.com"}
+ handler, ctx, mockService, mockClient := newTestHandler()
+
+ mockClient.EXPECT().
+ Get(ctx, k8stypes.NamespacedName{Name: userHandlerTestInstance, Namespace: userHandlerTestNamespace}, &sftpv1.Instance{}).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*sftpv1.Instance) = *instance
+ }).
+ Return(nil).
+ Once()
+
+ mockService.EXPECT().UpdatePublicKeysForSFTPUser(mock.Anything, instance.Name, user.Namespace+"/"+user.Name, mock.Anything).
+ Return(errors.New("dds unavailable")).
+ Once()
+
+ err := handler.CreateOrUpdate(ctx, user)
+
+ Expect(err).To(MatchError(ContainSubstring("updating public keys for User")))
+ Expect(err).To(MatchError(ContainSubstring("dds unavailable")))
+ })
+
+ It("removes User keys from service on delete", func() {
+ instance := testInstanceWithReadyStatus()
+ user := testUser()
+ handler, ctx, mockService, mockClient := newTestHandler()
+
+ mockClient.EXPECT().
+ Get(ctx, k8stypes.NamespacedName{Name: userHandlerTestInstance, Namespace: userHandlerTestNamespace}, &sftpv1.Instance{}).
+ Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) {
+ *out.(*sftpv1.Instance) = *instance
+ }).
+ Return(nil).
+ Once()
+
+ mockService.EXPECT().UpdatePublicKeysForSFTPUser(mock.Anything, instance.Name, user.Namespace+"/"+user.Name, service.ClientPublicKeyMap{
+ "items": []service.RoverPublicKeyModel{},
+ }).Return(nil).Once()
+
+ Expect(handler.Delete(ctx, user)).To(Succeed())
+ })
+
+ It("does not fail delete when referenced Instance does not exist", func() {
+ user := testUser()
+ handler, ctx, _, mockClient := newTestHandler()
+
+ mockClient.EXPECT().
+ Get(ctx, k8stypes.NamespacedName{Name: userHandlerTestInstance, Namespace: userHandlerTestNamespace}, &sftpv1.Instance{}).
+ Return(apierrors.NewNotFound(schema.GroupResource{Group: sftpv1.GroupVersion.Group, Resource: "instances"}, userHandlerTestInstance)).
+ Once()
+
+ Expect(handler.Delete(ctx, user)).To(Succeed())
+ })
+})
+
+func newTestHandler() (*UserHandler, context.Context, *sftpmocks.MockService, *fake.MockJanitorClient) {
+ mockClient := fake.NewMockJanitorClient(GinkgoT())
+ ctx := cclient.WithClient(context.Background(), mockClient)
+
+ mockService := sftpmocks.NewMockService(GinkgoT())
+ handler, err := New(recordingFactory{svc: mockService})
+ Expect(err).NotTo(HaveOccurred())
+
+ return handler, ctx, mockService, mockClient
+}
+
+func testInstance() *sftpv1.Instance {
+ return &sftpv1.Instance{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: sftpv1.GroupVersion.String(),
+ Kind: "Instance",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: userHandlerTestInstance,
+ Namespace: userHandlerTestNamespace,
+ Generation: 1,
+ },
+ Spec: sftpv1.InstanceSpec{
+ SFTPServiceConfigRef: types.ObjectRef{
+ Name: userHandlerTestSFTPServiceConfigName,
+ Namespace: userHandlerTestNamespace,
+ },
+ },
+ }
+}
+
+func testInstanceWithReadyStatus() *sftpv1.Instance {
+ instance := testInstance()
+ ready := condition.NewReadyCondition("InstanceProvided", "Instance has been provided")
+ ready.ObservedGeneration = instance.Generation
+ instance.SetCondition(ready)
+ return instance
+}
+
+func testUser() *sftpv1.User {
+ return testUserNamed(userHandlerTestName)
+}
+
+func testUserNamed(name string) *sftpv1.User {
+ return &sftpv1.User{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: sftpv1.GroupVersion.String(),
+ Kind: "User",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: userHandlerTestNamespace,
+ Generation: 1,
+ },
+ Spec: sftpv1.UserSpec{
+ InstanceRef: types.ObjectRef{
+ Name: userHandlerTestInstance,
+ Namespace: userHandlerTestNamespace,
+ },
+ },
+ }
+}
+
+func ptrTo[T any](value T) *T {
+ return &value
+}
+
+type recordingFactory struct {
+ svc service.Service
+ err error
+}
+
+func (f recordingFactory) ServiceFor(context.Context, client.ObjectKey) (service.Service, error) {
+ return f.svc, f.err
+}
diff --git a/sftp/internal/service/api_error.go b/sftp/internal/service/api_error.go
new file mode 100644
index 000000000..e7586eaaf
--- /dev/null
+++ b/sftp/internal/service/api_error.go
@@ -0,0 +1,71 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package service
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors"
+)
+
+func firstAPIError(apiErrors ...*ApiErrorResponse) *ApiErrorResponse {
+ for _, apiErr := range apiErrors {
+ if apiErr != nil {
+ return apiErr
+ }
+ }
+ return nil
+}
+
+func handleAPIError(operation string, statusCode int, body []byte, apiErr *ApiErrorResponse) error {
+ message := apiErrorMessage(apiErr)
+ if message == "" {
+ message = strings.TrimSpace(string(body))
+ }
+ if message == "" {
+ message = http.StatusText(statusCode)
+ }
+
+ errMessage := fmt.Sprintf("SFTP Tardis API returned %d while trying to %s: %s", statusCode, operation, message)
+ switch {
+ case statusCode == http.StatusBadRequest,
+ statusCode == http.StatusUnauthorized,
+ statusCode == http.StatusForbidden,
+ statusCode == http.StatusNotFound:
+ return ctrlerrors.BlockedErrorf("%s", errMessage)
+ case statusCode >= http.StatusInternalServerError:
+ return ctrlerrors.RetryableErrorf("%s", errMessage)
+ default:
+ return ctrlerrors.RetryableErrorf("%s", errMessage)
+ }
+}
+
+func apiErrorMessage(apiErr *ApiErrorResponse) string {
+ if apiErr == nil {
+ return ""
+ }
+
+ parts := make([]string, 0, 2)
+ if apiErr.Title != nil && *apiErr.Title != "" {
+ parts = append(parts, *apiErr.Title)
+ }
+ if apiErr.Detail != nil && *apiErr.Detail != "" {
+ parts = append(parts, *apiErr.Detail)
+ }
+ if apiErr.Errors != nil {
+ for _, detail := range *apiErr.Errors {
+ switch {
+ case detail.FieldName != nil && *detail.FieldName != "" && detail.Error != nil && *detail.Error != "":
+ parts = append(parts, fmt.Sprintf("%s: %s", *detail.FieldName, *detail.Error))
+ case detail.Error != nil && *detail.Error != "":
+ parts = append(parts, *detail.Error)
+ }
+ }
+ }
+
+ return strings.Join(parts, "; ")
+}
diff --git a/sftp/internal/service/http_service.go b/sftp/internal/service/http_service.go
new file mode 100644
index 000000000..9ea57dcee
--- /dev/null
+++ b/sftp/internal/service/http_service.go
@@ -0,0 +1,100 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package service
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors"
+)
+
+// HTTPService implements Service using the generated SFTP Tardis OpenAPI client.
+type HTTPService struct {
+ client ClientWithResponsesInterface
+}
+
+// Config configures an HTTPService.
+type Config struct {
+ Endpoint *url.URL
+ HTTPClient *http.Client
+ Generation int64
+}
+
+// NewHTTPService creates an SFTP Tardis HTTP service.
+func NewHTTPService(cfg Config) (*HTTPService, error) {
+ client, err := newClientWithResponses(cfg)
+ if err != nil {
+ return nil, err
+ }
+ return &HTTPService{client: client}, nil
+}
+
+func newClientWithResponses(cfg Config) (ClientWithResponsesInterface, error) {
+ httpClient := cfg.HTTPClient
+ if httpClient == nil {
+ httpClient = &http.Client{Timeout: 10 * time.Second}
+ }
+
+ generatedClient, err := NewClientWithResponses(cfg.Endpoint.String(),
+ WithHTTPClient(httpClient),
+ WithRequestEditorFn(func(_ context.Context, req *http.Request) error {
+ req.Header.Set("Accept", "application/json")
+ return nil
+ }),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("creating SFTP Tardis client: %w", err)
+ }
+
+ return generatedClient, nil
+}
+
+func (s *HTTPService) CreateOrUpdateSFTPUser(ctx context.Context, user RoverSftpUserModel) error {
+ res, err := s.client.CreateOrUpdateSftpUserWithResponse(ctx, user)
+ if err != nil {
+ return ctrlerrors.RetryableErrorf("SFTP Tardis API request failed: %s", err.Error())
+ }
+
+ switch res.StatusCode() {
+ case http.StatusOK, http.StatusCreated:
+ return nil
+ default:
+ return handleAPIError("create or update SFTP user", res.StatusCode(), res.Body, firstAPIError(res.JSON400, res.JSON500))
+ }
+}
+
+func (s *HTTPService) UpdatePublicKeysForSFTPUser(ctx context.Context, sftpUserName, clientID string, keys ClientPublicKeyMap) error {
+ res, err := s.client.UpdatePublicKeysForSftpUserWithResponse(ctx, sftpUserName, &UpdatePublicKeysForSftpUserParams{
+ ClientId: clientID,
+ }, keys)
+ if err != nil {
+ return ctrlerrors.RetryableErrorf("SFTP Tardis API request failed: %s", err.Error())
+ }
+
+ switch res.StatusCode() {
+ case http.StatusOK:
+ return nil
+ default:
+ return handleAPIError("update SFTP user public keys", res.StatusCode(), res.Body, firstAPIError(res.JSON400, res.JSON500))
+ }
+}
+
+func (s *HTTPService) DeleteSFTPUser(ctx context.Context, sftpUserName string) error {
+ res, err := s.client.DeleteSftpUserWithResponse(ctx, sftpUserName)
+ if err != nil {
+ return ctrlerrors.RetryableErrorf("SFTP Tardis API request failed: %s", err.Error())
+ }
+
+ switch res.StatusCode() {
+ case http.StatusOK:
+ return nil
+ default:
+ return handleAPIError("delete SFTP user", res.StatusCode(), res.Body, firstAPIError(res.JSON400, res.JSON500))
+ }
+}
diff --git a/sftp/internal/service/manager.go b/sftp/internal/service/manager.go
new file mode 100644
index 000000000..63c9fb542
--- /dev/null
+++ b/sftp/internal/service/manager.go
@@ -0,0 +1,177 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package service
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "golang.org/x/oauth2/clientcredentials"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+
+ "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors"
+ secretsapi "github.com/telekom/controlplane/secret-manager/api"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+)
+
+// HTTPServiceFactory manages HTTP services configured from SFTPServiceConfig resources.
+type HTTPServiceFactory struct {
+ mu sync.RWMutex
+ services map[string]cachedService
+}
+
+type cachedService struct {
+ generation int64
+ service Service
+}
+
+func NewHTTPServiceFactory() *HTTPServiceFactory {
+ return &HTTPServiceFactory{
+ services: make(map[string]cachedService),
+ }
+}
+
+func (f *HTTPServiceFactory) ServiceFor(ctx context.Context, sftpServiceConfig client.ObjectKey) (Service, error) {
+ cacheKey := sftpServiceConfigCacheKey(sftpServiceConfig)
+
+ f.mu.RLock()
+ cached, ok := f.services[cacheKey]
+ f.mu.RUnlock()
+ if !ok {
+ return nil, ctrlerrors.RetryableErrorf("SFTP client for SFTPServiceConfig %q is not initialized", cacheKey)
+ }
+
+ return cached.service, nil
+}
+
+func (f *HTTPServiceFactory) ExistClient(sftpServiceConfig client.ObjectKey) bool {
+ cacheKey := sftpServiceConfigCacheKey(sftpServiceConfig)
+
+ f.mu.RLock()
+ _, ok := f.services[cacheKey]
+ f.mu.RUnlock()
+ return ok
+}
+
+func (f *HTTPServiceFactory) CreateOrUpdate(ctx context.Context, sftpServiceConfig *sftpv1.SFTPServiceConfig) error {
+ cacheKey := sftpServiceConfigCacheKey(client.ObjectKeyFromObject(sftpServiceConfig))
+
+ f.mu.RLock()
+ cached, ok := f.services[cacheKey]
+ f.mu.RUnlock()
+ if ok && cached.generation == sftpServiceConfig.Generation {
+ return nil
+ }
+
+ cfg, err := f.ClientConfigFor(ctx, sftpServiceConfig)
+ if err != nil {
+ return err
+ }
+
+ service, err := NewHTTPService(cfg)
+ if err != nil {
+ return err
+ }
+
+ f.mu.Lock()
+ if f.services == nil {
+ f.services = make(map[string]cachedService)
+ }
+ f.services[cacheKey] = cachedService{
+ generation: cfg.Generation,
+ service: service,
+ }
+ f.mu.Unlock()
+ return nil
+}
+
+func (f *HTTPServiceFactory) ClientConfigFor(ctx context.Context, sftpServiceConfig *sftpv1.SFTPServiceConfig) (Config, error) {
+ return clientConfigFor(ctx, sftpServiceConfig)
+}
+
+func (f *HTTPServiceFactory) Delete(sftpServiceConfig *sftpv1.SFTPServiceConfig) {
+ cacheKey := sftpServiceConfigCacheKey(client.ObjectKeyFromObject(sftpServiceConfig))
+
+ f.mu.Lock()
+ delete(f.services, cacheKey)
+ f.mu.Unlock()
+}
+
+func clientConfigFor(ctx context.Context, sftpServiceConfig *sftpv1.SFTPServiceConfig) (Config, error) {
+ if sftpServiceConfig == nil {
+ return Config{}, fmt.Errorf("sftpServiceConfig is nil")
+ }
+
+ oauth2Config, err := clientCredentials(ctx, sftpServiceConfig.Spec.API)
+ if err != nil {
+ return Config{}, err
+ }
+
+ log := logf.FromContext(ctx)
+ log.V(1).Info("Fetching SFTP Tardis access token")
+ _, err = oauth2Config.Token(ctx)
+ if err != nil {
+ return Config{}, fmt.Errorf("fetching SFTP Tardis access token: %w", err)
+ }
+
+ endpointURL, err := parseBaseURL(sftpServiceConfig.Spec.API.Endpoint)
+ if err != nil {
+ return Config{}, err
+ }
+
+ cfg := Config{
+ Endpoint: endpointURL,
+ HTTPClient: oauth2Config.Client(context.Background()),
+ Generation: sftpServiceConfig.Generation,
+ }
+
+ cfg.HTTPClient.Timeout = 30 * time.Second
+
+ return cfg, nil
+}
+
+func sftpServiceConfigCacheKey(sftpServiceConfig client.ObjectKey) string {
+ return sftpServiceConfig.String()
+}
+
+func clientCredentials(ctx context.Context, api sftpv1.APIEndpoint) (*clientcredentials.Config, error) {
+ clientID := strings.TrimSpace(api.ClientID)
+ if clientID == "" {
+ return nil, fmt.Errorf("SFTP Tardis client ID must not be empty")
+ }
+
+ tokenURL := strings.TrimSpace(api.Issuer)
+ if tokenURL == "" {
+ return nil, fmt.Errorf("SFTP Tardis token endpoint must not be empty")
+ }
+
+ clientSecret := strings.TrimSpace(api.ClientSecret)
+ if clientSecret == "" {
+ return nil, fmt.Errorf("SFTP Tardis client secret must not be empty")
+ }
+
+ if secretsapi.IsRef(clientSecret) {
+ var err error
+ clientSecret, err = secretsapi.API().Get(ctx, clientSecret)
+ if err != nil {
+ return nil, fmt.Errorf("getting SFTP Tardis client secret from secret-manager: %w", err)
+ }
+ }
+
+ clientSecret = strings.TrimSpace(clientSecret)
+ if strings.TrimSpace(clientSecret) == "" {
+ return nil, fmt.Errorf("SFTP Tardis client secret must not be empty")
+ }
+
+ return &clientcredentials.Config{
+ ClientID: clientID,
+ ClientSecret: clientSecret,
+ TokenURL: tokenURL,
+ }, nil
+}
diff --git a/sftp/internal/service/nop.go b/sftp/internal/service/nop.go
new file mode 100644
index 000000000..6217268a1
--- /dev/null
+++ b/sftp/internal/service/nop.go
@@ -0,0 +1,54 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package service
+
+import (
+ "context"
+
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+)
+
+// NopService implements Service without performing external requests.
+type NopService struct{}
+
+func (NopService) CreateOrUpdateSFTPUser(context.Context, RoverSftpUserModel) error {
+ return nil
+}
+
+func (NopService) UpdatePublicKeysForSFTPUser(context.Context, string, string, ClientPublicKeyMap) error {
+ return nil
+}
+
+func (NopService) DeleteSFTPUser(context.Context, string) error {
+ return nil
+}
+
+func NewNopFactory() Factory {
+ return FactoryFunc(func(context.Context, client.ObjectKey) (Service, error) {
+ return NopService{}, nil
+ })
+}
+
+type NopClientManager struct{}
+
+func (NopClientManager) ServiceFor(context.Context, client.ObjectKey) (Service, error) {
+ return NopService{}, nil
+}
+
+func (NopClientManager) ExistClient(client.ObjectKey) bool {
+ return true
+}
+
+func (NopClientManager) CreateOrUpdate(context.Context, *sftpv1.SFTPServiceConfig) error {
+ return nil
+}
+
+func (NopClientManager) Delete(*sftpv1.SFTPServiceConfig) {}
+
+func NewNopClientManager() ClientManager {
+ return NopClientManager{}
+}
diff --git a/sftp/internal/service/service.gen.go b/sftp/internal/service/service.gen.go
new file mode 100644
index 000000000..8436a118e
--- /dev/null
+++ b/sftp/internal/service/service.gen.go
@@ -0,0 +1,699 @@
+// Package service provides primitives to interact with the openapi HTTP API.
+//
+// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT.
+package service
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/oapi-codegen/runtime"
+)
+
+// Defines values for RoverSftpUserModelHorizonNotificationEvents.
+const (
+ Delete RoverSftpUserModelHorizonNotificationEvents = "delete"
+ Download RoverSftpUserModelHorizonNotificationEvents = "download"
+ Rename RoverSftpUserModelHorizonNotificationEvents = "rename"
+ Upload RoverSftpUserModelHorizonNotificationEvents = "upload"
+)
+
+// Valid indicates whether the value is a known member of the RoverSftpUserModelHorizonNotificationEvents enum.
+func (e RoverSftpUserModelHorizonNotificationEvents) Valid() bool {
+ switch e {
+ case Delete:
+ return true
+ case Download:
+ return true
+ case Rename:
+ return true
+ case Upload:
+ return true
+ default:
+ return false
+ }
+}
+
+// ApiErrorResponse API Error Response model representing the structure of error responses returned by the API
+type ApiErrorResponse struct {
+ // Detail Detailed description of the error
+ Detail *string `json:"detail,omitempty"`
+
+ // Errors Detailed information about the individual errors, if applicable
+ Errors *[]ErrorDetail `json:"errors,omitempty"`
+
+ // Status HTTP status code associated with the error
+ Status *int32 `json:"status,omitempty"`
+
+ // Timestamp Timestamp when the error occurred, as returned by the SFTP Tardis API
+ Timestamp *string `json:"timestamp,omitempty"`
+
+ // Title Title or short summary of the error
+ Title *string `json:"title,omitempty"`
+
+ // Type Type of the error, typically used to categorize the error
+ Type *string `json:"type,omitempty"`
+}
+
+// ClientPublicKeyMap Map of client IDs to lists of public keys
+type ClientPublicKeyMap map[string][]RoverPublicKeyModel
+
+// ErrorDetail Detailed information about an individual error
+type ErrorDetail struct {
+ // Error The detailed error message for die field
+ Error *string `json:"error,omitempty"`
+
+ // FieldName The field in which the error occurred, the value is the error that occurred for the field
+ FieldName *string `json:"fieldName,omitempty"`
+}
+
+// RoverPublicKeyModel Model representing the public key details associated with an SFTP user
+type RoverPublicKeyModel struct {
+ // Description Description or label for the public key
+ Description *string `json:"description,omitempty"`
+
+ // Email Email associated with the public key owner
+ Email *string `json:"email,omitempty"`
+
+ // PublicKey The public key in SSH format
+ PublicKey string `json:"publicKey"`
+
+ // SftpUserName The SFTP username associated with this public key
+ SftpUserName string `json:"sftpUserName"`
+}
+
+// RoverSftpUserModel Model containing the SFTP user data
+type RoverSftpUserModel struct {
+ // Description Description of the SFTP user
+ Description *string `json:"description,omitempty"`
+
+ // HorizonNotificationEvents List of horizon events for notification
+ HorizonNotificationEvents *[]RoverSftpUserModelHorizonNotificationEvents `json:"horizonNotificationEvents,omitempty"`
+
+ // SftpUserName The SFTP username
+ SftpUserName string `json:"sftpUserName"`
+}
+
+// RoverSftpUserModelHorizonNotificationEvents Event type for notification
+type RoverSftpUserModelHorizonNotificationEvents string
+
+// UpdatePublicKeysForSftpUserParams defines parameters for UpdatePublicKeysForSftpUser.
+type UpdatePublicKeysForSftpUserParams struct {
+ // ClientId The client ID associated with the public keys
+ ClientId string `form:"clientId" json:"clientId"`
+}
+
+// CreateOrUpdateSftpUserJSONRequestBody defines body for CreateOrUpdateSftpUser for application/json ContentType.
+type CreateOrUpdateSftpUserJSONRequestBody = RoverSftpUserModel
+
+// UpdatePublicKeysForSftpUserJSONRequestBody defines body for UpdatePublicKeysForSftpUser for application/json ContentType.
+type UpdatePublicKeysForSftpUserJSONRequestBody = ClientPublicKeyMap
+
+// RequestEditorFn is the function signature for the RequestEditor callback function
+type RequestEditorFn func(ctx context.Context, req *http.Request) error
+
+// Doer performs HTTP requests.
+//
+// The standard http.Client implements this interface.
+type HttpRequestDoer interface {
+ Do(req *http.Request) (*http.Response, error)
+}
+
+// Client which conforms to the OpenAPI3 specification for this service.
+type Client struct {
+ // The endpoint of the server conforming to this interface, with scheme,
+ // https://api.deepmap.com for example. This can contain a path relative
+ // to the server, such as https://api.deepmap.com/dev-test, and all the
+ // paths in the swagger spec will be appended to the server.
+ Server string
+
+ // Doer for performing requests, typically a *http.Client with any
+ // customized settings, such as certificate chains.
+ Client HttpRequestDoer
+
+ // A list of callbacks for modifying requests which are generated before sending over
+ // the network.
+ RequestEditors []RequestEditorFn
+}
+
+// ClientOption allows setting custom parameters during construction
+type ClientOption func(*Client) error
+
+// Creates a new Client, with reasonable defaults
+func NewClient(server string, opts ...ClientOption) (*Client, error) {
+ // create a client with sane default values
+ client := Client{
+ Server: server,
+ }
+ // mutate client and add all optional params
+ for _, o := range opts {
+ if err := o(&client); err != nil {
+ return nil, err
+ }
+ }
+ // ensure the server URL always has a trailing slash
+ if !strings.HasSuffix(client.Server, "/") {
+ client.Server += "/"
+ }
+ // create httpClient, if not already present
+ if client.Client == nil {
+ client.Client = &http.Client{}
+ }
+ return &client, nil
+}
+
+// WithHTTPClient allows overriding the default Doer, which is
+// automatically created using http.Client. This is useful for tests.
+func WithHTTPClient(doer HttpRequestDoer) ClientOption {
+ return func(c *Client) error {
+ c.Client = doer
+ return nil
+ }
+}
+
+// WithRequestEditorFn allows setting up a callback function, which will be
+// called right before sending the request. This can be used to mutate the request.
+func WithRequestEditorFn(fn RequestEditorFn) ClientOption {
+ return func(c *Client) error {
+ c.RequestEditors = append(c.RequestEditors, fn)
+ return nil
+ }
+}
+
+// The interface specification for the client above.
+type ClientInterface interface {
+ // CreateOrUpdateSftpUserWithBody request with any body
+ CreateOrUpdateSftpUserWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
+
+ CreateOrUpdateSftpUser(ctx context.Context, body CreateOrUpdateSftpUserJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
+
+ // DeleteSftpUser request
+ DeleteSftpUser(ctx context.Context, sftpUserName string, reqEditors ...RequestEditorFn) (*http.Response, error)
+
+ // UpdatePublicKeysForSftpUserWithBody request with any body
+ UpdatePublicKeysForSftpUserWithBody(ctx context.Context, sftpUserName string, params *UpdatePublicKeysForSftpUserParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
+
+ UpdatePublicKeysForSftpUser(ctx context.Context, sftpUserName string, params *UpdatePublicKeysForSftpUserParams, body UpdatePublicKeysForSftpUserJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
+}
+
+func (c *Client) CreateOrUpdateSftpUserWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
+ req, err := NewCreateOrUpdateSftpUserRequestWithBody(c.Server, contentType, body)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if err := c.applyEditors(ctx, req, reqEditors); err != nil {
+ return nil, err
+ }
+ return c.Client.Do(req)
+}
+
+func (c *Client) CreateOrUpdateSftpUser(ctx context.Context, body CreateOrUpdateSftpUserJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
+ req, err := NewCreateOrUpdateSftpUserRequest(c.Server, body)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if err := c.applyEditors(ctx, req, reqEditors); err != nil {
+ return nil, err
+ }
+ return c.Client.Do(req)
+}
+
+func (c *Client) DeleteSftpUser(ctx context.Context, sftpUserName string, reqEditors ...RequestEditorFn) (*http.Response, error) {
+ req, err := NewDeleteSftpUserRequest(c.Server, sftpUserName)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if err := c.applyEditors(ctx, req, reqEditors); err != nil {
+ return nil, err
+ }
+ return c.Client.Do(req)
+}
+
+func (c *Client) UpdatePublicKeysForSftpUserWithBody(ctx context.Context, sftpUserName string, params *UpdatePublicKeysForSftpUserParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
+ req, err := NewUpdatePublicKeysForSftpUserRequestWithBody(c.Server, sftpUserName, params, contentType, body)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if err := c.applyEditors(ctx, req, reqEditors); err != nil {
+ return nil, err
+ }
+ return c.Client.Do(req)
+}
+
+func (c *Client) UpdatePublicKeysForSftpUser(ctx context.Context, sftpUserName string, params *UpdatePublicKeysForSftpUserParams, body UpdatePublicKeysForSftpUserJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
+ req, err := NewUpdatePublicKeysForSftpUserRequest(c.Server, sftpUserName, params, body)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if err := c.applyEditors(ctx, req, reqEditors); err != nil {
+ return nil, err
+ }
+ return c.Client.Do(req)
+}
+
+// NewCreateOrUpdateSftpUserRequest calls the generic CreateOrUpdateSftpUser builder with application/json body
+func NewCreateOrUpdateSftpUserRequest(server string, body CreateOrUpdateSftpUserJSONRequestBody) (*http.Request, error) {
+ var bodyReader io.Reader
+ buf, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ bodyReader = bytes.NewReader(buf)
+ return NewCreateOrUpdateSftpUserRequestWithBody(server, "application/json", bodyReader)
+}
+
+// NewCreateOrUpdateSftpUserRequestWithBody generates requests for CreateOrUpdateSftpUser with any type of body
+func NewCreateOrUpdateSftpUserRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) {
+ var err error
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/sftp-user")
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest(http.MethodPost, queryURL.String(), body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Add("Content-Type", contentType)
+
+ return req, nil
+}
+
+// NewDeleteSftpUserRequest generates requests for DeleteSftpUser
+func NewDeleteSftpUserRequest(server string, sftpUserName string) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParamWithOptions("simple", false, "sftpUserName", sftpUserName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/sftp-user/%s", pathParam0)
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ return req, nil
+}
+
+// NewUpdatePublicKeysForSftpUserRequest calls the generic UpdatePublicKeysForSftpUser builder with application/json body
+func NewUpdatePublicKeysForSftpUserRequest(server string, sftpUserName string, params *UpdatePublicKeysForSftpUserParams, body UpdatePublicKeysForSftpUserJSONRequestBody) (*http.Request, error) {
+ var bodyReader io.Reader
+ buf, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ bodyReader = bytes.NewReader(buf)
+ return NewUpdatePublicKeysForSftpUserRequestWithBody(server, sftpUserName, params, "application/json", bodyReader)
+}
+
+// NewUpdatePublicKeysForSftpUserRequestWithBody generates requests for UpdatePublicKeysForSftpUser with any type of body
+func NewUpdatePublicKeysForSftpUserRequestWithBody(server string, sftpUserName string, params *UpdatePublicKeysForSftpUserParams, contentType string, body io.Reader) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParamWithOptions("simple", false, "sftpUserName", sftpUserName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/sftp-user/%s/keys", pathParam0)
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ if params != nil {
+ // queryValues collects non-styled parameters (passthrough, JSON)
+ // that are safe to round-trip through url.Values.Encode().
+ queryValues := queryURL.Query()
+ // rawQueryFragments collects pre-encoded query fragments from
+ // styled parameters, preserving literal commas as delimiters
+ // per the OpenAPI spec (e.g. "color=blue,black,brown").
+ var rawQueryFragments []string
+
+ if queryFrag, err := runtime.StyleParamWithOptions("form", true, "clientId", params.ClientId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
+ return nil, err
+ } else {
+ for _, qp := range strings.Split(queryFrag, "&") {
+ rawQueryFragments = append(rawQueryFragments, qp)
+ }
+ }
+
+ if encoded := queryValues.Encode(); encoded != "" {
+ rawQueryFragments = append(rawQueryFragments, encoded)
+ }
+ queryURL.RawQuery = strings.Join(rawQueryFragments, "&")
+ }
+
+ req, err := http.NewRequest(http.MethodPost, queryURL.String(), body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Add("Content-Type", contentType)
+
+ return req, nil
+}
+
+func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error {
+ for _, r := range c.RequestEditors {
+ if err := r(ctx, req); err != nil {
+ return err
+ }
+ }
+ for _, r := range additionalEditors {
+ if err := r(ctx, req); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// ClientWithResponses builds on ClientInterface to offer response payloads
+type ClientWithResponses struct {
+ ClientInterface
+}
+
+// NewClientWithResponses creates a new ClientWithResponses, which wraps
+// Client with return type handling
+func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) {
+ client, err := NewClient(server, opts...)
+ if err != nil {
+ return nil, err
+ }
+ return &ClientWithResponses{client}, nil
+}
+
+// WithBaseURL overrides the baseURL.
+func WithBaseURL(baseURL string) ClientOption {
+ return func(c *Client) error {
+ newBaseURL, err := url.Parse(baseURL)
+ if err != nil {
+ return err
+ }
+ c.Server = newBaseURL.String()
+ return nil
+ }
+}
+
+// ClientWithResponsesInterface is the interface specification for the client with responses above.
+type ClientWithResponsesInterface interface {
+ // CreateOrUpdateSftpUserWithBodyWithResponse request with any body
+ CreateOrUpdateSftpUserWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateOrUpdateSftpUserResponse, error)
+
+ CreateOrUpdateSftpUserWithResponse(ctx context.Context, body CreateOrUpdateSftpUserJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateOrUpdateSftpUserResponse, error)
+
+ // DeleteSftpUserWithResponse request
+ DeleteSftpUserWithResponse(ctx context.Context, sftpUserName string, reqEditors ...RequestEditorFn) (*DeleteSftpUserResponse, error)
+
+ // UpdatePublicKeysForSftpUserWithBodyWithResponse request with any body
+ UpdatePublicKeysForSftpUserWithBodyWithResponse(ctx context.Context, sftpUserName string, params *UpdatePublicKeysForSftpUserParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdatePublicKeysForSftpUserResponse, error)
+
+ UpdatePublicKeysForSftpUserWithResponse(ctx context.Context, sftpUserName string, params *UpdatePublicKeysForSftpUserParams, body UpdatePublicKeysForSftpUserJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdatePublicKeysForSftpUserResponse, error)
+}
+
+type CreateOrUpdateSftpUserResponse struct {
+ Body []byte
+ HTTPResponse *http.Response
+ JSON400 *ApiErrorResponse
+ JSON500 *ApiErrorResponse
+}
+
+// Status returns HTTPResponse.Status
+func (r CreateOrUpdateSftpUserResponse) Status() string {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.Status
+ }
+ return http.StatusText(0)
+}
+
+// StatusCode returns HTTPResponse.StatusCode
+func (r CreateOrUpdateSftpUserResponse) StatusCode() int {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.StatusCode
+ }
+ return 0
+}
+
+// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
+func (r CreateOrUpdateSftpUserResponse) ContentType() string {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.Header.Get("Content-Type")
+ }
+ return ""
+}
+
+type DeleteSftpUserResponse struct {
+ Body []byte
+ HTTPResponse *http.Response
+ JSON400 *ApiErrorResponse
+ JSON500 *ApiErrorResponse
+}
+
+// Status returns HTTPResponse.Status
+func (r DeleteSftpUserResponse) Status() string {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.Status
+ }
+ return http.StatusText(0)
+}
+
+// StatusCode returns HTTPResponse.StatusCode
+func (r DeleteSftpUserResponse) StatusCode() int {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.StatusCode
+ }
+ return 0
+}
+
+// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
+func (r DeleteSftpUserResponse) ContentType() string {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.Header.Get("Content-Type")
+ }
+ return ""
+}
+
+type UpdatePublicKeysForSftpUserResponse struct {
+ Body []byte
+ HTTPResponse *http.Response
+ JSON400 *ApiErrorResponse
+ JSON500 *ApiErrorResponse
+}
+
+// Status returns HTTPResponse.Status
+func (r UpdatePublicKeysForSftpUserResponse) Status() string {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.Status
+ }
+ return http.StatusText(0)
+}
+
+// StatusCode returns HTTPResponse.StatusCode
+func (r UpdatePublicKeysForSftpUserResponse) StatusCode() int {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.StatusCode
+ }
+ return 0
+}
+
+// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
+func (r UpdatePublicKeysForSftpUserResponse) ContentType() string {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.Header.Get("Content-Type")
+ }
+ return ""
+}
+
+// CreateOrUpdateSftpUserWithBodyWithResponse request with arbitrary body returning *CreateOrUpdateSftpUserResponse
+func (c *ClientWithResponses) CreateOrUpdateSftpUserWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateOrUpdateSftpUserResponse, error) {
+ rsp, err := c.CreateOrUpdateSftpUserWithBody(ctx, contentType, body, reqEditors...)
+ if err != nil {
+ return nil, err
+ }
+ return ParseCreateOrUpdateSftpUserResponse(rsp)
+}
+
+func (c *ClientWithResponses) CreateOrUpdateSftpUserWithResponse(ctx context.Context, body CreateOrUpdateSftpUserJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateOrUpdateSftpUserResponse, error) {
+ rsp, err := c.CreateOrUpdateSftpUser(ctx, body, reqEditors...)
+ if err != nil {
+ return nil, err
+ }
+ return ParseCreateOrUpdateSftpUserResponse(rsp)
+}
+
+// DeleteSftpUserWithResponse request returning *DeleteSftpUserResponse
+func (c *ClientWithResponses) DeleteSftpUserWithResponse(ctx context.Context, sftpUserName string, reqEditors ...RequestEditorFn) (*DeleteSftpUserResponse, error) {
+ rsp, err := c.DeleteSftpUser(ctx, sftpUserName, reqEditors...)
+ if err != nil {
+ return nil, err
+ }
+ return ParseDeleteSftpUserResponse(rsp)
+}
+
+// UpdatePublicKeysForSftpUserWithBodyWithResponse request with arbitrary body returning *UpdatePublicKeysForSftpUserResponse
+func (c *ClientWithResponses) UpdatePublicKeysForSftpUserWithBodyWithResponse(ctx context.Context, sftpUserName string, params *UpdatePublicKeysForSftpUserParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdatePublicKeysForSftpUserResponse, error) {
+ rsp, err := c.UpdatePublicKeysForSftpUserWithBody(ctx, sftpUserName, params, contentType, body, reqEditors...)
+ if err != nil {
+ return nil, err
+ }
+ return ParseUpdatePublicKeysForSftpUserResponse(rsp)
+}
+
+func (c *ClientWithResponses) UpdatePublicKeysForSftpUserWithResponse(ctx context.Context, sftpUserName string, params *UpdatePublicKeysForSftpUserParams, body UpdatePublicKeysForSftpUserJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdatePublicKeysForSftpUserResponse, error) {
+ rsp, err := c.UpdatePublicKeysForSftpUser(ctx, sftpUserName, params, body, reqEditors...)
+ if err != nil {
+ return nil, err
+ }
+ return ParseUpdatePublicKeysForSftpUserResponse(rsp)
+}
+
+// ParseCreateOrUpdateSftpUserResponse parses an HTTP response from a CreateOrUpdateSftpUserWithResponse call
+func ParseCreateOrUpdateSftpUserResponse(rsp *http.Response) (*CreateOrUpdateSftpUserResponse, error) {
+ bodyBytes, err := io.ReadAll(rsp.Body)
+ defer func() { _ = rsp.Body.Close() }()
+ if err != nil {
+ return nil, err
+ }
+
+ response := &CreateOrUpdateSftpUserResponse{
+ Body: bodyBytes,
+ HTTPResponse: rsp,
+ }
+
+ switch {
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
+ var dest ApiErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON400 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500:
+ var dest ApiErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON500 = &dest
+
+ }
+
+ return response, nil
+}
+
+// ParseDeleteSftpUserResponse parses an HTTP response from a DeleteSftpUserWithResponse call
+func ParseDeleteSftpUserResponse(rsp *http.Response) (*DeleteSftpUserResponse, error) {
+ bodyBytes, err := io.ReadAll(rsp.Body)
+ defer func() { _ = rsp.Body.Close() }()
+ if err != nil {
+ return nil, err
+ }
+
+ response := &DeleteSftpUserResponse{
+ Body: bodyBytes,
+ HTTPResponse: rsp,
+ }
+
+ switch {
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
+ var dest ApiErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON400 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500:
+ var dest ApiErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON500 = &dest
+
+ }
+
+ return response, nil
+}
+
+// ParseUpdatePublicKeysForSftpUserResponse parses an HTTP response from a UpdatePublicKeysForSftpUserWithResponse call
+func ParseUpdatePublicKeysForSftpUserResponse(rsp *http.Response) (*UpdatePublicKeysForSftpUserResponse, error) {
+ bodyBytes, err := io.ReadAll(rsp.Body)
+ defer func() { _ = rsp.Body.Close() }()
+ if err != nil {
+ return nil, err
+ }
+
+ response := &UpdatePublicKeysForSftpUserResponse{
+ Body: bodyBytes,
+ HTTPResponse: rsp,
+ }
+
+ switch {
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
+ var dest ApiErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON400 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500:
+ var dest ApiErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON500 = &dest
+
+ }
+
+ return response, nil
+}
diff --git a/sftp/internal/service/service.gen.go.license b/sftp/internal/service/service.gen.go.license
new file mode 100644
index 000000000..be863cd5c
--- /dev/null
+++ b/sftp/internal/service/service.gen.go.license
@@ -0,0 +1,3 @@
+Copyright 2026 Deutsche Telekom IT GmbH
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/sftp/internal/service/service.go b/sftp/internal/service/service.go
new file mode 100644
index 000000000..318e8b7cb
--- /dev/null
+++ b/sftp/internal/service/service.go
@@ -0,0 +1,45 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package service
+
+import (
+ "context"
+
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+)
+
+// Service provides the operations exposed by the SFTP Tardis API.
+type Service interface {
+ CreateOrUpdateSFTPUser(ctx context.Context, user RoverSftpUserModel) error
+ UpdatePublicKeysForSFTPUser(ctx context.Context, sftpUserName, clientID string, keys ClientPublicKeyMap) error
+ DeleteSFTPUser(ctx context.Context, sftpUserName string) error
+}
+
+// Factory creates a Service for an SFTPServiceConfig.
+type Factory interface {
+ ServiceFor(ctx context.Context, sftpServiceConfig client.ObjectKey) (Service, error)
+}
+
+// ClientManager manages SFTP API clients configured by SFTPServiceConfig resources.
+// It provides reusability of clients and ensures that the clients are initialized only once per SFTPServiceConfig.
+type ClientManager interface {
+ Factory
+ // ExistClient returns true if a client for the given SFTPServiceConfig is already initialized and available to use.
+ ExistClient(sftpServiceConfig client.ObjectKey) bool
+ // CreateOrUpdate creates or updates the SFTP API client for the given SFTPServiceConfig in client manager
+ // It initialize oauth2 client credentials and creates a new SFTP API client if it does not exist yet.
+ CreateOrUpdate(ctx context.Context, sftpServiceConfig *sftpv1.SFTPServiceConfig) error
+ // Delete removes the client for the given SFTPServiceConfig from the client manager.
+ Delete(sftpServiceConfig *sftpv1.SFTPServiceConfig)
+}
+
+// FactoryFunc adapts a function to the Factory interface.
+type FactoryFunc func(ctx context.Context, sftpServiceConfig client.ObjectKey) (Service, error)
+
+func (f FactoryFunc) ServiceFor(ctx context.Context, sftpServiceConfig client.ObjectKey) (Service, error) {
+ return f(ctx, sftpServiceConfig)
+}
diff --git a/sftp/internal/service/service_test.go b/sftp/internal/service/service_test.go
new file mode 100644
index 000000000..5c0e4734c
--- /dev/null
+++ b/sftp/internal/service/service_test.go
@@ -0,0 +1,264 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "sync/atomic"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors"
+ secretsapi "github.com/telekom/controlplane/secret-manager/api"
+ secretsapifake "github.com/telekom/controlplane/secret-manager/api/fake"
+ sftpv1 "github.com/telekom/controlplane/sftp/api/v1"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+const testBasePath = "/test"
+
+var _ = Describe("HTTPService", func() {
+ It("creates or updates an SFTP user", func() {
+ var received RoverSftpUserModel
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ Expect(r.Method).To(Equal(http.MethodPost))
+ Expect(r.URL.Path).To(Equal(testBasePath + "/sftp-user"))
+ Expect(r.Header.Get("Content-Type")).To(Equal("application/json"))
+ Expect(json.NewDecoder(r.Body).Decode(&received)).To(Succeed())
+ w.WriteHeader(http.StatusCreated)
+ }))
+ defer server.Close()
+
+ baseURL, err := url.Parse(server.URL)
+ Expect(err).NotTo(HaveOccurred())
+
+ baseURL = baseURL.JoinPath(testBasePath)
+
+ svc, err := NewHTTPService(Config{Endpoint: baseURL})
+ Expect(err).NotTo(HaveOccurred())
+
+ err = svc.CreateOrUpdateSFTPUser(context.Background(), RoverSftpUserModel{
+ SftpUserName: "cetus--team--files",
+ Description: ptrTo("Team transfer user"),
+ })
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(received.SftpUserName).To(Equal("cetus--team--files"))
+ Expect(received.Description).NotTo(BeNil())
+ Expect(*received.Description).To(Equal("Team transfer user"))
+ })
+
+ It("updates public keys for an SFTP user", func() {
+ var received ClientPublicKeyMap
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ Expect(r.Method).To(Equal(http.MethodPost))
+ Expect(r.URL.Path).To(Equal(testBasePath + "/sftp-user/cetus--team--files/keys"))
+ Expect(r.URL.Query().Get("clientId")).To(Equal("client-123"))
+ Expect(json.NewDecoder(r.Body).Decode(&received)).To(Succeed())
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ baseURL, err := url.Parse(server.URL)
+ Expect(err).NotTo(HaveOccurred())
+
+ baseURL = baseURL.JoinPath(testBasePath)
+
+ svc, err := NewHTTPService(Config{Endpoint: baseURL})
+ Expect(err).NotTo(HaveOccurred())
+
+ err = svc.UpdatePublicKeysForSFTPUser(context.Background(), "cetus--team--files", "client-123", ClientPublicKeyMap{
+ "client-123": {
+ {
+ PublicKey: "ssh-rsa AAAAB3",
+ Description: ptrTo("build key"),
+ SftpUserName: "cetus--team--files",
+ },
+ },
+ })
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(received).To(HaveKey("client-123"))
+ Expect(received["client-123"]).To(HaveLen(1))
+ Expect(received["client-123"][0].Description).NotTo(BeNil())
+ Expect(*received["client-123"][0].Description).To(Equal("build key"))
+ })
+
+ It("deletes an SFTP user", func() {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ Expect(r.Method).To(Equal(http.MethodDelete))
+ Expect(r.URL.Path).To(Equal(testBasePath + "/sftp-user/cetus--team--files"))
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ baseURL, err := url.Parse(server.URL)
+ Expect(err).NotTo(HaveOccurred())
+
+ baseURL = baseURL.JoinPath(testBasePath)
+
+ svc, err := NewHTTPService(Config{Endpoint: baseURL})
+ Expect(err).NotTo(HaveOccurred())
+
+ err = svc.DeleteSFTPUser(context.Background(), "cetus--team--files")
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("maps client errors to blocked errors", func() {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"title":"bad request","detail":"invalid user"}`))
+ }))
+ defer server.Close()
+
+ baseURL, err := url.Parse(server.URL)
+ Expect(err).NotTo(HaveOccurred())
+
+ baseURL = baseURL.JoinPath(testBasePath)
+
+ svc, err := NewHTTPService(Config{Endpoint: baseURL})
+ Expect(err).NotTo(HaveOccurred())
+
+ err = svc.DeleteSFTPUser(context.Background(), "bad-user")
+
+ var blocked ctrlerrors.BlockedError
+ Expect(errors.As(err, &blocked)).To(BeTrue())
+ Expect(err.Error()).To(ContainSubstring("bad request"))
+ Expect(err.Error()).To(ContainSubstring("invalid user"))
+ })
+
+ It("parses API errors with non-RFC3339 timestamps", func() {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"title":"bad request","detail":"invalid user","timestamp":"2026-06-18T11:15:02.096673893","status":400}`))
+ }))
+ defer server.Close()
+
+ baseURL, err := url.Parse(server.URL)
+ Expect(err).NotTo(HaveOccurred())
+
+ baseURL = baseURL.JoinPath(testBasePath)
+
+ svc, err := NewHTTPService(Config{Endpoint: baseURL})
+ Expect(err).NotTo(HaveOccurred())
+
+ err = svc.DeleteSFTPUser(context.Background(), "bad-user")
+
+ var blocked ctrlerrors.BlockedError
+ Expect(errors.As(err, &blocked)).To(BeTrue())
+ Expect(err.Error()).To(ContainSubstring("bad request"))
+ Expect(err.Error()).To(ContainSubstring("invalid user"))
+ })
+})
+
+var _ = Describe("HTTPServiceFactory", func() {
+ It("reuses an SFTPServiceConfig client with a valid OAuth2 token", func() {
+ var tokenRequests int32
+ tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&tokenRequests, 1)
+ Expect(r.Method).To(Equal(http.MethodPost))
+ Expect(r.ParseForm()).To(Succeed())
+ Expect(r.Form.Get("grant_type")).To(Equal("client_credentials"))
+
+ clientID, clientSecret, ok := r.BasicAuth()
+ Expect(ok).To(BeTrue())
+ Expect(clientID).To(Equal("zone-client"))
+ Expect(clientSecret).To(Equal("resolved-secret"))
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"access_token":"zone-token","token_type":"Bearer","expires_in":3600}`))
+ }))
+ defer tokenServer.Close()
+
+ apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ Expect(r.Method).To(Equal(http.MethodPost))
+ Expect(r.URL.Path).To(Equal(testBasePath + "/sftp-user"))
+ Expect(r.Header.Get("Authorization")).To(Equal("Bearer zone-token"))
+ w.WriteHeader(http.StatusCreated)
+ }))
+ defer apiServer.Close()
+
+ apiBaseURL, err := url.Parse(apiServer.URL)
+ Expect(err).NotTo(HaveOccurred())
+
+ apiBaseURL = apiBaseURL.JoinPath(testBasePath)
+ secretRef := secretsapi.ToRef("sftp/cetus/client-secret")
+
+ sftpServiceConfig := &sftpv1.SFTPServiceConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "cetus",
+ Namespace: "controlplane-system",
+ },
+ Spec: sftpv1.SFTPServiceConfigSpec{
+ API: sftpv1.APIEndpoint{
+ ClientID: "zone-client",
+ ClientSecret: secretRef,
+ Endpoint: apiBaseURL.String(),
+ Issuer: tokenServer.URL,
+ },
+ },
+ }
+
+ ctx := context.Background()
+ secretManager := secretsapifake.NewMockSecretManager(GinkgoT())
+ secretManager.EXPECT().Get(ctx, secretRef).Return("resolved-secret", nil).Once()
+
+ originalAPI := secretsapi.API
+ DeferCleanup(func() {
+ secretsapi.API = originalAPI
+ })
+ secretsapi.API = func() secretsapi.SecretManager {
+ return secretManager
+ }
+
+ factory := NewHTTPServiceFactory()
+
+ cached := factory.ExistClient(client.ObjectKeyFromObject(sftpServiceConfig))
+ Expect(cached).To(BeFalse())
+
+ Expect(factory.CreateOrUpdate(ctx, sftpServiceConfig)).To(Succeed())
+
+ cached = factory.ExistClient(client.ObjectKeyFromObject(sftpServiceConfig))
+ Expect(cached).To(BeTrue())
+
+ svc, err := factory.ServiceFor(ctx, client.ObjectKeyFromObject(sftpServiceConfig))
+ Expect(err).NotTo(HaveOccurred())
+
+ err = svc.CreateOrUpdateSFTPUser(ctx, RoverSftpUserModel{
+ SftpUserName: "cetus--team--files",
+ })
+ Expect(err).NotTo(HaveOccurred())
+
+ Expect(factory.CreateOrUpdate(ctx, sftpServiceConfig)).To(Succeed())
+
+ svc, err = factory.ServiceFor(ctx, client.ObjectKeyFromObject(sftpServiceConfig))
+ Expect(err).NotTo(HaveOccurred())
+
+ err = svc.CreateOrUpdateSFTPUser(ctx, RoverSftpUserModel{
+ SftpUserName: "cetus--team--files",
+ })
+ Expect(err).NotTo(HaveOccurred())
+ Expect(atomic.LoadInt32(&tokenRequests)).To(Equal(int32(2)))
+
+ factory.Delete(sftpServiceConfig)
+
+ cached = factory.ExistClient(client.ObjectKeyFromObject(sftpServiceConfig))
+ Expect(cached).To(BeFalse())
+ })
+})
+
+func ptrTo[T any](value T) *T {
+ return &value
+}
diff --git a/sftp/internal/service/suite_test.go b/sftp/internal/service/suite_test.go
new file mode 100644
index 000000000..5687c8a52
--- /dev/null
+++ b/sftp/internal/service/suite_test.go
@@ -0,0 +1,17 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package service
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestService(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "SFTP Service Suite")
+}
diff --git a/sftp/internal/service/utils.go b/sftp/internal/service/utils.go
new file mode 100644
index 000000000..ff2f8e9e6
--- /dev/null
+++ b/sftp/internal/service/utils.go
@@ -0,0 +1,22 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package service
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+)
+
+func parseBaseURL(rawBaseURL string) (*url.URL, error) {
+ baseURL, err := url.Parse(strings.TrimRight(rawBaseURL, "/"))
+ if err != nil {
+ return nil, fmt.Errorf("parsing SFTP Tardis base URL: %w", err)
+ }
+ if baseURL.Scheme == "" || baseURL.Host == "" {
+ return nil, fmt.Errorf("SFTP Tardis base URL must include scheme and host")
+ }
+ return baseURL, nil
+}
diff --git a/sftp/test/mocks/service.go b/sftp/test/mocks/service.go
new file mode 100644
index 000000000..4f4a3a2ba
--- /dev/null
+++ b/sftp/test/mocks/service.go
@@ -0,0 +1,184 @@
+// SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+// Code generated by mockery v2.53.6. DO NOT EDIT.
+
+package mocks
+
+import (
+ context "context"
+
+ mock "github.com/stretchr/testify/mock"
+ service "github.com/telekom/controlplane/sftp/internal/service"
+)
+
+// MockService is an autogenerated mock type for the Service type
+type MockService struct {
+ mock.Mock
+}
+
+type MockService_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockService) EXPECT() *MockService_Expecter {
+ return &MockService_Expecter{mock: &_m.Mock}
+}
+
+// CreateOrUpdateSFTPUser provides a mock function with given fields: ctx, user
+func (_m *MockService) CreateOrUpdateSFTPUser(ctx context.Context, user service.RoverSftpUserModel) error {
+ ret := _m.Called(ctx, user)
+
+ if len(ret) == 0 {
+ panic("no return value specified for CreateOrUpdateSFTPUser")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, service.RoverSftpUserModel) error); ok {
+ r0 = rf(ctx, user)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockService_CreateOrUpdateSFTPUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateOrUpdateSFTPUser'
+type MockService_CreateOrUpdateSFTPUser_Call struct {
+ *mock.Call
+}
+
+// CreateOrUpdateSFTPUser is a helper method to define mock.On call
+// - ctx context.Context
+// - user service.RoverSftpUserModel
+func (_e *MockService_Expecter) CreateOrUpdateSFTPUser(ctx interface{}, user interface{}) *MockService_CreateOrUpdateSFTPUser_Call {
+ return &MockService_CreateOrUpdateSFTPUser_Call{Call: _e.mock.On("CreateOrUpdateSFTPUser", ctx, user)}
+}
+
+func (_c *MockService_CreateOrUpdateSFTPUser_Call) Run(run func(ctx context.Context, user service.RoverSftpUserModel)) *MockService_CreateOrUpdateSFTPUser_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(context.Context), args[1].(service.RoverSftpUserModel))
+ })
+ return _c
+}
+
+func (_c *MockService_CreateOrUpdateSFTPUser_Call) Return(_a0 error) *MockService_CreateOrUpdateSFTPUser_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockService_CreateOrUpdateSFTPUser_Call) RunAndReturn(run func(context.Context, service.RoverSftpUserModel) error) *MockService_CreateOrUpdateSFTPUser_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// DeleteSFTPUser provides a mock function with given fields: ctx, sftpUserName
+func (_m *MockService) DeleteSFTPUser(ctx context.Context, sftpUserName string) error {
+ ret := _m.Called(ctx, sftpUserName)
+
+ if len(ret) == 0 {
+ panic("no return value specified for DeleteSFTPUser")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
+ r0 = rf(ctx, sftpUserName)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockService_DeleteSFTPUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSFTPUser'
+type MockService_DeleteSFTPUser_Call struct {
+ *mock.Call
+}
+
+// DeleteSFTPUser is a helper method to define mock.On call
+// - ctx context.Context
+// - sftpUserName string
+func (_e *MockService_Expecter) DeleteSFTPUser(ctx interface{}, sftpUserName interface{}) *MockService_DeleteSFTPUser_Call {
+ return &MockService_DeleteSFTPUser_Call{Call: _e.mock.On("DeleteSFTPUser", ctx, sftpUserName)}
+}
+
+func (_c *MockService_DeleteSFTPUser_Call) Run(run func(ctx context.Context, sftpUserName string)) *MockService_DeleteSFTPUser_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(context.Context), args[1].(string))
+ })
+ return _c
+}
+
+func (_c *MockService_DeleteSFTPUser_Call) Return(_a0 error) *MockService_DeleteSFTPUser_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockService_DeleteSFTPUser_Call) RunAndReturn(run func(context.Context, string) error) *MockService_DeleteSFTPUser_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// UpdatePublicKeysForSFTPUser provides a mock function with given fields: ctx, sftpUserName, clientID, keys
+func (_m *MockService) UpdatePublicKeysForSFTPUser(ctx context.Context, sftpUserName string, clientID string, keys service.ClientPublicKeyMap) error {
+ ret := _m.Called(ctx, sftpUserName, clientID, keys)
+
+ if len(ret) == 0 {
+ panic("no return value specified for UpdatePublicKeysForSFTPUser")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, string, string, service.ClientPublicKeyMap) error); ok {
+ r0 = rf(ctx, sftpUserName, clientID, keys)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockService_UpdatePublicKeysForSFTPUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdatePublicKeysForSFTPUser'
+type MockService_UpdatePublicKeysForSFTPUser_Call struct {
+ *mock.Call
+}
+
+// UpdatePublicKeysForSFTPUser is a helper method to define mock.On call
+// - ctx context.Context
+// - sftpUserName string
+// - clientID string
+// - keys service.ClientPublicKeyMap
+func (_e *MockService_Expecter) UpdatePublicKeysForSFTPUser(ctx interface{}, sftpUserName interface{}, clientID interface{}, keys interface{}) *MockService_UpdatePublicKeysForSFTPUser_Call {
+ return &MockService_UpdatePublicKeysForSFTPUser_Call{Call: _e.mock.On("UpdatePublicKeysForSFTPUser", ctx, sftpUserName, clientID, keys)}
+}
+
+func (_c *MockService_UpdatePublicKeysForSFTPUser_Call) Run(run func(ctx context.Context, sftpUserName string, clientID string, keys service.ClientPublicKeyMap)) *MockService_UpdatePublicKeysForSFTPUser_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(service.ClientPublicKeyMap))
+ })
+ return _c
+}
+
+func (_c *MockService_UpdatePublicKeysForSFTPUser_Call) Return(_a0 error) *MockService_UpdatePublicKeysForSFTPUser_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockService_UpdatePublicKeysForSFTPUser_Call) RunAndReturn(run func(context.Context, string, string, service.ClientPublicKeyMap) error) *MockService_UpdatePublicKeysForSFTPUser_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewMockService(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockService {
+ mock := &MockService{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/sftp/tools/mockery.yaml b/sftp/tools/mockery.yaml
new file mode 100644
index 000000000..13948c99e
--- /dev/null
+++ b/sftp/tools/mockery.yaml
@@ -0,0 +1,16 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+with-expecter: True
+mockname: "Mock{{.InterfaceName}}"
+dir: "../test/mocks"
+outpkg: "mocks"
+filename: "{{.InterfaceName | camelcase | firstLower }}.go"
+boilerplate-file: ../../hack/boilerplate.go.txt
+packages:
+ github.com/telekom/controlplane/sftp/internal/service:
+ config:
+ interfaces:
+ Service:
+ config:
diff --git a/sftp/tools/service.yaml b/sftp/tools/service.yaml
new file mode 100644
index 000000000..62c72c22e
--- /dev/null
+++ b/sftp/tools/service.yaml
@@ -0,0 +1,9 @@
+# Copyright 2025 Deutsche Telekom IT GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+package: service
+output: ../internal/service/service.gen.go
+generate:
+ models: true
+ client: true
diff --git a/sftp/tools/tools.go b/sftp/tools/tools.go
new file mode 100644
index 000000000..ea24b599a
--- /dev/null
+++ b/sftp/tools/tools.go
@@ -0,0 +1,9 @@
+// Copyright 2026 Deutsche Telekom IT GmbH
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package tools
+
+//go:generate go tool oapi-codegen -config service.yaml ../api/service/api.yaml
+
+//go:generate go tool mockery --config=mockery.yaml
diff --git a/tools/e2e-tester/go.sum b/tools/e2e-tester/go.sum
index b5fea4580..91cffec3a 100644
--- a/tools/e2e-tester/go.sum
+++ b/tools/e2e-tester/go.sum
@@ -125,8 +125,10 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=