diff --git a/admin/README.md b/admin/README.md index d7ef26009..518e8c5a4 100644 --- a/admin/README.md +++ b/admin/README.md @@ -64,7 +64,7 @@ This CRD represents a physical or logical deployment target with gateway and ide - Each Zone creates its own dedicated namespace (stored in `status.namespace`) for managing related resources. - Zones define gateway configuration, identity provider settings, and Redis connection details. - The `visibility` field controls subscription behavior and can be either `World` or `Enterprise`. -- Zones can optionally define Team APIs through the `teamApis` field, which creates routes on the gateway. +- Zones can optionally define managed routes through the `managedRoutes` field. Each route has a `type`: `TeamAPI` (authenticated, no ACL) or `Proxy` (passthrough reverse proxy). - The Zone controller creates and manages related resources in its handlers. - All managed resources are labeled with both `cp.ei.telekom.de/environment` and `cp.ei.telekom.de/zone` labels. diff --git a/admin/api/v1/zone_types.go b/admin/api/v1/zone_types.go index c1099b128..d822b55c8 100644 --- a/admin/api/v1/zone_types.go +++ b/admin/api/v1/zone_types.go @@ -78,7 +78,23 @@ type GatewayConfig struct { CircuitBreaker bool `json:"circuitBreaker"` } -type ApiConfig struct { +// ManagedRouteType defines the type of a managed route. +// +kubebuilder:validation:Enum=TeamAPI;Proxy +type ManagedRouteType string + +const ( + // ManagedRouteTypeTeamAPI creates a route with authentication (PassThrough=false) + // and disabled access control on the zone's team-api gateway realm. + // Used for team APIs that require token validation but no per-consumer ACLs. + ManagedRouteTypeTeamAPI ManagedRouteType = "TeamAPI" + + // ManagedRouteTypeProxy creates a fully passthrough route (PassThrough=true) + // on the zone's default gateway realm that acts as a pure reverse proxy + // without any authentication or authorization. + ManagedRouteTypeProxy ManagedRouteType = "Proxy" +) + +type ManagedRouteConfig struct { // Name is the name of the created route. It must be unique within the zone. // +kubebuilder:validation:Required // +kubebuilder:validation:Pattern=^[a-z0-9]+(-?[a-z0-9]+)*$ @@ -91,10 +107,18 @@ type ApiConfig struct { // +kubebuilder:validation:Required // +kubebuilder:validation:Format=uri Url string `json:"url"` + // Type selects the route behavior: TeamAPI (authenticated, no ACL) or Proxy (passthrough reverse proxy). + // +kubebuilder:validation:Required + Type ManagedRouteType `json:"type"` } -type TeamApiConfig struct { - Apis []ApiConfig `json:"apis"` +// ManagedRoutesConfig defines the configuration for managed routes in a zone. +// Managed routes are automatically created and managed by the system based on this configuration. +type ManagedRoutesConfig struct { + // Routes is the list of routes to be created for this zone. + // It may be used to create additional routes that are required for operating the zone + // +optional + Routes []ManagedRouteConfig `json:"routes"` } type PermissionsConfig struct { @@ -142,7 +166,7 @@ type ZoneSpec struct { IdentityProvider IdentityProviderConfig `json:"identityProvider"` Gateway GatewayConfig `json:"gateway"` Redis RedisConfig `json:"redis"` - TeamApis *TeamApiConfig `json:"teamApis,omitempty"` + ManagedRoutes *ManagedRoutesConfig `json:"managedRoutes,omitempty"` // +kubebuilder:validation:Enum=World;Enterprise // Visibility controls what subscriptions are allowed from and to this zone. It's also relevant for features like failover Visibility ZoneVisibility `json:"visibility"` @@ -194,9 +218,10 @@ type ZoneStatus struct { // +optional Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` - Namespace string `json:"namespace,omitempty"` - IdentityProvider *types.ObjectRef `json:"identityProvider,omitempty"` - IdentityRealm *types.ObjectRef `json:"identityRealm,omitempty"` + Namespace string `json:"namespace,omitempty"` + IdentityProvider *types.ObjectRef `json:"identityProvider,omitempty"` + IdentityRealm *types.ObjectRef `json:"identityRealm,omitempty"` + InternalIdentityRealm *types.ObjectRef `json:"internalIdentityRealm,omitempty"` Gateway *types.ObjectRef `json:"gateway,omitempty"` GatewayRealm *types.ObjectRef `json:"gatewayRealm,omitempty"` @@ -205,7 +230,7 @@ type ZoneStatus struct { TeamApiIdentityRealm *types.ObjectRef `json:"teamApiIdentityRealm,omitempty"` TeamApiGatewayRealm *types.ObjectRef `json:"teamApiGatewayRealm,omitempty"` - TeamApiRoutes []types.ObjectRef `json:"teamApiRoutes,omitempty"` + ManagedRoutes []types.ObjectRef `json:"managedRoutes,omitempty"` Links Links `json:"links,omitempty"` // Features is a list of features that are enabled or disabled for this zone. diff --git a/admin/api/v1/zz_generated.deepcopy.go b/admin/api/v1/zz_generated.deepcopy.go index ad36c3ba8..ae7b8d9f8 100644 --- a/admin/api/v1/zz_generated.deepcopy.go +++ b/admin/api/v1/zz_generated.deepcopy.go @@ -15,21 +15,6 @@ import ( 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 *ApiConfig) DeepCopyInto(out *ApiConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiConfig. -func (in *ApiConfig) DeepCopy() *ApiConfig { - if in == nil { - return nil - } - out := new(ApiConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Environment) DeepCopyInto(out *Environment) { *out = *in @@ -248,6 +233,41 @@ func (in *Links) DeepCopy() *Links { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ManagedRouteConfig) DeepCopyInto(out *ManagedRouteConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ManagedRouteConfig. +func (in *ManagedRouteConfig) DeepCopy() *ManagedRouteConfig { + if in == nil { + return nil + } + out := new(ManagedRouteConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ManagedRoutesConfig) DeepCopyInto(out *ManagedRoutesConfig) { + *out = *in + if in.Routes != nil { + in, out := &in.Routes, &out.Routes + *out = make([]ManagedRouteConfig, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ManagedRoutesConfig. +func (in *ManagedRoutesConfig) DeepCopy() *ManagedRoutesConfig { + if in == nil { + return nil + } + out := new(ManagedRoutesConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PermissionsConfig) DeepCopyInto(out *PermissionsConfig) { *out = *in @@ -404,26 +424,6 @@ func (in *SecretRotationConfig) DeepCopy() *SecretRotationConfig { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TeamApiConfig) DeepCopyInto(out *TeamApiConfig) { - *out = *in - if in.Apis != nil { - in, out := &in.Apis, &out.Apis - *out = make([]ApiConfig, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TeamApiConfig. -func (in *TeamApiConfig) DeepCopy() *TeamApiConfig { - if in == nil { - return nil - } - out := new(TeamApiConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Zone) DeepCopyInto(out *Zone) { *out = *in @@ -489,9 +489,9 @@ func (in *ZoneSpec) DeepCopyInto(out *ZoneSpec) { in.IdentityProvider.DeepCopyInto(&out.IdentityProvider) in.Gateway.DeepCopyInto(&out.Gateway) out.Redis = in.Redis - if in.TeamApis != nil { - in, out := &in.TeamApis, &out.TeamApis - *out = new(TeamApiConfig) + if in.ManagedRoutes != nil { + in, out := &in.ManagedRoutes, &out.ManagedRoutes + *out = new(ManagedRoutesConfig) (*in).DeepCopyInto(*out) } if in.Permissions != nil { @@ -534,6 +534,10 @@ func (in *ZoneStatus) DeepCopyInto(out *ZoneStatus) { in, out := &in.IdentityRealm, &out.IdentityRealm *out = (*in).DeepCopy() } + if in.InternalIdentityRealm != nil { + in, out := &in.InternalIdentityRealm, &out.InternalIdentityRealm + *out = (*in).DeepCopy() + } if in.Gateway != nil { in, out := &in.Gateway, &out.Gateway *out = (*in).DeepCopy() @@ -558,8 +562,8 @@ func (in *ZoneStatus) DeepCopyInto(out *ZoneStatus) { in, out := &in.TeamApiGatewayRealm, &out.TeamApiGatewayRealm *out = (*in).DeepCopy() } - if in.TeamApiRoutes != nil { - in, out := &in.TeamApiRoutes, &out.TeamApiRoutes + if in.ManagedRoutes != nil { + in, out := &in.ManagedRoutes, &out.ManagedRoutes *out = make([]types.ObjectRef, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) diff --git a/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml b/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml index 4d59737a2..66bed5dc7 100644 --- a/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml +++ b/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml @@ -189,6 +189,46 @@ spec: - admin - url type: object + managedRoutes: + description: |- + ManagedRoutesConfig defines the configuration for managed routes in a zone. + Managed routes are automatically created and managed by the system based on this configuration. + properties: + routes: + description: |- + Routes is the list of routes to be created for this zone. + It may be used to create additional routes that are required for operating the zone + items: + 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 + type: array + type: object permissions: description: Permissions configuration for permission service integration properties: @@ -223,34 +263,6 @@ spec: - password - port type: object - teamApis: - properties: - apis: - items: - 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 - url: - description: Url is the upstream URL of the route. - format: uri - type: string - required: - - name - - path - - url - type: object - type: array - required: - - apis - type: object visibility: description: Visibility controls what subscriptions are allowed from and to this zone. It's also relevant for features like failover @@ -458,6 +470,25 @@ spec: - name - namespace type: object + internalIdentityRealm: + 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 links: properties: gatewayIssuer: @@ -491,6 +522,27 @@ spec: - gatewayIssuer - gatewayUrl type: object + managedRoutes: + 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 namespace: type: string teamApiGatewayRealm: @@ -531,27 +583,6 @@ spec: - name - namespace type: object - teamApiRoutes: - 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 type: object type: object x-kubernetes-validations: diff --git a/admin/config/samples/admin_v1_zone.yaml b/admin/config/samples/admin_v1_zone.yaml index 9ee86bef8..e9d4dca01 100644 --- a/admin/config/samples/admin_v1_zone.yaml +++ b/admin/config/samples/admin_v1_zone.yaml @@ -28,9 +28,10 @@ spec: host: bla port: 0 password: password - teamApis: - apis: + managedRoutes: + routes: - name: my-first-team-api path: /my/first/team/api url: https://somewhere.com/other/api/path + type: TeamAPI diff --git a/admin/internal/controller/zone_controller_test.go b/admin/internal/controller/zone_controller_test.go index 5efb7307c..c1a62c0e0 100644 --- a/admin/internal/controller/zone_controller_test.go +++ b/admin/internal/controller/zone_controller_test.go @@ -59,11 +59,12 @@ func NewZone(name, namespace string) *adminv1.Zone { Password: "test-redis-password", EnableTLS: true, }, - TeamApis: &adminv1.TeamApiConfig{ - Apis: []adminv1.ApiConfig{{ + ManagedRoutes: &adminv1.ManagedRoutesConfig{ + Routes: []adminv1.ManagedRouteConfig{{ Name: "test-team-api1", Path: "/test/team/api/v1", Url: "https://test-team-api-host.de/test-team-api-v1", + Type: adminv1.ManagedRouteTypeTeamAPI, }}, }, Visibility: adminv1.ZoneVisibilityWorld, @@ -131,6 +132,87 @@ var _ = Describe("Zone Controller", func() { }) }) + Context("When reconciling a zone with a Proxy managed route", func() { + It("should create a passthrough route on the default gateway realm", func() { + zone := NewZone("test-zone-proxy", testNamespace) + zone.Spec.ManagedRoutes = &adminv1.ManagedRoutesConfig{ + Routes: []adminv1.ManagedRouteConfig{ + { + Name: "test-team-api1", + Path: "/test/team/api/v1", + Url: "https://test-team-api-host.de/test-team-api-v1", + Type: adminv1.ManagedRouteTypeTeamAPI, + }, + { + Name: "test-proxy", + Path: "/proxy/path", + Url: "https://proxy-upstream.de/backend", + Type: adminv1.ManagedRouteTypeProxy, + }, + }, + } + Expect(k8sClient.Create(ctx, zone)).To(Succeed()) + DeferCleanup(func() { + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, zone))).To(Succeed()) + }) + + Eventually(func(g Gomega) { + got := &adminv1.Zone{} + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), got) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(meta.IsStatusConditionTrue(got.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue()) + + // The proxy route should be created on the default gateway realm (named "test") + proxyRoute := &gatewayapi.Route{} + proxyRouteRef := client.ObjectKey{ + Namespace: "test--test-zone-proxy", + Name: "test--test-proxy", + } + err = k8sClient.Get(ctx, proxyRouteRef, proxyRoute) + g.Expect(err).NotTo(HaveOccurred()) + + g.Expect(proxyRoute.Spec.PassThrough).To(BeTrue()) + g.Expect(proxyRoute.Spec.Security).To(BeNil()) + g.Expect(proxyRoute.Spec.Upstreams).To(HaveLen(1)) + g.Expect(proxyRoute.Spec.Upstreams[0].Host).To(Equal("proxy-upstream.de")) + g.Expect(proxyRoute.Spec.Upstreams[0].Path).To(Equal("/backend")) + g.Expect(proxyRoute.Spec.Downstreams).To(HaveLen(1)) + g.Expect(proxyRoute.Spec.Downstreams[0].IssuerUrl).To(BeEmpty()) + g.Expect(proxyRoute.Spec.Downstreams[0].Path).To(Equal("/proxy/path")) + + // Verify ManagedRoutes status contains refs for both routes + g.Expect(got.Status.ManagedRoutes).To(HaveLen(2)) + }, timeout, interval).Should(Succeed()) + }) + }) + + Context("When reconciling an Enterprise zone", func() { + It("should not add RouteOverwrites to the gateway realm", func() { + zone := NewZone("test-zone-enterprise", testNamespace) + zone.Spec.Visibility = adminv1.ZoneVisibilityEnterprise + zone.Spec.ManagedRoutes = nil + Expect(k8sClient.Create(ctx, zone)).To(Succeed()) + DeferCleanup(func() { + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, zone))).To(Succeed()) + }) + + Eventually(func(g Gomega) { + got := &adminv1.Zone{} + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), got) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(meta.IsStatusConditionTrue(got.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue()) + + gatewayRealm := &gatewayapi.Realm{} + err = k8sClient.Get(ctx, client.ObjectKey{ + Namespace: "test--test-zone-enterprise", + Name: "test", + }, gatewayRealm) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(gatewayRealm.Spec.RouteOverwrites).To(BeEmpty()) + }, timeout, interval).Should(Succeed()) + }) + }) + Context("ExternalIdPolicies round-trip", func() { It("persists ExternalIdPolicies on the Zone spec", func() { zone := NewZone("test-zone-extids", testNamespace) @@ -205,10 +287,45 @@ func VerifyZone(ctx context.Context, g Gomega, namespacedName client.ObjectKey, Name: "test-zone", Namespace: "test--test-zone", }, + Claims: []identityapi.ClaimConfig{ + { + Name: "originZone", + Value: zoneToVerify.Name, + Type: identityapi.ClaimTypeHardcodedClaim, + }, + { + Name: "originStargate", + Value: zoneToVerify.Spec.Gateway.Url, + Type: identityapi.ClaimTypeHardcodedClaim, + }, + { + Name: "clientId", + Type: identityapi.ClaimTypeSessionNote, + }, + }, } g.Expect(identityProviderRealm.Spec).To(Equal(*identityProviderSpecRealm)) g.Expect(zone.Status.IdentityRealm).To(Equal(types.ObjectRefFromObject(identityProviderRealm))) + // Internal identity realm (rover) for admin-config clients + By("Checking if the internal identity realm (rover) is created and spec is valid") + internalIdentityRealm := &identityapi.Realm{} + internalIdentityRealmRef := client.ObjectKey{ + Namespace: "test--test-zone", + Name: "rover", + } + err = k8sClient.Get(ctx, internalIdentityRealmRef, internalIdentityRealm) + g.Expect(err).NotTo(HaveOccurred()) + + internalIdentityRealmSpec := &identityapi.RealmSpec{ + IdentityProvider: &types.ObjectRef{ + Name: "test-zone", + Namespace: "test--test-zone", + }, + } + g.Expect(internalIdentityRealm.Spec).To(Equal(*internalIdentityRealmSpec)) + g.Expect(zone.Status.InternalIdentityRealm).To(Equal(types.ObjectRefFromObject(internalIdentityRealm))) + // Identity provider client (gateway client) By("Checking if the identity provider client (gateway) is created and spec is valid") identityProviderClient := &identityapi.Client{} @@ -274,6 +391,11 @@ func VerifyZone(ctx context.Context, g Gomega, namespacedName client.ObjectKey, Urls: []string{"https://test-stargate.de/"}, IssuerUrls: []string{"https://test-iris.de/auth/realms/test"}, DefaultConsumers: []string{}, + RouteOverwrites: []gatewayapi.RouteOverwrite{ + {Type: gatewayapi.RouteTypeIssuer, Enabled: true, PathPrefix: "/spacegate"}, + {Type: gatewayapi.RouteTypeCerts, Enabled: true, PathPrefix: "/spacegate"}, + {Type: gatewayapi.RouteTypeDiscovery, Enabled: true, PathPrefix: "/spacegate"}, + }, } g.Expect(gatewayRealm.Spec).To(Equal(gatewayRealmSpec)) g.Expect(zone.Status.GatewayRealm).To(Equal(types.ObjectRefFromObject(gatewayRealm))) @@ -310,6 +432,11 @@ func VerifyZone(ctx context.Context, g Gomega, namespacedName client.ObjectKey, Urls: []string{"https://test-stargate.de/"}, IssuerUrls: []string{"https://test-iris.de/auth/realms/team-test"}, DefaultConsumers: []string{}, + RouteOverwrites: []gatewayapi.RouteOverwrite{ + {Type: gatewayapi.RouteTypeIssuer, Enabled: true, PathPrefix: "/spacegate"}, + {Type: gatewayapi.RouteTypeCerts, Enabled: true, PathPrefix: "/spacegate"}, + {Type: gatewayapi.RouteTypeDiscovery, Enabled: true, PathPrefix: "/spacegate"}, + }, } g.Expect(teamApiGatewayRealm.Spec).To(Equal(teamApiGatewayRealmSpec)) g.Expect(zone.Status.TeamApiGatewayRealm).To(Equal(types.ObjectRefFromObject(teamApiGatewayRealm))) diff --git a/admin/internal/handler/remoteorganization/handler.go b/admin/internal/handler/remoteorganization/handler.go index 474414f0f..ff01e106c 100644 --- a/admin/internal/handler/remoteorganization/handler.go +++ b/admin/internal/handler/remoteorganization/handler.go @@ -43,7 +43,7 @@ func (h *RemoteOrganizationHandler) CreateOrUpdate(ctx context.Context, obj *adm _, err = c.CreateOrUpdate(ctx, namespace, mutator) if err != nil { - return errors.Wrapf(err, "❌ failed to create or update namespace %s, environment %s", namespace.Name, envName) + return errors.Wrapf(err, "failed to create or update namespace %s, environment %s", namespace.Name, envName) } obj.Status.Namespace = namespace.Name @@ -66,7 +66,7 @@ func (h *RemoteOrganizationHandler) Delete(ctx context.Context, obj *adminv1.Rem if apierrors.IsNotFound(err) { return nil } - return errors.Wrapf(err, "❌ failed to delete namespace %s", namespace.Name) + return errors.Wrapf(err, "failed to delete namespace %s", namespace.Name) } return nil } diff --git a/admin/internal/handler/util/naming/naming.go b/admin/internal/handler/util/naming/naming.go index 6ef7908b9..c09f2b784 100644 --- a/admin/internal/handler/util/naming/naming.go +++ b/admin/internal/handler/util/naming/naming.go @@ -8,6 +8,7 @@ import adminv1 "github.com/telekom/controlplane/admin/api/v1" const ( teamApiIdentityRealmPrefix = "team-" + internalIdentityRealmName = "rover" gatewayClientName = "gateway" gatewayAdminClientId = "rover" gateway = "gateway" @@ -18,6 +19,10 @@ func ForDefaultIdentityRealm(environment *adminv1.Environment) string { return environment.GetName() } +func ForInternalIdentityRealm() string { + return internalIdentityRealmName +} + func ForTeamApiIdentityRealm(environment *adminv1.Environment) string { return teamApiIdentityRealmPrefix + environment.GetName() } @@ -50,6 +55,6 @@ func ForGatewayConsumer() string { return gatewayConsumer } -func ForGatewayRoute(config adminv1.ApiConfig) string { +func ForGatewayRoute(config adminv1.ManagedRouteConfig) string { return config.Name } diff --git a/admin/internal/handler/util/urls/urls.go b/admin/internal/handler/util/urls/urls.go index ac56943e9..357ac6f21 100644 --- a/admin/internal/handler/util/urls/urls.go +++ b/admin/internal/handler/util/urls/urls.go @@ -43,10 +43,10 @@ func ForGatewayRealm(identityProviderBaseUrl, realmName string) string { return realmIssuerUrl + "auth/realms/" + realmName } -func ForRouteDownstream(gatewayBaseUrl string, config adminv1.ApiConfig) (*url.URL, error) { +func ForRouteDownstream(gatewayBaseUrl string, config adminv1.ManagedRouteConfig) (*url.URL, error) { raw, err := url.JoinPath(gatewayBaseUrl, config.Path) if err != nil { - return nil, errors.Wrapf(err, "Cannot combine gatewayBaseUrl %s with team api route path %s", gatewayBaseUrl, config.Path) + return nil, errors.Wrapf(err, "Cannot combine gatewayBaseUrl %s with managed route path %s", gatewayBaseUrl, config.Path) } return url.Parse(raw) } diff --git a/admin/internal/handler/zone/handler.go b/admin/internal/handler/zone/handler.go index c0766140d..b5675db9b 100644 --- a/admin/internal/handler/zone/handler.go +++ b/admin/internal/handler/zone/handler.go @@ -32,6 +32,10 @@ import ( const ( zoneLabelName = "zone" + + // spacegatePathPrefix is the downstream path prefix added to all identity + // routes (issuer, certs, discovery) when a zone's visibility is World. + spacegatePathPrefix = "/spacegate" ) var _ handler.Handler[*adminv1.Zone] = &ZoneHandler{} @@ -45,7 +49,7 @@ func (h *ZoneHandler) CreateOrUpdate(ctx context.Context, obj *adminv1.Zone) err environment := &adminv1.Environment{} err := c.Get(ctx, client.ObjectKey{Name: envName, Namespace: envName}, environment) if err != nil { - return errors.Wrapf(err, "❌ failed to get environment %s", envName) + return errors.Wrapf(err, "failed to get environment %s", envName) } // Namespace @@ -64,7 +68,7 @@ func (h *ZoneHandler) CreateOrUpdate(ctx context.Context, obj *adminv1.Zone) err } _, err = c.CreateOrUpdate(ctx, namespace, mutator) if err != nil { - return errors.Wrapf(err, "❌ failed to create or update namespace %s", namespace.Name) + return errors.Wrapf(err, "failed to create or update namespace %s", namespace.Name) } obj.Status.Namespace = namespace.Name @@ -83,7 +87,23 @@ func (h *ZoneHandler) CreateOrUpdate(ctx context.Context, obj *adminv1.Zone) err obj.Status.IdentityProvider = types.ObjectRefFromObject(identityProvider) // Identity Realm - identityRealm, err := createIdentityRealm(ctx, handlingContext, identityProvider, naming.ForDefaultIdentityRealm(handlingContext.Environment)) + defaultClaims := []identityapi.ClaimConfig{ + { + Name: "originZone", + Value: handlingContext.Zone.Name, + Type: identityapi.ClaimTypeHardcodedClaim, + }, + { + Name: "originStargate", + Value: handlingContext.Zone.Spec.Gateway.Url, + Type: identityapi.ClaimTypeHardcodedClaim, + }, + { + Name: "clientId", + Type: identityapi.ClaimTypeSessionNote, + }, + } + identityRealm, err := createIdentityRealm(ctx, handlingContext, identityProvider, naming.ForDefaultIdentityRealm(handlingContext.Environment), defaultClaims) if err != nil { return err } @@ -93,6 +113,13 @@ func (h *ZoneHandler) CreateOrUpdate(ctx context.Context, obj *adminv1.Zone) err return errors.Wrapf(err, "Cannot combine identityProviderBaseUrl %s with realm name %s", obj.Spec.IdentityProvider.Url, identityRealm.Name) } + // Internal Identity Realm (rover) for admin-config clients + internalIdentityRealm, err := createIdentityRealm(ctx, handlingContext, identityProvider, naming.ForInternalIdentityRealm(), nil) + if err != nil { + return err + } + obj.Status.InternalIdentityRealm = types.ObjectRefFromObject(internalIdentityRealm) + // Identity Client for gateway // TBD - how to handle passwords for this client - will be regenerated with every reconciliation gatewayClient, err := createIdentityClient(ctx, handlingContext, identityRealm) @@ -127,18 +154,24 @@ func (h *ZoneHandler) CreateOrUpdate(ctx context.Context, obj *adminv1.Zone) err } obj.Status.GatewayConsumer = types.ObjectRefFromObject(gatewayConsumer) - // Team apis configuration - if obj.Spec.TeamApis != nil { - if err := reconcileTeamApis(ctx, handlingContext, obj, identityProvider, gateway); err != nil { + // Internal routes configuration + if obj.Spec.ManagedRoutes != nil { + if err := reconcileManagedRoutes(ctx, handlingContext, obj, identityProvider, gateway, gatewayRealm); err != nil { return err } } else { obj.Status.TeamApiIdentityRealm = nil obj.Status.TeamApiGatewayRealm = nil - obj.Status.TeamApiRoutes = nil + obj.Status.ManagedRoutes = nil obj.Status.Links.TeamIssuer = "" } + // Cleanup managed routes that were not created or updated during this reconciliation. + // Using OwnedByLabel because routes live in a different namespace than the Zone CR. + if _, err := c.Cleanup(ctx, &gatewayapi.RouteList{}, cclient.OwnedByLabel(obj)); err != nil { + return errors.Wrapf(err, "failed to cleanup stale managed routes for zone %s", obj.Name) + } + // Populate Permissions URL if configured and feature enabled if cconfig.FeaturePermission.IsEnabled() && obj.Spec.Permissions != nil { // Use url.JoinPath to properly handle slashes when combining gateway URL with ApiBasePath @@ -157,8 +190,49 @@ func (h *ZoneHandler) CreateOrUpdate(ctx context.Context, obj *adminv1.Zone) err return nil } -func reconcileTeamApis(ctx context.Context, handlingContext HandlingContext, zone *adminv1.Zone, identityProvider *identityapi.IdentityProvider, gateway *gatewayapi.Gateway) error { - teamApiIdentityRealm, err := createIdentityRealm(ctx, handlingContext, identityProvider, naming.ForTeamApiIdentityRealm(handlingContext.Environment)) +func reconcileManagedRoutes(ctx context.Context, handlingContext HandlingContext, zone *adminv1.Zone, identityProvider *identityapi.IdentityProvider, gateway *gatewayapi.Gateway, defaultGatewayRealm *gatewayapi.Realm) error { + // Reset status to avoid stale/duplicate entries across reconciliations + zone.Status.ManagedRoutes = nil + + // Partition routes by type + var teamAPIRoutes, proxyRoutes []adminv1.ManagedRouteConfig + for _, r := range zone.Spec.ManagedRoutes.Routes { + switch r.Type { + case adminv1.ManagedRouteTypeTeamAPI: + teamAPIRoutes = append(teamAPIRoutes, r) + case adminv1.ManagedRouteTypeProxy: + proxyRoutes = append(proxyRoutes, r) + default: + return fmt.Errorf("unsupported managed route type %q for route %q", r.Type, r.Name) + } + } + + // TeamAPI routes require a dedicated identity and gateway realm + if err := reconcileTeamAPIRoutes(ctx, handlingContext, zone, identityProvider, gateway, teamAPIRoutes); err != nil { + return err + } + + // Proxy routes use the default gateway realm with full passthrough + for _, routeConfig := range proxyRoutes { + route, err := createManagedRoute(ctx, handlingContext, routeConfig, defaultGatewayRealm, true) + if err != nil { + return err + } + zone.Status.ManagedRoutes = append(zone.Status.ManagedRoutes, *types.ObjectRefFromObject(route)) + } + + return nil +} + +func reconcileTeamAPIRoutes(ctx context.Context, handlingContext HandlingContext, zone *adminv1.Zone, identityProvider *identityapi.IdentityProvider, gateway *gatewayapi.Gateway, routes []adminv1.ManagedRouteConfig) error { + if len(routes) == 0 { + zone.Status.TeamApiIdentityRealm = nil + zone.Status.TeamApiGatewayRealm = nil + zone.Status.Links.TeamIssuer = "" + return nil + } + + teamApiIdentityRealm, err := createIdentityRealm(ctx, handlingContext, identityProvider, naming.ForTeamApiIdentityRealm(handlingContext.Environment), nil) if err != nil { return err } @@ -173,50 +247,50 @@ func reconcileTeamApis(ctx context.Context, handlingContext HandlingContext, zon zone.Status.Links.TeamIssuer = teamApisGatewayRealm.Spec.IssuerUrls[0] } - teamApiRouteRefs := make([]types.ObjectRef, 0, len(zone.Spec.TeamApis.Apis)) - for _, teamApiRoute := range zone.Spec.TeamApis.Apis { - route, err := createTeamApiRoute(ctx, handlingContext, teamApiRoute, teamApisGatewayRealm) + for _, routeConfig := range routes { + route, err := createManagedRoute(ctx, handlingContext, routeConfig, teamApisGatewayRealm, false) if err != nil { return err } - teamApiRouteRefs = append(teamApiRouteRefs, *types.ObjectRefFromObject(route)) + zone.Status.ManagedRoutes = append(zone.Status.ManagedRoutes, *types.ObjectRefFromObject(route)) } - zone.Status.TeamApiRoutes = teamApiRouteRefs + return nil } -func createTeamApiRoute(ctx context.Context, handlingContext HandlingContext, teamRouteConfig adminv1.ApiConfig, gatewayRealm *gatewayapi.Realm) (*gatewayapi.Route, error) { +func createManagedRoute(ctx context.Context, handlingContext HandlingContext, routeConfig adminv1.ManagedRouteConfig, gatewayRealm *gatewayapi.Realm, passThrough bool) (*gatewayapi.Route, error) { scopedClient := cclient.ClientFromContextOrDie(ctx) - teamRoute := &gatewayapi.Route{ + route := &gatewayapi.Route{ ObjectMeta: metav1.ObjectMeta{ - Name: gatewayRealm.Name + "--" + naming.ForGatewayRoute(teamRouteConfig), + Name: gatewayRealm.Name + "--" + naming.ForGatewayRoute(routeConfig), Namespace: handlingContext.Namespace.Name, }, } mutator := func() error { - teamRoute.Labels = map[string]string{ + route.Labels = map[string]string{ cconfig.EnvironmentLabelKey: handlingContext.Environment.Name, cconfig.BuildLabelKey(zoneLabelName): handlingContext.Zone.Name, + cconfig.OwnerUidLabelKey: string(handlingContext.Zone.GetUID()), } - upstreamUrl, err := url.Parse(teamRouteConfig.Url) + upstreamUrl, err := url.Parse(routeConfig.Url) if err != nil { - return errors.Wrapf(err, "Cannot parse upstream url of team route %s", teamRouteConfig.Url) + return errors.Wrapf(err, "Cannot parse upstream url of internal route %s", routeConfig.Url) } upstream := gatewayapi.Upstream{ Scheme: upstreamUrl.Scheme, - Host: upstreamUrl.Host, + Host: upstreamUrl.Hostname(), Port: gatewayapi.GetPortOrDefaultFromScheme(upstreamUrl), Path: upstreamUrl.Path, } - downstreamUrl, err := urls.ForRouteDownstream(handlingContext.Zone.Spec.Gateway.Url, teamRouteConfig) + downstreamUrl, err := urls.ForRouteDownstream(handlingContext.Zone.Spec.Gateway.Url, routeConfig) if err != nil { return err } issuerUrl := "" - if len(gatewayRealm.Spec.IssuerUrls) > 0 { + if !passThrough && len(gatewayRealm.Spec.IssuerUrls) > 0 { issuerUrl = gatewayRealm.Spec.IssuerUrls[0] } downstream := gatewayapi.Downstream{ @@ -226,25 +300,28 @@ func createTeamApiRoute(ctx context.Context, handlingContext HandlingContext, te IssuerUrl: issuerUrl, } - teamRoute.Spec = gatewayapi.RouteSpec{ + route.Spec = gatewayapi.RouteSpec{ Realm: *types.ObjectRefFromObject(gatewayRealm), - PassThrough: false, + PassThrough: passThrough, Upstreams: []gatewayapi.Upstream{upstream}, Downstreams: []gatewayapi.Downstream{downstream}, Traffic: gatewayapi.Traffic{}, - Security: &gatewayapi.Security{ - DisableAccessControl: true, // Team APIs are not protected by ACLs - }, + } + + if !passThrough { + route.Spec.Security = &gatewayapi.Security{ + DisableAccessControl: true, + } } return nil } - _, err := scopedClient.CreateOrUpdate(ctx, teamRoute, mutator) + _, err := scopedClient.CreateOrUpdate(ctx, route, mutator) if err != nil { - return nil, errors.Wrapf(err, "failed to create or update Gateway route %s in zone %s", teamRoute.GetName(), handlingContext.Zone.Name) + return nil, errors.Wrapf(err, "failed to create or update Gateway route %s in zone %s", route.GetName(), handlingContext.Zone.Name) } - return teamRoute, nil + return route, nil } func createGatewayConsumer(ctx context.Context, handlingContext HandlingContext, gatewayRealm *gatewayapi.Realm) (*gatewayapi.Consumer, error) { @@ -291,11 +368,32 @@ func createGatewayRealm(ctx context.Context, handlingContext HandlingContext, ga cconfig.BuildLabelKey(zoneLabelName): handlingContext.Zone.Name, } + var routeOverwrites []gatewayapi.RouteOverwrite + // If the zone is WORLD visible, the gateway is considered a "SpaceGate" + // to reduce internet-facing exposure the actual IDP routes are not exposed directly + // but via a proxy route "/auth/realms/". However, this path is already used for + // the Gateway Realm itself, so we need to add another prefix to avoid conflicts. + // The SpaceGate route will then be available under a common-prefix + if handlingContext.Zone.Spec.Visibility == adminv1.ZoneVisibilityWorld { + for _, rt := range []gatewayapi.RouteType{ + gatewayapi.RouteTypeIssuer, + gatewayapi.RouteTypeCerts, + gatewayapi.RouteTypeDiscovery, + } { + routeOverwrites = append(routeOverwrites, gatewayapi.RouteOverwrite{ + Type: rt, + Enabled: true, + PathPrefix: spacegatePathPrefix, + }) + } + } + gatewayRealm.Spec = gatewayapi.RealmSpec{ Gateway: types.ObjectRefFromObject(gateway), Urls: []string{handlingContext.Zone.Spec.Gateway.Url}, IssuerUrls: []string{urls.ForGatewayRealm(handlingContext.Zone.Spec.IdentityProvider.Url, realmName)}, DefaultConsumers: []string{}, + RouteOverwrites: routeOverwrites, } return nil } @@ -403,7 +501,7 @@ func getIdentityClient(ctx context.Context, ref *types.ObjectRef) (*identityapi. return identityClient, nil } -func createIdentityRealm(ctx context.Context, handlingContext HandlingContext, identityProvider *identityapi.IdentityProvider, realmName string) (*identityapi.Realm, error) { +func createIdentityRealm(ctx context.Context, handlingContext HandlingContext, identityProvider *identityapi.IdentityProvider, realmName string, claims []identityapi.ClaimConfig) (*identityapi.Realm, error) { scopedClient := cclient.ClientFromContextOrDie(ctx) identityRealm := &identityapi.Realm{ @@ -424,6 +522,7 @@ func createIdentityRealm(ctx context.Context, handlingContext HandlingContext, i Name: identityProvider.Name, Namespace: identityProvider.Namespace, }, + Claims: claims, } secretRotationConfig := handlingContext.Zone.Spec.IdentityProvider.SecretRotation diff --git a/docs/docs/admin-journey/environments-and-zones.md b/docs/docs/admin-journey/environments-and-zones.md index 48f5309aa..510ca083e 100644 --- a/docs/docs/admin-journey/environments-and-zones.md +++ b/docs/docs/admin-journey/environments-and-zones.md @@ -93,6 +93,45 @@ Zones can be configured with different visibility levels: - **World** — The zone is accessible from outside the platform (public-facing APIs). - **Enterprise** — The zone is accessible only within the organization's network. +### Managed Routes + +Zones can optionally define **managed routes** — platform-managed gateway routes that are configured directly on the Zone resource rather than being created dynamically through Rover or API resources. + +Each managed route has a **type** that determines its behavior: + +| Type | Behavior | +|------|----------| +| **TeamAPI** | Authenticated route on the zone's team-api gateway realm. Requires token validation but does not enforce per-consumer ACLs. Used for team-facing platform APIs. | +| **Proxy** | Fully passthrough route on the zone's default gateway realm. Acts as a pure reverse proxy without any authentication or authorization. | + +Example: + +```yaml +spec: + managedRoutes: + routes: + - name: team-api + path: /team/api/v1 + url: https://my-team-api.internal.example.com/api/v1 + type: TeamAPI + - name: health-proxy + path: /health + url: https://health-service.internal.example.com/ + type: Proxy +``` + +### Token Claims + +The Control Plane automatically injects the following claims into all tokens issued for clients in a zone's default identity realm: + +| Claim | Type | Description | +|-------|------|-------------| +| `originZone` | Hardcoded | The name of the zone that issued the token. | +| `originStargate` | Hardcoded | The public gateway URL of the zone. | +| `clientId` | Session note | The OAuth2 client ID of the authenticated caller, populated automatically by Keycloak. | + +These claims allow downstream services to identify the origin of a request without additional lookups. + ## Remote Organizations :::caution Planned Feature diff --git a/docs/docs/architecture/admin.mdx b/docs/docs/architecture/admin.mdx index e1a6b7313..ad5ac5204 100644 --- a/docs/docs/architecture/admin.mdx +++ b/docs/docs/architecture/admin.mdx @@ -14,9 +14,9 @@ This domain is typically managed by platform administrators, not application tea ## Domain Interactions -- **Gateway domain** — Zones define which gateway instance is used. The Gateway operator reads the zone's gateway configuration when provisioning routes. -- **Identity domain** — Zones define which identity provider is used. The Identity operator reads the zone's IDP configuration when provisioning clients and realms. -- **Organization domain** — Teams are created within environments. Zones determine where team resources are provisioned. +- **Gateway domain** — Zones define which gateway instance is used. The Gateway operator reads the zone's gateway configuration when provisioning routes. Managed routes (TeamAPI and Proxy) are created directly by the zone handler on the appropriate gateway realm. +- **Identity domain** — Zones define which identity provider is used. The Identity operator reads the zone's IDP configuration when provisioning clients and realms. The zone handler creates a default identity realm with token claims (`originZone`, `originStargate`, `clientId`) and a dedicated internal "rover" realm for admin-config clients. +- **Organization domain** — Teams are created within environments. Zones determine where team resources are provisioned. If a zone has TeamAPI-type managed routes, a team-api identity realm and gateway realm are created for team-facing APIs. - **Event domain** — EventConfig resources reference zones for event routing and meshing. ## Related Pages diff --git a/gateway/api/v1/realm_types.go b/gateway/api/v1/realm_types.go index 18abb5aee..c32504a27 100644 --- a/gateway/api/v1/realm_types.go +++ b/gateway/api/v1/realm_types.go @@ -14,6 +14,17 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// RouteType defines the type of route to create for a realm. +// It is used to determine the path format for the route and the downstream URL to use for the route. +// +kubebuilder:validation:Enum=issuer;certs;discovery +type RouteType string + +const ( + RouteTypeIssuer RouteType = "issuer" + RouteTypeCerts RouteType = "certs" + RouteTypeDiscovery RouteType = "discovery" +) + // RealmSpec defines the desired state of Realm type RealmSpec struct { @@ -34,6 +45,29 @@ type RealmSpec struct { // +listType=set // +kubebuilder:default={} DefaultConsumers []string `json:"defaultConsumers"` + + // RouteOverwrites is a list of route overwrites for this realm. If empty, the default routes will be used + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +optional + RouteOverwrites []RouteOverwrite `json:"routeOverwrites,omitempty"` +} + +// RouteOverwrite defines the configuration for overwriting a route for a realm. +// It allows to enable/disable a route and to specify a custom path prefix for the route. +// Per default all routes are enabled and use the default path prefix. If a route is disabled, it will not be created for the realm. +type RouteOverwrite struct { + // Type is the type of route to overwrite. It is used to determine which route to overwrite + Type RouteType `json:"type"` + // Enabled indicates whether the route is enabled or not. If false, the route will be disabled and not created + // +kubebuilder:default=true + Enabled bool `json:"enabled"` + // PathPrefix is the path prefix to use for the route. If empty, no prefix will be used + // +kubebuilder:default="" + // +kubebuilder:validation:Pattern=`^(\/[a-zA-Z0-9\-\/]*)?$` + PathPrefix string `json:"pathPrefix,omitempty"` } // RealmStatus defines the observed state of Realm diff --git a/gateway/api/v1/zz_generated.deepcopy.go b/gateway/api/v1/zz_generated.deepcopy.go index 889f1a542..54a7c0d23 100644 --- a/gateway/api/v1/zz_generated.deepcopy.go +++ b/gateway/api/v1/zz_generated.deepcopy.go @@ -808,6 +808,11 @@ func (in *RealmSpec) DeepCopyInto(out *RealmSpec) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.RouteOverwrites != nil { + in, out := &in.RouteOverwrites, &out.RouteOverwrites + *out = make([]RouteOverwrite, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RealmSpec. @@ -944,6 +949,21 @@ func (in *RouteList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RouteOverwrite) DeepCopyInto(out *RouteOverwrite) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouteOverwrite. +func (in *RouteOverwrite) DeepCopy() *RouteOverwrite { + if in == nil { + return nil + } + out := new(RouteOverwrite) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RouteSpec) DeepCopyInto(out *RouteSpec) { *out = *in diff --git a/gateway/config/crd/bases/gateway.cp.ei.telekom.de_realms.yaml b/gateway/config/crd/bases/gateway.cp.ei.telekom.de_realms.yaml index b2c467abf..4ff7992de 100644 --- a/gateway/config/crd/bases/gateway.cp.ei.telekom.de_realms.yaml +++ b/gateway/config/crd/bases/gateway.cp.ei.telekom.de_realms.yaml @@ -78,6 +78,42 @@ spec: minItems: 1 type: array x-kubernetes-list-type: set + routeOverwrites: + description: RouteOverwrites is a list of route overwrites for this + realm. If empty, the default routes will be used + items: + description: |- + RouteOverwrite defines the configuration for overwriting a route for a realm. + It allows to enable/disable a route and to specify a custom path prefix for the route. + Per default all routes are enabled and use the default path prefix. If a route is disabled, it will not be created for the realm. + properties: + enabled: + default: true + description: Enabled indicates whether the route is enabled + or not. If false, the route will be disabled and not created + type: boolean + pathPrefix: + default: "" + description: PathPrefix is the path prefix to use for the route. + If empty, no prefix will be used + pattern: ^(\/[a-zA-Z0-9\-\/]*)?$ + type: string + type: + description: Type is the type of route to overwrite. It is used + to determine which route to overwrite + enum: + - issuer + - certs + - discovery + type: string + required: + - enabled + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map urls: description: Urls is the list of Gateway URLs that this realm should accept traffic from diff --git a/gateway/internal/controller/index.go b/gateway/internal/controller/index.go index 5d4815b0a..cc2e59d08 100644 --- a/gateway/internal/controller/index.go +++ b/gateway/internal/controller/index.go @@ -8,6 +8,7 @@ import ( "context" "os" + "github.com/telekom/controlplane/common/pkg/controller/index" gatewayv1 "github.com/telekom/controlplane/gateway/api/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -78,4 +79,9 @@ func RegisterIndecesOrDie(ctx context.Context, mgr ctrl.Manager) { os.Exit(1) } + err = index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &gatewayv1.Route{}) + if err != nil { + ctrl.Log.Error(err, "unable to create field-indexer") + os.Exit(1) + } } diff --git a/gateway/internal/features/feature/circuit_breaker.go b/gateway/internal/features/feature/circuit_breaker.go index 2febcdd31..58b7805b4 100644 --- a/gateway/internal/features/feature/circuit_breaker.go +++ b/gateway/internal/features/feature/circuit_breaker.go @@ -7,8 +7,8 @@ package feature import ( "context" "fmt" + "github.com/go-logr/logr" - "github.com/pkg/errors" "github.com/telekom/controlplane/common/pkg/util/contextutil" gatewayv1 "github.com/telekom/controlplane/gateway/api/v1" "github.com/telekom/controlplane/gateway/internal/features" @@ -161,10 +161,10 @@ func handleApply(ctx context.Context, builder features.FeaturesBuilder, route *g upstreamResponse, err := kongAdminApi.UpsertUpstreamWithResponse(ctx, upstreamName, upstreamBody) if err != nil { - return errors.Wrap(err, "failed to create upstream") + return fmt.Errorf("failed to create upstream: %w", err) } if err := client.CheckStatusCode(upstreamResponse, 200); err != nil { - return errors.Wrap(fmt.Errorf("error body from kong admin api: %s", string(upstreamResponse.Body)), "failed to create upstream") + return fmt.Errorf("failed to create upstream (%d): %s: %w", upstreamResponse.StatusCode(), string(upstreamResponse.Body), err) } route.SetUpstreamId(*upstreamResponse.JSON200.Id) @@ -184,10 +184,10 @@ func handleApply(ctx context.Context, builder features.FeaturesBuilder, route *g // this is a special case with the kong admin API - this endpoint /upstreams/:upstreamName/targets actually accepts multiple POST requests, so this is not a mistake targetsResponse, err := kongAdminApi.CreateTargetForUpstreamWithResponse(ctx, upstreamName, targetsBody) if err != nil { - return errors.Wrap(err, "failed to create targets for upstream") + return fmt.Errorf("failed to create targets for upstream: %w", err) } if err := client.CheckStatusCode(targetsResponse, 200, 201); err != nil { - return errors.Wrap(fmt.Errorf("error body from kong admin api: %s", string(targetsResponse.Body)), "failed to create targets for upstream") + return fmt.Errorf("failed to create targets for upstream (%d): %s: %w", targetsResponse.StatusCode(), string(targetsResponse.Body), err) } route.SetTargetsId(*targetsResponse.JSON200.Id) diff --git a/gateway/internal/features/feature/last_mile_security.go b/gateway/internal/features/feature/last_mile_security.go index 512d8fc67..7ac6a86b0 100644 --- a/gateway/internal/features/feature/last_mile_security.go +++ b/gateway/internal/features/feature/last_mile_security.go @@ -6,6 +6,7 @@ package feature import ( "context" + "fmt" "strconv" "strings" @@ -69,7 +70,7 @@ func (f *LastMileSecurityFeature) Apply(ctx context.Context, builder features.Fe log.V(1).Info("Resolving client secret from secret manager", "secretRef", clientSecret) clientSecret, err = secrets.Get(ctx, clientSecret) if err != nil { - return err + return fmt.Errorf("failed to resolve upstream client secret for route %s: %w", route.Name, err) } } diff --git a/gateway/internal/handler/consumer/handler.go b/gateway/internal/handler/consumer/handler.go index 0e76f6758..b9dae92d3 100644 --- a/gateway/internal/handler/consumer/handler.go +++ b/gateway/internal/handler/consumer/handler.go @@ -30,7 +30,7 @@ func (h *ConsumerHandler) CreateOrUpdate(ctx context.Context, consumer *gatewayv } if err := builder.BuildForConsumer(ctx); err != nil { - return errors.Wrap(err, "failed to build route") + return errors.Wrap(err, "failed to build consumer") } consumer.SetCondition(condition.NewDoneProcessingCondition("Consumer is ready")) diff --git a/gateway/internal/handler/realm/handler.go b/gateway/internal/handler/realm/handler.go index 633474e6b..611f01ebe 100644 --- a/gateway/internal/handler/realm/handler.go +++ b/gateway/internal/handler/realm/handler.go @@ -7,7 +7,9 @@ package realm import ( "context" + "github.com/go-logr/logr" "github.com/pkg/errors" + "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" @@ -19,7 +21,7 @@ var _ handler.Handler[*gatewayv1.Realm] = &RealmHandler{} type RealmHandler struct{} func (h *RealmHandler) CreateOrUpdate(ctx context.Context, realm *gatewayv1.Realm) error { - + logger := logr.FromContextOrDiscard(ctx) realm.Status.Virtual = realm.Spec.Gateway == nil if !realm.Status.Virtual { @@ -28,6 +30,16 @@ func (h *RealmHandler) CreateOrUpdate(ctx context.Context, realm *gatewayv1.Real } } + gc := client.ClientFromContextOrDie(ctx) + + n, err := gc.Cleanup(ctx, &gatewayv1.RouteList{}, client.OwnedBy(realm)) + if err != nil { + return errors.Wrap(err, "failed to cleanup routes") + } + if n > 0 { + logger.V(1).Info("Cleaned up routes", "count", n) + } + realm.SetCondition(condition.NewReadyCondition("RealmReady", "Realm has been provisioned")) realm.SetCondition(condition.NewDoneProcessingCondition("Realm has been provisioned")) @@ -40,26 +52,41 @@ func (h *RealmHandler) Delete(ctx context.Context, realm *gatewayv1.Realm) error func createRoutes(ctx context.Context, realm *gatewayv1.Realm) error { - route, err := CreateRoute(ctx, realm, RouteTypeIssuer) + route, err := CreateRoute(ctx, realm, gatewayv1.RouteTypeIssuer) if err != nil { - return errors.Wrapf(err, "failed to create route '%s'", RouteTypeIssuer) + return errors.Wrapf(err, "failed to create route %q", gatewayv1.RouteTypeIssuer) + } + if route != nil { + realm.Status.IssuerRoute = types.ObjectRefFromObject(route) + realm.Status.IssuerUrl = route.Spec.Downstreams[0].Url() + } else { + realm.Status.IssuerRoute = nil + realm.Status.IssuerUrl = "" } - realm.Status.IssuerRoute = types.ObjectRefFromObject(route) - realm.Status.IssuerUrl = route.Spec.Downstreams[0].Url() - route, err = CreateRoute(ctx, realm, RouteTypeCerts) + route, err = CreateRoute(ctx, realm, gatewayv1.RouteTypeCerts) if err != nil { - return errors.Wrapf(err, "failed to create route '%s'", RouteTypeCerts) + return errors.Wrapf(err, "failed to create route %q", gatewayv1.RouteTypeCerts) + } + if route != nil { + realm.Status.CertsRoute = types.ObjectRefFromObject(route) + realm.Status.CertsUrl = route.Spec.Downstreams[0].Url() + } else { + realm.Status.CertsRoute = nil + realm.Status.CertsUrl = "" } - realm.Status.CertsRoute = types.ObjectRefFromObject(route) - realm.Status.CertsUrl = route.Spec.Downstreams[0].Url() - route, err = CreateRoute(ctx, realm, RouteTypeDiscovery) + route, err = CreateRoute(ctx, realm, gatewayv1.RouteTypeDiscovery) if err != nil { - return errors.Wrapf(err, "failed to create route '%s'", RouteTypeDiscovery) + return errors.Wrapf(err, "failed to create route %q", gatewayv1.RouteTypeDiscovery) + } + if route != nil { + realm.Status.DiscoveryRoute = types.ObjectRefFromObject(route) + realm.Status.DiscoveryUrl = route.Spec.Downstreams[0].Url() + } else { + realm.Status.DiscoveryRoute = nil + realm.Status.DiscoveryUrl = "" } - realm.Status.DiscoveryRoute = types.ObjectRefFromObject(route) - realm.Status.DiscoveryUrl = route.Spec.Downstreams[0].Url() return nil } diff --git a/gateway/internal/handler/realm/routes.go b/gateway/internal/handler/realm/routes.go index 7b4e548a7..b3585e325 100644 --- a/gateway/internal/handler/realm/routes.go +++ b/gateway/internal/handler/realm/routes.go @@ -18,35 +18,51 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) -type RouteType string - -const ( - RouteTypeIssuer RouteType = "issuer" - RouteTypeCerts RouteType = "certs" - RouteTypeDiscovery RouteType = "discovery" -) - type routeConfig struct { UpstreamPathFormat string DownstreamPathFormat string } -var routeMap = map[RouteType]routeConfig{ - RouteTypeIssuer: { +func (c routeConfig) UpstreamPath(realmName string) string { + return fmt.Sprintf(c.UpstreamPathFormat, realmName) +} + +func (c routeConfig) DownstreamPath(realmName string) string { + return fmt.Sprintf(c.DownstreamPathFormat, realmName) +} + +func (c routeConfig) DownstreamPathWithPrefix(realmName, prefix string) string { + if prefix == "" { + return c.DownstreamPath(realmName) + } + return path.Join(prefix, c.DownstreamPath(realmName)) +} + +var routeMap = map[gatewayv1.RouteType]routeConfig{ + gatewayv1.RouteTypeIssuer: { UpstreamPathFormat: "/api/v1/issuer/%s", DownstreamPathFormat: "/auth/realms/%s", }, - RouteTypeCerts: { + gatewayv1.RouteTypeCerts: { UpstreamPathFormat: "/api/v1/certs/%s", DownstreamPathFormat: "/auth/realms/%s/protocol/openid-connect/certs", }, - RouteTypeDiscovery: { + gatewayv1.RouteTypeDiscovery: { UpstreamPathFormat: "/api/v1/discovery/%s", DownstreamPathFormat: "/auth/realms/%s/.well-known/openid-configuration", }, } -func CreateRoute(ctx context.Context, realm *gatewayv1.Realm, routeType RouteType) (*gatewayv1.Route, error) { +func findRouteOverwrite(realm *gatewayv1.Realm, routeType gatewayv1.RouteType) *gatewayv1.RouteOverwrite { + for i := range realm.Spec.RouteOverwrites { + if realm.Spec.RouteOverwrites[i].Type == routeType { + return &realm.Spec.RouteOverwrites[i] + } + } + return nil +} + +func CreateRoute(ctx context.Context, realm *gatewayv1.Realm, routeType gatewayv1.RouteType) (*gatewayv1.Route, error) { c := client.ClientFromContextOrDie(ctx) cfg, exists := routeMap[routeType] @@ -54,6 +70,16 @@ func CreateRoute(ctx context.Context, realm *gatewayv1.Realm, routeType RouteTyp return nil, errors.Errorf("route type %s not found", routeType) } + overwrite := findRouteOverwrite(realm, routeType) + if overwrite != nil && !overwrite.Enabled { + return nil, nil + } + + var downstreamPrefix string + if overwrite != nil { + downstreamPrefix = overwrite.PathPrefix + } + route := &gatewayv1.Route{ ObjectMeta: metav1.ObjectMeta{ Name: realm.Name + "--" + string(routeType), @@ -90,7 +116,7 @@ func CreateRoute(ctx context.Context, realm *gatewayv1.Realm, routeType RouteTyp { Host: url.Hostname(), Port: gatewayv1.GetPortOrDefaultFromScheme(url), - Path: path.Join(url.Path, fmt.Sprintf(cfg.DownstreamPathFormat, realm.Name)), + Path: path.Join(url.Path, cfg.DownstreamPathWithPrefix(realm.Name, downstreamPrefix)), IssuerUrl: "", }, }, diff --git a/gateway/pkg/kong/client/client.go b/gateway/pkg/kong/client/client.go index eb04fe83d..f8e3d0675 100644 --- a/gateway/pkg/kong/client/client.go +++ b/gateway/pkg/kong/client/client.go @@ -7,6 +7,7 @@ package client import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -15,7 +16,6 @@ import ( "github.com/go-logr/logr" "github.com/google/uuid" - "github.com/pkg/errors" "github.com/telekom/controlplane/common/pkg/util/contextutil" kong "github.com/telekom/controlplane/gateway/pkg/kong/api" @@ -115,7 +115,7 @@ func (c *kongClient) LoadPlugin( return nil, err } if err := CheckStatusCode(response, 200, 404); err != nil { - return nil, fmt.Errorf("failed to get plugin: (%d): %s", response.StatusCode(), string(response.Body)) + return nil, fmt.Errorf("failed to get plugin (%d): %s: %w", response.StatusCode(), string(response.Body), err) } if response.StatusCode() == 404 { log.V(1).Info("plugin not found", "id", pluginId) @@ -125,7 +125,7 @@ func (c *kongClient) LoadPlugin( if copyConfig { err = json.Unmarshal(response.Body, &plugin) if err != nil { - return nil, errors.Wrap(err, "failed to unmarshal plugin response") + return nil, fmt.Errorf("failed to unmarshal plugin response: %w", err) } } @@ -148,7 +148,7 @@ loadByTags: if copyConfig { err = deepCopy(kongPlugin, plugin) if err != nil { - return nil, errors.Wrap(err, "failed to copy plugin config") + return nil, fmt.Errorf("failed to copy plugin config: %w", err) } } } @@ -241,7 +241,7 @@ func (c *kongClient) CreateOrReplacePlugin( log.V(1).Info("upserting plugin for consumer", "consumer", *plugin.GetConsumer(), "id", pluginId) response, err = client.UpsertPluginForConsumer(ctx, *plugin.GetConsumer(), pluginId, body) if err != nil { - return nil, errors.Wrap(err, "failed to create plugin") + return nil, fmt.Errorf("failed to create plugin: %w", err) } } else if isRouteSpecific { @@ -251,7 +251,7 @@ func (c *kongClient) CreateOrReplacePlugin( log.V(1).Info("upserting plugin for route", "route", *plugin.GetRoute(), "id", pluginId) response, err = client.UpsertPluginForRoute(ctx, *plugin.GetRoute(), pluginId, body) if err != nil { - return nil, errors.Wrap(err, "failed to upsert plugin for route") + return nil, fmt.Errorf("failed to upsert plugin for route: %w", err) } } else { @@ -259,23 +259,23 @@ func (c *kongClient) CreateOrReplacePlugin( log.V(1).Info("upserting global plugin", "id", pluginId) response, err = client.UpsertPlugin(ctx, pluginId, body) if err != nil { - return nil, errors.Wrap(err, "failed to create plugin") + return nil, fmt.Errorf("failed to create plugin: %w", err) } } apiResponse := WrapApiResponse(response) responseBody, err := io.ReadAll(response.Body) if err != nil { - return nil, errors.Wrap(err, "failed to read response body") + return nil, fmt.Errorf("failed to read response body: %w", err) } response.Body.Close() //nolint:errcheck if err := CheckStatusCode(apiResponse, 200); err != nil { - return nil, fmt.Errorf("failed to create plugin: (%d): %s", apiResponse.StatusCode(), string(responseBody)) + return nil, fmt.Errorf("failed to create plugin (%d): %s: %w", apiResponse.StatusCode(), string(responseBody), err) } err = json.Unmarshal(responseBody, &kongPlugin) if err != nil { - return nil, errors.Wrap(err, "failed to unmarshal plugin response") + return nil, fmt.Errorf("failed to unmarshal plugin response: %w", err) } plugin.SetId(pluginId) @@ -319,7 +319,7 @@ func (c *kongClient) DeletePlugin(ctx context.Context, plugin CustomPlugin) (err return err } if err := CheckStatusCode(response, 200, 204); err != nil { - return fmt.Errorf("failed to delete plugin: (%d): %s", response.StatusCode(), string(response.Body)) + return fmt.Errorf("failed to delete plugin (%d): %s: %w", response.StatusCode(), string(response.Body), err) } return nil } @@ -344,7 +344,7 @@ func (c *kongClient) CleanupPlugins(ctx context.Context, route CustomRoute, cons kongPlugins, err := c.getPluginsMatchingTags(ctx, tags) if err != nil { - return errors.Wrap(err, "failed to list plugins") + return fmt.Errorf("failed to list plugins: %w", err) } pluginIds := make([]string, 0, len(plugins)) @@ -363,7 +363,7 @@ func (c *kongClient) CleanupPlugins(ctx context.Context, route CustomRoute, cons log.V(1).Info("deleting plugin", "name", *kongPlugin.Name, "id", *kongPlugin.Id) _, err := c.client.DeletePluginWithResponse(ctx, *kongPlugin.Id) if err != nil { - return errors.Wrap(err, "failed to delete plugin") + return fmt.Errorf("failed to delete plugin: %w", err) } } } @@ -382,7 +382,7 @@ func (c *kongClient) getPluginsMatchingTags( return nil, err } if err := CheckStatusCode(response, 200); err != nil { - return nil, fmt.Errorf("failed to list plugins: (%d): %s", response.StatusCode(), string(response.Body)) + return nil, fmt.Errorf("failed to list plugins (%d): %s: %w", response.StatusCode(), string(response.Body), err) } // ListPluginWithResponse does not return an array of plugins @@ -444,10 +444,10 @@ func (c *kongClient) CreateOrReplaceRoute(ctx context.Context, route CustomRoute } serviceResponse, err := c.client.UpsertServiceWithResponse(ctx, route.GetName(), serviceBody) if err != nil { - return errors.Wrap(err, "failed to create service") + return fmt.Errorf("failed to create service: %w", err) } if err := CheckStatusCode(serviceResponse, 200); err != nil { - return errors.Wrap(fmt.Errorf("failed to create service: %s", string(serviceResponse.Body)), "failed to create service") + return fmt.Errorf("failed to create service (%d): %s: %w", serviceResponse.StatusCode(), string(serviceResponse.Body), err) } service := serviceResponse.JSON200 @@ -479,10 +479,10 @@ func (c *kongClient) CreateOrReplaceRoute(ctx context.Context, route CustomRoute } routeResponse, err := c.client.UpsertRouteWithResponse(ctx, route.GetName(), routeBody) if err != nil { - return errors.Wrap(err, "failed to create route") + return fmt.Errorf("failed to create route: %w", err) } if err := CheckStatusCode(routeResponse, 200); err != nil { - return errors.Wrap(fmt.Errorf("failed to create route: %s", string(routeResponse.Body)), "failed to create route") + return fmt.Errorf("failed to create route (%d): %s: %w", routeResponse.StatusCode(), string(routeResponse.Body), err) } route.SetRouteId(*routeResponse.JSON200.Id) @@ -497,7 +497,7 @@ func (c *kongClient) DeleteRoute(ctx context.Context, route CustomRoute) error { return err } if err := CheckStatusCode(routeResponse, 200, 204, 404); err != nil { - return fmt.Errorf("failed to delete route: %s", string(routeResponse.Body)) + return fmt.Errorf("failed to delete route (%d): %s: %w", routeResponse.StatusCode(), string(routeResponse.Body), err) } serviceResponse, err := c.client.DeleteServiceWithResponse(ctx, routeName) @@ -505,7 +505,7 @@ func (c *kongClient) DeleteRoute(ctx context.Context, route CustomRoute) error { return err } if err := CheckStatusCode(serviceResponse, 200, 204, 404); err != nil { - return fmt.Errorf("failed to delete service: %s", string(serviceResponse.Body)) + return fmt.Errorf("failed to delete service (%d): %s: %w", serviceResponse.StatusCode(), string(serviceResponse.Body), err) } err = c.DeleteUpstream(ctx, route) @@ -532,7 +532,7 @@ func (c *kongClient) CreateOrReplaceConsumer(ctx context.Context, consumer Custo return nil, err } if err := CheckStatusCode(response, 200); err != nil { - return nil, fmt.Errorf("failed to create consumer: (%d): %s", response.StatusCode(), string(response.Body)) + return nil, fmt.Errorf("failed to create consumer (%d): %s: %w", response.StatusCode(), string(response.Body), err) } isInGroup, err := c.isConsumerInGroup(ctx, consumerName) @@ -542,14 +542,14 @@ func (c *kongClient) CreateOrReplaceConsumer(ctx context.Context, consumer Custo if !isInGroup { err = c.addConsumerToGroup(ctx, consumerName) if err != nil { - return nil, errors.Wrap(err, "failed to add consumer to group") + return nil, fmt.Errorf("failed to add consumer to group: %w", err) } } // The Api-Spec defines a wrong type for the response body, so we need to unmarshal it manually err = json.Unmarshal(response.Body, &kongConsumer) if err != nil { - return nil, errors.Wrap(err, "failed to unmarshal consumer response") + return nil, fmt.Errorf("failed to unmarshal consumer response: %w", err) } consumer.SetId(*kongConsumer.Id) @@ -562,7 +562,7 @@ func (c *kongClient) DeleteConsumer(ctx context.Context, consumer CustomConsumer return err } if err := CheckStatusCode(response, 200, 204, 404); err != nil { - return fmt.Errorf("failed to delete consumer (%d): %s", response.StatusCode(), string(response.Body)) + return fmt.Errorf("failed to delete consumer (%d): %s: %w", response.StatusCode(), string(response.Body), err) } return nil } @@ -576,7 +576,7 @@ func (c *kongClient) addConsumerToGroup(ctx context.Context, consumerName string return err } if err := CheckStatusCode(response, 200, 201); err != nil { - return fmt.Errorf("failed to add consumer to group (%d): %s", response.StatusCode(), string(response.Body)) + return fmt.Errorf("failed to add consumer to group (%d): %s: %w", response.StatusCode(), string(response.Body), err) } return nil @@ -585,11 +585,11 @@ func (c *kongClient) addConsumerToGroup(ctx context.Context, consumerName string func (c *kongClient) isConsumerInGroup(ctx context.Context, consumerName string) (bool, error) { response, err := c.client.ViewGroupConsumerWithResponse(ctx, consumerName) if err != nil { - return false, errors.Wrap(err, "error occurred when getting consumer group") + return false, fmt.Errorf("error occurred when getting consumer group: %w", err) } if err := CheckStatusCode(response, 200); err != nil { - return false, errors.Wrap(err, "error occurred when getting consumer group") + return false, fmt.Errorf("error occurred when getting consumer group: %w", err) } if len(*response.JSON200.Data) == 0 { @@ -605,7 +605,7 @@ func (c *kongClient) DeleteUpstream(ctx context.Context, route CustomRoute) erro return err } if err := CheckStatusCode(upstreamResponse, 200, 204, 404); err != nil { - return fmt.Errorf("failed to delete upstream: %s", string(upstreamResponse.Body)) + return fmt.Errorf("failed to delete upstream (%d): %s: %w", upstreamResponse.StatusCode(), string(upstreamResponse.Body), err) } if route.GetTargetsId() != "" { @@ -615,7 +615,7 @@ func (c *kongClient) DeleteUpstream(ctx context.Context, route CustomRoute) erro return err } if err := CheckStatusCode(targetsResponse, 200, 204, 404); err != nil { - return fmt.Errorf("failed to delete upstream targets: %s", string(targetsResponse.Body)) + return fmt.Errorf("failed to delete upstream targets (%d): %s: %w", targetsResponse.StatusCode(), string(targetsResponse.Body), err) } } return nil diff --git a/gateway/pkg/kong/client/error.go b/gateway/pkg/kong/client/error.go index c725a6fec..2d7060857 100644 --- a/gateway/pkg/kong/client/error.go +++ b/gateway/pkg/kong/client/error.go @@ -5,53 +5,103 @@ package client import ( + stderrors "errors" + "fmt" "net/http" "slices" + "time" ) type ApiResponse interface { StatusCode() int } +// ApiError represents an error from the Kong Admin API. +// It implements the ctrlerrors.BlockedError, ctrlerrors.RetryableError, and +// ctrlerrors.RetryableWithDelayError interfaces via duck-typing, so that +// errors propagated up the call stack are correctly classified by +// ctrlerrors.HandleError without introducing a direct import dependency. type ApiError interface { error Retriable() bool + IsRetryable() bool + IsBlocked() bool + RetryDelay() time.Duration } type apiError struct { - StatusCode int - Message string - RetryAllowed bool + statusCode int + message string + retryAllowed bool + retryDelay time.Duration } func (e *apiError) Error() string { - return e.Message + return e.message } +// Retriable is the original Kong-specific method, kept for backward compatibility. func (e *apiError) Retriable() bool { - return e.RetryAllowed + return e.retryAllowed } +// IsRetryable satisfies the ctrlerrors.RetryableError interface. +func (e *apiError) IsRetryable() bool { + return e.retryAllowed +} + +// IsBlocked satisfies the ctrlerrors.BlockedError interface. +// A 4xx error (non-retryable) is considered blocked — the request is invalid +// and retrying with the same parameters will not succeed. +func (e *apiError) IsBlocked() bool { + return !e.retryAllowed +} + +// RetryDelay satisfies the ctrlerrors.RetryableWithDelayError interface. +func (e *apiError) RetryDelay() time.Duration { + return e.retryDelay +} + +// CheckStatusCode classifies a Kong Admin API response into an ApiError. +// It returns nil when the response status code is in okStatusCodes. func CheckStatusCode(res ApiResponse, okStatusCodes ...int) ApiError { if slices.Contains(okStatusCodes, res.StatusCode()) { return nil } - if res.StatusCode() >= 500 { + if res.StatusCode() == http.StatusTooManyRequests { return &apiError{ - StatusCode: res.StatusCode(), - Message: "Kong server error", - RetryAllowed: true, + statusCode: res.StatusCode(), + message: fmt.Sprintf("Kong rate limit error (%d)", res.StatusCode()), + retryAllowed: true, + retryDelay: 3 * time.Second, + } + } + + if res.StatusCode() >= http.StatusInternalServerError { + return &apiError{ + statusCode: res.StatusCode(), + message: fmt.Sprintf("Kong server error (%d)", res.StatusCode()), + retryAllowed: true, } } return &apiError{ - StatusCode: res.StatusCode(), - Message: "Kong client error", - RetryAllowed: false, + statusCode: res.StatusCode(), + message: fmt.Sprintf("Kong client error (%d)", res.StatusCode()), + retryAllowed: false, } } +// IsNotFound returns true if the error is an ApiError with a 404 status code. +func IsNotFound(err error) bool { + var ae *apiError + if ok := errorAs(err, &ae); ok { + return ae.statusCode == http.StatusNotFound + } + return false +} + func WrapApiResponse(res *http.Response) ApiResponse { return &responseWrapper{ response: res, @@ -68,3 +118,9 @@ func (r *responseWrapper) StatusCode() int { } return r.response.StatusCode } + +// errorAs is a thin wrapper around errors.As to allow testing with the +// unexported apiError type. Production code uses the standard library. +var errorAs = func(err error, target interface{}) bool { + return stderrors.As(err, target) +} diff --git a/gateway/pkg/kong/client/error_test.go b/gateway/pkg/kong/client/error_test.go new file mode 100644 index 000000000..724ac3b99 --- /dev/null +++ b/gateway/pkg/kong/client/error_test.go @@ -0,0 +1,188 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "errors" + "fmt" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestClient(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Kong Client Suite") +} + +type fakeResponse struct { + code int +} + +func (f *fakeResponse) StatusCode() int { + return f.code +} + +var _ = Describe("CheckStatusCode", func() { + It("returns nil for an OK status code", func() { + err := CheckStatusCode(&fakeResponse{code: 200}, 200) + Expect(err).To(BeNil()) + }) + + It("returns nil when status matches one of multiple OK codes", func() { + err := CheckStatusCode(&fakeResponse{code: 204}, 200, 204, 404) + Expect(err).To(BeNil()) + }) + + Context("429 Too Many Requests", func() { + var apiErr ApiError + + BeforeEach(func() { + apiErr = CheckStatusCode(&fakeResponse{code: 429}, 200) + Expect(apiErr).NotTo(BeNil()) + }) + + It("is retryable", func() { + Expect(apiErr.IsRetryable()).To(BeTrue()) + Expect(apiErr.Retriable()).To(BeTrue()) + }) + + It("is not blocked", func() { + Expect(apiErr.IsBlocked()).To(BeFalse()) + }) + + It("has a retry delay", func() { + Expect(apiErr.RetryDelay()).To(Equal(3 * time.Second)) + }) + + It("includes status code in message", func() { + Expect(apiErr.Error()).To(ContainSubstring("429")) + }) + }) + + Context("5xx Server Error", func() { + var apiErr ApiError + + BeforeEach(func() { + apiErr = CheckStatusCode(&fakeResponse{code: 502}, 200) + Expect(apiErr).NotTo(BeNil()) + }) + + It("is retryable", func() { + Expect(apiErr.IsRetryable()).To(BeTrue()) + }) + + It("is not blocked", func() { + Expect(apiErr.IsBlocked()).To(BeFalse()) + }) + + It("has no retry delay", func() { + Expect(apiErr.RetryDelay()).To(Equal(time.Duration(0))) + }) + + It("includes status code in message", func() { + Expect(apiErr.Error()).To(ContainSubstring("502")) + }) + }) + + Context("4xx Client Error", func() { + var apiErr ApiError + + BeforeEach(func() { + apiErr = CheckStatusCode(&fakeResponse{code: 400}, 200) + Expect(apiErr).NotTo(BeNil()) + }) + + It("is not retryable", func() { + Expect(apiErr.IsRetryable()).To(BeFalse()) + }) + + It("is blocked", func() { + Expect(apiErr.IsBlocked()).To(BeTrue()) + }) + + It("includes status code in message", func() { + Expect(apiErr.Error()).To(ContainSubstring("400")) + }) + }) +}) + +var _ = Describe("IsNotFound", func() { + It("returns true for a 404 error", func() { + err := CheckStatusCode(&fakeResponse{code: 404}, 200) + Expect(IsNotFound(err)).To(BeTrue()) + }) + + It("returns false for a non-404 error", func() { + err := CheckStatusCode(&fakeResponse{code: 400}, 200) + Expect(IsNotFound(err)).To(BeFalse()) + }) + + It("returns false for a nil error", func() { + Expect(IsNotFound(nil)).To(BeFalse()) + }) + + It("returns true when wrapped with fmt.Errorf %%w", func() { + apiErr := CheckStatusCode(&fakeResponse{code: 404}, 200) + wrapped := fmt.Errorf("context: %w", apiErr) + Expect(IsNotFound(wrapped)).To(BeTrue()) + }) +}) + +// ctrlerrors interface types for duck-type verification. +type blockedError interface { + IsBlocked() bool +} + +type retryableError interface { + IsRetryable() bool +} + +type retryableWithDelayError interface { + IsRetryable() bool + RetryDelay() time.Duration +} + +var _ = Describe("Duck-typing through error chain", func() { + It("preserves IsBlocked through fmt.Errorf %%w", func() { + apiErr := CheckStatusCode(&fakeResponse{code: 400}, 200) + wrapped := fmt.Errorf("failed to create route: %w", apiErr) + + var be blockedError + Expect(errors.As(wrapped, &be)).To(BeTrue()) + Expect(be.IsBlocked()).To(BeTrue()) + }) + + It("preserves IsRetryable through fmt.Errorf %%w", func() { + apiErr := CheckStatusCode(&fakeResponse{code: 500}, 200) + wrapped := fmt.Errorf("failed to create route: %w", apiErr) + + var re retryableError + Expect(errors.As(wrapped, &re)).To(BeTrue()) + Expect(re.IsRetryable()).To(BeTrue()) + }) + + It("preserves RetryDelay through fmt.Errorf %%w for 429", func() { + apiErr := CheckStatusCode(&fakeResponse{code: 429}, 200) + wrapped := fmt.Errorf("failed to create route: %w", apiErr) + + var rde retryableWithDelayError + Expect(errors.As(wrapped, &rde)).To(BeTrue()) + Expect(rde.IsRetryable()).To(BeTrue()) + Expect(rde.RetryDelay()).To(Equal(3 * time.Second)) + }) + + It("preserves interfaces through multiple layers of wrapping", func() { + apiErr := CheckStatusCode(&fakeResponse{code: 502}, 200) + wrapped1 := fmt.Errorf("inner: %w", apiErr) + wrapped2 := fmt.Errorf("outer: %w", wrapped1) + + var re retryableError + Expect(errors.As(wrapped2, &re)).To(BeTrue()) + Expect(re.IsRetryable()).To(BeTrue()) + }) +}) diff --git a/install/overlays/local/resources/admin/zones/dataplane1.example.yaml b/install/overlays/local/resources/admin/zones/dataplane1.example.yaml index c6ea908a3..d8ad40f64 100644 --- a/install/overlays/local/resources/admin/zones/dataplane1.example.yaml +++ b/install/overlays/local/resources/admin/zones/dataplane1.example.yaml @@ -27,6 +27,4 @@ spec: host: my-redis-host port: 6379 password: somePassword - teamApis: - apis: [] visibility: Enterprise \ No newline at end of file diff --git a/install/overlays/local/resources/admin/zones/dataplane2.example.yaml b/install/overlays/local/resources/admin/zones/dataplane2.example.yaml index aaa813ee2..8dadfcad8 100644 --- a/install/overlays/local/resources/admin/zones/dataplane2.example.yaml +++ b/install/overlays/local/resources/admin/zones/dataplane2.example.yaml @@ -27,6 +27,4 @@ spec: host: my-redis-host port: 6379 password: somePassword - teamApis: - apis: [] visibility: Enterprise \ No newline at end of file diff --git a/organization/internal/controller/team_controller_test.go b/organization/internal/controller/team_controller_test.go index 0673731eb..0d6bdf08f 100644 --- a/organization/internal/controller/team_controller_test.go +++ b/organization/internal/controller/team_controller_test.go @@ -78,10 +78,11 @@ var _ = Describe("Team Controller", Ordered, func() { }, }, Spec: adminv1.ZoneSpec{ - TeamApis: &adminv1.TeamApiConfig{Apis: []adminv1.ApiConfig{{ + ManagedRoutes: &adminv1.ManagedRoutesConfig{Routes: []adminv1.ManagedRouteConfig{{ Name: "team-api-1", Path: "/teamAPI", Url: "http://example.org", + Type: adminv1.ManagedRouteTypeTeamAPI, }}}, Visibility: adminv1.ZoneVisibilityWorld, }, diff --git a/organization/internal/handler/util/zone.go b/organization/internal/handler/util/zone.go index f7452991a..734d92429 100644 --- a/organization/internal/handler/util/zone.go +++ b/organization/internal/handler/util/zone.go @@ -21,7 +21,7 @@ func GetZoneObjWithTeamInfo(ctx context.Context) (*adminv1.Zone, error) { zoneList := &adminv1.ZoneList{} clientFromContext := cclient.ClientFromContextOrDie(ctx) - err := clientFromContext.List(ctx, zoneList, client.MatchingFields{index.FieldSpecTeamApis: "true"}) + err := clientFromContext.List(ctx, zoneList, client.MatchingFields{index.FieldSpecManagedRoutes: "true"}) if err != nil { return nil, fmt.Errorf("failed to list zones: %w", err) } @@ -31,7 +31,7 @@ func GetZoneObjWithTeamInfo(ctx context.Context) (*adminv1.Zone, error) { if !ok { continue } - if z.Spec.TeamApis != nil { + if z.Spec.ManagedRoutes != nil { teamApiZone = z.DeepCopy() break } diff --git a/organization/internal/index/index.go b/organization/internal/index/index.go index 3bfdb32d2..63a1369ae 100644 --- a/organization/internal/index/index.go +++ b/organization/internal/index/index.go @@ -16,8 +16,8 @@ import ( ) const ( - FieldSpecGroup = "spec.group" - FieldSpecTeamApis = "spec.teamApis" + FieldSpecGroup = "spec.group" + FieldSpecManagedRoutes = "spec.managedRoutes" ) func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { @@ -36,11 +36,14 @@ func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { return nil } - if zone.Spec.TeamApis == nil { - return []string{"false"} - } else { - return []string{"true"} + if zone.Spec.ManagedRoutes != nil { + for _, r := range zone.Spec.ManagedRoutes.Routes { + if r.Type == adminv1.ManagedRouteTypeTeamAPI { + return []string{"true"} + } + } } + return []string{"false"} } err := mgr.GetFieldIndexer().IndexField(ctx, &organizationv1.Team{}, FieldSpecGroup, filterTeamGroup) @@ -49,9 +52,9 @@ func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { os.Exit(1) } - err = mgr.GetFieldIndexer().IndexField(ctx, &adminv1.Zone{}, FieldSpecTeamApis, filterZoneWithTeamRealmInfos) + err = mgr.GetFieldIndexer().IndexField(ctx, &adminv1.Zone{}, FieldSpecManagedRoutes, filterZoneWithTeamRealmInfos) if err != nil { - ctrl.Log.Error(err, "unable to create fieldIndex for zone", "FieldIndex", FieldSpecTeamApis) + ctrl.Log.Error(err, "unable to create fieldIndex for zone", "FieldIndex", FieldSpecManagedRoutes) os.Exit(1) } } diff --git a/organization/internal/team_webhook_reconciler_test.go b/organization/internal/team_webhook_reconciler_test.go index d2411c492..275dc93ef 100644 --- a/organization/internal/team_webhook_reconciler_test.go +++ b/organization/internal/team_webhook_reconciler_test.go @@ -86,10 +86,11 @@ var _ = Describe("Team Reconciler, Group Reconciler and Team Webhook", Ordered, }, }, Spec: adminv1.ZoneSpec{ - TeamApis: &adminv1.TeamApiConfig{Apis: []adminv1.ApiConfig{{ + ManagedRoutes: &adminv1.ManagedRoutesConfig{Routes: []adminv1.ManagedRouteConfig{{ Name: "team-api-1", Path: "/teamAPI", Url: "http://example.org", + Type: adminv1.ManagedRouteTypeTeamAPI, }}}, Visibility: adminv1.ZoneVisibilityWorld, }, diff --git a/organization/internal/webhook/v1/mutator/mutate.go b/organization/internal/webhook/v1/mutator/mutate.go index e88417d22..746509697 100644 --- a/organization/internal/webhook/v1/mutator/mutate.go +++ b/organization/internal/webhook/v1/mutator/mutate.go @@ -107,7 +107,7 @@ func GetZoneObjWithTeamInfo(ctx context.Context, k8sClient client.Client) (*admi return nil, errors.NewInternalError(fmt.Errorf("k8sClient is nil")) } - err := k8sClient.List(ctx, zoneList, client.MatchingFields{index.FieldSpecTeamApis: "true"}) + err := k8sClient.List(ctx, zoneList, client.MatchingFields{index.FieldSpecManagedRoutes: "true"}) if err != nil { return nil, errors.NewInternalError(err) } @@ -117,7 +117,7 @@ func GetZoneObjWithTeamInfo(ctx context.Context, k8sClient client.Client) (*admi if !ok { continue } - if zoneObj.Spec.TeamApis != nil { + if zoneObj.Spec.ManagedRoutes != nil { teamApiZone = zoneObj.DeepCopy() break } diff --git a/organization/internal/webhook/v1/team_webhook_test.go b/organization/internal/webhook/v1/team_webhook_test.go index dbb81ddf1..95e32d7fa 100644 --- a/organization/internal/webhook/v1/team_webhook_test.go +++ b/organization/internal/webhook/v1/team_webhook_test.go @@ -42,10 +42,11 @@ var _ = Describe("Team Webhook", func() { }, }, Spec: adminv1.ZoneSpec{ - TeamApis: &adminv1.TeamApiConfig{Apis: []adminv1.ApiConfig{{ + ManagedRoutes: &adminv1.ManagedRoutesConfig{Routes: []adminv1.ManagedRouteConfig{{ Name: "team-api-1", Path: "/teamAPI", Url: "https://example.org", + Type: adminv1.ManagedRouteTypeTeamAPI, }}}, Visibility: adminv1.ZoneVisibilityWorld, }, diff --git a/secret-manager/api/api.go b/secret-manager/api/api.go index 65cfefd4b..eec5aa24d 100644 --- a/secret-manager/api/api.go +++ b/secret-manager/api/api.go @@ -8,9 +8,9 @@ import ( "context" "encoding/json" "fmt" + "net/http" "strings" - "github.com/pkg/errors" "github.com/telekom/controlplane/common-server/pkg/client" "github.com/telekom/controlplane/secret-manager/api/gen" ) @@ -114,15 +114,17 @@ func (s *secretManagerAPI) Get(ctx context.Context, secretID string) (value stri secretID, _ = FromRef(secretID) res, err := s.client.GetSecretWithResponse(ctx, secretID) if err != nil { - return "", err + return "", fmt.Errorf("secret-manager request failed for %q: %w", secretID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: + case http.StatusOK: return res.JSON200.Value, nil - case 404: + case http.StatusNotFound: return "", ErrNotFound + case http.StatusUnauthorized: + return "", client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return "", client.HandleError(res.StatusCode(), string(res.Body)) + return "", handleError(res.StatusCode(), string(res.Body)) } } func (s *secretManagerAPI) Set(ctx context.Context, secretID string, secretValue string) (newID string, err error) { @@ -131,17 +133,19 @@ func (s *secretManagerAPI) Set(ctx context.Context, secretID string, secretValue secretID, _ = FromRef(secretID) res, err := s.client.PutSecretWithResponse(ctx, secretID, gen.PutSecretJSONRequestBody{Value: secretValue}) if err != nil { - return "", err + return "", fmt.Errorf("secret-manager request failed for %q: %w", secretID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: + case http.StatusOK: return ToRef(res.JSON200.Id), nil - case 204: + case http.StatusNoContent: return secretID, nil - case 404: + case http.StatusNotFound: return "", ErrNotFound + case http.StatusUnauthorized: + return "", client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return "", client.HandleError(res.StatusCode(), string(res.Body)) + return "", handleError(res.StatusCode(), string(res.Body)) } } @@ -164,17 +168,19 @@ func (s *secretManagerAPI) UpsertEnvironment(ctx context.Context, envID string, res, err := s.client.UpsertEnvironmentWithResponse(ctx, envID, reqBody) if err != nil { - return nil, err + return nil, fmt.Errorf("secret-manager request failed for environment %q: %w", envID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: + case http.StatusOK: return toMap(res.JSON200.Items), nil - case 204: + case http.StatusNoContent: return nil, nil - case 404: + case http.StatusNotFound: return nil, ErrNotFound + case http.StatusUnauthorized: + return nil, client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return nil, client.HandleError(res.StatusCode(), string(res.Body)) + return nil, handleError(res.StatusCode(), string(res.Body)) } } @@ -193,17 +199,19 @@ func (s *secretManagerAPI) UpsertTeam(ctx context.Context, envID, teamID string, res, err := s.client.UpsertTeamWithResponse(ctx, envID, teamID, reqBody) if err != nil { - return nil, err + return nil, fmt.Errorf("secret-manager request failed for team %q/%q: %w", envID, teamID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: + case http.StatusOK: return toMap(res.JSON200.Items), nil - case 204: + case http.StatusNoContent: return nil, nil - case 404: + case http.StatusNotFound: return nil, ErrNotFound + case http.StatusUnauthorized: + return nil, client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return nil, client.HandleError(res.StatusCode(), string(res.Body)) + return nil, handleError(res.StatusCode(), string(res.Body)) } } @@ -222,69 +230,83 @@ func (s *secretManagerAPI) UpsertApplication(ctx context.Context, envID, teamID, res, err := s.client.UpsertAppWithResponse(ctx, envID, teamID, appID, reqBody) if err != nil { - return nil, err + return nil, fmt.Errorf("secret-manager request failed for app %q/%q/%q: %w", envID, teamID, appID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: + case http.StatusOK: return toMap(res.JSON200.Items), nil - case 204: + case http.StatusNoContent: return nil, nil - case 404: + case http.StatusNotFound: return nil, ErrNotFound + case http.StatusUnauthorized: + return nil, client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return nil, client.HandleError(res.StatusCode(), string(res.Body)) + return nil, handleError(res.StatusCode(), string(res.Body)) } } func (s *secretManagerAPI) DeleteEnvironment(ctx context.Context, envID string) (err error) { res, err := s.client.DeleteEnvironmentWithResponse(ctx, envID) if err != nil { - return err + return fmt.Errorf("secret-manager request failed for environment %q: %w", envID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: - return nil - case 204: - return nil - case 404: + case http.StatusOK, http.StatusNoContent, http.StatusNotFound: return nil + case http.StatusUnauthorized: + return client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return client.HandleError(res.StatusCode(), string(res.Body)) + return handleError(res.StatusCode(), string(res.Body)) } } func (s *secretManagerAPI) DeleteTeam(ctx context.Context, envID, teamID string) (err error) { res, err := s.client.DeleteTeamWithResponse(ctx, envID, teamID) if err != nil { - return err + return fmt.Errorf("secret-manager request failed for team %q/%q: %w", envID, teamID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: - return nil - case 204: - return nil - case 404: + case http.StatusOK, http.StatusNoContent, http.StatusNotFound: return nil + case http.StatusUnauthorized: + return client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return client.HandleError(res.StatusCode(), string(res.Body)) + return handleError(res.StatusCode(), string(res.Body)) } } func (s *secretManagerAPI) DeleteApplication(ctx context.Context, envID, teamID, appID string) (err error) { res, err := s.client.DeleteAppWithResponse(ctx, envID, teamID, appID) if err != nil { - return err + return fmt.Errorf("secret-manager request failed for app %q/%q/%q: %w", envID, teamID, appID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: - return nil - case 204: - return nil - case 404: + case http.StatusOK, http.StatusNoContent, http.StatusNotFound: return nil + case http.StatusUnauthorized: + return client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return client.HandleError(res.StatusCode(), string(res.Body)) + return handleError(res.StatusCode(), string(res.Body)) + } +} + +// handleError classifies HTTP status codes from the secret-manager API following +// HTTP semantics: 4xx errors (client errors) are blocked because retrying with the +// same parameters will not succeed; 5xx errors are retryable (transient server failures). +// 408 (Request Timeout) and 429 (Too Many Requests) are special 4xx codes that are +// retryable and are handled by client.HandleError. +func handleError(httpStatus int, msg string) error { + // 408 and 429 are retryable 4xx codes — delegate to client.HandleError which handles them. + if httpStatus == http.StatusRequestTimeout || httpStatus == http.StatusTooManyRequests { + return client.HandleError(httpStatus, msg) + } + // All other 4xx are client errors — blocked, retrying won't help. + if httpStatus >= 400 && httpStatus < 500 { + return client.BlockedErrorf("client error (%d): %s", httpStatus, msg) } + // 5xx and anything else — delegate to client.HandleError. + return client.HandleError(httpStatus, msg) } // FindSecretId will find the secret ID for the given name in the list of secrets. @@ -340,7 +362,7 @@ func toNamedSecrets(secretValues map[string]any) ([]gen.NamedSecret, error) { case map[string]any: jsonValue, err := json.Marshal(v) if err != nil { - return nil, errors.Wrapf(err, "failed to marshal secret value for %s", name) + return nil, fmt.Errorf("failed to marshal secret value for %s: %w", name, err) } secrets = append(secrets, gen.NamedSecret{ Name: name,