From 5708d38ccae1b9835bc31614d893ad9ca97f09cb Mon Sep 17 00:00:00 2001 From: Gianluca Mardente Date: Thu, 16 Apr 2026 21:51:40 +0200 Subject: [PATCH] (feat) support remote YAML sources in PolicyRefs Add a url field to PolicyRef so ClusterProfile/Profile can reference YAML content served over HTTP/HTTPS, bypassing the ~1 MB ConfigMap size limit. When url is set, Sveltos fetches the content on every reconciliation and redeploys if the hash has changed. A periodic requeue (default 5 minutes, configurable via interval) drives change detection without requiring a Kubernetes watch event. Optional auth is supported via a secretRef pointing to a Secret with token, username+password or caFile keys. Set template: true to have the fetched content treated as a Go template, equivalent to the projectsveltos.io/template annotation on a ConfigMap. ```yaml policyRefs: - deploymentType: Remote remoteURL: interval: 1h0m0s url: https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml ``` --- api/v1beta1/spec.go | 45 ++- api/v1beta1/zz_generated.deepcopy.go | 46 ++- ...fig.projectsveltos.io_clusterprofiles.yaml | 56 ++- ...g.projectsveltos.io_clusterpromotions.yaml | 170 ++++++++- ...ig.projectsveltos.io_clustersummaries.yaml | 56 ++- .../config.projectsveltos.io_profiles.yaml | 56 ++- controllers/clustersummary_controller.go | 63 +++- controllers/controllers_suite_test.go | 2 +- controllers/export_test.go | 3 +- controllers/handlers_resources.go | 40 +++ controllers/handlers_utils.go | 39 +- controllers/url_source.go | 166 +++++++++ manifest/manifest.yaml | 338 ++++++++++++++++-- test/fv/remote_url_test.go | 166 +++++++++ 14 files changed, 1169 insertions(+), 77 deletions(-) create mode 100644 controllers/url_source.go create mode 100644 test/fv/remote_url_test.go diff --git a/api/v1beta1/spec.go b/api/v1beta1/spec.go index 42d860d6..636a742d 100644 --- a/api/v1beta1/spec.go +++ b/api/v1beta1/spec.go @@ -611,25 +611,30 @@ type TemplateResourceRef struct { IgnoreStatusChanges bool `json:"ignoreStatusChanges,omitempty"` } +// +kubebuilder:validation:XValidation:rule="has(self.remoteURL) != has(self.kind)",message="either remoteURL or kind must be set, but not both" type PolicyRef struct { // Namespace of the referenced resource. // For ClusterProfile namespace can be left empty. In such a case, namespace will // be implicit set to cluster's namespace. // For Profile namespace must be left empty. Profile namespace will be used. // Namespace can be expressed as a template and instantiate using any cluster field. + // Not used when RemoteURL is set. // +optional Namespace string `json:"namespace,omitempty"` // Name of the referenced resource. // Name can be expressed as a template and instantiate using any cluster field. - // +kubebuilder:validation:MinLength=1 - Name string `json:"name"` + // Required when RemoteURL is not set. + // +optional + Name string `json:"name,omitempty"` // Kind of the resource. Supported kinds are: // - ConfigMap/Secret // - flux GitRepository;OCIRepository;Bucket + // Required when RemoteURL is not set. // +kubebuilder:validation:Enum=GitRepository;OCIRepository;Bucket;ConfigMap;Secret - Kind string `json:"kind"` + // +optional + Kind string `json:"kind,omitempty"` // Path to the directory containing the YAML files. // Defaults to 'None', which translates to the root path of the SourceRef. @@ -671,6 +676,40 @@ type PolicyRef struct { // +kubebuilder:default:=false // +optional SkipNamespaceCreation bool `json:"skipNamespaceCreation,omitempty"` + + // RemoteURL configures fetching content from an HTTP/HTTPS endpoint. + // When set, Kind/Name/Namespace must be omitted. + // +optional + RemoteURL *RemoteURL `json:"remoteURL,omitempty"` +} + +// RemoteURL groups all fields related to fetching policy content from an HTTP/HTTPS endpoint. +type RemoteURL struct { + // URL is an HTTP/HTTPS endpoint serving raw YAML/JSON/KYAML content. + // Sveltos fetches the content on every reconciliation and redeploys if the + // content hash has changed. + // +kubebuilder:validation:Pattern=`^https?://` + URL string `json:"url"` + + // Interval defines how often Sveltos re-fetches the URL to detect changes. + // Defaults to 5 minutes. + // +optional + Interval *metav1.Duration `json:"interval,omitempty"` + + // SecretRef references a Secret in the management cluster containing optional + // credentials for fetching the URL. Supported Secret keys: + // "token" — Bearer token (Authorization: Bearer ) + // "username"+"password" — HTTP Basic Auth + // "caFile" — PEM-encoded CA certificate for TLS verification + // +optional + SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty"` + + // Template indicates that the content served at URL is a Go template that + // must be instantiated using cluster fields and templateResourceRefs values + // before deployment. Equivalent to the projectsveltos.io/template annotation + // on a ConfigMap or Secret. + // +optional + Template bool `json:"template,omitempty"` } type Clusters struct { diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index be924ef0..1130ef06 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -40,7 +40,9 @@ func (in *AutoTrigger) DeepCopyInto(out *AutoTrigger) { if in.PreHealthCheckDeployment != nil { in, out := &in.PreHealthCheckDeployment, &out.PreHealthCheckDeployment *out = make([]PolicyRef, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.PostDelayHealthChecks != nil { in, out := &in.PostDelayHealthChecks, &out.PostDelayHealthChecks @@ -956,7 +958,9 @@ func (in *ManualTrigger) DeepCopyInto(out *ManualTrigger) { if in.PreHealthCheckDeployment != nil { in, out := &in.PreHealthCheckDeployment, &out.PreHealthCheckDeployment *out = make([]PolicyRef, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.PostDelayHealthChecks != nil { in, out := &in.PostDelayHealthChecks, &out.PostDelayHealthChecks @@ -1000,6 +1004,11 @@ func (in *NonRetriableError) DeepCopy() *NonRetriableError { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PolicyRef) DeepCopyInto(out *PolicyRef) { *out = *in + if in.RemoteURL != nil { + in, out := &in.RemoteURL, &out.RemoteURL + *out = new(RemoteURL) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicyRef. @@ -1114,7 +1123,9 @@ func (in *ProfileSpec) DeepCopyInto(out *ProfileSpec) { if in.PolicyRefs != nil { in, out := &in.PolicyRefs, &out.PolicyRefs *out = make([]PolicyRef, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.HelmCharts != nil { in, out := &in.HelmCharts, &out.HelmCharts @@ -1227,6 +1238,31 @@ func (in *ReleaseReport) DeepCopy() *ReleaseReport { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RemoteURL) DeepCopyInto(out *RemoteURL) { + *out = *in + if in.Interval != nil { + in, out := &in.Interval, &out.Interval + *out = new(v1.Duration) + **out = **in + } + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(corev1.LocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemoteURL. +func (in *RemoteURL) DeepCopy() *RemoteURL { + if in == nil { + return nil + } + out := new(RemoteURL) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Spec) DeepCopyInto(out *Spec) { *out = *in @@ -1259,7 +1295,9 @@ func (in *Spec) DeepCopyInto(out *Spec) { if in.PolicyRefs != nil { in, out := &in.PolicyRefs, &out.PolicyRefs *out = make([]PolicyRef, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.HelmCharts != nil { in, out := &in.HelmCharts, &out.HelmCharts diff --git a/config/crd/bases/config.projectsveltos.io_clusterprofiles.yaml b/config/crd/bases/config.projectsveltos.io_clusterprofiles.yaml index 40b05275..afa416c1 100644 --- a/config/crd/bases/config.projectsveltos.io_clusterprofiles.yaml +++ b/config/crd/bases/config.projectsveltos.io_clusterprofiles.yaml @@ -977,6 +977,7 @@ spec: Kind of the resource. Supported kinds are: - ConfigMap/Secret - flux GitRepository;OCIRepository;Bucket + Required when RemoteURL is not set. enum: - GitRepository - OCIRepository @@ -988,7 +989,7 @@ spec: description: |- Name of the referenced resource. Name can be expressed as a template and instantiate using any cluster field. - minLength: 1 + Required when RemoteURL is not set. type: string namespace: description: |- @@ -997,6 +998,7 @@ spec: be implicit set to cluster's namespace. For Profile namespace must be left empty. Profile namespace will be used. Namespace can be expressed as a template and instantiate using any cluster field. + Not used when RemoteURL is set. type: string optional: default: false @@ -1011,6 +1013,52 @@ spec: Defaults to 'None', which translates to the root path of the SourceRef. Used only for GitRepository;OCIRepository;Bucket type: string + remoteURL: + description: |- + RemoteURL configures fetching content from an HTTP/HTTPS endpoint. + When set, Kind/Name/Namespace must be omitted. + properties: + interval: + description: |- + Interval defines how often Sveltos re-fetches the URL to detect changes. + Defaults to 5 minutes. + type: string + secretRef: + description: |- + SecretRef references a Secret in the management cluster containing optional + credentials for fetching the URL. Supported Secret keys: + "token" — Bearer token (Authorization: Bearer ) + "username"+"password" — HTTP Basic Auth + "caFile" — PEM-encoded CA certificate for TLS verification + properties: + 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 + type: object + x-kubernetes-map-type: atomic + template: + description: |- + Template indicates that the content served at URL is a Go template that + must be instantiated using cluster fields and templateResourceRefs values + before deployment. Equivalent to the projectsveltos.io/template annotation + on a ConfigMap or Secret. + type: boolean + url: + description: |- + URL is an HTTP/HTTPS endpoint serving raw YAML/JSON/KYAML content. + Sveltos fetches the content on every reconciliation and redeploys if the + content hash has changed. + pattern: ^https?:// + type: string + required: + - url + type: object skipNamespaceCreation: default: false description: |- @@ -1034,10 +1082,10 @@ spec: format: int32 minimum: 1 type: integer - required: - - kind - - name type: object + x-kubernetes-validations: + - message: either remoteURL or kind must be set, but not both + rule: has(self.remoteURL) != has(self.kind) type: array x-kubernetes-list-type: atomic postDeleteChecks: diff --git a/config/crd/bases/config.projectsveltos.io_clusterpromotions.yaml b/config/crd/bases/config.projectsveltos.io_clusterpromotions.yaml index bf61cfda..d6752f22 100644 --- a/config/crd/bases/config.projectsveltos.io_clusterpromotions.yaml +++ b/config/crd/bases/config.projectsveltos.io_clusterpromotions.yaml @@ -878,6 +878,7 @@ spec: Kind of the resource. Supported kinds are: - ConfigMap/Secret - flux GitRepository;OCIRepository;Bucket + Required when RemoteURL is not set. enum: - GitRepository - OCIRepository @@ -889,7 +890,7 @@ spec: description: |- Name of the referenced resource. Name can be expressed as a template and instantiate using any cluster field. - minLength: 1 + Required when RemoteURL is not set. type: string namespace: description: |- @@ -898,6 +899,7 @@ spec: be implicit set to cluster's namespace. For Profile namespace must be left empty. Profile namespace will be used. Namespace can be expressed as a template and instantiate using any cluster field. + Not used when RemoteURL is set. type: string optional: default: false @@ -912,6 +914,52 @@ spec: Defaults to 'None', which translates to the root path of the SourceRef. Used only for GitRepository;OCIRepository;Bucket type: string + remoteURL: + description: |- + RemoteURL configures fetching content from an HTTP/HTTPS endpoint. + When set, Kind/Name/Namespace must be omitted. + properties: + interval: + description: |- + Interval defines how often Sveltos re-fetches the URL to detect changes. + Defaults to 5 minutes. + type: string + secretRef: + description: |- + SecretRef references a Secret in the management cluster containing optional + credentials for fetching the URL. Supported Secret keys: + "token" — Bearer token (Authorization: Bearer ) + "username"+"password" — HTTP Basic Auth + "caFile" — PEM-encoded CA certificate for TLS verification + properties: + 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 + type: object + x-kubernetes-map-type: atomic + template: + description: |- + Template indicates that the content served at URL is a Go template that + must be instantiated using cluster fields and templateResourceRefs values + before deployment. Equivalent to the projectsveltos.io/template annotation + on a ConfigMap or Secret. + type: boolean + url: + description: |- + URL is an HTTP/HTTPS endpoint serving raw YAML/JSON/KYAML content. + Sveltos fetches the content on every reconciliation and redeploys if the + content hash has changed. + pattern: ^https?:// + type: string + required: + - url + type: object skipNamespaceCreation: default: false description: |- @@ -935,10 +983,10 @@ spec: format: int32 minimum: 1 type: integer - required: - - kind - - name type: object + x-kubernetes-validations: + - message: either remoteURL or kind must be set, but not both + rule: has(self.remoteURL) != has(self.kind) type: array x-kubernetes-list-type: atomic postDeleteChecks: @@ -1609,6 +1657,7 @@ spec: Kind of the resource. Supported kinds are: - ConfigMap/Secret - flux GitRepository;OCIRepository;Bucket + Required when RemoteURL is not set. enum: - GitRepository - OCIRepository @@ -1620,7 +1669,7 @@ spec: description: |- Name of the referenced resource. Name can be expressed as a template and instantiate using any cluster field. - minLength: 1 + Required when RemoteURL is not set. type: string namespace: description: |- @@ -1629,6 +1678,7 @@ spec: be implicit set to cluster's namespace. For Profile namespace must be left empty. Profile namespace will be used. Namespace can be expressed as a template and instantiate using any cluster field. + Not used when RemoteURL is set. type: string optional: default: false @@ -1643,6 +1693,52 @@ spec: Defaults to 'None', which translates to the root path of the SourceRef. Used only for GitRepository;OCIRepository;Bucket type: string + remoteURL: + description: |- + RemoteURL configures fetching content from an HTTP/HTTPS endpoint. + When set, Kind/Name/Namespace must be omitted. + properties: + interval: + description: |- + Interval defines how often Sveltos re-fetches the URL to detect changes. + Defaults to 5 minutes. + type: string + secretRef: + description: |- + SecretRef references a Secret in the management cluster containing optional + credentials for fetching the URL. Supported Secret keys: + "token" — Bearer token (Authorization: Bearer ) + "username"+"password" — HTTP Basic Auth + "caFile" — PEM-encoded CA certificate for TLS verification + properties: + 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 + type: object + x-kubernetes-map-type: atomic + template: + description: |- + Template indicates that the content served at URL is a Go template that + must be instantiated using cluster fields and templateResourceRefs values + before deployment. Equivalent to the projectsveltos.io/template annotation + on a ConfigMap or Secret. + type: boolean + url: + description: |- + URL is an HTTP/HTTPS endpoint serving raw YAML/JSON/KYAML content. + Sveltos fetches the content on every reconciliation and redeploys if the + content hash has changed. + pattern: ^https?:// + type: string + required: + - url + type: object skipNamespaceCreation: default: false description: |- @@ -1666,10 +1762,11 @@ spec: format: int32 minimum: 1 type: integer - required: - - kind - - name type: object + x-kubernetes-validations: + - message: either remoteURL or kind must be set, but + not both + rule: has(self.remoteURL) != has(self.kind) type: array promotionWindow: description: |- @@ -1850,6 +1947,7 @@ spec: Kind of the resource. Supported kinds are: - ConfigMap/Secret - flux GitRepository;OCIRepository;Bucket + Required when RemoteURL is not set. enum: - GitRepository - OCIRepository @@ -1861,7 +1959,7 @@ spec: description: |- Name of the referenced resource. Name can be expressed as a template and instantiate using any cluster field. - minLength: 1 + Required when RemoteURL is not set. type: string namespace: description: |- @@ -1870,6 +1968,7 @@ spec: be implicit set to cluster's namespace. For Profile namespace must be left empty. Profile namespace will be used. Namespace can be expressed as a template and instantiate using any cluster field. + Not used when RemoteURL is set. type: string optional: default: false @@ -1884,6 +1983,52 @@ spec: Defaults to 'None', which translates to the root path of the SourceRef. Used only for GitRepository;OCIRepository;Bucket type: string + remoteURL: + description: |- + RemoteURL configures fetching content from an HTTP/HTTPS endpoint. + When set, Kind/Name/Namespace must be omitted. + properties: + interval: + description: |- + Interval defines how often Sveltos re-fetches the URL to detect changes. + Defaults to 5 minutes. + type: string + secretRef: + description: |- + SecretRef references a Secret in the management cluster containing optional + credentials for fetching the URL. Supported Secret keys: + "token" — Bearer token (Authorization: Bearer ) + "username"+"password" — HTTP Basic Auth + "caFile" — PEM-encoded CA certificate for TLS verification + properties: + 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 + type: object + x-kubernetes-map-type: atomic + template: + description: |- + Template indicates that the content served at URL is a Go template that + must be instantiated using cluster fields and templateResourceRefs values + before deployment. Equivalent to the projectsveltos.io/template annotation + on a ConfigMap or Secret. + type: boolean + url: + description: |- + URL is an HTTP/HTTPS endpoint serving raw YAML/JSON/KYAML content. + Sveltos fetches the content on every reconciliation and redeploys if the + content hash has changed. + pattern: ^https?:// + type: string + required: + - url + type: object skipNamespaceCreation: default: false description: |- @@ -1907,10 +2052,11 @@ spec: format: int32 minimum: 1 type: integer - required: - - kind - - name type: object + x-kubernetes-validations: + - message: either remoteURL or kind must be set, but + not both + rule: has(self.remoteURL) != has(self.kind) type: array type: object type: object diff --git a/config/crd/bases/config.projectsveltos.io_clustersummaries.yaml b/config/crd/bases/config.projectsveltos.io_clustersummaries.yaml index 450c3c40..8f14dbe0 100644 --- a/config/crd/bases/config.projectsveltos.io_clustersummaries.yaml +++ b/config/crd/bases/config.projectsveltos.io_clustersummaries.yaml @@ -1015,6 +1015,7 @@ spec: Kind of the resource. Supported kinds are: - ConfigMap/Secret - flux GitRepository;OCIRepository;Bucket + Required when RemoteURL is not set. enum: - GitRepository - OCIRepository @@ -1026,7 +1027,7 @@ spec: description: |- Name of the referenced resource. Name can be expressed as a template and instantiate using any cluster field. - minLength: 1 + Required when RemoteURL is not set. type: string namespace: description: |- @@ -1035,6 +1036,7 @@ spec: be implicit set to cluster's namespace. For Profile namespace must be left empty. Profile namespace will be used. Namespace can be expressed as a template and instantiate using any cluster field. + Not used when RemoteURL is set. type: string optional: default: false @@ -1049,6 +1051,52 @@ spec: Defaults to 'None', which translates to the root path of the SourceRef. Used only for GitRepository;OCIRepository;Bucket type: string + remoteURL: + description: |- + RemoteURL configures fetching content from an HTTP/HTTPS endpoint. + When set, Kind/Name/Namespace must be omitted. + properties: + interval: + description: |- + Interval defines how often Sveltos re-fetches the URL to detect changes. + Defaults to 5 minutes. + type: string + secretRef: + description: |- + SecretRef references a Secret in the management cluster containing optional + credentials for fetching the URL. Supported Secret keys: + "token" — Bearer token (Authorization: Bearer ) + "username"+"password" — HTTP Basic Auth + "caFile" — PEM-encoded CA certificate for TLS verification + properties: + 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 + type: object + x-kubernetes-map-type: atomic + template: + description: |- + Template indicates that the content served at URL is a Go template that + must be instantiated using cluster fields and templateResourceRefs values + before deployment. Equivalent to the projectsveltos.io/template annotation + on a ConfigMap or Secret. + type: boolean + url: + description: |- + URL is an HTTP/HTTPS endpoint serving raw YAML/JSON/KYAML content. + Sveltos fetches the content on every reconciliation and redeploys if the + content hash has changed. + pattern: ^https?:// + type: string + required: + - url + type: object skipNamespaceCreation: default: false description: |- @@ -1072,10 +1120,10 @@ spec: format: int32 minimum: 1 type: integer - required: - - kind - - name type: object + x-kubernetes-validations: + - message: either remoteURL or kind must be set, but not both + rule: has(self.remoteURL) != has(self.kind) type: array x-kubernetes-list-type: atomic postDeleteChecks: diff --git a/config/crd/bases/config.projectsveltos.io_profiles.yaml b/config/crd/bases/config.projectsveltos.io_profiles.yaml index 2de68f48..ead8b0fe 100644 --- a/config/crd/bases/config.projectsveltos.io_profiles.yaml +++ b/config/crd/bases/config.projectsveltos.io_profiles.yaml @@ -977,6 +977,7 @@ spec: Kind of the resource. Supported kinds are: - ConfigMap/Secret - flux GitRepository;OCIRepository;Bucket + Required when RemoteURL is not set. enum: - GitRepository - OCIRepository @@ -988,7 +989,7 @@ spec: description: |- Name of the referenced resource. Name can be expressed as a template and instantiate using any cluster field. - minLength: 1 + Required when RemoteURL is not set. type: string namespace: description: |- @@ -997,6 +998,7 @@ spec: be implicit set to cluster's namespace. For Profile namespace must be left empty. Profile namespace will be used. Namespace can be expressed as a template and instantiate using any cluster field. + Not used when RemoteURL is set. type: string optional: default: false @@ -1011,6 +1013,52 @@ spec: Defaults to 'None', which translates to the root path of the SourceRef. Used only for GitRepository;OCIRepository;Bucket type: string + remoteURL: + description: |- + RemoteURL configures fetching content from an HTTP/HTTPS endpoint. + When set, Kind/Name/Namespace must be omitted. + properties: + interval: + description: |- + Interval defines how often Sveltos re-fetches the URL to detect changes. + Defaults to 5 minutes. + type: string + secretRef: + description: |- + SecretRef references a Secret in the management cluster containing optional + credentials for fetching the URL. Supported Secret keys: + "token" — Bearer token (Authorization: Bearer ) + "username"+"password" — HTTP Basic Auth + "caFile" — PEM-encoded CA certificate for TLS verification + properties: + 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 + type: object + x-kubernetes-map-type: atomic + template: + description: |- + Template indicates that the content served at URL is a Go template that + must be instantiated using cluster fields and templateResourceRefs values + before deployment. Equivalent to the projectsveltos.io/template annotation + on a ConfigMap or Secret. + type: boolean + url: + description: |- + URL is an HTTP/HTTPS endpoint serving raw YAML/JSON/KYAML content. + Sveltos fetches the content on every reconciliation and redeploys if the + content hash has changed. + pattern: ^https?:// + type: string + required: + - url + type: object skipNamespaceCreation: default: false description: |- @@ -1034,10 +1082,10 @@ spec: format: int32 minimum: 1 type: integer - required: - - kind - - name type: object + x-kubernetes-validations: + - message: either remoteURL or kind must be set, but not both + rule: has(self.remoteURL) != has(self.kind) type: array x-kubernetes-list-type: atomic postDeleteChecks: diff --git a/controllers/clustersummary_controller.go b/controllers/clustersummary_controller.go index e87c75c0..38166b6b 100644 --- a/controllers/clustersummary_controller.go +++ b/controllers/clustersummary_controller.go @@ -126,7 +126,16 @@ type ClusterSummaryReconciler struct { eventRecorder events.EventRecorder DeletedInstances map[types.NamespacedName]time.Time - NextReconcileTimes map[types.NamespacedName]time.Time // in-memory cooldown, survives status-patch conflicts + NextReconcileTimes map[types.NamespacedName]reconcileCooldown // in-memory cooldown, survives status-patch conflicts +} + +// reconcileCooldown tracks when a ClusterSummary may next be reconciled and the spec +// generation that was current when the cooldown was set. If the generation has advanced +// (i.e. the spec changed) the cooldown is discarded so the new spec takes effect +// immediately. +type reconcileCooldown struct { + Until time.Time + Generation int64 } // If the drift-detection component is deployed in the management cluster, the addon-controller will deploy ResourceSummaries within the same cluster, @@ -566,6 +575,16 @@ func (r *ClusterSummaryReconciler) proceedDeployingClusterSummary(ctx context.Co return reconcile.Result{Requeue: true, RequeueAfter: dryRunRequeueAfter}, nil } + // If any PolicyRef uses a URL source, schedule a periodic re-fetch so that + // remote content changes are detected even without a Kubernetes watch event. + // We deliberately do NOT call setNextReconcileTime here: the interval is a + // polling floor, not a cooldown — external events should still trigger + // immediate reconciliation. + if interval := minURLInterval(clusterSummaryScope.ClusterSummary.Spec.ClusterProfileSpec.PolicyRefs); interval > 0 { + r.setNextReconcileTime(clusterSummaryScope, interval) + return reconcile.Result{RequeueAfter: interval}, nil + } + return reconcile.Result{}, nil } @@ -577,18 +596,15 @@ func (r *ClusterSummaryReconciler) proceedDeployingClusterSummary(ctx context.Co func (r *ClusterSummaryReconciler) setNextReconcileTime( clusterSummaryScope *scope.ClusterSummaryScope, d time.Duration) { + cs := clusterSummaryScope.ClusterSummary nextTime := time.Now().Add(d) - clusterSummaryScope.ClusterSummary.Status.NextReconcileTime = - &metav1.Time{Time: nextTime} + cs.Status.NextReconcileTime = &metav1.Time{Time: nextTime} // Mirror in the in-memory map so skipReconciliation works even if scope.Close() // encounters a conflict and the status field is never persisted. - key := types.NamespacedName{ - Namespace: clusterSummaryScope.ClusterSummary.Namespace, - Name: clusterSummaryScope.ClusterSummary.Name, - } + key := types.NamespacedName{Namespace: cs.Namespace, Name: cs.Name} r.PolicyMux.Lock() - r.NextReconcileTimes[key] = nextTime + r.NextReconcileTimes[key] = reconcileCooldown{Until: nextTime, Generation: cs.Generation} r.PolicyMux.Unlock() } @@ -605,8 +621,8 @@ func (r *ClusterSummaryReconciler) remainingCooldown( } r.PolicyMux.Lock() - if v, ok := r.NextReconcileTimes[req.NamespacedName]; ok { - if remaining := time.Until(v); remaining > requeueAfter { + if cd, ok := r.NextReconcileTimes[req.NamespacedName]; ok { + if remaining := time.Until(cd.Until); remaining > requeueAfter { requeueAfter = remaining } } @@ -670,7 +686,7 @@ func (r *ClusterSummaryReconciler) SetupWithManager(ctx context.Context, mgr ctr initializeManager(ctrl.Log.WithName("watchers"), mgr.GetConfig(), mgr.GetClient()) r.DeletedInstances = make(map[types.NamespacedName]time.Time) - r.NextReconcileTimes = make(map[types.NamespacedName]time.Time) + r.NextReconcileTimes = make(map[types.NamespacedName]reconcileCooldown) r.eventRecorder = mgr.GetEventRecorder("event-recorder") r.ctrl = c @@ -1735,18 +1751,27 @@ func (r *ClusterSummaryReconciler) skipReconciliation(clusterSummaryScope *scope } } - // Checking if reconciliation should happen — check both the persisted status field - // and the in-memory map (which survives status-patch conflicts). - now := time.Now() - if cs.Status.NextReconcileTime != nil && now.Before(cs.Status.NextReconcileTime.Time) { - return true - } - if v, ok := r.NextReconcileTimes[req.NamespacedName]; ok { - if now.Before(v) { + if cd, ok := r.NextReconcileTimes[req.NamespacedName]; ok { + // A spec change (generation bump) always wins over a running cooldown so that + // changes to Interval or other URL fields take effect immediately. + if cd.Generation < cs.Generation { + delete(r.NextReconcileTimes, req.NamespacedName) + cs.Status.NextReconcileTime = nil + return false + } + if time.Now().Before(cd.Until) { return true } // Cooldown expired — remove from map delete(r.NextReconcileTimes, req.NamespacedName) + } else if cs.Status.NextReconcileTime != nil && time.Now().Before(cs.Status.NextReconcileTime.Time) { + // No in-memory entry but status says we should wait — this is the post-restart case. + // Rebuild the in-memory entry so subsequent events use the faster map path. + r.NextReconcileTimes[req.NamespacedName] = reconcileCooldown{ + Until: cs.Status.NextReconcileTime.Time, + Generation: cs.Generation, + } + return true } cs.Status.NextReconcileTime = nil diff --git a/controllers/controllers_suite_test.go b/controllers/controllers_suite_test.go index dc5c101d..7ce89644 100644 --- a/controllers/controllers_suite_test.go +++ b/controllers/controllers_suite_test.go @@ -177,7 +177,7 @@ func getClusterSummaryReconciler(c client.Client, dep deployer.DeployerInterface ClusterMap: make(map[corev1.ObjectReference]*libsveltosset.Set), ReferenceMap: make(map[corev1.ObjectReference]*libsveltosset.Set), DeletedInstances: make(map[types.NamespacedName]time.Time), - NextReconcileTimes: make(map[types.NamespacedName]time.Time), + NextReconcileTimes: make(map[types.NamespacedName]controllers.ReconcileCooldown), PolicyMux: sync.Mutex{}, } } diff --git a/controllers/export_test.go b/controllers/export_test.go index 90e74d4e..c2b7c73d 100644 --- a/controllers/export_test.go +++ b/controllers/export_test.go @@ -166,7 +166,8 @@ var ( ) type ( - ReleaseInfo = releaseInfo + ReleaseInfo = releaseInfo + ReconcileCooldown = reconcileCooldown ) var ( diff --git a/controllers/handlers_resources.go b/controllers/handlers_resources.go index bb685d90..86beb707 100644 --- a/controllers/handlers_resources.go +++ b/controllers/handlers_resources.go @@ -594,11 +594,45 @@ func resourcesHash(ctx context.Context, c client.Client, clusterSummary *configv } } + urlHash, err := urlPolicyRefsHash(ctx, clusterSummary, logger) + if err != nil { + return nil, err + } + config += urlHash + h := sha256.New() h.Write([]byte(config)) return h.Sum(nil), nil } +// urlPolicyRefsHash fetches each URL-based PolicyRef and returns a hash string +// that covers their content, tier, and template flag. +func urlPolicyRefsHash(ctx context.Context, clusterSummary *configv1beta1.ClusterSummary, + logger logr.Logger) (string, error) { + + result := "" + for i := range clusterSummary.Spec.ClusterProfileSpec.PolicyRefs { + ref := &clusterSummary.Spec.ClusterProfileSpec.PolicyRefs[i] + if ref.RemoteURL == nil { + continue + } + body, err := fetchURL(ctx, ref.RemoteURL.URL, ref.RemoteURL.SecretRef, clusterSummary.Namespace, logger) + if err != nil { + if ref.Optional { + logger.V(logs.LogInfo).Info(fmt.Sprintf( + "optional URL source %s could not be fetched for hashing, ignoring: %v", ref.RemoteURL.URL, err)) + continue + } + return "", err + } + h := sha256.Sum256(body) + result += hex.EncodeToString(h[:]) + result += fmt.Sprintf("%d", ref.Tier) + result += fmt.Sprintf("%t", ref.RemoteURL.Template) + } + return result, nil +} + func getInstantiatedPolicyRefInfo(ctx context.Context, c client.Client, clusterSummary *configv1beta1.ClusterSummary, sortedPolicyRefs []configv1beta1.PolicyRef, logger logr.Logger, ) (referencedObjects []corev1.ObjectReference, referencedObjectTiers map[corev1.ObjectReference]int32) { @@ -607,6 +641,12 @@ func getInstantiatedPolicyRefInfo(ctx context.Context, c client.Client, clusterS referencedObjectTiers = make(map[corev1.ObjectReference]int32, len(clusterSummary.Spec.ClusterProfileSpec.PolicyRefs)) for i := range sortedPolicyRefs { reference := &sortedPolicyRefs[i] + + // URL-based refs have no Kubernetes object to look up; they are hashed separately. + if reference.RemoteURL != nil { + continue + } + namespace, err := libsveltostemplate.GetReferenceResourceNamespace(ctx, c, clusterSummary.Spec.ClusterNamespace, clusterSummary.Spec.ClusterName, reference.Namespace, clusterSummary.Spec.ClusterType) diff --git a/controllers/handlers_utils.go b/controllers/handlers_utils.go index 0eafeb00..08e7e790 100644 --- a/controllers/handlers_utils.go +++ b/controllers/handlers_utils.go @@ -74,6 +74,10 @@ type referencedObject struct { SkipNamespaceCreation bool Optional bool Path string + // URL and related fields are set only for URL-based PolicyRefs (Kind == urlSourceKind). + URL string + IsTemplate bool + SecretRef *corev1.LocalObjectReference } func getClusterSummaryAnnotationValue(clusterSummary *configv1beta1.ClusterSummary) string { @@ -732,6 +736,26 @@ func collectReferencedObjects(references []configv1beta1.PolicyRef) (local, remo var object referencedObject reference := &references[i] + if reference.RemoteURL != nil { + object.ObjectReference = corev1.ObjectReference{ + Kind: urlSourceKind, + Name: reference.RemoteURL.URL, + } + object.URL = reference.RemoteURL.URL + object.IsTemplate = reference.RemoteURL.Template + object.SecretRef = reference.RemoteURL.SecretRef + object.Tier = reference.Tier + object.Optional = reference.Optional + object.SkipNamespaceCreation = reference.SkipNamespaceCreation + + if reference.DeploymentType == configv1beta1.DeploymentTypeLocal { + local = append(local, object) + } else { + remote = append(remote, object) + } + continue + } + switch reference.Kind { case string(libsveltosv1beta1.ConfigMapReferencedResourceKind): object.ObjectReference = corev1.ObjectReference{ @@ -914,6 +938,20 @@ func deployObjects(ctx context.Context, deployingToMgmtCluster bool, destClient reports = make([]libsveltosv1beta1.ResourceReport, 0, len(referencedObjects)) for i := range referencedObjects { + var tmpResourceReports []libsveltosv1beta1.ResourceReport + + if referencedObjects[i].URL != "" { + tmpResourceReports, err = deployContentOfURL(ctx, deployingToMgmtCluster, destConfig, destClient, + &referencedObjects[i], dCtx, logger) + if tmpResourceReports != nil { + reports = append(reports, tmpResourceReports...) + } + if err != nil { + return reports, err + } + continue + } + referencedObject, err := getReferencedObject(ctx, getManagementClusterClient(), clusterSummary, &referencedObjects[i], logger) if err != nil { @@ -923,7 +961,6 @@ func deployObjects(ctx context.Context, deployingToMgmtCluster bool, destClient continue } - var tmpResourceReports []libsveltosv1beta1.ResourceReport if referencedObjects[i].GroupVersionKind().Kind == string(libsveltosv1beta1.ConfigMapReferencedResourceKind) { configMap := referencedObject.(*corev1.ConfigMap) l := logger.WithValues("configMapNamespace", configMap.Namespace, "configMapName", configMap.Name) diff --git a/controllers/url_source.go b/controllers/url_source.go new file mode 100644 index 00000000..403d5e1c --- /dev/null +++ b/controllers/url_source.go @@ -0,0 +1,166 @@ +/* +Copyright 2025. projectsveltos.io. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net/http" + "time" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + configv1beta1 "github.com/projectsveltos/addon-controller/api/v1beta1" + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" + logs "github.com/projectsveltos/libsveltos/lib/logsettings" +) + +const ( + urlSourceKind = "URL" + urlFetchTimeout = 30 * time.Second + defaultURLInterval = 5 * time.Minute +) + +// fetchURL retrieves the raw content from rawURL. +// If secretRef is non-nil, the Secret named secretRef.Name in secretNamespace is read +// for optional auth credentials (keys: "token", "username"+"password", "caFile"). +func fetchURL(ctx context.Context, rawURL string, secretRef *corev1.LocalObjectReference, + secretNamespace string, logger logr.Logger) ([]byte, error) { + + transport := http.DefaultTransport + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to build HTTP request for %s: %w", rawURL, err) + } + + if secretRef != nil { + secret, err := getSecret(ctx, getManagementClusterClient(), + types.NamespacedName{Namespace: secretNamespace, Name: secretRef.Name}) + if err != nil { + return nil, fmt.Errorf("failed to get auth secret %s/%s for URL %s: %w", + secretNamespace, secretRef.Name, rawURL, err) + } + + if token, ok := secret.Data["token"]; ok { + req.Header.Set("Authorization", "Bearer "+string(token)) + } else if username, ok := secret.Data["username"]; ok { + if password, ok := secret.Data["password"]; ok { + req.SetBasicAuth(string(username), string(password)) + } + } + + if caFile, ok := secret.Data["caFile"]; ok { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caFile) { + return nil, fmt.Errorf("failed to parse caFile from secret %s/%s", + secretNamespace, secretRef.Name) + } + transport = &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool}, + } + } + } + + httpClient := &http.Client{ + Timeout: urlFetchTimeout, + Transport: transport, + } + + logger.V(logs.LogDebug).Info(fmt.Sprintf("fetching URL %s", rawURL)) + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s: %w", rawURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP status %d fetching %s", resp.StatusCode, rawURL) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body from %s: %w", rawURL, err) + } + + return body, nil +} + +// deployContentOfURL fetches YAML/JSON content from a remote URL and deploys it +// to the destination cluster using the same pipeline as ConfigMap/Secret sources. +func deployContentOfURL(ctx context.Context, deployingToMgmtCluster bool, destConfig *rest.Config, + destClient client.Client, ref *referencedObject, dCtx *deploymentContext, + logger logr.Logger) ([]libsveltosv1beta1.ResourceReport, error) { + + secretNamespace := dCtx.clusterSummary.Namespace + body, err := fetchURL(ctx, ref.URL, ref.SecretRef, secretNamespace, logger) + if err != nil { + if ref.Optional { + logger.V(logs.LogInfo).Info(fmt.Sprintf( + "optional URL source %s could not be fetched, ignoring: %v", ref.URL, err)) + return nil, nil + } + return nil, err + } + + // Build a synthetic source object so that deployContent can read annotations + // the same way it does for ConfigMap/Secret references. + syntheticSource := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: ref.URL, + }, + } + if ref.IsTemplate { + syntheticSource.Annotations = map[string]string{ + libsveltosv1beta1.PolicyTemplateAnnotation: "ok", + } + } + + data := map[string]string{"content.yaml": string(body)} + l := logger.WithValues("url", ref.URL) + l.V(logs.LogDebug).Info("deploying URL content") + + return deployContent(ctx, deployingToMgmtCluster, destConfig, destClient, syntheticSource, + data, ref.Tier, ref.SkipNamespaceCreation, dCtx, l) +} + +// minURLInterval returns the shortest polling interval across all URL-based PolicyRefs, +// using defaultURLInterval for any entry that does not specify an explicit interval. +// Returns 0 if no URL-based PolicyRefs are present. +func minURLInterval(refs []configv1beta1.PolicyRef) time.Duration { + result := time.Duration(0) + for i := range refs { + if refs[i].RemoteURL == nil { + continue + } + interval := defaultURLInterval + if refs[i].RemoteURL.Interval != nil && refs[i].RemoteURL.Interval.Duration > 0 { + interval = refs[i].RemoteURL.Interval.Duration + } + if result == 0 || interval < result { + result = interval + } + } + return result +} diff --git a/manifest/manifest.yaml b/manifest/manifest.yaml index 4d8ab23a..cb04db89 100644 --- a/manifest/manifest.yaml +++ b/manifest/manifest.yaml @@ -1286,6 +1286,7 @@ spec: Kind of the resource. Supported kinds are: - ConfigMap/Secret - flux GitRepository;OCIRepository;Bucket + Required when RemoteURL is not set. enum: - GitRepository - OCIRepository @@ -1297,7 +1298,7 @@ spec: description: |- Name of the referenced resource. Name can be expressed as a template and instantiate using any cluster field. - minLength: 1 + Required when RemoteURL is not set. type: string namespace: description: |- @@ -1306,6 +1307,7 @@ spec: be implicit set to cluster's namespace. For Profile namespace must be left empty. Profile namespace will be used. Namespace can be expressed as a template and instantiate using any cluster field. + Not used when RemoteURL is set. type: string optional: default: false @@ -1320,6 +1322,52 @@ spec: Defaults to 'None', which translates to the root path of the SourceRef. Used only for GitRepository;OCIRepository;Bucket type: string + remoteURL: + description: |- + RemoteURL configures fetching content from an HTTP/HTTPS endpoint. + When set, Kind/Name/Namespace must be omitted. + properties: + interval: + description: |- + Interval defines how often Sveltos re-fetches the URL to detect changes. + Defaults to 5 minutes. + type: string + secretRef: + description: |- + SecretRef references a Secret in the management cluster containing optional + credentials for fetching the URL. Supported Secret keys: + "token" — Bearer token (Authorization: Bearer ) + "username"+"password" — HTTP Basic Auth + "caFile" — PEM-encoded CA certificate for TLS verification + properties: + 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 + type: object + x-kubernetes-map-type: atomic + template: + description: |- + Template indicates that the content served at URL is a Go template that + must be instantiated using cluster fields and templateResourceRefs values + before deployment. Equivalent to the projectsveltos.io/template annotation + on a ConfigMap or Secret. + type: boolean + url: + description: |- + URL is an HTTP/HTTPS endpoint serving raw YAML/JSON/KYAML content. + Sveltos fetches the content on every reconciliation and redeploys if the + content hash has changed. + pattern: ^https?:// + type: string + required: + - url + type: object skipNamespaceCreation: default: false description: |- @@ -1343,10 +1391,10 @@ spec: format: int32 minimum: 1 type: integer - required: - - kind - - name type: object + x-kubernetes-validations: + - message: either remoteURL or kind must be set, but not both + rule: has(self.remoteURL) != has(self.kind) type: array x-kubernetes-list-type: atomic postDeleteChecks: @@ -2960,6 +3008,7 @@ spec: Kind of the resource. Supported kinds are: - ConfigMap/Secret - flux GitRepository;OCIRepository;Bucket + Required when RemoteURL is not set. enum: - GitRepository - OCIRepository @@ -2971,7 +3020,7 @@ spec: description: |- Name of the referenced resource. Name can be expressed as a template and instantiate using any cluster field. - minLength: 1 + Required when RemoteURL is not set. type: string namespace: description: |- @@ -2980,6 +3029,7 @@ spec: be implicit set to cluster's namespace. For Profile namespace must be left empty. Profile namespace will be used. Namespace can be expressed as a template and instantiate using any cluster field. + Not used when RemoteURL is set. type: string optional: default: false @@ -2994,6 +3044,52 @@ spec: Defaults to 'None', which translates to the root path of the SourceRef. Used only for GitRepository;OCIRepository;Bucket type: string + remoteURL: + description: |- + RemoteURL configures fetching content from an HTTP/HTTPS endpoint. + When set, Kind/Name/Namespace must be omitted. + properties: + interval: + description: |- + Interval defines how often Sveltos re-fetches the URL to detect changes. + Defaults to 5 minutes. + type: string + secretRef: + description: |- + SecretRef references a Secret in the management cluster containing optional + credentials for fetching the URL. Supported Secret keys: + "token" — Bearer token (Authorization: Bearer ) + "username"+"password" — HTTP Basic Auth + "caFile" — PEM-encoded CA certificate for TLS verification + properties: + 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 + type: object + x-kubernetes-map-type: atomic + template: + description: |- + Template indicates that the content served at URL is a Go template that + must be instantiated using cluster fields and templateResourceRefs values + before deployment. Equivalent to the projectsveltos.io/template annotation + on a ConfigMap or Secret. + type: boolean + url: + description: |- + URL is an HTTP/HTTPS endpoint serving raw YAML/JSON/KYAML content. + Sveltos fetches the content on every reconciliation and redeploys if the + content hash has changed. + pattern: ^https?:// + type: string + required: + - url + type: object skipNamespaceCreation: default: false description: |- @@ -3017,10 +3113,10 @@ spec: format: int32 minimum: 1 type: integer - required: - - kind - - name type: object + x-kubernetes-validations: + - message: either remoteURL or kind must be set, but not both + rule: has(self.remoteURL) != has(self.kind) type: array x-kubernetes-list-type: atomic postDeleteChecks: @@ -3691,6 +3787,7 @@ spec: Kind of the resource. Supported kinds are: - ConfigMap/Secret - flux GitRepository;OCIRepository;Bucket + Required when RemoteURL is not set. enum: - GitRepository - OCIRepository @@ -3702,7 +3799,7 @@ spec: description: |- Name of the referenced resource. Name can be expressed as a template and instantiate using any cluster field. - minLength: 1 + Required when RemoteURL is not set. type: string namespace: description: |- @@ -3711,6 +3808,7 @@ spec: be implicit set to cluster's namespace. For Profile namespace must be left empty. Profile namespace will be used. Namespace can be expressed as a template and instantiate using any cluster field. + Not used when RemoteURL is set. type: string optional: default: false @@ -3725,6 +3823,52 @@ spec: Defaults to 'None', which translates to the root path of the SourceRef. Used only for GitRepository;OCIRepository;Bucket type: string + remoteURL: + description: |- + RemoteURL configures fetching content from an HTTP/HTTPS endpoint. + When set, Kind/Name/Namespace must be omitted. + properties: + interval: + description: |- + Interval defines how often Sveltos re-fetches the URL to detect changes. + Defaults to 5 minutes. + type: string + secretRef: + description: |- + SecretRef references a Secret in the management cluster containing optional + credentials for fetching the URL. Supported Secret keys: + "token" — Bearer token (Authorization: Bearer ) + "username"+"password" — HTTP Basic Auth + "caFile" — PEM-encoded CA certificate for TLS verification + properties: + 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 + type: object + x-kubernetes-map-type: atomic + template: + description: |- + Template indicates that the content served at URL is a Go template that + must be instantiated using cluster fields and templateResourceRefs values + before deployment. Equivalent to the projectsveltos.io/template annotation + on a ConfigMap or Secret. + type: boolean + url: + description: |- + URL is an HTTP/HTTPS endpoint serving raw YAML/JSON/KYAML content. + Sveltos fetches the content on every reconciliation and redeploys if the + content hash has changed. + pattern: ^https?:// + type: string + required: + - url + type: object skipNamespaceCreation: default: false description: |- @@ -3748,10 +3892,11 @@ spec: format: int32 minimum: 1 type: integer - required: - - kind - - name type: object + x-kubernetes-validations: + - message: either remoteURL or kind must be set, but + not both + rule: has(self.remoteURL) != has(self.kind) type: array promotionWindow: description: |- @@ -3932,6 +4077,7 @@ spec: Kind of the resource. Supported kinds are: - ConfigMap/Secret - flux GitRepository;OCIRepository;Bucket + Required when RemoteURL is not set. enum: - GitRepository - OCIRepository @@ -3943,7 +4089,7 @@ spec: description: |- Name of the referenced resource. Name can be expressed as a template and instantiate using any cluster field. - minLength: 1 + Required when RemoteURL is not set. type: string namespace: description: |- @@ -3952,6 +4098,7 @@ spec: be implicit set to cluster's namespace. For Profile namespace must be left empty. Profile namespace will be used. Namespace can be expressed as a template and instantiate using any cluster field. + Not used when RemoteURL is set. type: string optional: default: false @@ -3966,6 +4113,52 @@ spec: Defaults to 'None', which translates to the root path of the SourceRef. Used only for GitRepository;OCIRepository;Bucket type: string + remoteURL: + description: |- + RemoteURL configures fetching content from an HTTP/HTTPS endpoint. + When set, Kind/Name/Namespace must be omitted. + properties: + interval: + description: |- + Interval defines how often Sveltos re-fetches the URL to detect changes. + Defaults to 5 minutes. + type: string + secretRef: + description: |- + SecretRef references a Secret in the management cluster containing optional + credentials for fetching the URL. Supported Secret keys: + "token" — Bearer token (Authorization: Bearer ) + "username"+"password" — HTTP Basic Auth + "caFile" — PEM-encoded CA certificate for TLS verification + properties: + 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 + type: object + x-kubernetes-map-type: atomic + template: + description: |- + Template indicates that the content served at URL is a Go template that + must be instantiated using cluster fields and templateResourceRefs values + before deployment. Equivalent to the projectsveltos.io/template annotation + on a ConfigMap or Secret. + type: boolean + url: + description: |- + URL is an HTTP/HTTPS endpoint serving raw YAML/JSON/KYAML content. + Sveltos fetches the content on every reconciliation and redeploys if the + content hash has changed. + pattern: ^https?:// + type: string + required: + - url + type: object skipNamespaceCreation: default: false description: |- @@ -3989,10 +4182,11 @@ spec: format: int32 minimum: 1 type: integer - required: - - kind - - name type: object + x-kubernetes-validations: + - message: either remoteURL or kind must be set, but + not both + rule: has(self.remoteURL) != has(self.kind) type: array type: object type: object @@ -5415,6 +5609,7 @@ spec: Kind of the resource. Supported kinds are: - ConfigMap/Secret - flux GitRepository;OCIRepository;Bucket + Required when RemoteURL is not set. enum: - GitRepository - OCIRepository @@ -5426,7 +5621,7 @@ spec: description: |- Name of the referenced resource. Name can be expressed as a template and instantiate using any cluster field. - minLength: 1 + Required when RemoteURL is not set. type: string namespace: description: |- @@ -5435,6 +5630,7 @@ spec: be implicit set to cluster's namespace. For Profile namespace must be left empty. Profile namespace will be used. Namespace can be expressed as a template and instantiate using any cluster field. + Not used when RemoteURL is set. type: string optional: default: false @@ -5449,6 +5645,52 @@ spec: Defaults to 'None', which translates to the root path of the SourceRef. Used only for GitRepository;OCIRepository;Bucket type: string + remoteURL: + description: |- + RemoteURL configures fetching content from an HTTP/HTTPS endpoint. + When set, Kind/Name/Namespace must be omitted. + properties: + interval: + description: |- + Interval defines how often Sveltos re-fetches the URL to detect changes. + Defaults to 5 minutes. + type: string + secretRef: + description: |- + SecretRef references a Secret in the management cluster containing optional + credentials for fetching the URL. Supported Secret keys: + "token" — Bearer token (Authorization: Bearer ) + "username"+"password" — HTTP Basic Auth + "caFile" — PEM-encoded CA certificate for TLS verification + properties: + 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 + type: object + x-kubernetes-map-type: atomic + template: + description: |- + Template indicates that the content served at URL is a Go template that + must be instantiated using cluster fields and templateResourceRefs values + before deployment. Equivalent to the projectsveltos.io/template annotation + on a ConfigMap or Secret. + type: boolean + url: + description: |- + URL is an HTTP/HTTPS endpoint serving raw YAML/JSON/KYAML content. + Sveltos fetches the content on every reconciliation and redeploys if the + content hash has changed. + pattern: ^https?:// + type: string + required: + - url + type: object skipNamespaceCreation: default: false description: |- @@ -5472,10 +5714,10 @@ spec: format: int32 minimum: 1 type: integer - required: - - kind - - name type: object + x-kubernetes-validations: + - message: either remoteURL or kind must be set, but not both + rule: has(self.remoteURL) != has(self.kind) type: array x-kubernetes-list-type: atomic postDeleteChecks: @@ -7120,6 +7362,7 @@ spec: Kind of the resource. Supported kinds are: - ConfigMap/Secret - flux GitRepository;OCIRepository;Bucket + Required when RemoteURL is not set. enum: - GitRepository - OCIRepository @@ -7131,7 +7374,7 @@ spec: description: |- Name of the referenced resource. Name can be expressed as a template and instantiate using any cluster field. - minLength: 1 + Required when RemoteURL is not set. type: string namespace: description: |- @@ -7140,6 +7383,7 @@ spec: be implicit set to cluster's namespace. For Profile namespace must be left empty. Profile namespace will be used. Namespace can be expressed as a template and instantiate using any cluster field. + Not used when RemoteURL is set. type: string optional: default: false @@ -7154,6 +7398,52 @@ spec: Defaults to 'None', which translates to the root path of the SourceRef. Used only for GitRepository;OCIRepository;Bucket type: string + remoteURL: + description: |- + RemoteURL configures fetching content from an HTTP/HTTPS endpoint. + When set, Kind/Name/Namespace must be omitted. + properties: + interval: + description: |- + Interval defines how often Sveltos re-fetches the URL to detect changes. + Defaults to 5 minutes. + type: string + secretRef: + description: |- + SecretRef references a Secret in the management cluster containing optional + credentials for fetching the URL. Supported Secret keys: + "token" — Bearer token (Authorization: Bearer ) + "username"+"password" — HTTP Basic Auth + "caFile" — PEM-encoded CA certificate for TLS verification + properties: + 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 + type: object + x-kubernetes-map-type: atomic + template: + description: |- + Template indicates that the content served at URL is a Go template that + must be instantiated using cluster fields and templateResourceRefs values + before deployment. Equivalent to the projectsveltos.io/template annotation + on a ConfigMap or Secret. + type: boolean + url: + description: |- + URL is an HTTP/HTTPS endpoint serving raw YAML/JSON/KYAML content. + Sveltos fetches the content on every reconciliation and redeploys if the + content hash has changed. + pattern: ^https?:// + type: string + required: + - url + type: object skipNamespaceCreation: default: false description: |- @@ -7177,10 +7467,10 @@ spec: format: int32 minimum: 1 type: integer - required: - - kind - - name type: object + x-kubernetes-validations: + - message: either remoteURL or kind must be set, but not both + rule: has(self.remoteURL) != has(self.kind) type: array x-kubernetes-list-type: atomic postDeleteChecks: diff --git a/test/fv/remote_url_test.go b/test/fv/remote_url_test.go new file mode 100644 index 00000000..1649ba7b --- /dev/null +++ b/test/fv/remote_url_test.go @@ -0,0 +1,166 @@ +/* +Copyright 2026. projectsveltos.io. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package fv_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" + + configv1beta1 "github.com/projectsveltos/addon-controller/api/v1beta1" + "github.com/projectsveltos/addon-controller/lib/clusterops" + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" +) + +var _ = Describe("Remote URL", func() { + const ( + namePrefix = "remote-url-" + clusterRoleName = "system:aggregated-metrics-reader" + deploymentNamespace = "kube-system" + deploymentName = "metrics-server" + saNamespace = "kube-system" + saName = "metrics-server" + ) + + // Extra Labels/Annotations are deprecated. Not supported in pull mode + // Do not run in PullMode. ExtraLabels/ExtraAnnotations are deprecated. So not implemented in pull mode. + It("Deploy the content of a remote URL", Label("FV", "EXTENDED"), func() { + Byf("Create a ClusterProfile matching Cluster %s/%s", kindWorkloadCluster.GetNamespace(), kindWorkloadCluster.GetName()) + clusterProfile := getClusterProfile(namePrefix, map[string]string{key: value}) + clusterProfile.Spec.SyncMode = configv1beta1.SyncModeContinuous + Expect(k8sClient.Create(context.TODO(), clusterProfile)).To(Succeed()) + + verifyClusterProfileMatches(clusterProfile) + + verifyClusterSummary(clusterops.ClusterProfileLabelName, clusterProfile.Name, &clusterProfile.Spec, + kindWorkloadCluster.GetNamespace(), kindWorkloadCluster.GetName(), getClusterType()) + + Byf("Update ClusterProfile %s to reference Remote URL", clusterProfile.Name) + currentClusterProfile := &configv1beta1.ClusterProfile{} + + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + Expect(k8sClient.Get(context.TODO(), + types.NamespacedName{Name: clusterProfile.Name}, currentClusterProfile)).To(Succeed()) + currentClusterProfile.Spec.PolicyRefs = []configv1beta1.PolicyRef{ + { + RemoteURL: &configv1beta1.RemoteURL{ + URL: "https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml", + Interval: &metav1.Duration{Duration: time.Hour}, + }, + }, + } + return k8sClient.Update(context.TODO(), currentClusterProfile) + }) + Expect(err).To(BeNil()) + + Expect(k8sClient.Get(context.TODO(), + types.NamespacedName{Name: clusterProfile.Name}, currentClusterProfile)).To(Succeed()) + + clusterSummary := verifyClusterSummary(clusterops.ClusterProfileLabelName, + currentClusterProfile.Name, ¤tClusterProfile.Spec, + kindWorkloadCluster.GetNamespace(), kindWorkloadCluster.GetName(), getClusterType()) + + Byf("Getting client to access the workload cluster") + workloadClient, err := getKindWorkloadClusterKubeconfig() + Expect(err).To(BeNil()) + Expect(workloadClient).ToNot(BeNil()) + + Byf("Verifying metric-server ClusterRole is created in the workload cluster") + Eventually(func() error { + currentClusterRole := &rbacv1.ClusterRole{} + return workloadClient.Get(context.TODO(), + types.NamespacedName{Name: clusterRoleName}, + currentClusterRole) + }, timeout, pollingInterval).Should(BeNil()) + + Byf("Verifying metric-server Deplyment is created in the workload cluster") + Eventually(func() error { + currentDeployment := &appsv1.Deployment{} + return workloadClient.Get(context.TODO(), + types.NamespacedName{Namespace: deploymentNamespace, Name: deploymentName}, + currentDeployment) + }, timeout, pollingInterval).Should(BeNil()) + + Byf("Verifying metric-server ServiceAccount is created in the workload cluster") + Eventually(func() error { + currentServiceAccount := &corev1.ServiceAccount{} + return workloadClient.Get(context.TODO(), + types.NamespacedName{Namespace: saNamespace, Name: saName}, + currentServiceAccount) + }, timeout, pollingInterval).Should(BeNil()) + + Byf("Verifying ClusterSummary %s status is set to Deployed for Resources feature", clusterSummary.Name) + verifyFeatureStatusIsProvisioned(kindWorkloadCluster.GetNamespace(), clusterSummary.Name, libsveltosv1beta1.FeatureResources) + + policies := []policy{ + {kind: "ClusterRole", name: clusterRoleName, namespace: "", group: "rbac.authorization.k8s.io"}, + {kind: "Deployment", name: deploymentName, namespace: deploymentNamespace, group: "apps"}, + {kind: "ServiceAccount", name: saName, namespace: saNamespace, group: ""}, + } + verifyClusterConfiguration(configv1beta1.ClusterProfileKind, clusterProfile.Name, + clusterSummary.Spec.ClusterNamespace, clusterSummary.Spec.ClusterName, libsveltosv1beta1.FeatureResources, + policies, nil) + + Byf("Update ClusterProfile %s to not reference Remote URL", clusterProfile.Name) + err = retry.RetryOnConflict(retry.DefaultRetry, func() error { + Expect(k8sClient.Get(context.TODO(), + types.NamespacedName{Name: clusterProfile.Name}, currentClusterProfile)).To(Succeed()) + currentClusterProfile.Spec.PolicyRefs = []configv1beta1.PolicyRef{} + return k8sClient.Update(context.TODO(), currentClusterProfile) + }) + Expect(err).To(BeNil()) + + Byf("Verifying metric-server ClusterRole is removed from the workload cluster") + Eventually(func() bool { + currentClusterRole := &rbacv1.ClusterRole{} + err = workloadClient.Get(context.TODO(), + types.NamespacedName{Name: clusterRoleName}, + currentClusterRole) + return err != nil && apierrors.IsNotFound(err) + }, timeout, pollingInterval).Should(BeTrue()) + + Byf("Verifying metric-server Deplyment is removed from the workload cluster") + Eventually(func() bool { + currentDeployment := &appsv1.Deployment{} + err = workloadClient.Get(context.TODO(), + types.NamespacedName{Namespace: deploymentNamespace, Name: deploymentName}, + currentDeployment) + return err != nil && apierrors.IsNotFound(err) + }, timeout, pollingInterval).Should(BeTrue()) + + Byf("Verifying metric-server ServiceAccount is removed from the workload cluster") + Eventually(func() bool { + currentServiceAccount := &corev1.ServiceAccount{} + err = workloadClient.Get(context.TODO(), + types.NamespacedName{Namespace: saNamespace, Name: saName}, + currentServiceAccount) + return err != nil && apierrors.IsNotFound(err) + }, timeout, pollingInterval).Should(BeTrue()) + + deleteClusterProfile(clusterProfile) + }) +})