From 192035c01e2d0bb07ad3190b24ac41a7078ef62c Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Fri, 21 Nov 2025 13:37:04 +0100 Subject: [PATCH 01/17] feat: add git commit author configuration and improve logging --- main.go | 67 ++++++++++++++++++++++++++++++++------------------------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/main.go b/main.go index 5a5a9ee..30ab6e8 100644 --- a/main.go +++ b/main.go @@ -18,7 +18,8 @@ import ( "github.com/krateoplatformops/provider-runtime/pkg/ratelimiter" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - github "github.com/krateoplatformops/git-provider/internal/controllers" + "github.com/krateoplatformops/git-provider/internal/controllers" + "github.com/krateoplatformops/git-provider/internal/controllers/common/option" "github.com/krateoplatformops/provider-runtime/pkg/controller" "github.com/stoewer/go-strcase" @@ -33,33 +34,22 @@ func main() { debug := flag.Bool("debug", env.Bool(fmt.Sprintf("%s_DEBUG", envVarPrefix), false), "Run with debug logging.") syncPeriod := flag.Duration("sync", env.Duration(fmt.Sprintf("%s_SYNC_PERIOD", envVarPrefix), time.Hour), "Controller manager sync period such as 300ms, 1.5h, or 2h45m") - pollInterval := flag.Duration("poll", env.Duration(fmt.Sprintf("%s_POLL_INTERVAL", envVarPrefix), 2*time.Minute), "Poll interval controls how often an individual resource should be checked for drift.") + pollInterval := flag.Duration("poll", env.Duration(fmt.Sprintf("%s_POLL_INTERVAL", envVarPrefix), 3*time.Minute), "Poll interval controls how often an individual resource should be checked for drift.") maxReconcileRate := flag.Int("max-reconcile-rate", env.Int(fmt.Sprintf("%s_MAX_RECONCILE_RATE", envVarPrefix), 5), "The number of concurrent reconciles for each controller. This is the maximum number of resources that can be reconciled at the same time.") leaderElection := flag.Bool("leader-election", env.Bool(fmt.Sprintf("%s_LEADER_ELECTION", envVarPrefix), false), "Use leader election for the controller manager.") maxErrorRetryInterval := flag.Duration("max-error-retry-interval", env.Duration(fmt.Sprintf("%s_MAX_ERROR_RETRY_INTERVAL", envVarPrefix), 1*time.Minute), "The maximum interval between retries when an error occurs. This should be less than the half of the poll interval.") minErrorRetryInterval := flag.Duration("min-error-retry-interval", env.Duration(fmt.Sprintf("%s_MIN_ERROR_RETRY_INTERVAL", envVarPrefix), 1*time.Second), "The minimum interval between retries when an error occurs. This should be less than max-error-retry-interval.") - flag.Parse() + timeout := flag.Duration("timeout", env.Duration(fmt.Sprintf("%s_TIMEOUT", envVarPrefix), 4*time.Minute), "The timeout for each reconcile.") + + gitCommitAuthorName := flag.String("git-commit-author-name", env.String(fmt.Sprintf("%s_GIT_COMMIT_AUTHOR_NAME", envVarPrefix), "krateo-git-provider"), "The name to use for git commits.") + gitCommitAuthorEmail := flag.String("git-commit-author-email", env.String(fmt.Sprintf("%s_GIT_COMMIT_AUTHOR_EMAIL", envVarPrefix), "contact@krateo.io"), "The email to use for git commits.") - // var zapOptions []zap.Opts - // if *debug { - // // Debug mode: mostra DEBUG, INFO, WARN, ERROR - // zapOptions = []zap.Opts{ - // zap.UseDevMode(true), - // zap.Level(zapcore.DebugLevel), - // } - // } else { - // // Production mode: mostra solo INFO, WARN, ERROR - // zapOptions = []zap.Opts{ - // zap.UseDevMode(false), - // zap.Level(zapcore.InfoLevel), - // } - // } - - // zl := zap.New(zapOptions...) - - // log := logging.NewLogrLogger(zl.WithName(fmt.Sprintf("%s-provider", strcase.KebabCase(providerName)))) - // ctrl.SetLogger(zl) + flag.Parse() + homeDir, err := os.UserHomeDir() + if err != nil { + homeDir = "/tmp" // this folder is guaranteed to be always writable + } logLevel := slog.LevelInfo if *debug { logLevel = slog.LevelDebug @@ -77,11 +67,20 @@ func main() { logrlog := logr.FromSlogHandler(slog.New(lh).Handler()) log := logging.NewLogrLogger(logrlog) - log.Info("Starting", "sync-period", syncPeriod.String()) + log.WithValues("sync-period", syncPeriod.String()). + WithValues("poll-interval", pollInterval.String()). + WithValues("max-reconcile-rate", *maxReconcileRate). + WithValues("leader-election", *leaderElection). + WithValues("min-error-retry-interval", minErrorRetryInterval.String()). + WithValues("max-error-retry-interval", maxErrorRetryInterval.String()). + WithValues("git-commit-author-name", *gitCommitAuthorName). + WithValues("git-commit-author-email", *gitCommitAuthorEmail). + WithValues("timeout", timeout.String()). + Info("Starting Git Provider") cfg, err := ctrl.GetConfig() if err != nil { - log.Info("Cannot get API server rest config, trying in-cluster config", "error", err) + log.Error(err, "Cannot get API server rest config, trying in-cluster config") os.Exit(1) } @@ -98,7 +97,7 @@ func main() { }, }) if err != nil { - log.Info("Trying to start metrics server", "error", err) + log.Error(err, "Cannot create controller manager") os.Exit(1) } @@ -110,15 +109,25 @@ func main() { } if err := apis.AddToScheme(mgr.GetScheme()); err != nil { - log.Info("Cannot add APIs to scheme", "error", err) + log.Error(err, "Cannot add APIs to scheme") os.Exit(1) } - if err := github.Setup(mgr, o); err != nil { - log.Info("Cannot setup controllers", "error", err) + if err := controllers.Setup(mgr, option.SetupOptions{ + Controller: option.ControllerOptions{ + Options: o, + Timeout: *timeout, + }, + Git: option.GitOptions{ + CommitAuthorName: *gitCommitAuthorName, + CommitAuthorEmail: *gitCommitAuthorEmail, + HomeDir: homeDir, + }, + }); err != nil { + log.Error(err, "Cannot setup controllers") os.Exit(1) } if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { - log.Info("Cannot start controller manager", "error", err) + log.Error(err, "Cannot start controller manager") os.Exit(1) } } From c399de77b5fe598bc8d5bdf1c09f276da1487e15 Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Fri, 21 Nov 2025 13:37:29 +0100 Subject: [PATCH 02/17] feat: implement LocalResource API and CRD for managing Git repositories --- apis/{git.go => apis.go} | 2 + .../v1alpha1/groupversion_info.go | 37 ++ apis/localresource/v1alpha1/types.go | 213 +++++++++++ .../v1alpha1/zz_generated.deepcopy.go | 224 ++++++++++++ apis/repo/v1alpha1/types.go | 1 + crds/git.krateo.io_localresources.yaml | 342 ++++++++++++++++++ crds/git.krateo.io_repoes.yaml | 6 +- patch.txt | 4 + 8 files changed, 827 insertions(+), 2 deletions(-) rename apis/{git.go => apis.go} (80%) create mode 100644 apis/localresource/v1alpha1/groupversion_info.go create mode 100644 apis/localresource/v1alpha1/types.go create mode 100644 apis/localresource/v1alpha1/zz_generated.deepcopy.go create mode 100644 crds/git.krateo.io_localresources.yaml create mode 100644 patch.txt diff --git a/apis/git.go b/apis/apis.go similarity index 80% rename from apis/git.go rename to apis/apis.go index 24e53cc..5dbe902 100644 --- a/apis/git.go +++ b/apis/apis.go @@ -3,6 +3,7 @@ package apis import ( "k8s.io/apimachinery/pkg/runtime" + localresourcev1alpha1 "github.com/krateoplatformops/git-provider/apis/localresource/v1alpha1" repov1alpha1 "github.com/krateoplatformops/git-provider/apis/repo/v1alpha1" ) @@ -10,6 +11,7 @@ func init() { // Register the types with the Scheme so the components can map objects to GroupVersionKinds and back AddToSchemes = append(AddToSchemes, repov1alpha1.SchemeBuilder.AddToScheme, + localresourcev1alpha1.SchemeBuilder.AddToScheme, ) } diff --git a/apis/localresource/v1alpha1/groupversion_info.go b/apis/localresource/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..d95d116 --- /dev/null +++ b/apis/localresource/v1alpha1/groupversion_info.go @@ -0,0 +1,37 @@ +// Package v1alpha1 contains API Schema definitions for the git v1alpha1 API group +// +kubebuilder:object:generate=true +// +groupName=git.krateo.io +// +versionName=v1alpha1 +package v1alpha1 + +import ( + "reflect" + + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +// Package type metadata. +const ( + Group = "git.krateo.io" + Version = "v1alpha1" +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: Group, Version: Version} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion} +) + +var ( + LocalResourceKind = reflect.TypeOf(LocalResource{}).Name() + LocalResourceGroupKind = schema.GroupKind{Group: Group, Kind: LocalResourceKind}.String() + LocalResourceKindAPIVersion = LocalResourceKind + "." + SchemeGroupVersion.String() + LocalResourceGroupVersionKind = SchemeGroupVersion.WithKind(LocalResourceKind) +) + +func init() { + SchemeBuilder.Register(&LocalResource{}, &LocalResourceList{}) +} diff --git a/apis/localresource/v1alpha1/types.go b/apis/localresource/v1alpha1/types.go new file mode 100644 index 0000000..ad0eb8a --- /dev/null +++ b/apis/localresource/v1alpha1/types.go @@ -0,0 +1,213 @@ +package v1alpha1 + +import ( + prv1 "github.com/krateoplatformops/provider-runtime/apis/common/v1" + "github.com/krateoplatformops/provider-runtime/pkg/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +type Credentials struct { + // AuthMethod: Possible values are: `basic`, `bearer`, `cookiefile`. `basic` requires `secretRef` and `usernameRef`; `basic` requires only `secretRef`; `cookiefile` requires only `secretRef` + // In case of 'cookiefile' the secretRef must contain a file with the cookie. + // +kubebuilder:validation:Enum=basic;bearer;cookiefile + // +kubebuilder:default:=basic + // +optional + AuthMethod string `json:"authMethod,omitempty"` + + // SecretRef: reference to a secret that contains token required to git server authentication or cookie file in case of 'cookiefile' authMethod. + SecretRef *prv1.SecretKeySelector `json:"secretRef"` + + // UsernameRef: holds username required to git server authentication. - If 'authMethod' is 'bearer' or 'cookiefile' the field is ignored. + // +optional + UsernameRef *prv1.SecretKeySelector `json:"usernameRef"` +} +type LocalResourceOpts struct { + // Url: url of the remote repository + Url string `json:"url"` + + // Path: if in spec.fromLocalResource, Represents the folder to clone from. If not set the entire repository is cloned. If in spec.toLocalResource, represents the folder to use as destination. + // +kubebuilder:default:="/" + // +optional + Path string `json:"path,omitempty"` + + // Branch: if in spec.fromLocalResource, the branch to copy from. If in spec.toLocalResource, represents the branch to populate; If the branch does not exist on remote is created by the provider. + // +required + Branch string `json:"branch"` + + // Credentials: Credentials required for git server authentication. + // +required + Credentials Credentials `json:"credentials,omitempty"` + /* + CloneFromBranch: used the parent of the new branch. + - If the branch exists, the parameter is ignored. + - If the parameter is not set, the branch is created empty and has no parents (no history) - `git switch --orphan branch-name` + */ + // +optional + CloneFromBranch string `json:"cloneFromBranch,omitempty"` +} + +type ResourceRef struct { + // Name: Name of the resource to reference + // +required + Name string `json:"name"` + + // Namespace: Namespace of the resource to reference. If not set, the resource is expected to be cluster-scoped. + // +optional + Namespace string `json:"namespace,omitempty"` + + // ApiVersion: API version of the resource to reference + // +required + ApiVersion string `json:"apiVersion"` + + // Resource: Kind of the resource to reference + // +required + Resource string `json:"resource"` +} + +type Placeholder struct { + // Name: Name of the placeholder to replace in the format {{ .name }} + // +required + Name string `json:"name"` + + // Value: Value to replace the placeholder with + // +required + Value string `json:"value"` +} + +// +kubebuilder:validation:XValidation:rule="!has(self.fromString) || has(self.fileName)",message="fileName is required when fromString is set" +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.fromRef) || self.fromRef == oldSelf.fromRef",message="fromRef is immutable once set" +// +kubebuilder:validation:ExactlyOneOf=fromYaml;fromRef;fromString +type FromResource struct { + // FileName: represent the file name to use. If not set the filename is defined as "gvk"_"metadata.name"_"metadata.namespace".yaml + // +kubebuilder:validation:Pattern=`^[\w\-. {}]+$` + // +optional + FileName string `json:"fileName,omitempty"` + + // FromYaml: Valid K8s Manifest to copy from. It is considered valid if it contains Kind, APIVersion and (Name or GenerateName) fields. + // +kubebuilder:validation:EmbeddedResource + // +optional + FromYaml *runtime.RawExtension `json:"fromYaml,omitempty"` + + // FromRef: Reference to a resource in the same cluster to copy from + // +optional + FromRef *ResourceRef `json:"fromRef,omitempty"` + + // FromString: This can cointain a string representation of a K8s manifest to copy from or any other text content. This is not validated but it preserves the complete ordering of fields as in the original string. + // +optional + FromString *string `json:"fromString,omitempty"` +} + +// A LocalResourceSpec defines the desired state of a LocalResource. +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.toRepo) || self.toRepo == oldSelf.toRepo",message="toRepo is immutable once set" +// +kubebuilder:validation:XValidation:rule="self.syncEnabled || self.override || !has(oldSelf.placeholdersToOverride) || self.placeholdersToOverride == oldSelf.placeholdersToOverride",message="placeholdersToOverride is immutable when syncEnabled is false" +// +kubebuilder:validation:XValidation:rule="self.syncEnabled || self.override || !has(oldSelf.fromResource.fromYaml) || self.fromResource.fromYaml == oldSelf.fromResource.fromYaml",message="fromResource.fromYaml is immutable when syncEnabled is false" +// +kubebuilder:validation:XValidation:rule="self.syncEnabled || self.override || !has(oldSelf.fromResource.fromString) || self.fromResource.fromString == oldSelf.fromResource.fromString",message="fromResource.fromString is immutable when syncEnabled is false" +// +kubebuilder:validation:XValidation:rule="!self.toRepo.url.contains('dev.azure.com') || self.unsupportedCapabilities == true",message="spec.unsupportedCapabilities must be true if toRepo.url contains 'dev.azure.com', as the go-git library does not support required capabilities for Azure DevOps. You can read more about that in the description of the 'unsupportedCapabilities' field." +type LocalResourceSpec struct { + // PlaceholdersToOverride: List of placeholders to override in the format {{ .placeholder }}. + // +optional + PlaceholdersToOverride []Placeholder `json:"placeholdersToOverride,omitempty"` + + // FromResource: Resource to copy from + // +required + FromResource FromResource `json:"fromResource"` + + // ToRepo: Repository destination to copy to + // +required + ToRepo LocalResourceOpts `json:"toRepo"` + + // Insecure: Insecure is useful with hand made SSL certs (default: false) + // +optional + Insecure bool `json:"insecure,omitempty"` + + // UnsupportedCapabilities: If `true` [capabilities not supported by any client implementation](https://github.com/go-git/go-git/blob/4fd9979d5c2940e72bdd6946fec21e02d959f0f6/plumbing/transport/common.go#L310) will not be used by the provider + // For azuredevops urls for example, the value must be true. Azure DevOps requires multi_ack and multi_ack_detailed capabilities, which go-git doesn't + // implement. But: it's possible to do a full clone by saying it's _not_ _un_supported, in which + // case the library happily functions so long as it doesn't _actually_ get a multi_ack packet. See + // https://github.com/go-git/go-git/blob/v5.5.1/_examples/azure_devops/main.go. + // +optional + // +kubebuilder:default:=false + UnsupportedCapabilities bool `json:"unsupportedCapabilities,omitempty"` + + // SyncEnabled: If `true`, the provider performs updates on the repository specified in `toLocalResource` when there are changes in `fromResource` or `fromResourceRef` field. + // +kubebuilder:default:=false + // +optional + SyncEnabled bool `json:"syncEnabled,omitempty"` + + // CreateCommitMessage: Commit message to use when creating new files in the destination repository. A description will be appeded on the commit message indicating some information about the CR that have triggered the commit. + // +optional + // +kubebuilder:default:="chore: add files to remote repository" + // +kubebuilder:validation:XValidation:rule="!self.contains('Managed by git-provider LocalResource:')",message="Il campo updateCommitMessage non può contenere la sottostringa 'Managed by git-provider LocalResource:'" + CreateCommitMessage string `json:"createCommitMessage,omitempty"` + + // UpdateCommitMessage: Commit message to use when updating existing files in the destination repository. A description will be appeded on the commit message indicating some information about the CR that have triggered the commit. + // +optional + // +kubebuilder:default:="chore: update files in remote repository" + // +kubebuilder:validation:XValidation:rule="!self.contains('Managed by git-provider LocalResource:')",message="Il campo updateCommitMessage non può contenere la sottostringa 'Managed by git-provider LocalResource:'" + UpdateCommitMessage string `json:"updateCommitMessage,omitempty"` + + // Override: If `true`, the provider will override the existing files in the destination repository with the files from the source repository. + // If `false`, the provider will only add new files and update existing files in the destination repository. + // If not set, the provider will use the default behavior of adding new files. + // Avoid using this option with originPath from / to /, as it will override also service folders like .git, .github, .gitignore, etc. + // +kubebuilder:default:=false + // +optional + Override bool `json:"override,omitempty"` +} + +// A LocalResourceStatus represents the observed state of a LocalResource. +type LocalResourceStatus struct { + prv1.ConditionedStatus `json:",inline"` + // TargetCommitId: last commit identifier of the target LocalResource + TargetCommitId string `json:"targetCommitId,omitempty"` + + // TargetBranch: branch where commit was done + TargetBranch string `json:"targetBranch,omitempty"` +} + +// +kubebuilder:object:root=true + +// A LocalResource is a managed resource that represents a Krateo Git repository +// +kubebuilder:printcolumn:name="TARGET_COMMIT_ID",type="string",JSONPath=".status.targetCommitId" +// +kubebuilder:printcolumn:name="TARGET_BRANCH",type="string",JSONPath=".status.targetBranch" +// +kubebuilder:printcolumn:name="READY",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" +// +kubebuilder:printcolumn:name="SYNCED",type="string",JSONPath=".status.conditions[?(@.type=='Synced')].status" +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,categories={git,krateo} +type LocalResource struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec LocalResourceSpec `json:"spec"` + Status LocalResourceStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// LocalResourceList contains a list of LocalResource. +type LocalResourceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []LocalResource `json:"items"` +} + +// GetCondition of this LocalResource. +func (mg *LocalResource) GetCondition(ct prv1.ConditionType) prv1.Condition { + return mg.Status.GetCondition(ct) +} + +// SetConditions of this LocalResource. +func (mg *LocalResource) SetConditions(c ...prv1.Condition) { + mg.Status.SetConditions(c...) +} + +// GetItems of this LocalResourceList. +func (l *LocalResourceList) GetItems() []resource.Managed { + items := make([]resource.Managed, len(l.Items)) + for i := range l.Items { + items[i] = &l.Items[i] + } + return items +} diff --git a/apis/localresource/v1alpha1/zz_generated.deepcopy.go b/apis/localresource/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..e582c54 --- /dev/null +++ b/apis/localresource/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,224 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2025 Krateo SRL. + +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. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "github.com/krateoplatformops/provider-runtime/apis/common/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Credentials) DeepCopyInto(out *Credentials) { + *out = *in + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(v1.SecretKeySelector) + **out = **in + } + if in.UsernameRef != nil { + in, out := &in.UsernameRef, &out.UsernameRef + *out = new(v1.SecretKeySelector) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Credentials. +func (in *Credentials) DeepCopy() *Credentials { + if in == nil { + return nil + } + out := new(Credentials) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FromResource) DeepCopyInto(out *FromResource) { + *out = *in + if in.FromYaml != nil { + in, out := &in.FromYaml, &out.FromYaml + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } + if in.FromRef != nil { + in, out := &in.FromRef, &out.FromRef + *out = new(ResourceRef) + **out = **in + } + if in.FromString != nil { + in, out := &in.FromString, &out.FromString + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FromResource. +func (in *FromResource) DeepCopy() *FromResource { + if in == nil { + return nil + } + out := new(FromResource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalResource) DeepCopyInto(out *LocalResource) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalResource. +func (in *LocalResource) DeepCopy() *LocalResource { + if in == nil { + return nil + } + out := new(LocalResource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LocalResource) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalResourceList) DeepCopyInto(out *LocalResourceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LocalResource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalResourceList. +func (in *LocalResourceList) DeepCopy() *LocalResourceList { + if in == nil { + return nil + } + out := new(LocalResourceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LocalResourceList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalResourceOpts) DeepCopyInto(out *LocalResourceOpts) { + *out = *in + in.Credentials.DeepCopyInto(&out.Credentials) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalResourceOpts. +func (in *LocalResourceOpts) DeepCopy() *LocalResourceOpts { + if in == nil { + return nil + } + out := new(LocalResourceOpts) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalResourceSpec) DeepCopyInto(out *LocalResourceSpec) { + *out = *in + if in.PlaceholdersToOverride != nil { + in, out := &in.PlaceholdersToOverride, &out.PlaceholdersToOverride + *out = make([]Placeholder, len(*in)) + copy(*out, *in) + } + in.FromResource.DeepCopyInto(&out.FromResource) + in.ToRepo.DeepCopyInto(&out.ToRepo) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalResourceSpec. +func (in *LocalResourceSpec) DeepCopy() *LocalResourceSpec { + if in == nil { + return nil + } + out := new(LocalResourceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalResourceStatus) DeepCopyInto(out *LocalResourceStatus) { + *out = *in + in.ConditionedStatus.DeepCopyInto(&out.ConditionedStatus) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalResourceStatus. +func (in *LocalResourceStatus) DeepCopy() *LocalResourceStatus { + if in == nil { + return nil + } + out := new(LocalResourceStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Placeholder) DeepCopyInto(out *Placeholder) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Placeholder. +func (in *Placeholder) DeepCopy() *Placeholder { + if in == nil { + return nil + } + out := new(Placeholder) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceRef) DeepCopyInto(out *ResourceRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceRef. +func (in *ResourceRef) DeepCopy() *ResourceRef { + if in == nil { + return nil + } + out := new(ResourceRef) + in.DeepCopyInto(out) + return out +} diff --git a/apis/repo/v1alpha1/types.go b/apis/repo/v1alpha1/types.go index 3da1c78..55d4c4e 100644 --- a/apis/repo/v1alpha1/types.go +++ b/apis/repo/v1alpha1/types.go @@ -119,6 +119,7 @@ type RepoStatus struct { // +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Namespaced,categories={git,krateo} +/*// +kubebuilder:deprecatedversion:warning="v1alpha1 will be removed in a future release. Please migrate to Kind RemoteRepo"*/ type Repo struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` diff --git a/crds/git.krateo.io_localresources.yaml b/crds/git.krateo.io_localresources.yaml new file mode 100644 index 0000000..80a7fd3 --- /dev/null +++ b/crds/git.krateo.io_localresources.yaml @@ -0,0 +1,342 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: localresources.git.krateo.io +spec: + group: git.krateo.io + names: + categories: + - git + - krateo + kind: LocalResource + listKind: LocalResourceList + plural: localresources + singular: localresource + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.targetCommitId + name: TARGET_COMMIT_ID + type: string + - jsonPath: .status.targetBranch + name: TARGET_BRANCH + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: READY + type: string + - jsonPath: .status.conditions[?(@.type=='Synced')].status + name: SYNCED + type: string + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: A LocalResource is a managed resource that represents a Krateo + Git repository + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: A LocalResourceSpec defines the desired state of a LocalResource. + properties: + createCommitMessage: + default: 'chore: add files to remote repository' + description: 'CreateCommitMessage: Commit message to use when creating + new files in the destination repository. A description will be appeded + on the commit message indicating some information about the CR that + have triggered the commit.' + type: string + x-kubernetes-validations: + - message: Il campo updateCommitMessage non può contenere la sottostringa + 'Managed by git-provider LocalResource:' + rule: '!self.contains(''Managed by git-provider LocalResource:'')' + fromResource: + description: 'FromResource: Resource to copy from' + properties: + fileName: + description: 'FileName: represent the file name to use. If not + set the filename is defined as "gvk"_"metadata.name"_"metadata.namespace".yaml' + pattern: ^[\w\-. {}]+$ + type: string + fromRef: + description: 'FromRef: Reference to a resource in the same cluster + to copy from' + properties: + apiVersion: + description: 'ApiVersion: API version of the resource to reference' + type: string + name: + description: 'Name: Name of the resource to reference' + type: string + namespace: + description: 'Namespace: Namespace of the resource to reference. + If not set, the resource is expected to be cluster-scoped.' + type: string + resource: + description: 'Resource: Kind of the resource to reference' + type: string + required: + - apiVersion + - name + - resource + type: object + fromString: + description: 'FromString: This can cointain a string representation + of a K8s manifest to copy from or any other text content. This + is not validated but it preserves the complete ordering of fields + as in the original string.' + type: string + fromYaml: + description: 'FromYaml: Valid K8s Manifest to copy from. It is + considered valid if it contains Kind, APIVersion and (Name or + GenerateName) fields.' + type: object + x-kubernetes-embedded-resource: true + x-kubernetes-preserve-unknown-fields: true + type: object + x-kubernetes-validations: + - message: fileName is required when fromString is set + rule: '!has(self.fromString) || has(self.fileName)' + - message: fromRef is immutable once set + rule: '!has(oldSelf.fromRef) || self.fromRef == oldSelf.fromRef' + - message: exactly one of the fields in [fromYaml fromRef fromString] + must be set + rule: '[has(self.fromYaml),has(self.fromRef),has(self.fromString)].filter(x,x==true).size() + == 1' + insecure: + description: 'Insecure: Insecure is useful with hand made SSL certs + (default: false)' + type: boolean + override: + default: false + description: |- + Override: If `true`, the provider will override the existing files in the destination repository with the files from the source repository. + If `false`, the provider will only add new files and update existing files in the destination repository. + If not set, the provider will use the default behavior of adding new files. + Avoid using this option with originPath from / to /, as it will override also service folders like .git, .github, .gitignore, etc. + type: boolean + placeholdersToOverride: + description: 'PlaceholdersToOverride: List of placeholders to override + in the format {{ .placeholder }}.' + items: + properties: + name: + description: 'Name: Name of the placeholder to replace in the + format {{ .name }}' + type: string + value: + description: 'Value: Value to replace the placeholder with' + type: string + required: + - name + - value + type: object + type: array + syncEnabled: + default: false + description: 'SyncEnabled: If `true`, the provider performs updates + on the repository specified in `toLocalResource` when there are + changes in `fromResource` or `fromResourceRef` field.' + type: boolean + toRepo: + description: 'ToRepo: Repository destination to copy to' + properties: + branch: + description: 'Branch: if in spec.fromLocalResource, the branch + to copy from. If in spec.toLocalResource, represents the branch + to populate; If the branch does not exist on remote is created + by the provider.' + type: string + cloneFromBranch: + description: |- + CloneFromBranch: used the parent of the new branch. + - If the branch exists, the parameter is ignored. + - If the parameter is not set, the branch is created empty and has no parents (no history) - `git switch --orphan branch-name` + type: string + credentials: + description: 'Credentials: Credentials required for git server + authentication.' + properties: + authMethod: + default: basic + description: |- + AuthMethod: Possible values are: `basic`, `bearer`, `cookiefile`. `basic` requires `secretRef` and `usernameRef`; `basic` requires only `secretRef`; `cookiefile` requires only `secretRef` + In case of 'cookiefile' the secretRef must contain a file with the cookie. + enum: + - basic + - bearer + - cookiefile + type: string + secretRef: + description: 'SecretRef: reference to a secret that contains + token required to git server authentication or cookie file + in case of ''cookiefile'' authMethod.' + properties: + key: + description: The key to select. + type: string + name: + description: Name of the referenced object. + type: string + namespace: + description: Namespace of the referenced object. + type: string + required: + - key + - name + - namespace + type: object + usernameRef: + description: 'UsernameRef: holds username required to git + server authentication. - If ''authMethod'' is ''bearer'' + or ''cookiefile'' the field is ignored.' + properties: + key: + description: The key to select. + type: string + name: + description: Name of the referenced object. + type: string + namespace: + description: Namespace of the referenced object. + type: string + required: + - key + - name + - namespace + type: object + required: + - secretRef + type: object + path: + default: / + description: 'Path: if in spec.fromLocalResource, Represents the + folder to clone from. If not set the entire repository is cloned. + If in spec.toLocalResource, represents the folder to use as + destination.' + type: string + url: + description: 'Url: url of the remote repository' + type: string + required: + - branch + - credentials + - url + type: object + unsupportedCapabilities: + default: false + description: |- + UnsupportedCapabilities: If `true` [capabilities not supported by any client implementation](https://github.com/go-git/go-git/blob/4fd9979d5c2940e72bdd6946fec21e02d959f0f6/plumbing/transport/common.go#L310) will not be used by the provider + For azuredevops urls for example, the value must be true. Azure DevOps requires multi_ack and multi_ack_detailed capabilities, which go-git doesn't + implement. But: it's possible to do a full clone by saying it's _not_ _un_supported, in which + case the library happily functions so long as it doesn't _actually_ get a multi_ack packet. See + https://github.com/go-git/go-git/blob/v5.5.1/_examples/azure_devops/main.go. + type: boolean + updateCommitMessage: + default: 'chore: update files in remote repository' + description: 'UpdateCommitMessage: Commit message to use when updating + existing files in the destination repository. A description will + be appeded on the commit message indicating some information about + the CR that have triggered the commit.' + type: string + x-kubernetes-validations: + - message: Il campo updateCommitMessage non può contenere la sottostringa + 'Managed by git-provider LocalResource:' + rule: '!self.contains(''Managed by git-provider LocalResource:'')' + required: + - fromResource + - toRepo + type: object + x-kubernetes-validations: + - message: toRepo is immutable once set + rule: '!has(oldSelf.toRepo) || self.toRepo == oldSelf.toRepo' + - message: placeholdersToOverride is immutable when syncEnabled is false + rule: self.syncEnabled || self.override || !has(oldSelf.placeholdersToOverride) + || self.placeholdersToOverride == oldSelf.placeholdersToOverride + - message: fromResource.fromYaml is immutable when syncEnabled is false + rule: self.syncEnabled || self.override || !has(oldSelf.fromResource.fromYaml) + || self.fromResource.fromYaml == oldSelf.fromResource.fromYaml + - message: fromResource.fromString is immutable when syncEnabled is false + rule: self.syncEnabled || self.override || !has(oldSelf.fromResource.fromString) + || self.fromResource.fromString == oldSelf.fromResource.fromString + - message: spec.unsupportedCapabilities must be true if toRepo.url contains + 'dev.azure.com', as the go-git library does not support required capabilities + for Azure DevOps. You can read more about that in the description + of the 'unsupportedCapabilities' field. + rule: '!self.toRepo.url.contains(''dev.azure.com'') || self.unsupportedCapabilities + == true' + status: + description: A LocalResourceStatus represents the observed state of a + LocalResource. + properties: + conditions: + description: Conditions of the resource. + items: + description: A Condition that may apply to a resource. + properties: + lastTransitionTime: + description: |- + LastTransitionTime is the last time this condition transitioned from one + status to another. + format: date-time + type: string + message: + description: |- + A Message containing details about this condition's last transition from + one status to another, if any. + type: string + reason: + description: A Reason for this condition's last transition from + one status to another. + type: string + status: + description: Status of this condition; is it currently True, + False, or Unknown? + type: string + type: + description: |- + Type of this condition. At most one of each condition type may apply to + a resource at any point in time. + type: string + required: + - lastTransitionTime + - reason + - status + - type + type: object + type: array + targetBranch: + description: 'TargetBranch: branch where commit was done' + type: string + targetCommitId: + description: 'TargetCommitId: last commit identifier of the target + LocalResource' + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/crds/git.krateo.io_repoes.yaml b/crds/git.krateo.io_repoes.yaml index 81cf0ef..5325b87 100644 --- a/crds/git.krateo.io_repoes.yaml +++ b/crds/git.krateo.io_repoes.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.17.1 + controller-gen.kubebuilder.io/version: v0.19.0 name: repoes.git.krateo.io spec: group: git.krateo.io @@ -42,7 +42,9 @@ spec: name: v1alpha1 schema: openAPIV3Schema: - description: A Repo is a managed resource that represents a Krateo Git Repository + description: |- + A Repo is a managed resource that represents a Krateo Git Repository + // +kubebuilder:deprecatedversion:warning="v1alpha1 will be removed in a future release. Please migrate to Kind RemoteRepo" properties: apiVersion: description: |- diff --git a/patch.txt b/patch.txt new file mode 100644 index 0000000..01b796e --- /dev/null +++ b/patch.txt @@ -0,0 +1,4 @@ +credo sia irrelivante il meccanismo di evitare commit duplicati nella history con opzioni tipo syncEnabled e override attive. + +L'unico caso in cui ha effettivamente senso evitare di pushare codice se già stato pushato in passato è con ovveride a false e syncEnabled a false. +In questo caso basterebbe leggere i commit message nella repo e verificare che git-provider abbia pushato in precedenza (si potrebbe sfruttare direttamente il commit message iniettando un digest delle spec della CR) From 01c154442ab112bc7993ced5a7f0f91d638ddf6c Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Fri, 21 Nov 2025 13:41:16 +0100 Subject: [PATCH 03/17] feat(hash): add ObjectHash implementation for cumulative hashing - Introduced ObjectHash type with methods to sum hashes from various inputs. - Implemented NewFNVObjectHash function to create a new FNV-based hash instance. - Added tests for hashing functionality. feat(localfs): implement LocalFS for managing local filesystem operations - Created LocalFS type to handle temporary filesystem operations. - Implemented methods for writing Kubernetes resources and handling JQ filters. - Added tests to validate LocalFS functionality and error handling. feat(template): add templating support for dynamic resource generation - Introduced Template and TemplateValue types for rendering templates. - Implemented FromTemplateValues function to convert template values to a map. - Added tests for deployment template rendering. test(samples): add sample YAML files for local resource testing - Added various sample YAML files to test local resource configurations. - Included examples for fromRef, fromString, and fromYaml specifications. --- go.mod | 106 ++- go.sum | 247 ++++-- internal/clients/git/git.go | 147 +++- internal/clients/git/git_test.go | 69 +- .../credentialHelper/credentialsHelper.go | 51 ++ internal/controllers/common/footer/footer.go | 70 ++ .../controllers/common/footer/footer_test.go | 106 +++ internal/controllers/common/option/option.go | 23 + .../controllers/{git.go => controllers.go} | 8 +- .../localresource/localresource.go | 450 +++++++++++ .../localresource/localresource_test.go | 733 ++++++++++++++++++ .../localresource/testdata/gitea-creds.yaml | 9 + internal/controllers/repo/copier.go | 180 ----- internal/controllers/repo/copier_test.go | 43 - internal/controllers/repo/repo.go | 155 ++-- internal/controllers/repo/setup.go | 84 -- internal/controllers/repo/utils.go | 79 -- internal/controllers/repo/utils_test.go | 74 -- internal/tools/copier/copier.go | 354 +++++++++ internal/tools/copier/copier_test.go | 146 ++++ internal/tools/hash/hasher.go | 38 + internal/tools/hash/hasher_test.go | 62 ++ internal/tools/localfs/localfs.go | 214 +++++ internal/tools/localfs/localfs_test.go | 589 ++++++++++++++ internal/tools/template/template.go | 37 + internal/tools/template/template_test.go | 80 ++ testdata/local_fromRef.yaml | 35 + testdata/local_fromString.yaml | 43 + testdata/local_fromString_override_false.yaml | 44 ++ .../local_fromString_syncEnabled_false.yaml | 45 ++ testdata/local_fromYaml.yaml | 45 ++ testdata/samples/cm.yaml | 11 + .../samples}/repo-gitcookies.yaml | 1 - .../samples}/repo-override.yaml | 2 +- {samples => testdata/samples}/repo.yaml | 26 +- 35 files changed, 3752 insertions(+), 654 deletions(-) create mode 100644 internal/controllers/common/credentialHelper/credentialsHelper.go create mode 100644 internal/controllers/common/footer/footer.go create mode 100644 internal/controllers/common/footer/footer_test.go create mode 100644 internal/controllers/common/option/option.go rename internal/controllers/{git.go => controllers.go} (51%) create mode 100644 internal/controllers/localresource/localresource.go create mode 100644 internal/controllers/localresource/localresource_test.go create mode 100644 internal/controllers/localresource/testdata/gitea-creds.yaml delete mode 100644 internal/controllers/repo/copier.go delete mode 100644 internal/controllers/repo/copier_test.go delete mode 100644 internal/controllers/repo/setup.go create mode 100644 internal/tools/copier/copier.go create mode 100644 internal/tools/copier/copier_test.go create mode 100644 internal/tools/hash/hasher.go create mode 100644 internal/tools/hash/hasher_test.go create mode 100644 internal/tools/localfs/localfs.go create mode 100644 internal/tools/localfs/localfs_test.go create mode 100644 internal/tools/template/template.go create mode 100644 internal/tools/template/template_test.go create mode 100644 testdata/local_fromRef.yaml create mode 100644 testdata/local_fromString.yaml create mode 100644 testdata/local_fromString_override_false.yaml create mode 100644 testdata/local_fromString_syncEnabled_false.yaml create mode 100644 testdata/local_fromYaml.yaml create mode 100644 testdata/samples/cm.yaml rename {samples => testdata/samples}/repo-gitcookies.yaml (96%) rename {samples => testdata/samples}/repo-override.yaml (97%) rename {samples => testdata/samples}/repo.yaml (57%) diff --git a/go.mod b/go.mod index fcb00fc..85b78f5 100644 --- a/go.mod +++ b/go.mod @@ -1,57 +1,74 @@ module github.com/krateoplatformops/git-provider -go 1.24.2 - -toolchain go1.24.3 +go 1.25.0 require ( + github.com/Masterminds/sprig/v3 v3.3.0 github.com/cbroglie/mustache v1.4.0 github.com/go-git/go-billy/v5 v5.6.2 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 github.com/go-git/go-git/v5 v5.13.1 - github.com/krateoplatformops/plumbing v0.5.2 - github.com/krateoplatformops/provider-runtime v0.9.1 + github.com/go-logr/logr v1.4.3 + github.com/krateoplatformops/plumbing v0.7.2 + github.com/krateoplatformops/provider-runtime v0.10.2 + github.com/moby/moby/api v1.52.0 + github.com/moby/moby/client v0.1.0 github.com/pkg/errors v0.9.1 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/stoewer/go-strcase v1.3.0 github.com/stretchr/testify v1.10.0 - k8s.io/api v0.33.1 - k8s.io/apimachinery v0.33.1 - k8s.io/client-go v0.33.1 - sigs.k8s.io/controller-runtime v0.21.0 - sigs.k8s.io/controller-tools v0.17.1 + k8s.io/api v0.34.1 + k8s.io/apiextensions-apiserver v0.34.1 + k8s.io/apimachinery v0.34.1 + k8s.io/client-go v0.34.1 + sigs.k8s.io/controller-runtime v0.22.3 + sigs.k8s.io/controller-tools v0.19.0 + sigs.k8s.io/e2e-framework v0.6.0 ) require ( dario.cat/mergo v1.0.1 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.5 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/cyphar/filepath-securejoin v0.4.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.12.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/evanphx/json-patch v4.12.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fatih/color v1.18.0 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-logr/logr v1.4.2 // indirect - github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/gobuffalo/flect v1.0.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/gojq v0.12.17 // indirect + github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -61,10 +78,17 @@ require ( github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mmcloughlin/avo v0.6.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pjbgf/sha1cd v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.22.0 // indirect @@ -73,37 +97,47 @@ require ( github.com/prometheus/procfs v0.15.1 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/skeema/knownhosts v1.3.0 // indirect - github.com/spf13/cobra v1.8.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/cast v1.7.0 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.7 // indirect + github.com/vladimirvivien/gexe v0.4.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/mod v0.22.0 // indirect - golang.org/x/net v0.38.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/term v0.30.0 // indirect - golang.org/x/text v0.23.0 // indirect - golang.org/x/time v0.9.0 // indirect - golang.org/x/tools v0.29.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/time v0.11.0 // indirect + golang.org/x/tools v0.36.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/protobuf v1.36.5 // indirect + google.golang.org/protobuf v1.36.7 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.33.0 // indirect + k8s.io/code-generator v0.34.1 // indirect + k8s.io/component-base v0.34.1 // indirect + k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect - k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index bd39848..c504a25 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,13 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= @@ -7,6 +15,8 @@ github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -15,11 +25,17 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/cbroglie/mustache v1.4.0 h1:Azg0dVhxTml5me+7PsZ7WPrQq1Gkf3WApcHMjMprYoU= github.com/cbroglie/mustache v1.4.0/go.mod h1:SS1FTIghy0sjse4DUVGV1k/40B1qE1XkD9DtDsHo9iM= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 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/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.4.0 h1:PioTG9TBRSApBpYGnDU8HC+miIsX8vitBH9LGNNMoLQ= github.com/cyphar/filepath-securejoin v0.4.0/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= @@ -27,10 +43,16 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs 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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/elazarl/goproxy v1.2.3 h1:xwIyKHbaP5yfT6O9KIeYJR5549MXRQkoQMRXGztz8YQ= github.com/elazarl/goproxy v1.2.3/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64= -github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= -github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= @@ -39,10 +61,14 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= -github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/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/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -53,8 +79,11 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.13.1 h1:DAQ9APonnlvSWpvolXWIuV6Q6zXy2wHbN4cVlNR5Q+M= github.com/go-git/go-git/v5 v5.13.1/go.mod h1:qryJB4cSBoq3FRoBRf5A77joojuBcmPJ0qu3XXXVixc= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= @@ -69,13 +98,16 @@ github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4 github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 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.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= -github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -85,8 +117,18 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 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/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= +github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -107,10 +149,10 @@ 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/krateoplatformops/plumbing v0.5.2 h1:FSdbOSskyHFUN3k61l+S2S56+0/ZYiNMzoI1FTIpSXA= -github.com/krateoplatformops/plumbing v0.5.2/go.mod h1:RhqIZ7si6p39Oor0/FE3LEqn3gqSt3fhcyKVJ9GhtRw= -github.com/krateoplatformops/provider-runtime v0.9.1 h1:AzeLCOmycSip4s7xokgWo4YuZVMyhXVE6glrgPYNOOA= -github.com/krateoplatformops/provider-runtime v0.9.1/go.mod h1:A0OKDAXE9KnX1GyhZH0UpZhpn15xQANoc4KVYLsfZM0= +github.com/krateoplatformops/plumbing v0.7.2 h1:4UuWy9747p9ligMtNEiOOQGsuK6d9lczg7R1no8ERsE= +github.com/krateoplatformops/plumbing v0.7.2/go.mod h1:mQ/sm0viyKgfR2ARzHuwCpY0rcyMKqCv8a8SOu52yYQ= +github.com/krateoplatformops/provider-runtime v0.10.2 h1:56PpG0hUkF8TiklyTfxzj4wfAqmUm3E4N9CFX70McKI= +github.com/krateoplatformops/provider-runtime v0.10.2/go.mod h1:nKW3ULWw6vji68J/XYlkyS/QMMnrkOKnD0Hn7FncK9I= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= @@ -119,23 +161,42 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mmcloughlin/avo v0.6.0 h1:QH6FU8SKoTLaVs80GA8TJuLNkUYl4VokHKlPhVDg4YY= github.com/mmcloughlin/avo v0.6.0/go.mod h1:8CoAGaCSYXtCPR+8y18Y9aB/kxb8JSS6FRI7mSkvD+8= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg= +github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= +github.com/moby/moby/client v0.1.0 h1:nt+hn6O9cyJQqq5UWnFGqsZRTS/JirUqzPjEl0Bdc/8= +github.com/moby/moby/client v0.1.0/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= -github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= -github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/onsi/gomega v1.38.1 h1:FaLA8GlcpXDwsb7m0h2A9ew2aTk3vnZMlzFgg5tz/pk= +github.com/onsi/gomega v1.38.1/go.mod h1:LfcV8wZLvwcYRwPiJysphKAEsmcFnLMK/9c+PjvlX8g= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pjbgf/sha1cd v0.3.1 h1:Dh2GYdpJnO84lIw0LJwTFXjcNbasP/bklicSznyAaPI= github.com/pjbgf/sha1cd v0.3.1/go.mod h1:Y8t7jSB/dEI/lQE04A1HVKteqjj9bX5O4+Cex0TCu8s= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -160,13 +221,18 @@ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDj github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -183,42 +249,70 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vladimirvivien/gexe v0.4.1 h1:W9gWkp8vSPjDoXDu04Yp4KljpVMaSt8IQuHswLDd5LY= +github.com/vladimirvivien/gexe v0.4.1/go.mod h1:3gjgTqE2c0VyHnU5UOIwk7gyNzZDGulPb/DJPgcw64E= 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/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +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/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.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= -golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= 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-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/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.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -229,32 +323,42 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= 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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 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-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= -golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY= +golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= 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.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= +google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= +google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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= @@ -273,30 +377,45 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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.33.1 h1:tA6Cf3bHnLIrUK4IqEgb2v++/GYUtqiu9sRVk3iBXyw= -k8s.io/api v0.33.1/go.mod h1:87esjTn9DRSRTD4fWMXamiXxJhpOIREjWOSjsW1kEHw= -k8s.io/apiextensions-apiserver v0.33.0 h1:d2qpYL7Mngbsc1taA4IjJPRJ9ilnsXIrndH+r9IimOs= -k8s.io/apiextensions-apiserver v0.33.0/go.mod h1:VeJ8u9dEEN+tbETo+lFkwaaZPg6uFKLGj5vyNEwwSzc= -k8s.io/apimachinery v0.33.1 h1:mzqXWV8tW9Rw4VeW9rEkqvnxj59k1ezDUl20tFK/oM4= -k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= -k8s.io/client-go v0.33.1 h1:ZZV/Ks2g92cyxWkRRnfUDsnhNn28eFpt26aGc8KbXF4= -k8s.io/client-go v0.33.1/go.mod h1:JAsUrl1ArO7uRVFWfcj6kOomSlCv+JpvIsp6usAGefA= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= +k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA= +k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/code-generator v0.34.1 h1:WpphT26E+j7tEgIUfFr5WfbJrktCGzB3JoJH9149xYc= +k8s.io/code-generator v0.34.1/go.mod h1:DeWjekbDnJWRwpw3s0Jat87c+e0TgkxoR4ar608yqvg= +k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A= +k8s.io/component-base v0.34.1/go.mod h1:mknCpLlTSKHzAQJJnnHVKqjxR7gBeHRv0rPXA7gdtQ0= +k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f h1:SLb+kxmzfA87x4E4brQzB33VBbT2+x7Zq9ROIHmGn9Q= +k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= -k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= -k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= -sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= -sigs.k8s.io/controller-tools v0.17.1 h1:bQ+dKCS7jY9AgpefenBDtm6geJZCHVKbegpLynxgyus= -sigs.k8s.io/controller-tools v0.17.1/go.mod h1:3QXAdrmdxYuQ4MifvbCAFD9wLXn7jylnfBPYS4yVDdc= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.22.3 h1:I7mfqz/a/WdmDCEnXmSPm8/b/yRTy6JsKKENTijTq8Y= +sigs.k8s.io/controller-runtime v0.22.3/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +sigs.k8s.io/controller-tools v0.19.0 h1:OU7jrPPiZusryu6YK0jYSjPqg8Vhf8cAzluP9XGI5uk= +sigs.k8s.io/controller-tools v0.19.0/go.mod h1:y5HY/iNDFkmFla2CfQoVb2AQXMsBk4ad84iR1PLANB0= +sigs.k8s.io/e2e-framework v0.6.0 h1:p7hFzHnLKO7eNsWGI2AbC1Mo2IYxidg49BiT4njxkrM= +sigs.k8s.io/e2e-framework v0.6.0/go.mod h1:IREnCHnKgRCioLRmNi0hxSJ1kJ+aAdjEKK/gokcZu4k= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 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/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/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/internal/clients/git/git.go b/internal/clients/git/git.go index 52af9ec..793f1d2 100644 --- a/internal/clients/git/git.go +++ b/internal/clients/git/git.go @@ -2,6 +2,7 @@ package git import ( "bytes" + "context" "errors" "fmt" "io/fs" @@ -15,6 +16,9 @@ import ( "sync" "time" + contexttools "github.com/krateoplatformops/provider-runtime/pkg/context" + "github.com/krateoplatformops/provider-runtime/pkg/logging" + "github.com/go-git/go-git/v5/plumbing/cache" gitclient "github.com/go-git/go-git/v5/plumbing/transport/client" githttp "github.com/go-git/go-git/v5/plumbing/transport/http" @@ -33,8 +37,8 @@ import ( ) const ( - commitAuthorEmail = "krateoctl@krateoplatformops.io" - commitAuthorName = "krateoctl" + commitAuthorEmail = "contact@krateo.io" + commitAuthorName = "krateo-git-provider" ) var ( @@ -224,7 +228,7 @@ func IsInGitCommitHistory(opts ListOptions, hash string) (bool, error) { if len(res.cookie) > 0 { if err := res.setCustomHTTPSClientWithCookieJar(); err != nil { - return false, err + return false, fmt.Errorf("failed to set custom HTTPS client: %w", err) } } defer res.setDefaultHTTPSClient() @@ -254,6 +258,7 @@ func IsInGitCommitHistory(opts ListOptions, hash string) (bool, error) { res.repo, err = git.Clone(res.storer, res.fs, &cloneOpts) if err != nil { if strings.Contains(err.Error(), "couldn't find remote ref") { + fmt.Println("Branch not found in remote repository") return false, nil } return false, fmt.Errorf("failed to clone repository: %v", err) @@ -282,6 +287,95 @@ func IsInGitCommitHistory(opts ListOptions, hash string) (bool, error) { return found, err } +// the first return value is false if a git-provider commit is +func IsFuncInGitCommitHistory(ctx context.Context, opts ListOptions, f func(commit *object.Commit) bool) (plumbing.Hash, error) { + + log := contexttools.LoggerFromCtx(ctx, logging.NewNopLogger()) + + tmpDir, err := os.MkdirTemp(opts.HomeDir, "git-provider-history-*") + if err != nil { + return plumbing.Hash{}, fmt.Errorf("failed to create temporary directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + diskFS := osfs.New(tmpDir) + dotGitFS, err := diskFS.Chroot(".git") + if err != nil { + return plumbing.Hash{}, fmt.Errorf("failed to create .git directory: %w", err) + } + + storer := filesystem.NewStorage(dotGitFS, cache.NewObjectLRUDefault()) + + res := &Repo{ + rawURL: opts.URL, + auth: opts.Auth, + storer: storer, + fs: diskFS, + cookie: opts.GitCookies, + tmpDir: tmpDir, + } + + if len(res.cookie) > 0 { + if err := res.setCustomHTTPSClientWithCookieJar(); err != nil { + return plumbing.Hash{}, err + } + } + defer res.setDefaultHTTPSClient() + + cloneOpts := git.CloneOptions{ + RemoteName: "origin", + URL: opts.URL, + Auth: opts.Auth, + ReferenceName: plumbing.NewBranchReferenceName(opts.Branch), + SingleBranch: true, + InsecureSkipTLS: opts.Insecure, + } + + oldUnsupportedCaps := transport.UnsupportedCapabilities + defer restoreUnsupportedCapabilities(oldUnsupportedCaps) + + // Azure DevOps requires multi_ack and multi_ack_detailed capabilities, which go-git doesn't + // implement. But: it's possible to do a full clone by saying it's _not_ _un_supported, in which + // case the library happily functions so long as it doesn't _actually_ get a multi_ack packet. See + // https://github.com/go-git/go-git/blob/v5.5.1/_examples/azure_devops/main.go. + if strings.Contains(opts.URL, "dev.azure.com") { + transport.UnsupportedCapabilities = []capability.Capability{ + capability.ThinPack, + } + } + + res.repo, err = git.Clone(res.storer, res.fs, &cloneOpts) + if err != nil { + if strings.Contains(err.Error(), "couldn't find remote ref") { + log.Warn("Branch not found in remote repository", "branch", opts.Branch, "url", opts.URL) + return plumbing.Hash{}, nil + } + return plumbing.Hash{}, fmt.Errorf("failed to clone repository: %v", err) + } + head, err := res.repo.Head() + if err != nil { + return plumbing.Hash{}, fmt.Errorf("failed to get HEAD: %v", err) + } + iter, err := res.repo.Log(&git.LogOptions{From: head.Hash()}) + if err != nil { + return plumbing.Hash{}, fmt.Errorf("failed to get commit history: %v", err) + } + + // Iterate through the commits + found := plumbing.Hash{} + err = iter.ForEach(func(c *object.Commit) error { + if f(c) { + found = c.Hash + return nil + } + return nil + }) + if err != nil { + return plumbing.Hash{}, fmt.Errorf("failed to iterate through commits: %v", err) + } + return found, err +} + /* The function simulate the application of filemode of each from the origin repo (contained in "IndexOption.FromPath") to the destination repo (to files contained in IndexOption.ToPath) ---- git update-index --chmod @@ -372,6 +466,16 @@ func Clone(opts CloneOptions) (*Repo, error) { } } + // Azure DevOps requires multi_ack and multi_ack_detailed capabilities, which go-git doesn't + // implement. But: it's possible to do a full clone by saying it's _not_ _un_supported, in which + // case the library happily functions so long as it doesn't _actually_ get a multi_ack packet. See + // https://github.com/go-git/go-git/blob/v5.5.1/_examples/azure_devops/main.go. + if strings.Contains(opts.URL, "dev.azure.com") { + transport.UnsupportedCapabilities = []capability.Capability{ + capability.ThinPack, + } + } + // Clone the given repository to the given directory cloneOpts := git.CloneOptions{ RemoteName: "origin", @@ -534,33 +638,36 @@ func (s *Repo) Branch(name string, createOpt *CreateOpt) error { }) } -func (s *Repo) Commit(path, msg string, opt *IndexOptions) (string, error) { +func (s *Repo) Commit(path, msg string, opt *IndexOptions) (plumbing.Hash, error) { if err := s.setCustomHTTPSClientWithCookieJar(); err != nil { - return "", fmt.Errorf("failed to set custom HTTPS client: %w", err) + return plumbing.Hash{}, fmt.Errorf("failed to set custom HTTPS client: %w", err) } defer s.setDefaultHTTPSClient() wt, err := s.repo.Worktree() if err != nil { - return "", fmt.Errorf("failed to get worktree: %w", err) + return plumbing.Hash{}, fmt.Errorf("failed to get worktree: %w", err) } + // git add $path if _, err := wt.Add(path); err != nil { - return "", fmt.Errorf("failed to add file to index: %w", err) + return plumbing.Hash{}, fmt.Errorf("failed to add file to index: %w", err) } - err = s.UpdateIndex(opt) - if err != nil { - return "", fmt.Errorf("failed to update index: %w", err) + if opt.OriginRepo != nil { + err = s.UpdateIndex(opt) + if err != nil { + return plumbing.Hash{}, fmt.Errorf("failed to update index: %w", err) + } } fStatus, err := wt.Status() if err != nil { - return "", fmt.Errorf("failed to get status of worktree: %w", err) + return plumbing.Hash{}, fmt.Errorf("failed to get status of worktree: %w", err) } if fStatus.IsClean() && !ptr.Deref(s.isNewBranch, false) { - return "", NoErrAlreadyUpToDate + return plumbing.Hash{}, NoErrAlreadyUpToDate } // git commit -m $message @@ -572,10 +679,10 @@ func (s *Repo) Commit(path, msg string, opt *IndexOptions) (string, error) { }, }) if err != nil { - return "", NoErrAlreadyUpToDate + return plumbing.Hash{}, NoErrAlreadyUpToDate } - return hash.String(), nil + return hash, nil } func (s *Repo) Push(downstream, branch string, insecure bool) error { @@ -596,7 +703,7 @@ func (s *Repo) Push(downstream, branch string, insecure bool) error { refs, err := s.repo.References() if err != nil { - return err + return fmt.Errorf("failed to get references: %w", err) } var foundLocal bool @@ -610,17 +717,17 @@ func (s *Repo) Push(downstream, branch string, insecure bool) error { if !foundLocal { headRef, err := s.repo.Head() if err != nil { - return err + return fmt.Errorf("failed to get HEAD reference: %w", err) } ref := plumbing.NewHashReference(refName, headRef.Hash()) err = s.repo.Storer.SetReference(ref) if err != nil { - return err + return fmt.Errorf("failed to create local branch reference: %w", err) } } - return s.repo.Push(&git.PushOptions{ + err = s.repo.Push(&git.PushOptions{ RemoteName: downstream, Force: false, Auth: s.auth, @@ -629,6 +736,10 @@ func (s *Repo) Push(downstream, branch string, insecure bool) error { config.RefSpec(refName + ":" + refName), }, }) + if err != nil { + return fmt.Errorf("failed to push to remote: %w", err) + } + return nil } func Pull(s *Repo, insecure bool) error { diff --git a/internal/clients/git/git_test.go b/internal/clients/git/git_test.go index faec417..92dbb94 100644 --- a/internal/clients/git/git_test.go +++ b/internal/clients/git/git_test.go @@ -1,9 +1,13 @@ package git import ( + "context" + "fmt" "os" + "strings" "testing" + "github.com/go-git/go-git/v5/plumbing/object" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -13,20 +17,22 @@ func TestIsInGitCommitHistory(t *testing.T) { baseRepo.BuildBasicRepository() opts := ListOptions{ - URL: baseRepo.GetBasicLocalRepositoryURL(), + URL: baseRepo.GetBasicLocalRepositoryURL(), + Branch: "master", } - hash := "0123456789abcdef0123456789abcdef01234567" + hash := "6ecf0ef2c2dffb796033e5a02219af86ec6584e5" exists, err := IsInGitCommitHistory(opts, hash) if err != nil { t.Errorf("Error checking commit history: %v", err) } + require.NoError(t, err) if exists { t.Logf("Commit %s exists in the Git repository", hash) } else { - t.Logf("Commit %s does not exist in the Git repository", hash) + t.Errorf("Commit %s does not exist in the Git repository", hash) } } @@ -249,3 +255,60 @@ func TestPush(t *testing.T) { require.NoError(t, err) }) } + +func TestIsFuncInGitCommitHistory(t *testing.T) { + baseRepo := BaseSuite{} + baseRepo.BuildBasicRepository() + + opts := ListOptions{ + URL: baseRepo.GetBasicLocalRepositoryURL(), + Branch: "master", + } + + hash := "6ecf0ef2c2dffb796033e5a02219af86ec6584e5" + + h, err := IsFuncInGitCommitHistory(context.TODO(), opts, func(commit *object.Commit) bool { + fmt.Println("Checking commit:", commit.Hash.String(), "with message:", commit.Message) + return commit.Hash.String() == hash + }) + if err != nil { + t.Errorf("Error checking commit history: %v", err) + } + + if !h.IsZero() { + t.Logf("Commit %s exists in the Git repository", hash) + } else { + t.Errorf("Commit %s does not exist in the Git repository", hash) + } +} + +func TestIsFuncInGitCommitHistory_complex(t *testing.T) { + baseRepo := BaseSuite{} + baseRepo.BuildBasicRepository() + + repo, err := Clone(CloneOptions{ + URL: baseRepo.GetBasicLocalRepositoryURL(), + Branch: "master", + }) + require.NoError(t, err) + defer repo.Cleanup() + + message := "Merge branch 'master' of github.com:tyba/git-fixture" + commit := "1669dce138d9b841a518c64b10914d88f5e488ea" + opts := ListOptions{ + URL: baseRepo.GetBasicLocalRepositoryURL(), + Branch: "master", + } + hash, err := IsFuncInGitCommitHistory(context.TODO(), opts, func(commit *object.Commit) bool { + return strings.Contains(commit.Message, message) + }) + require.NoError(t, err) + + require.Equal(t, commit, hash.String()) + + require.False(t, hash.IsZero()) + + // Try to push to a branch that doesn't exist locally yet + err = repo.Push("origin", "new-branch", false) + require.NoError(t, err) +} diff --git a/internal/controllers/common/credentialHelper/credentialsHelper.go b/internal/controllers/common/credentialHelper/credentialsHelper.go new file mode 100644 index 0000000..fb89f78 --- /dev/null +++ b/internal/controllers/common/credentialHelper/credentialsHelper.go @@ -0,0 +1,51 @@ +package credentialhelper + +import ( + "fmt" + "strings" + + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + + "github.com/go-git/go-git/v5/plumbing/transport" +) + +type CredentialHelperOpts struct { + AuthMethod string + Username string + Token string +} + +type Credentials struct { + Transport transport.AuthMethod + Cookie []byte +} + +func GetCredentials(opts CredentialHelperOpts) (*Credentials, error) { + if opts.Token == "" { + return nil, fmt.Errorf("token is required for authentication") + } + + if strings.EqualFold(opts.AuthMethod, "bearer") { + return &Credentials{ + Transport: &githttp.TokenAuth{ + Token: opts.Token, + }, + Cookie: nil, + }, nil + } + + if strings.EqualFold(opts.AuthMethod, "cookiefile") { + return &Credentials{ + Transport: nil, + Cookie: []byte(opts.Token), + }, nil + } + + return &Credentials{ + Transport: &githttp.BasicAuth{ + Username: opts.Username, + Password: opts.Token, + }, + Cookie: nil, + }, nil +} diff --git a/internal/controllers/common/footer/footer.go b/internal/controllers/common/footer/footer.go new file mode 100644 index 0000000..f91d187 --- /dev/null +++ b/internal/controllers/common/footer/footer.go @@ -0,0 +1,70 @@ +package footer + +import ( + "encoding/json" + "fmt" + "strings" + + localResourcev1alpha1 "github.com/krateoplatformops/git-provider/apis/localresource/v1alpha1" + hasher "github.com/krateoplatformops/git-provider/internal/tools/hash" +) + +type localResourceCommitFooter struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + SpecHash string `json:"specHash"` +} + +const localResourceCommitFooterPrefix = "Managed by git-provider LocalResource:" + +func CalculateLocalResourceSpecHash(cr *localResourcev1alpha1.LocalResource) (string, error) { + hash := hasher.NewFNVObjectHash() + err := hash.SumHash( + cr.Spec.FromResource, + cr.Spec.ToRepo.Url, + cr.Spec.ToRepo.Path, + cr.Spec.ToRepo.Branch, + cr.Spec.ToRepo.CloneFromBranch, + cr.Spec.PlaceholdersToOverride) + if err != nil { + return "", fmt.Errorf("unable to compute LocalResource spec hash: %w", err) + } + return hash.GetHash(), nil +} + +func LocalResourceCommitFooter(cr *localResourcev1alpha1.LocalResource) (string, error) { + hshString, err := CalculateLocalResourceSpecHash(cr) + if err != nil { + return "", fmt.Errorf("unable to compute LocalResource spec hash: %w", err) + } + // small struct format for easy parsing + footerStruct := localResourceCommitFooter{ + Namespace: cr.GetNamespace(), + Name: cr.GetName(), + SpecHash: hshString, + } + b, err := json.Marshal(footerStruct) + if err != nil { + return "", fmt.Errorf("unable to marshal LocalResource commit footer: %w", err) + } + return fmt.Sprintf("%s %s", localResourceCommitFooterPrefix, string(b)), nil +} + +func ParseLocalResourceCommitFooter(footer string) (namespace, name, specHash string, err error) { + // There could be other text before the footer, so we need to extract the footer part + footerStart := strings.LastIndex(footer, localResourceCommitFooterPrefix) + if footerStart == -1 { + return "", "", "", fmt.Errorf("LocalResource commit footer not found") + } + footer = footer[footerStart+len(localResourceCommitFooterPrefix):] // +1 to skip the space + footer = strings.TrimSpace(footer) + var footerStruct localResourceCommitFooter + err = json.Unmarshal([]byte(footer), &footerStruct) + if err != nil { + return "", "", "", fmt.Errorf("unable to unmarshal LocalResource commit footer: %w", err) + } + ns := footerStruct.Namespace + nm := footerStruct.Name + sh := footerStruct.SpecHash + return ns, nm, sh, nil +} diff --git a/internal/controllers/common/footer/footer_test.go b/internal/controllers/common/footer/footer_test.go new file mode 100644 index 0000000..e19259a --- /dev/null +++ b/internal/controllers/common/footer/footer_test.go @@ -0,0 +1,106 @@ +package footer + +import ( + "fmt" + "testing" + + localResourcev1alpha1 "github.com/krateoplatformops/git-provider/apis/localresource/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func makeTestLocalResource() *localResourcev1alpha1.LocalResource { + return &localResourcev1alpha1.LocalResource{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-name", + }, + Spec: localResourcev1alpha1.LocalResourceSpec{ + FromResource: localResourcev1alpha1.FromResource{ + FileName: "config.yaml", + }, + ToRepo: localResourcev1alpha1.LocalResourceOpts{ + Url: "https://example.com/repo.git", + Path: "path/in/repo", + Branch: "main", + CloneFromBranch: "develop", + }, + PlaceholdersToOverride: []localResourcev1alpha1.Placeholder{ + { + Name: "PLACEHOLDER_1", + Value: "value1", + }, + { + Name: "PLACEHOLDER_2", + Value: "value2", + }, + }, + }, + } +} + +func TestLocalResourceCommitFooterAndParse(t *testing.T) { + cr := makeTestLocalResource() + + footerStr, err := LocalResourceCommitFooter(cr) + if err != nil { + t.Fatalf("LocalResourceCommitFooter returned error: %v", err) + } + + fmt.Println("Generated footer:", footerStr) + + // ensure parse works even if there is other text before the footer + withPrefix := "Some message\n" + footerStr + ns, name, specHash, err := ParseLocalResourceCommitFooter(withPrefix) + if err != nil { + t.Fatalf("parseLocalResourceCommitFooter returned error: %v", err) + } + + if ns != cr.GetNamespace() { + t.Fatalf("namespace mismatch: got %q want %q", ns, cr.GetNamespace()) + } + if name != cr.GetName() { + t.Fatalf("name mismatch: got %q want %q", name, cr.GetName()) + } + + expectedHash, err := CalculateLocalResourceSpecHash(cr) + if err != nil { + t.Fatalf("CalculateLocalResourceSpecHash returned error: %v", err) + } + if specHash != expectedHash { + t.Fatalf("spec hash mismatch: got %q want %q", specHash, expectedHash) + } +} + +func TestParseLocalResourceCommitFooter_NotFound(t *testing.T) { + _, _, _, err := ParseLocalResourceCommitFooter("no footer present here") + if err == nil { + t.Fatal("expected error when parsing missing footer, got nil") + } +} + +func TestCalculateLocalResourceSpecHash_Deterministic(t *testing.T) { + cr1 := makeTestLocalResource() + cr2 := makeTestLocalResource() + + h1, err := CalculateLocalResourceSpecHash(cr1) + if err != nil { + t.Fatalf("calculateLocalResourceSpecHash returned error: %v", err) + } + h2, err := CalculateLocalResourceSpecHash(cr2) + if err != nil { + t.Fatalf("calculateLocalResourceSpecHash returned error: %v", err) + } + if h1 != h2 { + t.Fatalf("hash not deterministic for identical specs: %q vs %q", h1, h2) + } + + // change a field and expect a different hash + cr2.Spec.ToRepo.Path = "different-path" + h3, err := CalculateLocalResourceSpecHash(cr2) + if err != nil { + t.Fatalf("calculateLocalResourceSpecHash returned error: %v", err) + } + if h3 == h1 { + t.Fatalf("hash did not change after modifying spec: %q == %q", h1, h3) + } +} diff --git a/internal/controllers/common/option/option.go b/internal/controllers/common/option/option.go new file mode 100644 index 0000000..605d4f0 --- /dev/null +++ b/internal/controllers/common/option/option.go @@ -0,0 +1,23 @@ +package option + +import ( + "time" + + "github.com/krateoplatformops/provider-runtime/pkg/controller" +) + +type GitOptions struct { + CommitAuthorName string + CommitAuthorEmail string + HomeDir string +} + +type ControllerOptions struct { + controller.Options + Timeout time.Duration +} + +type SetupOptions struct { + Controller ControllerOptions + Git GitOptions +} diff --git a/internal/controllers/git.go b/internal/controllers/controllers.go similarity index 51% rename from internal/controllers/git.go rename to internal/controllers/controllers.go index 081f65f..27070f8 100644 --- a/internal/controllers/git.go +++ b/internal/controllers/controllers.go @@ -1,16 +1,18 @@ package controllers import ( - "github.com/krateoplatformops/provider-runtime/pkg/controller" ctrl "sigs.k8s.io/controller-runtime" + "github.com/krateoplatformops/git-provider/internal/controllers/common/option" + "github.com/krateoplatformops/git-provider/internal/controllers/localresource" "github.com/krateoplatformops/git-provider/internal/controllers/repo" ) // Setup creates all controllers with the supplied logger and adds them to // the supplied manager. -func Setup(mgr ctrl.Manager, o controller.Options) error { - for _, setup := range []func(ctrl.Manager, controller.Options) error{ +func Setup(mgr ctrl.Manager, o option.SetupOptions) error { + for _, setup := range []func(ctrl.Manager, option.SetupOptions) error{ + localresource.Setup, repo.Setup, } { if err := setup(mgr, o); err != nil { diff --git a/internal/controllers/localresource/localresource.go b/internal/controllers/localresource/localresource.go new file mode 100644 index 0000000..68276ee --- /dev/null +++ b/internal/controllers/localresource/localresource.go @@ -0,0 +1,450 @@ +package localresource + +import ( + "context" + "fmt" + + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/pkg/errors" + + commonv1 "github.com/krateoplatformops/provider-runtime/apis/common/v1" + "github.com/krateoplatformops/provider-runtime/pkg/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/tools/record" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/krateoplatformops/provider-runtime/pkg/event" + "github.com/krateoplatformops/provider-runtime/pkg/logging" + "github.com/krateoplatformops/provider-runtime/pkg/meta" + "github.com/krateoplatformops/provider-runtime/pkg/ratelimiter" + + localResourcev1alpha1 "github.com/krateoplatformops/git-provider/apis/localresource/v1alpha1" + "github.com/krateoplatformops/git-provider/internal/clients/git" + credentialhelper "github.com/krateoplatformops/git-provider/internal/controllers/common/credentialHelper" + "github.com/krateoplatformops/git-provider/internal/controllers/common/footer" + "github.com/krateoplatformops/git-provider/internal/controllers/common/option" + "github.com/krateoplatformops/git-provider/internal/tools/copier" + "github.com/krateoplatformops/git-provider/internal/tools/localfs" + "github.com/krateoplatformops/git-provider/internal/tools/template" + "github.com/krateoplatformops/plumbing/ptr" + contexttools "github.com/krateoplatformops/provider-runtime/pkg/context" + "github.com/krateoplatformops/provider-runtime/pkg/reconciler" + + corev1 "k8s.io/api/core/v1" +) + +const ( + errNotLocalResource = "managed resource is not a LocalResource custom resource" +) + +// Setup adds a controller that reconciles Token managed resources. +func Setup(mgr ctrl.Manager, o option.SetupOptions) error { + name := reconciler.ControllerName(localResourcev1alpha1.LocalResourceGroupKind) + + log := o.Controller.Logger.WithValues("controller", name) + + recorder := mgr.GetEventRecorderFor(name) + + r := reconciler.NewReconciler(mgr, + resource.ManagedKind(localResourcev1alpha1.LocalResourceGroupVersionKind), + reconciler.WithExternalConnecter(&connector{ + kube: mgr.GetClient(), + log: log, + dynamic: dynamic.NewForConfigOrDie(mgr.GetConfig()), + recorder: recorder, + homeDir: o.Git.HomeDir, + }), + reconciler.WithPollInterval(o.Controller.PollInterval), + reconciler.WithLogger(log), + reconciler.WithRecorder(event.NewAPIRecorder(recorder)), + reconciler.WithTimeout(o.Controller.Timeout), + ) + + return ctrl.NewControllerManagedBy(mgr). + Named(name). + WithOptions(o.Controller.ForControllerRuntime()). + For(&localResourcev1alpha1.LocalResource{}). + Complete(ratelimiter.New(name, r, o.Controller.GlobalRateLimiter)) +} + +type connector struct { + kube client.Client + dynamic dynamic.Interface + log logging.Logger + recorder record.EventRecorder + homeDir string +} +type gitClientOpts struct { + Insecure bool + UnsupportedCapabilities bool + ToRepoCreds *credentialhelper.Credentials + HomeDir string +} + +func (c *connector) Connect(ctx context.Context, mg resource.Managed) (reconciler.ExternalClient, error) { + cr, ok := mg.(*localResourcev1alpha1.LocalResource) + if !ok { + return nil, errors.New(errNotLocalResource) + } + + username, err := resource.GetSecret(ctx, c.kube, cr.Spec.ToRepo.Credentials.UsernameRef) + if err != nil { + return nil, fmt.Errorf("retrieving .toRepo username: %w", err) + } + token, err := resource.GetSecret(ctx, c.kube, cr.Spec.ToRepo.Credentials.SecretRef) + if err != nil { + return nil, fmt.Errorf("retrieving .toRepo token: %w", err) + } + + credOpts := credentialhelper.CredentialHelperOpts{ + AuthMethod: cr.Spec.ToRepo.Credentials.AuthMethod, + Username: username, + Token: token, + } + + creds, err := credentialhelper.GetCredentials(credOpts) + if err != nil { + return nil, fmt.Errorf("getting .toRepo credentials: %w", err) + } + + cfg := &gitClientOpts{ + Insecure: cr.Spec.Insecure, + UnsupportedCapabilities: cr.Spec.UnsupportedCapabilities, + ToRepoCreds: creds, + } + + log := c.log.WithValues("name", cr.Name, "namespace", cr.Namespace) + + return &external{ + kube: c.kube, + log: log, + cfg: cfg, + dynamic: c.dynamic, + rec: c.recorder, + }, nil +} + +// An ExternalClient observes, then either creates, updates, or deletes an +// external resource to ensure it reflects the managed resource's desired state. +type external struct { + kube client.Client + log logging.Logger + cfg *gitClientOpts + dynamic dynamic.Interface + rec record.EventRecorder +} + +func (e *external) Observe(ctx context.Context, mg resource.Managed) (reconciler.ExternalObservation, error) { + cr, ok := mg.(*localResourcev1alpha1.LocalResource) + if !ok { + return reconciler.ExternalObservation{}, errors.New(errNotLocalResource) + } + + log := e.log.WithValues("operation", "observe") + + ctx = contexttools.CtxWithLogger(ctx, log) + + log.Debug("Observing resource") + + if cr.GetCondition(commonv1.TypeReady).Reason == commonv1.ReasonDeleting { + return reconciler.ExternalObservation{ + ResourceExists: false, + ResourceUpToDate: true, + }, nil + } + + if !cr.DeletionTimestamp.IsZero() && cr.GetCondition(commonv1.TypeSynced).Reason == commonv1.ReasonReconcileError { + if !meta.IsActionAllowed(cr, meta.ActionDelete) { + log.Debug("External resource should not be deleted by provider, skip deleting.") + } else { + return reconciler.ExternalObservation{ + ResourceExists: false, + ResourceUpToDate: true, + }, nil + } + } + + if cr.Status.TargetCommitId != "" { + meta.SetExternalName(cr, cr.Status.TargetCommitId) + } + + var hasGitProviderPreviuslyCommitted bool + if hash, err := git.IsFuncInGitCommitHistory(ctx, git.ListOptions{ + URL: cr.Spec.ToRepo.Url, + Auth: e.cfg.ToRepoCreds.Transport, + Insecure: e.cfg.Insecure, + Branch: cr.Spec.ToRepo.Branch, + GitCookies: e.cfg.ToRepoCreds.Cookie, + HomeDir: e.cfg.HomeDir, // Use the configured home directory for temporary files + }, func(commit *object.Commit) bool { + e.log.Debug("Analyzing commit", "commitId", commit.Hash.String()) + namespace, name, specHash, err := footer.ParseLocalResourceCommitFooter(commit.Message) + if err != nil { + return false + } + e.log.Debug("Parsed commit footer", "namespace", namespace, "name", name, "specHash", specHash) + if namespace == cr.GetNamespace() && name == cr.GetName() { + hasGitProviderPreviuslyCommitted = true + e.log.Debug("Found previous commit from git-provider for this LocalResource", "commitId", commit.Hash.String()) + } + currentSpecHash, err := footer.CalculateLocalResourceSpecHash(cr) + if err != nil { + return false + } + + e.log.Debug("Comparing spec hash with commit footer hash", "specHash", currentSpecHash, "commitFooterHash", specHash) + return specHash == currentSpecHash + }); err != nil { + log.Debug("Unable to check if target LocalResource spec is up-to-date", "msg", err.Error()) + return reconciler.ExternalObservation{}, err + } else if hash.IsZero() && hasGitProviderPreviuslyCommitted { + log.Debug("Target LocalResource spec is not up-to-date", "commitId", cr.Status.TargetCommitId, "branch", cr.Status.TargetBranch) + return reconciler.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: false, + }, nil + } else if !hash.IsZero() && hasGitProviderPreviuslyCommitted { + meta.SetExternalName(cr, hash.String()) + cr.Status.TargetCommitId = hash.String() + cr.Status.TargetBranch = cr.Spec.ToRepo.Branch + + log.Debug("Target LocalResource spec is synced", "commitId", cr.Status.TargetCommitId, "branch", cr.Status.TargetBranch) + } else { + log.Debug("No previous commit from git-provider found for this LocalResource", "hash", hash.String(), "hasGitProviderPreviuslyCommitted", hasGitProviderPreviuslyCommitted) + } + + if meta.GetExternalName(cr) == "" { + return reconciler.ExternalObservation{ + ResourceExists: false, + ResourceUpToDate: true, + }, nil + } + + cr.Status.SetConditions(commonv1.Available()) + + return reconciler.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + }, nil +} + +func (e *external) Create(ctx context.Context, mg resource.Managed) error { + cr, ok := mg.(*localResourcev1alpha1.LocalResource) + if !ok { + return errors.New(errNotLocalResource) + } + log := e.log.WithValues("operation", "create") + ctx = contexttools.CtxWithLogger(ctx, log) + if !meta.IsActionAllowed(cr, meta.ActionCreate) { + log.Debug("External resource should not be created by provider, skip creating.") + return nil + } + log.Info("Creating resource") + cr.Status.SetConditions(commonv1.Creating()) + return e.SyncLocalResources(ctx, cr, cr.Spec.CreateCommitMessage) +} + +func (e *external) Update(ctx context.Context, mg resource.Managed) error { + cr, ok := mg.(*localResourcev1alpha1.LocalResource) + if !ok { + return errors.New(errNotLocalResource) + } + log := e.log.WithValues("operation", "update") + ctx = contexttools.CtxWithLogger(ctx, log) + if !cr.Spec.SyncEnabled { + log.Warn("External resource should not be updated by provider, skip updating. SyncEnabled is false.") + return nil + } + if !meta.IsActionAllowed(cr, meta.ActionUpdate) { + log.Debug("External resource should not be updated by provider, skip updating.") + return nil + } + + log.Info("Updating resource") + cr.Status.SetConditions(commonv1.Creating()) + return e.SyncLocalResources(ctx, cr, cr.Spec.UpdateCommitMessage) +} + +func (e *external) Delete(ctx context.Context, mg resource.Managed) error { + cr, ok := mg.(*localResourcev1alpha1.LocalResource) + if !ok { + return errors.New(errNotLocalResource) + } + log := e.log.WithValues("operation", "delete") + // You may want to use the logger in the context for further calls + // ctx = contexttools.CtxWithLogger(ctx, log) + if !meta.IsActionAllowed(cr, meta.ActionDelete) { + log.Debug("External resource should not be deleted by provider, skip deleting.") + return nil + } + + log.Info("Deleting resource") + + cr.Status.SetConditions(commonv1.Deleting()) + + return nil // noop +} + +func (e *external) SyncLocalResources(ctx context.Context, cr *localResourcev1alpha1.LocalResource, commitMessage string) error { + spec := cr.Spec.DeepCopy() + + toRepo, err := git.Clone(git.CloneOptions{ + URL: spec.ToRepo.Url, + Auth: e.cfg.ToRepoCreds.Transport, + Insecure: e.cfg.Insecure, + UnsupportedCapabilities: e.cfg.UnsupportedCapabilities, + Branch: spec.ToRepo.Branch, + AlternativeBranch: ptr.To(cr.Spec.ToRepo.CloneFromBranch), + GitCookies: e.cfg.ToRepoCreds.Cookie, + HomeDir: e.cfg.HomeDir, // Use the configured home directory for temporary files + }) + if err != nil { + return fmt.Errorf("cloning toLocalResource: %w", err) + } + defer toRepo.Cleanup() + + log := contexttools.LoggerFromCtx(ctx, e.log) + + log.Debug("Target LocalResource cloned", "url", spec.ToRepo.Url) + e.rec.Eventf(cr, corev1.EventTypeNormal, "TargetLocalResourceCloned", + "Successfully cloned target LocalResource: %s", spec.ToRepo.Url) + log.Debug(fmt.Sprintf("Target LocalResource on branch %s", toRepo.CurrentBranch())) + + fromLocal, err := localfs.NewLocalFS(e.cfg.HomeDir) + if err != nil { + return fmt.Errorf("creating local filesystem: %w", err) + } + defer fromLocal.Cleanup() + + fromPath := "/" + toPath := spec.ToRepo.Path + override := spec.Override + if len(toPath) == 0 { + toPath = "/" + } + + filename := spec.FromResource.FileName + if spec.FromResource.FromYaml != nil { + filename, err = fromLocal.WriteK8sResource(filename, *spec.FromResource.FromYaml) + if err != nil { + return fmt.Errorf("writing fromResource.FromYaml to local filesystem: %w", err) + } + log.Debug("fromResource.FromYaml written to local filesystem", "fileName", filename) + } else if spec.FromResource.FromRef != nil { + gv, err := schema.ParseGroupVersion(spec.FromResource.FromRef.ApiVersion) + if err != nil { + return fmt.Errorf("parsing group version from fromResource.FromRef: %w", err) + } + + var cli dynamic.ResourceInterface + if spec.FromResource.FromRef.Namespace == "" { + cli = e.dynamic.Resource(schema.GroupVersionResource{ + Group: gv.Group, + Version: gv.Version, + Resource: spec.FromResource.FromRef.Resource, + }) + } else { + cli = e.dynamic.Resource(schema.GroupVersionResource{ + Group: gv.Group, + Version: gv.Version, + Resource: spec.FromResource.FromRef.Resource, + }).Namespace(spec.FromResource.FromRef.Namespace) + } + uRes, err := cli.Get(ctx, spec.FromResource.FromRef.Name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("getting resource from fromResource.FromRef: %w", err) + } + filename, err = fromLocal.WriteK8sResource(filename, runtime.RawExtension{Object: uRes}) + if err != nil { + return fmt.Errorf("writing fromResource.FromRef to local filesystem: %w", err) + } + log.Debug("fromResource.FromRef written to local filesystem", "fileName", filename) + } else if spec.FromResource.FromString != nil { + filename, err = fromLocal.WriteStringResource(filename, *spec.FromResource.FromString) + if err != nil { + return fmt.Errorf("writing fromResource.FromString to local filesystem: %w", err) + } + log.Debug("fromResource.FromString written to local filesystem", "fileName", filename) + } + + var values []template.TemplateValue + if spec.PlaceholdersToOverride != nil { + for _, p := range spec.PlaceholdersToOverride { + values = append(values, template.TemplateValue{ + Key: p.Name, + Value: p.Value, + }) + } + } + co, err := copier.NewCopier(fromLocal, toRepo.FS(), + copier.WithOriginCopyPath(fromPath), + copier.WithTargetCopyPath(toPath), + copier.WithGoTemplate(values), + ) + if err != nil { + return fmt.Errorf("unable to create copier: %w", err) + } + if err := co.Copy(override); err != nil { + return fmt.Errorf("unable to copy files: %w", err) + } + + log.Debug("Origin and target LocalResource synchronized", + "toUrl", spec.ToRepo.Url, + "fromPath", fromPath, + "toPath", toPath) + + commitFooter, err := footer.LocalResourceCommitFooter(cr) + if err != nil { + return fmt.Errorf("unable to compute LocalResource commit footer: %w", err) + } + commitMessage = fmt.Sprintf("%s\n\n%s", commitMessage, commitFooter) + toLocalResourceCommitIdObj, err := toRepo.Commit(".", commitMessage, &git.IndexOptions{ + OriginRepo: nil, + FromPath: fromPath, + ToPath: toPath, + }) + toLocalResourceCommitId := toLocalResourceCommitIdObj.String() + if err == git.NoErrAlreadyUpToDate { + toLocalResourceCommitId, err := toRepo.GetLatestCommit(toRepo.CurrentBranch()) + if err != nil { + return fmt.Errorf("unable to get latest commit from target LocalResource: %w", err) + } + log.Debug("Target LocalResource not commited", "branch", toRepo.CurrentBranch(), "status", "repository already up-to-date") + + meta.SetExternalName(cr, toLocalResourceCommitId) + cr.Status.TargetCommitId = toLocalResourceCommitId + cr.Status.TargetBranch = toRepo.CurrentBranch() + + err = e.kube.Status().Update(ctx, cr) + if err != nil { + return fmt.Errorf("unable to update status: %w", err) + } + return nil + } else if err != nil { + return fmt.Errorf("unable to commit target LocalResource: %w", err) + } + log.Debug("Target LocalResource committed", "branch", toRepo.CurrentBranch(), "commitId", toLocalResourceCommitId) + + err = toRepo.Push("origin", toRepo.CurrentBranch(), e.cfg.Insecure) + if err != nil { + return fmt.Errorf("unable to push target LocalResource: %w", err) + } + log.Info("Target LocalResource pushed", "branch", toRepo.CurrentBranch(), "commitId", toLocalResourceCommitId) + e.rec.Eventf(cr, corev1.EventTypeNormal, "LocalResourcePushSuccess", + fmt.Sprintf("Target LocalResource pushed branch %s", toRepo.CurrentBranch())) + + meta.SetExternalName(cr, toLocalResourceCommitId) + cr.Status.TargetCommitId = toLocalResourceCommitId + cr.Status.TargetBranch = toRepo.CurrentBranch() + err = e.kube.Status().Update(ctx, cr) + if err != nil { + return fmt.Errorf("unable to update status: %w", err) + } + + return nil +} diff --git a/internal/controllers/localresource/localresource_test.go b/internal/controllers/localresource/localresource_test.go new file mode 100644 index 0000000..00c8a98 --- /dev/null +++ b/internal/controllers/localresource/localresource_test.go @@ -0,0 +1,733 @@ +//go:build integration +// +build integration + +package localresource + +import ( + "context" + "crypto/tls" + "encoding/base64" + "fmt" + "io" + "log/slog" + "net/http" + "net/netip" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/krateoplatformops/git-provider/apis" + "github.com/krateoplatformops/git-provider/apis/localresource/v1alpha1" + "github.com/krateoplatformops/git-provider/internal/controllers/common/option" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/client" + + "github.com/go-logr/logr" + prettylog "github.com/krateoplatformops/plumbing/slogs/pretty" + "github.com/krateoplatformops/provider-runtime/pkg/controller" + "github.com/krateoplatformops/provider-runtime/pkg/logging" + "github.com/krateoplatformops/provider-runtime/pkg/ratelimiter" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + "github.com/krateoplatformops/plumbing/e2e" + xenv "github.com/krateoplatformops/plumbing/env" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + clientsetscheme "k8s.io/client-go/kubernetes/scheme" + + "sigs.k8s.io/e2e-framework/klient/decoder" + "sigs.k8s.io/e2e-framework/klient/k8s" + "sigs.k8s.io/e2e-framework/klient/k8s/resources" + "sigs.k8s.io/e2e-framework/pkg/env" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/envfuncs" + "sigs.k8s.io/e2e-framework/pkg/features" + "sigs.k8s.io/e2e-framework/support/kind" +) + +var ( + testenv env.Environment + clusterName string +) + +const ( + crdPath = "../../../crds" + testdataPath = "../../../testdata/" + manifestsPath = "./testdata" + + namespace = "test-system" +) + +func TestMain(m *testing.M) { + xenv.SetTestMode(true) + + clusterName = "krateo-git-provider-controller" + testenv = env.New() + kindCluster := kind.NewCluster(clusterName) + + _ = apiextensionsv1.AddToScheme(clientsetscheme.Scheme) + _ = apis.AddToScheme(clientsetscheme.Scheme) + + cli, err := client.New( + client.FromEnv, + client.WithAPIVersionNegotiation(), + ) + if err != nil { + panic(err) + } + defer cli.Close() + + // var containerId string + + giteaAdmin := "admin" + giteaAdminPassword := "admin123" + + testenv.Setup( + envfuncs.CreateCluster(kindCluster, clusterName), + e2e.CreateNamespace(namespace), + + // Start docker gitea instance + func(ctx context.Context, cfg *envconf.Config) (context.Context, error) { + // Crea il client Docker + + tmpdir, err := os.MkdirTemp(os.TempDir(), "local-resource-test-gitea-*") + if err != nil { + panic(err) + } + + imageName := "gitea/gitea:latest" + fmt.Println("Pulling Gitea image...") + reader, err := cli.ImagePull(ctx, imageName, client.ImagePullOptions{}) + if err != nil { + panic(err) + } + defer reader.Close() + io.Copy(os.Stdout, reader) + + containerPort, err := network.ParsePort("3000/tcp") + if err != nil { + panic(err) + } + sshPort, err := network.ParsePort("22/tcp") + if err != nil { + panic(err) + } + httpsPort, err := network.ParsePort("443/tcp") + if err != nil { + panic(err) + } + + portBinding := network.PortMap{ + containerPort: []network.PortBinding{ + { + HostIP: netip.AddrFrom4([4]byte{0, 0, 0, 0}), + HostPort: "3000", + }, + }, + sshPort: []network.PortBinding{ + { + HostIP: netip.AddrFrom4([4]byte{0, 0, 0, 0}), + HostPort: "2222", + }, + }, + httpsPort: []network.PortBinding{ + { + HostIP: netip.AddrFrom4([4]byte{0, 0, 0, 0}), + HostPort: "443", + }, + }, + } + + containerConfig := &container.Config{ + Image: imageName, + ExposedPorts: network.PortSet{ + containerPort: struct{}{}, + sshPort: struct{}{}, + httpsPort: struct{}{}, + }, + Env: []string{ + "GITEA__database__DB_TYPE=sqlite3", + "GITEA__security__INSTALL_LOCK=true", + "USER_UID=1000", + "USER_GID=1000", + + // HTTPS with self-signed certs + "GITEA__server__DOMAIN=localhost", + "GITEA__server__HTTP_PORT=443", + "GITEA__server__ROOT_URL=https://localhost:443", + "GITEA__server__PROTOCOL=https", + "GITEA__server__CERT_FILE=/data/cert.pem", + "GITEA__server__KEY_FILE=/data/key.pem", + }, + Entrypoint: []string{"/bin/sh", "-c"}, + // Cmd: []string{ + // fmt.Sprintf("echo 'su-exec git /usr/local/bin/gitea migrate' >> /etc/s6/gitea/setup\necho 'su-exec git /usr/local/bin/gitea admin user create --username '%s' --password '%s' --email admin@local --admin --must-change-password=false' >> /etc/s6/gitea/setup\n/usr/bin/entrypoint /usr/bin/s6-svscan /etc/s6", giteaAdmin, giteaAdminPassword), + // }, + Cmd: []string{ + fmt.Sprintf(` + # Genera certificati self-signed se non esistono + if [ ! -f /data/cert.pem ]; then + echo "Generating self-signed certificates..." + cd /data && /usr/local/bin/gitea cert --host localhost,127.0.0.1 --ca + fi + + # Setup Gitea + echo 'su-exec git /usr/local/bin/gitea migrate' >> /etc/s6/gitea/setup + echo 'su-exec git /usr/local/bin/gitea admin user create --username %s --password %s --email admin@local --admin --must-change-password=false' >> /etc/s6/gitea/setup + + # Avvia Gitea + /usr/bin/entrypoint /usr/bin/s6-svscan /etc/s6 + `, giteaAdmin, giteaAdminPassword), + }, + } + + hostConfig := &container.HostConfig{ + PortBindings: portBinding, + RestartPolicy: container.RestartPolicy{ + Name: "always", + }, + Binds: []string{ + fmt.Sprintf("%s:/data", tmpdir), + }, + } + + networkConfig := &network.NetworkingConfig{} + + resp, err := cli.ContainerCreate( + ctx, + client.ContainerCreateOptions{ + Config: containerConfig, + HostConfig: hostConfig, + NetworkingConfig: networkConfig, + Name: "gitea-container", + }) + if err != nil { + panic(err) + } + + fmt.Printf("Container created: %s\n", resp.ID) + + _, err = cli.ContainerStart(ctx, resp.ID, client.ContainerStartOptions{}) + if err != nil { + panic(err) + } + // containerId = resp.ID + + fmt.Printf("Container started successfully!\n") + fmt.Printf("Access Gitea at: https://localhost:443\n") + + // Wait for Gitea to be ready + time.Sleep(10 * time.Second) + return ctx, nil + }, + + // Create test repository + func(ctx context.Context, cfg *envconf.Config) (context.Context, error) { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + client := &http.Client{Transport: tr} + + // Con Basic Auth + req2, _ := http.NewRequest("GET", "https://localhost:443/api/v1/user", nil) + req2.SetBasicAuth("admin", "admin123") + + resp2, err := client.Do(req2) + if err != nil { + panic(err) + } + defer resp2.Body.Close() + + if resp2.StatusCode != http.StatusOK { + panic(fmt.Sprintf("Failed to get user info: %s", resp2.Status)) + } + + req3Body := `{ + "name": "test-repo", + "description": "My test repository", + "private": false, + "auto_init": true, + "readme": "Default" + }` + req3, _ := http.NewRequest("POST", "https://localhost:443/api/v1/user/repos", strings.NewReader(req3Body)) + req3.Header.Set("Content-Type", "application/json") + req3.SetBasicAuth(giteaAdmin, giteaAdminPassword) + resp3, err := client.Do(req3) + if err != nil { + panic(err) + } + defer resp3.Body.Close() + if resp3.StatusCode != http.StatusCreated { + panic(fmt.Sprintf("Failed to create repository: %s", resp3.Status)) + } + + return ctx, nil + }, + + // Create Git credentials secret from giteaAdmin/giteaAdminPassword + func(ctx context.Context, cfg *envconf.Config) (context.Context, error) { + r, err := resources.New(cfg.Client().RESTConfig()) + if err != nil { + return ctx, err + } + r.WithNamespace(namespace) + + err = decoder.DecodeEachFile( + ctx, os.DirFS(filepath.Join(manifestsPath)), "gitea-creds.yaml", + decoder.CreateIgnoreAlreadyExists(r), + ) + if err != nil { + return ctx, err + } + + return ctx, nil + }, + + func(ctx context.Context, cfg *envconf.Config) (context.Context, error) { + r, err := resources.New(cfg.Client().RESTConfig()) + if err != nil { + return ctx, err + } + r.WithNamespace(namespace) + + // Install CRDs + err = decoder.DecodeEachFile( + ctx, os.DirFS(filepath.Join(crdPath)), "*.yaml", + decoder.CreateIgnoreAlreadyExists(r), + ) + + time.Sleep(2 * time.Second) // wait for the compositiondefinition CRD to be registered + + return ctx, nil + }, + ).Finish( + // envfuncs.DeleteNamespace(namespace), + // envfuncs.TeardownCRDs(crdPath, "git.krateo.io_localresources.yaml"), + // envfuncs.DestroyCluster(clusterName), + func(ctx context.Context, c *envconf.Config) (context.Context, error) { + if v := ctx.Value(stopKey{}); v != nil { + if stop, ok := v.(context.CancelFunc); ok { + fmt.Println("Stopping controller manager at ", time.Now().String()) + stop() // stops mgr.Start and the background goroutine + } + } + + // _, err := cli.ContainerStop(ctx, containerId, client.ContainerStopOptions{}) + // if err != nil { + // panic(err) + // } + // _, err = cli.ContainerRemove(ctx, containerId, client.ContainerRemoveOptions{}) + // if err != nil { + // panic(err) + // } + return ctx, nil + }, + ) + + os.Exit(testenv.Run(m)) +} + +type stopKey struct{} // key for storing stop func in ctx + +func TestController(t *testing.T) { + os.Setenv("DEBUG", "TRUE") + + setupController := func(ctx context.Context, cfg *envconf.Config) (context.Context, error) { + lh := prettylog.New(&slog.HandlerOptions{ + Level: slog.LevelDebug, + AddSource: false, + }, + prettylog.WithDestinationWriter(os.Stderr), + prettylog.WithColor(), + prettylog.WithOutputEmptyAttrs(), + ) + + logrlog := logr.FromSlogHandler(slog.New(lh).Handler()) + log := logging.NewLogrLogger(logrlog) + + ctrl.SetLogger(logrlog) + + mgr, err := ctrl.NewManager(cfg.Client().RESTConfig(), ctrl.Options{ + Metrics: server.Options{ + BindAddress: "0", // disable metrics for tests + }, + }) + if err != nil { + return ctx, err + } + + o := controller.Options{ + Logger: log, + MaxConcurrentReconciles: 1, + PollInterval: 20 * time.Second, + GlobalRateLimiter: ratelimiter.NewGlobalExponential(1*time.Second, 1*time.Minute), + } + + tmpdir, err := os.MkdirTemp(os.TempDir(), "local-resource-test-*") + if err != nil { + log.Info("Cannot create temp dir", "error", err) + os.Exit(1) + } + if err := Setup(mgr, option.SetupOptions{ + Controller: option.ControllerOptions{ + Options: o, + Timeout: 3 * time.Minute, + }, + Git: option.GitOptions{ + CommitAuthorName: "test-author", + CommitAuthorEmail: "test@email.com", + HomeDir: tmpdir, + }, + }); err != nil { + log.Info("Cannot setup controllers", "error", err) + os.Exit(1) + } + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + log.Info("Cannot start controller manager", "error", err) + os.Exit(1) + } + return ctx, nil + } + + var r *resources.Resources + + f := features.New("Setup"). + Setup(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + stopCtx, stop := context.WithCancel(context.Background()) + + var err error + r, err = resources.New(cfg.Client().RESTConfig()) + if err != nil { + t.Fatal(err) + } + + err = apis.AddToScheme(r.GetScheme()) + if err != nil { + t.Fatal(err) + } + err = apiextensionsv1.AddToScheme(r.GetScheme()) + if err != nil { + t.Fatal(err) + } + + go func() { + _, err := setupController(stopCtx, cfg) + if err != nil { + fmt.Printf("Error starting controller manager: %v\n", err) + } + }() + + // store cancel func in returned context so callers can stop the manager + ctx = context.WithValue(ctx, stopKey{}, stop) + return ctx + }). + Assess("Test Create", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + toCreate := []struct { + filename string + expected string + createError bool + }{ + { + filename: "local_fromYaml.yaml", + expected: `metadata: + name: example-repo + namespace: default`, + }, + { + filename: "local_fromString.yaml", + expected: `kind: RemoteRepo +apiVersion: test.com/v6 +metadata: + name: example-repo + namespace: default +spec: + zipArchive: false + authMethod: generic + branch: main + placeholderTest: "42"`, // order of field should be preserved if fromString is used + }, + { + filename: "local_fromRef.yaml", + expected: `testkey: testvalue`, // content of the configmap key + }, + { + filename: "local_fromString_syncEnabled_false.yaml", + expected: `kind: RemoteRepo +apiVersion: test.com/v6 +metadata: + name: example-repo + namespace: default +spec: + zipArchive: false + authMethod: generic + branch: main + placeholderTest: "42"`, // order of field should be preserved if fromString is used + }, + { + filename: "local_fromString_override_false.yaml", + expected: `kind: RemoteRepo +apiVersion: test.com/v6 +metadata: + name: example-repo + namespace: default +spec: + zipArchive: false + authMethod: generic + branch: main + placeholderTest: "42"`, // order of field should be preserved if fromString is used + }, + } + + // Create an configmap cr to reference in the localresource fromRef test + configMapResource := v1.ConfigMap{ + ObjectMeta: ctrl.ObjectMeta{ + Name: "test-configmap", + Namespace: namespace, + }, + Data: map[string]string{ + "testkey": "testvalue", + }, + } + err := r.Create(ctx, &configMapResource) + if err != nil { + t.Fatalf("Failed to create ConfigMap %s: %v", configMapResource.Name, err) + } + + r.WithNamespace(namespace) + + for _, test := range toCreate { + var res v1alpha1.LocalResource + err := decoder.DecodeFile( + os.DirFS(filepath.Join(testdataPath)), test.filename, + &res, + decoder.MutateNamespace(namespace), + ) + if err != nil { + t.Fatal(err) + } + + err = r.Create(ctx, &res) + if err != nil { + t.Fatalf("Failed to create LocalResource %s: %v", res.Name, err) + } + } + time.Sleep(30 * time.Second) // wait for the controller to pick up the new resources + + // verify that the resources have been created in the git repo + for _, test := range toCreate { + var res v1alpha1.LocalResource + err := decoder.DecodeFile( + os.DirFS(filepath.Join(testdataPath)), test.filename, + &res, + decoder.MutateNamespace(namespace), + ) + if err != nil { + t.Fatal(err) + } + + // we need to check if the resource has been created in the git repo + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + client := &http.Client{Transport: tr} + repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") + url := fmt.Sprintf("https://localhost:443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) + req, _ := http.NewRequest("GET", url, nil) + req.SetBasicAuth("admin", "admin123") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected status 200 OK, got %s calling %s", resp.Status, url) + } + // Check if the content matches the expected resource + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + bodyStr := string(body) + + // The content is base64 encoded inside the "content" field + decodedContent, err := decodeGiteaContent(bodyStr) + if err != nil { + t.Fatalf("Failed to decode content for LocalResource %s: %v", res.Name, err) + } + + if !strings.Contains(decodedContent, test.expected) { + t.Fatalf("Expected content (filename: %s) to contain: \n %q, but it was not found in response body:\n %s", test.filename, test.expected, decodedContent) + } + } + return ctx + }).Assess("Test Change Something", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + toUpgrade := []struct { + filename string + patch string + expected string + patchError bool + }{ + { + filename: "local_fromYaml.yaml", + patch: `[{"op": "replace", "path": "/spec/fromResource/fromYaml/spec/zipArchive", "value": true}]`, + expected: `zipArchive: true`, + }, + { + filename: "local_fromString.yaml", + patch: `[{ + "op": "replace", + "path": "/spec/fromResource/fromString", + "value": "Hello, Nginx v1.1.0!" + }]`, + expected: `Hello, Nginx v1.1.0!`, + }, + { + filename: "local_fromRef.yaml", + patch: `[{"op": "replace", "path": "/spec/fromResource/fromRef/name", "value": "test-configmap"}]`, + expected: `testkey: testvalue`, // content of the configmap key + }, + { + filename: "local_fromString_syncEnabled_false.yaml", + patch: `[{"op": "replace","path": "/spec/fromResource/fromString","value": "Hello, World v2!"}]`, + patchError: true, + expected: `kind: RemoteRepo +apiVersion: test.com/v6 +metadata: + name: example-repo + namespace: default +spec: + zipArchive: false + authMethod: generic + branch: main + placeholderTest: "42"`, // value should not change as syncEnabled is false + }, + { + filename: "local_fromString_override_false.yaml", + patch: `[{"op": "replace","path": "/spec/fromResource/fromString","value": "Overridden Value!"}]`, + patchError: true, + expected: `kind: RemoteRepo +apiVersion: test.com/v6 +metadata: + name: example-repo + namespace: default +spec: + zipArchive: false + authMethod: generic + branch: main + placeholderTest: "42"`, // value should not change as override is false + }, + } + + r, err := resources.New(cfg.Client().RESTConfig()) + if err != nil { + t.Fail() + } + + for _, test := range toUpgrade { + var res v1alpha1.LocalResource + err := decoder.DecodeFile( + os.DirFS(filepath.Join(testdataPath)), test.filename, + &res, + decoder.MutateNamespace(namespace), + ) + if err != nil { + t.Fatal(err) + } + + err = r.Patch(ctx, &res, k8s.Patch{PatchType: types.JSONPatchType, Data: []byte(test.patch)}) + if err != nil && test.patchError == false { + t.Fatalf("Failed to patch LocalResource %s: %v", res.Name, err) + } + } + + time.Sleep(10 * time.Second) // wait for the controller to pick up the new resources + + // verify that the changes have been applied + for _, test := range toUpgrade { + var res v1alpha1.LocalResource + err := decoder.DecodeFile( + os.DirFS(filepath.Join(testdataPath)), test.filename, + &res, + decoder.MutateNamespace(namespace), + ) + if err != nil { + t.Fatal(err) + } + + var updatedRes v1alpha1.LocalResource + err = r.Get(ctx, res.GetName(), res.GetNamespace(), &updatedRes) + if err != nil { + t.Fatal(err) + } + + // verify that the git-provider has updated the resource in the git repo + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + client := &http.Client{Transport: tr} + repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") + url := fmt.Sprintf("https://localhost:443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) + req, _ := http.NewRequest("GET", url, nil) + req.SetBasicAuth("admin", "admin123") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected status 200 OK, got %s calling %s", resp.Status, url) + } + // Check if the content matches the updated resource + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + bodyStr := string(body) + + // The content is base64 encoded inside the "content" field + decodedContent, err := decodeGiteaContent(bodyStr) + if err != nil { + t.Fatalf("Failed to decode content for LocalResource %s: %v", res.Name, err) + } + if !strings.Contains(decodedContent, test.expected) { + t.Fatalf("Expected content to contain %q, but it was not found in response body: %s", test.expected, decodedContent) + } + } + + return ctx + }).Assess("Test Delete", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + + return ctx + }).Feature() + + testenv.Test(t, f) +} + +func decodeGiteaContent(body string) (string, error) { + // Find the "content" field + contentKey := `"content":"` + startIndex := strings.Index(body, contentKey) + if startIndex == -1 { + return "", fmt.Errorf("content field not found") + } + startIndex += len(contentKey) + endIndex := strings.Index(body[startIndex:], `"`) + if endIndex == -1 { + return "", fmt.Errorf("end of content field not found") + } + encodedContent := body[startIndex : startIndex+endIndex] + + // Decode base64 content + decodedBytes, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(encodedContent))) + if err != nil { + return "", fmt.Errorf("failed to decode base64 content: %v", err) + } + + return string(decodedBytes), nil +} diff --git a/internal/controllers/localresource/testdata/gitea-creds.yaml b/internal/controllers/localresource/testdata/gitea-creds.yaml new file mode 100644 index 0000000..b772b3d --- /dev/null +++ b/internal/controllers/localresource/testdata/gitea-creds.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: gitea-repo-creds + namespace: test-system +type: Opaque +stringData: + username: admin + token: admin123 \ No newline at end of file diff --git a/internal/controllers/repo/copier.go b/internal/controllers/repo/copier.go deleted file mode 100644 index 409e285..0000000 --- a/internal/controllers/repo/copier.go +++ /dev/null @@ -1,180 +0,0 @@ -package repo - -import ( - "fmt" - "io" - "os" - "path/filepath" - - "github.com/krateoplatformops/git-provider/internal/clients/git" - - gi "github.com/sabhiram/go-gitignore" -) - -type copier struct { - fromRepo *git.Repo - toRepo *git.Repo - originCopyPath string - targetCopyPath string - renderFunc func(in io.Reader, out io.Writer) error - renderFileNames func(src string) (string, error) - krateoIgnore *gi.GitIgnore - targetIgnore *gi.GitIgnore -} - -func newCopier(fromRepo, toRepo *git.Repo, originCopyPath, targetCopyPath string) *copier { - if originCopyPath == "" { - originCopyPath = "/" - } - if targetCopyPath == "" { - targetCopyPath = "/" - } - return &copier{ - fromRepo: fromRepo, - toRepo: toRepo, - originCopyPath: originCopyPath, - targetCopyPath: targetCopyPath, - } -} - -func (co *copier) copyFile(src, dst string, doNotRender bool) (err error) { - fromFS, toFS := co.fromRepo.FS(), co.toRepo.FS() - - if !doNotRender && co.renderFileNames != nil { - var err error - dst, err = co.renderFileNames(dst) - if err != nil { - return fmt.Errorf("failed to render file names: %w", err) - } - } - - in, err := fromFS.Open(src) - if err != nil { - return fmt.Errorf("failed to open source file: %w", err) - } - defer in.Close() - - out, err := toFS.Create(dst) - if err != nil { - return fmt.Errorf("failed to create destination file: %w", err) - } - - defer func() { - if e := out.Close(); e != nil { - err = e - } - }() - - if doNotRender || co.renderFunc == nil { - _, err = io.Copy(out, in) - return err - } - - return co.renderFunc(in, out) -} - -// copyDir recursively copies a directory tree, attempting to preserve permissions. -// Source directory must exist, destination directory must *not* exist. -// Symlinks are ignored and skipped. -func (co *copier) copyDir(src, dst string) (err error) { - if len(src) == 0 { - src = "/" - } - - if len(dst) == 0 { - dst = "/" - } - - fromFS, toFS := co.fromRepo.FS(), co.toRepo.FS() - - src = filepath.Clean(src) - dst = filepath.Clean(dst) - - si, err := fromFS.Stat(src) - if err != nil { - return fmt.Errorf("failed to stat source: %w", err) - } - if !si.IsDir() { - return fmt.Errorf("source is not a directory") - } - - doNotRender := false - doNotCopy := false - if co.krateoIgnore != nil { - if co.krateoIgnore.MatchesPath(src) { - doNotRender = true - } - } - if co.targetIgnore != nil { - relSrc, err := filepath.Rel(co.originCopyPath, src) - if err != nil { - return fmt.Errorf("failed to get relative path: %w", err) - } - if co.targetIgnore.MatchesPath(filepath.Join(co.targetCopyPath, relSrc)) { - doNotCopy = true - } - } - if doNotCopy { - return - } - if !doNotRender && co.renderFileNames != nil { - dst, err = co.renderFileNames(dst) - if err != nil { - return fmt.Errorf("failed to render file names: %w", err) - } - } - - err = toFS.MkdirAll(dst, si.Mode()) - if err != nil { - return - } - - entries, err := fromFS.ReadDir(src) - if err != nil { - return - } - - for _, entry := range entries { - srcPath := filepath.Join(src, entry.Name()) - dstPath := filepath.Join(dst, entry.Name()) - - if entry.IsDir() { - err = co.copyDir(srcPath, dstPath) - if err != nil { - return - } - } else { - // Skip symlinks. - if entry.Mode()&os.ModeSymlink != 0 { - continue - } - - doNotRender := false - doNotCopy := false - if co.krateoIgnore != nil { - if co.krateoIgnore.MatchesPath(srcPath) { - doNotRender = true - } - } - if co.targetIgnore != nil { - relSrc, err := filepath.Rel(co.originCopyPath, srcPath) - if err != nil { - return fmt.Errorf("failed to get relative path: %w", err) - } - if co.targetIgnore.MatchesPath(filepath.Join(co.targetCopyPath, relSrc)) { - doNotCopy = true - } - } - - // do the copy - if !doNotCopy { - err = co.copyFile(srcPath, dstPath, doNotRender) - if err != nil { - return - } - } - } - } - - return -} diff --git a/internal/controllers/repo/copier_test.go b/internal/controllers/repo/copier_test.go deleted file mode 100644 index bfbdaf4..0000000 --- a/internal/controllers/repo/copier_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package repo - -import ( - "os" - "testing" - - "github.com/krateoplatformops/git-provider/internal/clients/git" - "github.com/stretchr/testify/require" -) - -func TestCopier(t *testing.T) { - baseRepo := git.BaseSuite{} - baseRepo.BuildBasicRepository() - origin, err := git.Clone(git.CloneOptions{ - URL: baseRepo.GetBasicLocalRepositoryURL(), - }) - require.NoError(t, err) - - f, _ := origin.FS().OpenFile(".krateoignore", os.O_RDWR|os.O_CREATE, 0644) - require.NoError(t, err) - f.Write([]byte("*")) - f.Close() - _, err = origin.FS().OpenFile("file1.txt", os.O_RDWR|os.O_CREATE, 0644) - require.NoError(t, err) - _, err = origin.FS().OpenFile("file2.txt", os.O_RDWR|os.O_CREATE, 0644) - require.NoError(t, err) - - targetRepo := git.BaseSuite{} - targetRepo.BuildBasicRepository() - target, err := git.Clone(git.CloneOptions{ - URL: targetRepo.GetBasicLocalRepositoryURL(), - }) - require.NoError(t, err) - - co := newCopier(origin, target, "/", "/") - co.toRepo.FS().MkdirAll("/", 0755) - err = loadIgnoreTargetFiles("/", co) - require.NoError(t, err) - err = loadIgnoreFileEventually(co, "/") - require.NoError(t, err) - err = co.copyDir("/", "/") - require.NoError(t, err) -} diff --git a/internal/controllers/repo/repo.go b/internal/controllers/repo/repo.go index 292d9fd..86691ea 100644 --- a/internal/controllers/repo/repo.go +++ b/internal/controllers/repo/repo.go @@ -12,16 +12,21 @@ import ( commonv1 "github.com/krateoplatformops/provider-runtime/apis/common/v1" "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/krateoplatformops/provider-runtime/pkg/event" "github.com/krateoplatformops/provider-runtime/pkg/logging" "github.com/krateoplatformops/provider-runtime/pkg/meta" + "github.com/krateoplatformops/provider-runtime/pkg/ratelimiter" "github.com/krateoplatformops/provider-runtime/pkg/reconciler" "github.com/krateoplatformops/provider-runtime/pkg/resource" repov1alpha1 "github.com/krateoplatformops/git-provider/apis/repo/v1alpha1" "github.com/krateoplatformops/git-provider/internal/clients/git" + "github.com/krateoplatformops/git-provider/internal/controllers/common/option" + "github.com/krateoplatformops/git-provider/internal/tools/copier" "github.com/krateoplatformops/plumbing/ptr" corev1 "k8s.io/api/core/v1" @@ -31,6 +36,66 @@ const ( errNotRepo = "managed resource is not a repo custom resource" ) +// Setup adds a controller that reconciles Token managed resources. +func Setup(mgr ctrl.Manager, o option.SetupOptions) error { + name := reconciler.ControllerName(repov1alpha1.RepoGroupKind) + + log := o.Controller.Logger.WithValues("controller", name) + + recorder := mgr.GetEventRecorderFor(name) + + r := reconciler.NewReconciler(mgr, + resource.ManagedKind(repov1alpha1.RepoGroupVersionKind), + reconciler.WithExternalConnecter(&connector{ + kube: mgr.GetClient(), + log: log, + recorder: recorder, + }), + reconciler.WithPollInterval(o.Controller.PollInterval), + reconciler.WithLogger(log), + reconciler.WithRecorder(event.NewAPIRecorder(recorder)), + reconciler.WithTimeout(o.Controller.Timeout), + ) + + return ctrl.NewControllerManagedBy(mgr). + Named(name). + WithOptions(o.Controller.ForControllerRuntime()). + For(&repov1alpha1.Repo{}). + Complete(ratelimiter.New(name, r, o.Controller.GlobalRateLimiter)) +} + +type connector struct { + kube client.Client + log logging.Logger + recorder record.EventRecorder +} + +func (c *connector) Connect(ctx context.Context, mg resource.Managed) (reconciler.ExternalClient, error) { + cr, ok := mg.(*repov1alpha1.Repo) + if !ok { + return nil, errors.New(errNotRepo) + } + + cfg, err := loadExternalClientOpts(ctx, c.kube, cr) + if err != nil { + return nil, err + } + + homeDir, err = os.UserHomeDir() + if err != nil { + homeDir = "/tmp" + } + + log := c.log.WithValues("name", cr.Name, "namespace", cr.Namespace) + + return &external{ + kube: c.kube, + log: log, + cfg: cfg, + rec: c.recorder, + }, nil +} + // An ExternalClient observes, then either creates, updates, or deletes an // external resource to ensure it reflects the managed resource's desired state. type external struct { @@ -256,72 +321,51 @@ func (e *external) SyncRepos(ctx context.Context, cr *repov1alpha1.Repo, commitM return err } - co := newCopier(fromRepo, toRepo, spec.FromRepo.Path, spec.ToRepo.Path) - - // If fromPath is not specified DON'T COPY! fromPath := spec.FromRepo.Path toPath := spec.ToRepo.Path + override := spec.Override if len(toPath) == 0 { toPath = "/" } if len(fromPath) == 0 { fromPath = "/" } - if len(fromPath) > 0 { - var values map[string]interface{} - if spec.ConfigMapKeyRef != nil { - values, err = e.loadValuesFromConfigMap(ctx, spec.ConfigMapKeyRef) - if err != nil { - e.log.Debug("Unable to load configmap with template data", "msg", err.Error()) - e.rec.Eventf(cr, corev1.EventTypeWarning, "CannotLoadConfigMap", - "Unable to load configmap with template data: %s", err.Error()) - } - - e.log.Debug("Loaded values from config map", - "name", spec.ConfigMapKeyRef.Name, - "key", spec.ConfigMapKeyRef.Key, - "namespace", spec.ConfigMapKeyRef.Namespace, - "values", values, - ) - } - - if !cr.Spec.Override { - e.log.Debug("Override is false, ignoring files that already exist in target repo") - if _, err := toRepo.FS().Stat(toPath); err == nil { - err = loadIgnoreTargetFiles(toPath, co) - if err != nil { - return fmt.Errorf("unable to load ignore target files: %w", err) - } - } else if os.IsNotExist(err) { - e.log.Debug("Target path does not exist, no files to ignore", "path", toPath) - } else { - return fmt.Errorf("unable to check target path: %w", err) - } - } else { - co.targetIgnore = nil - e.log.Debug("Override is true, overriding all files in target repo") - if co.originCopyPath == "/" && co.targetCopyPath == "/" { - e.rec.Eventf(cr, corev1.EventTypeWarning, "OverrideWarning", - "Override is set to true, but originPath and targetPath are both set to '/', this will override also service folders like .git, .github, .gitignore, etc. Consider using a different path for originPath or targetPath. This can broke the target repository causing the impossibility to push changes.") - e.log.Info("Override is set to true, but originPath and targetPath are both set to '/', this will override also service folders like .git, .github, .gitignore, etc. Consider using a different path for originPath or targetPath. This can broke the target repository causing the impossibility to push changes.") - } - } - - ignorePath := cr.Spec.FromRepo.KrateoIgnorePath - if err := loadIgnoreFileEventually(co, ignorePath); err != nil { - e.log.Info("Unable to load '.krateoignore'", "msg", err.Error()) - e.rec.Eventf(cr, corev1.EventTypeWarning, "CannotLoadIgnoreFile", - "Unable to load '.krateoignore' file: %s", err.Error()) - } - - if values != nil { - createRenderFuncs(co, values) + var values map[string]interface{} + if spec.ConfigMapKeyRef != nil { + values, err = e.loadValuesFromConfigMap(ctx, spec.ConfigMapKeyRef) + if err != nil { + e.log.Debug("Unable to load configmap with template data", "msg", err.Error()) + e.rec.Eventf(cr, corev1.EventTypeWarning, "CannotLoadConfigMap", + "Unable to load configmap with template data: %s", err.Error()) } - if err := co.copyDir(fromPath, toPath); err != nil { - return fmt.Errorf("unable to copy files: %w", err) + e.log.Debug("Loaded values from config map", + "name", spec.ConfigMapKeyRef.Name, + "key", spec.ConfigMapKeyRef.Key, + "namespace", spec.ConfigMapKeyRef.Namespace, + "values", values, + ) + } + co, err := copier.NewCopier(fromRepo.FS(), toRepo.FS(), + copier.WithOriginCopyPath(fromPath), + copier.WithTargetCopyPath(toPath), + copier.WithIgnorePath(spec.FromRepo.KrateoIgnorePath), + copier.WithMustacheTemplate(values), + ) + if err != nil { + return fmt.Errorf("unable to create copier: %w", err) + } + if override { + e.log.Debug("Override is true, overriding all files in target repo") + if fromPath == "/" && toPath == "/" { + e.rec.Eventf(cr, corev1.EventTypeWarning, "OverrideWarning", + "Override is set to true, but originPath and targetPath are both set to '/', this will override also service folders like .git, .github, .gitignore, etc. Consider using a different path for originPath or targetPath. This can broke the target repository causing the impossibility to push changes.") + e.log.Info("Override is set to true, but originPath and targetPath are both set to '/', this will override also service folders like .git, .github, .gitignore, etc. Consider using a different path for originPath or targetPath. This can broke the target repository causing the impossibility to push changes.") } } + if err := co.Copy(override); err != nil { + return fmt.Errorf("unable to copy files: %w", err) + } e.log.Info("Origin and target repo synchronized", "fromUrl", spec.FromRepo.Url, @@ -331,11 +375,12 @@ func (e *external) SyncRepos(ctx context.Context, cr *repov1alpha1.Repo, commitM e.rec.Eventf(cr, corev1.EventTypeNormal, "RepoSyncSuccess", "Origin and target repo synchronized") - toRepoCommitId, err := toRepo.Commit(".", commitMessage, &git.IndexOptions{ + toRepoCommitIdObj, err := toRepo.Commit(".", commitMessage, &git.IndexOptions{ OriginRepo: fromRepo, FromPath: fromPath, ToPath: toPath, }) + toRepoCommitId := toRepoCommitIdObj.String() if err == git.NoErrAlreadyUpToDate { toRepoCommitId, err := toRepo.GetLatestCommit(toRepo.CurrentBranch()) if err != nil { diff --git a/internal/controllers/repo/setup.go b/internal/controllers/repo/setup.go deleted file mode 100644 index dc767f4..0000000 --- a/internal/controllers/repo/setup.go +++ /dev/null @@ -1,84 +0,0 @@ -package repo - -import ( - "context" - "os" - "time" - - repov1alpha1 "github.com/krateoplatformops/git-provider/apis/repo/v1alpha1" - "github.com/krateoplatformops/plumbing/env" - "github.com/krateoplatformops/provider-runtime/pkg/reconciler" - - "github.com/krateoplatformops/provider-runtime/pkg/controller" - "github.com/krateoplatformops/provider-runtime/pkg/event" - "github.com/krateoplatformops/provider-runtime/pkg/logging" - "github.com/krateoplatformops/provider-runtime/pkg/ratelimiter" - "github.com/krateoplatformops/provider-runtime/pkg/resource" - "k8s.io/client-go/tools/record" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/pkg/errors" -) - -// Setup adds a controller that reconciles Token managed resources. -func Setup(mgr ctrl.Manager, o controller.Options) error { - name := reconciler.ControllerName(repov1alpha1.RepoGroupKind) - - log := o.Logger.WithValues("controller", name) - - recorder := mgr.GetEventRecorderFor(name) - - timeout := env.Duration("GIT_PROVIDER_TIMEOUT", 4*time.Minute) - - r := reconciler.NewReconciler(mgr, - resource.ManagedKind(repov1alpha1.RepoGroupVersionKind), - reconciler.WithExternalConnecter(&connector{ - kube: mgr.GetClient(), - log: log, - recorder: recorder, - }), - reconciler.WithPollInterval(o.PollInterval), - reconciler.WithLogger(log), - reconciler.WithRecorder(event.NewAPIRecorder(recorder)), - reconciler.WithTimeout(timeout), - ) - - return ctrl.NewControllerManagedBy(mgr). - Named(name). - WithOptions(o.ForControllerRuntime()). - For(&repov1alpha1.Repo{}). - Complete(ratelimiter.New(name, r, o.GlobalRateLimiter)) -} - -type connector struct { - kube client.Client - log logging.Logger - recorder record.EventRecorder -} - -func (c *connector) Connect(ctx context.Context, mg resource.Managed) (reconciler.ExternalClient, error) { - cr, ok := mg.(*repov1alpha1.Repo) - if !ok { - return nil, errors.New(errNotRepo) - } - - cfg, err := loadExternalClientOpts(ctx, c.kube, cr) - if err != nil { - return nil, err - } - - homeDir, err = os.UserHomeDir() - if err != nil { - homeDir = "/tmp" - } - - log := c.log.WithValues("name", cr.Name, "namespace", cr.Namespace) - - return &external{ - kube: c.kube, - log: log, - cfg: cfg, - rec: c.recorder, - }, nil -} diff --git a/internal/controllers/repo/utils.go b/internal/controllers/repo/utils.go index 21a7e66..2c2f0e2 100644 --- a/internal/controllers/repo/utils.go +++ b/internal/controllers/repo/utils.go @@ -3,18 +3,13 @@ package repo import ( "context" "fmt" - "io" - "path/filepath" "strings" githttp "github.com/go-git/go-git/v5/plumbing/transport/http" repov1alpha1 "github.com/krateoplatformops/git-provider/apis/repo/v1alpha1" - "github.com/cbroglie/mustache" - "github.com/go-git/go-billy/v5" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/krateoplatformops/provider-runtime/pkg/resource" - gi "github.com/sabhiram/go-gitignore" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -106,77 +101,3 @@ func getRepoCredentials(ctx context.Context, k client.Client, opts repov1alpha1. Password: token, }, nil } - -func createRenderFuncs(co *copier, values interface{}) { - co.renderFunc = func(in io.Reader, out io.Writer) error { - bin, err := io.ReadAll(in) - if err != nil { - return err - } - tmpl, err := mustache.ParseString(string(bin)) - if err != nil { - return err - } - - return tmpl.FRender(out, values) - } - co.renderFileNames = func(src string) (string, error) { - tmpl, err := mustache.ParseString(src) - if err != nil { - return "", err - } - return tmpl.Render(values) - } - -} - -func loadIgnoreFileEventually(co *copier, path string) error { - fp, err := co.fromRepo.FS().Open(filepath.Join(path, ".krateoignore")) - if err != nil { - return err - } - defer fp.Close() - - bs, err := io.ReadAll(fp) - if err != nil { - return err - } - - lines := strings.Split(string(bs), "\n") - - co.krateoIgnore = gi.CompileIgnoreLines(lines...) - - return nil -} - -func loadFilesIntoArray(fs billy.Filesystem, dir string, flist *[]string) error { - files, err := fs.ReadDir(dir) - if err != nil { - return err - } - - for _, file := range files { - if file.IsDir() { - err := loadFilesIntoArray(fs, filepath.Join(dir, file.Name()), flist) - if err != nil { - return err - } - } else { - absPath := filepath.Join(dir, file.Name()) - *flist = append(*flist, absPath) - } - } - - return nil -} - -func loadIgnoreTargetFiles(srcPath string, co *copier) error { - fs := co.toRepo.FS() - var flist []string - err := loadFilesIntoArray(fs, srcPath, &flist) - if err != nil { - return err - } - co.targetIgnore = gi.CompileIgnoreLines(flist...) - return nil -} diff --git a/internal/controllers/repo/utils_test.go b/internal/controllers/repo/utils_test.go index 30d33f2..8212e69 100644 --- a/internal/controllers/repo/utils_test.go +++ b/internal/controllers/repo/utils_test.go @@ -2,15 +2,11 @@ package repo import ( "context" - "os" "testing" - "github.com/go-git/go-billy/v5/memfs" githttp "github.com/go-git/go-git/v5/plumbing/transport/http" repov1alpha1 "github.com/krateoplatformops/git-provider/apis/repo/v1alpha1" - "github.com/krateoplatformops/git-provider/internal/clients/git" commonv1 "github.com/krateoplatformops/provider-runtime/apis/common/v1" - gi "github.com/sabhiram/go-gitignore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -132,73 +128,3 @@ func TestGetRepoCookies(t *testing.T) { expectedCookies := []byte("repo-cookie") assert.Equal(t, expectedCookies, cookies) } - -func TestLoadIgnoreTargetFiles(t *testing.T) { - - baseRepo := git.BaseSuite{} - baseRepo.BuildBasicRepository() - origin, err := git.Clone(git.CloneOptions{ - URL: baseRepo.GetBasicLocalRepositoryURL(), - }) - require.NoError(t, err) - - targetRepo := git.BaseSuite{} - targetRepo.BuildBasicRepository() - target, err := git.Clone(git.CloneOptions{ - URL: baseRepo.GetBasicLocalRepositoryURL(), - }) - require.NoError(t, err) - - co := newCopier(origin, target, "/", "/") - - srcPath := "/path/to/dir" - - co.toRepo.FS().MkdirAll("/path/to/dir", 0755) - err = loadIgnoreTargetFiles(srcPath, co) - require.NoError(t, err) - - expectedIgnore := gi.CompileIgnoreLines() - co.targetIgnore = expectedIgnore - - assert.Equal(t, expectedIgnore, co.targetIgnore) -} -func TestLoadFilesIntoArray(t *testing.T) { - fs := memfs.New() - - // Create some test files - err := fs.MkdirAll("/path/to/dir", 0755) - require.NoError(t, err) - - f1, err := fs.OpenFile("/path/to/file1.txt", os.O_RDWR|os.O_CREATE, 0644) - require.NoError(t, err) - _, err = f1.Write([]byte("file1")) - require.NoError(t, err) - err = f1.Close() - require.NoError(t, err) - - f2, err := fs.OpenFile("/path/to/file2.txt", os.O_RDWR|os.O_CREATE, 0644) - require.NoError(t, err) - _, err = f2.Write([]byte("file2")) - require.NoError(t, err) - err = f2.Close() - require.NoError(t, err) - - f3, err := fs.OpenFile("/path/to/dir/file3.txt", os.O_RDWR|os.O_CREATE, 0644) - require.NoError(t, err) - _, err = f3.Write([]byte("file3")) - require.NoError(t, err) - err = f3.Close() - require.NoError(t, err) - - var flist []string - err = loadFilesIntoArray(fs, "/path", &flist) - require.NoError(t, err) - - expectedFiles := []string{ - "/path/to/file1.txt", - "/path/to/file2.txt", - "/path/to/dir/file3.txt", - } - - assert.ElementsMatch(t, expectedFiles, flist) -} diff --git a/internal/tools/copier/copier.go b/internal/tools/copier/copier.go new file mode 100644 index 0000000..4fb2bcc --- /dev/null +++ b/internal/tools/copier/copier.go @@ -0,0 +1,354 @@ +package copier + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/cbroglie/mustache" + "github.com/go-git/go-billy/v5" + "github.com/krateoplatformops/git-provider/internal/tools/template" + + gi "github.com/sabhiram/go-gitignore" +) + +const IgnoreFileName = ".krateoignore" + +type Option func(*options) +type options struct { + renderFunc func(in io.Reader, out io.Writer) error + renderFileNames func(src string) (string, error) + ignorePath string + originCopyPath string + targetCopyPath string +} + +func defaultOptions() options { + return options{ + renderFunc: nil, + renderFileNames: nil, + ignorePath: "/", + originCopyPath: "/", + targetCopyPath: "/", + } +} + +func WithIgnorePath(ip string) Option { + return func(o *options) { + o.ignorePath = ip + } +} + +func WithOriginCopyPath(ocp string) Option { + return func(o *options) { + o.originCopyPath = ocp + } +} + +func WithTargetCopyPath(tcp string) Option { + return func(o *options) { + o.targetCopyPath = tcp + } +} + +func WithGoTemplate(templateValues []template.TemplateValue) Option { + return func(o *options) { + values := template.FromTemplateValues(templateValues) + o.renderFunc = func(in io.Reader, out io.Writer) error { + bin, err := io.ReadAll(in) + if err != nil { + return err + } + tmpl := template.Template(bin) + renderedBin, err := tmpl.Render(values) + if err != nil { + return err + } + + _, err = out.Write(renderedBin) + return err + } + o.renderFileNames = func(src string) (string, error) { + tmpl := template.Template(src) + renderedBin, err := tmpl.Render(values) + if err != nil { + return "", err + } + + return string(renderedBin), nil + } + } +} + +func WithMustacheTemplate(templateValues interface{}) Option { + return func(o *options) { + o.renderFunc = func(in io.Reader, out io.Writer) error { + bin, err := io.ReadAll(in) + if err != nil { + return err + } + tmpl, err := mustache.ParseString(string(bin)) + if err != nil { + return err + } + + return tmpl.FRender(out, templateValues) + } + o.renderFileNames = func(src string) (string, error) { + tmpl, err := mustache.ParseString(src) + if err != nil { + return "", err + } + + return tmpl.Render(templateValues) + } + } +} + +type Copier struct { + fromFS billy.Filesystem + toFS billy.Filesystem + krateoIgnore *gi.GitIgnore + targetIgnore *gi.GitIgnore + + options +} + +func NewCopier(fromFS, toFS billy.Filesystem, opts ...Option) (*Copier, error) { + if fromFS == nil { + return nil, fmt.Errorf("fromFS cannot be nil") + } + if toFS == nil { + return nil, fmt.Errorf("toFS cannot be nil") + } + + options := defaultOptions() + for _, o := range opts { + o(&options) + } + + return &Copier{ + fromFS: fromFS, + toFS: toFS, + options: options, + }, nil +} + +// CopyDir recursively copies a directory tree, attempting to preserve permissions. +// Source directory must exist, destination directory must *not* exist. +// Symlinks are ignored and skipped. +func (co *Copier) Copy(override bool) (err error) { + src := co.originCopyPath + dst := co.targetCopyPath + + if len(src) == 0 { + src = "/" + } + + if len(dst) == 0 { + dst = "/" + } + + if !override { + err = co.setTargetIgnore() + if err != nil { + return fmt.Errorf("failed to set target ignore: %w", err) + } + } + + err = co.setKrateoIgnore() + if err != nil { + return fmt.Errorf("failed to set krateo ignore: %w", err) + } + + return co.copyDir(src, dst) +} + +func (co *Copier) copyDir(src, dst string) (err error) { + fromFS, toFS := co.fromFS, co.toFS + src = filepath.Clean(src) + dst = filepath.Clean(dst) + + si, err := fromFS.Stat(src) + if err != nil { + return fmt.Errorf("failed to stat source: %w", err) + } + if !si.IsDir() { + return fmt.Errorf("source is not a directory") + } + + doNotRender := false + doNotCopy := false + if co.krateoIgnore != nil { + if co.krateoIgnore.MatchesPath(src) { + doNotRender = true + } + } + if co.targetIgnore != nil { + relSrc, err := filepath.Rel(co.originCopyPath, src) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + if co.targetIgnore.MatchesPath(filepath.Join(co.targetCopyPath, relSrc)) { + doNotCopy = true + } + } + if doNotCopy { + return + } + if !doNotRender && co.renderFileNames != nil { + dst, err = co.renderFileNames(dst) + if err != nil { + return fmt.Errorf("failed to render file names: %w", err) + } + } + + err = toFS.MkdirAll(dst, si.Mode()) + if err != nil { + return + } + + entries, err := fromFS.ReadDir(src) + if err != nil { + return + } + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + err = co.copyDir(srcPath, dstPath) + if err != nil { + return + } + } else { + // Skip symlinks. + if entry.Mode()&os.ModeSymlink != 0 { + continue + } + + doNotRender := false + doNotCopy := false + if co.krateoIgnore != nil { + if co.krateoIgnore.MatchesPath(srcPath) { + doNotRender = true + } + } + if co.targetIgnore != nil { + relSrc, err := filepath.Rel(co.originCopyPath, srcPath) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + if co.targetIgnore.MatchesPath(filepath.Join(co.targetCopyPath, relSrc)) { + doNotCopy = true + } + } + + // do the copy + if !doNotCopy { + err = co.copyFile(srcPath, dstPath, doNotRender) + if err != nil { + return + } + } + } + } + return nil +} + +func (co *Copier) copyFile(src, dst string, doNotRender bool) (err error) { + fromFS, toFS := co.fromFS, co.toFS + + if !doNotRender && co.renderFileNames != nil { + var err error + dst, err = co.renderFileNames(dst) + if err != nil { + return fmt.Errorf("failed to render file names: %w", err) + } + } + + in, err := fromFS.Open(src) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer in.Close() + + out, err := toFS.Create(dst) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + + defer func() { + if e := out.Close(); e != nil { + err = e + } + }() + + if doNotRender || co.renderFunc == nil { + _, err = io.Copy(out, in) + return err + } + + return co.renderFunc(in, out) +} + +func (co *Copier) setKrateoIgnore() error { + fp, err := co.fromFS.Open(filepath.Join(co.ignorePath, IgnoreFileName)) + if err != nil { + if os.IsNotExist(err) { + // .krateoignore does not exist, no files to ignore + return nil + } + return fmt.Errorf("unable to open .krateoignore: %w", err) + } + defer fp.Close() + + bs, err := io.ReadAll(fp) + if err != nil { + return err + } + lines := strings.Split(string(bs), "\n") + co.krateoIgnore = gi.CompileIgnoreLines(lines...) + return nil +} + +func (co *Copier) setTargetIgnore() error { + if _, err := co.fromFS.Stat(co.targetCopyPath); err == nil { + var flist []string + err = loadFilesFromPath(co.toFS, co.targetCopyPath, &flist) + if err != nil { + return fmt.Errorf("unable to load target files from path: %w", err) + } + co.targetIgnore = gi.CompileIgnoreLines(flist...) + } else if os.IsNotExist(err) { + // Target path does not exist, no files to ignore + return nil + } else { + return fmt.Errorf("unable to check target path: %w", err) + } + return nil +} + +func loadFilesFromPath(fs billy.Filesystem, dir string, flist *[]string) error { + files, err := fs.ReadDir(dir) + if err != nil { + return err + } + + for _, file := range files { + if file.IsDir() { + err := loadFilesFromPath(fs, filepath.Join(dir, file.Name()), flist) + if err != nil { + return err + } + } else { + absPath := filepath.Join(dir, file.Name()) + *flist = append(*flist, absPath) + } + } + + return nil +} diff --git a/internal/tools/copier/copier_test.go b/internal/tools/copier/copier_test.go new file mode 100644 index 0000000..0cbf0b3 --- /dev/null +++ b/internal/tools/copier/copier_test.go @@ -0,0 +1,146 @@ +package copier + +import ( + "io" + "path/filepath" + "testing" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/memfs" + "github.com/krateoplatformops/git-provider/internal/tools/template" +) + +func writeFile(t *testing.T, fsFileSystem billy.Filesystem, path string, data string) { + t.Helper() + dir := filepath.Dir(path) + if dir != "." { + if err := fsFileSystem.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdirall %s: %v", dir, err) + } + } + f, err := fsFileSystem.Create(path) + if err != nil { + t.Fatalf("create %s: %v", path, err) + } + defer f.Close() + if _, err := f.Write([]byte(data)); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func readFile(t *testing.T, fsFileSystem billy.Filesystem, path string) string { + t.Helper() + f, err := fsFileSystem.Open(path) + if err != nil { + t.Fatalf("open %s: %v", path, err) + } + defer f.Close() + b, err := io.ReadAll(f) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(b) +} + +func TestRenderFileNamesAndContent(t *testing.T) { + from := memfs.New() + to := memfs.New() + + // source file with templated name and content + writeFile(t, from, "/src/file_{{.name}}.txt", "hello {{.name}}") + + co, err := NewCopier(from, to, WithOriginCopyPath("/src"), WithTargetCopyPath("/dst"), WithIgnorePath("/"), WithGoTemplate([]template.TemplateValue{{Key: "name", Value: "world"}})) + if err != nil { + t.Fatalf("failed to create copier: %v", err) + } + if err := co.Copy(true); err != nil { + t.Fatalf("copy failed: %v", err) + } + + got := readFile(t, to, "/dst/file_world.txt") + if got != "hello world" { + t.Fatalf("unexpected content: %q", got) + } +} + +func TestMustacheRendering(t *testing.T) { + from := memfs.New() + to := memfs.New() + + // source file with templated content + writeFile(t, from, "/src/greet.txt", "Hello {{name}}!") + + co, err := NewCopier(from, to, WithOriginCopyPath("/src"), WithTargetCopyPath("/dst"), WithIgnorePath("/"), WithMustacheTemplate(map[string]string{"name": "Krateo"})) + if err != nil { + t.Fatalf("failed to create copier: %v", err) + } + if err := co.Copy(true); err != nil { + t.Fatalf("copy failed: %v", err) + } + + got := readFile(t, to, "/dst/greet.txt") + if got != "Hello Krateo!" { + t.Fatalf("unexpected content: %q", got) + } +} + +func TestKrateoIgnorePreventsRendering(t *testing.T) { + from := memfs.New() + to := memfs.New() + + // create ignore file that matches the source file path + writeFile(t, from, "/.krateoignore", "/src/ignored.txt\n") + + // source file contains a template but is listed in .krateoignore -> should NOT be rendered + writeFile(t, from, "/src/ignored.txt", "value: {{name}}") + + co, err := NewCopier(from, to, WithOriginCopyPath("/src"), WithTargetCopyPath("/dst"), WithIgnorePath("/"), WithGoTemplate([]template.TemplateValue{{Key: "name", Value: "X"}})) + if err != nil { + t.Fatalf("failed to create copier: %v", err) + } + if err := co.Copy(true); err != nil { + t.Fatalf("copy failed: %v", err) + } + got := readFile(t, to, "/dst/ignored.txt") + if got != "value: {{name}}" { + t.Fatalf("file was rendered despite being ignored: %q", got) + } +} + +func TestTargetIgnoreSkipsExisting(t *testing.T) { + from := memfs.New() + to := memfs.New() + + // source has two files + writeFile(t, from, "/src/skip.txt", "from-skip") + writeFile(t, from, "/src/keep.txt", "from-keep") + + // create target dir in FROM FS so setTargetIgnore will proceed to load files from TO FS + if err := from.MkdirAll("/dst", 0o755); err != nil { + t.Fatalf("mkdirall from:/dst: %v", err) + } + + // create an existing file in target (TO FS) that should be considered for ignoring + writeFile(t, to, "/dst/skip.txt", "to-skip-original") + + co, err := NewCopier(from, to, WithOriginCopyPath("/src"), WithTargetCopyPath("/dst"), WithIgnorePath("/")) + if err != nil { + t.Fatalf("failed to create copier: %v", err) + } + // override == false so setTargetIgnore is invoked and skip.txt should be ignored (not overwritten) + if err := co.Copy(false); err != nil { + t.Fatalf("copy failed: %v", err) + } + + // skip.txt should remain the original in target (not overwritten) + gotSkip := readFile(t, to, "/dst/skip.txt") + if gotSkip != "to-skip-original" { + t.Fatalf("skip.txt was overwritten or missing: %q", gotSkip) + } + + // keep.txt should be copied from source + gotKeep := readFile(t, to, "/dst/keep.txt") + if gotKeep != "from-keep" { + t.Fatalf("keep.txt not copied correctly: %q", gotKeep) + } +} diff --git a/internal/tools/hash/hasher.go b/internal/tools/hash/hasher.go new file mode 100644 index 0000000..60b7401 --- /dev/null +++ b/internal/tools/hash/hasher.go @@ -0,0 +1,38 @@ +package hasher + +import ( + "encoding/json" + "fmt" + "hash" + "hash/fnv" +) + +type ObjectHash struct { + hash.Hash64 +} + +// the hash is cumulative, so you can call Hash() multiple times +// with different values and the hash will be updated +func (h *ObjectHash) SumHash(a ...any) error { + for _, v := range a { + b, err := json.Marshal(v) + if err != nil { + return err + } + if _, err := h.Write(b); err != nil { + return err + } + } + return nil +} + +func (h *ObjectHash) Reset() { + h.Hash64.Reset() +} +func (h *ObjectHash) GetHash() string { + return fmt.Sprintf("%x", h.Hash64.Sum64()) +} + +func NewFNVObjectHash() ObjectHash { + return ObjectHash{fnv.New64()} +} diff --git a/internal/tools/hash/hasher_test.go b/internal/tools/hash/hasher_test.go new file mode 100644 index 0000000..6d48965 --- /dev/null +++ b/internal/tools/hash/hasher_test.go @@ -0,0 +1,62 @@ +package hasher + +import ( + "testing" +) + +func BenchmarkHash(b *testing.B) { + input := []any{"test", 123, true, "another string", 456.78} + + h := NewFNVObjectHash() + + for i := 0; i < b.N; i++ { + err := h.SumHash(input...) + if err != nil { + b.Fatalf("Hash() failed: %v", err) + } + } +} + +func TestHash(t *testing.T) { + tests := []struct { + name string + input []any + wantErr bool + }{ + { + name: "Single string input", + input: []any{"test"}, + wantErr: false, + }, + { + name: "Multiple inputs", + input: []any{"test", 123, true}, + wantErr: false, + }, + { + name: "Empty input", + input: []any{}, + wantErr: false, + }, + { + name: "Nil input", + input: nil, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := NewFNVObjectHash() + err := h.SumHash(tt.input...) + if (err != nil) != tt.wantErr { + t.Errorf("Hash() error = %v, wantErr %v", err, tt.wantErr) + return + } + got := h.GetHash() + if got == "" && !tt.wantErr { + t.Errorf("Hash() returned empty string, expected valid hash") + } + }) + } +} diff --git a/internal/tools/localfs/localfs.go b/internal/tools/localfs/localfs.go new file mode 100644 index 0000000..4279225 --- /dev/null +++ b/internal/tools/localfs/localfs.go @@ -0,0 +1,214 @@ +package localfs + +import ( + "bytes" + "context" + "fmt" + "os" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/osfs" + "github.com/krateoplatformops/plumbing/jqutil" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/conversion" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/json" + "k8s.io/apimachinery/pkg/util/yaml" +) + +type LocalFS struct { + billy.Filesystem + tmp string +} + +func NewLocalFS(path string) (*LocalFS, error) { + tmpDir, err := os.MkdirTemp(path, "git-provider-local-*") + if err != nil { + return nil, fmt.Errorf("failed to create temporary directory: %w", err) + } + + diskFS := osfs.New(tmpDir) + + return &LocalFS{Filesystem: diskFS, tmp: tmpDir}, nil +} + +func (lfs LocalFS) Cleanup() error { + if err := os.RemoveAll(lfs.tmp); err != nil { + return fmt.Errorf("failed to remove temporary directory: %w", err) + } + return nil +} + +func (lfs LocalFS) WriteStringResource(filename string, content string) (string, error) { + if filename == "" { + return "", fmt.Errorf("filename must be provided when writing fromString resource") + } + // Write the fromResource content to the temporary local filesystem + fi, err := lfs.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return "", fmt.Errorf("creating file in local filesystem: %w", err) + } + defer fi.Close() + + _, err = fi.Write([]byte(content)) + if err != nil { + return "", fmt.Errorf("writing string data to file: %w", err) + } + return filename, nil +} + +// WriteK8sResource writes a K8s manifest to the local filesystem, ensuring it has the required fields. +// If filename is empty, it generates a filename based on the resource's GVK and name. +// It returns the filename used. +func (lfs LocalFS) WriteK8sResource(filename string, manifest runtime.RawExtension) (string, error) { + var obj runtime.Object + var scope conversion.Scope + err := runtime.Convert_runtime_RawExtension_To_runtime_Object(&manifest, &obj, scope) + if err != nil { + return "", fmt.Errorf("converting fromResource.FromYaml to runtime.Object: %w", err) + } + res, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return "", fmt.Errorf("converting fromResource.FromYaml to unstructured: %w", err) + } + ures := &unstructured.Unstructured{Object: res} + + // checks that the required fields are present. Kind, APIVersion and (Name or GenerateName) are required + if ures.GetKind() == "" || ures.GetAPIVersion() == "" || (ures.GetName() == "" && ures.GetGenerateName() == "") { + return "", fmt.Errorf("resource is missing required fields (kind, apiVersion, name or generateName)") + } + stringGVK := fmt.Sprintf("%s.%s.%s", + ures.GroupVersionKind().Kind, + ures.GroupVersionKind().Version, + ures.GroupVersionKind().Group, + ) + if filename == "" { + if ures.GetNamespace() == "" { + filename = fmt.Sprintf("%s_%s.yaml", + stringGVK, + ures.GetName(), + ) + } else { + filename = fmt.Sprintf("%s_%s_%s.yaml", + stringGVK, + ures.GetName(), + ures.GetNamespace(), + ) + } + } + + // Write the fromResource content to the temporary local filesystem + fi, err := lfs.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return "", fmt.Errorf("creating file in local filesystem: %w", err) + } + defer fi.Close() + + scheme := runtime.NewScheme() + serializer := json.NewYAMLSerializer( + json.DefaultMetaFactory, + scheme, + scheme, + ) + + var buf bytes.Buffer + err = serializer.Encode(obj, &buf) + if err != nil { + return "", fmt.Errorf("encoding object: %w", err) + } + + manifestString := buf.String() + + _, err = fi.Write([]byte(manifestString)) + if err != nil { + return "", fmt.Errorf("writing YAML data to file: %w", err) + } + return filename, nil +} + +func (lfs LocalFS) WriteK8sResourceJQ(filename string, manifest runtime.RawExtension, jqFilters ...string) (string, error) { + var obj runtime.Object + var scope conversion.Scope + err := runtime.Convert_runtime_RawExtension_To_runtime_Object(&manifest, &obj, scope) + if err != nil { + return "", fmt.Errorf("converting fromResource.FromYaml to runtime.Object: %w", err) + } + + res, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return "", fmt.Errorf("converting fromResource.FromYaml to unstructured: %w", err) + } + ures := &unstructured.Unstructured{Object: res} + + // Validate required fields + if ures.GetKind() == "" || ures.GetAPIVersion() == "" || (ures.GetName() == "" && ures.GetGenerateName() == "") { + return "", fmt.Errorf("resource is missing required fields (kind, apiVersion, name or generateName)") + } + + // Generate filename if necessary + stringGVK := fmt.Sprintf("%s.%s.%s", + ures.GroupVersionKind().Kind, + ures.GroupVersionKind().Version, + ures.GroupVersionKind().Group, + ) + if filename == "" { + if ures.GetNamespace() == "" { + filename = fmt.Sprintf("%s_%s.yaml", stringGVK, ures.GetName()) + } else { + filename = fmt.Sprintf("%s_%s_%s.yaml", stringGVK, ures.GetName(), ures.GetNamespace()) + } + } + + // Apply jq filters sequentially + var processedJSON string + for _, filter := range jqFilters { + processedJSON, err = jqutil.Eval(context.Background(), jqutil.EvalOptions{ + Query: filter, + Data: ures.Object, + Unquote: false, + ModuleLoader: nil, + }) + if err != nil { + return "", fmt.Errorf("applying jq filter '%s': %w", filter, err) + } + // Update ures.Object for the next iteration + ures.Object = make(map[string]any) + err = yaml.NewYAMLOrJSONDecoder(bytes.NewReader([]byte(processedJSON)), 1024).Decode(&ures.Object) + if err != nil { + return "", fmt.Errorf("decoding JSON after jq filter '%s': %w", filter, err) + } + } + + scheme := runtime.NewScheme() + serializer := json.NewYAMLSerializer( + json.DefaultMetaFactory, + scheme, + scheme, + ) + + // serialize the processed JSON back into a runtime.Object + objProcessed := &unstructured.Unstructured{} + err = runtime.DecodeInto(unstructured.UnstructuredJSONScheme, []byte(processedJSON), objProcessed) + if err != nil { + return "", fmt.Errorf("decoding processed JSON into runtime.Object: %w", err) + } + + var buf bytes.Buffer + err = serializer.Encode(objProcessed, &buf) + if err != nil { + return "", fmt.Errorf("encoding object: %w", err) + } + // Write the processed content to the temporary local filesystem + fi, err := lfs.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return "", fmt.Errorf("creating file in local filesystem: %w", err) + } + defer fi.Close() + + _, err = fi.Write(buf.Bytes()) + if err != nil { + return "", fmt.Errorf("writing YAML data to file: %w", err) + } + + return filename, nil +} diff --git a/internal/tools/localfs/localfs_test.go b/internal/tools/localfs/localfs_test.go new file mode 100644 index 0000000..6b6c96f --- /dev/null +++ b/internal/tools/localfs/localfs_test.go @@ -0,0 +1,589 @@ +package localfs + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestNewLocalFS_Success(t *testing.T) { + base := t.TempDir() + lfs, err := NewLocalFS(base) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { + if err := lfs.Cleanup(); err != nil { + t.Fatalf("cleanup error: %v", err) + } + }() + + if info, err := os.Stat(lfs.tmp); err != nil { + t.Fatalf("tmp dir missing: %v", err) + } else if !info.IsDir() { + t.Fatalf("tmp is not a directory") + } + + f, err := lfs.Create("test.txt") + if err != nil { + t.Fatalf("create file error: %v", err) + } + _, err = f.Write([]byte("hello")) + if err != nil { + t.Fatalf("write error: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("close error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(lfs.tmp, "test.txt")) + if err != nil { + t.Fatalf("read file error: %v", err) + } + if string(data) != "hello" { + t.Fatalf("unexpected file content: %q", string(data)) + } +} + +func TestNewLocalFS_InvalidPath(t *testing.T) { + dir := t.TempDir() + tmpFile, err := os.CreateTemp(dir, "file-*") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + tmpPath := tmpFile.Name() + tmpFile.Close() + + if _, err := NewLocalFS(tmpPath); err == nil { + t.Fatalf("expected error when creating temp dir inside a file path, got nil") + } +} + +func TestCleanup_RemovesDir(t *testing.T) { + base := t.TempDir() + lfs, err := NewLocalFS(base) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + tmp := lfs.tmp + if err := lfs.Cleanup(); err != nil { + t.Fatalf("cleanup error: %v", err) + } + + if _, err := os.Stat(tmp); !os.IsNotExist(err) { + if err == nil { + t.Fatalf("expected directory to be removed, but it still exists") + } + t.Fatalf("unexpected stat error: %v", err) + } +} + +func TestWriteK8sResource_AutoFilename_Namespaced(t *testing.T) { + base := t.TempDir() + lfs, err := NewLocalFS(base) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { + if err := lfs.Cleanup(); err != nil { + t.Fatalf("cleanup error: %v", err) + } + }() + + u := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "mycm", + "namespace": "myns", + }, + "data": map[string]interface{}{ + "key": "value", + }, + }, + } + raw, err := u.MarshalJSON() + manifest := runtime.RawExtension{Raw: raw, Object: u} + + filename, err := lfs.WriteK8sResource("", manifest) + if err != nil { + t.Fatalf("WriteK8sResource error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(lfs.tmp, filename)) + if err != nil { + t.Fatalf("read written file error: %v", err) + } + s := string(content) + fmt.Println(s) + if !strings.Contains(s, "apiVersion: v1") { + t.Fatalf("missing apiVersion in file: %s", s) + } + if !strings.Contains(s, "kind: ConfigMap") { + t.Fatalf("missing kind in file: %s", s) + } + if !strings.Contains(s, "name: mycm") { + t.Fatalf("missing name in file: %s", s) + } + if !strings.Contains(s, "namespace: myns") { + t.Fatalf("missing namespace in file: %s", s) + } + if !strings.Contains(s, "key: value") { + t.Fatalf("missing data in file: %s", s) + } +} + +func TestWriteK8sResource_CustomFilename_NoNamespace(t *testing.T) { + base := t.TempDir() + lfs, err := NewLocalFS(base) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { + if err := lfs.Cleanup(); err != nil { + t.Fatalf("cleanup error: %v", err) + } + }() + + u := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "nonamespaced", + }, + "data": map[string]interface{}{ + "a": "b", + }, + }, + } + manifest := runtime.RawExtension{Object: u} + + const custom = "custom-name.yaml" + filename, err := lfs.WriteK8sResource(custom, manifest) + if err != nil { + t.Fatalf("WriteK8sResource error: %v", err) + } + if filename != custom { + t.Fatalf("expected filename %q, got %q", custom, filename) + } + + content, err := os.ReadFile(filepath.Join(lfs.tmp, filename)) + if err != nil { + t.Fatalf("read written file error: %v", err) + } + s := string(content) + if !strings.Contains(s, "name: nonamespaced") { + t.Fatalf("missing name in file: %s", s) + } + if !strings.Contains(s, "a: b") { + t.Fatalf("missing data in file: %s", s) + } + fmt.Println(s) +} +func TestWriteK8sResourceJQ_NoFilters_AutoFilename_Namespaced(t *testing.T) { + base := t.TempDir() + lfs, err := NewLocalFS(base) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { + if err := lfs.Cleanup(); err != nil { + t.Fatalf("cleanup error: %v", err) + } + }() + + u := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "jqcm", + "namespace": "jqns", + }, + "data": map[string]interface{}{ + "foo": "bar", + }, + }, + } + raw, err := u.MarshalJSON() + if err != nil { + t.Fatalf("marshal json error: %v", err) + } + manifest := runtime.RawExtension{Raw: raw, Object: u} + + filename, err := lfs.WriteK8sResourceJQ("", manifest) + if err != nil { + t.Fatalf("WriteK8sResourceJQ error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(lfs.tmp, filename)) + if err != nil { + t.Fatalf("read written file error: %v", err) + } + s := string(content) + if !strings.Contains(s, "apiVersion: v1") { + t.Fatalf("missing apiVersion in file: %s", s) + } + if !strings.Contains(s, "kind: ConfigMap") { + t.Fatalf("missing kind in file: %s", s) + } + if !strings.Contains(s, "name: jqcm") { + t.Fatalf("missing name in file: %s", s) + } + if !strings.Contains(s, "namespace: jqns") { + t.Fatalf("missing namespace in file: %s", s) + } + if !strings.Contains(s, "foo: bar") { + t.Fatalf("missing data in file: %s", s) + } +} + +func TestWriteK8sResourceJQ_WithJQ_NoOpFilter(t *testing.T) { + base := t.TempDir() + lfs, err := NewLocalFS(base) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { + if err := lfs.Cleanup(); err != nil { + t.Fatalf("cleanup error: %v", err) + } + }() + + u := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "secret-noop", + }, + "stringData": map[string]interface{}{ + "k": "v", + }, + }, + } + raw, err := u.MarshalJSON() + if err != nil { + t.Fatalf("marshal json error: %v", err) + } + manifest := runtime.RawExtension{Raw: raw, Object: u} + + // use the identity filter which should be a no-op + filename, err := lfs.WriteK8sResourceJQ("", manifest, ".") + if err != nil { + t.Fatalf("WriteK8sResourceJQ error with jq filter: %v", err) + } + + content, err := os.ReadFile(filepath.Join(lfs.tmp, filename)) + if err != nil { + t.Fatalf("read written file error: %v", err) + } + s := string(content) + if !strings.Contains(s, "kind: Secret") { + t.Fatalf("missing kind in file: %s", s) + } + if !strings.Contains(s, "name: secret-noop") { + t.Fatalf("missing name in file: %s", s) + } + if !strings.Contains(s, "k: v") { + t.Fatalf("missing data in file: %s", s) + } +} + +func TestWriteK8sResourceJQ_CustomFilename_NoNamespace(t *testing.T) { + base := t.TempDir() + lfs, err := NewLocalFS(base) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { + if err := lfs.Cleanup(); err != nil { + t.Fatalf("cleanup error: %v", err) + } + }() + + u := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "plain", + }, + "data": map[string]interface{}{ + "x": "y", + }, + }, + } + raw, err := u.MarshalJSON() + if err != nil { + t.Fatalf("marshal json error: %v", err) + } + manifest := runtime.RawExtension{Raw: raw, Object: u} + + const custom = "jq-custom.yaml" + filename, err := lfs.WriteK8sResourceJQ(custom, manifest, ".") + if err != nil { + t.Fatalf("WriteK8sResourceJQ error: %v", err) + } + if filename != custom { + t.Fatalf("expected filename %q, got %q", custom, filename) + } + + content, err := os.ReadFile(filepath.Join(lfs.tmp, filename)) + if err != nil { + t.Fatalf("read written file error: %v", err) + } + s := string(content) + if !strings.Contains(s, "name: plain") { + t.Fatalf("missing name in file: %s", s) + } + if !strings.Contains(s, "x: y") { + t.Fatalf("missing data in file: %s", s) + } +} + +func TestWriteK8sResourceJQ_MissingFields_Error(t *testing.T) { + base := t.TempDir() + lfs, err := NewLocalFS(base) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { + if err := lfs.Cleanup(); err != nil { + t.Fatalf("cleanup error: %v", err) + } + }() + + // missing kind and apiVersion + u := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "bad", + }, + }, + } + raw, err := u.MarshalJSON() + if err != nil { + t.Fatalf("marshal json error: %v", err) + } + manifest := runtime.RawExtension{Raw: raw, Object: u} + + if _, err := lfs.WriteK8sResourceJQ("", manifest); err == nil { + t.Fatalf("expected error for resource missing required fields, got nil") + } +} + +func TestWriteK8sResourceJQ_InvalidJQFilter_Error(t *testing.T) { + base := t.TempDir() + lfs, err := NewLocalFS(base) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { + if err := lfs.Cleanup(); err != nil { + t.Fatalf("cleanup error: %v", err) + } + }() + + u := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "invalid-jq", + }, + "data": map[string]interface{}{ + "foo": "bar", + }, + }, + } + raw, err := u.MarshalJSON() + if err != nil { + t.Fatalf("marshal json error: %v", err) + } + manifest := runtime.RawExtension{Raw: raw, Object: u} + + // invalid jq filter + if _, err := lfs.WriteK8sResourceJQ("", manifest, "invalid filter !!"); err == nil { + t.Fatalf("expected error for invalid jq filter, got nil") + } +} + +func TestWriteK8sResourceJQ_RemoveFieldWithJQFilter(t *testing.T) { + base := t.TempDir() + lfs, err := NewLocalFS(base) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { + if err := lfs.Cleanup(); err != nil { + t.Fatalf("cleanup error: %v", err) + } + }() + + u := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "my-deploy", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "replicas": 3, + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": "my-app", + }, + }, + }, + }, + } + raw, err := u.MarshalJSON() + if err != nil { + t.Fatalf("marshal json error: %v", err) + } + manifest := runtime.RawExtension{Raw: raw, Object: u} + + // JQ filter to remove the spec.replicas field + jqFilter := "del(.spec.replicas)" + + filename, err := lfs.WriteK8sResourceJQ("", manifest, jqFilter) + if err != nil { + t.Fatalf("WriteK8sResourceJQ error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(lfs.tmp, filename)) + if err != nil { + t.Fatalf("read written file error: %v", err) + } + s := string(content) + fmt.Println(s) + if strings.Contains(s, "replicas") { + t.Fatalf("expected replicas field to be removed, but found in file: %s", s) + } +} + +func TestWriteK8sResourceJQ_ModifyFieldWithJQFilter(t *testing.T) { + base := t.TempDir() + lfs, err := NewLocalFS(base) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { + if err := lfs.Cleanup(); err != nil { + t.Fatalf("cleanup error: %v", err) + } + }() + + u := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "modify-deploy", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "replicas": 2, + }, + }, + } + raw, err := u.MarshalJSON() + if err != nil { + t.Fatalf("marshal json error: %v", err) + } + manifest := runtime.RawExtension{Raw: raw, Object: u} + + // JQ filter to change spec.replicas to 5 + jqFilter := ".spec.replicas = 5" + + filename, err := lfs.WriteK8sResourceJQ("", manifest, jqFilter) + if err != nil { + t.Fatalf("WriteK8sResourceJQ error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(lfs.tmp, filename)) + if err != nil { + t.Fatalf("read written file error: %v", err) + } + s := string(content) + fmt.Println(s) + if !strings.Contains(s, "replicas: 5") { + t.Fatalf("expected replicas field to be modified to 5, but got file: %s", s) + } +} + +func TestWriteK8sResourceJQ_MultipleJQFilters(t *testing.T) { + base := t.TempDir() + lfs, err := NewLocalFS(base) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { + if err := lfs.Cleanup(); err != nil { + t.Fatalf("cleanup error: %v", err) + } + }() + + u := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "multi-deploy", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "replicas": 1, + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "app-container", + "image": "old-image:v1", + }, + }, + }, + }, + }, + }, + } + raw, err := u.MarshalJSON() + if err != nil { + t.Fatalf("marshal json error: %v", err) + } + manifest := runtime.RawExtension{Raw: raw, Object: u} + + // JQ filters to change spec.replicas to 4 and update container image + jqFilters := []string{ + ".spec.replicas = 4", + ".spec.template.spec.containers[0].image = \"new-image:v2\"", + } + + filename, err := lfs.WriteK8sResourceJQ("", manifest, jqFilters...) + if err != nil { + t.Fatalf("WriteK8sResourceJQ error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(lfs.tmp, filename)) + if err != nil { + t.Fatalf("read written file error: %v", err) + } + s := string(content) + fmt.Println(s) + if !strings.Contains(s, "replicas: 4") { + t.Fatalf("expected replicas field to be modified to 4, but got file: %s", s) + } + if !strings.Contains(s, "image: new-image:v2") { + t.Fatalf("expected container image to be updated, but got file: %s", s) + } +} diff --git a/internal/tools/template/template.go b/internal/tools/template/template.go new file mode 100644 index 0000000..b0e0679 --- /dev/null +++ b/internal/tools/template/template.go @@ -0,0 +1,37 @@ +package template + +import ( + "bytes" + "text/template" + + "github.com/Masterminds/sprig/v3" +) + +type TemplateValue struct { + Key string + Value string +} + +func FromTemplateValues(tplValues []TemplateValue) map[string]any { + values := make(map[string]any) + for _, v := range tplValues { + values[v.Key] = v.Value + } + return values +} + +type Template string + +func (t Template) Render(values map[string]any) ([]byte, error) { + tpl, err := template.New("template").Funcs(sprig.FuncMap()).Parse(string(t)) + if err != nil { + return nil, err + } + + buf := bytes.Buffer{} + if err := tpl.Execute(&buf, values); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} diff --git a/internal/tools/template/template_test.go b/internal/tools/template/template_test.go new file mode 100644 index 0000000..07e214c --- /dev/null +++ b/internal/tools/template/template_test.go @@ -0,0 +1,80 @@ +package template + +import ( + "fmt" + "testing" +) + +const deploymentTemplate = ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .resource }}-{{ .apiVersion }}-controller + namespace: {{ .namespace }} + labels: + app.kubernetes.io/name: {{ .name }} + app.kubernetes.io/instance: {{ .resource }}-{{ .apiVersion }} + app.kubernetes.io/component: controller + app.kubernetes.io/part-of: krateoplatformops + app.kubernetes.io/managed-by: krateo +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ .name }} + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + name: {{ .name }} + namespace: {{ .namespace }} + labels: + app.kubernetes.io/name: {{ .name }} + spec: + containers: + - name: {{ .resource }}-{{ .apiVersion }}-controller + image: ghcr.io/krateoplatformops/composition-dynamic-controller:0.15.3 + imagePullPolicy: IfNotPresent + env: + - name: HOME + value: /tmp + args: + - -debug + - -group={{ .apiGroup }} + - -version={{ .apiVersion }} + - -resource={{ .resource }} + - -namespace={{ .namespace }} + ports: + - containerPort: 8080 + name: metrics + protocol: TCP + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + serviceAccount: {{ .name }} + serviceAccountName: {{ .name }} + terminationGracePeriodSeconds: 30 +` + +func TestDeploymentManifest(t *testing.T) { + values := map[string]any{ + "apiGroup": "composition.krateo.io", + "apiVersion": "v12-8-3", + "resource": "postgresqls", + "name": "postgres-tgz", + "namespace": "default", + } + + bin, err := Template(deploymentTemplate).Render(values) + if err != nil { + t.Fatal(err) + } + + fmt.Println(string(bin)) +} diff --git a/testdata/local_fromRef.yaml b/testdata/local_fromRef.yaml new file mode 100644 index 0000000..000b7c4 --- /dev/null +++ b/testdata/local_fromRef.yaml @@ -0,0 +1,35 @@ +kind: LocalResource +apiVersion: git.krateo.io/v1alpha1 +metadata: + name: local-resource-example-from-ref + namespace: test-system +spec: + syncEnabled: true + override: true + insecure: true # needeed for local gitea with self-signed certificates + + fromResource: + fileName: Deployment.v1.apps_coredns_kube-system.yaml + transform: + - 'del(.metadata.managedFields)' + fromRef: + apiVersion: v1 + resource: configmaps + name: test-configmap + namespace: test-system + + toRepo: + branch: ref + cloneFromBranch: main + path: / + credentials: + authMethod: basic + secretRef: + key: token + name: gitea-repo-creds + namespace: test-system + usernameRef: + key: username + name: gitea-repo-creds + namespace: test-system + url: https://localhost:443/admin/test-repo.git diff --git a/testdata/local_fromString.yaml b/testdata/local_fromString.yaml new file mode 100644 index 0000000..6c4666e --- /dev/null +++ b/testdata/local_fromString.yaml @@ -0,0 +1,43 @@ +kind: LocalResource +apiVersion: git.krateo.io/v1alpha1 +metadata: + name: local-resource-example-string + namespace: default +spec: + placeholdersToOverride: + - name: example + value: "42" + + syncEnabled: true + override: true + insecure: true # needeed for local gitea with self-signed certificates + fromResource: + fileName: "string-resource.yaml" + fromString: | + kind: RemoteRepo + apiVersion: test.com/v6 + metadata: + name: example-repo + namespace: default + spec: + zipArchive: false + authMethod: generic + branch: main + placeholderTest: "{{ .example }}" + + + toRepo: + branch: string + cloneFromBranch: main + path: / + credentials: + authMethod: basic + secretRef: + key: token + name: gitea-repo-creds + namespace: test-system + usernameRef: + key: username + name: gitea-repo-creds + namespace: test-system + url: https://localhost:443/admin/test-repo.git \ No newline at end of file diff --git a/testdata/local_fromString_override_false.yaml b/testdata/local_fromString_override_false.yaml new file mode 100644 index 0000000..4615b19 --- /dev/null +++ b/testdata/local_fromString_override_false.yaml @@ -0,0 +1,44 @@ +kind: LocalResource +apiVersion: git.krateo.io/v1alpha1 +metadata: + name: local-resource-example-string-override-false + namespace: default +spec: + placeholdersToOverride: + - name: example + value: "42" + + syncEnabled: true + override: false + + insecure: true # needeed for local gitea with self-signed certificates + fromResource: + fileName: "string-resource.yaml" + fromString: | + kind: RemoteRepo + apiVersion: test.com/v6 + metadata: + name: example-repo + namespace: default + spec: + zipArchive: false + authMethod: generic + branch: main + placeholderTest: "{{ .example }}" + + + toRepo: + branch: string-override-false + cloneFromBranch: main + path: / + credentials: + authMethod: basic + secretRef: + key: token + name: gitea-repo-creds + namespace: test-system + usernameRef: + key: username + name: gitea-repo-creds + namespace: test-system + url: https://localhost:443/admin/test-repo.git \ No newline at end of file diff --git a/testdata/local_fromString_syncEnabled_false.yaml b/testdata/local_fromString_syncEnabled_false.yaml new file mode 100644 index 0000000..198acc6 --- /dev/null +++ b/testdata/local_fromString_syncEnabled_false.yaml @@ -0,0 +1,45 @@ +kind: LocalResource +apiVersion: git.krateo.io/v1alpha1 +metadata: + name: local-resource-example-string-syncenabled-false + namespace: default +spec: + placeholdersToOverride: + - name: example + value: "42" + + syncEnabled: false + override: true + insecure: true # needeed for local gitea with self-signed certificates + + fromResource: + fileName: "string-resource.yaml" + fromString: | + kind: RemoteRepo + apiVersion: test.com/v6 + metadata: + name: example-repo + namespace: default + spec: + zipArchive: false + authMethod: generic + branch: main + placeholderTest: "{{ .example }}" + + + toRepo: + branch: syncenabled-false + cloneFromBranch: main + path: / + credentials: + authMethod: basic + secretRef: + key: token + name: gitea-repo-creds + namespace: test-system + usernameRef: + key: username + name: gitea-repo-creds + namespace: test-system + url: https://localhost:443/admin/test-repo.git + diff --git a/testdata/local_fromYaml.yaml b/testdata/local_fromYaml.yaml new file mode 100644 index 0000000..1a30d91 --- /dev/null +++ b/testdata/local_fromYaml.yaml @@ -0,0 +1,45 @@ +kind: LocalResource +apiVersion: git.krateo.io/v1alpha1 +metadata: + name: local-resource-yaml + namespace: default +spec: + placeholdersToOverride: + - name: example + value: "42" + + syncEnabled: true + override: true + insecure: true # needeed for local gitea with self-signed certificates + + fromResource: + fileName: example.yaml + fromYaml: + kind: RemoteRepo + apiVersion: test.com/v6 + metadata: + name: example-repo + namespace: default + spec: + zipArchive: false + authMethod: generic + branch: main + placeholderTest: "{{ .example }}" + + + toRepo: + branch: yaml + cloneFromBranch: main + path: / + credentials: + authMethod: basic + secretRef: + key: token + name: gitea-repo-creds + namespace: test-system + usernameRef: + key: username + name: gitea-repo-creds + namespace: test-system + url: https://localhost:443/admin/test-repo.git + diff --git a/testdata/samples/cm.yaml b/testdata/samples/cm.yaml new file mode 100644 index 0000000..ecdb8fa --- /dev/null +++ b/testdata/samples/cm.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: fw-replace-values +data: + values: | + { "organizationName": "aa", + "repositoryName": "bb", + "serviceType": "123", + "servicePort": "123", + } \ No newline at end of file diff --git a/samples/repo-gitcookies.yaml b/testdata/samples/repo-gitcookies.yaml similarity index 96% rename from samples/repo-gitcookies.yaml rename to testdata/samples/repo-gitcookies.yaml index 9e03ec8..3e2c386 100644 --- a/samples/repo-gitcookies.yaml +++ b/testdata/samples/repo-gitcookies.yaml @@ -7,7 +7,6 @@ spec: key: values name: fw-replace-values namespace: default - deletionPolicy: Orphan enableUpdate: true fromRepo: authMethod: cookiefile diff --git a/samples/repo-override.yaml b/testdata/samples/repo-override.yaml similarity index 97% rename from samples/repo-override.yaml rename to testdata/samples/repo-override.yaml index 62acc91..9fcddca 100644 --- a/samples/repo-override.yaml +++ b/testdata/samples/repo-override.yaml @@ -20,7 +20,7 @@ spec: url: https://github.com/krateoplatformops-test/test-from-override toRepo: authMethod: generic - branch: test + branch: prova cloneFromBranch: main path: prova secretRef: diff --git a/samples/repo.yaml b/testdata/samples/repo.yaml similarity index 57% rename from samples/repo.yaml rename to testdata/samples/repo.yaml index 8c3e936..ed899a6 100644 --- a/samples/repo.yaml +++ b/testdata/samples/repo.yaml @@ -1,38 +1,38 @@ apiVersion: git.krateo.io/v1alpha1 kind: Repo metadata: - name: test-repo + name: test-repo-fw spec: - enableUpdate: false configMapKeyRef: key: values - name: filename-replace-values + name: fw-replace-values namespace: default + enableUpdate: false fromRepo: authMethod: generic branch: main - path: skeleton + path: skeleton/ usernameRef: key: username name: git-username - namespace: krateo-system + namespace: default secretRef: key: token - name: gh-sec - namespace: krateo-system - url: https://github.com/matteogastaldello/fromRepo + name: gh-token + namespace: default + url: https://github.com/krateoplatformops/krateo-v2-template-fireworksapp toRepo: authMethod: generic branch: test cloneFromBranch: main - path: / + path: test-fw/ secretRef: key: token - name: gh-sec - namespace: krateo-system + name: gh-token + namespace: default usernameRef: key: username name: git-username - namespace: krateo-system - url: https://github.com/matteogastaldello/toRepo + namespace: default + url: https://github.com/krateoplatformops-test/test-fw unsupportedCapabilities: true From c90d2cbdf8fffbf0fcac3e50fae2b86327fae437 Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Fri, 21 Nov 2025 16:30:06 +0100 Subject: [PATCH 04/17] feat: enhance LocalResource handling with dynamic interface and context support --- internal/clients/git/git.go | 10 +- internal/controllers/common/footer/footer.go | 46 ++- .../controllers/common/footer/footer_test.go | 11 +- .../localresource/localresource.go | 7 +- .../localresource/localresource_test.go | 264 ++++++++++++++++-- internal/tools/localfs/localfs.go | 37 +-- internal/tools/localfs/localfs_test.go | 16 +- 7 files changed, 328 insertions(+), 63 deletions(-) diff --git a/internal/clients/git/git.go b/internal/clients/git/git.go index 793f1d2..bfe2bac 100644 --- a/internal/clients/git/git.go +++ b/internal/clients/git/git.go @@ -36,9 +36,9 @@ import ( "github.com/krateoplatformops/plumbing/ptr" ) -const ( - commitAuthorEmail = "contact@krateo.io" - commitAuthorName = "krateo-git-provider" +var ( + CommitAuthorEmail = "contact@krateo.io" + CommitAuthorName = "krateo-git-provider" ) var ( @@ -673,8 +673,8 @@ func (s *Repo) Commit(path, msg string, opt *IndexOptions) (plumbing.Hash, error // git commit -m $message hash, err := wt.Commit(msg, &git.CommitOptions{ Author: &object.Signature{ - Name: commitAuthorName, - Email: commitAuthorEmail, + Name: CommitAuthorName, + Email: CommitAuthorEmail, When: time.Now(), }, }) diff --git a/internal/controllers/common/footer/footer.go b/internal/controllers/common/footer/footer.go index f91d187..89c1403 100644 --- a/internal/controllers/common/footer/footer.go +++ b/internal/controllers/common/footer/footer.go @@ -1,12 +1,17 @@ package footer import ( + "context" "encoding/json" "fmt" "strings" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + localResourcev1alpha1 "github.com/krateoplatformops/git-provider/apis/localresource/v1alpha1" hasher "github.com/krateoplatformops/git-provider/internal/tools/hash" + "k8s.io/client-go/dynamic" ) type localResourceCommitFooter struct { @@ -17,7 +22,7 @@ type localResourceCommitFooter struct { const localResourceCommitFooterPrefix = "Managed by git-provider LocalResource:" -func CalculateLocalResourceSpecHash(cr *localResourcev1alpha1.LocalResource) (string, error) { +func CalculateLocalResourceSpecHash(ctx context.Context, cr *localResourcev1alpha1.LocalResource, dyn dynamic.Interface) (string, error) { hash := hasher.NewFNVObjectHash() err := hash.SumHash( cr.Spec.FromResource, @@ -25,15 +30,48 @@ func CalculateLocalResourceSpecHash(cr *localResourcev1alpha1.LocalResource) (st cr.Spec.ToRepo.Path, cr.Spec.ToRepo.Branch, cr.Spec.ToRepo.CloneFromBranch, - cr.Spec.PlaceholdersToOverride) + cr.Spec.PlaceholdersToOverride, + ) if err != nil { return "", fmt.Errorf("unable to compute LocalResource spec hash: %w", err) } + + if cr.Spec.FromResource.FromRef != nil && dyn != nil { + // include the resource version of the referenced resource in the hash + gv, err := schema.ParseGroupVersion(cr.Spec.FromResource.FromRef.ApiVersion) + if err != nil { + return "", fmt.Errorf("unable to parse apiVersion %s of referenced resource: %w", cr.Spec.FromResource.FromRef.ApiVersion, err) + } + var cli dynamic.ResourceInterface + if cr.Spec.FromResource.FromRef.Namespace == "" { + cli = dyn.Resource(schema.GroupVersionResource{ + Group: gv.Group, + Version: gv.Version, + Resource: cr.Spec.FromResource.FromRef.Resource, + }) + } else { + cli = dyn.Resource(schema.GroupVersionResource{ + Group: gv.Group, + Version: gv.Version, + Resource: cr.Spec.FromResource.FromRef.Resource, + }).Namespace(cr.Spec.FromResource.FromRef.Namespace) + } + + u, err := cli.Get(ctx, cr.Spec.FromResource.FromRef.Name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("unable to get referenced resource %s/%s: %w", cr.Spec.FromResource.FromRef.Namespace, cr.Spec.FromResource.FromRef.Name, err) + } + err = hash.SumHash(u.GetResourceVersion()) + if err != nil { + return "", fmt.Errorf("unable to compute LocalResource spec hash: %w", err) + } + } + return hash.GetHash(), nil } -func LocalResourceCommitFooter(cr *localResourcev1alpha1.LocalResource) (string, error) { - hshString, err := CalculateLocalResourceSpecHash(cr) +func LocalResourceCommitFooter(ctx context.Context, cr *localResourcev1alpha1.LocalResource, dyn dynamic.Interface) (string, error) { + hshString, err := CalculateLocalResourceSpecHash(ctx, cr, dyn) if err != nil { return "", fmt.Errorf("unable to compute LocalResource spec hash: %w", err) } diff --git a/internal/controllers/common/footer/footer_test.go b/internal/controllers/common/footer/footer_test.go index e19259a..c6c94c1 100644 --- a/internal/controllers/common/footer/footer_test.go +++ b/internal/controllers/common/footer/footer_test.go @@ -1,6 +1,7 @@ package footer import ( + "context" "fmt" "testing" @@ -41,7 +42,7 @@ func makeTestLocalResource() *localResourcev1alpha1.LocalResource { func TestLocalResourceCommitFooterAndParse(t *testing.T) { cr := makeTestLocalResource() - footerStr, err := LocalResourceCommitFooter(cr) + footerStr, err := LocalResourceCommitFooter(context.TODO(), cr, nil) if err != nil { t.Fatalf("LocalResourceCommitFooter returned error: %v", err) } @@ -62,7 +63,7 @@ func TestLocalResourceCommitFooterAndParse(t *testing.T) { t.Fatalf("name mismatch: got %q want %q", name, cr.GetName()) } - expectedHash, err := CalculateLocalResourceSpecHash(cr) + expectedHash, err := CalculateLocalResourceSpecHash(context.TODO(), cr, nil) if err != nil { t.Fatalf("CalculateLocalResourceSpecHash returned error: %v", err) } @@ -82,11 +83,11 @@ func TestCalculateLocalResourceSpecHash_Deterministic(t *testing.T) { cr1 := makeTestLocalResource() cr2 := makeTestLocalResource() - h1, err := CalculateLocalResourceSpecHash(cr1) + h1, err := CalculateLocalResourceSpecHash(context.TODO(), cr1, nil) if err != nil { t.Fatalf("calculateLocalResourceSpecHash returned error: %v", err) } - h2, err := CalculateLocalResourceSpecHash(cr2) + h2, err := CalculateLocalResourceSpecHash(context.TODO(), cr2, nil) if err != nil { t.Fatalf("calculateLocalResourceSpecHash returned error: %v", err) } @@ -96,7 +97,7 @@ func TestCalculateLocalResourceSpecHash_Deterministic(t *testing.T) { // change a field and expect a different hash cr2.Spec.ToRepo.Path = "different-path" - h3, err := CalculateLocalResourceSpecHash(cr2) + h3, err := CalculateLocalResourceSpecHash(context.TODO(), cr2, nil) if err != nil { t.Fatalf("calculateLocalResourceSpecHash returned error: %v", err) } diff --git a/internal/controllers/localresource/localresource.go b/internal/controllers/localresource/localresource.go index 68276ee..32fca88 100644 --- a/internal/controllers/localresource/localresource.go +++ b/internal/controllers/localresource/localresource.go @@ -65,6 +65,9 @@ func Setup(mgr ctrl.Manager, o option.SetupOptions) error { reconciler.WithTimeout(o.Controller.Timeout), ) + git.CommitAuthorEmail = o.Git.CommitAuthorEmail + git.CommitAuthorName = o.Git.CommitAuthorName + return ctrl.NewControllerManagedBy(mgr). Named(name). WithOptions(o.Controller.ForControllerRuntime()). @@ -192,7 +195,7 @@ func (e *external) Observe(ctx context.Context, mg resource.Managed) (reconciler hasGitProviderPreviuslyCommitted = true e.log.Debug("Found previous commit from git-provider for this LocalResource", "commitId", commit.Hash.String()) } - currentSpecHash, err := footer.CalculateLocalResourceSpecHash(cr) + currentSpecHash, err := footer.CalculateLocalResourceSpecHash(ctx, cr, e.dynamic) if err != nil { return false } @@ -398,7 +401,7 @@ func (e *external) SyncLocalResources(ctx context.Context, cr *localResourcev1al "fromPath", fromPath, "toPath", toPath) - commitFooter, err := footer.LocalResourceCommitFooter(cr) + commitFooter, err := footer.LocalResourceCommitFooter(ctx, cr, e.dynamic) if err != nil { return fmt.Errorf("unable to compute LocalResource commit footer: %w", err) } diff --git a/internal/controllers/localresource/localresource_test.go b/internal/controllers/localresource/localresource_test.go index 00c8a98..4da15fa 100644 --- a/internal/controllers/localresource/localresource_test.go +++ b/internal/controllers/localresource/localresource_test.go @@ -7,6 +7,7 @@ import ( "context" "crypto/tls" "encoding/base64" + "encoding/json" "fmt" "io" "log/slog" @@ -18,6 +19,7 @@ import ( "testing" "time" + jsonpatch "github.com/evanphx/json-patch/v5" "github.com/krateoplatformops/git-provider/apis" "github.com/krateoplatformops/git-provider/apis/localresource/v1alpha1" "github.com/krateoplatformops/git-provider/internal/controllers/common/option" @@ -83,7 +85,7 @@ func TestMain(m *testing.M) { } defer cli.Close() - // var containerId string + var containerId string giteaAdmin := "admin" giteaAdminPassword := "admin123" @@ -95,7 +97,6 @@ func TestMain(m *testing.M) { // Start docker gitea instance func(ctx context.Context, cfg *envconf.Config) (context.Context, error) { // Crea il client Docker - tmpdir, err := os.MkdirTemp(os.TempDir(), "local-resource-test-gitea-*") if err != nil { panic(err) @@ -217,7 +218,7 @@ func TestMain(m *testing.M) { if err != nil { panic(err) } - // containerId = resp.ID + containerId = resp.ID fmt.Printf("Container started successfully!\n") fmt.Printf("Access Gitea at: https://localhost:443\n") @@ -307,9 +308,9 @@ func TestMain(m *testing.M) { return ctx, nil }, ).Finish( - // envfuncs.DeleteNamespace(namespace), - // envfuncs.TeardownCRDs(crdPath, "git.krateo.io_localresources.yaml"), - // envfuncs.DestroyCluster(clusterName), + envfuncs.DeleteNamespace(namespace), + envfuncs.TeardownCRDs(crdPath, "git.krateo.io_localresources.yaml"), + envfuncs.DestroyCluster(clusterName), func(ctx context.Context, c *envconf.Config) (context.Context, error) { if v := ctx.Value(stopKey{}); v != nil { if stop, ok := v.(context.CancelFunc); ok { @@ -318,14 +319,14 @@ func TestMain(m *testing.M) { } } - // _, err := cli.ContainerStop(ctx, containerId, client.ContainerStopOptions{}) - // if err != nil { - // panic(err) - // } - // _, err = cli.ContainerRemove(ctx, containerId, client.ContainerRemoveOptions{}) - // if err != nil { - // panic(err) - // } + _, err := cli.ContainerStop(ctx, containerId, client.ContainerStopOptions{}) + if err != nil { + panic(err) + } + _, err = cli.ContainerRemove(ctx, containerId, client.ContainerRemoveOptions{}) + if err != nil { + panic(err) + } return ctx, nil }, ) @@ -365,7 +366,7 @@ func TestController(t *testing.T) { o := controller.Options{ Logger: log, MaxConcurrentReconciles: 1, - PollInterval: 20 * time.Second, + PollInterval: 10 * time.Second, GlobalRateLimiter: ratelimiter.NewGlobalExponential(1*time.Second, 1*time.Minute), } @@ -499,6 +500,8 @@ spec: t.Fatalf("Failed to create ConfigMap %s: %v", configMapResource.Name, err) } + time.Sleep(5 * time.Second) // wait for the configmap to be created + r.WithNamespace(namespace) for _, test := range toCreate { @@ -571,6 +574,7 @@ spec: filename string patch string expected string + ref bool patchError bool }{ { @@ -588,9 +592,10 @@ spec: expected: `Hello, Nginx v1.1.0!`, }, { + ref: true, filename: "local_fromRef.yaml", - patch: `[{"op": "replace", "path": "/spec/fromResource/fromRef/name", "value": "test-configmap"}]`, - expected: `testkey: testvalue`, // content of the configmap key + patch: `[{"op": "replace", "path": "/data/testkey", "value": "test-update"}]`, + expected: `testkey: test-update`, // content of the configmap key }, { filename: "local_fromString_syncEnabled_false.yaml", @@ -640,13 +645,26 @@ spec: t.Fatal(err) } - err = r.Patch(ctx, &res, k8s.Patch{PatchType: types.JSONPatchType, Data: []byte(test.patch)}) - if err != nil && test.patchError == false { - t.Fatalf("Failed to patch LocalResource %s: %v", res.Name, err) + // This only works for Configmap refs for now + if test.ref { + var refCm v1.ConfigMap + err = r.Get(ctx, res.Spec.FromResource.FromRef.Name, res.Spec.FromResource.FromRef.Namespace, &refCm) + if err != nil { + t.Fatalf("Failed to get referenced ConfigMap: %v", err) + } + err = r.Patch(ctx, &refCm, k8s.Patch{PatchType: types.JSONPatchType, Data: []byte(test.patch)}) + if err != nil && test.patchError == false { + t.Fatalf("Failed to patch referenced ConfigMap %s: %v", refCm.Name, err) + } + } else { + err = r.Patch(ctx, &res, k8s.Patch{PatchType: types.JSONPatchType, Data: []byte(test.patch)}) + if err != nil && test.patchError == false { + t.Fatalf("Failed to patch LocalResource %s: %v", res.Name, err) + } } } - time.Sleep(10 * time.Second) // wait for the controller to pick up the new resources + time.Sleep(30 * time.Second) // wait for the controller to pick up the new resources // verify that the changes have been applied for _, test := range toUpgrade { @@ -701,7 +719,176 @@ spec: } return ctx - }).Assess("Test Delete", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + }).Assess("Test Delete and Recreate", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + toDeleteAndPatch := []struct { + filename string + patch string + expected string + patchError bool + ref bool + }{ + { + filename: "local_fromYaml.yaml", + patch: `[{"op": "replace", "path": "/spec/fromResource/fromYaml/spec/zipArchive", "value": true}]`, + expected: `zipArchive: true`, + }, + { + filename: "local_fromString.yaml", + patch: `[{ + "op": "replace", + "path": "/spec/fromResource/fromString", + "value": "Hello, Nginx v1.1.0!" + }]`, + expected: `Hello, Nginx v1.1.0!`, + }, + { + ref: true, + filename: "local_fromRef.yaml", + patch: `[{"op": "replace", "path": "/data/testkey", "value": "test-recreate"}]`, + expected: `testkey: test-recreate`, // content of the configmap key + }, + { + filename: "local_fromString_syncEnabled_false.yaml", + patch: `[{"op": "replace","path": "/spec/fromResource/fromString","value": "Hello, World v2!"}]`, + expected: `kind: RemoteRepo +apiVersion: test.com/v6 +metadata: + name: example-repo + namespace: default +spec: + zipArchive: false + authMethod: generic + branch: main + placeholderTest: "42"`, // value should not change as syncEnabled is false + }, + { + filename: "local_fromString_override_false.yaml", + patch: `[{"op": "replace","path": "/spec/fromResource/fromString","value": "Overridden Value!"}]`, + expected: `kind: RemoteRepo +apiVersion: test.com/v6 +metadata: + name: example-repo + namespace: default +spec: + zipArchive: false + authMethod: generic + branch: main + placeholderTest: "42"`, // value should not change as override is false + }, + } + r, err := resources.New(cfg.Client().RESTConfig()) + if err != nil { + t.Fail() + } + + for _, test := range toDeleteAndPatch { + var res v1alpha1.LocalResource + err := decoder.DecodeFile( + os.DirFS(filepath.Join(testdataPath)), test.filename, + &res, + decoder.MutateNamespace(namespace), + ) + if err != nil { + t.Fatal(err) + } + + err = r.Delete(ctx, &res) + if err != nil { + t.Fatalf("Failed to delete LocalResource %s: %v", res.Name, err) + } + } + + time.Sleep(30 * time.Second) // wait for the controller to process deletions + + // Now we recreate them with different specs and we check if values are overritten or not according to the specs + for _, test := range toDeleteAndPatch { + var res v1alpha1.LocalResource + err := decoder.DecodeFile( + os.DirFS(filepath.Join(testdataPath)), test.filename, + &res, + decoder.MutateNamespace(namespace), + ) + if err != nil { + t.Fatal(err) + } + + if test.ref { + var refCm v1.ConfigMap + err = r.Get(ctx, res.Spec.FromResource.FromRef.Name, res.Spec.FromResource.FromRef.Namespace, &refCm) + if err != nil { + t.Fatalf("Failed to get referenced ConfigMap: %v", err) + } + err = r.Patch(ctx, &refCm, k8s.Patch{PatchType: types.JSONPatchType, Data: []byte(test.patch)}) + if err != nil && test.patchError == false { + t.Fatalf("Failed to patch referenced ConfigMap %s: %v", refCm.Name, err) + } + } else { + // now we patch with the new spec to the resource (that does not exist in the cluster yet) so we need to change res object + err = applyPatchToCR(&res, test.patch) + if err != nil { + t.Fatalf("Failed to apply patch to LocalResource %s: %v", res.Name, err) + } + } + + err = r.Create(ctx, &res) + if err != nil { + t.Fatalf("Failed to recreate LocalResource %s: %v", res.Name, err) + } + } + + time.Sleep(15 * time.Second) + + // verify that the changes have been applied + for _, test := range toDeleteAndPatch { + var res v1alpha1.LocalResource + err := decoder.DecodeFile( + os.DirFS(filepath.Join(testdataPath)), test.filename, + &res, + decoder.MutateNamespace(namespace), + ) + if err != nil { + t.Fatal(err) + } + + var updatedRes v1alpha1.LocalResource + err = r.Get(ctx, res.GetName(), res.GetNamespace(), &updatedRes) + if err != nil { + t.Fatal(err) + } + + // verify that the git-provider has updated the resource in the git repo + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + client := &http.Client{Transport: tr} + repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") + url := fmt.Sprintf("https://localhost:443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) + req, _ := http.NewRequest("GET", url, nil) + req.SetBasicAuth("admin", "admin123") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected status 200 OK, got %s calling %s", resp.Status, url) + } + // Check if the content matches the updated resource + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + bodyStr := string(body) + + // The content is base64 encoded inside the "content" field + decodedContent, err := decodeGiteaContent(bodyStr) + if err != nil { + t.Fatalf("Failed to decode content for LocalResource %s: %v", res.Name, err) + } + if !strings.Contains(decodedContent, test.expected) { + t.Fatalf("Expected content to contain %q, but it was not found in response body: %s", test.expected, decodedContent) + } + } return ctx }).Feature() @@ -731,3 +918,36 @@ func decodeGiteaContent(body string) (string, error) { return string(decodedBytes), nil } + +// applyPatchToCR applies a JSON Patch (provided as a JSON string) to a Custom Resource. +// +// 'resource' must be a pointer to your CR structure (e.g., *v1alpha1.LocalResource). +// 'patchString' is the JSON Patch in string format. +// The function modifies 'resource' in place. +func applyPatchToCR(resource interface{}, patchString string) error { + // 1. Convert the original CR into JSON bytes (Target Document) + originalJSON, err := json.Marshal(resource) + if err != nil { + return err + } + + // 2. Decode the patch string into a Patch object + patch, err := jsonpatch.DecodePatch([]byte(patchString)) + if err != nil { + return err + } + + // 3. Apply the patch to the original JSON bytes + patchedJSON, err := patch.Apply(originalJSON) + if err != nil { + return err + } + + // 4. Decode the patched JSON back into the resource structure + // This updates the value pointed to by 'resource'. + if err := json.Unmarshal(patchedJSON, resource); err != nil { + return err + } + + return nil +} diff --git a/internal/tools/localfs/localfs.go b/internal/tools/localfs/localfs.go index 4279225..33bdd1a 100644 --- a/internal/tools/localfs/localfs.go +++ b/internal/tools/localfs/localfs.go @@ -158,25 +158,28 @@ func (lfs LocalFS) WriteK8sResourceJQ(filename string, manifest runtime.RawExten filename = fmt.Sprintf("%s_%s_%s.yaml", stringGVK, ures.GetName(), ures.GetNamespace()) } } - - // Apply jq filters sequentially var processedJSON string - for _, filter := range jqFilters { - processedJSON, err = jqutil.Eval(context.Background(), jqutil.EvalOptions{ - Query: filter, - Data: ures.Object, - Unquote: false, - ModuleLoader: nil, - }) - if err != nil { - return "", fmt.Errorf("applying jq filter '%s': %w", filter, err) - } - // Update ures.Object for the next iteration - ures.Object = make(map[string]any) - err = yaml.NewYAMLOrJSONDecoder(bytes.NewReader([]byte(processedJSON)), 1024).Decode(&ures.Object) - if err != nil { - return "", fmt.Errorf("decoding JSON after jq filter '%s': %w", filter, err) + if len(jqFilters) > 0 { + // Apply jq filters sequentially + for _, filter := range jqFilters { + processedJSON, err = jqutil.Eval(context.Background(), jqutil.EvalOptions{ + Query: filter, + Data: ures.Object, + Unquote: false, + ModuleLoader: nil, + }) + if err != nil { + return "", fmt.Errorf("applying jq filter '%s': %w", filter, err) + } + // Update ures.Object for the next iteration + ures.Object = make(map[string]any) + err = yaml.NewYAMLOrJSONDecoder(bytes.NewReader([]byte(processedJSON)), 1024).Decode(&ures.Object) + if err != nil { + return "", fmt.Errorf("decoding JSON after jq filter '%s': %w", filter, err) + } } + } else { + processedJSON = string(manifest.Raw) } scheme := runtime.NewScheme() diff --git a/internal/tools/localfs/localfs_test.go b/internal/tools/localfs/localfs_test.go index 6b6c96f..5b5e2ee 100644 --- a/internal/tools/localfs/localfs_test.go +++ b/internal/tools/localfs/localfs_test.go @@ -1,7 +1,6 @@ package localfs import ( - "fmt" "os" "path/filepath" "strings" @@ -109,7 +108,7 @@ func TestWriteK8sResource_AutoFilename_Namespaced(t *testing.T) { }, }, } - raw, err := u.MarshalJSON() + raw, _ := u.MarshalJSON() manifest := runtime.RawExtension{Raw: raw, Object: u} filename, err := lfs.WriteK8sResource("", manifest) @@ -122,7 +121,7 @@ func TestWriteK8sResource_AutoFilename_Namespaced(t *testing.T) { t.Fatalf("read written file error: %v", err) } s := string(content) - fmt.Println(s) + // fmt.Println(s) if !strings.Contains(s, "apiVersion: v1") { t.Fatalf("missing apiVersion in file: %s", s) } @@ -186,7 +185,7 @@ func TestWriteK8sResource_CustomFilename_NoNamespace(t *testing.T) { if !strings.Contains(s, "a: b") { t.Fatalf("missing data in file: %s", s) } - fmt.Println(s) + // fmt.Println(s) } func TestWriteK8sResourceJQ_NoFilters_AutoFilename_Namespaced(t *testing.T) { base := t.TempDir() @@ -342,10 +341,11 @@ func TestWriteK8sResourceJQ_CustomFilename_NoNamespace(t *testing.T) { t.Fatalf("read written file error: %v", err) } s := string(content) + // fmt.Println(s) if !strings.Contains(s, "name: plain") { t.Fatalf("missing name in file: %s", s) } - if !strings.Contains(s, "x: y") { + if !strings.Contains(s, "x: \"y\"") { t.Fatalf("missing data in file: %s", s) } } @@ -466,7 +466,7 @@ func TestWriteK8sResourceJQ_RemoveFieldWithJQFilter(t *testing.T) { t.Fatalf("read written file error: %v", err) } s := string(content) - fmt.Println(s) + // fmt.Println(s) if strings.Contains(s, "replicas") { t.Fatalf("expected replicas field to be removed, but found in file: %s", s) } @@ -516,7 +516,7 @@ func TestWriteK8sResourceJQ_ModifyFieldWithJQFilter(t *testing.T) { t.Fatalf("read written file error: %v", err) } s := string(content) - fmt.Println(s) + // fmt.Println(s) if !strings.Contains(s, "replicas: 5") { t.Fatalf("expected replicas field to be modified to 5, but got file: %s", s) } @@ -579,7 +579,7 @@ func TestWriteK8sResourceJQ_MultipleJQFilters(t *testing.T) { t.Fatalf("read written file error: %v", err) } s := string(content) - fmt.Println(s) + // ntln(s) if !strings.Contains(s, "replicas: 4") { t.Fatalf("expected replicas field to be modified to 4, but got file: %s", s) } From e12f9932e8f0f5270694f99080b92f8bd4093538 Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Mon, 24 Nov 2025 08:58:01 +0100 Subject: [PATCH 05/17] feat: update Go version in Dockerfile to 1.25.o-bookworm --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 651ff76..03ecd34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Build environment # ----------------- -FROM golang:1.24-bookworm AS builder +FROM golang:1.25.o-bookworm AS builder LABEL stage=builder ARG DEBIAN_FRONTEND=noninteractive From 2baed141a04e46f740ffbe9ffd50110e9d14a881 Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Mon, 24 Nov 2025 08:58:05 +0100 Subject: [PATCH 06/17] fix: increase wait time for Gitea readiness in integration test --- internal/controllers/localresource/localresource_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controllers/localresource/localresource_test.go b/internal/controllers/localresource/localresource_test.go index 4da15fa..eb11af7 100644 --- a/internal/controllers/localresource/localresource_test.go +++ b/internal/controllers/localresource/localresource_test.go @@ -224,7 +224,7 @@ func TestMain(m *testing.M) { fmt.Printf("Access Gitea at: https://localhost:443\n") // Wait for Gitea to be ready - time.Sleep(10 * time.Second) + time.Sleep(60 * time.Second) return ctx, nil }, From a1b2d975ec92201aa62c91e585552eb934cf4216 Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Mon, 24 Nov 2025 08:59:53 +0100 Subject: [PATCH 07/17] fix: correct Go version in Dockerfile from 1.25.o-bookworm to 1.25.0-bookworm --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 03ecd34..02ad6e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Build environment # ----------------- -FROM golang:1.25.o-bookworm AS builder +FROM golang:1.25.0-bookworm AS builder LABEL stage=builder ARG DEBIAN_FRONTEND=noninteractive From 7d2130654457b067020eee5ddb7e740ce4119ad4 Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Mon, 24 Nov 2025 09:12:36 +0100 Subject: [PATCH 08/17] test: update Gitea port from 443 to 3443 in integration tests --- .../localresource/localresource_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/controllers/localresource/localresource_test.go b/internal/controllers/localresource/localresource_test.go index eb11af7..8d21dd4 100644 --- a/internal/controllers/localresource/localresource_test.go +++ b/internal/controllers/localresource/localresource_test.go @@ -140,7 +140,7 @@ func TestMain(m *testing.M) { httpsPort: []network.PortBinding{ { HostIP: netip.AddrFrom4([4]byte{0, 0, 0, 0}), - HostPort: "443", + HostPort: "3443", }, }, } @@ -161,7 +161,7 @@ func TestMain(m *testing.M) { // HTTPS with self-signed certs "GITEA__server__DOMAIN=localhost", "GITEA__server__HTTP_PORT=443", - "GITEA__server__ROOT_URL=https://localhost:443", + "GITEA__server__ROOT_URL=https://localhost:3443", "GITEA__server__PROTOCOL=https", "GITEA__server__CERT_FILE=/data/cert.pem", "GITEA__server__KEY_FILE=/data/key.pem", @@ -221,7 +221,7 @@ func TestMain(m *testing.M) { containerId = resp.ID fmt.Printf("Container started successfully!\n") - fmt.Printf("Access Gitea at: https://localhost:443\n") + fmt.Printf("Access Gitea at: https://localhost:3443\n") // Wait for Gitea to be ready time.Sleep(60 * time.Second) @@ -236,7 +236,7 @@ func TestMain(m *testing.M) { client := &http.Client{Transport: tr} // Con Basic Auth - req2, _ := http.NewRequest("GET", "https://localhost:443/api/v1/user", nil) + req2, _ := http.NewRequest("GET", "https://localhost:3443/api/v1/user", nil) req2.SetBasicAuth("admin", "admin123") resp2, err := client.Do(req2) @@ -256,7 +256,7 @@ func TestMain(m *testing.M) { "auto_init": true, "readme": "Default" }` - req3, _ := http.NewRequest("POST", "https://localhost:443/api/v1/user/repos", strings.NewReader(req3Body)) + req3, _ := http.NewRequest("POST", "https://localhost:3443/api/v1/user/repos", strings.NewReader(req3Body)) req3.Header.Set("Content-Type", "application/json") req3.SetBasicAuth(giteaAdmin, giteaAdminPassword) resp3, err := client.Do(req3) @@ -540,7 +540,7 @@ spec: } client := &http.Client{Transport: tr} repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") - url := fmt.Sprintf("https://localhost:443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) + url := fmt.Sprintf("https://localhost:3443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) req, _ := http.NewRequest("GET", url, nil) req.SetBasicAuth("admin", "admin123") resp, err := client.Do(req) @@ -690,7 +690,7 @@ spec: } client := &http.Client{Transport: tr} repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") - url := fmt.Sprintf("https://localhost:443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) + url := fmt.Sprintf("https://localhost:3443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) req, _ := http.NewRequest("GET", url, nil) req.SetBasicAuth("admin", "admin123") resp, err := client.Do(req) @@ -862,7 +862,7 @@ spec: } client := &http.Client{Transport: tr} repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") - url := fmt.Sprintf("https://localhost:443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) + url := fmt.Sprintf("https://localhost:3443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) req, _ := http.NewRequest("GET", url, nil) req.SetBasicAuth("admin", "admin123") resp, err := client.Do(req) From 1aed017357623b7eab826f5c36bae468913c5697 Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Mon, 24 Nov 2025 09:26:28 +0100 Subject: [PATCH 09/17] test: implement waitForGitea function to check Gitea readiness before tests --- .../localresource/localresource_test.go | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/internal/controllers/localresource/localresource_test.go b/internal/controllers/localresource/localresource_test.go index 8d21dd4..5d0986a 100644 --- a/internal/controllers/localresource/localresource_test.go +++ b/internal/controllers/localresource/localresource_test.go @@ -224,7 +224,10 @@ func TestMain(m *testing.M) { fmt.Printf("Access Gitea at: https://localhost:3443\n") // Wait for Gitea to be ready - time.Sleep(60 * time.Second) + err = waitForGitea(ctx) + if err != nil { + panic(err) + } return ctx, nil }, @@ -951,3 +954,35 @@ func applyPatchToCR(resource interface{}, patchString string) error { return nil } + +func waitForGitea(ctx context.Context) error { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + client := &http.Client{Transport: tr} + + // Con Basic Auth (necessario per l'endpoint /api/v1/user) + req, _ := http.NewRequest("GET", "https://localhost:3443/api/v1/user", nil) + req.SetBasicAuth("admin", "admin123") + + const maxAttempts = 60 + const delay = 5 * time.Second + + for i := 0; i < maxAttempts; i++ { + resp, err := client.Do(req) + if err == nil && resp.StatusCode == http.StatusOK { + resp.Body.Close() + fmt.Println("Gitea is ready!") + return nil // Successo + } + + fmt.Printf("Attempt %d/%d: Gitea not ready yet (Status: %s, Error: %v). Retrying in %v...\n", i+1, maxAttempts, resp.Status, err, delay) + + if resp != nil { + resp.Body.Close() + } + time.Sleep(delay) + } + + return fmt.Errorf("Gitea failed to become ready after %v attempts", maxAttempts) +} From becde8bd8a5409c79cc6cb85201ac77d6957cb48 Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Mon, 24 Nov 2025 09:43:14 +0100 Subject: [PATCH 10/17] test: update Gitea port from 443 to 8443 in test configurations --- .../localresource/localresource_test.go | 24 +++++++++++-------- testdata/local_fromRef.yaml | 2 +- testdata/local_fromString.yaml | 6 +++-- testdata/local_fromString_override_false.yaml | 2 +- .../local_fromString_syncEnabled_false.yaml | 2 +- testdata/local_fromYaml.yaml | 2 +- 6 files changed, 22 insertions(+), 16 deletions(-) diff --git a/internal/controllers/localresource/localresource_test.go b/internal/controllers/localresource/localresource_test.go index 5d0986a..7d3b2fa 100644 --- a/internal/controllers/localresource/localresource_test.go +++ b/internal/controllers/localresource/localresource_test.go @@ -140,7 +140,7 @@ func TestMain(m *testing.M) { httpsPort: []network.PortBinding{ { HostIP: netip.AddrFrom4([4]byte{0, 0, 0, 0}), - HostPort: "3443", + HostPort: "8443", }, }, } @@ -161,7 +161,7 @@ func TestMain(m *testing.M) { // HTTPS with self-signed certs "GITEA__server__DOMAIN=localhost", "GITEA__server__HTTP_PORT=443", - "GITEA__server__ROOT_URL=https://localhost:3443", + "GITEA__server__ROOT_URL=https://localhost:8443", "GITEA__server__PROTOCOL=https", "GITEA__server__CERT_FILE=/data/cert.pem", "GITEA__server__KEY_FILE=/data/key.pem", @@ -221,7 +221,7 @@ func TestMain(m *testing.M) { containerId = resp.ID fmt.Printf("Container started successfully!\n") - fmt.Printf("Access Gitea at: https://localhost:3443\n") + fmt.Printf("Access Gitea at: https://localhost:8443\n") // Wait for Gitea to be ready err = waitForGitea(ctx) @@ -239,7 +239,7 @@ func TestMain(m *testing.M) { client := &http.Client{Transport: tr} // Con Basic Auth - req2, _ := http.NewRequest("GET", "https://localhost:3443/api/v1/user", nil) + req2, _ := http.NewRequest("GET", "https://localhost:8443/api/v1/user", nil) req2.SetBasicAuth("admin", "admin123") resp2, err := client.Do(req2) @@ -259,7 +259,7 @@ func TestMain(m *testing.M) { "auto_init": true, "readme": "Default" }` - req3, _ := http.NewRequest("POST", "https://localhost:3443/api/v1/user/repos", strings.NewReader(req3Body)) + req3, _ := http.NewRequest("POST", "https://localhost:8443/api/v1/user/repos", strings.NewReader(req3Body)) req3.Header.Set("Content-Type", "application/json") req3.SetBasicAuth(giteaAdmin, giteaAdminPassword) resp3, err := client.Do(req3) @@ -543,7 +543,7 @@ spec: } client := &http.Client{Transport: tr} repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") - url := fmt.Sprintf("https://localhost:3443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) + url := fmt.Sprintf("https://localhost:8443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) req, _ := http.NewRequest("GET", url, nil) req.SetBasicAuth("admin", "admin123") resp, err := client.Do(req) @@ -693,7 +693,7 @@ spec: } client := &http.Client{Transport: tr} repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") - url := fmt.Sprintf("https://localhost:3443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) + url := fmt.Sprintf("https://localhost:8443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) req, _ := http.NewRequest("GET", url, nil) req.SetBasicAuth("admin", "admin123") resp, err := client.Do(req) @@ -865,7 +865,7 @@ spec: } client := &http.Client{Transport: tr} repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") - url := fmt.Sprintf("https://localhost:3443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) + url := fmt.Sprintf("https://localhost:8443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) req, _ := http.NewRequest("GET", url, nil) req.SetBasicAuth("admin", "admin123") resp, err := client.Do(req) @@ -962,7 +962,7 @@ func waitForGitea(ctx context.Context) error { client := &http.Client{Transport: tr} // Con Basic Auth (necessario per l'endpoint /api/v1/user) - req, _ := http.NewRequest("GET", "https://localhost:3443/api/v1/user", nil) + req, _ := http.NewRequest("GET", "https://localhost:8443/api/v1/user", nil) req.SetBasicAuth("admin", "admin123") const maxAttempts = 60 @@ -976,7 +976,11 @@ func waitForGitea(ctx context.Context) error { return nil // Successo } - fmt.Printf("Attempt %d/%d: Gitea not ready yet (Status: %s, Error: %v). Retrying in %v...\n", i+1, maxAttempts, resp.Status, err, delay) + if resp != nil { + fmt.Printf("Attempt %d/%d: Gitea not ready yet (Status: %s, Error: %v). Retrying in %v...\n", i+1, maxAttempts, resp.Status, err, delay) + } else { + fmt.Printf("Attempt %d/%d: Gitea not ready yet (Error: %v). Retrying in %v...\n", i+1, maxAttempts, err, delay) + } if resp != nil { resp.Body.Close() diff --git a/testdata/local_fromRef.yaml b/testdata/local_fromRef.yaml index 000b7c4..f6d422c 100644 --- a/testdata/local_fromRef.yaml +++ b/testdata/local_fromRef.yaml @@ -32,4 +32,4 @@ spec: key: username name: gitea-repo-creds namespace: test-system - url: https://localhost:443/admin/test-repo.git + url: https://localhost:8443/admin/test-repo.git diff --git a/testdata/local_fromString.yaml b/testdata/local_fromString.yaml index 6c4666e..a6a5db9 100644 --- a/testdata/local_fromString.yaml +++ b/testdata/local_fromString.yaml @@ -24,7 +24,9 @@ spec: authMethod: generic branch: main placeholderTest: "{{ .example }}" - + + createCommitMessage: "chore: test create files in remote repository" + updateCommitMessage: "chore: test update files in remote repository" toRepo: branch: string @@ -40,4 +42,4 @@ spec: key: username name: gitea-repo-creds namespace: test-system - url: https://localhost:443/admin/test-repo.git \ No newline at end of file + url: https://localhost:8443/admin/test-repo.git \ No newline at end of file diff --git a/testdata/local_fromString_override_false.yaml b/testdata/local_fromString_override_false.yaml index 4615b19..8ae9b5c 100644 --- a/testdata/local_fromString_override_false.yaml +++ b/testdata/local_fromString_override_false.yaml @@ -41,4 +41,4 @@ spec: key: username name: gitea-repo-creds namespace: test-system - url: https://localhost:443/admin/test-repo.git \ No newline at end of file + url: https://localhost:8443/admin/test-repo.git \ No newline at end of file diff --git a/testdata/local_fromString_syncEnabled_false.yaml b/testdata/local_fromString_syncEnabled_false.yaml index 198acc6..c9e619c 100644 --- a/testdata/local_fromString_syncEnabled_false.yaml +++ b/testdata/local_fromString_syncEnabled_false.yaml @@ -41,5 +41,5 @@ spec: key: username name: gitea-repo-creds namespace: test-system - url: https://localhost:443/admin/test-repo.git + url: https://localhost:8443/admin/test-repo.git diff --git a/testdata/local_fromYaml.yaml b/testdata/local_fromYaml.yaml index 1a30d91..fea8b6c 100644 --- a/testdata/local_fromYaml.yaml +++ b/testdata/local_fromYaml.yaml @@ -41,5 +41,5 @@ spec: key: username name: gitea-repo-creds namespace: test-system - url: https://localhost:443/admin/test-repo.git + url: https://localhost:8443/admin/test-repo.git From 7d408545ca9d2a9c46a2d31fda04ea89cac40527 Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Fri, 28 Nov 2025 12:14:44 +0100 Subject: [PATCH 11/17] test: update Gitea URLs from localhost to 127.0.0.1 in test configurations --- .../localresource/localresource_test.go | 18 +++++++++--------- testdata/local_fromRef.yaml | 2 +- testdata/local_fromString.yaml | 2 +- testdata/local_fromString_override_false.yaml | 2 +- .../local_fromString_syncEnabled_false.yaml | 2 +- testdata/local_fromYaml.yaml | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/controllers/localresource/localresource_test.go b/internal/controllers/localresource/localresource_test.go index 7d3b2fa..6b425ec 100644 --- a/internal/controllers/localresource/localresource_test.go +++ b/internal/controllers/localresource/localresource_test.go @@ -159,9 +159,9 @@ func TestMain(m *testing.M) { "USER_GID=1000", // HTTPS with self-signed certs - "GITEA__server__DOMAIN=localhost", + "GITEA__server__DOMAIN=127.0.0.1", "GITEA__server__HTTP_PORT=443", - "GITEA__server__ROOT_URL=https://localhost:8443", + "GITEA__server__ROOT_URL=https://127.0.0.1:8443", "GITEA__server__PROTOCOL=https", "GITEA__server__CERT_FILE=/data/cert.pem", "GITEA__server__KEY_FILE=/data/key.pem", @@ -221,7 +221,7 @@ func TestMain(m *testing.M) { containerId = resp.ID fmt.Printf("Container started successfully!\n") - fmt.Printf("Access Gitea at: https://localhost:8443\n") + fmt.Printf("Access Gitea at: https://127.0.0.1:8443\n") // Wait for Gitea to be ready err = waitForGitea(ctx) @@ -239,7 +239,7 @@ func TestMain(m *testing.M) { client := &http.Client{Transport: tr} // Con Basic Auth - req2, _ := http.NewRequest("GET", "https://localhost:8443/api/v1/user", nil) + req2, _ := http.NewRequest("GET", "https://127.0.0.1:8443/api/v1/user", nil) req2.SetBasicAuth("admin", "admin123") resp2, err := client.Do(req2) @@ -259,7 +259,7 @@ func TestMain(m *testing.M) { "auto_init": true, "readme": "Default" }` - req3, _ := http.NewRequest("POST", "https://localhost:8443/api/v1/user/repos", strings.NewReader(req3Body)) + req3, _ := http.NewRequest("POST", "https://127.0.0.1:8443/api/v1/user/repos", strings.NewReader(req3Body)) req3.Header.Set("Content-Type", "application/json") req3.SetBasicAuth(giteaAdmin, giteaAdminPassword) resp3, err := client.Do(req3) @@ -543,7 +543,7 @@ spec: } client := &http.Client{Transport: tr} repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") - url := fmt.Sprintf("https://localhost:8443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) + url := fmt.Sprintf("https://127.0.0.1:8443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) req, _ := http.NewRequest("GET", url, nil) req.SetBasicAuth("admin", "admin123") resp, err := client.Do(req) @@ -693,7 +693,7 @@ spec: } client := &http.Client{Transport: tr} repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") - url := fmt.Sprintf("https://localhost:8443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) + url := fmt.Sprintf("https://127.0.0.1:8443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) req, _ := http.NewRequest("GET", url, nil) req.SetBasicAuth("admin", "admin123") resp, err := client.Do(req) @@ -865,7 +865,7 @@ spec: } client := &http.Client{Transport: tr} repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") - url := fmt.Sprintf("https://localhost:8443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) + url := fmt.Sprintf("https://127.0.0.1:8443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) req, _ := http.NewRequest("GET", url, nil) req.SetBasicAuth("admin", "admin123") resp, err := client.Do(req) @@ -962,7 +962,7 @@ func waitForGitea(ctx context.Context) error { client := &http.Client{Transport: tr} // Con Basic Auth (necessario per l'endpoint /api/v1/user) - req, _ := http.NewRequest("GET", "https://localhost:8443/api/v1/user", nil) + req, _ := http.NewRequest("GET", "https://127.0.0.1:8443/api/v1/user", nil) req.SetBasicAuth("admin", "admin123") const maxAttempts = 60 diff --git a/testdata/local_fromRef.yaml b/testdata/local_fromRef.yaml index f6d422c..f55246f 100644 --- a/testdata/local_fromRef.yaml +++ b/testdata/local_fromRef.yaml @@ -32,4 +32,4 @@ spec: key: username name: gitea-repo-creds namespace: test-system - url: https://localhost:8443/admin/test-repo.git + url: https://127.0.0.1:8443/admin/test-repo.git diff --git a/testdata/local_fromString.yaml b/testdata/local_fromString.yaml index a6a5db9..85762f7 100644 --- a/testdata/local_fromString.yaml +++ b/testdata/local_fromString.yaml @@ -42,4 +42,4 @@ spec: key: username name: gitea-repo-creds namespace: test-system - url: https://localhost:8443/admin/test-repo.git \ No newline at end of file + url: https://127.0.0.1:8443/admin/test-repo.git \ No newline at end of file diff --git a/testdata/local_fromString_override_false.yaml b/testdata/local_fromString_override_false.yaml index 8ae9b5c..f0d86d0 100644 --- a/testdata/local_fromString_override_false.yaml +++ b/testdata/local_fromString_override_false.yaml @@ -41,4 +41,4 @@ spec: key: username name: gitea-repo-creds namespace: test-system - url: https://localhost:8443/admin/test-repo.git \ No newline at end of file + url: https://127.0.0.1:8443/admin/test-repo.git \ No newline at end of file diff --git a/testdata/local_fromString_syncEnabled_false.yaml b/testdata/local_fromString_syncEnabled_false.yaml index c9e619c..872818d 100644 --- a/testdata/local_fromString_syncEnabled_false.yaml +++ b/testdata/local_fromString_syncEnabled_false.yaml @@ -41,5 +41,5 @@ spec: key: username name: gitea-repo-creds namespace: test-system - url: https://localhost:8443/admin/test-repo.git + url: https://127.0.0.1:8443/admin/test-repo.git diff --git a/testdata/local_fromYaml.yaml b/testdata/local_fromYaml.yaml index fea8b6c..a453ce0 100644 --- a/testdata/local_fromYaml.yaml +++ b/testdata/local_fromYaml.yaml @@ -41,5 +41,5 @@ spec: key: username name: gitea-repo-creds namespace: test-system - url: https://localhost:8443/admin/test-repo.git + url: https://127.0.0.1:8443/admin/test-repo.git From 6313e22245e4c947a9f380c0d01a2bbf6d0e74f6 Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Tue, 2 Dec 2025 11:30:03 +0100 Subject: [PATCH 12/17] test: set NetworkMode to host in TestMain for local resource tests --- internal/controllers/localresource/localresource_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/controllers/localresource/localresource_test.go b/internal/controllers/localresource/localresource_test.go index 6b425ec..fa2f12e 100644 --- a/internal/controllers/localresource/localresource_test.go +++ b/internal/controllers/localresource/localresource_test.go @@ -189,6 +189,7 @@ func TestMain(m *testing.M) { } hostConfig := &container.HostConfig{ + NetworkMode: "host", PortBindings: portBinding, RestartPolicy: container.RestartPolicy{ Name: "always", From 59b77c12df11dd957e83a6195663d0c54c322c0d Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Tue, 2 Dec 2025 11:31:42 +0100 Subject: [PATCH 13/17] test: update HostIP in port bindings to use localhost (127.0.0.1) --- internal/controllers/localresource/localresource_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/controllers/localresource/localresource_test.go b/internal/controllers/localresource/localresource_test.go index fa2f12e..c8c12ea 100644 --- a/internal/controllers/localresource/localresource_test.go +++ b/internal/controllers/localresource/localresource_test.go @@ -127,19 +127,19 @@ func TestMain(m *testing.M) { portBinding := network.PortMap{ containerPort: []network.PortBinding{ { - HostIP: netip.AddrFrom4([4]byte{0, 0, 0, 0}), + HostIP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), HostPort: "3000", }, }, sshPort: []network.PortBinding{ { - HostIP: netip.AddrFrom4([4]byte{0, 0, 0, 0}), + HostIP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), HostPort: "2222", }, }, httpsPort: []network.PortBinding{ { - HostIP: netip.AddrFrom4([4]byte{0, 0, 0, 0}), + HostIP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), HostPort: "8443", }, }, From bc539975b052bd80a2497acc42d838161c9ed697 Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Tue, 2 Dec 2025 12:17:31 +0100 Subject: [PATCH 14/17] test: refactor Gitea credentials to use variables for better maintainability --- .../localresource/localresource_test.go | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/internal/controllers/localresource/localresource_test.go b/internal/controllers/localresource/localresource_test.go index c8c12ea..f56a186 100644 --- a/internal/controllers/localresource/localresource_test.go +++ b/internal/controllers/localresource/localresource_test.go @@ -66,6 +66,11 @@ const ( namespace = "test-system" ) +var ( + giteaUsername = "admin" + giteaPassword = "admin123" +) + func TestMain(m *testing.M) { xenv.SetTestMode(true) @@ -87,9 +92,6 @@ func TestMain(m *testing.M) { var containerId string - giteaAdmin := "admin" - giteaAdminPassword := "admin123" - testenv.Setup( envfuncs.CreateCluster(kindCluster, clusterName), e2e.CreateNamespace(namespace), @@ -167,9 +169,6 @@ func TestMain(m *testing.M) { "GITEA__server__KEY_FILE=/data/key.pem", }, Entrypoint: []string{"/bin/sh", "-c"}, - // Cmd: []string{ - // fmt.Sprintf("echo 'su-exec git /usr/local/bin/gitea migrate' >> /etc/s6/gitea/setup\necho 'su-exec git /usr/local/bin/gitea admin user create --username '%s' --password '%s' --email admin@local --admin --must-change-password=false' >> /etc/s6/gitea/setup\n/usr/bin/entrypoint /usr/bin/s6-svscan /etc/s6", giteaAdmin, giteaAdminPassword), - // }, Cmd: []string{ fmt.Sprintf(` # Genera certificati self-signed se non esistono @@ -184,12 +183,12 @@ func TestMain(m *testing.M) { # Avvia Gitea /usr/bin/entrypoint /usr/bin/s6-svscan /etc/s6 - `, giteaAdmin, giteaAdminPassword), + `, giteaUsername, giteaPassword), }, } hostConfig := &container.HostConfig{ - NetworkMode: "host", + // NetworkMode: "host", PortBindings: portBinding, RestartPolicy: container.RestartPolicy{ Name: "always", @@ -241,7 +240,7 @@ func TestMain(m *testing.M) { // Con Basic Auth req2, _ := http.NewRequest("GET", "https://127.0.0.1:8443/api/v1/user", nil) - req2.SetBasicAuth("admin", "admin123") + req2.SetBasicAuth(giteaUsername, giteaPassword) resp2, err := client.Do(req2) if err != nil { @@ -262,7 +261,7 @@ func TestMain(m *testing.M) { }` req3, _ := http.NewRequest("POST", "https://127.0.0.1:8443/api/v1/user/repos", strings.NewReader(req3Body)) req3.Header.Set("Content-Type", "application/json") - req3.SetBasicAuth(giteaAdmin, giteaAdminPassword) + req3.SetBasicAuth(giteaUsername, giteaPassword) resp3, err := client.Do(req3) if err != nil { panic(err) @@ -546,7 +545,7 @@ spec: repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") url := fmt.Sprintf("https://127.0.0.1:8443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) req, _ := http.NewRequest("GET", url, nil) - req.SetBasicAuth("admin", "admin123") + req.SetBasicAuth(giteaUsername, giteaPassword) resp, err := client.Do(req) if err != nil { t.Fatal(err) @@ -696,7 +695,7 @@ spec: repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") url := fmt.Sprintf("https://127.0.0.1:8443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) req, _ := http.NewRequest("GET", url, nil) - req.SetBasicAuth("admin", "admin123") + req.SetBasicAuth(giteaUsername, giteaPassword) resp, err := client.Do(req) if err != nil { t.Fatal(err) @@ -868,7 +867,7 @@ spec: repoName := strings.TrimSuffix(strings.Split(res.Spec.ToRepo.Url, "/")[len(strings.Split(res.Spec.ToRepo.Url, "/"))-1], ".git") url := fmt.Sprintf("https://127.0.0.1:8443/api/v1/repos/admin/%s/contents/%s?ref=%s", repoName, res.Spec.FromResource.FileName, res.Spec.ToRepo.Branch) req, _ := http.NewRequest("GET", url, nil) - req.SetBasicAuth("admin", "admin123") + req.SetBasicAuth(giteaUsername, giteaPassword) resp, err := client.Do(req) if err != nil { t.Fatal(err) @@ -964,7 +963,7 @@ func waitForGitea(ctx context.Context) error { // Con Basic Auth (necessario per l'endpoint /api/v1/user) req, _ := http.NewRequest("GET", "https://127.0.0.1:8443/api/v1/user", nil) - req.SetBasicAuth("admin", "admin123") + req.SetBasicAuth(giteaUsername, giteaPassword) const maxAttempts = 60 const delay = 5 * time.Second From c6e6fd3fb301e695afb0d40cae0705e428ebcf4a Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Tue, 2 Dec 2025 12:37:10 +0100 Subject: [PATCH 15/17] test: enhance TestMain with container network settings and logging for better debugging --- .../localresource/localresource_test.go | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/internal/controllers/localresource/localresource_test.go b/internal/controllers/localresource/localresource_test.go index f56a186..3e79706 100644 --- a/internal/controllers/localresource/localresource_test.go +++ b/internal/controllers/localresource/localresource_test.go @@ -188,7 +188,6 @@ func TestMain(m *testing.M) { } hostConfig := &container.HostConfig{ - // NetworkMode: "host", PortBindings: portBinding, RestartPolicy: container.RestartPolicy{ Name: "always", @@ -214,6 +213,28 @@ func TestMain(m *testing.M) { fmt.Printf("Container created: %s\n", resp.ID) + // Print the network settings of the container + networkSettings, err := cli.ContainerInspect(ctx, resp.ID, client.ContainerInspectOptions{}) + if err != nil { + panic(err) + } + r, _ := networkSettings.Raw.MarshalJSON() + fmt.Printf("Network settings: %+v\n", r) + + // Print the container logs for debugging purposes + go func() { + logsReader, err := cli.ContainerLogs(ctx, resp.ID, client.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + }) + if err != nil { + panic(err) + } + defer logsReader.Close() + io.Copy(os.Stdout, logsReader) + }() + _, err = cli.ContainerStart(ctx, resp.ID, client.ContainerStartOptions{}) if err != nil { panic(err) From 85eacf9beab29c826f085dedffb0af1e0ffbb201 Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Tue, 2 Dec 2025 12:55:18 +0100 Subject: [PATCH 16/17] test: update TestMain to set ownership for /data and improve comments for clarity --- internal/controllers/localresource/localresource_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/controllers/localresource/localresource_test.go b/internal/controllers/localresource/localresource_test.go index 3e79706..f94aca7 100644 --- a/internal/controllers/localresource/localresource_test.go +++ b/internal/controllers/localresource/localresource_test.go @@ -171,7 +171,9 @@ func TestMain(m *testing.M) { Entrypoint: []string{"/bin/sh", "-c"}, Cmd: []string{ fmt.Sprintf(` - # Genera certificati self-signed se non esistono + chown -R 1000:1000 /data + + # Generate self-signed certificates if they don't exist if [ ! -f /data/cert.pem ]; then echo "Generating self-signed certificates..." cd /data && /usr/local/bin/gitea cert --host localhost,127.0.0.1 --ca @@ -181,7 +183,7 @@ func TestMain(m *testing.M) { echo 'su-exec git /usr/local/bin/gitea migrate' >> /etc/s6/gitea/setup echo 'su-exec git /usr/local/bin/gitea admin user create --username %s --password %s --email admin@local --admin --must-change-password=false' >> /etc/s6/gitea/setup - # Avvia Gitea + # Start Gitea /usr/bin/entrypoint /usr/bin/s6-svscan /etc/s6 `, giteaUsername, giteaPassword), }, From b7d15ce1fd340b97a30564e43e4445a63c5e3aa5 Mon Sep 17 00:00:00 2001 From: Matteo Gastaldello Date: Tue, 2 Dec 2025 14:45:49 +0100 Subject: [PATCH 17/17] test: update TestMain to ensure ownership of /data is set after certificate generation --- internal/controllers/localresource/localresource_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/controllers/localresource/localresource_test.go b/internal/controllers/localresource/localresource_test.go index f94aca7..85095d3 100644 --- a/internal/controllers/localresource/localresource_test.go +++ b/internal/controllers/localresource/localresource_test.go @@ -171,14 +171,15 @@ func TestMain(m *testing.M) { Entrypoint: []string{"/bin/sh", "-c"}, Cmd: []string{ fmt.Sprintf(` - chown -R 1000:1000 /data # Generate self-signed certificates if they don't exist if [ ! -f /data/cert.pem ]; then echo "Generating self-signed certificates..." cd /data && /usr/local/bin/gitea cert --host localhost,127.0.0.1 --ca fi - + + chown -R 1000:1000 /data + # Setup Gitea echo 'su-exec git /usr/local/bin/gitea migrate' >> /etc/s6/gitea/setup echo 'su-exec git /usr/local/bin/gitea admin user create --username %s --password %s --email admin@local --admin --must-change-password=false' >> /etc/s6/gitea/setup