From 9d356081cecb26b33140ded75d2870e782634d8f Mon Sep 17 00:00:00 2001 From: Daniel Schatzmann Date: Thu, 14 May 2026 09:42:23 +0000 Subject: [PATCH 01/31] update targetsource --- api/v1alpha1/targetsource_types.go | 168 +++++++++++- api/v1alpha1/zz_generated.deepcopy.go | 179 +++++++++++-- .../operator.gnmic.dev_targetsources.yaml | 249 +++++++++++++++++- 3 files changed, 556 insertions(+), 40 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 3d69743..143da3c 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -17,37 +17,191 @@ limitations under the License. package v1alpha1 import ( + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // TargetSourceSpec defines the desired state of TargetSource // +kubebuilder:validation:Required type TargetSourceSpec struct { + // Provider defines the source of targets for this TargetSource + // Only one provider can be specified per TargetSource + // +kubebuilder:validation:Required Provider *ProviderSpec `json:"provider"` + // TODO: implement in message processor + // Optional port to use for discovered targets if not specified by the provider + // +kubebuilder:validation:Optional + TargetPort int32 `json:"targetPort,omitempty"` + + // Optional labels to apply to all targets discovered by this TargetSource // +kubebuilder:validation:Optional TargetLabels map[string]string `json:"targetLabels,omitempty"` + // The TargetProfile to use for targets discovered by this TargetSource + // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 TargetProfile string `json:"targetProfile"` } -// +kubebuilder:validation:ExactlyOneOf=http;consul +// ProviderSpec defines the source of targets for a TargetSource +// Only one provider can be specified per TargetSource +// +kubebuilder:validation:ExactlyOneOf=http type ProviderSpec struct { - HTTP *HTTPConfig `json:"http,omitempty"` - Consul *ConsulConfig `json:"consul,omitempty"` + // HTTP defines the configuration for a HTTP provider + HTTP *HTTPConfig `json:"http,omitempty"` } +// HTTPConfig defines the configuration for the HTTP provider +// +kubebuilder:validation:AtLeastOneOf=url;acceptPush type HTTPConfig struct { - // +kubebuilder:validation:MinLength=1 - URL string `json:"url"` + // URL of the HTTP endpoint to pull targets from + // If defined, the loader will periodically poll this endpoint for targets + // +kubebuilder:validation:Optional + URL string `json:"url,omitempty"` + + // If true, the loader will accept pushed target updates to the controller endpoint + // The endpoint will be /{namespace}/{targetsource}/ + // +kubebuilder:default=false // +kubebuilder:validation:Optional AcceptPush bool `json:"acceptPush,omitempty"` + + // Optional authorization configuration for accessing the HTTP endpoint + // +kubebuilder:validation:Optional + Authorization *AuthorizationSpec `json:"authorization,omitempty"` + + // Optional interval for polling the HTTP endpoint for targets + // TODO: increase default value + // +kubebuilder:default="30s" + // +kubebuilder:validation:Optional + PollInterval *metav1.Duration `json:"interval,omitempty"` + + // Optional timeout for HTTP requests to the endpoint + // +kubebuilder:default="10s" + // +kubebuilder:validation:Optional + Timeout *metav1.Duration `json:"timeout,omitempty"` + + // Optional TLS configuration for connecting to the HTTP endpoint + // +kubebuilder:validation:Optional + TLS *ClientTLSConfig `json:"tls,omitempty"` + + // Optional pagination configuration for parsing responses from the HTTP endpoint + // +kubebuilder:validation:Optional + Pagination *PaginationSpec `json:"pagination,omitempty"` + + // Optional mapping configuration for parsing responses from the HTTP endpoint + // +kubebuilder:validation:Optional + ResponseMapping *ResponseMappingSpec `json:"mapping,omitempty"` +} + +// +kubebuilder:validation:XValidation:rule="!(has(self.caBundle) && has(self.caBundleSecretRef))",message="caBundle and caBundleSecretRef are mutually exclusive" +type ClientTLSConfig struct { + // Skip TLS verification of the Provider's certificate. + // +kubebuilder:default:=false + InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` + + // Base64-encoded bundle of PEM CAs which will be used to validate the certificate + // chain presented by the Provider. Only used if using HTTPS to connect to Provider and + // ignored for HTTP connections. + // Mutually exclusive with CABundleSecretRef. + // +optional + CABundle []byte `json:"caBundle,omitempty"` + + // Reference to a Secret containing a bundle of PEM-encoded CAs to use when + // verifying the certificate chain presented by the Provider when using HTTPS. + // Mutually exclusive with CABundle. + CABundleSecretRef *corev1.SecretKeySelector `json:"caBundleSecretRef,omitempty"` +} + +// AuthorizationSpec defines the configuration for authentication +// +kubebuilder:validation:ExactlyOneOf=basic;token +type AuthorizationSpec struct { + // Basic authentication configuration + Basic *BasicAuthSpec `json:"basic,omitempty"` + // Token-based authentication configuration + Token *TokenAuthSpec `json:"token,omitempty"` + // JWT *JWTAuthSpec `json:"jwt,omitempty"` + // MTLS } -type ConsulConfig struct { +// BasicAuthSpec defines the configuration for basic authentication +// Enforce EITHER inline creds OR secret ref +// +kubebuilder:validation:XValidation:rule="(has(self.credentialsSecretRef) && !has(self.username) && !has(self.password)) || (!has(self.credentialsSecretRef) && has(self.username) && has(self.password))",message="either credentialsSecretRef OR both username and password must be set, but not a mix" +type BasicAuthSpec struct { + // Username for basic auth + // Mutually exclusive with CredentialsSecretRef. + Username string `json:"username,omitempty"` + // Password for basic auth + // Mutually exclusive with CredentialsSecretRef. + Password string `json:"password,omitempty"` + + // Reference to a Secret containing "username" and "password" keys to use for + // basic authentication when connecting to the Provider. + // Mutually exclusive with Username and Password. + CredentialsSecretRef *corev1.SecretKeySelector `json:"credentialsSecretRef,omitempty"` +} + +// TokenAuthSpec defines the configuration for token-based authentication +// +kubebuilder:validation:XValidation:rule="has(self.token) != has(self.tokenSecretRef)",message="either token or tokenSecretRef must be set, but not both" +type TokenAuthSpec struct { + // Scheme for the token, e.g. "Bearer" // +kubebuilder:validation:MinLength=1 - URL string `json:"url,omitempty"` + Scheme string `json:"scheme"` + // Token value for authentication + // Mutually exclusive with TokenSecretRef. + Token string `json:"token,omitempty"` + // Reference to a Secret containing a key with the token value to use for + // authentication when connecting to the Provider. + // Mutually exclusive with Token. + TokenSecretRef *corev1.SecretKeySelector `json:"tokenSecretRef,omitempty"` +} + +// +kubebuilder:validation:XValidation:rule="!((has(self.token) || has(self.tokenSecretRef)) && ((has(self.key) || has(self.signingKeySecretRef) || has(self.claims)))",message="static JWT token and generated JWT configuration cannot be combined" +// +kubebuilder:validation:XValidation:rule="!has(self.signingKeySecretRef) || self.algorithm != \"\"",message="algorithm must be specified when generating a JWT" +// type JWTAuthSpec struct { +// // Static pre-generated JWT +// Token string `json:"token,omitempty"` +// TokenSecretRef *corev1.SecretKeySelector `json:"tokenSecretRef,omitempty"` +// // Optional: generate JWT dynamically +// Claims map[string]string `json:"claims,omitempty"` +// Key string `json:"key,omitempty"` +// SigningKeySecretRef *corev1.SecretKeySelector `json:"signingKeySecretRef,omitempty"` +// // HS256, RS256, ES256, etc. +// Algorithm string `json:"algorithm,omitempty"` +// TTL *metav1.Duration `json:"ttl,omitempty"` +// } + +// PaginationSpec defines the configuration for paginating through responses from providers +type PaginationSpec struct { + // JSONPath-style expression to extract the list of targets from the response + // Example: "results" + ItemsField string `json:"itemsField,omitempty"` + + // JSONPath-style expression to extract the next page token or URL from the response for pagination + // Example: "next" + NextField string `json:"nextField,omitempty"` +} + +// JSONPath-style expressions to extract target fields from the response +// and map them to the corresponding Target fields. +type ResponseMappingSpec struct { + // JSONPath expression to extract the target name from the response + // +kubebuilder:validation:Required + Name string `json:"name"` + + // JSONPath expression to extract the target address from the response + // +kubebuilder:validation:Required + Address string `json:"address"` + + // JSONPath expression to extract the target port from the response + // +kubebuilder:validation:Optional + Port string `json:"port,omitempty"` + + // JSONPath expression to extract the target labels from the response + // The extracted labels will be merged with the static TargetLabels defined in the TargetSourceSpec, + // with values from the response taking precedence in case of conflicts. + // +kubebuilder:validation:Optional + Labels map[string]string `json:"labels,omitempty"` } // TargetSourceStatus defines the observed state of TargetSource diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 61e81fd..dc4b784 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -46,6 +46,76 @@ func (in *APIConfig) DeepCopy() *APIConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthorizationSpec) DeepCopyInto(out *AuthorizationSpec) { + *out = *in + if in.Basic != nil { + in, out := &in.Basic, &out.Basic + *out = new(BasicAuthSpec) + (*in).DeepCopyInto(*out) + } + if in.Token != nil { + in, out := &in.Token, &out.Token + *out = new(TokenAuthSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthorizationSpec. +func (in *AuthorizationSpec) DeepCopy() *AuthorizationSpec { + if in == nil { + return nil + } + out := new(AuthorizationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BasicAuthSpec) DeepCopyInto(out *BasicAuthSpec) { + *out = *in + if in.CredentialsSecretRef != nil { + in, out := &in.CredentialsSecretRef, &out.CredentialsSecretRef + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BasicAuthSpec. +func (in *BasicAuthSpec) DeepCopy() *BasicAuthSpec { + if in == nil { + return nil + } + out := new(BasicAuthSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClientTLSConfig) DeepCopyInto(out *ClientTLSConfig) { + *out = *in + if in.CABundle != nil { + in, out := &in.CABundle, &out.CABundle + *out = make([]byte, len(*in)) + copy(*out, *in) + } + if in.CABundleSecretRef != nil { + in, out := &in.CABundleSecretRef, &out.CABundleSecretRef + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClientTLSConfig. +func (in *ClientTLSConfig) DeepCopy() *ClientTLSConfig { + if in == nil { + return nil + } + out := new(ClientTLSConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Cluster) DeepCopyInto(out *Cluster) { *out = *in @@ -213,21 +283,6 @@ func (in *ClusterTargetState) DeepCopy() *ClusterTargetState { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ConsulConfig) DeepCopyInto(out *ConsulConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConsulConfig. -func (in *ConsulConfig) DeepCopy() *ConsulConfig { - if in == nil { - return nil - } - out := new(ConsulConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GRPCKeepAliveConfig) DeepCopyInto(out *GRPCKeepAliveConfig) { *out = *in @@ -273,6 +328,36 @@ func (in *GRPCTunnelConfig) DeepCopy() *GRPCTunnelConfig { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HTTPConfig) DeepCopyInto(out *HTTPConfig) { *out = *in + if in.Authorization != nil { + in, out := &in.Authorization, &out.Authorization + *out = new(AuthorizationSpec) + (*in).DeepCopyInto(*out) + } + if in.PollInterval != nil { + in, out := &in.PollInterval, &out.PollInterval + *out = new(metav1.Duration) + **out = **in + } + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(metav1.Duration) + **out = **in + } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(ClientTLSConfig) + (*in).DeepCopyInto(*out) + } + if in.Pagination != nil { + in, out := &in.Pagination, &out.Pagination + *out = new(PaginationSpec) + **out = **in + } + if in.ResponseMapping != nil { + in, out := &in.ResponseMapping, &out.ResponseMapping + *out = new(ResponseMappingSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPConfig. @@ -587,6 +672,21 @@ func (in *OutputStatus) DeepCopy() *OutputStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PaginationSpec) DeepCopyInto(out *PaginationSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PaginationSpec. +func (in *PaginationSpec) DeepCopy() *PaginationSpec { + if in == nil { + return nil + } + out := new(PaginationSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Pipeline) DeepCopyInto(out *Pipeline) { *out = *in @@ -824,12 +924,7 @@ func (in *ProviderSpec) DeepCopyInto(out *ProviderSpec) { if in.HTTP != nil { in, out := &in.HTTP, &out.HTTP *out = new(HTTPConfig) - **out = **in - } - if in.Consul != nil { - in, out := &in.Consul, &out.Consul - *out = new(ConsulConfig) - **out = **in + (*in).DeepCopyInto(*out) } } @@ -843,6 +938,28 @@ func (in *ProviderSpec) DeepCopy() *ProviderSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponseMappingSpec) DeepCopyInto(out *ResponseMappingSpec) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponseMappingSpec. +func (in *ResponseMappingSpec) DeepCopy() *ResponseMappingSpec { + if in == nil { + return nil + } + out := new(ResponseMappingSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceConfig) DeepCopyInto(out *ServiceConfig) { *out = *in @@ -1384,6 +1501,26 @@ func (in *TargetTLSConfig) DeepCopy() *TargetTLSConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TokenAuthSpec) DeepCopyInto(out *TokenAuthSpec) { + *out = *in + if in.TokenSecretRef != nil { + in, out := &in.TokenSecretRef, &out.TokenSecretRef + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TokenAuthSpec. +func (in *TokenAuthSpec) DeepCopy() *TokenAuthSpec { + if in == nil { + return nil + } + out := new(TokenAuthSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TunnelTargetPolicy) DeepCopyInto(out *TunnelTargetPolicy) { *out = *in diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index 37d6919..d603546 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -40,33 +40,258 @@ spec: description: TargetSourceSpec defines the desired state of TargetSource properties: provider: + description: |- + Provider defines the source of targets for this TargetSource + Only one provider can be specified per TargetSource properties: - consul: - properties: - url: - minLength: 1 - type: string - type: object http: + description: HTTP defines the configuration for a HTTP provider properties: acceptPush: + default: false + description: |- + If true, the loader will accept pushed target updates to the controller endpoint + The endpoint will be /{namespace}/{targetsource}/ type: boolean + authorization: + description: Optional authorization configuration for accessing + the HTTP endpoint + properties: + basic: + description: Basic authentication configuration + properties: + credentialsSecretRef: + description: |- + Reference to a Secret containing "username" and "password" keys to use for + basic authentication when connecting to the Provider. + Mutually exclusive with Username and Password. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + password: + description: |- + Password for basic auth + Mutually exclusive with CredentialsSecretRef. + type: string + username: + description: |- + Username for basic auth + Mutually exclusive with CredentialsSecretRef. + type: string + type: object + x-kubernetes-validations: + - message: either credentialsSecretRef OR both username + and password must be set, but not a mix + rule: (has(self.credentialsSecretRef) && !has(self.username) + && !has(self.password)) || (!has(self.credentialsSecretRef) + && has(self.username) && has(self.password)) + token: + description: Token-based authentication configuration + properties: + scheme: + description: Scheme for the token, e.g. "Bearer" + minLength: 1 + type: string + token: + description: |- + Token value for authentication + Mutually exclusive with TokenSecretRef. + type: string + tokenSecretRef: + description: |- + Reference to a Secret containing a key with the token value to use for + authentication when connecting to the Provider. + Mutually exclusive with Token. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + required: + - scheme + type: object + x-kubernetes-validations: + - message: either token or tokenSecretRef must be set, + but not both + rule: has(self.token) != has(self.tokenSecretRef) + type: object + x-kubernetes-validations: + - message: exactly one of the fields in [basic token] must + be set + rule: '[has(self.basic),has(self.token)].filter(x,x==true).size() + == 1' + interval: + default: 30s + description: Optional interval for polling the HTTP endpoint + for targets + type: string + mapping: + description: Optional mapping configuration for parsing responses + from the HTTP endpoint + properties: + address: + description: JSONPath expression to extract the target + address from the response + type: string + labels: + additionalProperties: + type: string + description: |- + JSONPath expression to extract the target labels from the response + The extracted labels will be merged with the static TargetLabels defined in the TargetSourceSpec, + with values from the response taking precedence in case of conflicts. + type: object + name: + description: JSONPath expression to extract the target + name from the response + type: string + port: + description: JSONPath expression to extract the target + port from the response + type: string + required: + - address + - name + type: object + pagination: + description: Optional pagination configuration for parsing + responses from the HTTP endpoint + properties: + itemsField: + description: |- + JSONPath-style expression to extract the list of targets from the response + Example: "results" + type: string + nextField: + description: |- + JSONPath-style expression to extract the next page token or URL from the response for pagination + Example: "next" + type: string + type: object + x-kubernetes-validations: + - message: static JWT token and generated JWT configuration + cannot be combined + rule: '!((has(self.token) || has(self.tokenSecretRef)) && + ((has(self.key) || has(self.signingKeySecretRef) || has(self.claims)))' + - message: algorithm must be specified when generating a JWT + rule: '!has(self.signingKeySecretRef) || self.algorithm + != ""' + timeout: + default: 10s + description: Optional timeout for HTTP requests to the endpoint + type: string + tls: + description: Optional TLS configuration for connecting to + the HTTP endpoint + properties: + caBundle: + description: |- + Base64-encoded bundle of PEM CAs which will be used to validate the certificate + chain presented by the Provider. Only used if using HTTPS to connect to Provider and + ignored for HTTP connections. + Mutually exclusive with CABundleSecretRef. + format: byte + type: string + caBundleSecretRef: + description: |- + Reference to a Secret containing a bundle of PEM-encoded CAs to use when + verifying the certificate chain presented by the Provider when using HTTPS. + Mutually exclusive with CABundle. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + insecureSkipVerify: + default: false + description: Skip TLS verification of the Provider's certificate. + type: boolean + type: object + x-kubernetes-validations: + - message: caBundle and caBundleSecretRef are mutually exclusive + rule: '!(has(self.caBundle) && has(self.caBundleSecretRef))' url: - minLength: 1 + description: |- + URL of the HTTP endpoint to pull targets from + If defined, the loader will periodically poll this endpoint for targets type: string - required: - - url type: object + x-kubernetes-validations: + - message: at least one of the fields in [url acceptPush] must + be set + rule: '[has(self.url),has(self.acceptPush)].filter(x,x==true).size() + >= 1' type: object x-kubernetes-validations: - - message: exactly one of the fields in [http consul] must be set - rule: '[has(self.http),has(self.consul)].filter(x,x==true).size() - == 1' + - message: exactly one of the fields in [http] must be set + rule: '[has(self.http)].filter(x,x==true).size() == 1' targetLabels: additionalProperties: type: string + description: Optional labels to apply to all targets discovered by + this TargetSource type: object + targetPort: + description: Optional port to use for discovered targets if not specified + by the provider + format: int32 + type: integer targetProfile: + description: The TargetProfile to use for targets discovered by this + TargetSource minLength: 1 type: string required: From bcc0b4f993526db5ffd7b9f45dc6fd47c05c4d00 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Wed, 20 May 2026 13:30:54 -0600 Subject: [PATCH 02/31] cherry-pick 5d95c90: DiscoveredTarget type changes --- api/v1alpha1/targetsource_types.go | 4 ++-- .../bases/operator.gnmic.dev_targetsources.yaml | 6 +++--- go.mod | 2 ++ go.sum | 5 +++++ internal/controller/discovery/core/types.go | 7 ++++--- internal/controller/discovery/loaders.go | 2 -- .../controller/discovery/loaders/http/loader.go | 14 ++++++++------ internal/controller/discovery/message_processor.go | 3 ++- 8 files changed, 26 insertions(+), 17 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 143da3c..666805d 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -189,9 +189,9 @@ type ResponseMappingSpec struct { // +kubebuilder:validation:Required Name string `json:"name"` - // JSONPath expression to extract the target address from the response + // JSONPath expression to extract the target IP from the response // +kubebuilder:validation:Required - Address string `json:"address"` + IP string `json:"ip"` // JSONPath expression to extract the target port from the response // +kubebuilder:validation:Optional diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index d603546..1b71922 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -165,9 +165,9 @@ spec: description: Optional mapping configuration for parsing responses from the HTTP endpoint properties: - address: + ip: description: JSONPath expression to extract the target - address from the response + IP from the response type: string labels: additionalProperties: @@ -186,7 +186,7 @@ spec: port from the response type: string required: - - address + - ip - name type: object pagination: diff --git a/go.mod b/go.mod index 9dc2b78..c877a7b 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,8 @@ require ( require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/PaesslerAG/gval v1.0.0 // indirect + github.com/PaesslerAG/jsonpath v0.1.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect diff --git a/go.sum b/go.sum index 45485f1..d900003 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,11 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= 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/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8= +github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= +github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= +github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk= +github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= 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/cert-manager/cert-manager v1.19.3 h1:3d0Nk/HO3BOmAdBJNaBh+6YgaO3Ciey3xCpOjiX5Obs= diff --git a/internal/controller/discovery/core/types.go b/internal/controller/discovery/core/types.go index 99605b9..51a3477 100644 --- a/internal/controller/discovery/core/types.go +++ b/internal/controller/discovery/core/types.go @@ -37,9 +37,10 @@ const ( // DiscoveredTarget represents a target discovered from an external source // before it is materialized as a Kubernetes Target CR type DiscoveredTarget struct { - Name string - Address string - Labels map[string]string + Name string + IP string + Port int32 + Labels map[string]string } type DiscoveryEvent struct { diff --git a/internal/controller/discovery/loaders.go b/internal/controller/discovery/loaders.go index c888c27..c4ebe78 100644 --- a/internal/controller/discovery/loaders.go +++ b/internal/controller/discovery/loaders.go @@ -15,8 +15,6 @@ func NewLoader(cfg *core.CommonLoaderConfig, spec gnmicv1alpha1.TargetSourceSpec case spec.Provider.HTTP != nil: cfg.AcceptPush = spec.Provider.HTTP.AcceptPush return http.New(*cfg), nil - case spec.Provider.Consul != nil: - return nil, fmt.Errorf("Unimplemented targetsource provider, check TargetSource CRD for %s", cfg.TargetsourceNN) default: return nil, fmt.Errorf("unknown targetsource provider, check TargetSource CRD for %s", cfg.TargetsourceNN) } diff --git a/internal/controller/discovery/loaders/http/loader.go b/internal/controller/discovery/loaders/http/loader.go index 3325adb..a2bfa0e 100644 --- a/internal/controller/discovery/loaders/http/loader.go +++ b/internal/controller/discovery/loaders/http/loader.go @@ -53,14 +53,16 @@ func (l *Loader) Run(ctx context.Context, out chan<- []core.DiscoveryMessage) er snapshotID := fmt.Sprintf("%s-%s-%s", l.commonCfg.TargetsourceNN.Namespace, l.commonCfg.TargetsourceNN.Name, uuid.NewString()) targets := []core.DiscoveredTarget{ { - Name: "ceos1", - Address: "clab-3-nodes-ceos1:6030", - Labels: map[string]string{"TargetSource": l.commonCfg.TargetsourceNN.String()}, + Name: "ceos1", + IP: "clab-3-nodes-ceos1", + Port: 57400, + Labels: map[string]string{"TargetSource": l.commonCfg.TargetsourceNN.String()}, }, { - Name: "leaf1", - Address: "clab-3-nodes-leaf1:57400", - Labels: map[string]string{"TargetSource": l.commonCfg.TargetsourceNN.String()}, + Name: "leaf1", + IP: "clab-3-nodes-leaf1", + Port: 57400, + Labels: map[string]string{"TargetSource": l.commonCfg.TargetsourceNN.String()}, }, } diff --git a/internal/controller/discovery/message_processor.go b/internal/controller/discovery/message_processor.go index f7aafb1..cb1e068 100644 --- a/internal/controller/discovery/message_processor.go +++ b/internal/controller/discovery/message_processor.go @@ -283,7 +283,8 @@ func (m *MessageProcessor) applyEvent(ctx context.Context, event core.DiscoveryE logger.Info( "Applying Target", "target", event.Target.Name, - "address", event.Target.Address, + "port", event.Target.Port, + "ip", event.Target.IP, "labels", event.Target.Labels, "targetsource", m.targetSource.Name, ) From d523adb2653a9fc6abaa174b90a6589a04007b58 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Wed, 20 May 2026 13:33:18 -0600 Subject: [PATCH 03/31] renamed IP to Address --- api/v1alpha1/targetsource_types.go | 4 ++-- .../bases/operator.gnmic.dev_targetsources.yaml | 6 +++--- internal/controller/discovery/core/types.go | 8 ++++---- .../controller/discovery/loaders/http/loader.go | 16 ++++++++-------- .../controller/discovery/message_processor.go | 2 +- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 666805d..143da3c 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -189,9 +189,9 @@ type ResponseMappingSpec struct { // +kubebuilder:validation:Required Name string `json:"name"` - // JSONPath expression to extract the target IP from the response + // JSONPath expression to extract the target address from the response // +kubebuilder:validation:Required - IP string `json:"ip"` + Address string `json:"address"` // JSONPath expression to extract the target port from the response // +kubebuilder:validation:Optional diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index 1b71922..d603546 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -165,9 +165,9 @@ spec: description: Optional mapping configuration for parsing responses from the HTTP endpoint properties: - ip: + address: description: JSONPath expression to extract the target - IP from the response + address from the response type: string labels: additionalProperties: @@ -186,7 +186,7 @@ spec: port from the response type: string required: - - ip + - address - name type: object pagination: diff --git a/internal/controller/discovery/core/types.go b/internal/controller/discovery/core/types.go index 51a3477..66bbe50 100644 --- a/internal/controller/discovery/core/types.go +++ b/internal/controller/discovery/core/types.go @@ -37,10 +37,10 @@ const ( // DiscoveredTarget represents a target discovered from an external source // before it is materialized as a Kubernetes Target CR type DiscoveredTarget struct { - Name string - IP string - Port int32 - Labels map[string]string + Name string + Address string + Port int32 + Labels map[string]string } type DiscoveryEvent struct { diff --git a/internal/controller/discovery/loaders/http/loader.go b/internal/controller/discovery/loaders/http/loader.go index a2bfa0e..aace9cd 100644 --- a/internal/controller/discovery/loaders/http/loader.go +++ b/internal/controller/discovery/loaders/http/loader.go @@ -53,16 +53,16 @@ func (l *Loader) Run(ctx context.Context, out chan<- []core.DiscoveryMessage) er snapshotID := fmt.Sprintf("%s-%s-%s", l.commonCfg.TargetsourceNN.Namespace, l.commonCfg.TargetsourceNN.Name, uuid.NewString()) targets := []core.DiscoveredTarget{ { - Name: "ceos1", - IP: "clab-3-nodes-ceos1", - Port: 57400, - Labels: map[string]string{"TargetSource": l.commonCfg.TargetsourceNN.String()}, + Name: "ceos1", + Address: "clab-3-nodes-ceos1", + Port: 57400, + Labels: map[string]string{"TargetSource": l.commonCfg.TargetsourceNN.String()}, }, { - Name: "leaf1", - IP: "clab-3-nodes-leaf1", - Port: 57400, - Labels: map[string]string{"TargetSource": l.commonCfg.TargetsourceNN.String()}, + Name: "leaf1", + Address: "clab-3-nodes-leaf1", + Port: 57400, + Labels: map[string]string{"TargetSource": l.commonCfg.TargetsourceNN.String()}, }, } diff --git a/internal/controller/discovery/message_processor.go b/internal/controller/discovery/message_processor.go index cb1e068..930635d 100644 --- a/internal/controller/discovery/message_processor.go +++ b/internal/controller/discovery/message_processor.go @@ -284,7 +284,7 @@ func (m *MessageProcessor) applyEvent(ctx context.Context, event core.DiscoveryE "Applying Target", "target", event.Target.Name, "port", event.Target.Port, - "ip", event.Target.IP, + "ip", event.Target.Address, "labels", event.Target.Labels, "targetsource", m.targetSource.Name, ) From 2c0d7cb2d69c7852ea0fbab9262013931bf3a8e2 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Wed, 20 May 2026 14:24:27 -0600 Subject: [PATCH 04/31] disabled JWTAuthSpec validations --- api/v1alpha1/targetsource_types.go | 4 ++-- config/crd/bases/operator.gnmic.dev_targetsources.yaml | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 143da3c..5bbfcec 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -156,8 +156,8 @@ type TokenAuthSpec struct { TokenSecretRef *corev1.SecretKeySelector `json:"tokenSecretRef,omitempty"` } -// +kubebuilder:validation:XValidation:rule="!((has(self.token) || has(self.tokenSecretRef)) && ((has(self.key) || has(self.signingKeySecretRef) || has(self.claims)))",message="static JWT token and generated JWT configuration cannot be combined" -// +kubebuilder:validation:XValidation:rule="!has(self.signingKeySecretRef) || self.algorithm != \"\"",message="algorithm must be specified when generating a JWT" +// disabled: +kubebuilder:validation:XValidation:rule="!((has(self.token) || has(self.tokenSecretRef)) && ((has(self.key) || has(self.signingKeySecretRef) || has(self.claims)))",message="static JWT token and generated JWT configuration cannot be combined" +// disabled: +kubebuilder:validation:XValidation:rule="!has(self.signingKeySecretRef) || self.algorithm != \"\"",message="algorithm must be specified when generating a JWT" // type JWTAuthSpec struct { // // Static pre-generated JWT // Token string `json:"token,omitempty"` diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index d603546..6851ad7 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -204,14 +204,6 @@ spec: Example: "next" type: string type: object - x-kubernetes-validations: - - message: static JWT token and generated JWT configuration - cannot be combined - rule: '!((has(self.token) || has(self.tokenSecretRef)) && - ((has(self.key) || has(self.signingKeySecretRef) || has(self.claims)))' - - message: algorithm must be specified when generating a JWT - rule: '!has(self.signingKeySecretRef) || self.algorithm - != ""' timeout: default: 10s description: Optional timeout for HTTP requests to the endpoint From 77a5bd4277eba07fb623ef7b4b21c6e2a2fc1bf7 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Wed, 20 May 2026 19:04:22 -0600 Subject: [PATCH 05/31] cherry-pick 6a83f49: added TargetProfile variable to DiscoveredTarget --- api/v1alpha1/targetsource_types.go | 4 ++++ config/crd/bases/operator.gnmic.dev_targetsources.yaml | 4 ++++ internal/controller/discovery/core/types.go | 9 +++++---- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 5bbfcec..5b0f8e5 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -202,6 +202,10 @@ type ResponseMappingSpec struct { // with values from the response taking precedence in case of conflicts. // +kubebuilder:validation:Optional Labels map[string]string `json:"labels,omitempty"` + + // JSONPath expression to extract the target profile from the response + // +kubebuilder:validation:Optional + TargetProfile string `json:"targetProfile,omitempty"` } // TargetSourceStatus defines the observed state of TargetSource diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index 6851ad7..5816ecd 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -185,6 +185,10 @@ spec: description: JSONPath expression to extract the target port from the response type: string + targetProfile: + description: JSONPath expression to extract the target + profile from the response + type: string required: - address - name diff --git a/internal/controller/discovery/core/types.go b/internal/controller/discovery/core/types.go index 66bbe50..27c4774 100644 --- a/internal/controller/discovery/core/types.go +++ b/internal/controller/discovery/core/types.go @@ -37,10 +37,11 @@ const ( // DiscoveredTarget represents a target discovered from an external source // before it is materialized as a Kubernetes Target CR type DiscoveredTarget struct { - Name string - Address string - Port int32 - Labels map[string]string + Name string + Address string + Port int32 + Labels map[string]string + TargetProfile string } type DiscoveryEvent struct { From 0d744ba61e263f1b8ad08470ca3b4e42b1d17b33 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Thu, 21 May 2026 13:47:23 -0600 Subject: [PATCH 06/31] added port + targetProfile handling to mapper --- internal/controller/discovery/mapper.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/internal/controller/discovery/mapper.go b/internal/controller/discovery/mapper.go index bc42531..4690fd1 100644 --- a/internal/controller/discovery/mapper.go +++ b/internal/controller/discovery/mapper.go @@ -1,6 +1,7 @@ package discovery import ( + "fmt" "maps" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -20,10 +21,19 @@ func generateTargetResource(d core.DiscoveredTarget, ts *gnmicv1alpha1.TargetSou }, } - // Add Address from DiscoveredTarget - t.Spec.Address = d.Address - // Add default Target Profile from the TargetSource Spec TargetProfile - t.Spec.Profile = ts.Spec.TargetProfile + // Add Address + Port from DiscoveredTarget or use TargetSource.spec.targetPort + targetPort := ts.Spec.TargetPort + if d.Port != 0 { + targetPort = d.Port + } + t.Spec.Address = fmt.Sprintf("%s:%d", d.Address, targetPort) + + // Add discovered Target Profile or use TargetSource.spec.targetProfile + targetProfile := ts.Spec.TargetProfile + if d.TargetProfile != "" { + targetProfile = d.TargetProfile + } + t.Spec.Profile = targetProfile // Copy TargetLabels from TargetSource Spec & DiscoveredTarget. Discovered labels take precedence over TargetSource labels. maps.Copy(t.Labels, ts.Spec.TargetLabels) From 5b612f2d6ea32ebfd8b3c4567a23a48c47fe1c24 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Thu, 21 May 2026 14:09:55 -0600 Subject: [PATCH 07/31] added webhook spec for authorization --- api/v1alpha1/targetsource_types.go | 40 ++++++-- api/v1alpha1/zz_generated.deepcopy.go | 90 ++++++++++++++++++ .../operator.gnmic.dev_targetsources.yaml | 91 +++++++++++++++++-- 3 files changed, 209 insertions(+), 12 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index e8b4c4c..7d96c0d 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -60,12 +60,6 @@ type HTTPConfig struct { // +kubebuilder:validation:Optional URL string `json:"url,omitempty"` - // If true, the loader will accept pushed target updates to the controller endpoint - // The endpoint will be /{namespace}/{targetsource}/ - // +kubebuilder:default=false - // +kubebuilder:validation:Optional - AcceptPush bool `json:"acceptPush,omitempty"` - // Optional authorization configuration for accessing the HTTP endpoint // +kubebuilder:validation:Optional Authorization *AuthorizationSpec `json:"authorization,omitempty"` @@ -92,6 +86,10 @@ type HTTPConfig struct { // Optional mapping configuration for parsing responses from the HTTP endpoint // +kubebuilder:validation:Optional ResponseMapping *ResponseMappingSpec `json:"mapping,omitempty"` + + // Optional configuration to enable webhooks + // +kubebuilder:validation:Optional + Webhook *WebhookSpec `json:"webhook,omitempty"` } // +kubebuilder:validation:XValidation:rule="!(has(self.caBundle) && has(self.caBundleSecretRef))",message="caBundle and caBundleSecretRef are mutually exclusive" @@ -208,6 +206,36 @@ type ResponseMappingSpec struct { TargetProfile string `json:"targetProfile,omitempty"` } +// WebhookSpec defines the settings for event-based update mechanism (i.e. push-based) +type WebhookSpec struct { + // +kubebuilder:default=false + Enabled bool `json:"enabled"` + + // +kubebuilder:validation:Optional + Auth *WebhookAuthSpec `json:"auth,omitempty"` +} + +// +kubebuilder:validation:ExactlyOneOf=bearer;signature +type WebhookAuthSpec struct { + Bearer *WebhookBearerAuthSpec `json:"bearer,omitempty"` + Signature *WebhookSignatureAuthSpec `json:"signature,omitempty"` +} + +type WebhookBearerAuthSpec struct { + TokenSecretRef *corev1.SecretKeySelector `json:"tokenSecretRef,omitempty"` +} + +type WebhookSignatureAuthSpec struct { + SecretRef *corev1.SecretKeySelector `json:"secretRef"` + + // Header containing the signature + Header string `json:"header,omitempty"` + + // +kubebuilder:default="sha256" + // +kubebuilder:validation:Enum=sha1;sha256;sha512 + Algorithm string `json:"algorithm,omitempty"` +} + // TargetSourceStatus defines the observed state of TargetSource type TargetSourceStatus struct { Status string `json:"status,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index dc4b784..9d59d3c 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -358,6 +358,11 @@ func (in *HTTPConfig) DeepCopyInto(out *HTTPConfig) { *out = new(ResponseMappingSpec) (*in).DeepCopyInto(*out) } + if in.Webhook != nil { + in, out := &in.Webhook, &out.Webhook + *out = new(WebhookSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPConfig. @@ -1614,3 +1619,88 @@ func (in *TunnelTargetPolicyStatus) DeepCopy() *TunnelTargetPolicyStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebhookAuthSpec) DeepCopyInto(out *WebhookAuthSpec) { + *out = *in + if in.Bearer != nil { + in, out := &in.Bearer, &out.Bearer + *out = new(WebhookBearerAuthSpec) + (*in).DeepCopyInto(*out) + } + if in.Signature != nil { + in, out := &in.Signature, &out.Signature + *out = new(WebhookSignatureAuthSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookAuthSpec. +func (in *WebhookAuthSpec) DeepCopy() *WebhookAuthSpec { + if in == nil { + return nil + } + out := new(WebhookAuthSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebhookBearerAuthSpec) DeepCopyInto(out *WebhookBearerAuthSpec) { + *out = *in + if in.TokenSecretRef != nil { + in, out := &in.TokenSecretRef, &out.TokenSecretRef + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookBearerAuthSpec. +func (in *WebhookBearerAuthSpec) DeepCopy() *WebhookBearerAuthSpec { + if in == nil { + return nil + } + out := new(WebhookBearerAuthSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebhookSignatureAuthSpec) DeepCopyInto(out *WebhookSignatureAuthSpec) { + *out = *in + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookSignatureAuthSpec. +func (in *WebhookSignatureAuthSpec) DeepCopy() *WebhookSignatureAuthSpec { + if in == nil { + return nil + } + out := new(WebhookSignatureAuthSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebhookSpec) DeepCopyInto(out *WebhookSpec) { + *out = *in + if in.Auth != nil { + in, out := &in.Auth, &out.Auth + *out = new(WebhookAuthSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookSpec. +func (in *WebhookSpec) DeepCopy() *WebhookSpec { + if in == nil { + return nil + } + out := new(WebhookSpec) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index d6def2b..81c8e20 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -47,12 +47,6 @@ spec: http: description: HTTP defines the configuration for a HTTP provider properties: - acceptPush: - default: false - description: |- - If true, the loader will accept pushed target updates to the controller endpoint - The endpoint will be /{namespace}/{targetsource}/ - type: boolean authorization: description: Optional authorization configuration for accessing the HTTP endpoint @@ -264,6 +258,91 @@ spec: URL of the HTTP endpoint to pull targets from If defined, the loader will periodically poll this endpoint for targets type: string + webhook: + description: Optional configuration to enable webhooks + properties: + auth: + properties: + bearer: + properties: + tokenSecretRef: + description: SecretKeySelector selects a key of + a Secret. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + signature: + properties: + algorithm: + default: sha256 + enum: + - sha1 + - sha256 + - sha512 + type: string + header: + description: Header containing the signature + type: string + secretRef: + description: SecretKeySelector selects a key of + a Secret. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + required: + - secretRef + type: object + type: object + x-kubernetes-validations: + - message: exactly one of the fields in [bearer signature] + must be set + rule: '[has(self.bearer),has(self.signature)].filter(x,x==true).size() + == 1' + enabled: + default: false + type: boolean + required: + - enabled + type: object type: object x-kubernetes-validations: - message: at least one of the fields in [url acceptPush] must From 58b77d4929be55d594894c1f7fbaf648605cf1c2 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Thu, 21 May 2026 14:11:23 -0600 Subject: [PATCH 08/31] removed inline credential fields --- api/v1alpha1/targetsource_types.go | 15 ----------- .../operator.gnmic.dev_targetsources.yaml | 27 ------------------- 2 files changed, 42 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 7d96c0d..6e72439 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -123,34 +123,19 @@ type AuthorizationSpec struct { } // BasicAuthSpec defines the configuration for basic authentication -// Enforce EITHER inline creds OR secret ref -// +kubebuilder:validation:XValidation:rule="(has(self.credentialsSecretRef) && !has(self.username) && !has(self.password)) || (!has(self.credentialsSecretRef) && has(self.username) && has(self.password))",message="either credentialsSecretRef OR both username and password must be set, but not a mix" type BasicAuthSpec struct { - // Username for basic auth - // Mutually exclusive with CredentialsSecretRef. - Username string `json:"username,omitempty"` - // Password for basic auth - // Mutually exclusive with CredentialsSecretRef. - Password string `json:"password,omitempty"` - // Reference to a Secret containing "username" and "password" keys to use for // basic authentication when connecting to the Provider. - // Mutually exclusive with Username and Password. CredentialsSecretRef *corev1.SecretKeySelector `json:"credentialsSecretRef,omitempty"` } // TokenAuthSpec defines the configuration for token-based authentication -// +kubebuilder:validation:XValidation:rule="has(self.token) != has(self.tokenSecretRef)",message="either token or tokenSecretRef must be set, but not both" type TokenAuthSpec struct { // Scheme for the token, e.g. "Bearer" // +kubebuilder:validation:MinLength=1 Scheme string `json:"scheme"` - // Token value for authentication - // Mutually exclusive with TokenSecretRef. - Token string `json:"token,omitempty"` // Reference to a Secret containing a key with the token value to use for // authentication when connecting to the Provider. - // Mutually exclusive with Token. TokenSecretRef *corev1.SecretKeySelector `json:"tokenSecretRef,omitempty"` } diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index 81c8e20..d333a9e 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -58,7 +58,6 @@ spec: description: |- Reference to a Secret containing "username" and "password" keys to use for basic authentication when connecting to the Provider. - Mutually exclusive with Username and Password. properties: key: description: The key of the secret to select from. Must @@ -81,23 +80,7 @@ spec: - key type: object x-kubernetes-map-type: atomic - password: - description: |- - Password for basic auth - Mutually exclusive with CredentialsSecretRef. - type: string - username: - description: |- - Username for basic auth - Mutually exclusive with CredentialsSecretRef. - type: string type: object - x-kubernetes-validations: - - message: either credentialsSecretRef OR both username - and password must be set, but not a mix - rule: (has(self.credentialsSecretRef) && !has(self.username) - && !has(self.password)) || (!has(self.credentialsSecretRef) - && has(self.username) && has(self.password)) token: description: Token-based authentication configuration properties: @@ -105,16 +88,10 @@ spec: description: Scheme for the token, e.g. "Bearer" minLength: 1 type: string - token: - description: |- - Token value for authentication - Mutually exclusive with TokenSecretRef. - type: string tokenSecretRef: description: |- Reference to a Secret containing a key with the token value to use for authentication when connecting to the Provider. - Mutually exclusive with Token. properties: key: description: The key of the secret to select from. Must @@ -140,10 +117,6 @@ spec: required: - scheme type: object - x-kubernetes-validations: - - message: either token or tokenSecretRef must be set, - but not both - rule: has(self.token) != has(self.tokenSecretRef) type: object x-kubernetes-validations: - message: exactly one of the fields in [basic token] must From aa023abdd11feb748b9d91c5627f116613fa2cb3 Mon Sep 17 00:00:00 2001 From: Daniel Schatzmann Date: Thu, 21 May 2026 09:25:46 +0000 Subject: [PATCH 09/31] use itemsField independent of pagination --- api/v1alpha1/targetsource_types.go | 21 +++++++++++++------ .../operator.gnmic.dev_targetsources.yaml | 17 ++++++++------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 6e72439..63bd535 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -76,9 +76,17 @@ type HTTPConfig struct { Timeout *metav1.Duration `json:"timeout,omitempty"` // Optional TLS configuration for connecting to the HTTP endpoint + // If it is an HTTP endpoint, this will be ignored // +kubebuilder:validation:Optional TLS *ClientTLSConfig `json:"tls,omitempty"` + // Field name in the JSON response that contains the list of items (targets). + // Must refer to a top-level key in the response object. + // If not specified, the entire response is expected to be a list of items. + // Example: "results" + // +kubebuilder:validation:Optional + ItemsField string `json:"itemsField,omitempty"` + // Optional pagination configuration for parsing responses from the HTTP endpoint // +kubebuilder:validation:Optional Pagination *PaginationSpec `json:"pagination,omitempty"` @@ -156,12 +164,13 @@ type TokenAuthSpec struct { // PaginationSpec defines the configuration for paginating through responses from providers type PaginationSpec struct { - // JSONPath-style expression to extract the list of targets from the response - // Example: "results" - ItemsField string `json:"itemsField,omitempty"` - - // JSONPath-style expression to extract the next page token or URL from the response for pagination - // Example: "next" + // Field name in the JSON response that contains the next page reference. + // The value can be either: + // - a full URL (used directly for the next request), or + // - a pagination token (appended as a query parameter using this field name as the key). + // + // Must refer to a top-level key in the response object. + // Example: "next" or "nextToken" NextField string `json:"nextField,omitempty"` } diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index d333a9e..f3a66f2 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -128,6 +128,13 @@ spec: description: Optional interval for polling the HTTP endpoint for targets type: string + itemsField: + description: |- + Field name in the JSON response that contains the list of items (targets). + Must refer to a top-level key in the response object. + If not specified, the entire response is expected to be a list of items. + Example: "results" + type: string mapping: description: Optional mapping configuration for parsing responses from the HTTP endpoint @@ -164,11 +171,6 @@ spec: description: Optional pagination configuration for parsing responses from the HTTP endpoint properties: - itemsField: - description: |- - JSONPath-style expression to extract the list of targets from the response - Example: "results" - type: string nextField: description: |- JSONPath-style expression to extract the next page token or URL from the response for pagination @@ -180,8 +182,9 @@ spec: description: Optional timeout for HTTP requests to the endpoint type: string tls: - description: Optional TLS configuration for connecting to - the HTTP endpoint + description: |- + Optional TLS configuration for connecting to the HTTP endpoint + If it is an HTTP endpoint, this will be ignored properties: caBundle: description: |- From fc6008da9ded3f1a5edff8150519bbaf557bf26d Mon Sep 17 00:00:00 2001 From: Daniel Schatzmann Date: Thu, 21 May 2026 09:27:00 +0000 Subject: [PATCH 10/31] rename ItemsField to TargetsField --- api/v1alpha1/targetsource_types.go | 2 +- .../bases/operator.gnmic.dev_targetsources.yaml | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 63bd535..d0045be 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -85,7 +85,7 @@ type HTTPConfig struct { // If not specified, the entire response is expected to be a list of items. // Example: "results" // +kubebuilder:validation:Optional - ItemsField string `json:"itemsField,omitempty"` + TargetsField string `json:"targetsField,omitempty"` // Optional pagination configuration for parsing responses from the HTTP endpoint // +kubebuilder:validation:Optional diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index f3a66f2..08615c8 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -128,13 +128,6 @@ spec: description: Optional interval for polling the HTTP endpoint for targets type: string - itemsField: - description: |- - Field name in the JSON response that contains the list of items (targets). - Must refer to a top-level key in the response object. - If not specified, the entire response is expected to be a list of items. - Example: "results" - type: string mapping: description: Optional mapping configuration for parsing responses from the HTTP endpoint @@ -177,6 +170,13 @@ spec: Example: "next" type: string type: object + targetsField: + description: |- + Field name in the JSON response that contains the list of items (targets). + Must refer to a top-level key in the response object. + If not specified, the entire response is expected to be a list of items. + Example: "results" + type: string timeout: default: 10s description: Optional timeout for HTTP requests to the endpoint From 0c1b414b609d5b2047b10fa125d0f2fd2658acd6 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Thu, 21 May 2026 14:16:01 -0600 Subject: [PATCH 11/31] changed assertion to new webhook spec --- internal/controller/discovery/loaders.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controller/discovery/loaders.go b/internal/controller/discovery/loaders.go index c4ebe78..dce3928 100644 --- a/internal/controller/discovery/loaders.go +++ b/internal/controller/discovery/loaders.go @@ -13,7 +13,7 @@ func NewLoader(cfg *core.CommonLoaderConfig, spec gnmicv1alpha1.TargetSourceSpec switch { case spec.Provider.HTTP != nil: - cfg.AcceptPush = spec.Provider.HTTP.AcceptPush + cfg.AcceptPush = spec.Provider.HTTP.Webhook.Enabled return http.New(*cfg), nil default: return nil, fmt.Errorf("unknown targetsource provider, check TargetSource CRD for %s", cfg.TargetsourceNN) From 7e06e146f98b266638da1a3f034bf2b41e4bb962 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Thu, 21 May 2026 14:18:04 -0600 Subject: [PATCH 12/31] moved TargetsField to ResponseMappingSpec --- api/v1alpha1/targetsource_types.go | 26 ++++++------- .../operator.gnmic.dev_targetsources.yaml | 37 ++++++++++--------- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index d0045be..0ca4181 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -80,13 +80,6 @@ type HTTPConfig struct { // +kubebuilder:validation:Optional TLS *ClientTLSConfig `json:"tls,omitempty"` - // Field name in the JSON response that contains the list of items (targets). - // Must refer to a top-level key in the response object. - // If not specified, the entire response is expected to be a list of items. - // Example: "results" - // +kubebuilder:validation:Optional - TargetsField string `json:"targetsField,omitempty"` - // Optional pagination configuration for parsing responses from the HTTP endpoint // +kubebuilder:validation:Optional Pagination *PaginationSpec `json:"pagination,omitempty"` @@ -177,25 +170,30 @@ type PaginationSpec struct { // JSONPath-style expressions to extract target fields from the response // and map them to the corresponding Target fields. type ResponseMappingSpec struct { - // JSONPath expression to extract the target name from the response + // Field name in the JSON response that contains the list of items (targets). + // If not specified, the entire response is expected to be a list of items. + // All subsequent fields are specified relative to this field + // Example: "results" + // +kubebuilder:validation:Optional + TargetsField string `json:"targetsField,omitempty"` + + // JSONPath expression to extract the target name from the response list // +kubebuilder:validation:Required Name string `json:"name"` - // JSONPath expression to extract the target address from the response + // JSONPath expression to extract the target address from the response list // +kubebuilder:validation:Required Address string `json:"address"` - // JSONPath expression to extract the target port from the response + // JSONPath expression to extract the target port from the response list // +kubebuilder:validation:Optional Port string `json:"port,omitempty"` - // JSONPath expression to extract the target labels from the response - // The extracted labels will be merged with the static TargetLabels defined in the TargetSourceSpec, - // with values from the response taking precedence in case of conflicts. + // JSONPath expression to extract the target labels from the response list // +kubebuilder:validation:Optional Labels map[string]string `json:"labels,omitempty"` - // JSONPath expression to extract the target profile from the response + // JSONPath expression to extract the target profile from the response list // +kubebuilder:validation:Optional TargetProfile string `json:"targetProfile,omitempty"` } diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index 08615c8..e68385d 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -134,27 +134,32 @@ spec: properties: address: description: JSONPath expression to extract the target - address from the response + address from the response list type: string labels: additionalProperties: type: string - description: |- - JSONPath expression to extract the target labels from the response - The extracted labels will be merged with the static TargetLabels defined in the TargetSourceSpec, - with values from the response taking precedence in case of conflicts. + description: JSONPath expression to extract the target + labels from the response list type: object name: description: JSONPath expression to extract the target - name from the response + name from the response list type: string port: description: JSONPath expression to extract the target - port from the response + port from the response list type: string targetProfile: description: JSONPath expression to extract the target - profile from the response + profile from the response list + type: string + targetsField: + description: |- + Field name in the JSON response that contains the list of items (targets). + If not specified, the entire response is expected to be a list of items. + All subsequent fields are specified relative to this field + Example: "results" type: string required: - address @@ -166,17 +171,15 @@ spec: properties: nextField: description: |- - JSONPath-style expression to extract the next page token or URL from the response for pagination - Example: "next" + Field name in the JSON response that contains the next page reference. + The value can be either: + - a full URL (used directly for the next request), or + - a pagination token (appended as a query parameter using this field name as the key). + + Must refer to a top-level key in the response object. + Example: "next" or "nextToken" type: string type: object - targetsField: - description: |- - Field name in the JSON response that contains the list of items (targets). - Must refer to a top-level key in the response object. - If not specified, the entire response is expected to be a list of items. - Example: "results" - type: string timeout: default: 10s description: Optional timeout for HTTP requests to the endpoint From 421d40ff3baf3c8c178000769beb968f76fcc03e Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Thu, 21 May 2026 14:22:38 -0600 Subject: [PATCH 13/31] changed validation rule for url or webhook --- api/v1alpha1/targetsource_types.go | 2 +- config/crd/bases/operator.gnmic.dev_targetsources.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 0ca4181..e5776ca 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -53,7 +53,7 @@ type ProviderSpec struct { } // HTTPConfig defines the configuration for the HTTP provider -// +kubebuilder:validation:AtLeastOneOf=url;acceptPush +// +kubebuilder:validation:AtLeastOneOf=url;webhook type HTTPConfig struct { // URL of the HTTP endpoint to pull targets from // If defined, the loader will periodically poll this endpoint for targets diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index e68385d..0aae289 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -324,9 +324,9 @@ spec: type: object type: object x-kubernetes-validations: - - message: at least one of the fields in [url acceptPush] must - be set - rule: '[has(self.url),has(self.acceptPush)].filter(x,x==true).size() + - message: at least one of the fields in [url webhook] must be + set + rule: '[has(self.url),has(self.webhook)].filter(x,x==true).size() >= 1' type: object x-kubernetes-validations: From 85c46b5ec6e7ae599262968b58ade8795baca4c0 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Thu, 21 May 2026 16:25:49 -0600 Subject: [PATCH 14/31] reworked kubebuilder validations --- api/v1alpha1/targetsource_types.go | 13 ++++++++----- .../crd/bases/operator.gnmic.dev_targetsources.yaml | 2 ++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index e5776ca..9febc06 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -53,7 +53,7 @@ type ProviderSpec struct { } // HTTPConfig defines the configuration for the HTTP provider -// +kubebuilder:validation:AtLeastOneOf=url;webhook +// +kubebuilder:validation:AtLeastOneOf:=url;webhook type HTTPConfig struct { // URL of the HTTP endpoint to pull targets from // If defined, the loader will periodically poll this endpoint for targets @@ -207,25 +207,28 @@ type WebhookSpec struct { Auth *WebhookAuthSpec `json:"auth,omitempty"` } -// +kubebuilder:validation:ExactlyOneOf=bearer;signature +// +kubebuilder:validation:ExactlyOneOf:=bearer;signature type WebhookAuthSpec struct { Bearer *WebhookBearerAuthSpec `json:"bearer,omitempty"` Signature *WebhookSignatureAuthSpec `json:"signature,omitempty"` } +// +kubebuilder:validation:Required type WebhookBearerAuthSpec struct { TokenSecretRef *corev1.SecretKeySelector `json:"tokenSecretRef,omitempty"` } +// +kubebuilder:validation:Required type WebhookSignatureAuthSpec struct { SecretRef *corev1.SecretKeySelector `json:"secretRef"` // Header containing the signature - Header string `json:"header,omitempty"` + // +kubebuilder:validation:MinLength=1 + Header string `json:"header"` - // +kubebuilder:default="sha256" + // +kubebuilder:default="sha512" // +kubebuilder:validation:Enum=sha1;sha256;sha512 - Algorithm string `json:"algorithm,omitempty"` + Algorithm string `json:"algorithm"` } // TargetSourceStatus defines the observed state of TargetSource diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index 0aae289..63ae646 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -281,6 +281,7 @@ spec: type: string header: description: Header containing the signature + minLength: 1 type: string secretRef: description: SecretKeySelector selects a key of @@ -308,6 +309,7 @@ spec: type: object x-kubernetes-map-type: atomic required: + - header - secretRef type: object type: object From e7a62dff98fd03d420af63defa146e38540cc6a4 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Thu, 21 May 2026 16:33:20 -0600 Subject: [PATCH 15/31] generated manifests --- config/crd/bases/operator.gnmic.dev_targetsources.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index 63ae646..7816afc 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -273,7 +273,7 @@ spec: signature: properties: algorithm: - default: sha256 + default: sha512 enum: - sha1 - sha256 @@ -309,6 +309,7 @@ spec: type: object x-kubernetes-map-type: atomic required: + - algorithm - header - secretRef type: object From 022ed92641fb6298958db72dc3db213026e5067f Mon Sep 17 00:00:00 2001 From: Daniel Schatzmann Date: Fri, 22 May 2026 14:10:49 +0000 Subject: [PATCH 16/31] refactor --- api/v1alpha1/targetsource_types.go | 66 ++++---- api/v1alpha1/zz_generated.deepcopy.go | 57 ++++--- .../operator.gnmic.dev_targetsources.yaml | 141 +++++++++--------- internal/controller/discovery/loaders.go | 2 +- 4 files changed, 120 insertions(+), 146 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 9febc06..8cfb6ad 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -88,28 +88,20 @@ type HTTPConfig struct { // +kubebuilder:validation:Optional ResponseMapping *ResponseMappingSpec `json:"mapping,omitempty"` - // Optional configuration to enable webhooks + // Optional configuration to enable push // +kubebuilder:validation:Optional - Webhook *WebhookSpec `json:"webhook,omitempty"` + Push *PushSpec `json:"push,omitempty"` } -// +kubebuilder:validation:XValidation:rule="!(has(self.caBundle) && has(self.caBundleSecretRef))",message="caBundle and caBundleSecretRef are mutually exclusive" type ClientTLSConfig struct { // Skip TLS verification of the Provider's certificate. // +kubebuilder:default:=false InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` - // Base64-encoded bundle of PEM CAs which will be used to validate the certificate - // chain presented by the Provider. Only used if using HTTPS to connect to Provider and - // ignored for HTTP connections. - // Mutually exclusive with CABundleSecretRef. - // +optional - CABundle []byte `json:"caBundle,omitempty"` - - // Reference to a Secret containing a bundle of PEM-encoded CAs to use when + // Reference to a ConfigMap containing a bundle of PEM-encoded CAs to use when // verifying the certificate chain presented by the Provider when using HTTPS. // Mutually exclusive with CABundle. - CABundleSecretRef *corev1.SecretKeySelector `json:"caBundleSecretRef,omitempty"` + CABundleRef *corev1.ConfigMapKeySelector `json:"caBundleSecretRef,omitempty"` } // AuthorizationSpec defines the configuration for authentication @@ -119,15 +111,14 @@ type AuthorizationSpec struct { Basic *BasicAuthSpec `json:"basic,omitempty"` // Token-based authentication configuration Token *TokenAuthSpec `json:"token,omitempty"` - // JWT *JWTAuthSpec `json:"jwt,omitempty"` - // MTLS } // BasicAuthSpec defines the configuration for basic authentication type BasicAuthSpec struct { // Reference to a Secret containing "username" and "password" keys to use for // basic authentication when connecting to the Provider. - CredentialsSecretRef *corev1.SecretKeySelector `json:"credentialsSecretRef,omitempty"` + // +kubebuilder:validation:Required + CredentialsSecretRef *corev1.SecretKeySelector `json:"credentialsSecretRef"` } // TokenAuthSpec defines the configuration for token-based authentication @@ -137,24 +128,11 @@ type TokenAuthSpec struct { Scheme string `json:"scheme"` // Reference to a Secret containing a key with the token value to use for // authentication when connecting to the Provider. + // Mutually exclusive with Token. + // +kubebuilder:validation:Required TokenSecretRef *corev1.SecretKeySelector `json:"tokenSecretRef,omitempty"` } -// disabled: +kubebuilder:validation:XValidation:rule="!((has(self.token) || has(self.tokenSecretRef)) && ((has(self.key) || has(self.signingKeySecretRef) || has(self.claims)))",message="static JWT token and generated JWT configuration cannot be combined" -// disabled: +kubebuilder:validation:XValidation:rule="!has(self.signingKeySecretRef) || self.algorithm != \"\"",message="algorithm must be specified when generating a JWT" -// type JWTAuthSpec struct { -// // Static pre-generated JWT -// Token string `json:"token,omitempty"` -// TokenSecretRef *corev1.SecretKeySelector `json:"tokenSecretRef,omitempty"` -// // Optional: generate JWT dynamically -// Claims map[string]string `json:"claims,omitempty"` -// Key string `json:"key,omitempty"` -// SigningKeySecretRef *corev1.SecretKeySelector `json:"signingKeySecretRef,omitempty"` -// // HS256, RS256, ES256, etc. -// Algorithm string `json:"algorithm,omitempty"` -// TTL *metav1.Duration `json:"ttl,omitempty"` -// } - // PaginationSpec defines the configuration for paginating through responses from providers type PaginationSpec struct { // Field name in the JSON response that contains the next page reference. @@ -167,39 +145,45 @@ type PaginationSpec struct { NextField string `json:"nextField,omitempty"` } -// JSONPath-style expressions to extract target fields from the response +// CEL expressions to extract target fields from the response // and map them to the corresponding Target fields. type ResponseMappingSpec struct { // Field name in the JSON response that contains the list of items (targets). // If not specified, the entire response is expected to be a list of items. // All subsequent fields are specified relative to this field - // Example: "results" + // Example: "results" if the response is of the form {"results": [ ... list of items ... ]} // +kubebuilder:validation:Optional TargetsField string `json:"targetsField,omitempty"` - // JSONPath expression to extract the target name from the response list - // +kubebuilder:validation:Required + // CEL expression to extract the target name from the response + // If TargetsField is specified, this should be relative to TargetsField + // +kubebuilder:validation:Optional Name string `json:"name"` - // JSONPath expression to extract the target address from the response list - // +kubebuilder:validation:Required + // CEL expression to extract the target Address from the response + // If TargetsField is specified, this should be relative to TargetsField + // +kubebuilder:validation:Optional Address string `json:"address"` - // JSONPath expression to extract the target port from the response list + // CEL expression to extract the target port from the response + // If TargetsField is specified, this should be relative to TargetsField // +kubebuilder:validation:Optional Port string `json:"port,omitempty"` - // JSONPath expression to extract the target labels from the response list + // CEL expression to extract the target labels from the response + // The extracted labels will be merged with the static TargetLabels defined in the TargetSourceSpec, + // with values from the response taking precedence in case of conflicts. // +kubebuilder:validation:Optional Labels map[string]string `json:"labels,omitempty"` - // JSONPath expression to extract the target profile from the response list + // CEL expression to extract the target profile from the response + // If TargetsField is specified, this should be relative to TargetsField // +kubebuilder:validation:Optional TargetProfile string `json:"targetProfile,omitempty"` } -// WebhookSpec defines the settings for event-based update mechanism (i.e. push-based) -type WebhookSpec struct { +// PushSpec defines the settings for event-based update mechanism (i.e. push-based) +type PushSpec struct { // +kubebuilder:default=false Enabled bool `json:"enabled"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 9d59d3c..c9a7235 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -94,14 +94,9 @@ func (in *BasicAuthSpec) DeepCopy() *BasicAuthSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClientTLSConfig) DeepCopyInto(out *ClientTLSConfig) { *out = *in - if in.CABundle != nil { - in, out := &in.CABundle, &out.CABundle - *out = make([]byte, len(*in)) - copy(*out, *in) - } - if in.CABundleSecretRef != nil { - in, out := &in.CABundleSecretRef, &out.CABundleSecretRef - *out = new(v1.SecretKeySelector) + if in.CABundleRef != nil { + in, out := &in.CABundleRef, &out.CABundleRef + *out = new(v1.ConfigMapKeySelector) (*in).DeepCopyInto(*out) } } @@ -358,9 +353,9 @@ func (in *HTTPConfig) DeepCopyInto(out *HTTPConfig) { *out = new(ResponseMappingSpec) (*in).DeepCopyInto(*out) } - if in.Webhook != nil { - in, out := &in.Webhook, &out.Webhook - *out = new(WebhookSpec) + if in.Push != nil { + in, out := &in.Push, &out.Push + *out = new(PushSpec) (*in).DeepCopyInto(*out) } } @@ -943,6 +938,26 @@ func (in *ProviderSpec) DeepCopy() *ProviderSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PushSpec) DeepCopyInto(out *PushSpec) { + *out = *in + if in.Auth != nil { + in, out := &in.Auth, &out.Auth + *out = new(WebhookAuthSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushSpec. +func (in *PushSpec) DeepCopy() *PushSpec { + if in == nil { + return nil + } + out := new(PushSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResponseMappingSpec) DeepCopyInto(out *ResponseMappingSpec) { *out = *in @@ -1684,23 +1699,3 @@ func (in *WebhookSignatureAuthSpec) DeepCopy() *WebhookSignatureAuthSpec { in.DeepCopyInto(out) return out } - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *WebhookSpec) DeepCopyInto(out *WebhookSpec) { - *out = *in - if in.Auth != nil { - in, out := &in.Auth, &out.Auth - *out = new(WebhookAuthSpec) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookSpec. -func (in *WebhookSpec) DeepCopy() *WebhookSpec { - if in == nil { - return nil - } - out := new(WebhookSpec) - in.DeepCopyInto(out) - return out -} diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index 7816afc..b3eaa7d 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -80,6 +80,8 @@ spec: - key type: object x-kubernetes-map-type: atomic + required: + - credentialsSecretRef type: object token: description: Token-based authentication configuration @@ -92,6 +94,7 @@ spec: description: |- Reference to a Secret containing a key with the token value to use for authentication when connecting to the Provider. + Mutually exclusive with Token. properties: key: description: The key of the secret to select from. Must @@ -116,6 +119,7 @@ spec: x-kubernetes-map-type: atomic required: - scheme + - tokenSecretRef type: object type: object x-kubernetes-validations: @@ -133,37 +137,40 @@ spec: from the HTTP endpoint properties: address: - description: JSONPath expression to extract the target - address from the response list + description: |- + CEL expression to extract the target Address from the response + If TargetsField is specified, this should be relative to TargetsField type: string labels: additionalProperties: type: string - description: JSONPath expression to extract the target - labels from the response list + description: |- + CEL expression to extract the target labels from the response + The extracted labels will be merged with the static TargetLabels defined in the TargetSourceSpec, + with values from the response taking precedence in case of conflicts. type: object name: - description: JSONPath expression to extract the target - name from the response list + description: |- + CEL expression to extract the target name from the response + If TargetsField is specified, this should be relative to TargetsField type: string port: - description: JSONPath expression to extract the target - port from the response list + description: |- + CEL expression to extract the target port from the response + If TargetsField is specified, this should be relative to TargetsField type: string targetProfile: - description: JSONPath expression to extract the target - profile from the response list + description: |- + CEL expression to extract the target profile from the response + If TargetsField is specified, this should be relative to TargetsField type: string targetsField: description: |- Field name in the JSON response that contains the list of items (targets). If not specified, the entire response is expected to be a list of items. All subsequent fields are specified relative to this field - Example: "results" + Example: "results" if the response is of the form {"results": [ ... list of items ... ]} type: string - required: - - address - - name type: object pagination: description: Optional pagination configuration for parsing @@ -180,65 +187,8 @@ spec: Example: "next" or "nextToken" type: string type: object - timeout: - default: 10s - description: Optional timeout for HTTP requests to the endpoint - type: string - tls: - description: |- - Optional TLS configuration for connecting to the HTTP endpoint - If it is an HTTP endpoint, this will be ignored - properties: - caBundle: - description: |- - Base64-encoded bundle of PEM CAs which will be used to validate the certificate - chain presented by the Provider. Only used if using HTTPS to connect to Provider and - ignored for HTTP connections. - Mutually exclusive with CABundleSecretRef. - format: byte - type: string - caBundleSecretRef: - description: |- - Reference to a Secret containing a bundle of PEM-encoded CAs to use when - verifying the certificate chain presented by the Provider when using HTTPS. - Mutually exclusive with CABundle. - properties: - key: - description: The key of the secret to select from. Must - be a valid secret key. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the Secret or its key - must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - insecureSkipVerify: - default: false - description: Skip TLS verification of the Provider's certificate. - type: boolean - type: object - x-kubernetes-validations: - - message: caBundle and caBundleSecretRef are mutually exclusive - rule: '!(has(self.caBundle) && has(self.caBundleSecretRef))' - url: - description: |- - URL of the HTTP endpoint to pull targets from - If defined, the loader will periodically poll this endpoint for targets - type: string - webhook: - description: Optional configuration to enable webhooks + push: + description: Optional configuration to enable push properties: auth: properties: @@ -325,6 +275,51 @@ spec: required: - enabled type: object + timeout: + default: 10s + description: Optional timeout for HTTP requests to the endpoint + type: string + tls: + description: |- + Optional TLS configuration for connecting to the HTTP endpoint + If it is an HTTP endpoint, this will be ignored + properties: + caBundleSecretRef: + description: |- + Reference to a ConfigMap containing a bundle of PEM-encoded CAs to use when + verifying the certificate chain presented by the Provider when using HTTPS. + Mutually exclusive with CABundle. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + insecureSkipVerify: + default: false + description: Skip TLS verification of the Provider's certificate. + type: boolean + type: object + url: + description: |- + URL of the HTTP endpoint to pull targets from + If defined, the loader will periodically poll this endpoint for targets + type: string type: object x-kubernetes-validations: - message: at least one of the fields in [url webhook] must be diff --git a/internal/controller/discovery/loaders.go b/internal/controller/discovery/loaders.go index dce3928..af014a5 100644 --- a/internal/controller/discovery/loaders.go +++ b/internal/controller/discovery/loaders.go @@ -13,7 +13,7 @@ func NewLoader(cfg *core.CommonLoaderConfig, spec gnmicv1alpha1.TargetSourceSpec switch { case spec.Provider.HTTP != nil: - cfg.AcceptPush = spec.Provider.HTTP.Webhook.Enabled + cfg.AcceptPush = spec.Provider.HTTP.Push.Enabled return http.New(*cfg), nil default: return nil, fmt.Errorf("unknown targetsource provider, check TargetSource CRD for %s", cfg.TargetsourceNN) From 214810c45c6d5361651958354710a6f4e445dd8a Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Fri, 22 May 2026 08:16:35 -0600 Subject: [PATCH 17/31] removed old webhook statements --- api/v1alpha1/targetsource_types.go | 14 +- api/v1alpha1/zz_generated.deepcopy.go | 132 +++++++++--------- .../operator.gnmic.dev_targetsources.yaml | 5 +- 3 files changed, 75 insertions(+), 76 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 8cfb6ad..56afe46 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -53,7 +53,7 @@ type ProviderSpec struct { } // HTTPConfig defines the configuration for the HTTP provider -// +kubebuilder:validation:AtLeastOneOf:=url;webhook +// +kubebuilder:validation:AtLeastOneOf:=url;push type HTTPConfig struct { // URL of the HTTP endpoint to pull targets from // If defined, the loader will periodically poll this endpoint for targets @@ -188,22 +188,22 @@ type PushSpec struct { Enabled bool `json:"enabled"` // +kubebuilder:validation:Optional - Auth *WebhookAuthSpec `json:"auth,omitempty"` + Auth *PushAuthSpec `json:"auth,omitempty"` } // +kubebuilder:validation:ExactlyOneOf:=bearer;signature -type WebhookAuthSpec struct { - Bearer *WebhookBearerAuthSpec `json:"bearer,omitempty"` - Signature *WebhookSignatureAuthSpec `json:"signature,omitempty"` +type PushAuthSpec struct { + Bearer *PushBearerAuthSpec `json:"bearer,omitempty"` + Signature *PushSignatureAuthSpec `json:"signature,omitempty"` } // +kubebuilder:validation:Required -type WebhookBearerAuthSpec struct { +type PushBearerAuthSpec struct { TokenSecretRef *corev1.SecretKeySelector `json:"tokenSecretRef,omitempty"` } // +kubebuilder:validation:Required -type WebhookSignatureAuthSpec struct { +type PushSignatureAuthSpec struct { SecretRef *corev1.SecretKeySelector `json:"secretRef"` // Header containing the signature diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index c9a7235..5567a56 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -938,12 +938,77 @@ func (in *ProviderSpec) DeepCopy() *ProviderSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PushAuthSpec) DeepCopyInto(out *PushAuthSpec) { + *out = *in + if in.Bearer != nil { + in, out := &in.Bearer, &out.Bearer + *out = new(PushBearerAuthSpec) + (*in).DeepCopyInto(*out) + } + if in.Signature != nil { + in, out := &in.Signature, &out.Signature + *out = new(PushSignatureAuthSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushAuthSpec. +func (in *PushAuthSpec) DeepCopy() *PushAuthSpec { + if in == nil { + return nil + } + out := new(PushAuthSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PushBearerAuthSpec) DeepCopyInto(out *PushBearerAuthSpec) { + *out = *in + if in.TokenSecretRef != nil { + in, out := &in.TokenSecretRef, &out.TokenSecretRef + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushBearerAuthSpec. +func (in *PushBearerAuthSpec) DeepCopy() *PushBearerAuthSpec { + if in == nil { + return nil + } + out := new(PushBearerAuthSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PushSignatureAuthSpec) DeepCopyInto(out *PushSignatureAuthSpec) { + *out = *in + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushSignatureAuthSpec. +func (in *PushSignatureAuthSpec) DeepCopy() *PushSignatureAuthSpec { + if in == nil { + return nil + } + out := new(PushSignatureAuthSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PushSpec) DeepCopyInto(out *PushSpec) { *out = *in if in.Auth != nil { in, out := &in.Auth, &out.Auth - *out = new(WebhookAuthSpec) + *out = new(PushAuthSpec) (*in).DeepCopyInto(*out) } } @@ -1634,68 +1699,3 @@ func (in *TunnelTargetPolicyStatus) DeepCopy() *TunnelTargetPolicyStatus { in.DeepCopyInto(out) return out } - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *WebhookAuthSpec) DeepCopyInto(out *WebhookAuthSpec) { - *out = *in - if in.Bearer != nil { - in, out := &in.Bearer, &out.Bearer - *out = new(WebhookBearerAuthSpec) - (*in).DeepCopyInto(*out) - } - if in.Signature != nil { - in, out := &in.Signature, &out.Signature - *out = new(WebhookSignatureAuthSpec) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookAuthSpec. -func (in *WebhookAuthSpec) DeepCopy() *WebhookAuthSpec { - if in == nil { - return nil - } - out := new(WebhookAuthSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *WebhookBearerAuthSpec) DeepCopyInto(out *WebhookBearerAuthSpec) { - *out = *in - if in.TokenSecretRef != nil { - in, out := &in.TokenSecretRef, &out.TokenSecretRef - *out = new(v1.SecretKeySelector) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookBearerAuthSpec. -func (in *WebhookBearerAuthSpec) DeepCopy() *WebhookBearerAuthSpec { - if in == nil { - return nil - } - out := new(WebhookBearerAuthSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *WebhookSignatureAuthSpec) DeepCopyInto(out *WebhookSignatureAuthSpec) { - *out = *in - if in.SecretRef != nil { - in, out := &in.SecretRef, &out.SecretRef - *out = new(v1.SecretKeySelector) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookSignatureAuthSpec. -func (in *WebhookSignatureAuthSpec) DeepCopy() *WebhookSignatureAuthSpec { - if in == nil { - return nil - } - out := new(WebhookSignatureAuthSpec) - in.DeepCopyInto(out) - return out -} diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index b3eaa7d..5ee51af 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -322,9 +322,8 @@ spec: type: string type: object x-kubernetes-validations: - - message: at least one of the fields in [url webhook] must be - set - rule: '[has(self.url),has(self.webhook)].filter(x,x==true).size() + - message: at least one of the fields in [url push] must be set + rule: '[has(self.url),has(self.push)].filter(x,x==true).size() >= 1' type: object x-kubernetes-validations: From 89bfbf54d76b6d593b003a051cee24a2932b4b26 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Fri, 22 May 2026 08:25:50 -0600 Subject: [PATCH 18/31] changed pushSpec comment --- api/v1alpha1/targetsource_types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 56afe46..320482c 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -182,7 +182,7 @@ type ResponseMappingSpec struct { TargetProfile string `json:"targetProfile,omitempty"` } -// PushSpec defines the settings for event-based update mechanism (i.e. push-based) +// PushSpec defines the settings for event-based update mechanism (i.e. webhooks sent from the server) type PushSpec struct { // +kubebuilder:default=false Enabled bool `json:"enabled"` From 0e147516f06d5745c54c45855c011e01bc5be6d8 Mon Sep 17 00:00:00 2001 From: Daniel Schatzmann Date: Fri, 22 May 2026 14:34:19 +0000 Subject: [PATCH 19/31] update webhook to push --- api/v1alpha1/targetsource_types.go | 14 +- api/v1alpha1/zz_generated.deepcopy.go | 132 +++++++++--------- .../operator.gnmic.dev_targetsources.yaml | 5 +- 3 files changed, 75 insertions(+), 76 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 8cfb6ad..56afe46 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -53,7 +53,7 @@ type ProviderSpec struct { } // HTTPConfig defines the configuration for the HTTP provider -// +kubebuilder:validation:AtLeastOneOf:=url;webhook +// +kubebuilder:validation:AtLeastOneOf:=url;push type HTTPConfig struct { // URL of the HTTP endpoint to pull targets from // If defined, the loader will periodically poll this endpoint for targets @@ -188,22 +188,22 @@ type PushSpec struct { Enabled bool `json:"enabled"` // +kubebuilder:validation:Optional - Auth *WebhookAuthSpec `json:"auth,omitempty"` + Auth *PushAuthSpec `json:"auth,omitempty"` } // +kubebuilder:validation:ExactlyOneOf:=bearer;signature -type WebhookAuthSpec struct { - Bearer *WebhookBearerAuthSpec `json:"bearer,omitempty"` - Signature *WebhookSignatureAuthSpec `json:"signature,omitempty"` +type PushAuthSpec struct { + Bearer *PushBearerAuthSpec `json:"bearer,omitempty"` + Signature *PushSignatureAuthSpec `json:"signature,omitempty"` } // +kubebuilder:validation:Required -type WebhookBearerAuthSpec struct { +type PushBearerAuthSpec struct { TokenSecretRef *corev1.SecretKeySelector `json:"tokenSecretRef,omitempty"` } // +kubebuilder:validation:Required -type WebhookSignatureAuthSpec struct { +type PushSignatureAuthSpec struct { SecretRef *corev1.SecretKeySelector `json:"secretRef"` // Header containing the signature diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index c9a7235..5567a56 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -938,12 +938,77 @@ func (in *ProviderSpec) DeepCopy() *ProviderSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PushAuthSpec) DeepCopyInto(out *PushAuthSpec) { + *out = *in + if in.Bearer != nil { + in, out := &in.Bearer, &out.Bearer + *out = new(PushBearerAuthSpec) + (*in).DeepCopyInto(*out) + } + if in.Signature != nil { + in, out := &in.Signature, &out.Signature + *out = new(PushSignatureAuthSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushAuthSpec. +func (in *PushAuthSpec) DeepCopy() *PushAuthSpec { + if in == nil { + return nil + } + out := new(PushAuthSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PushBearerAuthSpec) DeepCopyInto(out *PushBearerAuthSpec) { + *out = *in + if in.TokenSecretRef != nil { + in, out := &in.TokenSecretRef, &out.TokenSecretRef + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushBearerAuthSpec. +func (in *PushBearerAuthSpec) DeepCopy() *PushBearerAuthSpec { + if in == nil { + return nil + } + out := new(PushBearerAuthSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PushSignatureAuthSpec) DeepCopyInto(out *PushSignatureAuthSpec) { + *out = *in + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushSignatureAuthSpec. +func (in *PushSignatureAuthSpec) DeepCopy() *PushSignatureAuthSpec { + if in == nil { + return nil + } + out := new(PushSignatureAuthSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PushSpec) DeepCopyInto(out *PushSpec) { *out = *in if in.Auth != nil { in, out := &in.Auth, &out.Auth - *out = new(WebhookAuthSpec) + *out = new(PushAuthSpec) (*in).DeepCopyInto(*out) } } @@ -1634,68 +1699,3 @@ func (in *TunnelTargetPolicyStatus) DeepCopy() *TunnelTargetPolicyStatus { in.DeepCopyInto(out) return out } - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *WebhookAuthSpec) DeepCopyInto(out *WebhookAuthSpec) { - *out = *in - if in.Bearer != nil { - in, out := &in.Bearer, &out.Bearer - *out = new(WebhookBearerAuthSpec) - (*in).DeepCopyInto(*out) - } - if in.Signature != nil { - in, out := &in.Signature, &out.Signature - *out = new(WebhookSignatureAuthSpec) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookAuthSpec. -func (in *WebhookAuthSpec) DeepCopy() *WebhookAuthSpec { - if in == nil { - return nil - } - out := new(WebhookAuthSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *WebhookBearerAuthSpec) DeepCopyInto(out *WebhookBearerAuthSpec) { - *out = *in - if in.TokenSecretRef != nil { - in, out := &in.TokenSecretRef, &out.TokenSecretRef - *out = new(v1.SecretKeySelector) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookBearerAuthSpec. -func (in *WebhookBearerAuthSpec) DeepCopy() *WebhookBearerAuthSpec { - if in == nil { - return nil - } - out := new(WebhookBearerAuthSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *WebhookSignatureAuthSpec) DeepCopyInto(out *WebhookSignatureAuthSpec) { - *out = *in - if in.SecretRef != nil { - in, out := &in.SecretRef, &out.SecretRef - *out = new(v1.SecretKeySelector) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookSignatureAuthSpec. -func (in *WebhookSignatureAuthSpec) DeepCopy() *WebhookSignatureAuthSpec { - if in == nil { - return nil - } - out := new(WebhookSignatureAuthSpec) - in.DeepCopyInto(out) - return out -} diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index b3eaa7d..5ee51af 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -322,9 +322,8 @@ spec: type: string type: object x-kubernetes-validations: - - message: at least one of the fields in [url webhook] must be - set - rule: '[has(self.url),has(self.webhook)].filter(x,x==true).size() + - message: at least one of the fields in [url push] must be set + rule: '[has(self.url),has(self.push)].filter(x,x==true).size() >= 1' type: object x-kubernetes-validations: From b62f3f5a635eae07060f7b43b3d611337ea001cd Mon Sep 17 00:00:00 2001 From: Daniel Schatzmann Date: Fri, 22 May 2026 15:01:05 +0000 Subject: [PATCH 20/31] refactor CRD --- api/v1alpha1/targetsource_types.go | 9 +++++---- api/v1alpha1/zz_generated.deepcopy.go | 4 ++-- config/crd/bases/operator.gnmic.dev_targetsources.yaml | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 320482c..b34b096 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -65,10 +65,10 @@ type HTTPConfig struct { Authorization *AuthorizationSpec `json:"authorization,omitempty"` // Optional interval for polling the HTTP endpoint for targets - // TODO: increase default value - // +kubebuilder:default="30s" + // TODO: document about default value + // +kubebuilder:default="6h" // +kubebuilder:validation:Optional - PollInterval *metav1.Duration `json:"interval,omitempty"` + Interval *metav1.Duration `json:"interval,omitempty"` // Optional timeout for HTTP requests to the endpoint // +kubebuilder:default="10s" @@ -101,7 +101,8 @@ type ClientTLSConfig struct { // Reference to a ConfigMap containing a bundle of PEM-encoded CAs to use when // verifying the certificate chain presented by the Provider when using HTTPS. // Mutually exclusive with CABundle. - CABundleRef *corev1.ConfigMapKeySelector `json:"caBundleSecretRef,omitempty"` + // +kubebuilder:validation:Optional + CABundleRef *corev1.ConfigMapKeySelector `json:"caBundleRef,omitempty"` } // AuthorizationSpec defines the configuration for authentication diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 5567a56..9051fc7 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -328,8 +328,8 @@ func (in *HTTPConfig) DeepCopyInto(out *HTTPConfig) { *out = new(AuthorizationSpec) (*in).DeepCopyInto(*out) } - if in.PollInterval != nil { - in, out := &in.PollInterval, &out.PollInterval + if in.Interval != nil { + in, out := &in.Interval, &out.Interval *out = new(metav1.Duration) **out = **in } diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index 5ee51af..d9c9184 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -128,7 +128,7 @@ spec: rule: '[has(self.basic),has(self.token)].filter(x,x==true).size() == 1' interval: - default: 30s + default: 6h description: Optional interval for polling the HTTP endpoint for targets type: string @@ -284,7 +284,7 @@ spec: Optional TLS configuration for connecting to the HTTP endpoint If it is an HTTP endpoint, this will be ignored properties: - caBundleSecretRef: + caBundleRef: description: |- Reference to a ConfigMap containing a bundle of PEM-encoded CAs to use when verifying the certificate chain presented by the Provider when using HTTPS. From fe4c4d6f4046e55d96c0caf6d24231b07a095aee Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Mon, 25 May 2026 14:55:08 -0600 Subject: [PATCH 21/31] added SyncStatus values to CRD --- api/v1alpha1/targetsource_types.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index dd6fb59..98976a8 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -52,12 +52,21 @@ type ConsulConfig struct { // TargetSourceStatus defines the observed state of TargetSource type TargetSourceStatus struct { - Status string `json:"status,omitempty"` - ObservedGeneration int64 `json:"observedGeneration"` - TargetsCount int32 `json:"targetsCount,omitempty"` - LastSync metav1.Time `json:"lastSync,omitempty"` + SyncStatus TargetSourceSyncStatus `json:"status"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + TargetsCount int32 `json:"targetsCount,omitempty"` + LastUpdate metav1.Time `json:"lastSync,omitempty"` } +const ( + SyncStatusError TargetSourceSyncStatus = "Error" + SyncStatusPending TargetSourceSyncStatus = "Pending" + SyncStatusSynced TargetSourceSyncStatus = "Synced" + SyncStatusSyncedWithErrors TargetSourceSyncStatus = "SyncedWithErrors" +) + +type TargetSourceSyncStatus string + //+kubebuilder:object:root=true //+kubebuilder:subresource:status From ad6394bd4b2a2bc1e1406691b1bf606ad525546c Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Mon, 25 May 2026 14:55:43 -0600 Subject: [PATCH 22/31] generated manifests --- api/v1alpha1/zz_generated.deepcopy.go | 2 +- config/crd/bases/operator.gnmic.dev_targetsources.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 61e81fd..5350766 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1314,7 +1314,7 @@ func (in *TargetSourceSpec) DeepCopy() *TargetSourceSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TargetSourceStatus) DeepCopyInto(out *TargetSourceStatus) { *out = *in - in.LastSync.DeepCopyInto(&out.LastSync) + in.LastUpdate.DeepCopyInto(&out.LastUpdate) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TargetSourceStatus. diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index b8d24e1..700ffdb 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -88,7 +88,7 @@ spec: format: int32 type: integer required: - - observedGeneration + - status type: object type: object served: true From 70594fc305d16f78cd3650aa9e90c67a9baf445b Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Mon, 25 May 2026 15:09:05 -0600 Subject: [PATCH 23/31] added interface for StatusUpdate --- .../discovery/core/status_updater_interface.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 internal/controller/discovery/core/status_updater_interface.go diff --git a/internal/controller/discovery/core/status_updater_interface.go b/internal/controller/discovery/core/status_updater_interface.go new file mode 100644 index 0000000..6326d17 --- /dev/null +++ b/internal/controller/discovery/core/status_updater_interface.go @@ -0,0 +1,18 @@ +package core + +import ( + "context" + + gnmicv1alpha1 "github.com/gnmic/operator/api/v1alpha1" +) + +type StatusUpdate struct { + SyncStatus gnmicv1alpha1.TargetSourceSyncStatus + TargetsCount int32 + Err error +} + +// StatusUpdater defines the interface for TargetLoaders and MessageProcessor to update the status of the TargetSource +type StatusUpdater interface { + UpdateStatus(context.Context, StatusUpdate) error +} From 34dea8a906c72e68b958971a2c62006cf4b09a23 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Wed, 27 May 2026 14:44:16 -0600 Subject: [PATCH 24/31] changed status to include conditions and implemented interface --- api/v1alpha1/targetsource_types.go | 17 +- api/v1alpha1/zz_generated.deepcopy.go | 9 +- .../operator.gnmic.dev_targetsources.yaml | 60 ++++- internal/controller/discovery/client.go | 18 -- .../discovery/core/client_interfaces.go | 5 + .../core/status_updater_interface.go | 14 +- internal/controller/discovery/core/types.go | 1 + .../discovery/loaders/http/loader.go | 1 + .../controller/discovery/message_processor.go | 39 +-- .../controller/discovery/status_updater.go | 231 ++++++++++++++++++ .../controller/targetsource_controller.go | 25 +- 11 files changed, 355 insertions(+), 65 deletions(-) create mode 100644 internal/controller/discovery/core/client_interfaces.go create mode 100644 internal/controller/discovery/status_updater.go diff --git a/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 98976a8..909698e 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -52,21 +52,12 @@ type ConsulConfig struct { // TargetSourceStatus defines the observed state of TargetSource type TargetSourceStatus struct { - SyncStatus TargetSourceSyncStatus `json:"status"` - ObservedGeneration int64 `json:"observedGeneration,omitempty"` - TargetsCount int32 `json:"targetsCount,omitempty"` - LastUpdate metav1.Time `json:"lastSync,omitempty"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + TargetsCount int32 `json:"targetsCount,omitempty"` + LastSync metav1.Time `json:"lastSync,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` } -const ( - SyncStatusError TargetSourceSyncStatus = "Error" - SyncStatusPending TargetSourceSyncStatus = "Pending" - SyncStatusSynced TargetSourceSyncStatus = "Synced" - SyncStatusSyncedWithErrors TargetSourceSyncStatus = "SyncedWithErrors" -) - -type TargetSourceSyncStatus string - //+kubebuilder:object:root=true //+kubebuilder:subresource:status diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 5350766..3fe3bf3 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1314,7 +1314,14 @@ func (in *TargetSourceSpec) DeepCopy() *TargetSourceSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TargetSourceStatus) DeepCopyInto(out *TargetSourceStatus) { *out = *in - in.LastUpdate.DeepCopyInto(&out.LastUpdate) + in.LastSync.DeepCopyInto(&out.LastSync) + 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 TargetSourceStatus. diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index 700ffdb..1a13d44 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -76,19 +76,71 @@ spec: status: description: TargetSourceStatus defines the observed state of TargetSource 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 lastSync: format: date-time type: string observedGeneration: format: int64 type: integer - status: - type: string targetsCount: format: int32 type: integer - required: - - status type: object type: object served: true diff --git a/internal/controller/discovery/client.go b/internal/controller/discovery/client.go index e5cc5ea..a9d790f 100644 --- a/internal/controller/discovery/client.go +++ b/internal/controller/discovery/client.go @@ -7,7 +7,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -70,20 +69,3 @@ func deleteTarget(ctx context.Context, c client.Client, name string, namespace s return err } - -// updateTargetSourceStatus updates the status of the TargetSource Object ts. The only fields updated are targetCount and LastSync, which takes the current timestamp. -func updateTargetSourceStatus(ctx context.Context, c client.Client, ts *gnmicv1alpha1.TargetSource, targetCount int32) error { - err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - latest := &gnmicv1alpha1.TargetSource{} - if err := c.Get(ctx, client.ObjectKeyFromObject(ts), latest); err != nil { - return err - } - - latest.Status.TargetsCount = targetCount - latest.Status.LastSync = metav1.Now() - - return c.Status().Update(ctx, latest) - }) - - return err -} diff --git a/internal/controller/discovery/core/client_interfaces.go b/internal/controller/discovery/core/client_interfaces.go new file mode 100644 index 0000000..2367f34 --- /dev/null +++ b/internal/controller/discovery/core/client_interfaces.go @@ -0,0 +1,5 @@ +package core + +// type LoaderClient interface { +// UpdateStatus(context.Context, StatusUpdate) error +// } diff --git a/internal/controller/discovery/core/status_updater_interface.go b/internal/controller/discovery/core/status_updater_interface.go index 6326d17..6440e58 100644 --- a/internal/controller/discovery/core/status_updater_interface.go +++ b/internal/controller/discovery/core/status_updater_interface.go @@ -2,17 +2,13 @@ package core import ( "context" - - gnmicv1alpha1 "github.com/gnmic/operator/api/v1alpha1" ) -type StatusUpdate struct { - SyncStatus gnmicv1alpha1.TargetSourceSyncStatus - TargetsCount int32 - Err error -} - // StatusUpdater defines the interface for TargetLoaders and MessageProcessor to update the status of the TargetSource type StatusUpdater interface { - UpdateStatus(context.Context, StatusUpdate) error + SetPending(context.Context) error + SetFetching(context.Context) error + SetSuccessfulSync(context.Context, int32) error + SetSyncWithErrors(context.Context, int32, error) error + SetFetchFailed(context.Context, error) error } diff --git a/internal/controller/discovery/core/types.go b/internal/controller/discovery/core/types.go index 5a1c8cf..7107c34 100644 --- a/internal/controller/discovery/core/types.go +++ b/internal/controller/discovery/core/types.go @@ -22,6 +22,7 @@ type CommonLoaderConfig struct { TargetsourceNN types.NamespacedName ChunkSize int AcceptPush bool + Client StatusUpdater } // EventAction represents the type of a discovery event diff --git a/internal/controller/discovery/loaders/http/loader.go b/internal/controller/discovery/loaders/http/loader.go index 5169e59..e70a11d 100644 --- a/internal/controller/discovery/loaders/http/loader.go +++ b/internal/controller/discovery/loaders/http/loader.go @@ -51,6 +51,7 @@ func (l *Loader) Run(ctx context.Context, out chan<- []core.DiscoveryMessage) er return nil case <-ticker.C: + l.commonCfg.Client.SetFetching(ctx) // Switch case + i only needed to test behavior for messages with different values. switch i { case 1: diff --git a/internal/controller/discovery/message_processor.go b/internal/controller/discovery/message_processor.go index f573b1b..ce3373e 100644 --- a/internal/controller/discovery/message_processor.go +++ b/internal/controller/discovery/message_processor.go @@ -31,15 +31,17 @@ type MessageProcessor struct { // Events are deferred while snapshot is in progress deferredEvents []core.DiscoveryEvent targetCount int32 + updater core.StatusUpdater } // NewMessageProcessor wires a MessageProcessor instance -func NewMessageProcessor(c client.Client, s *runtime.Scheme, ts *gnmicv1alpha1.TargetSource, in <-chan []core.DiscoveryMessage) *MessageProcessor { +func NewMessageProcessor(c client.Client, s *runtime.Scheme, ts *gnmicv1alpha1.TargetSource, in <-chan []core.DiscoveryMessage, u core.StatusUpdater) *MessageProcessor { return &MessageProcessor{ client: c, scheme: s, targetSource: ts, in: in, + updater: u, } } @@ -234,11 +236,13 @@ func (m *MessageProcessor) processEvent(ctx context.Context, event core.Discover switch event.Event { case core.EventApply: m.targetCount++ - m.updateStatus(ctx, logger) + m.updater.SetSuccessfulSync(ctx, m.targetCount) case core.EventDelete: m.targetCount-- - m.updateStatus(ctx, logger) + m.updater.SetSuccessfulSync(ctx, m.targetCount) } + } else { + // m.updateStatus(ctx, gnmicv1alpha1.SyncStatusError, err) } return err @@ -309,8 +313,21 @@ func (m *MessageProcessor) applySnapshot(ctx context.Context, snapshot *snapshot "numOfDelete", nDelete, ) + errCount := 0 for _, e := range events { - m.applyEvent(ctx, e, logger) + err = m.applyEvent(ctx, e, logger) + if err != nil { + errCount++ + } + } + if errCount != 0 { + // m.updateStatus(ctx, gnmicv1alpha1.SyncStatusSyncedWithErrors, err) + } else { + // Because of idempotency, allTargets = desired state = targets existing in Kubernetes. Overwrites the counter to "reset" it. + m.targetCount = int32(len(allTargets)) + if err := m.updater.SetSuccessfulSync(ctx, m.targetCount); err != nil { + logger.Error(err, "error updating TargetSource status") + } } // Replay deferred events @@ -325,10 +342,6 @@ func (m *MessageProcessor) applySnapshot(ctx context.Context, snapshot *snapshot } } - // Because of idempotency, allTargets = desired state = targets existing in Kubernetes. Overwrites the counter to "reset" it. - m.targetCount = int32(len(allTargets)) - m.updateStatus(ctx, logger) - m.resetSnapshot() m.deferredEvents = nil return nil @@ -365,16 +378,6 @@ func (m *MessageProcessor) applyEvent(ctx context.Context, event core.DiscoveryE return nil } -func (m *MessageProcessor) updateStatus(ctx context.Context, logger logr.Logger) { - if err := updateTargetSourceStatus(ctx, m.client, m.targetSource, m.targetCount); err != nil { - logger.Error(err, "error updating TargetSource status") - } else { - logger.Info("updated target source status", - "targetCount", m.targetCount, - ) - } -} - func (m *MessageProcessor) resetSnapshot() { m.activeSnapshot = nil } diff --git a/internal/controller/discovery/status_updater.go b/internal/controller/discovery/status_updater.go new file mode 100644 index 0000000..2b747a0 --- /dev/null +++ b/internal/controller/discovery/status_updater.go @@ -0,0 +1,231 @@ +package discovery + +import ( + "context" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + + gnmicv1alpha1 "github.com/gnmic/operator/api/v1alpha1" +) + +const ( + ConditionReady = "Ready" + ConditionReconciling = "Reconciling" + ConditionDegraded = "Degraded" + ConditionStalled = "Stalled" + + ReasonWaitingForSync = "WaitingForSync" + ReasonSyncStarted = "SyncStarted" + ReasonSyncSucceeded = "SyncSucceeded" + ReasonSyncCompleted = "SyncCompleted" + ReasonSyncWithErrors = "SyncSucceededWithErrors" + ReasonSyncFailed = "SyncFailed" +) + +type TargetSourceStatusUpdater struct { + client client.Client + targetSource *gnmicv1alpha1.TargetSource +} + +func NewTargetSourceStatusUpdater(c client.Client, ts *gnmicv1alpha1.TargetSource) *TargetSourceStatusUpdater { + return &TargetSourceStatusUpdater{ + client: c, + targetSource: ts, + } +} + +func (u *TargetSourceStatusUpdater) SetPending(ctx context.Context) error { + + return u.patchStatus(ctx, func( + ts *gnmicv1alpha1.TargetSource, + ) { + now := metav1.Now() + + // Ready=True + meta.SetStatusCondition(&ts.Status.Conditions, metav1.Condition{ + Type: ConditionReady, + Status: metav1.ConditionFalse, + Reason: ReasonWaitingForSync, + Message: "Waiting for the TargetLoader to start the sync", + LastTransitionTime: now, + }) + + // Remove other status conditions + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionReconciling, + ) + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionStalled, + ) + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionDegraded, + ) + }) +} + +func (u *TargetSourceStatusUpdater) SetFetching(ctx context.Context) error { + + return u.patchStatus(ctx, func( + ts *gnmicv1alpha1.TargetSource, + ) { + now := metav1.Now() + + // Reconciling=True + meta.SetStatusCondition(&ts.Status.Conditions, metav1.Condition{ + Type: ConditionReconciling, + Status: metav1.ConditionTrue, + Reason: ReasonSyncStarted, + Message: "Started fetching targets", + LastTransitionTime: now, + }) + + // Remove other status conditions + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionReady, + ) + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionStalled, + ) + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionDegraded, + ) + }) +} + +func (u *TargetSourceStatusUpdater) SetFetchFailed(ctx context.Context, err error) error { + + return u.patchStatus(ctx, func( + ts *gnmicv1alpha1.TargetSource, + ) { + now := metav1.Now() + + // Reconciling=True + meta.SetStatusCondition(&ts.Status.Conditions, metav1.Condition{ + Type: ConditionStalled, + Status: metav1.ConditionTrue, + Reason: ReasonSyncFailed, + Message: err.Error(), + LastTransitionTime: now, + }) + + // Remove other status conditions + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionReady, + ) + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionReconciling, + ) + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionDegraded, + ) + }) +} + +func (u *TargetSourceStatusUpdater) SetSuccessfulSync(ctx context.Context, targetsCount int32) error { + + return u.patchStatus(ctx, func( + ts *gnmicv1alpha1.TargetSource, + ) { + now := metav1.Now() + + // Ready=True + meta.SetStatusCondition(&ts.Status.Conditions, metav1.Condition{ + Type: ConditionReady, + Status: metav1.ConditionTrue, + Reason: ReasonSyncSucceeded, + Message: "Targets synchronized successfully", + LastTransitionTime: now, + }) + + // Remove other status conditions + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionReconciling, + ) + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionStalled, + ) + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionDegraded, + ) + + // Update status fields + ts.Status.TargetsCount = targetsCount + ts.Status.LastSync = now + }) +} + +func (u *TargetSourceStatusUpdater) SetSyncWithErrors(ctx context.Context, targetsCount int32, err error) error { + + return u.patchStatus(ctx, func( + ts *gnmicv1alpha1.TargetSource, + ) { + now := metav1.Now() + + // Ready=True + meta.SetStatusCondition(&ts.Status.Conditions, metav1.Condition{ + Type: ConditionReady, + Status: metav1.ConditionTrue, + Reason: ReasonSyncSucceeded, + Message: "Targets synchronized", + LastTransitionTime: now, + }) + + // Degraded=True + meta.SetStatusCondition(&ts.Status.Conditions, metav1.Condition{ + Type: ConditionDegraded, + Status: metav1.ConditionTrue, + Reason: ReasonSyncWithErrors, + Message: err.Error(), + LastTransitionTime: now, + }) + + // Remove other status conditions + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionReady, + ) + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionReconciling, + ) + meta.RemoveStatusCondition( + &ts.Status.Conditions, + ConditionStalled, + ) + + // Update status fields + ts.Status.TargetsCount = targetsCount + ts.Status.LastSync = now + }) +} + +func (u *TargetSourceStatusUpdater) patchStatus(ctx context.Context, mutate func(*gnmicv1alpha1.TargetSource)) error { + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest := &gnmicv1alpha1.TargetSource{} + if err := u.client.Get(ctx, client.ObjectKeyFromObject(u.targetSource), latest); err != nil { + return err + } + + patch := client.MergeFrom(latest.DeepCopy()) + mutate(latest) + + return u.client.Status().Patch(ctx, latest, patch) + }) + + return err +} diff --git a/internal/controller/targetsource_controller.go b/internal/controller/targetsource_controller.go index 7f30fc8..19eab6d 100644 --- a/internal/controller/targetsource_controller.go +++ b/internal/controller/targetsource_controller.go @@ -22,6 +22,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" @@ -104,8 +105,8 @@ func (r *TargetSourceReconciler) Reconcile(ctx context.Context, req ctrl.Request return ctrl.Result{}, err } - targetSource.Status.ObservedGeneration = targetSource.Generation - if err := r.Status().Update(ctx, targetSource); err != nil { + // Update TargetSource Status for new generation + if err := r.updateObservedGeneration(ctx, targetSource); err != nil { return ctrl.Result{}, err } @@ -179,9 +180,12 @@ func (r *TargetSourceReconciler) startDiscovery( ) error { targetChannel := make(chan []discoveryTypes.DiscoveryMessage, r.BufferSize) ctx, cancel := context.WithCancel(context.Background()) + statusUpdater := discovery.NewTargetSourceStatusUpdater(r.Client, targetSource) + statusUpdater.SetPending(ctx) loaderConfig := discoveryTypes.CommonLoaderConfig{ TargetsourceNN: key, ChunkSize: r.ChunkSize, + Client: statusUpdater, } // Cleanup function to cleanup discovery runtime of targetsource @@ -195,6 +199,7 @@ func (r *TargetSourceReconciler) startDiscovery( r.Scheme, targetSource, targetChannel, + statusUpdater, ) loader, err := discovery.NewLoader(&loaderConfig, targetSource.Spec) if err != nil { @@ -238,6 +243,22 @@ func (r *TargetSourceReconciler) startDiscovery( return nil } +func (r *TargetSourceReconciler) updateObservedGeneration(ctx context.Context, ts *gnmicv1alpha1.TargetSource) error { + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest := &gnmicv1alpha1.TargetSource{} + if err := r.Client.Get(ctx, client.ObjectKeyFromObject(ts), latest); err != nil { + return err + } + + patch := client.MergeFrom(latest.DeepCopy()) + latest.Status.ObservedGeneration = ts.Generation + + return r.Client.Status().Patch(ctx, latest, patch) + }) + + return err +} + // SetupWithManager sets up the controller with the Manager. func (r *TargetSourceReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). From bc3c2c686eacd4c15f4292d725d5e75962860cb1 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Wed, 27 May 2026 18:14:00 -0600 Subject: [PATCH 25/31] fixed http integration test resources based on type changes --- test/integration/http/resources/configmap.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/integration/http/resources/configmap.yaml b/test/integration/http/resources/configmap.yaml index f017566..be7091f 100644 --- a/test/integration/http/resources/configmap.yaml +++ b/test/integration/http/resources/configmap.yaml @@ -6,7 +6,8 @@ data: targets.json: | [ { - "address": "clab-t1-spine1:57400", + "address": "clab-t1-spine1", + "port": 57400, "name": "spine1", "labels": { "vendor": "nokia_srlinux", @@ -14,7 +15,8 @@ data: } }, { - "address": "clab-t1-leaf1:57400", + "address": "clab-t1-leaf1", + "port": 57400, "name": "leaf1", "labels": { "vendor": "nokia_srlinux", @@ -22,7 +24,8 @@ data: } }, { - "address": "clab-t1-leaf2:57400", + "address": "clab-t1-leaf2", + "port": 57400, "name": "leaf2", "labels": { "vendor": "nokia_srlinux", From 10aee05195c196bf486931bf03fe4a744a48e5ed Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Thu, 28 May 2026 20:36:32 -0600 Subject: [PATCH 26/31] changed StatusUpdater functions to use only UpdateStatus() and created KubernetesClient --- .../discovery/core/client_interfaces.go | 5 - .../core/discovery_kubernetes_client.go | 68 ++++++ .../core/status_updater_interface.go | 29 ++- .../discovery/loaders/http/loader.go | 15 +- .../controller/discovery/message_processor.go | 48 +++- .../controller/discovery/status_updater.go | 231 ------------------ .../controller/targetsource_controller.go | 18 +- 7 files changed, 165 insertions(+), 249 deletions(-) delete mode 100644 internal/controller/discovery/core/client_interfaces.go create mode 100644 internal/controller/discovery/core/discovery_kubernetes_client.go delete mode 100644 internal/controller/discovery/status_updater.go diff --git a/internal/controller/discovery/core/client_interfaces.go b/internal/controller/discovery/core/client_interfaces.go deleted file mode 100644 index 2367f34..0000000 --- a/internal/controller/discovery/core/client_interfaces.go +++ /dev/null @@ -1,5 +0,0 @@ -package core - -// type LoaderClient interface { -// UpdateStatus(context.Context, StatusUpdate) error -// } diff --git a/internal/controller/discovery/core/discovery_kubernetes_client.go b/internal/controller/discovery/core/discovery_kubernetes_client.go new file mode 100644 index 0000000..f8a3a33 --- /dev/null +++ b/internal/controller/discovery/core/discovery_kubernetes_client.go @@ -0,0 +1,68 @@ +package core + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + + gnmicv1alpha1 "github.com/gnmic/operator/api/v1alpha1" +) + +// DiscoveryKubernetesClient is a client which fulfills the StatusUpdater interface +type DiscoveryKubernetesClient struct { + client client.Client + scheme *runtime.Scheme + targetSource *gnmicv1alpha1.TargetSource +} + +// Returns an instance of DiscoveryKubernetesClient +func NewDiscoveryKubernetesClient(c client.Client, s *runtime.Scheme, ts *gnmicv1alpha1.TargetSource) *DiscoveryKubernetesClient { + return &DiscoveryKubernetesClient{ + client: c, + scheme: s, + targetSource: ts, + } +} + +// UpdateStatus takes a StatusUpdate holding Conditions and a pointer referencing the TargetsCount. +// If TargetsCount is set, the LastSync time gets set to metav1.Now(). +// Replaces LastTransitionTime of each Condition with metav1.Now(). +func (c *DiscoveryKubernetesClient) UpdateStatus(ctx context.Context, update StatusUpdate) error { + + return c.patchStatus(ctx, func( + ts *gnmicv1alpha1.TargetSource, + ) { + now := metav1.Now() + + // Update status fields: Replace all Conditions and set TargetsCount and LastSync if pointer != nil + for i := range update.Conditions { + update.Conditions[i].LastTransitionTime = now + } + ts.Status.Conditions = update.Conditions + + if update.TargetsCount != nil { + ts.Status.TargetsCount = *update.TargetsCount + ts.Status.LastSync = now + } + }) +} + +// patchStatus is an internal function to update the Kubernetes object +func (c *DiscoveryKubernetesClient) patchStatus(ctx context.Context, mutate func(*gnmicv1alpha1.TargetSource)) error { + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest := &gnmicv1alpha1.TargetSource{} + if err := c.client.Get(ctx, client.ObjectKeyFromObject(c.targetSource), latest); err != nil { + return err + } + + patch := client.MergeFrom(latest.DeepCopy()) + mutate(latest) + + return c.client.Status().Patch(ctx, latest, patch) + }) + + return err +} diff --git a/internal/controller/discovery/core/status_updater_interface.go b/internal/controller/discovery/core/status_updater_interface.go index 6440e58..35a7c3d 100644 --- a/internal/controller/discovery/core/status_updater_interface.go +++ b/internal/controller/discovery/core/status_updater_interface.go @@ -2,13 +2,32 @@ package core import ( "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + ConditionTypeReady = "Ready" + ConditionTypeReconciling = "Reconciling" + ConditionTypeDegraded = "Degraded" + ConditionTypeStalled = "Stalled" + + ReasonWaitingForSync Reason = "WaitingForSync" + ReasonSyncStarted Reason = "SyncStarted" + ReasonSyncSucceeded Reason = "SyncSucceeded" + ReasonSyncCompleted Reason = "SyncCompleted" + ReasonSyncWithErrors Reason = "SyncSucceededWithErrors" + ReasonSyncFailed Reason = "SyncFailed" ) +type Reason string + +type StatusUpdate struct { + Conditions []metav1.Condition + TargetsCount *int32 +} + // StatusUpdater defines the interface for TargetLoaders and MessageProcessor to update the status of the TargetSource type StatusUpdater interface { - SetPending(context.Context) error - SetFetching(context.Context) error - SetSuccessfulSync(context.Context, int32) error - SetSyncWithErrors(context.Context, int32, error) error - SetFetchFailed(context.Context, error) error + UpdateStatus(context.Context, StatusUpdate) error } diff --git a/internal/controller/discovery/loaders/http/loader.go b/internal/controller/discovery/loaders/http/loader.go index e70a11d..a37a50b 100644 --- a/internal/controller/discovery/loaders/http/loader.go +++ b/internal/controller/discovery/loaders/http/loader.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/log" "github.com/gnmic/operator/internal/controller/discovery/core" @@ -51,7 +52,19 @@ func (l *Loader) Run(ctx context.Context, out chan<- []core.DiscoveryMessage) er return nil case <-ticker.C: - l.commonCfg.Client.SetFetching(ctx) + l.commonCfg.Client.UpdateStatus( + ctx, + core.StatusUpdate{ + Conditions: []metav1.Condition{ + { + Type: core.ConditionTypeReconciling, + Status: metav1.ConditionStatus("True"), + Reason: string(core.ReasonSyncStarted), + Message: "Started fetching targets", + }, + }, + }, + ) // Switch case + i only needed to test behavior for messages with different values. switch i { case 1: diff --git a/internal/controller/discovery/message_processor.go b/internal/controller/discovery/message_processor.go index ce3373e..4302a90 100644 --- a/internal/controller/discovery/message_processor.go +++ b/internal/controller/discovery/message_processor.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -236,10 +237,36 @@ func (m *MessageProcessor) processEvent(ctx context.Context, event core.Discover switch event.Event { case core.EventApply: m.targetCount++ - m.updater.SetSuccessfulSync(ctx, m.targetCount) + m.updater.UpdateStatus( + ctx, + core.StatusUpdate{ + Conditions: []metav1.Condition{ + { + Type: core.ConditionTypeReady, + Status: metav1.ConditionStatus("True"), + Reason: string(core.ReasonSyncSucceeded), + Message: "Successfully synced all targets", + }, + }, + TargetsCount: &m.targetCount, + }, + ) case core.EventDelete: m.targetCount-- - m.updater.SetSuccessfulSync(ctx, m.targetCount) + m.updater.UpdateStatus( + ctx, + core.StatusUpdate{ + Conditions: []metav1.Condition{ + { + Type: core.ConditionTypeReady, + Status: metav1.ConditionStatus("True"), + Reason: string(core.ReasonSyncSucceeded), + Message: "Successfully synced all targets", + }, + }, + TargetsCount: &m.targetCount, + }, + ) } } else { // m.updateStatus(ctx, gnmicv1alpha1.SyncStatusError, err) @@ -325,9 +352,20 @@ func (m *MessageProcessor) applySnapshot(ctx context.Context, snapshot *snapshot } else { // Because of idempotency, allTargets = desired state = targets existing in Kubernetes. Overwrites the counter to "reset" it. m.targetCount = int32(len(allTargets)) - if err := m.updater.SetSuccessfulSync(ctx, m.targetCount); err != nil { - logger.Error(err, "error updating TargetSource status") - } + m.updater.UpdateStatus( + ctx, + core.StatusUpdate{ + Conditions: []metav1.Condition{ + { + Type: core.ConditionTypeReady, + Status: metav1.ConditionStatus("True"), + Reason: string(core.ReasonSyncSucceeded), + Message: "Successfully synced all targets", + }, + }, + TargetsCount: &m.targetCount, + }, + ) } // Replay deferred events diff --git a/internal/controller/discovery/status_updater.go b/internal/controller/discovery/status_updater.go deleted file mode 100644 index 2b747a0..0000000 --- a/internal/controller/discovery/status_updater.go +++ /dev/null @@ -1,231 +0,0 @@ -package discovery - -import ( - "context" - - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/util/retry" - "sigs.k8s.io/controller-runtime/pkg/client" - - gnmicv1alpha1 "github.com/gnmic/operator/api/v1alpha1" -) - -const ( - ConditionReady = "Ready" - ConditionReconciling = "Reconciling" - ConditionDegraded = "Degraded" - ConditionStalled = "Stalled" - - ReasonWaitingForSync = "WaitingForSync" - ReasonSyncStarted = "SyncStarted" - ReasonSyncSucceeded = "SyncSucceeded" - ReasonSyncCompleted = "SyncCompleted" - ReasonSyncWithErrors = "SyncSucceededWithErrors" - ReasonSyncFailed = "SyncFailed" -) - -type TargetSourceStatusUpdater struct { - client client.Client - targetSource *gnmicv1alpha1.TargetSource -} - -func NewTargetSourceStatusUpdater(c client.Client, ts *gnmicv1alpha1.TargetSource) *TargetSourceStatusUpdater { - return &TargetSourceStatusUpdater{ - client: c, - targetSource: ts, - } -} - -func (u *TargetSourceStatusUpdater) SetPending(ctx context.Context) error { - - return u.patchStatus(ctx, func( - ts *gnmicv1alpha1.TargetSource, - ) { - now := metav1.Now() - - // Ready=True - meta.SetStatusCondition(&ts.Status.Conditions, metav1.Condition{ - Type: ConditionReady, - Status: metav1.ConditionFalse, - Reason: ReasonWaitingForSync, - Message: "Waiting for the TargetLoader to start the sync", - LastTransitionTime: now, - }) - - // Remove other status conditions - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionReconciling, - ) - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionStalled, - ) - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionDegraded, - ) - }) -} - -func (u *TargetSourceStatusUpdater) SetFetching(ctx context.Context) error { - - return u.patchStatus(ctx, func( - ts *gnmicv1alpha1.TargetSource, - ) { - now := metav1.Now() - - // Reconciling=True - meta.SetStatusCondition(&ts.Status.Conditions, metav1.Condition{ - Type: ConditionReconciling, - Status: metav1.ConditionTrue, - Reason: ReasonSyncStarted, - Message: "Started fetching targets", - LastTransitionTime: now, - }) - - // Remove other status conditions - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionReady, - ) - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionStalled, - ) - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionDegraded, - ) - }) -} - -func (u *TargetSourceStatusUpdater) SetFetchFailed(ctx context.Context, err error) error { - - return u.patchStatus(ctx, func( - ts *gnmicv1alpha1.TargetSource, - ) { - now := metav1.Now() - - // Reconciling=True - meta.SetStatusCondition(&ts.Status.Conditions, metav1.Condition{ - Type: ConditionStalled, - Status: metav1.ConditionTrue, - Reason: ReasonSyncFailed, - Message: err.Error(), - LastTransitionTime: now, - }) - - // Remove other status conditions - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionReady, - ) - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionReconciling, - ) - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionDegraded, - ) - }) -} - -func (u *TargetSourceStatusUpdater) SetSuccessfulSync(ctx context.Context, targetsCount int32) error { - - return u.patchStatus(ctx, func( - ts *gnmicv1alpha1.TargetSource, - ) { - now := metav1.Now() - - // Ready=True - meta.SetStatusCondition(&ts.Status.Conditions, metav1.Condition{ - Type: ConditionReady, - Status: metav1.ConditionTrue, - Reason: ReasonSyncSucceeded, - Message: "Targets synchronized successfully", - LastTransitionTime: now, - }) - - // Remove other status conditions - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionReconciling, - ) - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionStalled, - ) - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionDegraded, - ) - - // Update status fields - ts.Status.TargetsCount = targetsCount - ts.Status.LastSync = now - }) -} - -func (u *TargetSourceStatusUpdater) SetSyncWithErrors(ctx context.Context, targetsCount int32, err error) error { - - return u.patchStatus(ctx, func( - ts *gnmicv1alpha1.TargetSource, - ) { - now := metav1.Now() - - // Ready=True - meta.SetStatusCondition(&ts.Status.Conditions, metav1.Condition{ - Type: ConditionReady, - Status: metav1.ConditionTrue, - Reason: ReasonSyncSucceeded, - Message: "Targets synchronized", - LastTransitionTime: now, - }) - - // Degraded=True - meta.SetStatusCondition(&ts.Status.Conditions, metav1.Condition{ - Type: ConditionDegraded, - Status: metav1.ConditionTrue, - Reason: ReasonSyncWithErrors, - Message: err.Error(), - LastTransitionTime: now, - }) - - // Remove other status conditions - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionReady, - ) - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionReconciling, - ) - meta.RemoveStatusCondition( - &ts.Status.Conditions, - ConditionStalled, - ) - - // Update status fields - ts.Status.TargetsCount = targetsCount - ts.Status.LastSync = now - }) -} - -func (u *TargetSourceStatusUpdater) patchStatus(ctx context.Context, mutate func(*gnmicv1alpha1.TargetSource)) error { - err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - latest := &gnmicv1alpha1.TargetSource{} - if err := u.client.Get(ctx, client.ObjectKeyFromObject(u.targetSource), latest); err != nil { - return err - } - - patch := client.MergeFrom(latest.DeepCopy()) - mutate(latest) - - return u.client.Status().Patch(ctx, latest, patch) - }) - - return err -} diff --git a/internal/controller/targetsource_controller.go b/internal/controller/targetsource_controller.go index 19eab6d..ed880f4 100644 --- a/internal/controller/targetsource_controller.go +++ b/internal/controller/targetsource_controller.go @@ -20,6 +20,7 @@ import ( "context" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/util/retry" @@ -180,8 +181,21 @@ func (r *TargetSourceReconciler) startDiscovery( ) error { targetChannel := make(chan []discoveryTypes.DiscoveryMessage, r.BufferSize) ctx, cancel := context.WithCancel(context.Background()) - statusUpdater := discovery.NewTargetSourceStatusUpdater(r.Client, targetSource) - statusUpdater.SetPending(ctx) + + statusUpdater := discoveryTypes.NewDiscoveryKubernetesClient(r.Client, r.Scheme, targetSource) + if err := statusUpdater.UpdateStatus(ctx, discoveryTypes.StatusUpdate{ + Conditions: []metav1.Condition{ + { + Type: discoveryTypes.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: string(discoveryTypes.ReasonWaitingForSync), + Message: "Waiting for initial sync", + }, + }, + }); err != nil { + logger.Error(err, "updating targetsource status failed") + } + loaderConfig := discoveryTypes.CommonLoaderConfig{ TargetsourceNN: key, ChunkSize: r.ChunkSize, From db42ddf0a2332f7738dd9ec6e1ec30c800ffb933 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Thu, 28 May 2026 20:43:44 -0600 Subject: [PATCH 27/31] added http targetsource to integration tests --- Makefile | 3 ++- internal/controller/discovery/loaders/http/loader.go | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index fdcc2b2..e8ddee2 100644 --- a/Makefile +++ b/Makefile @@ -308,9 +308,10 @@ delete-targetsources-dev-lab: ## Delete the target sources for the development l ##@ Testing Lab .PHONY: run-integration-tests -run-integration-tests: docker-build undeploy-test-cluster deploy-test-cluster install-test-cluster-dependencies load-test-image deploy install-kubectl install-gnmic install-containerlab deploy-test-topology apply-test-resources +run-integration-tests: docker-build undeploy-test-cluster deploy-test-cluster install-test-cluster-dependencies load-test-image deploy install-kubectl install-gnmic install-containerlab deploy-test-topology deploy-test-http-server apply-test-resources kubectl wait --for=condition=Ready cluster --all --timeout=180s kubectl wait --for=condition=Ready pipeline --all --timeout=180s + kubectl wait --for=condition=Ready targetsource --all --timeout=180s kubectl wait --for=jsonpath='{.status.connectionState}'=READY target --all --timeout=180s kubectl get subscriptions -o yaml kubectl get outputs -o yaml diff --git a/internal/controller/discovery/loaders/http/loader.go b/internal/controller/discovery/loaders/http/loader.go index a37a50b..f1e6d3b 100644 --- a/internal/controller/discovery/loaders/http/loader.go +++ b/internal/controller/discovery/loaders/http/loader.go @@ -65,6 +65,7 @@ func (l *Loader) Run(ctx context.Context, out chan<- []core.DiscoveryMessage) er }, }, ) + time.Sleep(10 * time.Second) // Switch case + i only needed to test behavior for messages with different values. switch i { case 1: From c6383e78f1fe1bbf2c963b1bcb8e4e503e05cd50 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Thu, 28 May 2026 20:48:49 -0600 Subject: [PATCH 28/31] added targetsources to apply-test-resources --- test.mk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test.mk b/test.mk index 23c5983..67f1923 100644 --- a/test.mk +++ b/test.mk @@ -153,5 +153,5 @@ apply-test-clusters: ## Apply the test clusters for testing kubectl apply -f test/integration/resources/clusters .PHONY: apply-test-resources -apply-test-resources: apply-test-targets apply-test-subscriptions apply-test-outputs apply-test-pipelines apply-test-clusters +apply-test-resources: apply-test-targets apply-test-targetsources apply-test-subscriptions apply-test-outputs apply-test-pipelines apply-test-clusters From 69bab6b2bb04aeab211d439f45efd7f6c9795def Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Thu, 28 May 2026 20:53:56 -0600 Subject: [PATCH 29/31] go mod tidy --- go.mod | 2 -- go.sum | 5 ----- 2 files changed, 7 deletions(-) diff --git a/go.mod b/go.mod index c877a7b..9dc2b78 100644 --- a/go.mod +++ b/go.mod @@ -21,8 +21,6 @@ require ( require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/PaesslerAG/gval v1.0.0 // indirect - github.com/PaesslerAG/jsonpath v0.1.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect diff --git a/go.sum b/go.sum index d900003..45485f1 100644 --- a/go.sum +++ b/go.sum @@ -2,11 +2,6 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= 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/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8= -github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= -github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= -github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk= -github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= 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/cert-manager/cert-manager v1.19.3 h1:3d0Nk/HO3BOmAdBJNaBh+6YgaO3Ciey3xCpOjiX5Obs= From a1d9e492ce92c2d83fe4b704c9339d16d05657f4 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Thu, 28 May 2026 21:13:40 -0600 Subject: [PATCH 30/31] fixed nil pointer reference in loader instantiation --- internal/controller/discovery/loaders.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/controller/discovery/loaders.go b/internal/controller/discovery/loaders.go index af014a5..e8061d9 100644 --- a/internal/controller/discovery/loaders.go +++ b/internal/controller/discovery/loaders.go @@ -13,7 +13,9 @@ func NewLoader(cfg *core.CommonLoaderConfig, spec gnmicv1alpha1.TargetSourceSpec switch { case spec.Provider.HTTP != nil: - cfg.AcceptPush = spec.Provider.HTTP.Push.Enabled + if spec.Provider.HTTP.Push != nil { + cfg.AcceptPush = spec.Provider.HTTP.Push.Enabled + } return http.New(*cfg), nil default: return nil, fmt.Errorf("unknown targetsource provider, check TargetSource CRD for %s", cfg.TargetsourceNN) From 9f10e4a18f0a8f6c35e1fa2e83415c154e76eb15 Mon Sep 17 00:00:00 2001 From: Valentino Diller Date: Thu, 28 May 2026 21:23:33 -0600 Subject: [PATCH 31/31] changed loader to comply with integration test --- .../discovery/loaders/http/loader.go | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/internal/controller/discovery/loaders/http/loader.go b/internal/controller/discovery/loaders/http/loader.go index 5169e59..6b85a9b 100644 --- a/internal/controller/discovery/loaders/http/loader.go +++ b/internal/controller/discovery/loaders/http/loader.go @@ -57,14 +57,16 @@ func (l *Loader) Run(ctx context.Context, out chan<- []core.DiscoveryMessage) er snapshotID := fmt.Sprintf("%s-%s-%s", l.commonCfg.TargetsourceNN.Namespace, l.commonCfg.TargetsourceNN.Name, uuid.NewString()) targets := []core.DiscoveredTarget{ { - Name: "ceos1", - Address: "clab-3-nodes-ceos1:6030", + Name: "spine1", + Address: "clab-t1-spine1", + Port: 57400, Labels: map[string]string{}, }, { Name: "leaf1", - Address: "clab-3-nodes-leaf1:57400", - Labels: map[string]string{"gnmic_operator_target_profile": "default1"}, + Address: "clab-leaf1", + Port: 57400, + Labels: map[string]string{}, }, } @@ -75,14 +77,16 @@ func (l *Loader) Run(ctx context.Context, out chan<- []core.DiscoveryMessage) er snapshotID := fmt.Sprintf("%s-%s-%s", l.commonCfg.TargetsourceNN.Namespace, l.commonCfg.TargetsourceNN.Name, uuid.NewString()) targets := []core.DiscoveredTarget{ { - Name: "ceos1", - Address: "clab-3-nodes-ceos1:6030", - Labels: map[string]string{"gnmic_operator_target_profile": "default1"}, + Name: "spine1", + Address: "clab-t1-spine1", + Port: 57400, + Labels: map[string]string{}, }, { Name: "leaf2", - Address: "clab-3-nodes-leaf2:57400", - Labels: map[string]string{"gnmic_operator_target_profile": "default1"}, + Address: "clab-t1-leaf2", + Port: 57400, + Labels: map[string]string{}, }, } @@ -94,9 +98,22 @@ func (l *Loader) Run(ctx context.Context, out chan<- []core.DiscoveryMessage) er snapshotID := fmt.Sprintf("%s-%s-%s", l.commonCfg.TargetsourceNN.Namespace, l.commonCfg.TargetsourceNN.Name, uuid.NewString()) targets := []core.DiscoveredTarget{ { - Name: "ceos1", - Address: "clab-3-nodes-ceos2:6030", - Labels: map[string]string{"gnmic_operator_target_profile": "default2"}, + Name: "spine1", + Address: "clab-t1-spine1", + Port: 57400, + Labels: map[string]string{}, + }, + { + Name: "leaf1", + Address: "clab-t1-leaf1", + Port: 57400, + Labels: map[string]string{}, + }, + { + Name: "leaf2", + Address: "clab-t1-leaf2", + Port: 57400, + Labels: map[string]string{}, }, }