diff --git a/Makefile b/Makefile index fdcc2b2..b5acbac 100644 --- a/Makefile +++ b/Makefile @@ -282,7 +282,7 @@ delete-outputs-dev-lab: ## Delete the outputs for the development lab cluster kubectl delete -f lab/dev/resources/outputs .PHONY: apply-pipelines-dev-lab -apply-pipelines-dev-lab: ## Apply the pipelines for the development lab cluster + §apply-pipelines-dev-lab: ## Apply the pipelines for the development lab cluster kubectl apply -f lab/dev/resources/pipelines .PHONY: delete-pipelines-dev-lab @@ -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 deploy-test-http-server install-kubectl install-gnmic install-containerlab deploy-test-topology apply-test-resources kubectl wait --for=condition=Ready cluster --all --timeout=180s kubectl wait --for=condition=Ready pipeline --all --timeout=180s + kubectl wait --for=jsonpath='{.status.targetsCount}'=3 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/api/v1alpha1/targetsource_types.go b/api/v1alpha1/targetsource_types.go index 3d69743..89f48c1 100644 --- a/api/v1alpha1/targetsource_types.go +++ b/api/v1alpha1/targetsource_types.go @@ -17,44 +17,209 @@ 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"` } -type ConsulConfig struct { +// +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 +} + +// 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(disabled):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(disabled):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 list of items (targets). + // Must refer to a top-level key in the response object. + // Example: "results" + ItemsField string `json:"itemsField,omitempty"` + + // 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"` +} + +// 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 IP from the response + // +kubebuilder:validation:Required + IP string `json:"ip"` + + // 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"` + + // 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 type TargetSourceStatus struct { - Status string `json:"status"` - TargetsCount int32 `json:"targetsCount"` - LastSync metav1.Time `json:"lastSync"` + Status string `json:"status,omitempty"` + ObservedGeneration int64 `json:"observedGeneration"` + TargetsCount int32 `json:"targetsCount,omitempty"` + LastSync metav1.Time `json:"lastSync,omitempty"` } //+kubebuilder:object:root=true 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/cmd/main.go b/cmd/main.go index aaf398a..23f944a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -18,7 +18,9 @@ package main import ( "context" + "errors" "flag" + "net/http" "os" "time" @@ -41,8 +43,8 @@ import ( operatorv1alpha1 "github.com/gnmic/operator/api/v1alpha1" "github.com/gnmic/operator/internal/apiserver" "github.com/gnmic/operator/internal/controller" - "github.com/gnmic/operator/internal/controller/discovery/core" "github.com/gnmic/operator/internal/controller/discovery" + "github.com/gnmic/operator/internal/controller/discovery/core" webhookv1alpha1 "github.com/gnmic/operator/internal/webhook/v1alpha1" //+kubebuilder:scaffold:imports ) @@ -125,12 +127,22 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Pipeline") os.Exit(1) } + + var api *apiserver.APIServer + if apiAddr != "" { + api, err = apiserver.New(apiAddr, clusterReconciler, discoveryRegistry, discoveryChunkSize, os.Getenv("API_BEARER_TOKEN")) + if err != nil { + setupLog.Error(err, "unable to initialize API server") + os.Exit(1) + } + } if err := (&controller.TargetSourceReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), BufferSize: discoveryBufferSize, ChunkSize: discoveryChunkSize, DiscoveryRegistry: discoveryRegistry, + APIRouter: api.Router(), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "TargetSource") os.Exit(1) @@ -230,21 +242,31 @@ func main() { os.Exit(1) } - if apiAddr != "" { - apiServer := apiserver.New(apiAddr, clusterReconciler) - apiServer.DiscoveryRegistry = discoveryRegistry + if api != nil { err = mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { + if err := api.InitializeBearerToken(ctx); err != nil { + return err + } + errCh := make(chan error) go func() { - errCh <- apiServer.Server.ListenAndServe() + err := api.Server.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + close(errCh) }() + select { - case err := <-errCh: + case err, ok := <-errCh: + if !ok { + return nil + } return err case <-ctx.Done(): ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - return apiServer.Server.Shutdown(ctx) + return api.Server.Shutdown(ctx) } })) if err != nil { diff --git a/config/crd/bases/operator.gnmic.dev_targetsources.yaml b/config/crd/bases/operator.gnmic.dev_targetsources.yaml index 37d6919..eecee6f 100644 --- a/config/crd/bases/operator.gnmic.dev_targetsources.yaml +++ b/config/crd/bases/operator.gnmic.dev_targetsources.yaml @@ -40,33 +40,260 @@ 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: + ip: + description: JSONPath expression to extract the target + IP 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 + targetProfile: + description: JSONPath expression to extract the target + profile from the response + type: string + required: + - ip + - name + type: object + pagination: + description: Optional pagination configuration for parsing + responses from the HTTP endpoint + properties: + 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. + Example: "results" + type: string + nextField: + description: |- + 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 + 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: @@ -79,15 +306,16 @@ spec: lastSync: format: date-time type: string + observedGeneration: + format: int64 + type: integer status: type: string targetsCount: format: int32 type: integer required: - - lastSync - - status - - targetsCount + - observedGeneration type: object type: object served: true diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 2cd79f0..b377fe4 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -73,6 +73,21 @@ spec: - --leader-elect image: controller:latest name: manager + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: CLUSTER_NAME + valueFrom: + fieldRef: + fieldPath: metadata.labels['app.kubernetes.io/name'] + - name: API_BEARER_TOKEN + valueFrom: + secretKeyRef: + name: gnmic-api-auth + key: bearer-token + optional: true securityContext: allowPrivilegeEscalation: false capabilities: diff --git a/docs/content/docs/user-guide/rest-api/.openapi-generator-ignore b/docs/content/docs/user-guide/rest-api/.openapi-generator-ignore new file mode 100644 index 0000000..7484ee5 --- /dev/null +++ b/docs/content/docs/user-guide/rest-api/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/docs/content/docs/user-guide/rest-api/.openapi-generator/FILES b/docs/content/docs/user-guide/rest-api/.openapi-generator/FILES new file mode 100644 index 0000000..6398cda --- /dev/null +++ b/docs/content/docs/user-guide/rest-api/.openapi-generator/FILES @@ -0,0 +1,4 @@ +Apis/DefaultApi.md +Models/Label.md +Models/Target.md +README.md diff --git a/docs/content/docs/user-guide/rest-api/.openapi-generator/VERSION b/docs/content/docs/user-guide/rest-api/.openapi-generator/VERSION new file mode 100644 index 0000000..ca7bf6e --- /dev/null +++ b/docs/content/docs/user-guide/rest-api/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.23.0-SNAPSHOT diff --git a/docs/content/docs/user-guide/rest-api/Apis/DefaultApi.md b/docs/content/docs/user-guide/rest-api/Apis/DefaultApi.md new file mode 100644 index 0000000..a69288c --- /dev/null +++ b/docs/content/docs/user-guide/rest-api/Apis/DefaultApi.md @@ -0,0 +1,57 @@ +# DefaultApi + +All URIs are relative to *http://localhost* + +| Method | HTTP request | Description | +|------------- | ------------- | -------------| +| [**applyTargets**](DefaultApi.md#applyTargets) | **POST** /api/v1/:namespace/target-source/:name/applyTargets | Targets received are applied in gNMIc Operator. | +| [**getClusterPlan**](DefaultApi.md#getClusterPlan) | **GET** /clusters/:namespace/:name/plan | Get cluster plan. | + + + +# **applyTargets** +> List applyTargets(Target) + +Targets received are applied in gNMIc Operator. + +### Parameters + +|Name | Type | Description | Notes | +|------------- | ------------- | ------------- | -------------| +| **Target** | [**List**](../Models/Target.md)| | | + +### Return type + +[**List**](../Models/Target.md) + +### Authorization + +[bearerAuth](../README.md#bearerAuth) + +### HTTP request headers + +- **Content-Type**: application/json +- **Accept**: application/json + + +# **getClusterPlan** +> getClusterPlan() + +Get cluster plan. + +### Parameters +This endpoint does not need any parameter. + +### Return type + +null (empty response body) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: Not defined + diff --git a/docs/content/docs/user-guide/rest-api/Models/Label.md b/docs/content/docs/user-guide/rest-api/Models/Label.md new file mode 100644 index 0000000..7c5ad79 --- /dev/null +++ b/docs/content/docs/user-guide/rest-api/Models/Label.md @@ -0,0 +1,10 @@ +# Label +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **key** | **String** | key value, e.g. vendor | [optional] [default to null] | +| **value** | **String** | value, e.g. srlinux | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/docs/content/docs/user-guide/rest-api/Models/Target.md b/docs/content/docs/user-guide/rest-api/Models/Target.md new file mode 100644 index 0000000..7edb156 --- /dev/null +++ b/docs/content/docs/user-guide/rest-api/Models/Target.md @@ -0,0 +1,14 @@ +# Target +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **name** | **String** | Routername | [default to null] | +| **ip** | **String** | IPv4 or IPv6 | [default to null] | +| **port** | **Integer** | gNMIc port | [optional] [default to null] | +| **targetProfile** | **String** | TargetProfile applied to specific router | [optional] [default to null] | +| **labels** | [**List**](Label.md) | Input of labels through key:value pair | [optional] [default to null] | +| **operation** | **String** | Either created, updated or deleted. created and updated internally is the same operation (apply) | [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/docs/content/docs/user-guide/rest-api/README.md b/docs/content/docs/user-guide/rest-api/README.md new file mode 100644 index 0000000..1867b0a --- /dev/null +++ b/docs/content/docs/user-guide/rest-api/README.md @@ -0,0 +1,28 @@ +# Documentation for gNMIc Operator REST API + + +## Documentation for API Endpoints + +All URIs are relative to *http://localhost* + +| Class | Method | HTTP request | Description | +|------------ | ------------- | ------------- | -------------| +| *DefaultApi* | [**applyTargets**](Apis/DefaultApi.md#applyTargets) | **POST** /api/v1/:namespace/target-source/:name/applyTargets | Targets received are applied in gNMIc Operator. | +*DefaultApi* | [**getClusterPlan**](Apis/DefaultApi.md#getClusterPlan) | **GET** /clusters/:namespace/:name/plan | Get cluster plan. | + + + +## Documentation for Models + + - [Label](./Models/Label.md) + - [Target](./Models/Target.md) + + + +## Documentation for Authorization + + +### bearerAuth + +- **Type**: HTTP Bearer Token authentication + diff --git a/go.mod b/go.mod index 9dc2b78..5100842 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.5 require ( github.com/cert-manager/cert-manager v1.19.3 + github.com/getkin/kin-openapi v0.133.0 github.com/go-logr/logr v1.4.3 github.com/google/uuid v1.6.0 github.com/onsi/ginkgo/v2 v2.28.3 @@ -18,9 +19,49 @@ require ( sigs.k8s.io/controller-runtime v0.22.4 ) +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.6.0 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/speakeasy-api/jsonpath v0.6.0 // indirect + github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + 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 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 @@ -28,6 +69,7 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gin-gonic/gin v1.12.0 github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/jsonreference v0.21.4 // indirect @@ -83,6 +125,8 @@ require ( sigs.k8s.io/gateway-api v1.4.1 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) + +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen diff --git a/go.sum b/go.sum index 45485f1..9ebcdc7 100644 --- a/go.sum +++ b/go.sum @@ -2,26 +2,55 @@ 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/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cert-manager/cert-manager v1.19.3 h1:3d0Nk/HO3BOmAdBJNaBh+6YgaO3Ciey3xCpOjiX5Obs= github.com/cert-manager/cert-manager v1.19.3/go.mod h1:e9NzLtOKxTw7y99qLyWGmPo6mrC1Nh0EKKcMkRfK+GE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -68,39 +97,81 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxE github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -109,14 +180,40 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/oapi-codegen/v2 v2.6.0 h1:4i+F2cvwBFZeplxCssNdLy3MhNzUD87mI3HnayHZkAU= +github.com/oapi-codegen/oapi-codegen/v2 v2.6.0/go.mod h1:eWHeJSohQJIINJZzzQriVynfGsnlQVh0UkN2UYYcw4Q= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= github.com/openconfig/gnmic/pkg/api v0.1.10 h1:zU57bogHrnraDFCYDnxHZB8Hcd53bWx1fDkRTPw/R2w= github.com/openconfig/gnmic/pkg/api v0.1.10/go.mod h1:6PntONfjCMq3XzsDfWMkLeoVuBRbkm2foQO5m6PeYo0= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -130,14 +227,32 @@ github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+L github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8= +github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= +github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= +github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -148,8 +263,19 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= @@ -164,6 +290,8 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6 go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= @@ -172,24 +300,70 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= @@ -198,17 +372,35 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= @@ -233,7 +425,7 @@ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5E sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index b85e661..314c565 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -41,6 +41,15 @@ spec: {{- if .Values.api.port }} - --api-bind-address=:{{ .Values.api.port }} {{- end }} + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: CLUSTER_NAME + valueFrom: + fieldRef: + fieldPath: metadata.labels['app.kubernetes.io/name'] ports: {{- if .Values.webhook.enabled }} - name: webhook diff --git a/internal/apiserver/apiserver.go b/internal/apiserver/apiserver.go index 5eb88b8..9797a79 100644 --- a/internal/apiserver/apiserver.go +++ b/internal/apiserver/apiserver.go @@ -1,50 +1,138 @@ package apiserver +//go:generate go tool oapi-codegen -config cfg.yaml openapi.yaml +// To generate code, install openapi-codegen from https://github.com/oapi-codegen/oapi-codegen) +// Then use: go generate ./internal/apiserver +// +// kubectl port-forward -n gnmic-system svc/gnmic-controller-manager-api 8082:8082 --address=0.0.0.0 + +// docker run --rm -v ${PWD}:/local openapitools/openapi-generator-cli generate -i /local/internal/apiserver/openapi.yaml -g markdown -o /local/docs/content/docs/user-guide/rest-api + import ( - "encoding/json" + "context" + "fmt" "net/http" + "github.com/gin-gonic/gin" "github.com/gnmic/operator/internal/controller" "github.com/gnmic/operator/internal/controller/discovery" "github.com/gnmic/operator/internal/controller/discovery/core" + "github.com/gnmic/operator/internal/controller/discovery/loaders/utils" + "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/log" ) type APIServer struct { Server *http.Server + router *gin.Engine clusterReconciler *controller.ClusterReconciler + DiscoveryRegistry *discovery.Registry[ + types.NamespacedName, + core.DiscoveryRegistryValue, + ] + chunzSize int + logger logr.Logger + bearerToken bool +} - DiscoveryRegistry *discovery.Registry[types.NamespacedName, core.DiscoveryRegistryValue] +type urlStruct struct { + Namespace string `uri:"namespace" binding:"required"` + Name string `uri:"name" binding:"required"` } -func New(addr string, clusterReconciler *controller.ClusterReconciler) *APIServer { - mux := http.NewServeMux() +func New( + addr string, + clusterReconciler *controller.ClusterReconciler, + discoveryRegistry *discovery.Registry[ + types.NamespacedName, + core.DiscoveryRegistryValue, + ], + discoveryChunksize int, + bearerToken string, +) (*APIServer, error) { + // router := gin.New() + // router.Use(gin.Recovery()) + // gin.SetMode(gin.ReleaseMode) + router := gin.Default() + + logger := log.Log.WithValues("component", "api-server") a := &APIServer{ Server: &http.Server{ Addr: addr, - Handler: mux, + Handler: router, }, + router: router, clusterReconciler: clusterReconciler, + DiscoveryRegistry: discoveryRegistry, + chunzSize: discoveryChunksize, + logger: logger, } - a.routes(mux) - return a + RegisterHandlers(router, a) + logger.Info("API server initialized", "addr", addr, "chunkSize", discoveryChunksize) + return a, nil } -func (a *APIServer) routes(mux *http.ServeMux) { - mux.HandleFunc("GET /clusters/{namespace}/{name}/plan", a.getClusterPlan) +func (a *APIServer) Router() *gin.Engine { + return a.router } -func (a *APIServer) getClusterPlan(w http.ResponseWriter, r *http.Request) { - namespace, name := r.PathValue("namespace"), r.PathValue("name") - plan, err := a.clusterReconciler.GetClusterPlan(namespace, name) +// GetClusterPlan returns cluster plan +func (a *APIServer) GetClusterPlan(c *gin.Context) { + url := parseURI(c) + logger := log.FromContext(c.Request.Context()).WithValues( + "component", "apiserver", + "namespace", url.Namespace, + "cluster", url.Name, + ) + logger.Info("Received GET request for GetClusterPlan") + + plan, err := a.clusterReconciler.GetClusterPlan(url.Namespace, url.Name) if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) + logger.Error(err, "Failed to get cluster plan") + c.String(404, err.Error()) return } - w.Header().Set("Content-Type", "application/json") - err = json.NewEncoder(w).Encode(plan) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + c.JSON(200, plan) +} + +// CreateTargets binds payload to payloadTargets struct defined in openapi contract. Creates a []core.DiscoveryEvent sends it to the core package. +func (a *APIServer) ApplyTargets(c *gin.Context) { + url := parseURI(c) + logger := log.FromContext(c.Request.Context()).WithValues( + "component", "apiserver", + "namespace", url.Namespace, + "targetsource", url.Name, + ) + logger.Info("Received POST request for CreateTargets") + + if !a.verifyBearerToken(c, a.clusterReconciler) { + logger.Info("Unauthorized request for CreateTargets") return } + + var payloadTargets Targets + if err := c.ShouldBind(&payloadTargets); err != nil { + logger.Error(err, "Failed to bind request payload") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + registry, ok := a.DiscoveryRegistry.Get(getKey(url)) + if !ok { + err := fmt.Errorf("targetSource %s/%s does not exist", url.Namespace, url.Name) + logger.Error(err, "TargetSource lookup failed") + c.JSON(http.StatusBadRequest, gin.H{"error": "TargetSource " + url.Namespace + " / " + url.Name + " does not exist"}) + return + } + // WROMA: both of these things are not relevant here, but instead in utils.send. TODO, check with Daniel if and how this can be implemented + // make sure channel is not closed if targetsource in registry is deleted -> + // timeout for sending to the channel + targets, err := createDiscoveryEvent(payloadTargets) + if err != nil { + logger.Error(err, "failed creating discoveryEvent") + c.JSON(http.StatusBadRequest, gin.H{"error": err}) + } + utils.SendEvents(context.Background(), registry.Channel, targets, a.chunzSize) + c.JSON(http.StatusOK, payloadTargets) } diff --git a/internal/apiserver/auth.go b/internal/apiserver/auth.go new file mode 100644 index 0000000..ebdb4c4 --- /dev/null +++ b/internal/apiserver/auth.go @@ -0,0 +1,134 @@ +package apiserver + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/gnmic/operator/internal/controller" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + apiAuthSecretName = "gnmic-api-auth" + apiAuthSecretKey = "bearer-token" +) + +// InitializeBearerToken creates a new bearer token in form of a Kubernetes secret, only if it doesn't exist yet. +func (a *APIServer) InitializeBearerToken(ctx context.Context) error { + if bearerTokenExists(a.clusterReconciler) { + return nil + } + err := createBearerToken(ctx, a.clusterReconciler) + if err != nil { + return err + } + return nil +} + +// createBearerToken creates a new Opaque kubernetes secret +func createBearerToken(ctx context.Context, clusterReconciler *controller.ClusterReconciler) error { + logger := log.FromContext(ctx).WithValues("component", "apiserver") + namespace := strings.TrimSpace(os.Getenv("POD_NAMESPACE")) + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + token, err := getStringForBearerToken() + if err != nil { + return err + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: apiAuthSecretName, + Namespace: namespace, + }, + Type: corev1.SecretTypeOpaque, + StringData: map[string]string{ + apiAuthSecretKey: token, + }, + } + + if err := clusterReconciler.Create(ctx, secret); err != nil { + if !apierrors.IsAlreadyExists(err) { + return fmt.Errorf("failed to create secret %s/%s: %w", namespace, apiAuthSecretName, err) + } + } + logger.Info( + "Created kubernetes auth secret", + "secret", fmt.Sprintf("%s/%s", namespace, apiAuthSecretName), + "key", apiAuthSecretKey, + "namespace", namespace, + ) + return nil +} + +// getStringForBearerToken returns a base64 encoded string used for the bearer token. +func getStringForBearerToken() (string, error) { + b := make([]byte, 48) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate bearer token: %w", err) + } + return base64.StdEncoding.EncodeToString(b), nil +} + +// verifyBearerToken verifies bearer token from authorization header with value stored in kubernetes secret. +func (a *APIServer) verifyBearerToken(ctx *gin.Context, clusterReconciler *controller.ClusterReconciler) bool { + const bearerPrefix = "Bearer " + authHeader := strings.TrimSpace(ctx.GetHeader("Authorization")) + if !strings.HasPrefix(authHeader, bearerPrefix) { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or invalid authorization header"}) + return false + } + + tokenSecret, err := getBearerToken(clusterReconciler) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, err) + return false + } + + tokenHeader := strings.TrimSpace(strings.TrimPrefix(authHeader, bearerPrefix)) + if subtle.ConstantTimeCompare([]byte(tokenHeader), tokenSecret) != 1 { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid bearer token"}) + return false + } + return true +} + +// bearerTokenExists returns true if the bearerToken exists and false if it doesn't. +func bearerTokenExists(clusterReconciler *controller.ClusterReconciler) bool { + _, err := getBearerToken(clusterReconciler) + if err != nil { + return false + } + return true +} + +// getBearerToken returns bearer token stored as kubernetes secret. +func getBearerToken(clusterReconciler *controller.ClusterReconciler) ([]byte, error) { + namespace := strings.TrimSpace(os.Getenv("POD_NAMESPACE")) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + var secret corev1.Secret + if err := clusterReconciler.Get(ctx, types.NamespacedName{Name: apiAuthSecretName, Namespace: namespace}, &secret); err != nil { + return nil, fmt.Errorf("failed to get secret %s/%s: %w", namespace, apiAuthSecretName, err) + } + token, ok := secret.Data[apiAuthSecretKey] + if !ok { + return nil, fmt.Errorf("secret %s/%s does not contain key %q", namespace, apiAuthSecretName, apiAuthSecretKey) + } + // kubectl get secret -n gnmic-system gnmic-api-auth -o jsonpath="{.data.bearer-token}" | base64 --decode + return token, nil +} diff --git a/internal/apiserver/auth_test.go b/internal/apiserver/auth_test.go new file mode 100644 index 0000000..1c7ecf6 --- /dev/null +++ b/internal/apiserver/auth_test.go @@ -0,0 +1,10 @@ +package apiserver + +import "testing" + +func TestGenereateBearerToken(t *testing.T) { + _, err := getStringForBearerToken() + if err != nil { + t.Errorf("generateBearerToken returns err: %s", err) + } +} diff --git a/internal/apiserver/cfg.yaml b/internal/apiserver/cfg.yaml new file mode 100644 index 0000000..4bc7f02 --- /dev/null +++ b/internal/apiserver/cfg.yaml @@ -0,0 +1,6 @@ +package: apiserver +output: gen.go +generate: + gin-server: true + models: true + embedded-spec: true \ No newline at end of file diff --git a/internal/apiserver/gen.go b/internal/apiserver/gen.go new file mode 100644 index 0000000..231e293 --- /dev/null +++ b/internal/apiserver/gen.go @@ -0,0 +1,232 @@ +// Package apiserver provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.6.0 DO NOT EDIT. +package apiserver + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "fmt" + "net/url" + "path" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/gin-gonic/gin" +) + +const ( + BearerAuthScopes = "bearerAuth.Scopes" +) + +// Defines values for TargetOperation. +const ( + Created TargetOperation = "created" + Deleted TargetOperation = "deleted" + Updated TargetOperation = "updated" +) + +// Valid indicates whether the value is a known member of the TargetOperation enum. +func (e TargetOperation) Valid() bool { + switch e { + case Created: + return true + case Deleted: + return true + case Updated: + return true + default: + return false + } +} + +// Label defines model for Label. +type Label struct { + Key *string `json:"key,omitempty"` + Value *string `json:"value,omitempty"` +} + +// Target defines model for Target. +type Target struct { + Ip string `json:"ip"` + Labels *[]Label `json:"labels,omitempty"` + Name string `json:"name"` + Operation TargetOperation `json:"operation"` + Port *int `json:"port,omitempty"` + TargetProfile *string `json:"targetProfile,omitempty"` +} + +// TargetOperation defines model for Target.Operation. +type TargetOperation string + +// Targets defines model for Targets. +type Targets = []Target + +// ApplyTargetsJSONRequestBody defines body for ApplyTargets for application/json ContentType. +type ApplyTargetsJSONRequestBody = Targets + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Create targets in the gNMIc Operator + // (POST /api/v1/:namespace/target-source/:name/applyTargets) + ApplyTargets(c *gin.Context) + // Get cluster plan + // (GET /clusters/:namespace/:name/plan) + GetClusterPlan(c *gin.Context) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// ApplyTargets operation middleware +func (siw *ServerInterfaceWrapper) ApplyTargets(c *gin.Context) { + + c.Set(BearerAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.ApplyTargets(c) +} + +// GetClusterPlan operation middleware +func (siw *ServerInterfaceWrapper) GetClusterPlan(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetClusterPlan(c) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.POST(options.BaseURL+"/api/v1/:namespace/target-source/:name/applyTargets", wrapper.ApplyTargets) + router.GET(options.BaseURL+"/clusters/:namespace/:name/plan", wrapper.GetClusterPlan) +} + +// Base64 encoded, gzipped, json marshaled Swagger object +var swaggerSpec = []string{ + + "H4sIAAAAAAAC/7SUTW/bPAzHv4rA5zl6cdrt5FtWDEWBvRRrb0UOqswkam1JI6kARuHvPkhyUwdJgV52", + "CiPx5c8fKb+A8X3wDp0wNC/AZoe9zuZ3/YhdMgL5gCQW8/EzDulHhoDQAAtZt4Wxgr3uIp65GavXE//4", + "hEaS772mLcppbhvOpu6SkuIg2Gfjf8INNPBf/Sa/nrTXRfhbXU2kh/Tf6R7PFkgatFjv0i262EPzAIZQ", + "C7ZQQQztZLXYYbLW1WmS4Elm2a0T3CJlHbndW/Ib272DiPBPtIRtKpxlVgnGXNn6XY4fJzNxP0EzVsBo", + "IlkZ7pJrmcYjakJaRdkdNiPFlGM45NiJBBhTDus2PrdnJfUJ258/boz6lVvwpH5/u7tXq9sbqGCPxBk3", + "LBfLxcU0A6eDhQY+Ly4XS6ggaNllIbUOtt5f1E0iw0EbrAvST+wjGSwXtQ6hG2ZIguc8kAPDmxYaWM29", + "Cnhk+erbvNbGO0GXw1I6a3Jg/cRlNwrIj2HmAuVtskIR8wEH77gwvlxe/JuyLbIhG8pSvy6KysmxVRyN", + "QeZN7Lr8Mr4UGcdBq+yjxD+jU5ZVb5mt2ypPyrq97mx7tDnQPBzvzMN6XFfAse81DdDAVX5QSiYt1inZ", + "oTpekpyxNl1kQeL5xMuMQ6czkenzcTzaa5SrEnmb3E5QL097nPkrQonkcOrqIPsaRU2CVC4/juP4NwAA", + "//9oPXG9OAUAAA==", +} + +// GetSwagger returns the content of the embedded swagger specification file +// or error if failed to decode +func decodeSpec() ([]byte, error) { + zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, "")) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %w", err) + } + zr, err := gzip.NewReader(bytes.NewReader(zipped)) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + var buf bytes.Buffer + _, err = buf.ReadFrom(zr) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + + return buf.Bytes(), nil +} + +var rawSpec = decodeSpecCached() + +// a naive cached of a decoded swagger spec +func decodeSpecCached() func() ([]byte, error) { + data, err := decodeSpec() + return func() ([]byte, error) { + return data, err + } +} + +// Constructs a synthetic filesystem for resolving external references when loading openapi specifications. +func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { + res := make(map[string]func() ([]byte, error)) + if len(pathToFile) > 0 { + res[pathToFile] = rawSpec + } + + return res +} + +// GetSwagger returns the Swagger specification corresponding to the generated code +// in this file. The external references of Swagger specification are resolved. +// The logic of resolving external references is tightly connected to "import-mapping" feature. +// Externally referenced files must be embedded in the corresponding golang packages. +// Urls can be supported but this task was out of the scope. +func GetSwagger() (swagger *openapi3.T, err error) { + resolvePath := PathToRawSpec("") + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { + pathToFile := url.String() + pathToFile = path.Clean(pathToFile) + getSpec, ok := resolvePath[pathToFile] + if !ok { + err1 := fmt.Errorf("path not found: %s", pathToFile) + return nil, err1 + } + return getSpec() + } + var specData []byte + specData, err = rawSpec() + if err != nil { + return + } + swagger, err = loader.LoadFromData(specData) + if err != nil { + return + } + return +} diff --git a/internal/apiserver/helpers.go b/internal/apiserver/helpers.go new file mode 100644 index 0000000..6caa2bb --- /dev/null +++ b/internal/apiserver/helpers.go @@ -0,0 +1,112 @@ +package apiserver + +import ( + "fmt" + "net" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gnmic/operator/internal/controller/discovery/core" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// createDiscoveryEvent creates object of type core.DiscoveryEvent +func createDiscoveryEvent(payloadTargets []Target) ([]core.DiscoveryEvent, error) { + targets := []core.DiscoveryEvent{} + + if len(payloadTargets) > 0 { + for i, target := range payloadTargets { + if target.Name == "" { + return nil, fmt.Errorf("Target receieved at index %d by pull interface has no Name.", i) + } + if target.Ip == "" { + return nil, fmt.Errorf("Target receieved at index %d by pull interface has no Ip.", i) + } + event, err := getEvent(target, i) + if err != nil { + return nil, err + } + + if err != nil { + return nil, err + } + + targets = append(targets, core.DiscoveryEvent{ + Target: core.DiscoveredTarget{ + Name: target.Name, + IP: target.Ip, + Port: int32(*target.Port), + Labels: convertTargetLabelsToMap(target), + TargetProfile: *target.TargetProfile, + }, + Event: event, + }) + } + } + return targets, nil +} + +// validateAddress +func validateAddress(address string) (string, error) { + address, port, err := net.SplitHostPort(address) + if err != nil { + return "", err + } + if port == "" { + port = "57400" + } + return address + ":" + port, nil +} + +// getKey returns key for used to identify correct channel in DiscoveryRegistry +func getKey(u urlStruct) types.NamespacedName { + key := types.NamespacedName{ + Namespace: u.Namespace, + Name: u.Name, + } + // or kubectl get secret -n gnmic-system gnmic-api-auth -o jsonpath="{.data.bearer-token}" | base64 --decode + return key +} + +// convertTargetLabelsToMap converts target.Labels to map. +func convertTargetLabelsToMap(target Target) map[string]string { + labelToMap := make(map[string]string) + if target.Labels != nil { + for _, tag := range *target.Labels { + if tag.Key == nil || tag.Value == nil || *tag.Key == "" { + continue + } + labelToMap[*tag.Key] = *tag.Value + } + } + return labelToMap +} + +// getEvent converts target.Operation to core.Operation. +func getEvent(target Target, index int) (core.EventAction, error) { + event := core.EventApply + switch target.Operation { + case Created: + event = core.EventApply + case Updated: + event = core.EventApply + case Deleted: + event = core.EventDelete + default: + return event, fmt.Errorf("Target receieved at index %d by pull interface has no valid Operation", index) + } + return event, nil +} + +// parseURI parses URI to urlStruct. +func parseURI(c *gin.Context) (url urlStruct) { + logger := log.FromContext(c.Request.Context()).WithValues("component", "apiserver", "action", "parse-uri") + var u urlStruct + if err := c.ShouldBindUri(&u); err != nil { + logger.Error(err, "Failed to bind request URI") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + return u +} diff --git a/internal/apiserver/helpers_test.go b/internal/apiserver/helpers_test.go new file mode 100644 index 0000000..bc952a2 --- /dev/null +++ b/internal/apiserver/helpers_test.go @@ -0,0 +1,322 @@ +package apiserver + +import ( + "reflect" + "testing" + + "net/http" + "net/http/httptest" + + "github.com/gin-gonic/gin" + "github.com/gnmic/operator/internal/controller/discovery/core" + "k8s.io/apimachinery/pkg/types" +) + +func TestGetEventApply(t *testing.T) { + port := 22 + target := Target{ + Ip: "1.1.1.1", + Port: &port, + Name: "routername", + Labels: &[]Label{}, + Operation: "created", + } + event, err := getEvent(target, 0) + if event != core.EventApply { + t.Errorf("getEvent(target) = %d, want core.EventApply", event) + } + if err != nil { + t.Errorf("getEvent(target) returns err: %s", err) + } +} + +func TestGetEventDelete(t *testing.T) { + port := 22 + target := Target{ + Ip: "1.1.1.1", + Port: &port, + Name: "routername", + Labels: &[]Label{}, + Operation: "deleted", + } + event, err := getEvent(target, 0) + if event != core.EventDelete { + t.Errorf("getEvent(target) = %d, want core.EventDelete", event) + } + if err != nil { + t.Errorf("getEvent(target) returns err: %s", err) + } +} + +func TestGetEventEmptyOperation(t *testing.T) { + port := 22 + target := Target{ + Ip: "1.1.1.1", + Port: &port, + Name: "routername", + Labels: &[]Label{}, + Operation: "", + } + event, err := getEvent(target, 0) + if err == nil { + t.Errorf("getEvent(target, 0) = %d, want error", event) + } +} + +func TestGetEventUpdate(t *testing.T) { + port := 22 + target := Target{ + Ip: "1.1.1.1", + Port: &port, + Name: "routername", + Labels: &[]Label{}, + Operation: "updated", + } + event, err := getEvent(target, 0) + if event != core.EventApply { + t.Errorf("getEvent(target) = %d, want core.EventApply", event) + } + if err != nil { + t.Errorf("getEvent(target) returns err: %s", err) + } +} + +func TestGetKey(t *testing.T) { + u := urlStruct{ + Namespace: "default", + Name: "http-discovery", + } + expected := types.NamespacedName{ + Namespace: "default", + Name: "http-discovery", + } + result := getKey(u) + if result != expected { + t.Errorf("getKey(%v) = %v; want %v", u, result, expected) + } +} + +func TestConvertTargetLabelsToMapEmpty(t *testing.T) { + target := Target{} + result := convertTargetLabelsToMap(target) + if len(result) != 0 { + t.Errorf("convertTargetLabelsToMap(target) = %v; want empty map", result) + } +} + +func TestConvertTargetLabelsToMap(t *testing.T) { + key := "Tag" + value := "TT1, TT2" + label := Label{ + Key: &key, + Value: &value, + } + target := Target{ + Labels: &[]Label{label}, + } + expected := map[string]string{ + "Tag": "TT1, TT2", + } + result := convertTargetLabelsToMap(target) + if !reflect.DeepEqual(result, expected) { + t.Errorf("convertTargetLabelsToMap(target) = %v; want %v", result, expected) + } +} + +func TestConvertTargetLabelsToMapEmptyKey(t *testing.T) { + key := "" + value := "TT1, TT2" + label := Label{ + Key: &key, + Value: &value, + } + target := Target{ + Labels: &[]Label{label}, + } + result := convertTargetLabelsToMap(target) + if len(result) != 0 { + t.Errorf("convertTargetLabelsToMap(target) = %v; want empty map", result) + } +} + +func TestConvertTargetLabelsToMapTwoEntries(t *testing.T) { + key := "Tag" + key2 := "Tag1" + value := "TT1, TT2" + value2 := "TT1" + label := Label{ + Key: &key, + Value: &value, + } + label2 := Label{ + Key: &key2, + Value: &value2, + } + target := Target{ + Labels: &[]Label{label, label2}, + } + expected := map[string]string{ + "Tag": "TT1, TT2", + "Tag1": "TT1", + } + result := convertTargetLabelsToMap(target) + if !reflect.DeepEqual(result, expected) { + t.Errorf("convertTargetLabelsToMap(target) = %v; want %v", result, expected) + } +} + +func TestCreateDiscoveryEvent(t *testing.T) { + port := 22 + targetprofile := "" + targets := []Target{{ + Name: "router1", + Ip: "1.1.1.1", + Port: &port, + Labels: &[]Label{}, + TargetProfile: &targetprofile, + Operation: "updated"}} + + expected := []core.DiscoveryEvent{ + { + Target: core.DiscoveredTarget{ + Name: "router1", + IP: "1.1.1.1", + Port: 22, + Labels: map[string]string{}, + TargetProfile: "", + }, + Event: core.EventApply, + }, + } + result, _ := createDiscoveryEvent(targets) + if !reflect.DeepEqual(result, expected) { + t.Errorf("createDiscoveryEvent(targets) = %v; want %v", result, expected) + } +} + +func TestCreateDiscoveryEventEmptyName(t *testing.T) { + port := 22 + targets := []Target{{ + Ip: "1.1.1.1", + Port: &port, + Labels: &[]Label{}, + Operation: "updated"}} + + result, err := createDiscoveryEvent(targets) + if err == nil { + t.Errorf("createDiscoveryEvent(targets) returns %v, want missing name error", result) + } +} + +func TestCreateDiscoveryEventEmptyIP(t *testing.T) { + port := 22 + targets := []Target{{ + Ip: "", + Port: &port, + Name: "routername", + Labels: &[]Label{}, + Operation: "updated"}} + + result, err := createDiscoveryEvent(targets) + if err == nil { + t.Errorf("createDiscoveryEvent(targets) returns %v, want missing address error", result) + } +} + +func TestCreateDiscoveryEventWrongEvent(t *testing.T) { + port := 22 + targets := []Target{{ + Ip: "1.1.1.1", + Port: &port, + Name: "", + Labels: &[]Label{}, + Operation: "upWROOONGdated"}} + + result, err := createDiscoveryEvent(targets) + if err == nil { + t.Errorf("createDiscoveryEvent(targets) returns %v, want wrong Operation error", result) + } +} + +func TestParseURI(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + router := gin.New() + var result urlStruct + router.POST("/api/v1/:namespace/target-source/:name/createTargets", func(ctx *gin.Context) { + result = parseURI(ctx) + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/default/target-source/http-discovery/createTargets", nil) + router.ServeHTTP(recorder, req) + + expected := urlStruct{ + Namespace: "default", + Name: "http-discovery", + } + + if !reflect.DeepEqual(result, expected) { + t.Errorf("parseURI(ctx) = %v; want %v", result, expected) + } + if recorder.Code != http.StatusOK { + t.Errorf("parseURI(ctx) status code = %d; want %d", recorder.Code, http.StatusOK) + } +} + +func TestParseURIMissingName(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + router := gin.New() + var result urlStruct + router.POST("/api/v1/:namespace/target-source/:name/createTargets", func(ctx *gin.Context) { + result = parseURI(ctx) + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/default/target-source//createTargets", nil) + router.ServeHTTP(recorder, req) + + if !reflect.DeepEqual(result, urlStruct{}) { + t.Errorf("parseURI(ctx) = %v; want empty urlStruct", result) + } + if recorder.Code != http.StatusBadRequest { + t.Errorf("parseURI(ctx) status code = %d; want %d", recorder.Code, http.StatusBadRequest) + } +} + +func TestVerifyAddress(t *testing.T) { + address := "10.10.10.10:57400" + expected := "10.10.10.10:57400" + convertedAddress, _ := validateAddress(address) + if !reflect.DeepEqual(convertedAddress, expected) { + t.Errorf("addDefaultPortIfEmpty(address) = %s; want %s", convertedAddress, expected) + } +} + +func TestVerifyAddressIPv6(t *testing.T) { + address := "[2345:0425:2CA1:0000:0000:0567:5673:23b5]:57400" + expected := "2345:0425:2CA1:0000:0000:0567:5673:23b5:57400" + convertedAddress, _ := validateAddress(address) + if !reflect.DeepEqual(convertedAddress, expected) { + t.Errorf("addDefaultPortIfEmpty(address) = %s; want %s", convertedAddress, expected) + } +} + +func TestVerifyAddressNoPort(t *testing.T) { + address := "10.10.10.10:" + expected := "10.10.10.10:57400" + convertedAddress, err := validateAddress(address) + if err != nil { + t.Errorf("addDefaultPortIfEmpty(address) threw unexpected error: %s", err) + } + if !reflect.DeepEqual(convertedAddress, expected) { + t.Errorf("addDefaultPortIfEmpty(address) = %s; want %s", convertedAddress, expected) + } +} + +func TestVerifyWrongAddressFormat(t *testing.T) { + address := "10.10.10.10" + result, err := validateAddress(address) + if err == nil { + t.Errorf("TestVerifyWrongAddressFormat expected error due to wrong address format(missing port), instead got: %s", result) + } +} diff --git a/internal/apiserver/openapi.yaml b/internal/apiserver/openapi.yaml new file mode 100644 index 0000000..2612707 --- /dev/null +++ b/internal/apiserver/openapi.yaml @@ -0,0 +1,86 @@ +openapi: 3.0.3 +info: + title: "gNMIc Operator REST API" + version: "0.0.1" +paths: + /clusters/:namespace/:name/plan: + get: + summary: "Get cluster plan." + operationId: "getClusterPlan" + responses: + '200': + description: "ClusterPlan returned" + /api/v1/:namespace/target-source/:name/applyTargets: + post: + summary: "Targets received are applied in gNMIc Operator." + operationId: "applyTargets" + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Targets' + responses: + '201': + description: "Targets applied successfully" + content: + application/json: + schema: + $ref: '#/components/schemas/Targets' + '401': + description: Access token is missing or invalid + +components: + schemas: + Targets: + type: array + items: + $ref: '#/components/schemas/Target' + Label: + description: Label must be passed as key:value pair. Multiple values per key possible + type: object + properties: + key: + description: key value, e.g. vendor + type: string + value: + type: string + description: value, e.g. srlinux + Target: + type: object + required: + - name + - ip + - operation + properties: + name: + type: string + description: Routername + ip: + type: string + description: IPv4 or IPv6 + port: + type: integer + description: gNMIc port + targetProfile: + type: string + description: TargetProfile applied to specific router + labels: + type: array + description: Input of labels through key:value pair + items: + $ref: '#/components/schemas/Label' + operation: + type: string + enum: + - created + - updated + - deleted + description: Either created, updated or deleted. created and updated internally is the same operation (apply) + securitySchemes: + bearerAuth: + type: http + scheme: bearer + \ No newline at end of file diff --git a/internal/apiserver/temp.md b/internal/apiserver/temp.md new file mode 100644 index 0000000..6d8996b --- /dev/null +++ b/internal/apiserver/temp.md @@ -0,0 +1,15 @@ +curl -X POST "http://localhost:8082/api/v1/default/target-source/targetsource-1/applyTargets" \ + -H "Authorization: Bearer I+MieBB72PAD5Cu8y4iOc75q+xYiE8WhXjFA8K5Xm/4DtjA6GJufQisZuM7JIWQS" \ + -H "Content-Type: application/json" \ + -d '[ + { + "ip": "1.1.1.1", + "port": 22, + "name": "Router1", + "operation": "created", + "targetProfile": "defaultProfile", + "labels": [ + { "key": "tags", "value": "tag1, tag2" } + ] + } + ]' diff --git a/internal/controller/discovery/client.go b/internal/controller/discovery/client.go index cb02161..45798ed 100644 --- a/internal/controller/discovery/client.go +++ b/internal/controller/discovery/client.go @@ -2,18 +2,21 @@ package discovery import ( "context" + "fmt" + corev1 "k8s.io/api/core/v1" + 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" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" gnmicv1alpha1 "github.com/gnmic/operator/api/v1alpha1" ) -func fetchExistingTargets( - ctx context.Context, - c client.Client, - ts *gnmicv1alpha1.TargetSource, -) ([]gnmicv1alpha1.Target, error) { - +func fetchExistingTargets(ctx context.Context, c client.Client, ts *gnmicv1alpha1.TargetSource) ([]gnmicv1alpha1.Target, error) { var targetList gnmicv1alpha1.TargetList err := c.List( @@ -30,3 +33,100 @@ func fetchExistingTargets( return targetList.Items, nil } + +func applyTarget(ctx context.Context, c client.Client, s *runtime.Scheme, desired *gnmicv1alpha1.Target, ts *gnmicv1alpha1.TargetSource) error { + existing := &gnmicv1alpha1.Target{ + ObjectMeta: metav1.ObjectMeta{ + Name: desired.Name, + Namespace: desired.Namespace, + }, + } + + _, err := controllerutil.CreateOrUpdate(ctx, c, existing, func() error { + existing.Spec = desired.Spec + existing.Labels = desired.Labels + + return controllerutil.SetControllerReference(ts, existing, s) + }) + + return err +} + +func deleteTarget(ctx context.Context, c client.Client, name string, namespace string) error { + existing := &gnmicv1alpha1.Target{} + + err := c.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: namespace, + }, existing) + if apierrors.IsNotFound(err) { + return nil + } else if err != nil { + return err + } + + err = c.Delete(ctx, existing) + if apierrors.IsNotFound(err) { + return nil + } + + 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 +} + +// Helper: GetSecretValues returns values from a secret +// If keys are provided -> returns only those keys +// If keys is empty -> returns entire secret data +func GetSecretValues( + ctx context.Context, + c client.Client, + namespace string, + secretRef string, + keys ...string, +) (map[string]string, error) { + var secret corev1.Secret + if err := c.Get(ctx, + client.ObjectKey{ + Name: secretRef, + Namespace: namespace, + }, &secret); err != nil { + return nil, fmt.Errorf("failed to get secret %s/%s: %w", namespace, secretRef, err) + } + + result := make(map[string]string) + + // Return full secret + if len(keys) == 0 { + for k, v := range secret.Data { + result[k] = string(v) + } + return result, nil + } + + // Return specific keys + for _, key := range keys { + val, ok := secret.Data[key] + if !ok { + return nil, fmt.Errorf("key %s missing in secret %s/%s", key, namespace, secretRef) + } + result[key] = string(val) + } + + return result, nil +} diff --git a/internal/controller/discovery/const.go b/internal/controller/discovery/const.go index ac7a57f..8d37785 100644 --- a/internal/controller/discovery/const.go +++ b/internal/controller/discovery/const.go @@ -1,6 +1,13 @@ package discovery const ( - // Labels + // Kubernetes Side Labels LabelTargetSourceName = "operator.gnmic.dev/targetsource" ) + +const ( + // Prefix and Labels for external systems + ExternalLabelPrefix = "gnmic_operator_" + + ExternalLabelTargetProfile = ExternalLabelPrefix + "target_profile" +) diff --git a/internal/controller/discovery/core/loader_interface.go b/internal/controller/discovery/core/loader_interface.go index 895258a..97810cb 100644 --- a/internal/controller/discovery/core/loader_interface.go +++ b/internal/controller/discovery/core/loader_interface.go @@ -2,6 +2,8 @@ package core import ( "context" + + gnmicv1alpha1 "github.com/gnmic/operator/api/v1alpha1" ) // Loader defines a pluggable TargetSource loader interface @@ -12,5 +14,5 @@ type Loader interface { // Run begins discovery and pushes target snapshots or events into the out channel // The loader must stop cleanly when ctx is canceled - Run(ctx context.Context, out chan<- []DiscoveryMessage) error + Run(ctx context.Context, out chan<- []DiscoveryMessage, spec gnmicv1alpha1.TargetSourceSpec) error } diff --git a/internal/controller/discovery/core/types.go b/internal/controller/discovery/core/types.go index 99605b9..c09c0b8 100644 --- a/internal/controller/discovery/core/types.go +++ b/internal/controller/discovery/core/types.go @@ -3,6 +3,7 @@ package core import ( "context" + "github.com/gin-gonic/gin" "k8s.io/apimachinery/pkg/types" ) @@ -21,6 +22,7 @@ type DiscoveryRegistryValue struct { type CommonLoaderConfig struct { TargetsourceNN types.NamespacedName ChunkSize int + Router *gin.Engine AcceptPush bool } @@ -37,9 +39,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 - Labels map[string]string + Name string + IP string + Port int32 + Labels map[string]string + TargetProfile string } type DiscoveryEvent struct { @@ -47,6 +51,17 @@ type DiscoveryEvent struct { Event EventAction } +func (e EventAction) String() string { + switch e { + case EventDelete: + return "DELETE" + case EventApply: + return "APPLY" + default: + return "UNKNOWN" + } +} + type DiscoverySnapshot struct { SnapshotID string ChunkIndex int diff --git a/internal/controller/discovery/loaders.go b/internal/controller/discovery/loaders.go index c888c27..33e51e9 100644 --- a/internal/controller/discovery/loaders.go +++ b/internal/controller/discovery/loaders.go @@ -1,24 +1,170 @@ package discovery import ( + "context" "fmt" + "sigs.k8s.io/controller-runtime/pkg/client" + gnmicv1alpha1 "github.com/gnmic/operator/api/v1alpha1" "github.com/gnmic/operator/internal/controller/discovery/core" - http "github.com/gnmic/operator/internal/controller/discovery/loaders/http" + "github.com/gnmic/operator/internal/controller/discovery/loaders/http" ) // NewLoader creates a loader by name -func NewLoader(cfg *core.CommonLoaderConfig, spec gnmicv1alpha1.TargetSourceSpec) (core.Loader, error) { +func NewLoader(ctx context.Context, c client.Client, cfg *core.CommonLoaderConfig, spec gnmicv1alpha1.TargetSourceSpec) (core.Loader, error) { switch { 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) + httpSpec := *spec.Provider.HTTP + cfg.AcceptPush = httpSpec.AcceptPush + + // TODO: watch secrets -> if secret changes reconcile has to be executed + if httpSpec.Authorization != nil { + if err := resolveAuthorizationIntoSpec( + ctx, + c, + cfg.TargetsourceNN.Namespace, + httpSpec.Authorization, + ); err != nil { + return nil, err + } + } + if httpSpec.TLS != nil { + if err := resolveTLSIntoSpec( + ctx, + c, + cfg.TargetsourceNN.Namespace, + httpSpec.TLS, + ); err != nil { + return nil, err + } + } + + return http.New(*cfg, httpSpec), nil default: return nil, fmt.Errorf("unknown targetsource provider, check TargetSource CRD for %s", cfg.TargetsourceNN) } +} + +// resolveAuthorizationIntoSpec fetches credentials from Kubernetes Secrets +// and populates the AuthorizationSpec accordingly +func resolveAuthorizationIntoSpec( + ctx context.Context, + c client.Client, + namespace string, + authSpec *gnmicv1alpha1.AuthorizationSpec, +) error { + if authSpec == nil { + return nil + } + auth := authSpec + + switch { + case auth.Basic != nil: + b := auth.Basic + + if b.CredentialsSecretRef != nil { + values, err := GetSecretValues( + ctx, + c, + namespace, + b.CredentialsSecretRef.Name, + "username", + "password", + ) + if err != nil { + return err + } + b.Username = values["username"] + b.Password = values["password"] + } + + case auth.Token != nil: + t := auth.Token + if t.TokenSecretRef != nil { + key := "token" + if t.TokenSecretRef.Key != "" { + key = t.TokenSecretRef.Key + } + values, err := GetSecretValues( + ctx, + c, + namespace, + t.TokenSecretRef.Name, + key, + ) + if err != nil { + return err + } + t.Token = values[key] + } + + // case auth.JWT != nil: + // jwt := auth.JWT + // if jwt.TokenSecretRef != nil { + // values, err := GetSecretValues( + // ctx, + // c, + // namespaceName, + // jwt.TokenSecretRef.Name, + // "token", + // ) + // if err != nil { + // return err + // } + // jwt.Token = values[jwt.TokenSecretRef.Key] + // } + // if jwt.SigningKeySecretRef != nil { + // values, err := GetSecretValues( + // ctx, + // c, + // namespaceName, + // jwt.SigningKeySecretRef.Name, + // "key", + // ) + // if err != nil { + // return err + // } + // jwt.Key = values[jwt.SigningKeySecretRef.Key] + + // } + } + + return nil +} + +// resolveTLSIntoSpec fetches TLS credentials from Kubernetes Secrets +// and populates the ClientTLSConfig accordingly +func resolveTLSIntoSpec( + ctx context.Context, + c client.Client, + namespace string, + tlsSpec *gnmicv1alpha1.ClientTLSConfig, +) error { + if tlsSpec == nil { + return nil + } + tls := tlsSpec + + if tls.CABundleSecretRef != nil { + key := "ca.crt" + if tls.CABundleSecretRef.Key != "" { + key = tls.CABundleSecretRef.Key + } + values, err := GetSecretValues( + ctx, + c, + namespace, + tls.CABundleSecretRef.Name, + key, + ) + if err != nil { + return err + } + // convert string to []byte + tls.CABundle = []byte(values[key]) + } + return nil } diff --git a/internal/controller/discovery/loaders/http/auth.go b/internal/controller/discovery/loaders/http/auth.go new file mode 100644 index 0000000..0af0556 --- /dev/null +++ b/internal/controller/discovery/loaders/http/auth.go @@ -0,0 +1,38 @@ +package http + +import ( + "fmt" + "net/http" +) + +func (l *Loader) applyAuthorization(req *http.Request) { + auth := l.spec.Authorization + if auth == nil { + return + } + + switch { + case auth.Basic != nil: + req.SetBasicAuth( + auth.Basic.Username, + auth.Basic.Password, + ) + + case auth.Token != nil: + req.Header.Set( + "Authorization", + fmt.Sprintf("%s %s", + auth.Token.Scheme, + auth.Token.Token, + ), + ) + + // case auth.JWT != nil: + // if auth.JWT.Token != "" { + // req.Header.Set( + // "Authorization", + // fmt.Sprintf("Bearer %s", auth.JWT.Token), + // ) + // } + } +} diff --git a/internal/controller/discovery/loaders/http/loader.go b/internal/controller/discovery/loaders/http/loader.go index 3325adb..5f837dc 100644 --- a/internal/controller/discovery/loaders/http/loader.go +++ b/internal/controller/discovery/loaders/http/loader.go @@ -2,46 +2,112 @@ package http import ( "context" + "crypto/tls" + "crypto/x509" + "encoding/json" "fmt" + "net/http" "time" + "github.com/google/uuid" + "sigs.k8s.io/controller-runtime/pkg/log" + gnmicv1alpha1 "github.com/gnmic/operator/api/v1alpha1" "github.com/gnmic/operator/internal/controller/discovery/core" loaderUtils "github.com/gnmic/operator/internal/controller/discovery/loaders/utils" - "github.com/google/uuid" ) +// Loader implements the HTTP pull discovery mechanism +// It periodically polls an HTTP endpoint, extracts targets from the response, +// and emits discovery snapshots downstream type Loader struct { - commonCfg core.CommonLoaderConfig + loaderCfg core.CommonLoaderConfig + spec gnmicv1alpha1.HTTPConfig } -// New instantiates the http loader with the provided config -func New(cfg core.CommonLoaderConfig) core.Loader { - return &Loader{commonCfg: cfg} +// New creates a new HTTP loader instance with the provided configuration. +// The loader is stateless apart from its config and spec +func New(cfg core.CommonLoaderConfig, httpConfig gnmicv1alpha1.HTTPConfig) core.Loader { + return &Loader{loaderCfg: cfg, spec: httpConfig} } +// Name returns the loader's name, used for logging and metrics func (l *Loader) Name() string { return "http" } -func (l *Loader) Run(ctx context.Context, out chan<- []core.DiscoveryMessage) error { +// Run starts the HTTP discovery loop +// It performs an immediate fetch and then continues polling at a fixed interval +func (l *Loader) Run(ctx context.Context, out chan<- []core.DiscoveryMessage, spec gnmicv1alpha1.TargetSourceSpec) error { + if l.spec.URL == "" { + return nil + } + logger := log.FromContext(ctx).WithValues( "component", "loader", "name", l.Name(), - "targetsource", l.commonCfg.TargetsourceNN, + "targetsource", l.loaderCfg.TargetsourceNN, ) logger.Info( "HTTP loader started", - "targetsource", l.commonCfg.TargetsourceNN.Name, - "namespace", l.commonCfg.TargetsourceNN.Namespace, + "targetsource", l.loaderCfg.TargetsourceNN.Name, + "namespace", l.loaderCfg.TargetsourceNN.Namespace, ) - // Only for debugging: emit a static snapshot every 30 seconds - ticker := time.NewTicker(30 * time.Second) + logger.Info("HTTP loader started") + + client, err := l.buildHTTPClient() + if err != nil { + return fmt.Errorf("failed to build HTTP client: %w", err) + } + interval := l.spec.PollInterval.Duration + ticker := time.NewTicker(interval) defer ticker.Stop() + logger.Info( + "HTTP polling discovery started", + "interval", interval.String(), + "url", l.spec.URL, + ) + + // helper function to fetch targets and emit discovery messages + fetchAndEmit := func() { + // Fetch targets from HTTP endpoint + targets, err := l.fetchTargetsFromHTTPEndpoint(ctx, client) + if err != nil { + logger.Error( + err, + "Failed to fetch targets from HTTP endpoint", + "url", l.spec.URL, + ) + return + } + + // Emit discovery snapshot downstream + snapshotID := fmt.Sprintf("%s-%s-%s", l.loaderCfg.TargetsourceNN.Namespace, l.loaderCfg.TargetsourceNN.Name, uuid.NewString()) + if err := loaderUtils.SendSnapshot(ctx, out, targets, snapshotID, l.loaderCfg.ChunkSize); err != nil { + logger.Error( + err, + "Failed to send discovery snapshot", + "snapshotID", snapshotID, + "targets", len(targets), + ) + return + } + + logger.Info( + "Discovery snapshot sent", + "snapshotID", snapshotID, + "targets", len(targets), + ) + } + + // Immediate fetch on startup + fetchAndEmit() + + // Periodic fetch for { select { case <-ctx.Done(): @@ -49,24 +115,139 @@ func (l *Loader) Run(ctx context.Context, out chan<- []core.DiscoveryMessage) er return nil case <-ticker.C: - // Example snapshot (placeholder) - 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: "leaf1", - Address: "clab-3-nodes-leaf1:57400", - Labels: map[string]string{"TargetSource": l.commonCfg.TargetsourceNN.String()}, - }, + fetchAndEmit() + } + } +} + +// buildHTTPClient constructs an HTTP client with optional configuration +func (l *Loader) buildHTTPClient() (*http.Client, error) { + tlsConfig := &tls.Config{ + InsecureSkipVerify: l.spec.TLS != nil && l.spec.TLS.InsecureSkipVerify, + } + + // If a CA bundle is provided, add it to the TLS config + if l.spec.TLS != nil && len(l.spec.TLS.CABundle) > 0 { + certPool := x509.NewCertPool() + if ok := certPool.AppendCertsFromPEM(l.spec.TLS.CABundle); !ok { + return nil, fmt.Errorf("Failed to parse CA bundle for TargetSource %s/%s\n", l.loaderCfg.TargetsourceNN.Namespace, l.loaderCfg.TargetsourceNN.Name) + } + tlsConfig.RootCAs = certPool + } + + // Build the HTTP client with the specified timeout and TLS config + return &http.Client{ + Timeout: l.spec.Timeout.Duration, + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + }, nil +} + +// fetchTargetsFromHTTPEndpoint retrieves targets from the configured HTTP endpoint +func (l *Loader) fetchTargetsFromHTTPEndpoint( + ctx context.Context, + client *http.Client, +) ([]core.DiscoveredTarget, error) { + var allTargets []core.DiscoveredTarget + currentUrl := l.spec.URL + + for { + // Create HTTP request with context + req, err := http.NewRequestWithContext(ctx, http.MethodGet, currentUrl, nil) + if err != nil { + return nil, fmt.Errorf("creating HTTP request failed: %w", err) + } + req.Header.Set("Accept", "application/json") + l.applyAuthorization(req) + + // Execute HTTP request + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP status code: %d", resp.StatusCode) + } + + // Decode response into raw map for pagination support + var raw interface{} + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("failed to decode HTTP response: %w", err) + } + + // Extract targets from response + targets, err := l.extractTargetsFromResponse(raw) + if err != nil { + return nil, err + } + allTargets = append(allTargets, targets...) + + // Check for pagination + nextPageInfo, err := l.extractNextPageInfo(raw) + if err != nil { + return nil, err + } + if nextPageInfo == "" { + break + } + nextURL, err := l.buildNextURL(currentUrl, nextPageInfo) + if err != nil { + return nil, err + } + currentUrl = nextURL + } + + return allTargets, nil +} + +// extractTargetsFromResponse extracts items from the response +// and maps each item into a DiscoveredTarget +func (l *Loader) extractTargetsFromResponse(raw interface{}) ([]core.DiscoveredTarget, error) { + var items []interface{} + + switch v := raw.(type) { + // Top-level array response + case []interface{}: + items = v + // Object with itemsField containing the array + case map[string]interface{}: + if l.spec.Pagination != nil && l.spec.Pagination.ItemsField != "" { + // Extract items array from response using itemsField + val, ok := v[l.spec.Pagination.ItemsField] + if !ok { + return nil, fmt.Errorf("itemsField '%s' not found", l.spec.Pagination.ItemsField) } - if err := loaderUtils.SendSnapshot(ctx, out, targets, snapshotID, l.commonCfg.ChunkSize); err != nil { - return err + arr, ok := val.([]interface{}) + if !ok { + return nil, fmt.Errorf("itemsField '%s' is not an array", l.spec.Pagination.ItemsField) } + + items = arr + } else { + return nil, fmt.Errorf("response is an object but no itemsField specified for TargetSource %s/%s", l.loaderCfg.TargetsourceNN.Namespace, l.loaderCfg.TargetsourceNN.Name) } + default: + return nil, fmt.Errorf("unexpected response format") } + + // Map items to targets + var targets []core.DiscoveredTarget + for _, item := range items { + obj, ok := item.(map[string]interface{}) + if !ok { + continue + } + + target, err := l.mapItem(obj) + if err != nil { + return nil, err + } + + targets = append(targets, target) + } + + return targets, nil } diff --git a/internal/controller/discovery/loaders/http/mapper.go b/internal/controller/discovery/loaders/http/mapper.go new file mode 100644 index 0000000..de618fa --- /dev/null +++ b/internal/controller/discovery/loaders/http/mapper.go @@ -0,0 +1,85 @@ +package http + +import ( + "math" + "strconv" + + "github.com/gnmic/operator/internal/controller/discovery/core" +) + +// valueGetter defines the contract for extracting values from a response item +type valueGetter interface { + GetName() (string, error) + GetIP() (string, error) + GetPort() int32 + GetLabels() map[string]string + GetTargetProfile() string +} + +// getGetter selects the extraction strategy based on the spec +// If no ResponseMapping is defined -> use direct mapping +func (l *Loader) getGetter(item map[string]interface{}) valueGetter { + if l.spec.ResponseMapping == nil { + return &directGetter{ + item: item, + } + } + + return &jsonPathGetter{ + item: item, + spec: l.spec.ResponseMapping, + } +} + +// mapItem is the mapping entrypoint used by the loader +// It uses the selected valueGetter and produces a DiscoveredTarget +func (l *Loader) mapItem(item map[string]interface{}) (core.DiscoveredTarget, error) { + getter := l.getGetter(item) + + name, err := getter.GetName() + if err != nil { + return core.DiscoveredTarget{}, err + } + + ip, err := getter.GetIP() + if err != nil { + return core.DiscoveredTarget{}, err + } + + port := getter.GetPort() + labels := getter.GetLabels() + targetProfile := getter.GetTargetProfile() + + return core.DiscoveredTarget{ + Name: name, + IP: ip, + Port: port, + Labels: labels, + TargetProfile: targetProfile, + }, nil +} + +// extractPort attempts to normalize different JSON types into int32 +// +// Supports: +// - float64 (default JSON number type) +// - string ("1234") +// +// Returns 0 if conversion fails (treated as "no port specified"). +func extractPort(val interface{}) int32 { + switch v := val.(type) { + case float64: + if v < 0 || v > math.MaxInt32 { + return 0 + } + return int32(v) + case string: + p, err := strconv.ParseInt(v, 10, 32) + if err != nil { + return 0 + } + return int32(p) + default: + return 0 + } +} diff --git a/internal/controller/discovery/loaders/http/mapper_direct.go b/internal/controller/discovery/loaders/http/mapper_direct.go new file mode 100644 index 0000000..185e1cb --- /dev/null +++ b/internal/controller/discovery/loaders/http/mapper_direct.go @@ -0,0 +1,82 @@ +package http + +import ( + "fmt" +) + +// directGetter extracts values via direct map access +// Example input: +// +// { +// "name": "router1", +// "ip": "10.0.0.1", +// "port": 57400, +// "labels": { ... }, +// "targetProfile": "profile1" +// } +type directGetter struct { + item map[string]interface{} +} + +// GetName extracts the "name" field directly +func (g *directGetter) GetName() (string, error) { + val, ok := g.item["name"].(string) + if !ok || val == "" { + return "", fmt.Errorf("name must be a non-empty string") + } + return val, nil +} + +// GetIP extracts the "ip" field directly. +func (g *directGetter) GetIP() (string, error) { + val, ok := g.item["ip"].(string) + if !ok || val == "" { + return "", fmt.Errorf("ip must be a non-empty string") + } + return val, nil +} + +// GetPort extracts and normalizes the "port" field +// +// Behavior: +// - supports int, float64, string +// - returns 0 if value is missing or invalid +func (g *directGetter) GetPort() int32 { + if val, ok := g.item["port"]; ok { + return extractPort(val) + } + return 0 +} + +// GetLabels extracts labels from the "labels" field +// Expected format: +// +// "labels": { +// "key": "value" +// } +// +// Non-string values are converted to string +func (g *directGetter) GetLabels() map[string]string { + labels := make(map[string]string) + + if val, ok := g.item["labels"]; ok { + if m, ok := val.(map[string]interface{}); ok { + for k, v := range m { + labels[k] = fmt.Sprintf("%v", v) + } + } + } + + return labels +} + +// GetTargetProfile extracts the "targetProfile" field directly +// +// Behavior: +// - returns "" if value is missing or invalid +func (g *directGetter) GetTargetProfile() string { + if val, ok := g.item["targetProfile"].(string); ok { + return val + } + return "" +} diff --git a/internal/controller/discovery/loaders/http/mapper_jsonpath.go b/internal/controller/discovery/loaders/http/mapper_jsonpath.go new file mode 100644 index 0000000..85bf00a --- /dev/null +++ b/internal/controller/discovery/loaders/http/mapper_jsonpath.go @@ -0,0 +1,106 @@ +package http + +import ( + "fmt" + + "github.com/PaesslerAG/jsonpath" + gnmicv1alpha1 "github.com/gnmic/operator/api/v1alpha1" +) + +// jsonPathGetter extracts values using JSONPath expressions defined in the CR +// Example mapping: +// +// name: "$.hostname" +// ip: "$.ip" +// port: "$.port" +// labels: +// rack: "$.meta.rack" +type jsonPathGetter struct { + item map[string]interface{} + spec *gnmicv1alpha1.ResponseMappingSpec +} + +// helper function to execute JSONPath queries +func (g *jsonPathGetter) get(expr string) (interface{}, error) { + return jsonpath.Get(expr, g.item) +} + +// GetName extracts the target name using JSONPath +func (g *jsonPathGetter) GetName() (string, error) { + val, err := g.get(g.spec.Name) + if err != nil { + return "", fmt.Errorf("name mapping failed: %w", err) + } + + str, ok := val.(string) + if !ok || str == "" { + return "", fmt.Errorf("name must be a non-empty string") + } + + return str, nil +} + +// GetIP extracts the IP using JSONPath +func (g *jsonPathGetter) GetIP() (string, error) { + val, err := g.get(g.spec.IP) + if err != nil { + return "", fmt.Errorf("IP mapping failed: %w", err) + } + + str, ok := val.(string) + if !ok || str == "" { + return "", fmt.Errorf("IP must be a non-empty string") + } + + return str, nil +} + +// GetPort extracts the port using JSONPath +// +// Behavior: +// - returns 0 if no port mapping defined +// - returns 0 if extraction fails or value invalid +func (g *jsonPathGetter) GetPort() int32 { + if g.spec.Port == "" { + return 0 + } + + val, err := g.get(g.spec.Port) + if err != nil { + return 0 + } + + return extractPort(val) +} + +// GetLabels extracts labels using JSONPath expressions defined per label key +func (g *jsonPathGetter) GetLabels() map[string]string { + labels := make(map[string]string) + + for key, expr := range g.spec.Labels { + if val, err := g.get(expr); err == nil { + labels[key] = fmt.Sprintf("%v", val) + } + } + + return labels +} + +// GetTargetProfile extracts the target profile using JSONPath +// +// Behavior: +// - returns "" if no target profile mapping defined +// - returns "" if extraction fails or value invalid +func (g *jsonPathGetter) GetTargetProfile() string { + val, err := g.get(g.spec.TargetProfile) + if err != nil { + return "" + } + + str, ok := val.(string) + if !ok { + return "" + } + + return str +} diff --git a/internal/controller/discovery/loaders/http/pagination.go b/internal/controller/discovery/loaders/http/pagination.go new file mode 100644 index 0000000..9fef778 --- /dev/null +++ b/internal/controller/discovery/loaders/http/pagination.go @@ -0,0 +1,51 @@ +package http + +import ( + "fmt" + "net/url" +) + +// extractNextPageInfo extracts pagination information from a response +func (l *Loader) extractNextPageInfo(raw interface{}) (string, error) { + if l.spec.Pagination == nil || l.spec.Pagination.NextField == "" { + return "", nil + } + + // Only objects can have "next" fields + obj, ok := raw.(map[string]interface{}) + if !ok { + // array case -> no pagination + return "", nil + } + + val, ok := obj[l.spec.Pagination.NextField] + if !ok { + return "", fmt.Errorf("nextField '%s' not found in response", l.spec.Pagination.NextField) + } + + next, ok := val.(string) + if !ok { + return "", fmt.Errorf("nextField '%s' is not a string in response", l.spec.Pagination.NextField) + } + + return next, nil +} + +// buildNextURL constructs the URL for the next page based on the current URL and pagination info +func (l *Loader) buildNextURL(currentURL, nextVal string) (string, error) { + // nextVal is a full URL -> return as is + if parsed, err := url.Parse(nextVal); err == nil && parsed.Scheme != "" { + return nextVal, nil + } + + // nextVal is a token -> append as query parameter + parsedURL, err := url.Parse(currentURL) + if err != nil { + return "", fmt.Errorf("failed to parse current URL in order to build next URL: %w", err) + } + q := parsedURL.Query() + q.Set(l.spec.Pagination.NextField, nextVal) + parsedURL.RawQuery = q.Encode() + + return parsedURL.String(), nil +} diff --git a/internal/controller/discovery/loaders/utils/endpoint.go b/internal/controller/discovery/loaders/utils/endpoint.go new file mode 100644 index 0000000..ef83f18 --- /dev/null +++ b/internal/controller/discovery/loaders/utils/endpoint.go @@ -0,0 +1,22 @@ +package utils + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "k8s.io/apimachinery/pkg/types" +) + +func CreateTargetsPath( + router *gin.Engine, + nn types.NamespacedName, + handler gin.HandlerFunc, +) { + path := fmt.Sprintf( + "/api/v1/%s/target-source/%s/createTargets", + nn.Namespace, + nn.Name, + ) + + router.POST(path, handler) +} diff --git a/internal/controller/discovery/mapper.go b/internal/controller/discovery/mapper.go new file mode 100644 index 0000000..f3521cb --- /dev/null +++ b/internal/controller/discovery/mapper.go @@ -0,0 +1,91 @@ +package discovery + +import ( + "fmt" + "maps" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + gnmicv1alpha1 "github.com/gnmic/operator/api/v1alpha1" + "github.com/gnmic/operator/internal/controller/discovery/core" +) + +// generateTargetResource converts a DiscoveredTarget into a Kubernetes Target Object based on the TargetSource Spec. +// Returns the Target Resource and a map of unknown operator labels. +func generateTargetResource(d core.DiscoveredTarget, ts *gnmicv1alpha1.TargetSource) (*gnmicv1alpha1.Target, map[string]string) { + // Create object instance + t := &gnmicv1alpha1.Target{ + ObjectMeta: metav1.ObjectMeta{ + Name: d.Name, + Namespace: ts.Namespace, + Labels: make(map[string]string), + }, + } + unknownLabels := make(map[string]string) + + // Add Address from DiscoveredTarget + t.Spec.Address = fmt.Sprintf("%s:%d", d.IP, d.Port) + // Add default Target Profile from the TargetSource Spec TargetProfile + t.Spec.Profile = ts.Spec.TargetProfile + + // Handle labels from Source of Truth + for k, v := range d.Labels { + if strings.HasPrefix(k, ExternalLabelPrefix) { + switch k { + case ExternalLabelTargetProfile: // Overwrite TargetProfile if specified by SoT + t.Spec.Profile = v + default: + unknownLabels[k] = v + } + } else { // Copy all other labels into the Target + t.Labels[k] = v + } + } + + // Copy TargetLabels from TargetSource Spec + maps.Copy(t.Labels, ts.Spec.TargetLabels) + + // Add TargetSource Label to the Target (precedence over all labels) + t.Labels[LabelTargetSourceName] = ts.Name + + return t, unknownLabels +} + +// generateEvents returns a list of DiscoveryEvents. Needed for snapshot handling to determine which devices get deleted and which applied. +func generateEvents(existing []gnmicv1alpha1.Target, discovered []core.DiscoveredTarget) []core.DiscoveryEvent { + var events []core.DiscoveryEvent + + discoveredMap := make(map[string]core.DiscoveredTarget) + for _, d := range discovered { + discoveredMap[d.Name] = d + } + + // Create delete events for targets which are present in existing but not in discovered + for _, e := range existing { + if _, found := discoveredMap[e.Name]; !found { + events = append(events, core.DiscoveryEvent{ + Target: core.DiscoveredTarget{ + Name: e.Name, + }, + Event: core.EventDelete, + }) + } + } + + // Create apply events for all targets in discovered + for _, d := range discovered { + events = append(events, core.DiscoveryEvent{ + Target: d, + Event: core.EventApply, + }) + } + + return events +} + +// normalizeTarget adds the prefix to the target name for identification in Kubernetes +func normalizeTarget(t core.DiscoveredTarget, tsName string) core.DiscoveredTarget { + t.Name = tsName + "-" + t.Name + return t +} diff --git a/internal/controller/discovery/mapper_test.go b/internal/controller/discovery/mapper_test.go new file mode 100644 index 0000000..295478e --- /dev/null +++ b/internal/controller/discovery/mapper_test.go @@ -0,0 +1,454 @@ +package discovery + +import ( + "fmt" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + gnmicv1alpha1 "github.com/gnmic/operator/api/v1alpha1" + "github.com/gnmic/operator/internal/controller/discovery/core" +) + +func mockDiscoveredTargetList(len int) []core.DiscoveredTarget { + targets := make([]core.DiscoveredTarget, len) + + if len > 100 { + len = 100 + } + + for i := range len { + targets[i] = core.DiscoveredTarget{ + IP: fmt.Sprintf("192.168.1.%d", i+1), + Port: 57400, + Name: fmt.Sprintf("router%d", i+1), + } + } + + return targets +} + +func mockDiscoveryTarget(opts ...func(*core.DiscoveredTarget)) core.DiscoveredTarget { + t := core.DiscoveredTarget{ + Name: "target1", + IP: "10.0.0.1", + Port: 57400, + Labels: map[string]string{}, + } + + for _, opt := range opts { + opt(&t) + } + + return t +} + +func withDiscoveredTargetName(name string) func(*core.DiscoveredTarget) { + return func(t *core.DiscoveredTarget) { + t.Name = name + } +} + +func withDiscoveredTargetIP(ip string) func(*core.DiscoveredTarget) { + return func(t *core.DiscoveredTarget) { + t.IP = ip + } +} + +func withDiscoveredTargetLabels(labels map[string]string) func(*core.DiscoveredTarget) { + return func(t *core.DiscoveredTarget) { + t.Labels = labels + } +} + +func mockTargetSource(opts ...func(*gnmicv1alpha1.TargetSource)) gnmicv1alpha1.TargetSource { + ts := gnmicv1alpha1.TargetSource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ts1", + Namespace: "default", + }, + Spec: gnmicv1alpha1.TargetSourceSpec{ + TargetProfile: "default", + TargetLabels: map[string]string{}, + }, + } + + for _, opt := range opts { + opt(&ts) + } + + return ts +} + +func withTargetSourceName(name string) func(*gnmicv1alpha1.TargetSource) { + return func(ts *gnmicv1alpha1.TargetSource) { + ts.ObjectMeta.Name = name + } +} + +func withTargetSourceNamespace(namespace string) func(*gnmicv1alpha1.TargetSource) { + return func(ts *gnmicv1alpha1.TargetSource) { + ts.ObjectMeta.Namespace = namespace + } +} + +func withTargetSourceTargetProfile(profile string) func(*gnmicv1alpha1.TargetSource) { + return func(ts *gnmicv1alpha1.TargetSource) { + ts.Spec.TargetProfile = profile + } +} + +func withTargetSourceTargetLabels(labels map[string]string) func(*gnmicv1alpha1.TargetSource) { + return func(ts *gnmicv1alpha1.TargetSource) { + ts.Spec.TargetLabels = labels + } +} + +func mockGnmicTargetList(len int) []gnmicv1alpha1.Target { + targets := make([]gnmicv1alpha1.Target, len) + + if len > 100 { + len = 100 + } + + for i := range len { + targets[i] = gnmicv1alpha1.Target{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("router%d", i+1), + Namespace: "default", + }, + Spec: gnmicv1alpha1.TargetSpec{ + Address: fmt.Sprintf("192.168.1.%d", i+1), + Profile: "default", + }, + } + } + + return targets +} + +func TestGenerateEvents_EmptyLists(t *testing.T) { + events := generateEvents( + mockGnmicTargetList(0), + mockDiscoveredTargetList(0), + ) + + if len(events) != 0 { + t.Fatalf("expected 0 events, got %d", len(events)) + } +} + +func TestGenerateEvents_AllDiscoveredTargetsBecomeApplyEvents(t *testing.T) { + discovered := mockDiscoveredTargetList(5) + + events := generateEvents( + mockGnmicTargetList(0), + discovered, + ) + + if len(events) != len(discovered) { + t.Fatalf("expected %d events, got %d", len(discovered), len(events)) + } + + for _, event := range events { + if event.Event != core.EventApply { + t.Fatalf( + "expected all events to be %s, got %s", + core.EventApply.String(), + event.Event.String(), + ) + } + } +} + +func TestGenerateEvents_AllExistingTargetsBecomeDeleteEvents(t *testing.T) { + existing := mockGnmicTargetList(5) + + events := generateEvents( + existing, + mockDiscoveredTargetList(0), + ) + + if len(events) != len(existing) { + t.Fatalf("expected %d events, got %d", len(existing), len(events)) + } + + for _, event := range events { + if event.Event != core.EventDelete { + t.Fatalf( + "expected all events to be %s, got %s", + core.EventDelete.String(), + event.Event.String(), + ) + } + } +} + +func TestGenerateEvents_GeneratesDeleteThenApplyEvents(t *testing.T) { + existing := mockGnmicTargetList(5) + discovered := mockDiscoveredTargetList(3) + + events := generateEvents(existing, discovered) + + var ( + numDelete int + numApply int + seenApply bool + ) + + for _, event := range events { + switch event.Event { + case core.EventDelete: + if seenApply { + t.Fatalf("expected delete events before apply events") + } + numDelete++ + + case core.EventApply: + seenApply = true + numApply++ + } + } + + if numDelete != 2 { + t.Fatalf("expected 2 delete events, got %d", numDelete) + } + + if numApply != 3 { + t.Fatalf("expected 3 apply events, got %d", numApply) + } +} + +func TestGenerateEvents_OnlyApplyEventsAreGeneratedForNewTargets(t *testing.T) { + existing := mockGnmicTargetList(3) + discovered := mockDiscoveredTargetList(5) + + events := generateEvents(existing, discovered) + + var ( + numDelete int + numApply int + ) + + for _, event := range events { + switch event.Event { + case core.EventDelete: + numDelete++ + + case core.EventApply: + numApply++ + } + } + + if numDelete != 0 { + t.Fatalf("expected 0 delete events, got %d", numDelete) + } + + if numApply != 5 { + t.Fatalf("expected 5 apply events, got %d", numApply) + } +} + +func TestGenerateEvents_NonOverlappingListsGenerateDeleteAndApplyEvents(t *testing.T) { + existing := mockGnmicTargetList(5) + + discovered := mockDiscoveredTargetList(10)[5:] + + events := generateEvents(existing, discovered) + + var ( + numDelete int + numApply int + seenApply bool + ) + + for _, event := range events { + switch event.Event { + case core.EventDelete: + if seenApply { + t.Fatalf("expected delete events before apply events") + } + numDelete++ + + case core.EventApply: + seenApply = true + numApply++ + } + } + + if numDelete != 5 { + t.Fatalf("expected 5 delete events, got %d", numDelete) + } + + if numApply != 5 { + t.Fatalf("expected 5 apply events, got %d", numApply) + } +} + +func TestGenerateTargetResource_SetsTargetSourceNameLabel(t *testing.T) { + ts := mockTargetSource() + d := mockDiscoveryTarget() + + target, _ := generateTargetResource(d, &ts) + + if got := target.Labels[LabelTargetSourceName]; got != ts.Name { + t.Fatalf( + "expected %s=%q, got %q", + LabelTargetSourceName, + ts.Name, + got, + ) + } +} + +func TestGenerateTargetResource_CopiesDiscoveredLabels(t *testing.T) { + d := mockDiscoveryTarget( + withDiscoveredTargetLabels(map[string]string{ + "discoveredLabel1": "discoveredValue1", + "discoveredLabel2": "discoveredValue2", + }), + ) + + ts := mockTargetSource() + + target, _ := generateTargetResource(d, &ts) + + tests := map[string]string{ + "discoveredLabel1": "discoveredValue1", + "discoveredLabel2": "discoveredValue2", + } + + for k, want := range tests { + if got := target.Labels[k]; got != want { + t.Fatalf("expected label %s=%q, got %q", k, want, got) + } + } +} + +func TestGenerateTargetResource_CopiesTargetSourceLabels(t *testing.T) { + ts := mockTargetSource( + withTargetSourceTargetLabels(map[string]string{ + "targetSourceLabel1": "targetSourceValue1", + "targetSourceLabel2": "targetSourceValue2", + }), + ) + + d := mockDiscoveryTarget() + + target, _ := generateTargetResource(d, &ts) + + tests := map[string]string{ + "targetSourceLabel1": "targetSourceValue1", + "targetSourceLabel2": "targetSourceValue2", + } + + for k, want := range tests { + if got := target.Labels[k]; got != want { + t.Fatalf("expected label %s=%q, got %q", k, want, got) + } + } +} + +func TestGenerateTargetResource_OverridesReservedTargetSourceNameLabel(t *testing.T) { + ts := mockTargetSource( + withTargetSourceTargetLabels(map[string]string{ + LabelTargetSourceName: "wrong-value", + }), + ) + + d := mockDiscoveryTarget( + withDiscoveredTargetLabels(map[string]string{ + LabelTargetSourceName: "another-wrong-value", + }), + ) + + target, _ := generateTargetResource(d, &ts) + + if got := target.Labels[LabelTargetSourceName]; got != ts.Name { + t.Fatalf( + "expected reserved label %s=%q, got %q", + LabelTargetSourceName, + ts.Name, + got, + ) + } +} + +func TestGenerateTargetResource_TargetSourceLabelsOverrideDiscoveredLabels(t *testing.T) { + ts := mockTargetSource( + withTargetSourceTargetLabels(map[string]string{ + "sharedLabel": "targetSourceValue", + }), + ) + + d := mockDiscoveryTarget( + withDiscoveredTargetLabels(map[string]string{ + "sharedLabel": "discoveredValue", + }), + ) + + target, _ := generateTargetResource(d, &ts) + + if got := target.Labels["sharedLabel"]; got != "targetSourceValue" { + t.Fatalf( + "expected target source label to override discovered label, got %q", + got, + ) + } +} + +func TestNormalizeTarget_PrefixesTargetName(t *testing.T) { + target := mockDiscoveryTarget( + withDiscoveredTargetName("router1"), + ) + + normalized := normalizeTarget(target, "ts1") + + if got := normalized.Name; got != "ts1-router1" { + t.Fatalf( + "expected normalized name %q, got %q", + "ts1-router1", + got, + ) + } +} + +func TestNormalizeTarget_PreservesTargetAddress(t *testing.T) { + target := mockDiscoveryTarget( + withDiscoveredTargetIP("192.168.1.10"), + ) + + normalized := normalizeTarget(target, "ts1") + + if got := normalized.IP; got != "192.168.1.10" { + t.Fatalf( + "expected IP %q, got %q", + "192.168.1.10", + got, + ) + } +} + +func TestNormalizeTarget_PreservesTargetLabels(t *testing.T) { + labels := map[string]string{ + "env": "prod", + "role": "leaf", + } + + target := mockDiscoveryTarget( + withDiscoveredTargetLabels(labels), + ) + + normalized := normalizeTarget(target, "ts1") + + for k, want := range labels { + if got := normalized.Labels[k]; got != want { + t.Fatalf( + "expected label %s=%q, got %q", + k, + want, + got, + ) + } + } +} diff --git a/internal/controller/discovery/message_processor.go b/internal/controller/discovery/message_processor.go index f7aafb1..e202347 100644 --- a/internal/controller/discovery/message_processor.go +++ b/internal/controller/discovery/message_processor.go @@ -30,6 +30,7 @@ type MessageProcessor struct { activeSnapshot *snapshotBuffer // Events are deferred while snapshot is in progress deferredEvents []core.DiscoveryEvent + targetCount int32 } // NewMessageProcessor wires a MessageProcessor instance @@ -53,6 +54,13 @@ func (m *MessageProcessor) Run(ctx context.Context) error { logger.Info("Message processor started") + // Update internal counter in case of a process restart + if existing, err := fetchExistingTargets(ctx, m.client, m.targetSource); err != nil { + logger.Error(err, "error fetching existing targets") + } else { + m.targetCount = int32(len(existing)) + } + for { select { case batch, ok := <-m.in: @@ -90,6 +98,7 @@ func (m *MessageProcessor) Run(ctx context.Context) error { } } +// processMessage handles all of the incoming messages from the channel func (m *MessageProcessor) processMessage(ctx context.Context, message core.DiscoveryMessage, logger logr.Logger) error { if err := ctx.Err(); err != nil { return err @@ -105,6 +114,11 @@ func (m *MessageProcessor) processMessage(ctx context.Context, message core.Disc "chunkIndex", msg.ChunkIndex, "targets", len(msg.Targets), ) + + for i := range msg.Targets { + msg.Targets[i] = normalizeTarget(msg.Targets[i], m.targetSource.Name) + } + return m.processSnapshot(ctx, msg, logger) case core.DiscoveryEvent: @@ -114,6 +128,8 @@ func (m *MessageProcessor) processMessage(ctx context.Context, message core.Disc "event", msg.Event, "target", msg.Target.Name, ) + + msg.Target = normalizeTarget(msg.Target, m.targetSource.Name) return m.processEvent(ctx, msg, logger) default: @@ -204,6 +220,31 @@ func (m *MessageProcessor) collectSnapshot(ctx context.Context, chunk core.Disco return nil } +// processEvent handles a single DiscoveryEvent message. If a snapshot is in the queue, the events get deferred and applied after. +func (m *MessageProcessor) processEvent(ctx context.Context, event core.DiscoveryEvent, logger logr.Logger) error { + // If snapshot collecting is active defer events + if m.activeSnapshot != nil { + m.deferredEvents = append(m.deferredEvents, event) + return nil + } + + // Apply events + err := m.applyEvent(ctx, event, logger) + if err == nil { + switch event.Event { + case core.EventApply: + m.targetCount++ + m.updateStatus(ctx, logger) + case core.EventDelete: + m.targetCount-- + m.updateStatus(ctx, logger) + } + } + + return err +} + +// applySnapshot is in charge of getting the Events for the discovered targets and applying them through applyEvent func (m *MessageProcessor) applySnapshot(ctx context.Context, snapshot *snapshotBuffer, logger logr.Logger) error { select { case <-ctx.Done(): @@ -240,8 +281,37 @@ func (m *MessageProcessor) applySnapshot(ctx context.Context, snapshot *snapshot "targets", len(allTargets), ) - // todo: apply all targets - // a.applyTargets + existing, err := fetchExistingTargets(ctx, m.client, m.targetSource) + if err != nil { + logger.Error(err, "error fetching existing targets") + } else { + logger.Info("fetched existing targets", + "numOfTargets", len(existing), + ) + } + + events := generateEvents(existing, allTargets) + + nApply := 0 + nDelete := 0 + + for _, e := range events { + switch e.Event { + case core.EventApply: + nApply++ + case core.EventDelete: + nDelete++ + } + } + + logger.Info("generated events", + "numOfApply", nApply, + "numOfDelete", nDelete, + ) + + for _, e := range events { + m.applyEvent(ctx, e, logger) + } // Replay deferred events for _, event := range m.deferredEvents { @@ -250,47 +320,68 @@ func (m *MessageProcessor) applySnapshot(ctx context.Context, snapshot *snapshot return nil default: } - if err := m.applyEvent(ctx, event, logger); err != nil { + if err := m.processEvent(ctx, event, logger); err != nil { return err } } + // 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 } -func (m *MessageProcessor) processEvent(ctx context.Context, event core.DiscoveryEvent, logger logr.Logger) error { - // If snapshot collecting is active defer events - if m.activeSnapshot != nil { - m.deferredEvents = append(m.deferredEvents, event) - return nil - } - - // Apply events - return m.applyEvent(ctx, event, logger) -} - func (m *MessageProcessor) applyEvent(ctx context.Context, event core.DiscoveryEvent, logger logr.Logger) error { switch event.Event { case core.EventDelete: - logger.Info( - "Deleting Target", - "target", event.Target.Name, - "targetsource", m.targetSource.Name, - ) + if err := deleteTarget(ctx, m.client, event.Target.Name, m.targetSource.Namespace); err != nil { + logger.Error(err, "error deleting target", + "targetName", event.Target.Name, + ) + return err + } else { + logger.Info("deleted target object", + "name", event.Target.Name, + ) + } case core.EventApply: - logger.Info( - "Applying Target", - "target", event.Target.Name, - "address", event.Target.Address, - "labels", event.Target.Labels, - "targetsource", m.targetSource.Name, - ) + target, unknownLabels := generateTargetResource(event.Target, m.targetSource) + for k, v := range unknownLabels { + logger.Info("unknown operator label for target", + "target", event.Target.Name, + "label", k, + "value", v, + ) + } + + if err := applyTarget(ctx, m.client, m.scheme, target, m.targetSource); err != nil { + logger.Error(err, "error applying target", + "targetName", event.Target.Name, + ) + return err + } else { + logger.Info("applied target object", + "name", event.Target.Name, + ) + } } + 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/targetsource_controller.go b/internal/controller/targetsource_controller.go index 522aabd..e0095f0 100644 --- a/internal/controller/targetsource_controller.go +++ b/internal/controller/targetsource_controller.go @@ -23,10 +23,13 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "github.com/gin-gonic/gin" gnmicv1alpha1 "github.com/gnmic/operator/api/v1alpha1" "github.com/gnmic/operator/internal/controller/discovery" discoveryTypes "github.com/gnmic/operator/internal/controller/discovery/core" @@ -51,6 +54,8 @@ type TargetSourceReconciler struct { types.NamespacedName, discoveryTypes.DiscoveryRegistryValue, ] + + APIRouter *gin.Engine } // +kubebuilder:rbac:groups=operator.gnmic.dev,resources=targetsources,verbs=get;list;watch;create;update;patch;delete @@ -90,11 +95,20 @@ func (r *TargetSourceReconciler) Reconcile(ctx context.Context, req ctrl.Request } if r.DiscoveryRegistry.Exists(req.NamespacedName) { - logger.Info("Discovery runtime already running; reconciliation completed") - return ctrl.Result{}, nil + if targetSource.Generation != targetSource.Status.ObservedGeneration { + return r.reconcileDeletion(ctx, req.NamespacedName, targetSource) + } else { + logger.Info("Discovery runtime already running; reconciliation completed") + return ctrl.Result{}, nil + } + } + + if err := r.startDiscovery(ctx, req.NamespacedName, targetSource, logger); err != nil { + return ctrl.Result{}, err } - if err := r.startDiscovery(req.NamespacedName, targetSource, logger); err != nil { + targetSource.Status.ObservedGeneration = targetSource.Generation + if err := r.Status().Update(ctx, targetSource); err != nil { return ctrl.Result{}, err } @@ -162,6 +176,7 @@ func (r *TargetSourceReconciler) ensureFinalizer(ctx context.Context, targetSour // - MessageProcessor and Loader must run for the lifetime of the TargetSource // - Any unexpected exit is treated as a bug and triggers full shutdown func (r *TargetSourceReconciler) startDiscovery( + reconcileCtx context.Context, key types.NamespacedName, targetSource *gnmicv1alpha1.TargetSource, logger logr.Logger, @@ -185,7 +200,7 @@ func (r *TargetSourceReconciler) startDiscovery( targetSource, targetChannel, ) - loader, err := discovery.NewLoader(&loaderConfig, targetSource.Spec) + loader, err := discovery.NewLoader(reconcileCtx, r.Client, &loaderConfig, targetSource.Spec) if err != nil { logger.Error(err, "Target loader could not be created") cleanup() @@ -217,7 +232,7 @@ func (r *TargetSourceReconciler) startDiscovery( // Start target loader go func() { - if err := loader.Run(ctx, targetChannel); err != nil { + if err := loader.Run(ctx, targetChannel, targetSource.Spec); err != nil { logger.Error(err, "Target loader exited unexpectedly") } else { logger.Error(nil, "Target loader exited unexpectedly without error") @@ -230,7 +245,10 @@ func (r *TargetSourceReconciler) startDiscovery( // SetupWithManager sets up the controller with the Manager. func (r *TargetSourceReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&gnmicv1alpha1.TargetSource{}). + For( + &gnmicv1alpha1.TargetSource{}, + builder.WithPredicates(predicate.GenerationChangedPredicate{}), + ). Named("targetsource"). Complete(r) } diff --git a/lab/dev/resources/targetsource/ts.yaml b/lab/dev/resources/targetsource/ts.yaml new file mode 100644 index 0000000..0bc4c79 --- /dev/null +++ b/lab/dev/resources/targetsource/ts.yaml @@ -0,0 +1,13 @@ +apiVersion: operator.gnmic.dev/v1alpha1 +kind: TargetSource +metadata: + name: targetsource-1 +spec: + provider: + http: + url: http://targetsource-1:8080/targets + acceptPush: true + targetLabels: + datacenter: dc-a + environment: production + targetProfile: default \ No newline at end of file diff --git a/test.mk b/test.mk index 23c5983..fb30c30 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-subscriptions apply-test-outputs apply-test-pipelines apply-test-clusters apply-test-targetsources diff --git a/test/integration/http/resources/configmap.yaml b/test/integration/http/resources/configmap.yaml index f017566..685c670 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", + "ip": "clab-t1-spine1", + "port": 57400, "name": "spine1", "labels": { "vendor": "nokia_srlinux", @@ -14,7 +15,8 @@ data: } }, { - "address": "clab-t1-leaf1:57400", + "ip": "clab-t1-leaf1", + "port": 57400, "name": "leaf1", "labels": { "vendor": "nokia_srlinux", @@ -22,11 +24,12 @@ data: } }, { - "address": "clab-t1-leaf2:57400", + "ip": "clab-t1-leaf2", + "port": 57400, "name": "leaf2", "labels": { "vendor": "nokia_srlinux", "role": "leaf" } } - ] \ No newline at end of file + ]