From 4af8535b244f95bd7db62edc9eb25797c3d7603b Mon Sep 17 00:00:00 2001 From: Henry Sachs Date: Wed, 1 Apr 2026 08:39:50 +0200 Subject: [PATCH 1/3] feat: add repository file resource Signed-off-by: Henry Sachs --- PLAN_REPOSITORY_FILE.md | 322 +++++++++++++ .../v1alpha1/zz_generated.deepcopy.go | 202 +++++++++ .../projects/v1alpha1/zz_generated.managed.go | 50 ++ .../v1alpha1/zz_generated.managedlist.go | 9 + .../v1alpha1/zz_generated.resolvers.go | 27 ++ apis/cluster/projects/v1alpha1/zz_register.go | 9 + .../v1alpha1/zz_repositoryfile_types.go | 183 ++++++++ apis/namespaced/projects/v1alpha1/register.go | 9 + .../projects/v1alpha1/repositoryfile_types.go | 183 ++++++++ .../v1alpha1/zz_generated.deepcopy.go | 202 +++++++++ .../projects/v1alpha1/zz_generated.managed.go | 40 ++ .../v1alpha1/zz_generated.managedlist.go | 9 + .../v1alpha1/zz_generated.resolvers.go | 27 ++ examples/projects/repositoryfile.yaml | 21 + ....gitlab.crossplane.io_repositoryfiles.yaml | 428 ++++++++++++++++++ ...itlab.m.crossplane.io_repositoryfiles.yaml | 387 ++++++++++++++++ pkg/cluster/clients/projects/fake/zz_fake.go | 25 + .../clients/projects/zz_repositoryfile.go | 257 +++++++++++ .../projects/repositoryfiles/zz_controller.go | 303 +++++++++++++ pkg/cluster/controller/projects/zz_setup.go | 3 + pkg/namespaced/clients/projects/fake/fake.go | 25 + .../clients/projects/repositoryfile.go | 255 +++++++++++ .../clients/projects/repositoryfile_test.go | 240 ++++++++++ .../projects/repositoryfiles/controller.go | 301 ++++++++++++ .../repositoryfiles/controller_test.go | 170 +++++++ pkg/namespaced/controller/projects/setup.go | 3 + 26 files changed, 3690 insertions(+) create mode 100644 PLAN_REPOSITORY_FILE.md create mode 100644 apis/cluster/projects/v1alpha1/zz_repositoryfile_types.go create mode 100644 apis/namespaced/projects/v1alpha1/repositoryfile_types.go create mode 100644 examples/projects/repositoryfile.yaml create mode 100644 package/crds/projects.gitlab.crossplane.io_repositoryfiles.yaml create mode 100644 package/crds/projects.gitlab.m.crossplane.io_repositoryfiles.yaml create mode 100644 pkg/cluster/clients/projects/zz_repositoryfile.go create mode 100644 pkg/cluster/controller/projects/repositoryfiles/zz_controller.go create mode 100644 pkg/namespaced/clients/projects/repositoryfile.go create mode 100644 pkg/namespaced/clients/projects/repositoryfile_test.go create mode 100644 pkg/namespaced/controller/projects/repositoryfiles/controller.go create mode 100644 pkg/namespaced/controller/projects/repositoryfiles/controller_test.go diff --git a/PLAN_REPOSITORY_FILE.md b/PLAN_REPOSITORY_FILE.md new file mode 100644 index 00000000..adef0a24 --- /dev/null +++ b/PLAN_REPOSITORY_FILE.md @@ -0,0 +1,322 @@ +# RepositoryFile Managed Resource — Implementierungsplan + +## Problem + +Dateien in GitLab-Repos über Crossplane verwalten. Hauptprobleme: +1. Standard-Reconcile-Loop flutet GitLab API mit GetFile-Calls +2. Manche Dateien sollen einmalig erstellt und nie wieder angefasst werden +3. Verschiedene Dateien brauchen verschiedene Reconcile-Frequenzen (1h vs 8h) + +## Entscheidungen + +| Frage | Entscheidung | +|---|---| +| Reconcile-Kontrolle | Custom `reconcileInterval` per Resource + `createOnly` Flag (setzt intern managementPolicies) | +| Content-Quelle | Inline `content` + `contentSecretRef` (Secret als Kubernetes-Objekt für Dateiinhalt) | +| Project-Referenz | `projectIdRef`/`projectIdSelector` wie bei allen anderen Resources im Provider | +| Commit Messages | Getrennt pro Action: `createCommitMessage`, `updateCommitMessage`, `deleteCommitMessage` | +| Große Dateien | 1MB etcd-Limit reicht. S3-Referenz wäre Future Work | +| createOnly | Bool-Flag `createOnly: true` das intern `managementPolicies: ["Create", "Delete", "Observe"]` setzt — User-freundliche Abstraktion über managementPolicies | +| Konfliktregel | Wenn `createOnly` und explizite `managementPolicies` widersprechen: bevorzugt Validation Error, sonst gewinnen `managementPolicies` | + +## Reconcile-Kontrolle im Detail + +### Per-Resource `reconcileInterval` + +Feld `spec.forProvider.reconcileInterval` (z.B. `"1h"`, `"8h"`). + +Implementierung im `Observe()`: +- Status-Feld `status.atProvider.lastObserveTime` speichert Zeitpunkt des letzten echten API-Calls +- Wenn `now - lastObserveTime < reconcileInterval` → return `ResourceExists: true, ResourceUpToDate: true` ohne API-Call +- Erst wenn Interval abgelaufen → tatsächlich `GetFile` aufrufen +- Default: Controller-globales Poll-Interval (wenn Feld nicht gesetzt) + +### `createOnly` Flag + +Feld `spec.forProvider.createOnly` (bool, default false). + +Implementierung: +- Wenn `createOnly: true` → Controller verhält sich wie `managementPolicies: ["Create", "Delete", "Observe"]` +- Konkret: `Observe()` prüft nur Existenz (kein Content-Vergleich), `Update()` wird nie aufgerufen +- User muss managementPolicies nicht kennen +- Kann mit `reconcileInterval` kombiniert werden (z.B. `createOnly: true` + `reconcileInterval: "24h"` = einmal am Tag prüfen ob Datei noch existiert) + +### Konfliktregel: `createOnly` vs `managementPolicies` + +Wenn ein User sowohl `createOnly: true` als auch explizite `managementPolicies` setzt, die nicht zu +`["Create", "Delete", "Observe"]` passen: + +- bevorzugt: Validation Error im CRD / Admission-Pfad +- fallback falls saubere Validation zu aufwendig ist: explizite `managementPolicies` gewinnen +- nie stillschweigend `managementPolicies` überschreiben + +## CRD Design + +```yaml +apiVersion: projects.gitlab.m.crossplane.io/v1alpha1 +kind: RepositoryFile +metadata: + name: my-readme +spec: + forProvider: + # --- Identifikation --- + projectId: 12345 + projectIdRef: # Cross-Resource-Referenz auf Project CR + name: my-project + projectIdSelector: # Label-basierte Selektion + matchLabels: + team: platform + + filePath: "README.md" # +immutable + branch: "main" # +immutable + + # --- Content --- + content: "# My Project" # inline, mutually exclusive mit contentSecretRef + contentSecretRef: # Content aus Secret laden + name: my-file-secret + key: readme-content + encoding: "text" # "text" (default) | "base64" + + # --- Git Commit Metadata (pro Action) --- + createCommitMessage: "feat: initial file creation by crossplane" + updateCommitMessage: "chore: update file content via crossplane" + deleteCommitMessage: "chore: remove crossplane-managed file" + authorEmail: "crossplane@example.com" + authorName: "Crossplane" + executeFilemode: false + + # --- Reconcile-Kontrolle --- + reconcileInterval: "1h" # optional, per-resource poll interval + createOnly: true # optional, default false — erstellt Datei einmalig, updated nie + + providerConfigRef: + name: gitlab-config + +status: + atProvider: + filePath: "README.md" + blobId: "abc123" + commitId: "def456" + lastCommitId: "ghi789" + contentSha256: "e3b0c44..." + size: 42 + lastObserveTime: "2026-03-31T12:00:00Z" +``` + +## Go Types (Entwurf) + +```go +// RepositoryFileParameters define desired state of a GitLab Repository File +type RepositoryFileParameters struct { + // ProjectID is the ID of the project. + // +optional + // +immutable + ProjectID *int64 `json:"projectId,omitempty"` + + // ProjectIDRef is a reference to a project to retrieve its projectId. + // +optional + // +immutable + ProjectIDRef *xpv1.NamespacedReference `json:"projectIdRef,omitempty"` + + // ProjectIDSelector selects reference to a project to retrieve its projectId. + // +optional + ProjectIDSelector *xpv1.NamespacedSelector `json:"projectIdSelector,omitempty"` + + // FilePath is the path of the file in the repository. + // +immutable + // +kubebuilder:validation:MinLength=1 + FilePath string `json:"filePath"` + + // Branch is the name of the branch to commit to. + // +immutable + // +kubebuilder:validation:MinLength=1 + Branch string `json:"branch"` + + // Content is the file content. Mutually exclusive with ContentSecretRef. + // +optional + Content *string `json:"content,omitempty"` + + // ContentSecretRef references a Secret key containing the file content. + // Mutually exclusive with Content. + // +optional + ContentSecretRef *xpv1.LocalSecretKeySelector `json:"contentSecretRef,omitempty"` + + // Encoding is the file encoding: "text" (default) or "base64". + // +optional + // +kubebuilder:validation:Enum=text;base64 + // +kubebuilder:default="text" + Encoding *string `json:"encoding,omitempty"` + + // CreateCommitMessage is the commit message used when creating the file. + // +optional + CreateCommitMessage *string `json:"createCommitMessage,omitempty"` + + // UpdateCommitMessage is the commit message used when updating the file. + // +optional + UpdateCommitMessage *string `json:"updateCommitMessage,omitempty"` + + // DeleteCommitMessage is the commit message used when deleting the file. + // +optional + DeleteCommitMessage *string `json:"deleteCommitMessage,omitempty"` + + // AuthorEmail is the commit author email. + // +optional + AuthorEmail *string `json:"authorEmail,omitempty"` + + // AuthorName is the commit author name. + // +optional + AuthorName *string `json:"authorName,omitempty"` + + // ExecuteFilemode enables the executable flag on the file. + // +optional + ExecuteFilemode *bool `json:"executeFilemode,omitempty"` + + // ReconcileInterval controls how often this resource is reconciled against + // the GitLab API. Examples: "5m", "1h", "8h". If unset, uses the controller + // default poll interval. + // +optional + ReconcileInterval *string `json:"reconcileInterval,omitempty"` + + // CreateOnly when true, creates the file once and never updates it. + // The file is still deleted from GitLab when the CR is deleted. + // Internally sets managementPolicies to ["Create", "Delete", "Observe"]. + // +optional + // +kubebuilder:default=false + CreateOnly *bool `json:"createOnly,omitempty"` +} + +// RepositoryFileObservation represents observed state of a GitLab Repository File +type RepositoryFileObservation struct { + FilePath string `json:"filePath,omitempty"` + BlobID string `json:"blobId,omitempty"` + CommitID string `json:"commitId,omitempty"` + LastCommitID string `json:"lastCommitId,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Size int64 `json:"size,omitempty"` + LastObserveTime *metav1.Time `json:"lastObserveTime,omitempty"` +} +``` + +## Dateien die erstellt/geändert werden + +### Neue Dateien (nur namespaced — cluster wird via `make generate` erzeugt) + +| # | Datei | Beschreibung | +|---|---|---| +| 1 | `apis/namespaced/projects/v1alpha1/repositoryfile_types.go` | CRD Types | +| 2 | `pkg/namespaced/clients/projects/repositoryfile.go` | GitLab Client Wrapper + Helpers | +| 3 | `pkg/namespaced/clients/projects/repositoryfile_test.go` | Client Helper Tests | +| 4 | `pkg/namespaced/controller/projects/repositoryfiles/controller.go` | Reconciler | +| 5 | `pkg/namespaced/controller/projects/repositoryfiles/controller_test.go` | Controller Tests | +| 6 | `examples/projects/repositoryfile.yaml` | Beispiel-Manifest | + +### Geänderte Dateien + +| # | Datei | Änderung | +|---|---|---| +| 7 | `apis/namespaced/projects/v1alpha1/register.go` | RepositoryFile + RepositoryFileList registrieren | +| 8 | `pkg/namespaced/clients/projects/fake/fake.go` | Mock-Methods für RepositoryFile | +| 9 | `pkg/namespaced/controller/projects/setup.go` | `repositoryfiles.SetupRepositoryFile` + Gated registrieren | + +### Generierte Dateien (via `make generate`) + +- `apis/cluster/projects/v1alpha1/zz_repositoryfile_types.go` +- `pkg/cluster/clients/projects/zz_repositoryfile.go` +- `pkg/cluster/controller/projects/repositoryfiles/zz_controller.go` +- `apis/*/projects/v1alpha1/zz_generated.deepcopy.go` +- `apis/*/projects/v1alpha1/zz_generated.managed.go` +- `package/crds/projects.gitlab.*.crossplane.io_repositoryfiles.yaml` + +## Controller-Logik + +### Observe + +``` +1. Prüfe projectID vorhanden +2. Prüfe reconcileInterval: + - Parse reconcileInterval als time.Duration + - Wenn status.atProvider.lastObserveTime + reconcileInterval > now: + → return ResourceExists: true, ResourceUpToDate: true (KEIN API call) +3. GetFile(projectID, filePath, {Ref: branch}) + - 404 → ResourceExists: false + - Error → return error +4. Wenn createOnly == true: + → ResourceExists: true, ResourceUpToDate: true (Existenz reicht) +5. Sonst: Vergleiche content_sha256 aus GitLab Response mit SHA256 von spec content + - Match → ResourceUpToDate: true + - Mismatch → ResourceUpToDate: false +6. Update status.atProvider (blobId, commitId, sha256, size, lastObserveTime) +7. LateInitialize: encoding, executeFilemode +``` + +### Create + +``` +1. Resolve content (inline oder aus Secret via contentSecretRef) +2. CreateFile(projectID, filePath, { + Branch, Content, + CommitMessage: createCommitMessage (default: "crossplane: create "), + Encoding, AuthorEmail, AuthorName, ExecuteFilemode + }) +3. Set external-name annotation = filePath +``` + +### Update + +``` +1. Wenn createOnly == true → sollte nie aufgerufen werden (Observe returns UpToDate) +2. Resolve content +3. UpdateFile(projectID, filePath, { + Branch, Content, + CommitMessage: updateCommitMessage (default: "crossplane: update "), + Encoding, AuthorEmail, AuthorName, ExecuteFilemode, + LastCommitID: status.atProvider.lastCommitId (optimistic locking) + }) +``` + +### Delete + +``` +1. DeleteFile(projectID, filePath, { + Branch, + CommitMessage: deleteCommitMessage (default: "crossplane: delete "), + AuthorEmail, AuthorName + }) +``` + +## External Name + +`crossplane.io/external-name` = `filePath`. Unique Key = projectID + branch + filePath. + +## GitLab Client Interface + +```go +type RepositoryFileClient interface { + GetFile(pid any, fileName string, opt *gitlab.GetFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.File, *gitlab.Response, error) + CreateFile(pid any, fileName string, opt *gitlab.CreateFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.FileInfo, *gitlab.Response, error) + UpdateFile(pid any, fileName string, opt *gitlab.UpdateFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.FileInfo, *gitlab.Response, error) + DeleteFile(pid any, fileName string, opt *gitlab.DeleteFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) +} +``` + +Konstruktor: `NewRepositoryFileClient(cfg) → git.RepositoryFiles` + +## Codebase Patterns zu beachten + +- Alle Types in `apis/namespaced/` schreiben, `hack/generate-cluster-scope.go` erzeugt cluster-Variante +- `xpv2.ManagedResourceSpec` für namespaced (wird zu `xpv1.ResourceSpec` in cluster) +- `xpv1.NamespacedReference` für Refs (wird zu `xpv1.Reference` in cluster) +- `xpv1.LocalSecretKeySelector` für Secrets (wird zu `xpv1.SecretKeySelector` in cluster) +- Controller braucht `Setup` + `SetupGated` Funktionen +- `managed.WithManagementPolicies()` in Setup wenn Feature-Flag aktiv +- `managed.WithPollInterval(o.PollInterval)` für globales Interval +- Fake Client in `fake/fake.go` mit `Mock*` Feldern +- Tests nutzen `test.MockClient` und table-driven Tests + +## Build & Verify + +```bash +make generate # generiert cluster-scope, deepcopy, CRDs, managed methodsets +make build # kompiliert alles +make test # unit tests +``` diff --git a/apis/cluster/projects/v1alpha1/zz_generated.deepcopy.go b/apis/cluster/projects/v1alpha1/zz_generated.deepcopy.go index 3502683b..204662f6 100644 --- a/apis/cluster/projects/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/projects/v1alpha1/zz_generated.deepcopy.go @@ -3263,6 +3263,208 @@ func (in *PushRules) DeepCopy() *PushRules { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryFile) DeepCopyInto(out *RepositoryFile) { + *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 RepositoryFile. +func (in *RepositoryFile) DeepCopy() *RepositoryFile { + if in == nil { + return nil + } + out := new(RepositoryFile) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RepositoryFile) 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 *RepositoryFileList) DeepCopyInto(out *RepositoryFileList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]RepositoryFile, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryFileList. +func (in *RepositoryFileList) DeepCopy() *RepositoryFileList { + if in == nil { + return nil + } + out := new(RepositoryFileList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RepositoryFileList) 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 *RepositoryFileObservation) DeepCopyInto(out *RepositoryFileObservation) { + *out = *in + if in.LastObserveTime != nil { + in, out := &in.LastObserveTime, &out.LastObserveTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryFileObservation. +func (in *RepositoryFileObservation) DeepCopy() *RepositoryFileObservation { + if in == nil { + return nil + } + out := new(RepositoryFileObservation) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryFileParameters) DeepCopyInto(out *RepositoryFileParameters) { + *out = *in + if in.ProjectID != nil { + in, out := &in.ProjectID, &out.ProjectID + *out = new(string) + **out = **in + } + if in.ProjectIDRef != nil { + in, out := &in.ProjectIDRef, &out.ProjectIDRef + *out = new(v1.Reference) + (*in).DeepCopyInto(*out) + } + if in.ProjectIDSelector != nil { + in, out := &in.ProjectIDSelector, &out.ProjectIDSelector + *out = new(v1.Selector) + (*in).DeepCopyInto(*out) + } + if in.Content != nil { + in, out := &in.Content, &out.Content + *out = new(string) + **out = **in + } + if in.ContentSecretRef != nil { + in, out := &in.ContentSecretRef, &out.ContentSecretRef + *out = new(v1.SecretKeySelector) + **out = **in + } + if in.StartBranch != nil { + in, out := &in.StartBranch, &out.StartBranch + *out = new(string) + **out = **in + } + if in.Encoding != nil { + in, out := &in.Encoding, &out.Encoding + *out = new(string) + **out = **in + } + if in.CreateCommitMessage != nil { + in, out := &in.CreateCommitMessage, &out.CreateCommitMessage + *out = new(string) + **out = **in + } + if in.UpdateCommitMessage != nil { + in, out := &in.UpdateCommitMessage, &out.UpdateCommitMessage + *out = new(string) + **out = **in + } + if in.DeleteCommitMessage != nil { + in, out := &in.DeleteCommitMessage, &out.DeleteCommitMessage + *out = new(string) + **out = **in + } + if in.AuthorEmail != nil { + in, out := &in.AuthorEmail, &out.AuthorEmail + *out = new(string) + **out = **in + } + if in.AuthorName != nil { + in, out := &in.AuthorName, &out.AuthorName + *out = new(string) + **out = **in + } + if in.ExecuteFilemode != nil { + in, out := &in.ExecuteFilemode, &out.ExecuteFilemode + *out = new(bool) + **out = **in + } + if in.ReconcileInterval != nil { + in, out := &in.ReconcileInterval, &out.ReconcileInterval + *out = new(string) + **out = **in + } + if in.CreateOnly != nil { + in, out := &in.CreateOnly, &out.CreateOnly + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryFileParameters. +func (in *RepositoryFileParameters) DeepCopy() *RepositoryFileParameters { + if in == nil { + return nil + } + out := new(RepositoryFileParameters) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryFileSpec) DeepCopyInto(out *RepositoryFileSpec) { + *out = *in + in.ResourceSpec.DeepCopyInto(&out.ResourceSpec) + in.ForProvider.DeepCopyInto(&out.ForProvider) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryFileSpec. +func (in *RepositoryFileSpec) DeepCopy() *RepositoryFileSpec { + if in == nil { + return nil + } + out := new(RepositoryFileSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryFileStatus) DeepCopyInto(out *RepositoryFileStatus) { + *out = *in + in.ResourceStatus.DeepCopyInto(&out.ResourceStatus) + in.AtProvider.DeepCopyInto(&out.AtProvider) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryFileStatus. +func (in *RepositoryFileStatus) DeepCopy() *RepositoryFileStatus { + if in == nil { + return nil + } + out := new(RepositoryFileStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Runner) DeepCopyInto(out *Runner) { *out = *in diff --git a/apis/cluster/projects/v1alpha1/zz_generated.managed.go b/apis/cluster/projects/v1alpha1/zz_generated.managed.go index 98274fa5..a3ce2d6b 100644 --- a/apis/cluster/projects/v1alpha1/zz_generated.managed.go +++ b/apis/cluster/projects/v1alpha1/zz_generated.managed.go @@ -670,6 +670,56 @@ func (mg *ProtectedEnvironment) SetWriteConnectionSecretToReference(r *xpv1.Secr mg.Spec.WriteConnectionSecretToReference = r } +// GetCondition of this RepositoryFile. +func (mg *RepositoryFile) GetCondition(ct xpv1.ConditionType) xpv1.Condition { + return mg.Status.GetCondition(ct) +} + +// GetDeletionPolicy of this RepositoryFile. +func (mg *RepositoryFile) GetDeletionPolicy() xpv1.DeletionPolicy { + return mg.Spec.DeletionPolicy +} + +// GetManagementPolicies of this RepositoryFile. +func (mg *RepositoryFile) GetManagementPolicies() xpv1.ManagementPolicies { + return mg.Spec.ManagementPolicies +} + +// GetProviderConfigReference of this RepositoryFile. +func (mg *RepositoryFile) GetProviderConfigReference() *xpv1.Reference { + return mg.Spec.ProviderConfigReference +} + +// GetWriteConnectionSecretToReference of this RepositoryFile. +func (mg *RepositoryFile) GetWriteConnectionSecretToReference() *xpv1.SecretReference { + return mg.Spec.WriteConnectionSecretToReference +} + +// SetConditions of this RepositoryFile. +func (mg *RepositoryFile) SetConditions(c ...xpv1.Condition) { + mg.Status.SetConditions(c...) +} + +// SetDeletionPolicy of this RepositoryFile. +func (mg *RepositoryFile) SetDeletionPolicy(r xpv1.DeletionPolicy) { + mg.Spec.DeletionPolicy = r +} + +// SetManagementPolicies of this RepositoryFile. +func (mg *RepositoryFile) SetManagementPolicies(r xpv1.ManagementPolicies) { + mg.Spec.ManagementPolicies = r +} + +// SetProviderConfigReference of this RepositoryFile. +func (mg *RepositoryFile) SetProviderConfigReference(r *xpv1.Reference) { + mg.Spec.ProviderConfigReference = r +} + +// SetWriteConnectionSecretToReference of this RepositoryFile. +func (mg *RepositoryFile) SetWriteConnectionSecretToReference(r *xpv1.SecretReference) { + mg.Spec.WriteConnectionSecretToReference = r +} + // GetCondition of this Runner. func (mg *Runner) GetCondition(ct xpv1.ConditionType) xpv1.Condition { return mg.Status.GetCondition(ct) diff --git a/apis/cluster/projects/v1alpha1/zz_generated.managedlist.go b/apis/cluster/projects/v1alpha1/zz_generated.managedlist.go index a5d29da0..7df9bb15 100644 --- a/apis/cluster/projects/v1alpha1/zz_generated.managedlist.go +++ b/apis/cluster/projects/v1alpha1/zz_generated.managedlist.go @@ -137,6 +137,15 @@ func (l *ProtectedEnvironmentList) GetItems() []resource.Managed { return items } +// GetItems of this RepositoryFileList. +func (l *RepositoryFileList) GetItems() []resource.Managed { + items := make([]resource.Managed, len(l.Items)) + for i := range l.Items { + items[i] = &l.Items[i] + } + return items +} + // GetItems of this RunnerList. func (l *RunnerList) GetItems() []resource.Managed { items := make([]resource.Managed, len(l.Items)) diff --git a/apis/cluster/projects/v1alpha1/zz_generated.resolvers.go b/apis/cluster/projects/v1alpha1/zz_generated.resolvers.go index fa500f92..2075bdd7 100644 --- a/apis/cluster/projects/v1alpha1/zz_generated.resolvers.go +++ b/apis/cluster/projects/v1alpha1/zz_generated.resolvers.go @@ -204,3 +204,30 @@ func (mg *ProtectedEnvironment) ResolveReferences(ctx context.Context, c client. return nil } + +// ResolveReferences of this RepositoryFile. +func (mg *RepositoryFile) ResolveReferences(ctx context.Context, c client.Reader) error { + r := reference.NewAPIResolver(c, mg) + + var rsp reference.ResolutionResponse + var err error + + rsp, err = r.Resolve(ctx, reference.ResolutionRequest{ + CurrentValue: reference.FromPtrValue(mg.Spec.ForProvider.ProjectID), + Extract: reference.ExternalName(), + Namespace: mg.GetNamespace(), + Reference: mg.Spec.ForProvider.ProjectIDRef, + Selector: mg.Spec.ForProvider.ProjectIDSelector, + To: reference.To{ + List: &ProjectList{}, + Managed: &Project{}, + }, + }) + if err != nil { + return errors.Wrap(err, "mg.Spec.ForProvider.ProjectID") + } + mg.Spec.ForProvider.ProjectID = reference.ToPtrValue(rsp.ResolvedValue) + mg.Spec.ForProvider.ProjectIDRef = rsp.ResolvedReference + + return nil +} diff --git a/apis/cluster/projects/v1alpha1/zz_register.go b/apis/cluster/projects/v1alpha1/zz_register.go index f509eaad..140c69f0 100644 --- a/apis/cluster/projects/v1alpha1/zz_register.go +++ b/apis/cluster/projects/v1alpha1/zz_register.go @@ -95,6 +95,14 @@ var ( VariableGroupVersionKind = SchemeGroupVersion.WithKind(VariableKind) ) +// RepositoryFile type metadata +var ( + RepositoryFileKind = reflect.TypeOf(RepositoryFile{}).Name() + RepositoryFileGroupKind = schema.GroupKind{Group: Group, Kind: RepositoryFileKind}.String() + RepositoryFileKindAPIVersion = RepositoryFileKind + "." + SchemeGroupVersion.String() + RepositoryFileGroupVersionKind = SchemeGroupVersion.WithKind(RepositoryFileKind) +) + // Deploy Key type metadata var ( DeployKeyKind = reflect.TypeOf(DeployKey{}).Name() @@ -165,6 +173,7 @@ func init() { SchemeBuilder.Register(&ApprovalRule{}, &ApprovalRuleList{}) SchemeBuilder.Register(&DeployToken{}, &DeployTokenList{}) SchemeBuilder.Register(&Variable{}, &VariableList{}) + SchemeBuilder.Register(&RepositoryFile{}, &RepositoryFileList{}) SchemeBuilder.Register(&DeployKey{}, &DeployKeyList{}) SchemeBuilder.Register(&AccessToken{}, &AccessTokenList{}) SchemeBuilder.Register(&PipelineSchedule{}, &PipelineScheduleList{}) diff --git a/apis/cluster/projects/v1alpha1/zz_repositoryfile_types.go b/apis/cluster/projects/v1alpha1/zz_repositoryfile_types.go new file mode 100644 index 00000000..d79940f0 --- /dev/null +++ b/apis/cluster/projects/v1alpha1/zz_repositoryfile_types.go @@ -0,0 +1,183 @@ +/* +Copyright 2024 The Crossplane Authors. + +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 hack/generate-cluster-scope.go - DO NOT EDIT. + +package v1alpha1 + +import ( + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// RepositoryFileParameters define the desired state of a GitLab Repository File. +// https://docs.gitlab.com/api/repository_files/ +type RepositoryFileParameters struct { + // The ID or URL-encoded path of the project owned by the authenticated user. + // +optional + // +immutable + // +crossplane:generate:reference:type=github.com/crossplane-contrib/provider-gitlab/apis/cluster/projects/v1alpha1.Project + // +crossplane:generate:reference:refFieldName=ProjectIDRef + // +crossplane:generate:reference:selectorFieldName=ProjectIDSelector + ProjectID *string `json:"projectId,omitempty"` + + // ProjectIDRef is a reference to a project to retrieve its projectId. + // +optional + // +immutable + ProjectIDRef *xpv1.Reference `json:"projectIdRef,omitempty"` + + // ProjectIDSelector selects reference to a project to retrieve its projectId. + // +optional + // +immutable + ProjectIDSelector *xpv1.Selector `json:"projectIdSelector,omitempty"` + + // FilePath is the path of the file in the repository. + // +immutable + // +kubebuilder:validation:MinLength=1 + FilePath string `json:"filePath"` + + // Branch is the name of the branch to commit to. + // +immutable + // +kubebuilder:validation:MinLength=1 + Branch string `json:"branch"` + + // Content is the file content. Mutually exclusive with ContentSecretRef. + // +optional + Content *string `json:"content,omitempty"` + + // ContentSecretRef references a Secret key containing the file content. + // Mutually exclusive with Content. + // +optional + // +nullable + ContentSecretRef *xpv1.SecretKeySelector `json:"contentSecretRef,omitempty"` + + // StartBranch is the source branch used to create branch if Branch does not exist yet. + // +optional + // +immutable + StartBranch *string `json:"startBranch,omitempty"` + + // Encoding is the file encoding: "text" (default) or "base64". + // +optional + // +kubebuilder:validation:Enum=text;base64 + // +kubebuilder:default="text" + Encoding *string `json:"encoding,omitempty"` + + // CreateCommitMessage is the commit message used when creating the file. + // Defaults to "crossplane: create ". + // +optional + CreateCommitMessage *string `json:"createCommitMessage,omitempty"` + + // UpdateCommitMessage is the commit message used when updating the file. + // Defaults to "crossplane: update ". + // +optional + UpdateCommitMessage *string `json:"updateCommitMessage,omitempty"` + + // DeleteCommitMessage is the commit message used when deleting the file. + // Defaults to "crossplane: delete ". + // +optional + DeleteCommitMessage *string `json:"deleteCommitMessage,omitempty"` + + // AuthorEmail is the commit author email. + // +optional + AuthorEmail *string `json:"authorEmail,omitempty"` + + // AuthorName is the commit author name. + // +optional + AuthorName *string `json:"authorName,omitempty"` + + // ExecuteFilemode enables the executable flag on the file. + // +optional + ExecuteFilemode *bool `json:"executeFilemode,omitempty"` + + // ReconcileInterval controls how often this resource is reconciled against + // the GitLab API. Examples: "5m", "1h", "8h". If unset, the resource is + // reconciled at the controller's default poll interval. + // +optional + ReconcileInterval *string `json:"reconcileInterval,omitempty"` + + // CreateOnly when true, creates the file once and never updates it. + // The file is still deleted from GitLab when the CR is deleted. + // Observe only checks for file existence, not content drift. + // +optional + // +kubebuilder:default=false + CreateOnly *bool `json:"createOnly,omitempty"` +} + +// RepositoryFileObservation represents the observed state of a GitLab Repository File. +type RepositoryFileObservation struct { + // FilePath is the observed file path. + FilePath string `json:"filePath,omitempty"` + + // BlobID is the blob SHA of the file. + BlobID string `json:"blobId,omitempty"` + + // CommitID is the commit SHA when the file was last read. + CommitID string `json:"commitId,omitempty"` + + // LastCommitID is the last commit SHA that modified this file. + LastCommitID string `json:"lastCommitId,omitempty"` + + // SHA256 is the SHA-256 hash of the file content. + SHA256 string `json:"sha256,omitempty"` + + // Size is the file size in bytes. + Size int64 `json:"size,omitempty"` + + // LastObserveTime is the timestamp of the last successful observe + // that made an actual API call to GitLab. Used for reconcileInterval. + LastObserveTime *metav1.Time `json:"lastObserveTime,omitempty"` +} + +// A RepositoryFileSpec defines the desired state of a GitLab Repository File. +type RepositoryFileSpec struct { + xpv1.ResourceSpec `json:",inline"` + // ForProvider specifies the desired state of the RepositoryFile. + ForProvider RepositoryFileParameters `json:"forProvider"` +} + +// A RepositoryFileStatus represents the observed state of a GitLab Repository File. +type RepositoryFileStatus struct { + xpv1.ResourceStatus `json:",inline"` + // AtProvider reflects the observed state from GitLab. + AtProvider RepositoryFileObservation `json:"atProvider,omitempty"` +} + +// +kubebuilder:object:root=true + +// A RepositoryFile is a managed resource that represents a file in a GitLab repository. +// +kubebuilder:printcolumn:name="FILE",type="string",JSONPath=".spec.forProvider.filePath" +// +kubebuilder:printcolumn:name="BRANCH",type="string",JSONPath=".spec.forProvider.branch" +// +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=Cluster,categories={crossplane,managed,gitlab} +type RepositoryFile struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec RepositoryFileSpec `json:"spec"` + Status RepositoryFileStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// RepositoryFileList contains a list of RepositoryFile items. +type RepositoryFileList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []RepositoryFile `json:"items"` +} diff --git a/apis/namespaced/projects/v1alpha1/register.go b/apis/namespaced/projects/v1alpha1/register.go index 8d7db348..8aec9d8d 100644 --- a/apis/namespaced/projects/v1alpha1/register.go +++ b/apis/namespaced/projects/v1alpha1/register.go @@ -93,6 +93,14 @@ var ( VariableGroupVersionKind = SchemeGroupVersion.WithKind(VariableKind) ) +// RepositoryFile type metadata +var ( + RepositoryFileKind = reflect.TypeOf(RepositoryFile{}).Name() + RepositoryFileGroupKind = schema.GroupKind{Group: Group, Kind: RepositoryFileKind}.String() + RepositoryFileKindAPIVersion = RepositoryFileKind + "." + SchemeGroupVersion.String() + RepositoryFileGroupVersionKind = SchemeGroupVersion.WithKind(RepositoryFileKind) +) + // Deploy Key type metadata var ( DeployKeyKind = reflect.TypeOf(DeployKey{}).Name() @@ -163,6 +171,7 @@ func init() { SchemeBuilder.Register(&ApprovalRule{}, &ApprovalRuleList{}) SchemeBuilder.Register(&DeployToken{}, &DeployTokenList{}) SchemeBuilder.Register(&Variable{}, &VariableList{}) + SchemeBuilder.Register(&RepositoryFile{}, &RepositoryFileList{}) SchemeBuilder.Register(&DeployKey{}, &DeployKeyList{}) SchemeBuilder.Register(&AccessToken{}, &AccessTokenList{}) SchemeBuilder.Register(&PipelineSchedule{}, &PipelineScheduleList{}) diff --git a/apis/namespaced/projects/v1alpha1/repositoryfile_types.go b/apis/namespaced/projects/v1alpha1/repositoryfile_types.go new file mode 100644 index 00000000..3e81237c --- /dev/null +++ b/apis/namespaced/projects/v1alpha1/repositoryfile_types.go @@ -0,0 +1,183 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + // +cluster-scope:delete=1 + xpv2 "github.com/crossplane/crossplane-runtime/v2/apis/common/v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// RepositoryFileParameters define the desired state of a GitLab Repository File. +// https://docs.gitlab.com/api/repository_files/ +type RepositoryFileParameters struct { + // The ID or URL-encoded path of the project owned by the authenticated user. + // +optional + // +immutable + // +crossplane:generate:reference:type=github.com/crossplane-contrib/provider-gitlab/apis/namespaced/projects/v1alpha1.Project + // +crossplane:generate:reference:refFieldName=ProjectIDRef + // +crossplane:generate:reference:selectorFieldName=ProjectIDSelector + ProjectID *string `json:"projectId,omitempty"` + + // ProjectIDRef is a reference to a project to retrieve its projectId. + // +optional + // +immutable + ProjectIDRef *xpv1.NamespacedReference `json:"projectIdRef,omitempty"` + + // ProjectIDSelector selects reference to a project to retrieve its projectId. + // +optional + // +immutable + ProjectIDSelector *xpv1.NamespacedSelector `json:"projectIdSelector,omitempty"` + + // FilePath is the path of the file in the repository. + // +immutable + // +kubebuilder:validation:MinLength=1 + FilePath string `json:"filePath"` + + // Branch is the name of the branch to commit to. + // +immutable + // +kubebuilder:validation:MinLength=1 + Branch string `json:"branch"` + + // Content is the file content. Mutually exclusive with ContentSecretRef. + // +optional + Content *string `json:"content,omitempty"` + + // ContentSecretRef references a Secret key containing the file content. + // Mutually exclusive with Content. + // +optional + // +nullable + ContentSecretRef *xpv1.LocalSecretKeySelector `json:"contentSecretRef,omitempty"` + + // StartBranch is the source branch used to create branch if Branch does not exist yet. + // +optional + // +immutable + StartBranch *string `json:"startBranch,omitempty"` + + // Encoding is the file encoding: "text" (default) or "base64". + // +optional + // +kubebuilder:validation:Enum=text;base64 + // +kubebuilder:default="text" + Encoding *string `json:"encoding,omitempty"` + + // CreateCommitMessage is the commit message used when creating the file. + // Defaults to "crossplane: create ". + // +optional + CreateCommitMessage *string `json:"createCommitMessage,omitempty"` + + // UpdateCommitMessage is the commit message used when updating the file. + // Defaults to "crossplane: update ". + // +optional + UpdateCommitMessage *string `json:"updateCommitMessage,omitempty"` + + // DeleteCommitMessage is the commit message used when deleting the file. + // Defaults to "crossplane: delete ". + // +optional + DeleteCommitMessage *string `json:"deleteCommitMessage,omitempty"` + + // AuthorEmail is the commit author email. + // +optional + AuthorEmail *string `json:"authorEmail,omitempty"` + + // AuthorName is the commit author name. + // +optional + AuthorName *string `json:"authorName,omitempty"` + + // ExecuteFilemode enables the executable flag on the file. + // +optional + ExecuteFilemode *bool `json:"executeFilemode,omitempty"` + + // ReconcileInterval controls how often this resource is reconciled against + // the GitLab API. Examples: "5m", "1h", "8h". If unset, the resource is + // reconciled at the controller's default poll interval. + // +optional + ReconcileInterval *string `json:"reconcileInterval,omitempty"` + + // CreateOnly when true, creates the file once and never updates it. + // The file is still deleted from GitLab when the CR is deleted. + // Observe only checks for file existence, not content drift. + // +optional + // +kubebuilder:default=false + CreateOnly *bool `json:"createOnly,omitempty"` +} + +// RepositoryFileObservation represents the observed state of a GitLab Repository File. +type RepositoryFileObservation struct { + // FilePath is the observed file path. + FilePath string `json:"filePath,omitempty"` + + // BlobID is the blob SHA of the file. + BlobID string `json:"blobId,omitempty"` + + // CommitID is the commit SHA when the file was last read. + CommitID string `json:"commitId,omitempty"` + + // LastCommitID is the last commit SHA that modified this file. + LastCommitID string `json:"lastCommitId,omitempty"` + + // SHA256 is the SHA-256 hash of the file content. + SHA256 string `json:"sha256,omitempty"` + + // Size is the file size in bytes. + Size int64 `json:"size,omitempty"` + + // LastObserveTime is the timestamp of the last successful observe + // that made an actual API call to GitLab. Used for reconcileInterval. + LastObserveTime *metav1.Time `json:"lastObserveTime,omitempty"` +} + +// A RepositoryFileSpec defines the desired state of a GitLab Repository File. +type RepositoryFileSpec struct { + xpv2.ManagedResourceSpec `json:",inline"` + // ForProvider specifies the desired state of the RepositoryFile. + ForProvider RepositoryFileParameters `json:"forProvider"` +} + +// A RepositoryFileStatus represents the observed state of a GitLab Repository File. +type RepositoryFileStatus struct { + xpv1.ResourceStatus `json:",inline"` + // AtProvider reflects the observed state from GitLab. + AtProvider RepositoryFileObservation `json:"atProvider,omitempty"` +} + +// +kubebuilder:object:root=true + +// A RepositoryFile is a managed resource that represents a file in a GitLab repository. +// +kubebuilder:printcolumn:name="FILE",type="string",JSONPath=".spec.forProvider.filePath" +// +kubebuilder:printcolumn:name="BRANCH",type="string",JSONPath=".spec.forProvider.branch" +// +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={crossplane,managed,gitlab} +type RepositoryFile struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec RepositoryFileSpec `json:"spec"` + Status RepositoryFileStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// RepositoryFileList contains a list of RepositoryFile items. +type RepositoryFileList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []RepositoryFile `json:"items"` +} diff --git a/apis/namespaced/projects/v1alpha1/zz_generated.deepcopy.go b/apis/namespaced/projects/v1alpha1/zz_generated.deepcopy.go index ea71a518..5a2fc048 100644 --- a/apis/namespaced/projects/v1alpha1/zz_generated.deepcopy.go +++ b/apis/namespaced/projects/v1alpha1/zz_generated.deepcopy.go @@ -3263,6 +3263,208 @@ func (in *PushRules) DeepCopy() *PushRules { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryFile) DeepCopyInto(out *RepositoryFile) { + *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 RepositoryFile. +func (in *RepositoryFile) DeepCopy() *RepositoryFile { + if in == nil { + return nil + } + out := new(RepositoryFile) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RepositoryFile) 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 *RepositoryFileList) DeepCopyInto(out *RepositoryFileList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]RepositoryFile, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryFileList. +func (in *RepositoryFileList) DeepCopy() *RepositoryFileList { + if in == nil { + return nil + } + out := new(RepositoryFileList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RepositoryFileList) 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 *RepositoryFileObservation) DeepCopyInto(out *RepositoryFileObservation) { + *out = *in + if in.LastObserveTime != nil { + in, out := &in.LastObserveTime, &out.LastObserveTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryFileObservation. +func (in *RepositoryFileObservation) DeepCopy() *RepositoryFileObservation { + if in == nil { + return nil + } + out := new(RepositoryFileObservation) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryFileParameters) DeepCopyInto(out *RepositoryFileParameters) { + *out = *in + if in.ProjectID != nil { + in, out := &in.ProjectID, &out.ProjectID + *out = new(string) + **out = **in + } + if in.ProjectIDRef != nil { + in, out := &in.ProjectIDRef, &out.ProjectIDRef + *out = new(v1.NamespacedReference) + (*in).DeepCopyInto(*out) + } + if in.ProjectIDSelector != nil { + in, out := &in.ProjectIDSelector, &out.ProjectIDSelector + *out = new(v1.NamespacedSelector) + (*in).DeepCopyInto(*out) + } + if in.Content != nil { + in, out := &in.Content, &out.Content + *out = new(string) + **out = **in + } + if in.ContentSecretRef != nil { + in, out := &in.ContentSecretRef, &out.ContentSecretRef + *out = new(v1.LocalSecretKeySelector) + **out = **in + } + if in.StartBranch != nil { + in, out := &in.StartBranch, &out.StartBranch + *out = new(string) + **out = **in + } + if in.Encoding != nil { + in, out := &in.Encoding, &out.Encoding + *out = new(string) + **out = **in + } + if in.CreateCommitMessage != nil { + in, out := &in.CreateCommitMessage, &out.CreateCommitMessage + *out = new(string) + **out = **in + } + if in.UpdateCommitMessage != nil { + in, out := &in.UpdateCommitMessage, &out.UpdateCommitMessage + *out = new(string) + **out = **in + } + if in.DeleteCommitMessage != nil { + in, out := &in.DeleteCommitMessage, &out.DeleteCommitMessage + *out = new(string) + **out = **in + } + if in.AuthorEmail != nil { + in, out := &in.AuthorEmail, &out.AuthorEmail + *out = new(string) + **out = **in + } + if in.AuthorName != nil { + in, out := &in.AuthorName, &out.AuthorName + *out = new(string) + **out = **in + } + if in.ExecuteFilemode != nil { + in, out := &in.ExecuteFilemode, &out.ExecuteFilemode + *out = new(bool) + **out = **in + } + if in.ReconcileInterval != nil { + in, out := &in.ReconcileInterval, &out.ReconcileInterval + *out = new(string) + **out = **in + } + if in.CreateOnly != nil { + in, out := &in.CreateOnly, &out.CreateOnly + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryFileParameters. +func (in *RepositoryFileParameters) DeepCopy() *RepositoryFileParameters { + if in == nil { + return nil + } + out := new(RepositoryFileParameters) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryFileSpec) DeepCopyInto(out *RepositoryFileSpec) { + *out = *in + in.ManagedResourceSpec.DeepCopyInto(&out.ManagedResourceSpec) + in.ForProvider.DeepCopyInto(&out.ForProvider) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryFileSpec. +func (in *RepositoryFileSpec) DeepCopy() *RepositoryFileSpec { + if in == nil { + return nil + } + out := new(RepositoryFileSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryFileStatus) DeepCopyInto(out *RepositoryFileStatus) { + *out = *in + in.ResourceStatus.DeepCopyInto(&out.ResourceStatus) + in.AtProvider.DeepCopyInto(&out.AtProvider) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryFileStatus. +func (in *RepositoryFileStatus) DeepCopy() *RepositoryFileStatus { + if in == nil { + return nil + } + out := new(RepositoryFileStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Runner) DeepCopyInto(out *Runner) { *out = *in diff --git a/apis/namespaced/projects/v1alpha1/zz_generated.managed.go b/apis/namespaced/projects/v1alpha1/zz_generated.managed.go index 0f1dbdff..151d9119 100644 --- a/apis/namespaced/projects/v1alpha1/zz_generated.managed.go +++ b/apis/namespaced/projects/v1alpha1/zz_generated.managed.go @@ -540,6 +540,46 @@ func (mg *ProtectedEnvironment) SetWriteConnectionSecretToReference(r *xpv1.Loca mg.Spec.WriteConnectionSecretToReference = r } +// GetCondition of this RepositoryFile. +func (mg *RepositoryFile) GetCondition(ct xpv1.ConditionType) xpv1.Condition { + return mg.Status.GetCondition(ct) +} + +// GetManagementPolicies of this RepositoryFile. +func (mg *RepositoryFile) GetManagementPolicies() xpv1.ManagementPolicies { + return mg.Spec.ManagementPolicies +} + +// GetProviderConfigReference of this RepositoryFile. +func (mg *RepositoryFile) GetProviderConfigReference() *xpv1.ProviderConfigReference { + return mg.Spec.ProviderConfigReference +} + +// GetWriteConnectionSecretToReference of this RepositoryFile. +func (mg *RepositoryFile) GetWriteConnectionSecretToReference() *xpv1.LocalSecretReference { + return mg.Spec.WriteConnectionSecretToReference +} + +// SetConditions of this RepositoryFile. +func (mg *RepositoryFile) SetConditions(c ...xpv1.Condition) { + mg.Status.SetConditions(c...) +} + +// SetManagementPolicies of this RepositoryFile. +func (mg *RepositoryFile) SetManagementPolicies(r xpv1.ManagementPolicies) { + mg.Spec.ManagementPolicies = r +} + +// SetProviderConfigReference of this RepositoryFile. +func (mg *RepositoryFile) SetProviderConfigReference(r *xpv1.ProviderConfigReference) { + mg.Spec.ProviderConfigReference = r +} + +// SetWriteConnectionSecretToReference of this RepositoryFile. +func (mg *RepositoryFile) SetWriteConnectionSecretToReference(r *xpv1.LocalSecretReference) { + mg.Spec.WriteConnectionSecretToReference = r +} + // GetCondition of this Runner. func (mg *Runner) GetCondition(ct xpv1.ConditionType) xpv1.Condition { return mg.Status.GetCondition(ct) diff --git a/apis/namespaced/projects/v1alpha1/zz_generated.managedlist.go b/apis/namespaced/projects/v1alpha1/zz_generated.managedlist.go index a5d29da0..7df9bb15 100644 --- a/apis/namespaced/projects/v1alpha1/zz_generated.managedlist.go +++ b/apis/namespaced/projects/v1alpha1/zz_generated.managedlist.go @@ -137,6 +137,15 @@ func (l *ProtectedEnvironmentList) GetItems() []resource.Managed { return items } +// GetItems of this RepositoryFileList. +func (l *RepositoryFileList) GetItems() []resource.Managed { + items := make([]resource.Managed, len(l.Items)) + for i := range l.Items { + items[i] = &l.Items[i] + } + return items +} + // GetItems of this RunnerList. func (l *RunnerList) GetItems() []resource.Managed { items := make([]resource.Managed, len(l.Items)) diff --git a/apis/namespaced/projects/v1alpha1/zz_generated.resolvers.go b/apis/namespaced/projects/v1alpha1/zz_generated.resolvers.go index d7bd73ff..7feff111 100644 --- a/apis/namespaced/projects/v1alpha1/zz_generated.resolvers.go +++ b/apis/namespaced/projects/v1alpha1/zz_generated.resolvers.go @@ -204,3 +204,30 @@ func (mg *ProtectedEnvironment) ResolveReferences(ctx context.Context, c client. return nil } + +// ResolveReferences of this RepositoryFile. +func (mg *RepositoryFile) ResolveReferences(ctx context.Context, c client.Reader) error { + r := reference.NewAPINamespacedResolver(c, mg) + + var rsp reference.NamespacedResolutionResponse + var err error + + rsp, err = r.Resolve(ctx, reference.NamespacedResolutionRequest{ + CurrentValue: reference.FromPtrValue(mg.Spec.ForProvider.ProjectID), + Extract: reference.ExternalName(), + Namespace: mg.GetNamespace(), + Reference: mg.Spec.ForProvider.ProjectIDRef, + Selector: mg.Spec.ForProvider.ProjectIDSelector, + To: reference.To{ + List: &ProjectList{}, + Managed: &Project{}, + }, + }) + if err != nil { + return errors.Wrap(err, "mg.Spec.ForProvider.ProjectID") + } + mg.Spec.ForProvider.ProjectID = reference.ToPtrValue(rsp.ResolvedValue) + mg.Spec.ForProvider.ProjectIDRef = rsp.ResolvedReference + + return nil +} diff --git a/examples/projects/repositoryfile.yaml b/examples/projects/repositoryfile.yaml new file mode 100644 index 00000000..08118dd0 --- /dev/null +++ b/examples/projects/repositoryfile.yaml @@ -0,0 +1,21 @@ +apiVersion: projects.gitlab.m.crossplane.io/v1alpha1 +kind: RepositoryFile +metadata: + name: example-repositoryfile +spec: + forProvider: + projectIdRef: + name: example-project + filePath: README.md + branch: main + content: | + # Example Project + + This file is managed by Crossplane. + reconcileInterval: 1h + createCommitMessage: "docs: create README.md" + updateCommitMessage: "docs: update README.md" + deleteCommitMessage: "docs: delete README.md" + providerConfigRef: + name: default + kind: ProviderConfig diff --git a/package/crds/projects.gitlab.crossplane.io_repositoryfiles.yaml b/package/crds/projects.gitlab.crossplane.io_repositoryfiles.yaml new file mode 100644 index 00000000..cc7c8f2a --- /dev/null +++ b/package/crds/projects.gitlab.crossplane.io_repositoryfiles.yaml @@ -0,0 +1,428 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: repositoryfiles.projects.gitlab.crossplane.io +spec: + group: projects.gitlab.crossplane.io + names: + categories: + - crossplane + - managed + - gitlab + kind: RepositoryFile + listKind: RepositoryFileList + plural: repositoryfiles + singular: repositoryfile + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.forProvider.filePath + name: FILE + type: string + - jsonPath: .spec.forProvider.branch + name: 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 RepositoryFile is a managed resource that represents a file + in a GitLab 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 RepositoryFileSpec defines the desired state of a GitLab + Repository File. + properties: + deletionPolicy: + default: Delete + description: |- + DeletionPolicy specifies what will happen to the underlying external + when this managed resource is deleted - either "Delete" or "Orphan" the + external resource. + This field is planned to be deprecated in favor of the ManagementPolicies + field in a future release. Currently, both could be set independently and + non-default values would be honored if the feature flag is enabled. + See the design doc for more information: https://github.com/crossplane/crossplane/blob/499895a25d1a1a0ba1604944ef98ac7a1a71f197/design/design-doc-observe-only-resources.md?plain=1#L223 + enum: + - Orphan + - Delete + type: string + forProvider: + description: ForProvider specifies the desired state of the RepositoryFile. + properties: + authorEmail: + description: AuthorEmail is the commit author email. + type: string + authorName: + description: AuthorName is the commit author name. + type: string + branch: + description: Branch is the name of the branch to commit to. + minLength: 1 + type: string + content: + description: Content is the file content. Mutually exclusive with + ContentSecretRef. + type: string + contentSecretRef: + description: |- + ContentSecretRef references a Secret key containing the file content. + Mutually exclusive with Content. + nullable: true + properties: + key: + description: The key to select. + type: string + name: + description: Name of the secret. + type: string + namespace: + description: Namespace of the secret. + type: string + required: + - key + - name + - namespace + type: object + createCommitMessage: + description: |- + CreateCommitMessage is the commit message used when creating the file. + Defaults to "crossplane: create ". + type: string + createOnly: + default: false + description: |- + CreateOnly when true, creates the file once and never updates it. + The file is still deleted from GitLab when the CR is deleted. + Observe only checks for file existence, not content drift. + type: boolean + deleteCommitMessage: + description: |- + DeleteCommitMessage is the commit message used when deleting the file. + Defaults to "crossplane: delete ". + type: string + encoding: + default: text + description: 'Encoding is the file encoding: "text" (default) + or "base64".' + enum: + - text + - base64 + type: string + executeFilemode: + description: ExecuteFilemode enables the executable flag on the + file. + type: boolean + filePath: + description: FilePath is the path of the file in the repository. + minLength: 1 + type: string + projectId: + description: The ID or URL-encoded path of the project owned by + the authenticated user. + type: string + projectIdRef: + description: ProjectIDRef is a reference to a project to retrieve + its projectId. + properties: + name: + description: Name of the referenced object. + type: string + policy: + description: Policies for referencing. + properties: + resolution: + default: Required + description: |- + Resolution specifies whether resolution of this reference is required. + The default is 'Required', which means the reconcile will fail if the + reference cannot be resolved. 'Optional' means this reference will be + a no-op if it cannot be resolved. + enum: + - Required + - Optional + type: string + resolve: + description: |- + Resolve specifies when this reference should be resolved. The default + is 'IfNotPresent', which will attempt to resolve the reference only when + the corresponding field is not present. Use 'Always' to resolve the + reference on every reconcile. + enum: + - Always + - IfNotPresent + type: string + type: object + required: + - name + type: object + projectIdSelector: + description: ProjectIDSelector selects reference to a project + to retrieve its projectId. + properties: + matchControllerRef: + description: |- + MatchControllerRef ensures an object with the same controller reference + as the selecting object is selected. + type: boolean + matchLabels: + additionalProperties: + type: string + description: MatchLabels ensures an object with matching labels + is selected. + type: object + policy: + description: Policies for selection. + properties: + resolution: + default: Required + description: |- + Resolution specifies whether resolution of this reference is required. + The default is 'Required', which means the reconcile will fail if the + reference cannot be resolved. 'Optional' means this reference will be + a no-op if it cannot be resolved. + enum: + - Required + - Optional + type: string + resolve: + description: |- + Resolve specifies when this reference should be resolved. The default + is 'IfNotPresent', which will attempt to resolve the reference only when + the corresponding field is not present. Use 'Always' to resolve the + reference on every reconcile. + enum: + - Always + - IfNotPresent + type: string + type: object + type: object + reconcileInterval: + description: |- + ReconcileInterval controls how often this resource is reconciled against + the GitLab API. Examples: "5m", "1h", "8h". If unset, the resource is + reconciled at the controller's default poll interval. + type: string + startBranch: + description: StartBranch is the source branch used to create branch + if Branch does not exist yet. + type: string + updateCommitMessage: + description: |- + UpdateCommitMessage is the commit message used when updating the file. + Defaults to "crossplane: update ". + type: string + required: + - branch + - filePath + type: object + managementPolicies: + default: + - '*' + description: |- + THIS IS A BETA FIELD. It is on by default but can be opted out + through a Crossplane feature flag. + ManagementPolicies specify the array of actions Crossplane is allowed to + take on the managed and external resources. + This field is planned to replace the DeletionPolicy field in a future + release. Currently, both could be set independently and non-default + values would be honored if the feature flag is enabled. If both are + custom, the DeletionPolicy field will be ignored. + See the design doc for more information: https://github.com/crossplane/crossplane/blob/499895a25d1a1a0ba1604944ef98ac7a1a71f197/design/design-doc-observe-only-resources.md?plain=1#L223 + and this one: https://github.com/crossplane/crossplane/blob/444267e84783136daa93568b364a5f01228cacbe/design/one-pager-ignore-changes.md + items: + description: |- + A ManagementAction represents an action that the Crossplane controllers + can take on an external resource. + enum: + - Observe + - Create + - Update + - Delete + - LateInitialize + - '*' + type: string + type: array + providerConfigRef: + default: + name: default + description: |- + ProviderConfigReference specifies how the provider that will be used to + create, observe, update, and delete this managed resource should be + configured. + properties: + name: + description: Name of the referenced object. + type: string + policy: + description: Policies for referencing. + properties: + resolution: + default: Required + description: |- + Resolution specifies whether resolution of this reference is required. + The default is 'Required', which means the reconcile will fail if the + reference cannot be resolved. 'Optional' means this reference will be + a no-op if it cannot be resolved. + enum: + - Required + - Optional + type: string + resolve: + description: |- + Resolve specifies when this reference should be resolved. The default + is 'IfNotPresent', which will attempt to resolve the reference only when + the corresponding field is not present. Use 'Always' to resolve the + reference on every reconcile. + enum: + - Always + - IfNotPresent + type: string + type: object + required: + - name + type: object + writeConnectionSecretToRef: + description: |- + WriteConnectionSecretToReference specifies the namespace and name of a + Secret to which any connection details for this managed resource should + be written. Connection details frequently include the endpoint, username, + and password required to connect to the managed resource. + properties: + name: + description: Name of the secret. + type: string + namespace: + description: Namespace of the secret. + type: string + required: + - name + - namespace + type: object + required: + - forProvider + type: object + status: + description: A RepositoryFileStatus represents the observed state of a + GitLab Repository File. + properties: + atProvider: + description: AtProvider reflects the observed state from GitLab. + properties: + blobId: + description: BlobID is the blob SHA of the file. + type: string + commitId: + description: CommitID is the commit SHA when the file was last + read. + type: string + filePath: + description: FilePath is the observed file path. + type: string + lastCommitId: + description: LastCommitID is the last commit SHA that modified + this file. + type: string + lastObserveTime: + description: |- + LastObserveTime is the timestamp of the last successful observe + that made an actual API call to GitLab. Used for reconcileInterval. + format: date-time + type: string + sha256: + description: SHA256 is the SHA-256 hash of the file content. + type: string + size: + description: Size is the file size in bytes. + format: int64 + type: integer + type: object + 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 + observedGeneration: + description: |- + ObservedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + type: integer + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: |- + ObservedGeneration is the latest metadata.generation + which resulted in either a ready state, or stalled due to error + it can not recover from without human intervention. + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/package/crds/projects.gitlab.m.crossplane.io_repositoryfiles.yaml b/package/crds/projects.gitlab.m.crossplane.io_repositoryfiles.yaml new file mode 100644 index 00000000..822719c1 --- /dev/null +++ b/package/crds/projects.gitlab.m.crossplane.io_repositoryfiles.yaml @@ -0,0 +1,387 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: repositoryfiles.projects.gitlab.m.crossplane.io +spec: + group: projects.gitlab.m.crossplane.io + names: + categories: + - crossplane + - managed + - gitlab + kind: RepositoryFile + listKind: RepositoryFileList + plural: repositoryfiles + singular: repositoryfile + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.forProvider.filePath + name: FILE + type: string + - jsonPath: .spec.forProvider.branch + name: 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 RepositoryFile is a managed resource that represents a file + in a GitLab 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 RepositoryFileSpec defines the desired state of a GitLab + Repository File. + properties: + forProvider: + description: ForProvider specifies the desired state of the RepositoryFile. + properties: + authorEmail: + description: AuthorEmail is the commit author email. + type: string + authorName: + description: AuthorName is the commit author name. + type: string + branch: + description: Branch is the name of the branch to commit to. + minLength: 1 + type: string + content: + description: Content is the file content. Mutually exclusive with + ContentSecretRef. + type: string + contentSecretRef: + description: |- + ContentSecretRef references a Secret key containing the file content. + Mutually exclusive with Content. + nullable: true + properties: + key: + type: string + name: + description: Name of the secret. + type: string + required: + - key + - name + type: object + createCommitMessage: + description: |- + CreateCommitMessage is the commit message used when creating the file. + Defaults to "crossplane: create ". + type: string + createOnly: + default: false + description: |- + CreateOnly when true, creates the file once and never updates it. + The file is still deleted from GitLab when the CR is deleted. + Observe only checks for file existence, not content drift. + type: boolean + deleteCommitMessage: + description: |- + DeleteCommitMessage is the commit message used when deleting the file. + Defaults to "crossplane: delete ". + type: string + encoding: + default: text + description: 'Encoding is the file encoding: "text" (default) + or "base64".' + enum: + - text + - base64 + type: string + executeFilemode: + description: ExecuteFilemode enables the executable flag on the + file. + type: boolean + filePath: + description: FilePath is the path of the file in the repository. + minLength: 1 + type: string + projectId: + description: The ID or URL-encoded path of the project owned by + the authenticated user. + type: string + projectIdRef: + description: ProjectIDRef is a reference to a project to retrieve + its projectId. + properties: + name: + description: Name of the referenced object. + type: string + namespace: + description: Namespace of the referenced object + type: string + policy: + description: Policies for referencing. + properties: + resolution: + default: Required + description: |- + Resolution specifies whether resolution of this reference is required. + The default is 'Required', which means the reconcile will fail if the + reference cannot be resolved. 'Optional' means this reference will be + a no-op if it cannot be resolved. + enum: + - Required + - Optional + type: string + resolve: + description: |- + Resolve specifies when this reference should be resolved. The default + is 'IfNotPresent', which will attempt to resolve the reference only when + the corresponding field is not present. Use 'Always' to resolve the + reference on every reconcile. + enum: + - Always + - IfNotPresent + type: string + type: object + required: + - name + type: object + projectIdSelector: + description: ProjectIDSelector selects reference to a project + to retrieve its projectId. + properties: + matchControllerRef: + description: |- + MatchControllerRef ensures an object with the same controller reference + as the selecting object is selected. + type: boolean + matchLabels: + additionalProperties: + type: string + description: MatchLabels ensures an object with matching labels + is selected. + type: object + namespace: + description: Namespace for the selector + type: string + policy: + description: Policies for selection. + properties: + resolution: + default: Required + description: |- + Resolution specifies whether resolution of this reference is required. + The default is 'Required', which means the reconcile will fail if the + reference cannot be resolved. 'Optional' means this reference will be + a no-op if it cannot be resolved. + enum: + - Required + - Optional + type: string + resolve: + description: |- + Resolve specifies when this reference should be resolved. The default + is 'IfNotPresent', which will attempt to resolve the reference only when + the corresponding field is not present. Use 'Always' to resolve the + reference on every reconcile. + enum: + - Always + - IfNotPresent + type: string + type: object + type: object + reconcileInterval: + description: |- + ReconcileInterval controls how often this resource is reconciled against + the GitLab API. Examples: "5m", "1h", "8h". If unset, the resource is + reconciled at the controller's default poll interval. + type: string + startBranch: + description: StartBranch is the source branch used to create branch + if Branch does not exist yet. + type: string + updateCommitMessage: + description: |- + UpdateCommitMessage is the commit message used when updating the file. + Defaults to "crossplane: update ". + type: string + required: + - branch + - filePath + type: object + managementPolicies: + default: + - '*' + description: |- + THIS IS A BETA FIELD. It is on by default but can be opted out + through a Crossplane feature flag. + ManagementPolicies specify the array of actions Crossplane is allowed to + take on the managed and external resources. + See the design doc for more information: https://github.com/crossplane/crossplane/blob/499895a25d1a1a0ba1604944ef98ac7a1a71f197/design/design-doc-observe-only-resources.md?plain=1#L223 + and this one: https://github.com/crossplane/crossplane/blob/444267e84783136daa93568b364a5f01228cacbe/design/one-pager-ignore-changes.md + items: + description: |- + A ManagementAction represents an action that the Crossplane controllers + can take on an external resource. + enum: + - Observe + - Create + - Update + - Delete + - LateInitialize + - '*' + type: string + type: array + providerConfigRef: + default: + kind: ClusterProviderConfig + name: default + description: |- + ProviderConfigReference specifies how the provider that will be used to + create, observe, update, and delete this managed resource should be + configured. + properties: + kind: + description: Kind of the referenced object. + type: string + name: + description: Name of the referenced object. + type: string + required: + - kind + - name + type: object + writeConnectionSecretToRef: + description: |- + WriteConnectionSecretToReference specifies the namespace and name of a + Secret to which any connection details for this managed resource should + be written. Connection details frequently include the endpoint, username, + and password required to connect to the managed resource. + properties: + name: + description: Name of the secret. + type: string + required: + - name + type: object + required: + - forProvider + type: object + status: + description: A RepositoryFileStatus represents the observed state of a + GitLab Repository File. + properties: + atProvider: + description: AtProvider reflects the observed state from GitLab. + properties: + blobId: + description: BlobID is the blob SHA of the file. + type: string + commitId: + description: CommitID is the commit SHA when the file was last + read. + type: string + filePath: + description: FilePath is the observed file path. + type: string + lastCommitId: + description: LastCommitID is the last commit SHA that modified + this file. + type: string + lastObserveTime: + description: |- + LastObserveTime is the timestamp of the last successful observe + that made an actual API call to GitLab. Used for reconcileInterval. + format: date-time + type: string + sha256: + description: SHA256 is the SHA-256 hash of the file content. + type: string + size: + description: Size is the file size in bytes. + format: int64 + type: integer + type: object + 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 + observedGeneration: + description: |- + ObservedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + type: integer + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: |- + ObservedGeneration is the latest metadata.generation + which resulted in either a ready state, or stalled due to error + it can not recover from without human intervention. + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/cluster/clients/projects/fake/zz_fake.go b/pkg/cluster/clients/projects/fake/zz_fake.go index 43c8fe9b..820b3e67 100644 --- a/pkg/cluster/clients/projects/fake/zz_fake.go +++ b/pkg/cluster/clients/projects/fake/zz_fake.go @@ -55,6 +55,11 @@ type MockClient struct { MockListVariables func(pid any, opt *gitlab.ListProjectVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectVariable, *gitlab.Response, error) MockRemoveVariable func(pid any, key string, opt *gitlab.RemoveProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + MockGetFile func(pid any, fileName string, opt *gitlab.GetFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.File, *gitlab.Response, error) + MockCreateFile func(pid any, fileName string, opt *gitlab.CreateFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.FileInfo, *gitlab.Response, error) + MockUpdateFile func(pid any, fileName string, opt *gitlab.UpdateFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.FileInfo, *gitlab.Response, error) + MockDeleteFile func(pid any, fileName string, opt *gitlab.DeleteFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + MockGetProjectAccessToken func(pid any, id int64, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectAccessToken, *gitlab.Response, error) MockCreateProjectAccessToken func(pid any, opt *gitlab.CreateProjectAccessTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectAccessToken, *gitlab.Response, error) MockRevokeProjectAccessToken func(pid any, id int64, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) @@ -235,6 +240,26 @@ func (c *MockClient) ListVariables(pid any, opt *gitlab.ListProjectVariablesOpti return c.MockListVariables(pid, opt) } +// GetFile calls the underlying MockGetFile. +func (c *MockClient) GetFile(pid any, fileName string, opt *gitlab.GetFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.File, *gitlab.Response, error) { + return c.MockGetFile(pid, fileName, opt) +} + +// CreateFile calls the underlying MockCreateFile. +func (c *MockClient) CreateFile(pid any, fileName string, opt *gitlab.CreateFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.FileInfo, *gitlab.Response, error) { + return c.MockCreateFile(pid, fileName, opt) +} + +// UpdateFile calls the underlying MockUpdateFile. +func (c *MockClient) UpdateFile(pid any, fileName string, opt *gitlab.UpdateFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.FileInfo, *gitlab.Response, error) { + return c.MockUpdateFile(pid, fileName, opt) +} + +// DeleteFile calls the underlying MockDeleteFile. +func (c *MockClient) DeleteFile(pid any, fileName string, opt *gitlab.DeleteFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return c.MockDeleteFile(pid, fileName, opt) +} + // GetDeployKey calls the underlying MockGetDeployKey func (c *MockClient) GetDeployKey(pid any, deployKey int64, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectDeployKey, *gitlab.Response, error) { return c.MockGetDeployKey(pid, deployKey) diff --git a/pkg/cluster/clients/projects/zz_repositoryfile.go b/pkg/cluster/clients/projects/zz_repositoryfile.go new file mode 100644 index 00000000..afe01a68 --- /dev/null +++ b/pkg/cluster/clients/projects/zz_repositoryfile.go @@ -0,0 +1,257 @@ +/* +Copyright 2026 The Crossplane Authors. + +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 hack/generate-cluster-scope.go - DO NOT EDIT. + +package projects + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" + "time" + + gitlab "gitlab.com/gitlab-org/api/client-go" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + projectsv1alpha1 "github.com/crossplane-contrib/provider-gitlab/apis/cluster/projects/v1alpha1" + "github.com/crossplane-contrib/provider-gitlab/pkg/cluster/clients" + "github.com/crossplane-contrib/provider-gitlab/pkg/common" +) + +const ( + defaultRepositoryFileEncoding = "text" +) + +// RepositoryFileClient defines GitLab repository file operations. +type RepositoryFileClient interface { + GetFile(pid any, fileName string, opt *gitlab.GetFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.File, *gitlab.Response, error) + CreateFile(pid any, fileName string, opt *gitlab.CreateFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.FileInfo, *gitlab.Response, error) + UpdateFile(pid any, fileName string, opt *gitlab.UpdateFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.FileInfo, *gitlab.Response, error) + DeleteFile(pid any, fileName string, opt *gitlab.DeleteFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) +} + +// NewRepositoryFileClient returns a new GitLab repository files service. +func NewRepositoryFileClient(cfg common.Config) RepositoryFileClient { + git := common.NewClient(cfg) + return git.RepositoryFiles +} + +// GenerateRepositoryFileObservation builds observation from a gitlab file. +func GenerateRepositoryFileObservation(file *gitlab.File, observedAt time.Time) projectsv1alpha1.RepositoryFileObservation { + observation := projectsv1alpha1.RepositoryFileObservation{ + LastObserveTime: &metav1.Time{Time: observedAt}, + } + if file == nil { + return observation + } + + observation.FilePath = file.FilePath + observation.BlobID = file.BlobID + observation.CommitID = file.CommitID + observation.LastCommitID = file.LastCommitID + observation.SHA256 = file.SHA256 + observation.Size = file.Size + + return observation +} + +// LateInitializeRepositoryFile fills empty optional fields from the external file. +func LateInitializeRepositoryFile(in *projectsv1alpha1.RepositoryFileParameters, file *gitlab.File) { + if in == nil || file == nil { + return + } + + if in.Encoding == nil && file.Encoding != "" { + in.Encoding = &file.Encoding + } + + if in.ExecuteFilemode == nil { + in.ExecuteFilemode = &file.ExecuteFilemode + } + + if in.Branch == "" && file.Ref != "" { + in.Branch = file.Ref + } +} + +// GenerateGetFileOptions generates get options. +func GenerateGetFileOptions(p *projectsv1alpha1.RepositoryFileParameters) *gitlab.GetFileOptions { + if p == nil { + return nil + } + return &gitlab.GetFileOptions{Ref: clients.StringToPtr(p.Branch)} +} + +// GenerateCreateFileOptions generates create options. +func GenerateCreateFileOptions(p *projectsv1alpha1.RepositoryFileParameters, content string) *gitlab.CreateFileOptions { + if p == nil { + return nil + } + return &gitlab.CreateFileOptions{ + Branch: clients.StringToPtr(p.Branch), + StartBranch: p.StartBranch, + Encoding: RepositoryFileEncodingOrDefault(p.Encoding), + AuthorEmail: p.AuthorEmail, + AuthorName: p.AuthorName, + Content: &content, + CommitMessage: RepositoryFileCommitMessageForCreate(p), + ExecuteFilemode: p.ExecuteFilemode, + } +} + +// GenerateUpdateFileOptions generates update options. +func GenerateUpdateFileOptions(p *projectsv1alpha1.RepositoryFileParameters, content string, lastCommitID *string) *gitlab.UpdateFileOptions { + if p == nil { + return nil + } + return &gitlab.UpdateFileOptions{ + Branch: clients.StringToPtr(p.Branch), + StartBranch: p.StartBranch, + Encoding: RepositoryFileEncodingOrDefault(p.Encoding), + AuthorEmail: p.AuthorEmail, + AuthorName: p.AuthorName, + Content: &content, + CommitMessage: RepositoryFileCommitMessageForUpdate(p), + LastCommitID: lastCommitID, + ExecuteFilemode: p.ExecuteFilemode, + } +} + +// GenerateDeleteFileOptions generates delete options. +func GenerateDeleteFileOptions(p *projectsv1alpha1.RepositoryFileParameters, lastCommitID *string) *gitlab.DeleteFileOptions { + if p == nil { + return nil + } + return &gitlab.DeleteFileOptions{ + Branch: clients.StringToPtr(p.Branch), + StartBranch: p.StartBranch, + AuthorEmail: p.AuthorEmail, + AuthorName: p.AuthorName, + CommitMessage: RepositoryFileCommitMessageForDelete(p), + LastCommitID: lastCommitID, + } +} + +// IsRepositoryFileUpToDate checks whether the desired file content and settings match the external file. +func IsRepositoryFileUpToDate(p *projectsv1alpha1.RepositoryFileParameters, external *gitlab.File, content string) bool { + if p == nil { + return true + } + if external == nil { + return false + } + + if p.FilePath != external.FilePath { + return false + } + + if p.Branch != external.Ref { + return false + } + + if !clients.IsComparableEqualToComparablePtr(p.ExecuteFilemode, external.ExecuteFilemode) { + return false + } + + return RepositoryFileContentSHA256(p, content) == external.SHA256 +} + +// ShouldObserveRepositoryFileNow checks whether observe should call GitLab now. +func ShouldObserveRepositoryFileNow(p *projectsv1alpha1.RepositoryFileParameters, observation *projectsv1alpha1.RepositoryFileObservation, defaultPollInterval time.Duration, now time.Time) (bool, error) { + if observation == nil || observation.LastObserveTime == nil { + return true, nil + } + + interval, err := RepositoryFileReconcileInterval(p, defaultPollInterval) + if err != nil { + return false, err + } + + if interval <= 0 { + return true, nil + } + + return !observation.LastObserveTime.Add(interval).After(now), nil +} + +// RepositoryFileReconcileInterval returns configured reconcile interval or default. +func RepositoryFileReconcileInterval(p *projectsv1alpha1.RepositoryFileParameters, defaultPollInterval time.Duration) (time.Duration, error) { + if p == nil || p.ReconcileInterval == nil || strings.TrimSpace(*p.ReconcileInterval) == "" { + return defaultPollInterval, nil + } + return time.ParseDuration(*p.ReconcileInterval) +} + +// RepositoryFileEncodingOrDefault returns configured encoding or text. +func RepositoryFileEncodingOrDefault(encoding *string) *string { + if encoding == nil || *encoding == "" { + return clients.StringToPtr(defaultRepositoryFileEncoding) + } + return encoding +} + +// RepositoryFileCommitMessageForCreate returns commit message for create. +func RepositoryFileCommitMessageForCreate(p *projectsv1alpha1.RepositoryFileParameters) *string { + if p != nil && p.CreateCommitMessage != nil && *p.CreateCommitMessage != "" { + return p.CreateCommitMessage + } + if p == nil { + return nil + } + return clients.StringToPtr(fmt.Sprintf("crossplane: create %s", p.FilePath)) +} + +// RepositoryFileCommitMessageForUpdate returns commit message for update. +func RepositoryFileCommitMessageForUpdate(p *projectsv1alpha1.RepositoryFileParameters) *string { + if p != nil && p.UpdateCommitMessage != nil && *p.UpdateCommitMessage != "" { + return p.UpdateCommitMessage + } + if p == nil { + return nil + } + return clients.StringToPtr(fmt.Sprintf("crossplane: update %s", p.FilePath)) +} + +// RepositoryFileCommitMessageForDelete returns commit message for delete. +func RepositoryFileCommitMessageForDelete(p *projectsv1alpha1.RepositoryFileParameters) *string { + if p != nil && p.DeleteCommitMessage != nil && *p.DeleteCommitMessage != "" { + return p.DeleteCommitMessage + } + if p == nil { + return nil + } + return clients.StringToPtr(fmt.Sprintf("crossplane: delete %s", p.FilePath)) +} + +// RepositoryFileContentSHA256 calculates the GitLab-compatible SHA256 for desired content. +func RepositoryFileContentSHA256(p *projectsv1alpha1.RepositoryFileParameters, content string) string { + bytes := []byte(content) + if p != nil && p.Encoding != nil && *p.Encoding == "base64" { + decoded, err := base64.StdEncoding.DecodeString(content) + if err == nil { + bytes = decoded + } + } + sum := sha256.Sum256(bytes) + return fmt.Sprintf("%x", sum) +} + +// RepositoryFileCreateOnly returns true if createOnly is enabled. +func RepositoryFileCreateOnly(p *projectsv1alpha1.RepositoryFileParameters) bool { + return p != nil && p.CreateOnly != nil && *p.CreateOnly +} diff --git a/pkg/cluster/controller/projects/repositoryfiles/zz_controller.go b/pkg/cluster/controller/projects/repositoryfiles/zz_controller.go new file mode 100644 index 00000000..41aa3321 --- /dev/null +++ b/pkg/cluster/controller/projects/repositoryfiles/zz_controller.go @@ -0,0 +1,303 @@ +/* +Copyright 2026 The Crossplane Authors. + +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 hack/generate-cluster-scope.go - DO NOT EDIT. + +package repositoryfiles + +import ( + "context" + "time" + + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + "github.com/crossplane/crossplane-runtime/v2/pkg/controller" + "github.com/crossplane/crossplane-runtime/v2/pkg/event" + "github.com/crossplane/crossplane-runtime/v2/pkg/feature" + "github.com/crossplane/crossplane-runtime/v2/pkg/meta" + "github.com/crossplane/crossplane-runtime/v2/pkg/reconciler/managed" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource" + "github.com/crossplane/crossplane-runtime/v2/pkg/statemetrics" + "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" + gitlab "gitlab.com/gitlab-org/api/client-go" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + projectsv1alpha1 "github.com/crossplane-contrib/provider-gitlab/apis/cluster/projects/v1alpha1" + commonclients "github.com/crossplane-contrib/provider-gitlab/pkg/cluster/clients" + projectclients "github.com/crossplane-contrib/provider-gitlab/pkg/cluster/clients/projects" + "github.com/crossplane-contrib/provider-gitlab/pkg/common" +) + +const ( + errNotRepositoryFile = "managed resource is not a Gitlab repository file custom resource" + errGetRepositoryFileFailed = "cannot get Gitlab repository file" + errCreateRepositoryFileFailed = "cannot create Gitlab repository file" + errUpdateRepositoryFileFailed = "cannot update Gitlab repository file" + errDeleteRepositoryFileFailed = "cannot delete Gitlab repository file" + errResolveContentFailed = "cannot resolve Gitlab repository file content" + errProjectIDMissing = "ProjectID is missing" + errExternalNameMismatch = "external-name must match spec.forProvider.filePath" + errReconcileIntervalInvalid = "invalid reconcileInterval" + errRepositoryFileContentMissing = "exactly one of content or contentSecretRef must be set" +) + +// SetupRepositoryFile adds a controller that reconciles RepositoryFiles. +func SetupRepositoryFile(mgr ctrl.Manager, o controller.Options) error { + name := managed.ControllerName("cluster." + projectsv1alpha1.RepositoryFileGroupKind) + + reconcilerOpts := []managed.ReconcilerOption{ + managed.WithExternalConnecter(&connector{ + kube: mgr.GetClient(), + newGitlabClientFn: projectclients.NewRepositoryFileClient, + pollInterval: o.PollInterval, + }), + managed.WithInitializers(), + managed.WithPollInterval(o.PollInterval), + managed.WithLogger(o.Logger.WithValues("controller", name)), + managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), + } + + if o.Features.Enabled(feature.EnableBetaManagementPolicies) { + reconcilerOpts = append(reconcilerOpts, managed.WithManagementPolicies()) + } + + r := managed.NewReconciler(mgr, + resource.ManagedKind(projectsv1alpha1.RepositoryFileGroupVersionKind), + reconcilerOpts...) + + if err := mgr.Add(statemetrics.NewMRStateRecorder( + mgr.GetClient(), o.Logger, o.MetricOptions.MRStateMetrics, &projectsv1alpha1.RepositoryFileList{}, o.MetricOptions.PollStateMetricInterval)); err != nil { + return err + } + + return ctrl.NewControllerManagedBy(mgr). + Named(name). + For(&projectsv1alpha1.RepositoryFile{}). + Complete(r) +} + +// SetupRepositoryFileGated adds a controller with CRD gate support. +func SetupRepositoryFileGated(mgr ctrl.Manager, o controller.Options) error { + o.Gate.Register(func() { + if err := SetupRepositoryFile(mgr, o); err != nil { + mgr.GetLogger().Error(err, "unable to setup reconciler", "gvk", projectsv1alpha1.RepositoryFileGroupVersionKind.String()) + } + }, projectsv1alpha1.RepositoryFileGroupVersionKind) + return nil +} + +type connector struct { + kube client.Client + newGitlabClientFn func(cfg common.Config) projectclients.RepositoryFileClient + pollInterval time.Duration +} + +func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.ExternalClient, error) { + cr, ok := mg.(*projectsv1alpha1.RepositoryFile) + if !ok { + return nil, errors.New(errNotRepositoryFile) + } + cfg, err := common.GetConfig(ctx, c.kube, cr) + if err != nil { + return nil, err + } + return &external{kube: c.kube, client: c.newGitlabClientFn(*cfg), pollInterval: c.pollInterval}, nil +} + +type external struct { + kube client.Client + client projectclients.RepositoryFileClient + pollInterval time.Duration +} + +func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) { + cr, ok := mg.(*projectsv1alpha1.RepositoryFile) + if !ok { + return managed.ExternalObservation{}, errors.New(errNotRepositoryFile) + } + + if cr.Spec.ForProvider.ProjectID == nil { + return managed.ExternalObservation{}, errors.New(errProjectIDMissing) + } + + if externalName := meta.GetExternalName(cr); externalName != "" && externalName != cr.Spec.ForProvider.FilePath { + return managed.ExternalObservation{}, errors.New(errExternalNameMismatch) + } + + now := time.Now() + shouldObserve, err := projectclients.ShouldObserveRepositoryFileNow(&cr.Spec.ForProvider, &cr.Status.AtProvider, e.pollInterval, now) + if err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, errReconcileIntervalInvalid) + } + if !shouldObserve { + return managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + }, nil + } + + file, res, err := e.client.GetFile( + *cr.Spec.ForProvider.ProjectID, + cr.Spec.ForProvider.FilePath, + projectclients.GenerateGetFileOptions(&cr.Spec.ForProvider), + gitlab.WithContext(ctx), + ) + if err != nil { + if commonclients.IsResponseNotFound(res) { + return managed.ExternalObservation{}, nil + } + return managed.ExternalObservation{}, errors.Wrap(err, errGetRepositoryFileFailed) + } + + current := cr.Spec.ForProvider.DeepCopy() + projectclients.LateInitializeRepositoryFile(&cr.Spec.ForProvider, file) + cr.Status.AtProvider = projectclients.GenerateRepositoryFileObservation(file, now) + cr.Status.SetConditions(xpv1.Available()) + + if projectclients.RepositoryFileCreateOnly(&cr.Spec.ForProvider) { + return managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + ResourceLateInitialized: !cmp.Equal(current, &cr.Spec.ForProvider), + }, nil + } + + content, err := e.resolveContent(ctx, mg, &cr.Spec.ForProvider) + if err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, errResolveContentFailed) + } + + return managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: projectclients.IsRepositoryFileUpToDate(&cr.Spec.ForProvider, file, content), + ResourceLateInitialized: !cmp.Equal(current, &cr.Spec.ForProvider), + }, nil +} + +func (e *external) Create(ctx context.Context, mg resource.Managed) (managed.ExternalCreation, error) { + cr, ok := mg.(*projectsv1alpha1.RepositoryFile) + if !ok { + return managed.ExternalCreation{}, errors.New(errNotRepositoryFile) + } + if cr.Spec.ForProvider.ProjectID == nil { + return managed.ExternalCreation{}, errors.New(errProjectIDMissing) + } + + content, err := e.resolveContent(ctx, mg, &cr.Spec.ForProvider) + if err != nil { + return managed.ExternalCreation{}, errors.Wrap(err, errResolveContentFailed) + } + + cr.Status.SetConditions(xpv1.Creating()) + _, _, err = e.client.CreateFile( + *cr.Spec.ForProvider.ProjectID, + cr.Spec.ForProvider.FilePath, + projectclients.GenerateCreateFileOptions(&cr.Spec.ForProvider, content), + gitlab.WithContext(ctx), + ) + if err != nil { + return managed.ExternalCreation{}, errors.Wrap(err, errCreateRepositoryFileFailed) + } + + meta.SetExternalName(cr, cr.Spec.ForProvider.FilePath) + return managed.ExternalCreation{}, nil +} + +func (e *external) Update(ctx context.Context, mg resource.Managed) (managed.ExternalUpdate, error) { + cr, ok := mg.(*projectsv1alpha1.RepositoryFile) + if !ok { + return managed.ExternalUpdate{}, errors.New(errNotRepositoryFile) + } + if cr.Spec.ForProvider.ProjectID == nil { + return managed.ExternalUpdate{}, errors.New(errProjectIDMissing) + } + if projectclients.RepositoryFileCreateOnly(&cr.Spec.ForProvider) { + return managed.ExternalUpdate{}, nil + } + + content, err := e.resolveContent(ctx, mg, &cr.Spec.ForProvider) + if err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errResolveContentFailed) + } + + lastCommitID := emptyStringToNil(cr.Status.AtProvider.LastCommitID) + _, _, err = e.client.UpdateFile( + *cr.Spec.ForProvider.ProjectID, + cr.Spec.ForProvider.FilePath, + projectclients.GenerateUpdateFileOptions(&cr.Spec.ForProvider, content, lastCommitID), + gitlab.WithContext(ctx), + ) + if err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateRepositoryFileFailed) + } + return managed.ExternalUpdate{}, nil +} + +func (e *external) Delete(ctx context.Context, mg resource.Managed) (managed.ExternalDelete, error) { + cr, ok := mg.(*projectsv1alpha1.RepositoryFile) + if !ok { + return managed.ExternalDelete{}, errors.New(errNotRepositoryFile) + } + if cr.Spec.ForProvider.ProjectID == nil { + return managed.ExternalDelete{}, errors.New(errProjectIDMissing) + } + + cr.Status.SetConditions(xpv1.Deleting()) + res, err := e.client.DeleteFile( + *cr.Spec.ForProvider.ProjectID, + cr.Spec.ForProvider.FilePath, + projectclients.GenerateDeleteFileOptions(&cr.Spec.ForProvider, emptyStringToNil(cr.Status.AtProvider.LastCommitID)), + gitlab.WithContext(ctx), + ) + if err != nil { + if commonclients.IsResponseNotFound(res) { + return managed.ExternalDelete{}, nil + } + return managed.ExternalDelete{}, errors.Wrap(err, errDeleteRepositoryFileFailed) + } + return managed.ExternalDelete{}, nil +} + +func (e *external) Disconnect(ctx context.Context) error { + return nil +} + +func (e *external) resolveContent(ctx context.Context, mg resource.Managed, p *projectsv1alpha1.RepositoryFileParameters) (string, error) { + inline := p != nil && p.Content != nil + secret := p != nil && p.ContentSecretRef != nil + if inline == secret { + return "", errors.New(errRepositoryFileContentMissing) + } + if inline { + return *p.Content, nil + } + content, err := common.GetTokenValueFromSecret(ctx, e.kube, mg, p.ContentSecretRef) + if err != nil { + return "", err + } + if content == nil { + return "", errors.New(errRepositoryFileContentMissing) + } + return *content, nil +} + +func emptyStringToNil(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/pkg/cluster/controller/projects/zz_setup.go b/pkg/cluster/controller/projects/zz_setup.go index 5c4890e9..e394b341 100644 --- a/pkg/cluster/controller/projects/zz_setup.go +++ b/pkg/cluster/controller/projects/zz_setup.go @@ -35,6 +35,7 @@ import ( "github.com/crossplane-contrib/provider-gitlab/pkg/cluster/controller/projects/projectsharegroups" "github.com/crossplane-contrib/provider-gitlab/pkg/cluster/controller/projects/protectedbranches" "github.com/crossplane-contrib/provider-gitlab/pkg/cluster/controller/projects/protectedenvironments" + "github.com/crossplane-contrib/provider-gitlab/pkg/cluster/controller/projects/repositoryfiles" "github.com/crossplane-contrib/provider-gitlab/pkg/cluster/controller/projects/runners" "github.com/crossplane-contrib/provider-gitlab/pkg/cluster/controller/projects/variables" ) @@ -48,6 +49,7 @@ func Setup(mgr ctrl.Manager, o controller.Options) error { deploytokens.SetupDeployToken, accesstokens.SetupAccessToken, variables.SetupVariable, + repositoryfiles.SetupRepositoryFile, deploykeys.SetupDeployKey, pipelineschedules.SetupPipelineSchedule, approvalrules.SetupRules, @@ -75,6 +77,7 @@ func SetupGated(mgr ctrl.Manager, o controller.Options) error { deploytokens.SetupDeployTokenGated, accesstokens.SetupAccessTokenGated, variables.SetupVariableGated, + repositoryfiles.SetupRepositoryFileGated, deploykeys.SetupDeployKeyGated, pipelineschedules.SetupPipelineScheduleGated, approvalrules.SetupRulesGated, diff --git a/pkg/namespaced/clients/projects/fake/fake.go b/pkg/namespaced/clients/projects/fake/fake.go index a118de75..dd349f8e 100644 --- a/pkg/namespaced/clients/projects/fake/fake.go +++ b/pkg/namespaced/clients/projects/fake/fake.go @@ -53,6 +53,11 @@ type MockClient struct { MockListVariables func(pid any, opt *gitlab.ListProjectVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectVariable, *gitlab.Response, error) MockRemoveVariable func(pid any, key string, opt *gitlab.RemoveProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + MockGetFile func(pid any, fileName string, opt *gitlab.GetFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.File, *gitlab.Response, error) + MockCreateFile func(pid any, fileName string, opt *gitlab.CreateFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.FileInfo, *gitlab.Response, error) + MockUpdateFile func(pid any, fileName string, opt *gitlab.UpdateFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.FileInfo, *gitlab.Response, error) + MockDeleteFile func(pid any, fileName string, opt *gitlab.DeleteFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + MockGetProjectAccessToken func(pid any, id int64, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectAccessToken, *gitlab.Response, error) MockCreateProjectAccessToken func(pid any, opt *gitlab.CreateProjectAccessTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectAccessToken, *gitlab.Response, error) MockRevokeProjectAccessToken func(pid any, id int64, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) @@ -233,6 +238,26 @@ func (c *MockClient) ListVariables(pid any, opt *gitlab.ListProjectVariablesOpti return c.MockListVariables(pid, opt) } +// GetFile calls the underlying MockGetFile. +func (c *MockClient) GetFile(pid any, fileName string, opt *gitlab.GetFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.File, *gitlab.Response, error) { + return c.MockGetFile(pid, fileName, opt) +} + +// CreateFile calls the underlying MockCreateFile. +func (c *MockClient) CreateFile(pid any, fileName string, opt *gitlab.CreateFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.FileInfo, *gitlab.Response, error) { + return c.MockCreateFile(pid, fileName, opt) +} + +// UpdateFile calls the underlying MockUpdateFile. +func (c *MockClient) UpdateFile(pid any, fileName string, opt *gitlab.UpdateFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.FileInfo, *gitlab.Response, error) { + return c.MockUpdateFile(pid, fileName, opt) +} + +// DeleteFile calls the underlying MockDeleteFile. +func (c *MockClient) DeleteFile(pid any, fileName string, opt *gitlab.DeleteFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return c.MockDeleteFile(pid, fileName, opt) +} + // GetDeployKey calls the underlying MockGetDeployKey func (c *MockClient) GetDeployKey(pid any, deployKey int64, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectDeployKey, *gitlab.Response, error) { return c.MockGetDeployKey(pid, deployKey) diff --git a/pkg/namespaced/clients/projects/repositoryfile.go b/pkg/namespaced/clients/projects/repositoryfile.go new file mode 100644 index 00000000..29a19c56 --- /dev/null +++ b/pkg/namespaced/clients/projects/repositoryfile.go @@ -0,0 +1,255 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package projects + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" + "time" + + gitlab "gitlab.com/gitlab-org/api/client-go" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + projectsv1alpha1 "github.com/crossplane-contrib/provider-gitlab/apis/namespaced/projects/v1alpha1" + "github.com/crossplane-contrib/provider-gitlab/pkg/common" + "github.com/crossplane-contrib/provider-gitlab/pkg/namespaced/clients" +) + +const ( + defaultRepositoryFileEncoding = "text" +) + +// RepositoryFileClient defines GitLab repository file operations. +type RepositoryFileClient interface { + GetFile(pid any, fileName string, opt *gitlab.GetFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.File, *gitlab.Response, error) + CreateFile(pid any, fileName string, opt *gitlab.CreateFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.FileInfo, *gitlab.Response, error) + UpdateFile(pid any, fileName string, opt *gitlab.UpdateFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.FileInfo, *gitlab.Response, error) + DeleteFile(pid any, fileName string, opt *gitlab.DeleteFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) +} + +// NewRepositoryFileClient returns a new GitLab repository files service. +func NewRepositoryFileClient(cfg common.Config) RepositoryFileClient { + git := common.NewClient(cfg) + return git.RepositoryFiles +} + +// GenerateRepositoryFileObservation builds observation from a gitlab file. +func GenerateRepositoryFileObservation(file *gitlab.File, observedAt time.Time) projectsv1alpha1.RepositoryFileObservation { + observation := projectsv1alpha1.RepositoryFileObservation{ + LastObserveTime: &metav1.Time{Time: observedAt}, + } + if file == nil { + return observation + } + + observation.FilePath = file.FilePath + observation.BlobID = file.BlobID + observation.CommitID = file.CommitID + observation.LastCommitID = file.LastCommitID + observation.SHA256 = file.SHA256 + observation.Size = file.Size + + return observation +} + +// LateInitializeRepositoryFile fills empty optional fields from the external file. +func LateInitializeRepositoryFile(in *projectsv1alpha1.RepositoryFileParameters, file *gitlab.File) { + if in == nil || file == nil { + return + } + + if in.Encoding == nil && file.Encoding != "" { + in.Encoding = &file.Encoding + } + + if in.ExecuteFilemode == nil { + in.ExecuteFilemode = &file.ExecuteFilemode + } + + if in.Branch == "" && file.Ref != "" { + in.Branch = file.Ref + } +} + +// GenerateGetFileOptions generates get options. +func GenerateGetFileOptions(p *projectsv1alpha1.RepositoryFileParameters) *gitlab.GetFileOptions { + if p == nil { + return nil + } + return &gitlab.GetFileOptions{Ref: clients.StringToPtr(p.Branch)} +} + +// GenerateCreateFileOptions generates create options. +func GenerateCreateFileOptions(p *projectsv1alpha1.RepositoryFileParameters, content string) *gitlab.CreateFileOptions { + if p == nil { + return nil + } + return &gitlab.CreateFileOptions{ + Branch: clients.StringToPtr(p.Branch), + StartBranch: p.StartBranch, + Encoding: RepositoryFileEncodingOrDefault(p.Encoding), + AuthorEmail: p.AuthorEmail, + AuthorName: p.AuthorName, + Content: &content, + CommitMessage: RepositoryFileCommitMessageForCreate(p), + ExecuteFilemode: p.ExecuteFilemode, + } +} + +// GenerateUpdateFileOptions generates update options. +func GenerateUpdateFileOptions(p *projectsv1alpha1.RepositoryFileParameters, content string, lastCommitID *string) *gitlab.UpdateFileOptions { + if p == nil { + return nil + } + return &gitlab.UpdateFileOptions{ + Branch: clients.StringToPtr(p.Branch), + StartBranch: p.StartBranch, + Encoding: RepositoryFileEncodingOrDefault(p.Encoding), + AuthorEmail: p.AuthorEmail, + AuthorName: p.AuthorName, + Content: &content, + CommitMessage: RepositoryFileCommitMessageForUpdate(p), + LastCommitID: lastCommitID, + ExecuteFilemode: p.ExecuteFilemode, + } +} + +// GenerateDeleteFileOptions generates delete options. +func GenerateDeleteFileOptions(p *projectsv1alpha1.RepositoryFileParameters, lastCommitID *string) *gitlab.DeleteFileOptions { + if p == nil { + return nil + } + return &gitlab.DeleteFileOptions{ + Branch: clients.StringToPtr(p.Branch), + StartBranch: p.StartBranch, + AuthorEmail: p.AuthorEmail, + AuthorName: p.AuthorName, + CommitMessage: RepositoryFileCommitMessageForDelete(p), + LastCommitID: lastCommitID, + } +} + +// IsRepositoryFileUpToDate checks whether the desired file content and settings match the external file. +func IsRepositoryFileUpToDate(p *projectsv1alpha1.RepositoryFileParameters, external *gitlab.File, content string) bool { + if p == nil { + return true + } + if external == nil { + return false + } + + if p.FilePath != external.FilePath { + return false + } + + if p.Branch != external.Ref { + return false + } + + if !clients.IsComparableEqualToComparablePtr(p.ExecuteFilemode, external.ExecuteFilemode) { + return false + } + + return RepositoryFileContentSHA256(p, content) == external.SHA256 +} + +// ShouldObserveRepositoryFileNow checks whether observe should call GitLab now. +func ShouldObserveRepositoryFileNow(p *projectsv1alpha1.RepositoryFileParameters, observation *projectsv1alpha1.RepositoryFileObservation, defaultPollInterval time.Duration, now time.Time) (bool, error) { + if observation == nil || observation.LastObserveTime == nil { + return true, nil + } + + interval, err := RepositoryFileReconcileInterval(p, defaultPollInterval) + if err != nil { + return false, err + } + + if interval <= 0 { + return true, nil + } + + return !observation.LastObserveTime.Add(interval).After(now), nil +} + +// RepositoryFileReconcileInterval returns configured reconcile interval or default. +func RepositoryFileReconcileInterval(p *projectsv1alpha1.RepositoryFileParameters, defaultPollInterval time.Duration) (time.Duration, error) { + if p == nil || p.ReconcileInterval == nil || strings.TrimSpace(*p.ReconcileInterval) == "" { + return defaultPollInterval, nil + } + return time.ParseDuration(*p.ReconcileInterval) +} + +// RepositoryFileEncodingOrDefault returns configured encoding or text. +func RepositoryFileEncodingOrDefault(encoding *string) *string { + if encoding == nil || *encoding == "" { + return clients.StringToPtr(defaultRepositoryFileEncoding) + } + return encoding +} + +// RepositoryFileCommitMessageForCreate returns commit message for create. +func RepositoryFileCommitMessageForCreate(p *projectsv1alpha1.RepositoryFileParameters) *string { + if p != nil && p.CreateCommitMessage != nil && *p.CreateCommitMessage != "" { + return p.CreateCommitMessage + } + if p == nil { + return nil + } + return clients.StringToPtr(fmt.Sprintf("crossplane: create %s", p.FilePath)) +} + +// RepositoryFileCommitMessageForUpdate returns commit message for update. +func RepositoryFileCommitMessageForUpdate(p *projectsv1alpha1.RepositoryFileParameters) *string { + if p != nil && p.UpdateCommitMessage != nil && *p.UpdateCommitMessage != "" { + return p.UpdateCommitMessage + } + if p == nil { + return nil + } + return clients.StringToPtr(fmt.Sprintf("crossplane: update %s", p.FilePath)) +} + +// RepositoryFileCommitMessageForDelete returns commit message for delete. +func RepositoryFileCommitMessageForDelete(p *projectsv1alpha1.RepositoryFileParameters) *string { + if p != nil && p.DeleteCommitMessage != nil && *p.DeleteCommitMessage != "" { + return p.DeleteCommitMessage + } + if p == nil { + return nil + } + return clients.StringToPtr(fmt.Sprintf("crossplane: delete %s", p.FilePath)) +} + +// RepositoryFileContentSHA256 calculates the GitLab-compatible SHA256 for desired content. +func RepositoryFileContentSHA256(p *projectsv1alpha1.RepositoryFileParameters, content string) string { + bytes := []byte(content) + if p != nil && p.Encoding != nil && *p.Encoding == "base64" { + decoded, err := base64.StdEncoding.DecodeString(content) + if err == nil { + bytes = decoded + } + } + sum := sha256.Sum256(bytes) + return fmt.Sprintf("%x", sum) +} + +// RepositoryFileCreateOnly returns true if createOnly is enabled. +func RepositoryFileCreateOnly(p *projectsv1alpha1.RepositoryFileParameters) bool { + return p != nil && p.CreateOnly != nil && *p.CreateOnly +} diff --git a/pkg/namespaced/clients/projects/repositoryfile_test.go b/pkg/namespaced/clients/projects/repositoryfile_test.go new file mode 100644 index 00000000..28931973 --- /dev/null +++ b/pkg/namespaced/clients/projects/repositoryfile_test.go @@ -0,0 +1,240 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package projects + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + gitlab "gitlab.com/gitlab-org/api/client-go" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + projectsv1alpha1 "github.com/crossplane-contrib/provider-gitlab/apis/namespaced/projects/v1alpha1" +) + +func TestRepositoryFileContentSHA256(t *testing.T) { + plain := "hello world" + encoded := base64.StdEncoding.EncodeToString([]byte(plain)) + plainSum := fmt.Sprintf("%x", sha256.Sum256([]byte(plain))) + + text := "text" + base64Encoding := "base64" + + cases := map[string]struct { + params *projectsv1alpha1.RepositoryFileParameters + content string + wantHash string + }{ + "TextContent": { + params: &projectsv1alpha1.RepositoryFileParameters{Encoding: &text}, + content: plain, + wantHash: plainSum, + }, + "Base64Content": { + params: &projectsv1alpha1.RepositoryFileParameters{Encoding: &base64Encoding}, + content: encoded, + wantHash: plainSum, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := RepositoryFileContentSHA256(tc.params, tc.content) + if diff := cmp.Diff(tc.wantHash, got); diff != "" { + t.Fatalf("RepositoryFileContentSHA256(): -want, +got:\n%s", diff) + } + }) + } +} + +func TestShouldObserveRepositoryFileNow(t *testing.T) { + now := time.Now() + oneHour := "1h" + + cases := map[string]struct { + params *projectsv1alpha1.RepositoryFileParameters + observation *projectsv1alpha1.RepositoryFileObservation + defaultPoll time.Duration + want bool + wantErr bool + }{ + "NoLastObserveTime": { + params: &projectsv1alpha1.RepositoryFileParameters{}, + observation: &projectsv1alpha1.RepositoryFileObservation{}, + defaultPoll: time.Minute, + want: true, + }, + "SkipBeforeInterval": { + params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: &oneHour}, + observation: &projectsv1alpha1.RepositoryFileObservation{ + LastObserveTime: &metav1.Time{Time: now.Add(-30 * time.Minute)}, + }, + defaultPoll: time.Minute, + want: false, + }, + "ObserveAfterInterval": { + params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: &oneHour}, + observation: &projectsv1alpha1.RepositoryFileObservation{ + LastObserveTime: &metav1.Time{Time: now.Add(-2 * time.Hour)}, + }, + defaultPoll: time.Minute, + want: true, + }, + "InvalidInterval": { + params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: stringPtr("nope")}, + observation: &projectsv1alpha1.RepositoryFileObservation{LastObserveTime: &metav1.Time{Time: now}}, + defaultPoll: time.Minute, + wantErr: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := ShouldObserveRepositoryFileNow(tc.params, tc.observation, tc.defaultPoll, now) + if tc.wantErr { + if err == nil { + t.Fatal("ShouldObserveRepositoryFileNow() expected error") + } + return + } + if err != nil { + t.Fatalf("ShouldObserveRepositoryFileNow() error = %v", err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("ShouldObserveRepositoryFileNow(): -want, +got:\n%s", diff) + } + }) + } +} + +func TestGenerateRepositoryFileOptions(t *testing.T) { + content := "content" + branch := "main" + startBranch := "main" + authorEmail := "dev@example.org" + authorName := "Dev" + createCommit := "create" + updateCommit := "update" + deleteCommit := "delete" + encoding := "base64" + exec := true + lastCommitID := "abc123" + + params := &projectsv1alpha1.RepositoryFileParameters{ + Branch: branch, + StartBranch: &startBranch, + AuthorEmail: &authorEmail, + AuthorName: &authorName, + CreateCommitMessage: &createCommit, + UpdateCommitMessage: &updateCommit, + DeleteCommitMessage: &deleteCommit, + Encoding: &encoding, + ExecuteFilemode: &exec, + FilePath: "README.md", + } + + create := GenerateCreateFileOptions(params, content) + update := GenerateUpdateFileOptions(params, content, &lastCommitID) + deleteOpts := GenerateDeleteFileOptions(params, &lastCommitID) + + if diff := cmp.Diff(&gitlab.CreateFileOptions{ + Branch: &branch, + StartBranch: &startBranch, + Encoding: &encoding, + AuthorEmail: &authorEmail, + AuthorName: &authorName, + Content: &content, + CommitMessage: &createCommit, + ExecuteFilemode: &exec, + }, create); diff != "" { + t.Fatalf("GenerateCreateFileOptions(): -want, +got:\n%s", diff) + } + + if diff := cmp.Diff(&gitlab.UpdateFileOptions{ + Branch: &branch, + StartBranch: &startBranch, + Encoding: &encoding, + AuthorEmail: &authorEmail, + AuthorName: &authorName, + Content: &content, + CommitMessage: &updateCommit, + LastCommitID: &lastCommitID, + ExecuteFilemode: &exec, + }, update); diff != "" { + t.Fatalf("GenerateUpdateFileOptions(): -want, +got:\n%s", diff) + } + + if diff := cmp.Diff(&gitlab.DeleteFileOptions{ + Branch: &branch, + StartBranch: &startBranch, + AuthorEmail: &authorEmail, + AuthorName: &authorName, + CommitMessage: &deleteCommit, + LastCommitID: &lastCommitID, + }, deleteOpts); diff != "" { + t.Fatalf("GenerateDeleteFileOptions(): -want, +got:\n%s", diff) + } +} + +func TestIsRepositoryFileUpToDate(t *testing.T) { + content := "hello" + branch := "main" + sha := RepositoryFileContentSHA256(&projectsv1alpha1.RepositoryFileParameters{}, content) + + cases := map[string]struct { + params *projectsv1alpha1.RepositoryFileParameters + external *gitlab.File + content string + want bool + }{ + "Matching": { + params: &projectsv1alpha1.RepositoryFileParameters{ + FilePath: "README.md", + Branch: branch, + }, + external: &gitlab.File{FilePath: "README.md", Ref: branch, SHA256: sha}, + content: content, + want: true, + }, + "DifferentSHA": { + params: &projectsv1alpha1.RepositoryFileParameters{ + FilePath: "README.md", + Branch: branch, + }, + external: &gitlab.File{FilePath: "README.md", Ref: branch, SHA256: "different"}, + content: content, + want: false, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := IsRepositoryFileUpToDate(tc.params, tc.external, tc.content) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("IsRepositoryFileUpToDate(): -want, +got:\n%s", diff) + } + }) + } +} + +func stringPtr(s string) *string { + return &s +} diff --git a/pkg/namespaced/controller/projects/repositoryfiles/controller.go b/pkg/namespaced/controller/projects/repositoryfiles/controller.go new file mode 100644 index 00000000..0a6100fe --- /dev/null +++ b/pkg/namespaced/controller/projects/repositoryfiles/controller.go @@ -0,0 +1,301 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package repositoryfiles + +import ( + "context" + "time" + + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + "github.com/crossplane/crossplane-runtime/v2/pkg/controller" + "github.com/crossplane/crossplane-runtime/v2/pkg/event" + "github.com/crossplane/crossplane-runtime/v2/pkg/feature" + "github.com/crossplane/crossplane-runtime/v2/pkg/meta" + "github.com/crossplane/crossplane-runtime/v2/pkg/reconciler/managed" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource" + "github.com/crossplane/crossplane-runtime/v2/pkg/statemetrics" + "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" + gitlab "gitlab.com/gitlab-org/api/client-go" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + projectsv1alpha1 "github.com/crossplane-contrib/provider-gitlab/apis/namespaced/projects/v1alpha1" + "github.com/crossplane-contrib/provider-gitlab/pkg/common" + commonclients "github.com/crossplane-contrib/provider-gitlab/pkg/namespaced/clients" + projectclients "github.com/crossplane-contrib/provider-gitlab/pkg/namespaced/clients/projects" +) + +const ( + errNotRepositoryFile = "managed resource is not a Gitlab repository file custom resource" + errGetRepositoryFileFailed = "cannot get Gitlab repository file" + errCreateRepositoryFileFailed = "cannot create Gitlab repository file" + errUpdateRepositoryFileFailed = "cannot update Gitlab repository file" + errDeleteRepositoryFileFailed = "cannot delete Gitlab repository file" + errResolveContentFailed = "cannot resolve Gitlab repository file content" + errProjectIDMissing = "ProjectID is missing" + errExternalNameMismatch = "external-name must match spec.forProvider.filePath" + errReconcileIntervalInvalid = "invalid reconcileInterval" + errRepositoryFileContentMissing = "exactly one of content or contentSecretRef must be set" +) + +// SetupRepositoryFile adds a controller that reconciles RepositoryFiles. +func SetupRepositoryFile(mgr ctrl.Manager, o controller.Options) error { + name := managed.ControllerName(projectsv1alpha1.RepositoryFileGroupKind) + + reconcilerOpts := []managed.ReconcilerOption{ + managed.WithExternalConnecter(&connector{ + kube: mgr.GetClient(), + newGitlabClientFn: projectclients.NewRepositoryFileClient, + pollInterval: o.PollInterval, + }), + managed.WithInitializers(), + managed.WithPollInterval(o.PollInterval), + managed.WithLogger(o.Logger.WithValues("controller", name)), + managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), + } + + if o.Features.Enabled(feature.EnableBetaManagementPolicies) { + reconcilerOpts = append(reconcilerOpts, managed.WithManagementPolicies()) + } + + r := managed.NewReconciler(mgr, + resource.ManagedKind(projectsv1alpha1.RepositoryFileGroupVersionKind), + reconcilerOpts...) + + if err := mgr.Add(statemetrics.NewMRStateRecorder( + mgr.GetClient(), o.Logger, o.MetricOptions.MRStateMetrics, &projectsv1alpha1.RepositoryFileList{}, o.MetricOptions.PollStateMetricInterval)); err != nil { + return err + } + + return ctrl.NewControllerManagedBy(mgr). + Named(name). + For(&projectsv1alpha1.RepositoryFile{}). + Complete(r) +} + +// SetupRepositoryFileGated adds a controller with CRD gate support. +func SetupRepositoryFileGated(mgr ctrl.Manager, o controller.Options) error { + o.Gate.Register(func() { + if err := SetupRepositoryFile(mgr, o); err != nil { + mgr.GetLogger().Error(err, "unable to setup reconciler", "gvk", projectsv1alpha1.RepositoryFileGroupVersionKind.String()) + } + }, projectsv1alpha1.RepositoryFileGroupVersionKind) + return nil +} + +type connector struct { + kube client.Client + newGitlabClientFn func(cfg common.Config) projectclients.RepositoryFileClient + pollInterval time.Duration +} + +func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.ExternalClient, error) { + cr, ok := mg.(*projectsv1alpha1.RepositoryFile) + if !ok { + return nil, errors.New(errNotRepositoryFile) + } + cfg, err := common.GetConfig(ctx, c.kube, cr) + if err != nil { + return nil, err + } + return &external{kube: c.kube, client: c.newGitlabClientFn(*cfg), pollInterval: c.pollInterval}, nil +} + +type external struct { + kube client.Client + client projectclients.RepositoryFileClient + pollInterval time.Duration +} + +func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) { + cr, ok := mg.(*projectsv1alpha1.RepositoryFile) + if !ok { + return managed.ExternalObservation{}, errors.New(errNotRepositoryFile) + } + + if cr.Spec.ForProvider.ProjectID == nil { + return managed.ExternalObservation{}, errors.New(errProjectIDMissing) + } + + if externalName := meta.GetExternalName(cr); externalName != "" && externalName != cr.Spec.ForProvider.FilePath { + return managed.ExternalObservation{}, errors.New(errExternalNameMismatch) + } + + now := time.Now() + shouldObserve, err := projectclients.ShouldObserveRepositoryFileNow(&cr.Spec.ForProvider, &cr.Status.AtProvider, e.pollInterval, now) + if err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, errReconcileIntervalInvalid) + } + if !shouldObserve { + return managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + }, nil + } + + file, res, err := e.client.GetFile( + *cr.Spec.ForProvider.ProjectID, + cr.Spec.ForProvider.FilePath, + projectclients.GenerateGetFileOptions(&cr.Spec.ForProvider), + gitlab.WithContext(ctx), + ) + if err != nil { + if commonclients.IsResponseNotFound(res) { + return managed.ExternalObservation{}, nil + } + return managed.ExternalObservation{}, errors.Wrap(err, errGetRepositoryFileFailed) + } + + current := cr.Spec.ForProvider.DeepCopy() + projectclients.LateInitializeRepositoryFile(&cr.Spec.ForProvider, file) + cr.Status.AtProvider = projectclients.GenerateRepositoryFileObservation(file, now) + cr.Status.SetConditions(xpv1.Available()) + + if projectclients.RepositoryFileCreateOnly(&cr.Spec.ForProvider) { + return managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + ResourceLateInitialized: !cmp.Equal(current, &cr.Spec.ForProvider), + }, nil + } + + content, err := e.resolveContent(ctx, mg, &cr.Spec.ForProvider) + if err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, errResolveContentFailed) + } + + return managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: projectclients.IsRepositoryFileUpToDate(&cr.Spec.ForProvider, file, content), + ResourceLateInitialized: !cmp.Equal(current, &cr.Spec.ForProvider), + }, nil +} + +func (e *external) Create(ctx context.Context, mg resource.Managed) (managed.ExternalCreation, error) { + cr, ok := mg.(*projectsv1alpha1.RepositoryFile) + if !ok { + return managed.ExternalCreation{}, errors.New(errNotRepositoryFile) + } + if cr.Spec.ForProvider.ProjectID == nil { + return managed.ExternalCreation{}, errors.New(errProjectIDMissing) + } + + content, err := e.resolveContent(ctx, mg, &cr.Spec.ForProvider) + if err != nil { + return managed.ExternalCreation{}, errors.Wrap(err, errResolveContentFailed) + } + + cr.Status.SetConditions(xpv1.Creating()) + _, _, err = e.client.CreateFile( + *cr.Spec.ForProvider.ProjectID, + cr.Spec.ForProvider.FilePath, + projectclients.GenerateCreateFileOptions(&cr.Spec.ForProvider, content), + gitlab.WithContext(ctx), + ) + if err != nil { + return managed.ExternalCreation{}, errors.Wrap(err, errCreateRepositoryFileFailed) + } + + meta.SetExternalName(cr, cr.Spec.ForProvider.FilePath) + return managed.ExternalCreation{}, nil +} + +func (e *external) Update(ctx context.Context, mg resource.Managed) (managed.ExternalUpdate, error) { + cr, ok := mg.(*projectsv1alpha1.RepositoryFile) + if !ok { + return managed.ExternalUpdate{}, errors.New(errNotRepositoryFile) + } + if cr.Spec.ForProvider.ProjectID == nil { + return managed.ExternalUpdate{}, errors.New(errProjectIDMissing) + } + if projectclients.RepositoryFileCreateOnly(&cr.Spec.ForProvider) { + return managed.ExternalUpdate{}, nil + } + + content, err := e.resolveContent(ctx, mg, &cr.Spec.ForProvider) + if err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errResolveContentFailed) + } + + lastCommitID := emptyStringToNil(cr.Status.AtProvider.LastCommitID) + _, _, err = e.client.UpdateFile( + *cr.Spec.ForProvider.ProjectID, + cr.Spec.ForProvider.FilePath, + projectclients.GenerateUpdateFileOptions(&cr.Spec.ForProvider, content, lastCommitID), + gitlab.WithContext(ctx), + ) + if err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateRepositoryFileFailed) + } + return managed.ExternalUpdate{}, nil +} + +func (e *external) Delete(ctx context.Context, mg resource.Managed) (managed.ExternalDelete, error) { + cr, ok := mg.(*projectsv1alpha1.RepositoryFile) + if !ok { + return managed.ExternalDelete{}, errors.New(errNotRepositoryFile) + } + if cr.Spec.ForProvider.ProjectID == nil { + return managed.ExternalDelete{}, errors.New(errProjectIDMissing) + } + + cr.Status.SetConditions(xpv1.Deleting()) + res, err := e.client.DeleteFile( + *cr.Spec.ForProvider.ProjectID, + cr.Spec.ForProvider.FilePath, + projectclients.GenerateDeleteFileOptions(&cr.Spec.ForProvider, emptyStringToNil(cr.Status.AtProvider.LastCommitID)), + gitlab.WithContext(ctx), + ) + if err != nil { + if commonclients.IsResponseNotFound(res) { + return managed.ExternalDelete{}, nil + } + return managed.ExternalDelete{}, errors.Wrap(err, errDeleteRepositoryFileFailed) + } + return managed.ExternalDelete{}, nil +} + +func (e *external) Disconnect(ctx context.Context) error { + return nil +} + +func (e *external) resolveContent(ctx context.Context, mg resource.Managed, p *projectsv1alpha1.RepositoryFileParameters) (string, error) { + inline := p != nil && p.Content != nil + secret := p != nil && p.ContentSecretRef != nil + if inline == secret { + return "", errors.New(errRepositoryFileContentMissing) + } + if inline { + return *p.Content, nil + } + content, err := common.GetTokenValueFromLocalSecret(ctx, e.kube, mg, p.ContentSecretRef) + if err != nil { + return "", err + } + if content == nil { + return "", errors.New(errRepositoryFileContentMissing) + } + return *content, nil +} + +func emptyStringToNil(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/pkg/namespaced/controller/projects/repositoryfiles/controller_test.go b/pkg/namespaced/controller/projects/repositoryfiles/controller_test.go new file mode 100644 index 00000000..c0c9f831 --- /dev/null +++ b/pkg/namespaced/controller/projects/repositoryfiles/controller_test.go @@ -0,0 +1,170 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package repositoryfiles + +import ( + "context" + "net/http" + "testing" + "time" + + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + "github.com/crossplane/crossplane-runtime/v2/pkg/meta" + "github.com/crossplane/crossplane-runtime/v2/pkg/reconciler/managed" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + "github.com/google/go-cmp/cmp" + gitlab "gitlab.com/gitlab-org/api/client-go" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + projectsv1alpha1 "github.com/crossplane-contrib/provider-gitlab/apis/namespaced/projects/v1alpha1" + commonpkg "github.com/crossplane-contrib/provider-gitlab/pkg/common" + projectclients "github.com/crossplane-contrib/provider-gitlab/pkg/namespaced/clients/projects" + "github.com/crossplane-contrib/provider-gitlab/pkg/namespaced/clients/projects/fake" +) + +func TestResolveContent(t *testing.T) { + content := "hello" + cr := &projectsv1alpha1.RepositoryFile{} + cr.Namespace = "default" + + e := &external{} + got, err := e.resolveContent(context.Background(), cr, &projectsv1alpha1.RepositoryFileParameters{Content: &content}) + if err != nil { + t.Fatalf("resolveContent() error = %v", err) + } + if diff := cmp.Diff(content, got); diff != "" { + t.Fatalf("resolveContent(): -want, +got:\n%s", diff) + } +} + +func TestResolveContentFromSecret(t *testing.T) { + content := "hello" + cr := &projectsv1alpha1.RepositoryFile{} + cr.Namespace = "default" + + e := &external{kube: &test.MockClient{ + MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error { + secret := obj.(*corev1.Secret) + secret.Data = map[string][]byte{"content": []byte(content)} + return nil + }, + }} + + got, err := e.resolveContent(context.Background(), cr, &projectsv1alpha1.RepositoryFileParameters{ + ContentSecretRef: commonpkg.TestCreateLocalSecretKeySelector("", "content"), + }) + if err != nil { + t.Fatalf("resolveContent() error = %v", err) + } + if diff := cmp.Diff(content, got); diff != "" { + t.Fatalf("resolveContent(): -want, +got:\n%s", diff) + } +} + +func TestObserveCreateOnlyAndInterval(t *testing.T) { + projectID := "123" + filePath := "README.md" + branch := "main" + content := "hello" + createOnly := true + observeAt := metav1.NewTime(time.Now()) + + cr := &projectsv1alpha1.RepositoryFile{ + Spec: projectsv1alpha1.RepositoryFileSpec{ + ForProvider: projectsv1alpha1.RepositoryFileParameters{ + ProjectID: &projectID, + FilePath: filePath, + Branch: branch, + Content: &content, + CreateOnly: &createOnly, + ReconcileInterval: stringPtr("1h"), + }, + }, + Status: projectsv1alpha1.RepositoryFileStatus{ + AtProvider: projectsv1alpha1.RepositoryFileObservation{ + LastObserveTime: &observeAt, + }, + }, + } + + e := &external{pollInterval: time.Minute, client: &fake.MockClient{}} + obs, err := e.Observe(context.Background(), cr) + if err != nil { + t.Fatalf("Observe() error = %v", err) + } + if diff := cmp.Diff(managed.ExternalObservation{ResourceExists: true, ResourceUpToDate: true}, obs); diff != "" { + t.Fatalf("Observe(): -want, +got:\n%s", diff) + } +} + +func TestObserveExternalNameMismatch(t *testing.T) { + projectID := "123" + content := "hello" + cr := &projectsv1alpha1.RepositoryFile{ + Spec: projectsv1alpha1.RepositoryFileSpec{ + ForProvider: projectsv1alpha1.RepositoryFileParameters{ + ProjectID: &projectID, + FilePath: "README.md", + Branch: "main", + Content: &content, + }, + }, + } + meta.SetExternalName(cr, "DIFFERENT.md") + + e := &external{pollInterval: time.Minute, client: &fake.MockClient{}} + _, err := e.Observe(context.Background(), cr) + if err == nil || err.Error() != errExternalNameMismatch { + t.Fatalf("Observe() error = %v, want %s", err, errExternalNameMismatch) + } +} + +func TestDeleteIgnores404(t *testing.T) { + projectID := "123" + content := "hello" + cr := &projectsv1alpha1.RepositoryFile{ + Spec: projectsv1alpha1.RepositoryFileSpec{ + ForProvider: projectsv1alpha1.RepositoryFileParameters{ + ProjectID: &projectID, + FilePath: "README.md", + Branch: "main", + Content: &content, + }, + }, + } + + e := &external{client: &fake.MockClient{ + MockDeleteFile: func(pid any, fileName string, opt *gitlab.DeleteFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return &gitlab.Response{Response: &http.Response{StatusCode: 404}}, context.DeadlineExceeded + }, + }} + + _, err := e.Delete(context.Background(), cr) + if err != nil { + t.Fatalf("Delete() error = %v", err) + } + if got := cr.Status.ConditionedStatus.Conditions[0].Reason; got != xpv1.Deleting().Reason { + t.Fatalf("Delete() condition reason = %s, want %s", got, xpv1.Deleting().Reason) + } + _ = projectclients.RepositoryFileCreateOnly +} + +func stringPtr(s string) *string { + return &s +} diff --git a/pkg/namespaced/controller/projects/setup.go b/pkg/namespaced/controller/projects/setup.go index 6e41469c..aa9e73e1 100644 --- a/pkg/namespaced/controller/projects/setup.go +++ b/pkg/namespaced/controller/projects/setup.go @@ -33,6 +33,7 @@ import ( "github.com/crossplane-contrib/provider-gitlab/pkg/namespaced/controller/projects/projectsharegroups" "github.com/crossplane-contrib/provider-gitlab/pkg/namespaced/controller/projects/protectedbranches" "github.com/crossplane-contrib/provider-gitlab/pkg/namespaced/controller/projects/protectedenvironments" + "github.com/crossplane-contrib/provider-gitlab/pkg/namespaced/controller/projects/repositoryfiles" "github.com/crossplane-contrib/provider-gitlab/pkg/namespaced/controller/projects/runners" "github.com/crossplane-contrib/provider-gitlab/pkg/namespaced/controller/projects/variables" ) @@ -46,6 +47,7 @@ func Setup(mgr ctrl.Manager, o controller.Options) error { deploytokens.SetupDeployToken, accesstokens.SetupAccessToken, variables.SetupVariable, + repositoryfiles.SetupRepositoryFile, deploykeys.SetupDeployKey, pipelineschedules.SetupPipelineSchedule, approvalrules.SetupRules, @@ -73,6 +75,7 @@ func SetupGated(mgr ctrl.Manager, o controller.Options) error { deploytokens.SetupDeployTokenGated, accesstokens.SetupAccessTokenGated, variables.SetupVariableGated, + repositoryfiles.SetupRepositoryFileGated, deploykeys.SetupDeployKeyGated, pipelineschedules.SetupPipelineScheduleGated, approvalrules.SetupRulesGated, From 84f8770d3cdbef6c78aa05c4003a3543999adc66 Mon Sep 17 00:00:00 2001 From: Henry Sachs Date: Thu, 2 Apr 2026 10:12:12 +0200 Subject: [PATCH 2/3] fix: align createOnly with management policies Signed-off-by: Henry Sachs --- .../v1alpha1/zz_repositoryfile_types.go | 6 + .../projects/v1alpha1/repositoryfile_types.go | 6 + ....gitlab.crossplane.io_repositoryfiles.yaml | 8 + ...itlab.m.crossplane.io_repositoryfiles.yaml | 8 + .../clients/projects/zz_repositoryfile.go | 30 ++ .../projects/zz_repositoryfile_test.go | 263 ++++++++++++++++++ .../projects/repositoryfiles/zz_controller.go | 39 ++- .../repositoryfiles/zz_controller_test.go | 207 ++++++++++++++ .../clients/projects/repositoryfile.go | 30 ++ .../clients/projects/repositoryfile_test.go | 21 ++ .../projects/repositoryfiles/controller.go | 39 ++- .../repositoryfiles/controller_test.go | 43 ++- 12 files changed, 672 insertions(+), 28 deletions(-) create mode 100644 pkg/cluster/clients/projects/zz_repositoryfile_test.go create mode 100644 pkg/cluster/controller/projects/repositoryfiles/zz_controller_test.go diff --git a/apis/cluster/projects/v1alpha1/zz_repositoryfile_types.go b/apis/cluster/projects/v1alpha1/zz_repositoryfile_types.go index d79940f0..92424ff1 100644 --- a/apis/cluster/projects/v1alpha1/zz_repositoryfile_types.go +++ b/apis/cluster/projects/v1alpha1/zz_repositoryfile_types.go @@ -23,6 +23,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +const ( + // RepositoryFileCreateOnlyManagementPolicies is the management policy set implied by createOnly. + RepositoryFileCreateOnlyManagementPolicies = `{"Observe","Create","Delete"}` +) + // RepositoryFileParameters define the desired state of a GitLab Repository File. // https://docs.gitlab.com/api/repository_files/ type RepositoryFileParameters struct { @@ -158,6 +163,7 @@ type RepositoryFileStatus struct { // +kubebuilder:object:root=true // A RepositoryFile is a managed resource that represents a file in a GitLab repository. +// +kubebuilder:validation:XValidation:rule="!has(self.spec.forProvider.createOnly) || !self.spec.forProvider.createOnly || !has(self.spec.managementPolicies) || self.spec.managementPolicies == ['Observe', 'Create', 'Delete'] || self.spec.managementPolicies == ['Create', 'Observe', 'Delete'] || self.spec.managementPolicies == ['Create', 'Delete', 'Observe']",message="createOnly=true requires managementPolicies to be exactly [Observe, Create, Delete] when managementPolicies is set explicitly" // +kubebuilder:printcolumn:name="FILE",type="string",JSONPath=".spec.forProvider.filePath" // +kubebuilder:printcolumn:name="BRANCH",type="string",JSONPath=".spec.forProvider.branch" // +kubebuilder:printcolumn:name="READY",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" diff --git a/apis/namespaced/projects/v1alpha1/repositoryfile_types.go b/apis/namespaced/projects/v1alpha1/repositoryfile_types.go index 3e81237c..4b58bf6e 100644 --- a/apis/namespaced/projects/v1alpha1/repositoryfile_types.go +++ b/apis/namespaced/projects/v1alpha1/repositoryfile_types.go @@ -23,6 +23,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +const ( + // RepositoryFileCreateOnlyManagementPolicies is the management policy set implied by createOnly. + RepositoryFileCreateOnlyManagementPolicies = `{"Observe","Create","Delete"}` +) + // RepositoryFileParameters define the desired state of a GitLab Repository File. // https://docs.gitlab.com/api/repository_files/ type RepositoryFileParameters struct { @@ -158,6 +163,7 @@ type RepositoryFileStatus struct { // +kubebuilder:object:root=true // A RepositoryFile is a managed resource that represents a file in a GitLab repository. +// +kubebuilder:validation:XValidation:rule="!has(self.spec.forProvider.createOnly) || !self.spec.forProvider.createOnly || !has(self.spec.managementPolicies) || self.spec.managementPolicies == ['Observe', 'Create', 'Delete'] || self.spec.managementPolicies == ['Create', 'Observe', 'Delete'] || self.spec.managementPolicies == ['Create', 'Delete', 'Observe']",message="createOnly=true requires managementPolicies to be exactly [Observe, Create, Delete] when managementPolicies is set explicitly" // +kubebuilder:printcolumn:name="FILE",type="string",JSONPath=".spec.forProvider.filePath" // +kubebuilder:printcolumn:name="BRANCH",type="string",JSONPath=".spec.forProvider.branch" // +kubebuilder:printcolumn:name="READY",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" diff --git a/package/crds/projects.gitlab.crossplane.io_repositoryfiles.yaml b/package/crds/projects.gitlab.crossplane.io_repositoryfiles.yaml index cc7c8f2a..23ef2334 100644 --- a/package/crds/projects.gitlab.crossplane.io_repositoryfiles.yaml +++ b/package/crds/projects.gitlab.crossplane.io_repositoryfiles.yaml @@ -422,6 +422,14 @@ spec: required: - spec type: object + x-kubernetes-validations: + - message: createOnly=true requires managementPolicies to be exactly [Observe, + Create, Delete] when managementPolicies is set explicitly + rule: '!has(self.spec.forProvider.createOnly) || !self.spec.forProvider.createOnly + || !has(self.spec.managementPolicies) || self.spec.managementPolicies + == [''Observe'', ''Create'', ''Delete''] || self.spec.managementPolicies + == [''Create'', ''Observe'', ''Delete''] || self.spec.managementPolicies + == [''Create'', ''Delete'', ''Observe'']' served: true storage: true subresources: diff --git a/package/crds/projects.gitlab.m.crossplane.io_repositoryfiles.yaml b/package/crds/projects.gitlab.m.crossplane.io_repositoryfiles.yaml index 822719c1..8cc120c8 100644 --- a/package/crds/projects.gitlab.m.crossplane.io_repositoryfiles.yaml +++ b/package/crds/projects.gitlab.m.crossplane.io_repositoryfiles.yaml @@ -381,6 +381,14 @@ spec: required: - spec type: object + x-kubernetes-validations: + - message: createOnly=true requires managementPolicies to be exactly [Observe, + Create, Delete] when managementPolicies is set explicitly + rule: '!has(self.spec.forProvider.createOnly) || !self.spec.forProvider.createOnly + || !has(self.spec.managementPolicies) || self.spec.managementPolicies + == [''Observe'', ''Create'', ''Delete''] || self.spec.managementPolicies + == [''Create'', ''Observe'', ''Delete''] || self.spec.managementPolicies + == [''Create'', ''Delete'', ''Observe'']' served: true storage: true subresources: diff --git a/pkg/cluster/clients/projects/zz_repositoryfile.go b/pkg/cluster/clients/projects/zz_repositoryfile.go index afe01a68..d74d4ac9 100644 --- a/pkg/cluster/clients/projects/zz_repositoryfile.go +++ b/pkg/cluster/clients/projects/zz_repositoryfile.go @@ -25,6 +25,7 @@ import ( "strings" "time" + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" gitlab "gitlab.com/gitlab-org/api/client-go" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -255,3 +256,32 @@ func RepositoryFileContentSHA256(p *projectsv1alpha1.RepositoryFileParameters, c func RepositoryFileCreateOnly(p *projectsv1alpha1.RepositoryFileParameters) bool { return p != nil && p.CreateOnly != nil && *p.CreateOnly } + +// RepositoryFileCreateOnlyPolicies returns the policy set implied by createOnly. +func RepositoryFileCreateOnlyPolicies() xpv1.ManagementPolicies { + return xpv1.ManagementPolicies{ + xpv1.ManagementActionObserve, + xpv1.ManagementActionCreate, + xpv1.ManagementActionDelete, + } +} + +// RepositoryFilePoliciesEqual compares policy sets ignoring order. +func RepositoryFilePoliciesEqual(a, b xpv1.ManagementPolicies) bool { + if len(a) != len(b) { + return false + } + counts := map[xpv1.ManagementAction]int{} + for _, action := range a { + counts[action]++ + } + for _, action := range b { + counts[action]-- + } + for _, count := range counts { + if count != 0 { + return false + } + } + return true +} diff --git a/pkg/cluster/clients/projects/zz_repositoryfile_test.go b/pkg/cluster/clients/projects/zz_repositoryfile_test.go new file mode 100644 index 00000000..62939be8 --- /dev/null +++ b/pkg/cluster/clients/projects/zz_repositoryfile_test.go @@ -0,0 +1,263 @@ +/* +Copyright 2026 The Crossplane Authors. + +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 hack/generate-cluster-scope.go - DO NOT EDIT. + +package projects + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "testing" + "time" + + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + "github.com/google/go-cmp/cmp" + gitlab "gitlab.com/gitlab-org/api/client-go" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + projectsv1alpha1 "github.com/crossplane-contrib/provider-gitlab/apis/cluster/projects/v1alpha1" +) + +func TestRepositoryFileContentSHA256(t *testing.T) { + plain := "hello world" + encoded := base64.StdEncoding.EncodeToString([]byte(plain)) + plainSum := fmt.Sprintf("%x", sha256.Sum256([]byte(plain))) + + text := "text" + base64Encoding := "base64" + + cases := map[string]struct { + params *projectsv1alpha1.RepositoryFileParameters + content string + wantHash string + }{ + "TextContent": { + params: &projectsv1alpha1.RepositoryFileParameters{Encoding: &text}, + content: plain, + wantHash: plainSum, + }, + "Base64Content": { + params: &projectsv1alpha1.RepositoryFileParameters{Encoding: &base64Encoding}, + content: encoded, + wantHash: plainSum, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := RepositoryFileContentSHA256(tc.params, tc.content) + if diff := cmp.Diff(tc.wantHash, got); diff != "" { + t.Fatalf("RepositoryFileContentSHA256(): -want, +got:\n%s", diff) + } + }) + } +} + +func TestShouldObserveRepositoryFileNow(t *testing.T) { + now := time.Now() + oneHour := "1h" + + cases := map[string]struct { + params *projectsv1alpha1.RepositoryFileParameters + observation *projectsv1alpha1.RepositoryFileObservation + defaultPoll time.Duration + want bool + wantErr bool + }{ + "NoLastObserveTime": { + params: &projectsv1alpha1.RepositoryFileParameters{}, + observation: &projectsv1alpha1.RepositoryFileObservation{}, + defaultPoll: time.Minute, + want: true, + }, + "SkipBeforeInterval": { + params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: &oneHour}, + observation: &projectsv1alpha1.RepositoryFileObservation{ + LastObserveTime: &metav1.Time{Time: now.Add(-30 * time.Minute)}, + }, + defaultPoll: time.Minute, + want: false, + }, + "ObserveAfterInterval": { + params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: &oneHour}, + observation: &projectsv1alpha1.RepositoryFileObservation{ + LastObserveTime: &metav1.Time{Time: now.Add(-2 * time.Hour)}, + }, + defaultPoll: time.Minute, + want: true, + }, + "InvalidInterval": { + params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: stringPtr("nope")}, + observation: &projectsv1alpha1.RepositoryFileObservation{LastObserveTime: &metav1.Time{Time: now}}, + defaultPoll: time.Minute, + wantErr: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := ShouldObserveRepositoryFileNow(tc.params, tc.observation, tc.defaultPoll, now) + if tc.wantErr { + if err == nil { + t.Fatal("ShouldObserveRepositoryFileNow() expected error") + } + return + } + if err != nil { + t.Fatalf("ShouldObserveRepositoryFileNow() error = %v", err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("ShouldObserveRepositoryFileNow(): -want, +got:\n%s", diff) + } + }) + } +} + +func TestGenerateRepositoryFileOptions(t *testing.T) { + content := "content" + branch := "main" + startBranch := "main" + authorEmail := "dev@example.org" + authorName := "Dev" + createCommit := "create" + updateCommit := "update" + deleteCommit := "delete" + encoding := "base64" + exec := true + lastCommitID := "abc123" + + params := &projectsv1alpha1.RepositoryFileParameters{ + Branch: branch, + StartBranch: &startBranch, + AuthorEmail: &authorEmail, + AuthorName: &authorName, + CreateCommitMessage: &createCommit, + UpdateCommitMessage: &updateCommit, + DeleteCommitMessage: &deleteCommit, + Encoding: &encoding, + ExecuteFilemode: &exec, + FilePath: "README.md", + } + + create := GenerateCreateFileOptions(params, content) + update := GenerateUpdateFileOptions(params, content, &lastCommitID) + deleteOpts := GenerateDeleteFileOptions(params, &lastCommitID) + + if diff := cmp.Diff(&gitlab.CreateFileOptions{ + Branch: &branch, + StartBranch: &startBranch, + Encoding: &encoding, + AuthorEmail: &authorEmail, + AuthorName: &authorName, + Content: &content, + CommitMessage: &createCommit, + ExecuteFilemode: &exec, + }, create); diff != "" { + t.Fatalf("GenerateCreateFileOptions(): -want, +got:\n%s", diff) + } + + if diff := cmp.Diff(&gitlab.UpdateFileOptions{ + Branch: &branch, + StartBranch: &startBranch, + Encoding: &encoding, + AuthorEmail: &authorEmail, + AuthorName: &authorName, + Content: &content, + CommitMessage: &updateCommit, + LastCommitID: &lastCommitID, + ExecuteFilemode: &exec, + }, update); diff != "" { + t.Fatalf("GenerateUpdateFileOptions(): -want, +got:\n%s", diff) + } + + if diff := cmp.Diff(&gitlab.DeleteFileOptions{ + Branch: &branch, + StartBranch: &startBranch, + AuthorEmail: &authorEmail, + AuthorName: &authorName, + CommitMessage: &deleteCommit, + LastCommitID: &lastCommitID, + }, deleteOpts); diff != "" { + t.Fatalf("GenerateDeleteFileOptions(): -want, +got:\n%s", diff) + } +} + +func TestIsRepositoryFileUpToDate(t *testing.T) { + content := "hello" + branch := "main" + sha := RepositoryFileContentSHA256(&projectsv1alpha1.RepositoryFileParameters{}, content) + + cases := map[string]struct { + params *projectsv1alpha1.RepositoryFileParameters + external *gitlab.File + content string + want bool + }{ + "Matching": { + params: &projectsv1alpha1.RepositoryFileParameters{ + FilePath: "README.md", + Branch: branch, + }, + external: &gitlab.File{FilePath: "README.md", Ref: branch, SHA256: sha}, + content: content, + want: true, + }, + "DifferentSHA": { + params: &projectsv1alpha1.RepositoryFileParameters{ + FilePath: "README.md", + Branch: branch, + }, + external: &gitlab.File{FilePath: "README.md", Ref: branch, SHA256: "different"}, + content: content, + want: false, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := IsRepositoryFileUpToDate(tc.params, tc.external, tc.content) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("IsRepositoryFileUpToDate(): -want, +got:\n%s", diff) + } + }) + } +} + +func TestRepositoryFilePoliciesEqual(t *testing.T) { + a := xpv1.ManagementPolicies{xpv1.ManagementActionObserve, xpv1.ManagementActionCreate, xpv1.ManagementActionDelete} + b := xpv1.ManagementPolicies{xpv1.ManagementActionDelete, xpv1.ManagementActionObserve, xpv1.ManagementActionCreate} + c := xpv1.ManagementPolicies{xpv1.ManagementActionAll} + + if !RepositoryFilePoliciesEqual(a, b) { + t.Fatal("RepositoryFilePoliciesEqual() = false, want true") + } + if RepositoryFilePoliciesEqual(a, c) { + t.Fatal("RepositoryFilePoliciesEqual() = true, want false") + } +} + +func TestRepositoryFileCreateOnlyPolicies(t *testing.T) { + want := xpv1.ManagementPolicies{xpv1.ManagementActionObserve, xpv1.ManagementActionCreate, xpv1.ManagementActionDelete} + if diff := cmp.Diff(want, RepositoryFileCreateOnlyPolicies()); diff != "" { + t.Fatalf("RepositoryFileCreateOnlyPolicies(): -want, +got:\n%s", diff) + } +} + +func stringPtr(s string) *string { + return &s +} diff --git a/pkg/cluster/controller/projects/repositoryfiles/zz_controller.go b/pkg/cluster/controller/projects/repositoryfiles/zz_controller.go index 41aa3321..ae7cd469 100644 --- a/pkg/cluster/controller/projects/repositoryfiles/zz_controller.go +++ b/pkg/cluster/controller/projects/repositoryfiles/zz_controller.go @@ -53,6 +53,7 @@ const ( errExternalNameMismatch = "external-name must match spec.forProvider.filePath" errReconcileIntervalInvalid = "invalid reconcileInterval" errRepositoryFileContentMissing = "exactly one of content or contentSecretRef must be set" + errCreateOnlyPolicyConflict = "createOnly conflicts with explicit managementPolicies" ) // SetupRepositoryFile adds a controller that reconciles RepositoryFiles. @@ -65,7 +66,7 @@ func SetupRepositoryFile(mgr ctrl.Manager, o controller.Options) error { newGitlabClientFn: projectclients.NewRepositoryFileClient, pollInterval: o.PollInterval, }), - managed.WithInitializers(), + managed.WithInitializers(managed.NewNameAsExternalName(mgr.GetClient()), createOnlyInitializer{client: mgr.GetClient()}), managed.WithPollInterval(o.PollInterval), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -124,6 +125,31 @@ type external struct { pollInterval time.Duration } +type createOnlyInitializer struct { + client client.Client +} + +func (i createOnlyInitializer) Initialize(ctx context.Context, mg resource.Managed) error { + cr, ok := mg.(*projectsv1alpha1.RepositoryFile) + if !ok { + return errors.New(errNotRepositoryFile) + } + if !projectclients.RepositoryFileCreateOnly(&cr.Spec.ForProvider) { + return nil + } + + createOnlyPolicies := projectclients.RepositoryFileCreateOnlyPolicies() + if len(cr.GetManagementPolicies()) > 0 && !projectclients.RepositoryFilePoliciesEqual(cr.GetManagementPolicies(), createOnlyPolicies) { + return errors.New(errCreateOnlyPolicyConflict) + } + if projectclients.RepositoryFilePoliciesEqual(cr.GetManagementPolicies(), createOnlyPolicies) { + return nil + } + + cr.SetManagementPolicies(createOnlyPolicies) + return errors.Wrap(i.client.Update(ctx, cr), "cannot update RepositoryFile managementPolicies") +} + func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) { cr, ok := mg.(*projectsv1alpha1.RepositoryFile) if !ok { @@ -168,14 +194,6 @@ func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex cr.Status.AtProvider = projectclients.GenerateRepositoryFileObservation(file, now) cr.Status.SetConditions(xpv1.Available()) - if projectclients.RepositoryFileCreateOnly(&cr.Spec.ForProvider) { - return managed.ExternalObservation{ - ResourceExists: true, - ResourceUpToDate: true, - ResourceLateInitialized: !cmp.Equal(current, &cr.Spec.ForProvider), - }, nil - } - content, err := e.resolveContent(ctx, mg, &cr.Spec.ForProvider) if err != nil { return managed.ExternalObservation{}, errors.Wrap(err, errResolveContentFailed) @@ -225,9 +243,6 @@ func (e *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext if cr.Spec.ForProvider.ProjectID == nil { return managed.ExternalUpdate{}, errors.New(errProjectIDMissing) } - if projectclients.RepositoryFileCreateOnly(&cr.Spec.ForProvider) { - return managed.ExternalUpdate{}, nil - } content, err := e.resolveContent(ctx, mg, &cr.Spec.ForProvider) if err != nil { diff --git a/pkg/cluster/controller/projects/repositoryfiles/zz_controller_test.go b/pkg/cluster/controller/projects/repositoryfiles/zz_controller_test.go new file mode 100644 index 00000000..9b8ddd8a --- /dev/null +++ b/pkg/cluster/controller/projects/repositoryfiles/zz_controller_test.go @@ -0,0 +1,207 @@ +/* +Copyright 2026 The Crossplane Authors. + +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 hack/generate-cluster-scope.go - DO NOT EDIT. + +package repositoryfiles + +import ( + "context" + "net/http" + "testing" + "time" + + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + "github.com/crossplane/crossplane-runtime/v2/pkg/meta" + "github.com/crossplane/crossplane-runtime/v2/pkg/reconciler/managed" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + "github.com/google/go-cmp/cmp" + gitlab "gitlab.com/gitlab-org/api/client-go" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + projectsv1alpha1 "github.com/crossplane-contrib/provider-gitlab/apis/cluster/projects/v1alpha1" + projectclients "github.com/crossplane-contrib/provider-gitlab/pkg/cluster/clients/projects" + "github.com/crossplane-contrib/provider-gitlab/pkg/cluster/clients/projects/fake" + commonpkg "github.com/crossplane-contrib/provider-gitlab/pkg/common" +) + +func TestResolveContent(t *testing.T) { + content := "hello" + cr := &projectsv1alpha1.RepositoryFile{} + cr.Namespace = "default" + + e := &external{} + got, err := e.resolveContent(context.Background(), cr, &projectsv1alpha1.RepositoryFileParameters{Content: &content}) + if err != nil { + t.Fatalf("resolveContent() error = %v", err) + } + if diff := cmp.Diff(content, got); diff != "" { + t.Fatalf("resolveContent(): -want, +got:\n%s", diff) + } +} + +func TestResolveContentFromSecret(t *testing.T) { + content := "hello" + cr := &projectsv1alpha1.RepositoryFile{} + cr.Namespace = "default" + + e := &external{kube: &test.MockClient{ + MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error { + secret := obj.(*corev1.Secret) + secret.Data = map[string][]byte{"content": []byte(content)} + return nil + }, + }} + + got, err := e.resolveContent(context.Background(), cr, &projectsv1alpha1.RepositoryFileParameters{ + ContentSecretRef: commonpkg.TestCreateSecretKeySelector("", "content"), + }) + if err != nil { + t.Fatalf("resolveContent() error = %v", err) + } + if diff := cmp.Diff(content, got); diff != "" { + t.Fatalf("resolveContent(): -want, +got:\n%s", diff) + } +} + +func TestObserveIntervalSkip(t *testing.T) { + projectID := "123" + filePath := "README.md" + branch := "main" + content := "hello" + observeAt := metav1.NewTime(time.Now()) + + cr := &projectsv1alpha1.RepositoryFile{ + Spec: projectsv1alpha1.RepositoryFileSpec{ + ForProvider: projectsv1alpha1.RepositoryFileParameters{ + ProjectID: &projectID, + FilePath: filePath, + Branch: branch, + Content: &content, + ReconcileInterval: stringPtr("1h"), + }, + }, + Status: projectsv1alpha1.RepositoryFileStatus{ + AtProvider: projectsv1alpha1.RepositoryFileObservation{ + LastObserveTime: &observeAt, + }, + }, + } + + e := &external{pollInterval: time.Minute, client: &fake.MockClient{}} + obs, err := e.Observe(context.Background(), cr) + if err != nil { + t.Fatalf("Observe() error = %v", err) + } + if diff := cmp.Diff(managed.ExternalObservation{ResourceExists: true, ResourceUpToDate: true}, obs); diff != "" { + t.Fatalf("Observe(): -want, +got:\n%s", diff) + } +} + +func TestCreateOnlyInitializer(t *testing.T) { + createOnly := true + cr := &projectsv1alpha1.RepositoryFile{} + cr.Spec.ForProvider.CreateOnly = &createOnly + + updated := false + init := createOnlyInitializer{client: &test.MockClient{ + MockUpdate: func(_ context.Context, _ client.Object, _ ...client.UpdateOption) error { + updated = true + return nil + }, + }} + + if err := init.Initialize(context.Background(), resource.Managed(cr)); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + if !updated { + t.Fatal("Initialize() did not persist policy") + } + if diff := cmp.Diff(projectclients.RepositoryFileCreateOnlyPolicies(), cr.GetManagementPolicies()); diff != "" { + t.Fatalf("Initialize(): -want, +got:\n%s", diff) + } +} + +func TestCreateOnlyInitializerConflict(t *testing.T) { + createOnly := true + cr := &projectsv1alpha1.RepositoryFile{} + cr.Spec.ForProvider.CreateOnly = &createOnly + cr.SetManagementPolicies(xpv1.ManagementPolicies{xpv1.ManagementActionAll}) + + init := createOnlyInitializer{client: &test.MockClient{}} + err := init.Initialize(context.Background(), resource.Managed(cr)) + if err == nil || err.Error() != errCreateOnlyPolicyConflict { + t.Fatalf("Initialize() error = %v, want %s", err, errCreateOnlyPolicyConflict) + } +} + +func TestObserveExternalNameMismatch(t *testing.T) { + projectID := "123" + content := "hello" + cr := &projectsv1alpha1.RepositoryFile{ + Spec: projectsv1alpha1.RepositoryFileSpec{ + ForProvider: projectsv1alpha1.RepositoryFileParameters{ + ProjectID: &projectID, + FilePath: "README.md", + Branch: "main", + Content: &content, + }, + }, + } + meta.SetExternalName(cr, "DIFFERENT.md") + + e := &external{pollInterval: time.Minute, client: &fake.MockClient{}} + _, err := e.Observe(context.Background(), cr) + if err == nil || err.Error() != errExternalNameMismatch { + t.Fatalf("Observe() error = %v, want %s", err, errExternalNameMismatch) + } +} + +func TestDeleteIgnores404(t *testing.T) { + projectID := "123" + content := "hello" + cr := &projectsv1alpha1.RepositoryFile{ + Spec: projectsv1alpha1.RepositoryFileSpec{ + ForProvider: projectsv1alpha1.RepositoryFileParameters{ + ProjectID: &projectID, + FilePath: "README.md", + Branch: "main", + Content: &content, + }, + }, + } + + e := &external{client: &fake.MockClient{ + MockDeleteFile: func(pid any, fileName string, opt *gitlab.DeleteFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return &gitlab.Response{Response: &http.Response{StatusCode: 404}}, context.DeadlineExceeded + }, + }} + + _, err := e.Delete(context.Background(), cr) + if err != nil { + t.Fatalf("Delete() error = %v", err) + } + if got := cr.Status.ConditionedStatus.Conditions[0].Reason; got != xpv1.Deleting().Reason { + t.Fatalf("Delete() condition reason = %s, want %s", got, xpv1.Deleting().Reason) + } +} + +func stringPtr(s string) *string { + return &s +} diff --git a/pkg/namespaced/clients/projects/repositoryfile.go b/pkg/namespaced/clients/projects/repositoryfile.go index 29a19c56..f32b7a6b 100644 --- a/pkg/namespaced/clients/projects/repositoryfile.go +++ b/pkg/namespaced/clients/projects/repositoryfile.go @@ -23,6 +23,7 @@ import ( "strings" "time" + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" gitlab "gitlab.com/gitlab-org/api/client-go" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -253,3 +254,32 @@ func RepositoryFileContentSHA256(p *projectsv1alpha1.RepositoryFileParameters, c func RepositoryFileCreateOnly(p *projectsv1alpha1.RepositoryFileParameters) bool { return p != nil && p.CreateOnly != nil && *p.CreateOnly } + +// RepositoryFileCreateOnlyPolicies returns the policy set implied by createOnly. +func RepositoryFileCreateOnlyPolicies() xpv1.ManagementPolicies { + return xpv1.ManagementPolicies{ + xpv1.ManagementActionObserve, + xpv1.ManagementActionCreate, + xpv1.ManagementActionDelete, + } +} + +// RepositoryFilePoliciesEqual compares policy sets ignoring order. +func RepositoryFilePoliciesEqual(a, b xpv1.ManagementPolicies) bool { + if len(a) != len(b) { + return false + } + counts := map[xpv1.ManagementAction]int{} + for _, action := range a { + counts[action]++ + } + for _, action := range b { + counts[action]-- + } + for _, count := range counts { + if count != 0 { + return false + } + } + return true +} diff --git a/pkg/namespaced/clients/projects/repositoryfile_test.go b/pkg/namespaced/clients/projects/repositoryfile_test.go index 28931973..af205d03 100644 --- a/pkg/namespaced/clients/projects/repositoryfile_test.go +++ b/pkg/namespaced/clients/projects/repositoryfile_test.go @@ -23,6 +23,7 @@ import ( "testing" "time" + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" "github.com/google/go-cmp/cmp" gitlab "gitlab.com/gitlab-org/api/client-go" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -235,6 +236,26 @@ func TestIsRepositoryFileUpToDate(t *testing.T) { } } +func TestRepositoryFilePoliciesEqual(t *testing.T) { + a := xpv1.ManagementPolicies{xpv1.ManagementActionObserve, xpv1.ManagementActionCreate, xpv1.ManagementActionDelete} + b := xpv1.ManagementPolicies{xpv1.ManagementActionDelete, xpv1.ManagementActionObserve, xpv1.ManagementActionCreate} + c := xpv1.ManagementPolicies{xpv1.ManagementActionAll} + + if !RepositoryFilePoliciesEqual(a, b) { + t.Fatal("RepositoryFilePoliciesEqual() = false, want true") + } + if RepositoryFilePoliciesEqual(a, c) { + t.Fatal("RepositoryFilePoliciesEqual() = true, want false") + } +} + +func TestRepositoryFileCreateOnlyPolicies(t *testing.T) { + want := xpv1.ManagementPolicies{xpv1.ManagementActionObserve, xpv1.ManagementActionCreate, xpv1.ManagementActionDelete} + if diff := cmp.Diff(want, RepositoryFileCreateOnlyPolicies()); diff != "" { + t.Fatalf("RepositoryFileCreateOnlyPolicies(): -want, +got:\n%s", diff) + } +} + func stringPtr(s string) *string { return &s } diff --git a/pkg/namespaced/controller/projects/repositoryfiles/controller.go b/pkg/namespaced/controller/projects/repositoryfiles/controller.go index 0a6100fe..e77ee77b 100644 --- a/pkg/namespaced/controller/projects/repositoryfiles/controller.go +++ b/pkg/namespaced/controller/projects/repositoryfiles/controller.go @@ -51,6 +51,7 @@ const ( errExternalNameMismatch = "external-name must match spec.forProvider.filePath" errReconcileIntervalInvalid = "invalid reconcileInterval" errRepositoryFileContentMissing = "exactly one of content or contentSecretRef must be set" + errCreateOnlyPolicyConflict = "createOnly conflicts with explicit managementPolicies" ) // SetupRepositoryFile adds a controller that reconciles RepositoryFiles. @@ -63,7 +64,7 @@ func SetupRepositoryFile(mgr ctrl.Manager, o controller.Options) error { newGitlabClientFn: projectclients.NewRepositoryFileClient, pollInterval: o.PollInterval, }), - managed.WithInitializers(), + managed.WithInitializers(managed.NewNameAsExternalName(mgr.GetClient()), createOnlyInitializer{client: mgr.GetClient()}), managed.WithPollInterval(o.PollInterval), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), @@ -122,6 +123,31 @@ type external struct { pollInterval time.Duration } +type createOnlyInitializer struct { + client client.Client +} + +func (i createOnlyInitializer) Initialize(ctx context.Context, mg resource.Managed) error { + cr, ok := mg.(*projectsv1alpha1.RepositoryFile) + if !ok { + return errors.New(errNotRepositoryFile) + } + if !projectclients.RepositoryFileCreateOnly(&cr.Spec.ForProvider) { + return nil + } + + createOnlyPolicies := projectclients.RepositoryFileCreateOnlyPolicies() + if len(cr.GetManagementPolicies()) > 0 && !projectclients.RepositoryFilePoliciesEqual(cr.GetManagementPolicies(), createOnlyPolicies) { + return errors.New(errCreateOnlyPolicyConflict) + } + if projectclients.RepositoryFilePoliciesEqual(cr.GetManagementPolicies(), createOnlyPolicies) { + return nil + } + + cr.SetManagementPolicies(createOnlyPolicies) + return errors.Wrap(i.client.Update(ctx, cr), "cannot update RepositoryFile managementPolicies") +} + func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) { cr, ok := mg.(*projectsv1alpha1.RepositoryFile) if !ok { @@ -166,14 +192,6 @@ func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex cr.Status.AtProvider = projectclients.GenerateRepositoryFileObservation(file, now) cr.Status.SetConditions(xpv1.Available()) - if projectclients.RepositoryFileCreateOnly(&cr.Spec.ForProvider) { - return managed.ExternalObservation{ - ResourceExists: true, - ResourceUpToDate: true, - ResourceLateInitialized: !cmp.Equal(current, &cr.Spec.ForProvider), - }, nil - } - content, err := e.resolveContent(ctx, mg, &cr.Spec.ForProvider) if err != nil { return managed.ExternalObservation{}, errors.Wrap(err, errResolveContentFailed) @@ -223,9 +241,6 @@ func (e *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext if cr.Spec.ForProvider.ProjectID == nil { return managed.ExternalUpdate{}, errors.New(errProjectIDMissing) } - if projectclients.RepositoryFileCreateOnly(&cr.Spec.ForProvider) { - return managed.ExternalUpdate{}, nil - } content, err := e.resolveContent(ctx, mg, &cr.Spec.ForProvider) if err != nil { diff --git a/pkg/namespaced/controller/projects/repositoryfiles/controller_test.go b/pkg/namespaced/controller/projects/repositoryfiles/controller_test.go index c0c9f831..e4e90888 100644 --- a/pkg/namespaced/controller/projects/repositoryfiles/controller_test.go +++ b/pkg/namespaced/controller/projects/repositoryfiles/controller_test.go @@ -25,6 +25,7 @@ import ( xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" "github.com/crossplane/crossplane-runtime/v2/pkg/meta" "github.com/crossplane/crossplane-runtime/v2/pkg/reconciler/managed" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource" "github.com/crossplane/crossplane-runtime/v2/pkg/test" "github.com/google/go-cmp/cmp" gitlab "gitlab.com/gitlab-org/api/client-go" @@ -77,12 +78,11 @@ func TestResolveContentFromSecret(t *testing.T) { } } -func TestObserveCreateOnlyAndInterval(t *testing.T) { +func TestObserveIntervalSkip(t *testing.T) { projectID := "123" filePath := "README.md" branch := "main" content := "hello" - createOnly := true observeAt := metav1.NewTime(time.Now()) cr := &projectsv1alpha1.RepositoryFile{ @@ -92,7 +92,6 @@ func TestObserveCreateOnlyAndInterval(t *testing.T) { FilePath: filePath, Branch: branch, Content: &content, - CreateOnly: &createOnly, ReconcileInterval: stringPtr("1h"), }, }, @@ -113,6 +112,43 @@ func TestObserveCreateOnlyAndInterval(t *testing.T) { } } +func TestCreateOnlyInitializer(t *testing.T) { + createOnly := true + cr := &projectsv1alpha1.RepositoryFile{} + cr.Spec.ForProvider.CreateOnly = &createOnly + + updated := false + init := createOnlyInitializer{client: &test.MockClient{ + MockUpdate: func(_ context.Context, _ client.Object, _ ...client.UpdateOption) error { + updated = true + return nil + }, + }} + + if err := init.Initialize(context.Background(), resource.Managed(cr)); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + if !updated { + t.Fatal("Initialize() did not persist policy") + } + if diff := cmp.Diff(projectclients.RepositoryFileCreateOnlyPolicies(), cr.GetManagementPolicies()); diff != "" { + t.Fatalf("Initialize(): -want, +got:\n%s", diff) + } +} + +func TestCreateOnlyInitializerConflict(t *testing.T) { + createOnly := true + cr := &projectsv1alpha1.RepositoryFile{} + cr.Spec.ForProvider.CreateOnly = &createOnly + cr.SetManagementPolicies(xpv1.ManagementPolicies{xpv1.ManagementActionAll}) + + init := createOnlyInitializer{client: &test.MockClient{}} + err := init.Initialize(context.Background(), resource.Managed(cr)) + if err == nil || err.Error() != errCreateOnlyPolicyConflict { + t.Fatalf("Initialize() error = %v, want %s", err, errCreateOnlyPolicyConflict) + } +} + func TestObserveExternalNameMismatch(t *testing.T) { projectID := "123" content := "hello" @@ -162,7 +198,6 @@ func TestDeleteIgnores404(t *testing.T) { if got := cr.Status.ConditionedStatus.Conditions[0].Reason; got != xpv1.Deleting().Reason { t.Fatalf("Delete() condition reason = %s, want %s", got, xpv1.Deleting().Reason) } - _ = projectclients.RepositoryFileCreateOnly } func stringPtr(s string) *string { From 651a268669b853151429d7d8f9f14f24082307bb Mon Sep 17 00:00:00 2001 From: Henry Sachs Date: Wed, 8 Apr 2026 11:47:01 +0200 Subject: [PATCH 3/3] fix: polish repository file reconciliation Signed-off-by: Henry Sachs --- Makefile | 4 +- .../v1alpha1/zz_generated.deepcopy.go | 6 +- .../v1alpha1/zz_repositoryfile_types.go | 14 +-- .../projects/v1alpha1/repositoryfile_types.go | 14 +-- .../v1alpha1/zz_generated.deepcopy.go | 6 +- .../projects/repositoryfile-createonly.yaml | 18 ++++ examples/projects/repositoryfile-secret.yaml | 32 ++++++ examples/projects/repositoryfile.yaml | 5 +- examples/projects/test-project-local.yaml | 16 +++ .../test-repositoryfile-createonly-local.yaml | 33 +++++++ .../projects/test-repositoryfile-local.yaml | 98 +++++++++++++++++++ ...toryfile-root-readme-createonly-local.yaml | 48 +++++++++ .../test-repositoryfile-secret-local.yaml | 37 +++++++ ....gitlab.crossplane.io_repositoryfiles.yaml | 19 ++-- ...itlab.m.crossplane.io_repositoryfiles.yaml | 19 ++-- .../clients/projects/zz_repositoryfile.go | 42 ++------ .../projects/zz_repositoryfile_test.go | 41 +++----- .../projects/repositoryfiles/zz_controller.go | 69 ++++++++----- .../repositoryfiles/zz_controller_test.go | 50 ++++------ .../clients/projects/repositoryfile.go | 42 ++------ .../clients/projects/repositoryfile_test.go | 41 +++----- .../projects/repositoryfiles/controller.go | 69 ++++++++----- .../repositoryfiles/controller_test.go | 50 ++++------ 23 files changed, 488 insertions(+), 285 deletions(-) create mode 100644 examples/projects/repositoryfile-createonly.yaml create mode 100644 examples/projects/repositoryfile-secret.yaml create mode 100644 examples/projects/test-project-local.yaml create mode 100644 examples/projects/test-repositoryfile-createonly-local.yaml create mode 100644 examples/projects/test-repositoryfile-local.yaml create mode 100644 examples/projects/test-repositoryfile-root-readme-createonly-local.yaml create mode 100644 examples/projects/test-repositoryfile-secret-local.yaml diff --git a/Makefile b/Makefile index 78fdb807..aa4dc9ad 100644 --- a/Makefile +++ b/Makefile @@ -9,8 +9,8 @@ PLATFORMS ?= linux_amd64 linux_arm64 # kind-related versions KIND_VERSION = v0.24.0 -# upgraded golangci-lint to Go 1.25 compatible version -GOLANGCILINT_VERSION = 2.5.0 +# pinned golangci-lint version used by make targets +GOLANGCILINT_VERSION = 2.11.4 # -include will silently skip missing files, which allows us # to load those files with a target in the Makefile. If only diff --git a/apis/cluster/projects/v1alpha1/zz_generated.deepcopy.go b/apis/cluster/projects/v1alpha1/zz_generated.deepcopy.go index 204662f6..ae6655e9 100644 --- a/apis/cluster/projects/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/projects/v1alpha1/zz_generated.deepcopy.go @@ -3325,10 +3325,6 @@ func (in *RepositoryFileList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RepositoryFileObservation) DeepCopyInto(out *RepositoryFileObservation) { *out = *in - if in.LastObserveTime != nil { - in, out := &in.LastObserveTime, &out.LastObserveTime - *out = (*in).DeepCopy() - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryFileObservation. @@ -3452,7 +3448,7 @@ func (in *RepositoryFileSpec) DeepCopy() *RepositoryFileSpec { func (in *RepositoryFileStatus) DeepCopyInto(out *RepositoryFileStatus) { *out = *in in.ResourceStatus.DeepCopyInto(&out.ResourceStatus) - in.AtProvider.DeepCopyInto(&out.AtProvider) + out.AtProvider = in.AtProvider } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryFileStatus. diff --git a/apis/cluster/projects/v1alpha1/zz_repositoryfile_types.go b/apis/cluster/projects/v1alpha1/zz_repositoryfile_types.go index 92424ff1..56788fe0 100644 --- a/apis/cluster/projects/v1alpha1/zz_repositoryfile_types.go +++ b/apis/cluster/projects/v1alpha1/zz_repositoryfile_types.go @@ -23,11 +23,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -const ( - // RepositoryFileCreateOnlyManagementPolicies is the management policy set implied by createOnly. - RepositoryFileCreateOnlyManagementPolicies = `{"Observe","Create","Delete"}` -) - // RepositoryFileParameters define the desired state of a GitLab Repository File. // https://docs.gitlab.com/api/repository_files/ type RepositoryFileParameters struct { @@ -111,12 +106,14 @@ type RepositoryFileParameters struct { // the GitLab API. Examples: "5m", "1h", "8h". If unset, the resource is // reconciled at the controller's default poll interval. // +optional + // +kubebuilder:validation:Pattern=`^([0-9]+(ns|us|µs|ms|s|m|h))+$` ReconcileInterval *string `json:"reconcileInterval,omitempty"` // CreateOnly when true, creates the file once and never updates it. // The file is still deleted from GitLab when the CR is deleted. // Observe only checks for file existence, not content drift. // +optional + // +immutable // +kubebuilder:default=false CreateOnly *bool `json:"createOnly,omitempty"` } @@ -140,10 +137,6 @@ type RepositoryFileObservation struct { // Size is the file size in bytes. Size int64 `json:"size,omitempty"` - - // LastObserveTime is the timestamp of the last successful observe - // that made an actual API call to GitLab. Used for reconcileInterval. - LastObserveTime *metav1.Time `json:"lastObserveTime,omitempty"` } // A RepositoryFileSpec defines the desired state of a GitLab Repository File. @@ -163,7 +156,8 @@ type RepositoryFileStatus struct { // +kubebuilder:object:root=true // A RepositoryFile is a managed resource that represents a file in a GitLab repository. -// +kubebuilder:validation:XValidation:rule="!has(self.spec.forProvider.createOnly) || !self.spec.forProvider.createOnly || !has(self.spec.managementPolicies) || self.spec.managementPolicies == ['Observe', 'Create', 'Delete'] || self.spec.managementPolicies == ['Create', 'Observe', 'Delete'] || self.spec.managementPolicies == ['Create', 'Delete', 'Observe']",message="createOnly=true requires managementPolicies to be exactly [Observe, Create, Delete] when managementPolicies is set explicitly" +// +kubebuilder:validation:XValidation:rule="has(self.spec.forProvider.content) != has(self.spec.forProvider.contentSecretRef)",message="exactly one of spec.forProvider.content or spec.forProvider.contentSecretRef must be set" +// +kubebuilder:validation:XValidation:rule="!has(self.spec.forProvider.createOnly) || !self.spec.forProvider.createOnly || !has(self.spec.managementPolicies) || size(self.spec.managementPolicies) != 1 || self.spec.managementPolicies[0] != '*'",message="createOnly=true cannot be combined with managementPolicies=['*']" // +kubebuilder:printcolumn:name="FILE",type="string",JSONPath=".spec.forProvider.filePath" // +kubebuilder:printcolumn:name="BRANCH",type="string",JSONPath=".spec.forProvider.branch" // +kubebuilder:printcolumn:name="READY",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" diff --git a/apis/namespaced/projects/v1alpha1/repositoryfile_types.go b/apis/namespaced/projects/v1alpha1/repositoryfile_types.go index 4b58bf6e..305467b5 100644 --- a/apis/namespaced/projects/v1alpha1/repositoryfile_types.go +++ b/apis/namespaced/projects/v1alpha1/repositoryfile_types.go @@ -23,11 +23,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -const ( - // RepositoryFileCreateOnlyManagementPolicies is the management policy set implied by createOnly. - RepositoryFileCreateOnlyManagementPolicies = `{"Observe","Create","Delete"}` -) - // RepositoryFileParameters define the desired state of a GitLab Repository File. // https://docs.gitlab.com/api/repository_files/ type RepositoryFileParameters struct { @@ -111,12 +106,14 @@ type RepositoryFileParameters struct { // the GitLab API. Examples: "5m", "1h", "8h". If unset, the resource is // reconciled at the controller's default poll interval. // +optional + // +kubebuilder:validation:Pattern=`^([0-9]+(ns|us|µs|ms|s|m|h))+$` ReconcileInterval *string `json:"reconcileInterval,omitempty"` // CreateOnly when true, creates the file once and never updates it. // The file is still deleted from GitLab when the CR is deleted. // Observe only checks for file existence, not content drift. // +optional + // +immutable // +kubebuilder:default=false CreateOnly *bool `json:"createOnly,omitempty"` } @@ -140,10 +137,6 @@ type RepositoryFileObservation struct { // Size is the file size in bytes. Size int64 `json:"size,omitempty"` - - // LastObserveTime is the timestamp of the last successful observe - // that made an actual API call to GitLab. Used for reconcileInterval. - LastObserveTime *metav1.Time `json:"lastObserveTime,omitempty"` } // A RepositoryFileSpec defines the desired state of a GitLab Repository File. @@ -163,7 +156,8 @@ type RepositoryFileStatus struct { // +kubebuilder:object:root=true // A RepositoryFile is a managed resource that represents a file in a GitLab repository. -// +kubebuilder:validation:XValidation:rule="!has(self.spec.forProvider.createOnly) || !self.spec.forProvider.createOnly || !has(self.spec.managementPolicies) || self.spec.managementPolicies == ['Observe', 'Create', 'Delete'] || self.spec.managementPolicies == ['Create', 'Observe', 'Delete'] || self.spec.managementPolicies == ['Create', 'Delete', 'Observe']",message="createOnly=true requires managementPolicies to be exactly [Observe, Create, Delete] when managementPolicies is set explicitly" +// +kubebuilder:validation:XValidation:rule="has(self.spec.forProvider.content) != has(self.spec.forProvider.contentSecretRef)",message="exactly one of spec.forProvider.content or spec.forProvider.contentSecretRef must be set" +// +kubebuilder:validation:XValidation:rule="!has(self.spec.forProvider.createOnly) || !self.spec.forProvider.createOnly || !has(self.spec.managementPolicies) || size(self.spec.managementPolicies) != 1 || self.spec.managementPolicies[0] != '*'",message="createOnly=true cannot be combined with managementPolicies=['*']" // +kubebuilder:printcolumn:name="FILE",type="string",JSONPath=".spec.forProvider.filePath" // +kubebuilder:printcolumn:name="BRANCH",type="string",JSONPath=".spec.forProvider.branch" // +kubebuilder:printcolumn:name="READY",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" diff --git a/apis/namespaced/projects/v1alpha1/zz_generated.deepcopy.go b/apis/namespaced/projects/v1alpha1/zz_generated.deepcopy.go index 5a2fc048..a995c670 100644 --- a/apis/namespaced/projects/v1alpha1/zz_generated.deepcopy.go +++ b/apis/namespaced/projects/v1alpha1/zz_generated.deepcopy.go @@ -3325,10 +3325,6 @@ func (in *RepositoryFileList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RepositoryFileObservation) DeepCopyInto(out *RepositoryFileObservation) { *out = *in - if in.LastObserveTime != nil { - in, out := &in.LastObserveTime, &out.LastObserveTime - *out = (*in).DeepCopy() - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryFileObservation. @@ -3452,7 +3448,7 @@ func (in *RepositoryFileSpec) DeepCopy() *RepositoryFileSpec { func (in *RepositoryFileStatus) DeepCopyInto(out *RepositoryFileStatus) { *out = *in in.ResourceStatus.DeepCopyInto(&out.ResourceStatus) - in.AtProvider.DeepCopyInto(&out.AtProvider) + out.AtProvider = in.AtProvider } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryFileStatus. diff --git a/examples/projects/repositoryfile-createonly.yaml b/examples/projects/repositoryfile-createonly.yaml new file mode 100644 index 00000000..6fc126a5 --- /dev/null +++ b/examples/projects/repositoryfile-createonly.yaml @@ -0,0 +1,18 @@ +apiVersion: projects.gitlab.crossplane.io/v1alpha1 +kind: RepositoryFile +metadata: + name: example-repositoryfile-createonly +spec: + forProvider: + projectIdRef: + name: example-project + filePath: docs/bootstrap.md + branch: main + content: | + bootstrap content written once + createOnly: true + reconcileInterval: 8h + createCommitMessage: "docs: bootstrap file" + deleteCommitMessage: "docs: remove bootstrap file" + providerConfigRef: + name: gitlab-provider diff --git a/examples/projects/repositoryfile-secret.yaml b/examples/projects/repositoryfile-secret.yaml new file mode 100644 index 00000000..0670f1d0 --- /dev/null +++ b/examples/projects/repositoryfile-secret.yaml @@ -0,0 +1,32 @@ +apiVersion: v1 +kind: Secret +metadata: + name: example-repositoryfile-content + namespace: default +type: Opaque +stringData: + content: | + apiVersion: v1 + kind: ConfigMap + metadata: + name: generated-file +--- +apiVersion: projects.gitlab.crossplane.io/v1alpha1 +kind: RepositoryFile +metadata: + name: example-repositoryfile-secret +spec: + forProvider: + projectIdRef: + name: example-project + filePath: manifests/generated-file.yaml + branch: main + contentSecretRef: + name: example-repositoryfile-content + key: content + reconcileInterval: 2h + createCommitMessage: "feat: add generated manifest" + updateCommitMessage: "feat: update generated manifest" + deleteCommitMessage: "feat: remove generated manifest" + providerConfigRef: + name: gitlab-provider diff --git a/examples/projects/repositoryfile.yaml b/examples/projects/repositoryfile.yaml index 08118dd0..40a1f6ab 100644 --- a/examples/projects/repositoryfile.yaml +++ b/examples/projects/repositoryfile.yaml @@ -1,4 +1,4 @@ -apiVersion: projects.gitlab.m.crossplane.io/v1alpha1 +apiVersion: projects.gitlab.crossplane.io/v1alpha1 kind: RepositoryFile metadata: name: example-repositoryfile @@ -17,5 +17,4 @@ spec: updateCommitMessage: "docs: update README.md" deleteCommitMessage: "docs: delete README.md" providerConfigRef: - name: default - kind: ProviderConfig + name: gitlab-provider diff --git a/examples/projects/test-project-local.yaml b/examples/projects/test-project-local.yaml new file mode 100644 index 00000000..3dae7028 --- /dev/null +++ b/examples/projects/test-project-local.yaml @@ -0,0 +1,16 @@ +apiVersion: projects.gitlab.m.crossplane.io/v1alpha1 +kind: Project +metadata: + name: local-repositoryfile-test + namespace: default +spec: + forProvider: + name: "Local RepositoryFile Test" + path: local-repositoryfile-test + description: "Local test project for provider-gitlab RepositoryFile resource" + visibility: private + initializeWithReadme: true + defaultBranch: main + providerConfigRef: + name: gitlab-provider + kind: ProviderConfig diff --git a/examples/projects/test-repositoryfile-createonly-local.yaml b/examples/projects/test-repositoryfile-createonly-local.yaml new file mode 100644 index 00000000..e7bf2252 --- /dev/null +++ b/examples/projects/test-repositoryfile-createonly-local.yaml @@ -0,0 +1,33 @@ +apiVersion: projects.gitlab.m.crossplane.io/v1alpha1 +kind: RepositoryFile +metadata: + name: local-repositoryfile-createonly + namespace: default +spec: + managementPolicies: + - Observe + - Create + - Delete + forProvider: + projectId: "1" + filePath: bootstrap/CREATE-ONCE.md + branch: main + reconcileInterval: 8h + createOnly: true + createCommitMessage: "docs: add create-once bootstrap note" + deleteCommitMessage: "docs: remove create-once bootstrap note" + content: | + # Create Once + + This file was created by the RepositoryFile resource with `createOnly: true`. + + Crossplane should ensure the file exists, but it should not keep rewriting the + content after creation. This is useful for bootstrap files or initial seed data + that operators may want to edit manually later on. + + Reconcile interval: 8h + Project ID: 1 + Resource: local-repositoryfile-createonly + providerConfigRef: + name: gitlab-provider + kind: ProviderConfig diff --git a/examples/projects/test-repositoryfile-local.yaml b/examples/projects/test-repositoryfile-local.yaml new file mode 100644 index 00000000..7d169dd6 --- /dev/null +++ b/examples/projects/test-repositoryfile-local.yaml @@ -0,0 +1,98 @@ +apiVersion: projects.gitlab.m.crossplane.io/v1alpha1 +kind: RepositoryFile +metadata: + name: local-repositoryfile-readme + namespace: default +spec: + forProvider: + projectId: "1" + filePath: docs/README-LONG.md + branch: main + reconcileInterval: 1h + createCommitMessage: "docs: create long repository file test" + updateCommitMessage: "docs: update long repository file test" + deleteCommitMessage: "docs: delete long repository file test" + content: | + # Local RepositoryFile Test + + This document is managed by the Crossplane provider-gitlab RepositoryFile resource. + It exists to validate end-to-end behavior against a local GitLab instance running in Docker. + The content is intentionally long so the resource exercises non-trivial payload sizes while + still fitting comfortably within Kubernetes object limits. + + ## Purpose + + The goal of this file is to prove a few things at once. + First, the provider can authenticate against a non-public GitLab API endpoint. + Second, the provider can create a project-scoped file on a specific branch. + Third, the provider can keep a larger multi-paragraph document in sync using the normal + reconciliation loop without relying on tiny placeholder content. + Fourth, the resource can be inspected by humans in both Kubernetes and GitLab without any + special tooling. + + ## Background + + Crossplane works best when the desired state is explicit, reviewable, and reproducible. + A repository file is a deceptively small resource, but it is also a useful integration test. + If file creation works, then authentication, provider configuration, project lookup, branch + targeting, content transport, commit creation, and status observation all work together. + That makes this resource a good way to validate the provider locally before introducing more + complex use cases. + + ## Reconciliation Notes + + This example uses a one hour reconcile interval. + That means the controller should not hammer the GitLab API every few minutes for a document + that changes rarely. + In real-world usage, some files might reconcile hourly while other files reconcile only every + eight hours or once per day. + The important part is that the interval is configured per resource instead of globally. + + ## Content Strategy + + The text here is plain Markdown stored inline in the managed resource. + For sensitive or generated content, the provider also supports sourcing file content from a + Kubernetes Secret. + That makes it possible to manage rendered manifests, bootstrap configuration, policy files, + or application templates without embedding sensitive data directly in Git. + + ## Validation Checklist + + When this example succeeds, the following should be true. + The project exists in the local GitLab instance. + The default branch is available. + The file path `docs/README-LONG.md` exists. + A commit was created with the configured commit message. + The Crossplane managed resource reports as Ready and Synced. + The provider pod remains healthy after observe and late-initialize steps. + + ## Long Form Text + + Reliable infrastructure automation depends on clarity. + Clear state models reduce operator confusion. + Clear APIs reduce accidental misuse. + Clear validation rules catch mistakes early. + Clear status fields shorten debugging loops. + Clear examples help other engineers reproduce success quickly. + + A good provider should behave predictably when the external system is reachable, when it is + slow, and when it disagrees with the desired state. + It should also avoid unnecessary API traffic, because noisy reconciliation scales poorly and + tends to mask real operational issues. + A per-resource interval gives operators a pragmatic control surface without forcing every + managed object into the same cadence. + + In this example, the file content is mostly narrative text, but the same mechanism can be + used for release metadata, generated documentation, static site content, bootstrap manifests, + compliance notices, onboarding guides, or application-level configuration checked into a + repository for auditability. + + ## Final Notes + + If you are reading this inside GitLab, the local end-to-end test worked. + If you are reading this inside Kubernetes, the manifest applied successfully. + If you are reading this in the repository examples directory, the setup is reproducible. + That is enough for a solid local validation loop. + providerConfigRef: + name: gitlab-provider + kind: ProviderConfig diff --git a/examples/projects/test-repositoryfile-root-readme-createonly-local.yaml b/examples/projects/test-repositoryfile-root-readme-createonly-local.yaml new file mode 100644 index 00000000..16a26e57 --- /dev/null +++ b/examples/projects/test-repositoryfile-root-readme-createonly-local.yaml @@ -0,0 +1,48 @@ +apiVersion: projects.gitlab.m.crossplane.io/v1alpha1 +kind: RepositoryFile +metadata: + name: local-repositoryfile-root-readme-createonly + namespace: default +spec: + managementPolicies: + - Observe + - Create + - Delete + forProvider: + projectId: "1" + filePath: README.md + branch: main + reconcileInterval: 8h + createOnly: true + createCommitMessage: "docs: create root readme once" + deleteCommitMessage: "docs: remove root readme once" + content: | + # Root README managed once + + This root README was created by the `RepositoryFile` resource with `createOnly: true`. + + The intention of this example is to show a bootstrap workflow: + Crossplane creates the file once, but it does not continuously rewrite the content after that. + Humans can edit the README later without the provider forcing the original text back on every reconcile. + + ## Why this exists + + This example uses the same local GitLab test repository as the other RepositoryFile examples. + It demonstrates a root-level file instead of a nested path. + It also demonstrates the create-once pattern for a highly visible file. + + ## Expected behavior + + The provider should create this README once. + The resource should report Ready and Synced. + Future reconciles should observe the file, but not update content drift. + Deleting the Kubernetes resource should delete the file from GitLab. + + ## Resource metadata + + Project ID: 1 + Resource name: local-repositoryfile-root-readme-createonly + Reconcile interval: 8h + providerConfigRef: + name: gitlab-provider + kind: ProviderConfig diff --git a/examples/projects/test-repositoryfile-secret-local.yaml b/examples/projects/test-repositoryfile-secret-local.yaml new file mode 100644 index 00000000..b9ce376e --- /dev/null +++ b/examples/projects/test-repositoryfile-secret-local.yaml @@ -0,0 +1,37 @@ +apiVersion: v1 +kind: Secret +metadata: + name: local-repositoryfile-secret-content + namespace: default +type: Opaque +stringData: + content: | + apiVersion: v1 + kind: ConfigMap + metadata: + name: generated-config + data: + mode: secret-backed + source: crossplane-provider-gitlab + note: this file content came from a kubernetes secret +--- +apiVersion: projects.gitlab.m.crossplane.io/v1alpha1 +kind: RepositoryFile +metadata: + name: local-repositoryfile-secret-backed + namespace: default +spec: + forProvider: + projectId: "1" + filePath: generated/secret-backed-config.yaml + branch: main + reconcileInterval: 2h + createCommitMessage: "feat: add secret-backed config file" + updateCommitMessage: "chore: refresh secret-backed config file" + deleteCommitMessage: "chore: remove secret-backed config file" + contentSecretRef: + name: local-repositoryfile-secret-content + key: content + providerConfigRef: + name: gitlab-provider + kind: ProviderConfig diff --git a/package/crds/projects.gitlab.crossplane.io_repositoryfiles.yaml b/package/crds/projects.gitlab.crossplane.io_repositoryfiles.yaml index 23ef2334..382f4cdd 100644 --- a/package/crds/projects.gitlab.crossplane.io_repositoryfiles.yaml +++ b/package/crds/projects.gitlab.crossplane.io_repositoryfiles.yaml @@ -230,6 +230,7 @@ spec: ReconcileInterval controls how often this resource is reconciled against the GitLab API. Examples: "5m", "1h", "8h". If unset, the resource is reconciled at the controller's default poll interval. + pattern: ^([0-9]+(ns|us|µs|ms|s|m|h))+$ type: string startBranch: description: StartBranch is the source branch used to create branch @@ -351,12 +352,6 @@ spec: description: LastCommitID is the last commit SHA that modified this file. type: string - lastObserveTime: - description: |- - LastObserveTime is the timestamp of the last successful observe - that made an actual API call to GitLab. Used for reconcileInterval. - format: date-time - type: string sha256: description: SHA256 is the SHA-256 hash of the file content. type: string @@ -423,13 +418,13 @@ spec: - spec type: object x-kubernetes-validations: - - message: createOnly=true requires managementPolicies to be exactly [Observe, - Create, Delete] when managementPolicies is set explicitly + - message: exactly one of spec.forProvider.content or spec.forProvider.contentSecretRef + must be set + rule: has(self.spec.forProvider.content) != has(self.spec.forProvider.contentSecretRef) + - message: createOnly=true cannot be combined with managementPolicies=['*'] rule: '!has(self.spec.forProvider.createOnly) || !self.spec.forProvider.createOnly - || !has(self.spec.managementPolicies) || self.spec.managementPolicies - == [''Observe'', ''Create'', ''Delete''] || self.spec.managementPolicies - == [''Create'', ''Observe'', ''Delete''] || self.spec.managementPolicies - == [''Create'', ''Delete'', ''Observe'']' + || !has(self.spec.managementPolicies) || size(self.spec.managementPolicies) + != 1 || self.spec.managementPolicies[0] != ''*''' served: true storage: true subresources: diff --git a/package/crds/projects.gitlab.m.crossplane.io_repositoryfiles.yaml b/package/crds/projects.gitlab.m.crossplane.io_repositoryfiles.yaml index 8cc120c8..8f9c2022 100644 --- a/package/crds/projects.gitlab.m.crossplane.io_repositoryfiles.yaml +++ b/package/crds/projects.gitlab.m.crossplane.io_repositoryfiles.yaml @@ -217,6 +217,7 @@ spec: ReconcileInterval controls how often this resource is reconciled against the GitLab API. Examples: "5m", "1h", "8h". If unset, the resource is reconciled at the controller's default poll interval. + pattern: ^([0-9]+(ns|us|µs|ms|s|m|h))+$ type: string startBranch: description: StartBranch is the source branch used to create branch @@ -310,12 +311,6 @@ spec: description: LastCommitID is the last commit SHA that modified this file. type: string - lastObserveTime: - description: |- - LastObserveTime is the timestamp of the last successful observe - that made an actual API call to GitLab. Used for reconcileInterval. - format: date-time - type: string sha256: description: SHA256 is the SHA-256 hash of the file content. type: string @@ -382,13 +377,13 @@ spec: - spec type: object x-kubernetes-validations: - - message: createOnly=true requires managementPolicies to be exactly [Observe, - Create, Delete] when managementPolicies is set explicitly + - message: exactly one of spec.forProvider.content or spec.forProvider.contentSecretRef + must be set + rule: has(self.spec.forProvider.content) != has(self.spec.forProvider.contentSecretRef) + - message: createOnly=true cannot be combined with managementPolicies=['*'] rule: '!has(self.spec.forProvider.createOnly) || !self.spec.forProvider.createOnly - || !has(self.spec.managementPolicies) || self.spec.managementPolicies - == [''Observe'', ''Create'', ''Delete''] || self.spec.managementPolicies - == [''Create'', ''Observe'', ''Delete''] || self.spec.managementPolicies - == [''Create'', ''Delete'', ''Observe'']' + || !has(self.spec.managementPolicies) || size(self.spec.managementPolicies) + != 1 || self.spec.managementPolicies[0] != ''*''' served: true storage: true subresources: diff --git a/pkg/cluster/clients/projects/zz_repositoryfile.go b/pkg/cluster/clients/projects/zz_repositoryfile.go index d74d4ac9..99a0fe66 100644 --- a/pkg/cluster/clients/projects/zz_repositoryfile.go +++ b/pkg/cluster/clients/projects/zz_repositoryfile.go @@ -27,7 +27,6 @@ import ( xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" gitlab "gitlab.com/gitlab-org/api/client-go" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" projectsv1alpha1 "github.com/crossplane-contrib/provider-gitlab/apis/cluster/projects/v1alpha1" "github.com/crossplane-contrib/provider-gitlab/pkg/cluster/clients" @@ -53,22 +52,19 @@ func NewRepositoryFileClient(cfg common.Config) RepositoryFileClient { } // GenerateRepositoryFileObservation builds observation from a gitlab file. -func GenerateRepositoryFileObservation(file *gitlab.File, observedAt time.Time) projectsv1alpha1.RepositoryFileObservation { - observation := projectsv1alpha1.RepositoryFileObservation{ - LastObserveTime: &metav1.Time{Time: observedAt}, - } +func GenerateRepositoryFileObservation(file *gitlab.File) projectsv1alpha1.RepositoryFileObservation { if file == nil { - return observation + return projectsv1alpha1.RepositoryFileObservation{} } - observation.FilePath = file.FilePath - observation.BlobID = file.BlobID - observation.CommitID = file.CommitID - observation.LastCommitID = file.LastCommitID - observation.SHA256 = file.SHA256 - observation.Size = file.Size - - return observation + return projectsv1alpha1.RepositoryFileObservation{ + FilePath: file.FilePath, + BlobID: file.BlobID, + CommitID: file.CommitID, + LastCommitID: file.LastCommitID, + SHA256: file.SHA256, + Size: file.Size, + } } // LateInitializeRepositoryFile fills empty optional fields from the external file. @@ -172,24 +168,6 @@ func IsRepositoryFileUpToDate(p *projectsv1alpha1.RepositoryFileParameters, exte return RepositoryFileContentSHA256(p, content) == external.SHA256 } -// ShouldObserveRepositoryFileNow checks whether observe should call GitLab now. -func ShouldObserveRepositoryFileNow(p *projectsv1alpha1.RepositoryFileParameters, observation *projectsv1alpha1.RepositoryFileObservation, defaultPollInterval time.Duration, now time.Time) (bool, error) { - if observation == nil || observation.LastObserveTime == nil { - return true, nil - } - - interval, err := RepositoryFileReconcileInterval(p, defaultPollInterval) - if err != nil { - return false, err - } - - if interval <= 0 { - return true, nil - } - - return !observation.LastObserveTime.Add(interval).After(now), nil -} - // RepositoryFileReconcileInterval returns configured reconcile interval or default. func RepositoryFileReconcileInterval(p *projectsv1alpha1.RepositoryFileParameters, defaultPollInterval time.Duration) (time.Duration, error) { if p == nil || p.ReconcileInterval == nil || strings.TrimSpace(*p.ReconcileInterval) == "" { diff --git a/pkg/cluster/clients/projects/zz_repositoryfile_test.go b/pkg/cluster/clients/projects/zz_repositoryfile_test.go index 62939be8..9c8080f8 100644 --- a/pkg/cluster/clients/projects/zz_repositoryfile_test.go +++ b/pkg/cluster/clients/projects/zz_repositoryfile_test.go @@ -28,7 +28,6 @@ import ( xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" "github.com/google/go-cmp/cmp" gitlab "gitlab.com/gitlab-org/api/client-go" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" projectsv1alpha1 "github.com/crossplane-contrib/provider-gitlab/apis/cluster/projects/v1alpha1" ) @@ -68,42 +67,26 @@ func TestRepositoryFileContentSHA256(t *testing.T) { } } -func TestShouldObserveRepositoryFileNow(t *testing.T) { - now := time.Now() +func TestRepositoryFileReconcileInterval(t *testing.T) { oneHour := "1h" - cases := map[string]struct { params *projectsv1alpha1.RepositoryFileParameters - observation *projectsv1alpha1.RepositoryFileObservation defaultPoll time.Duration - want bool + want time.Duration wantErr bool }{ - "NoLastObserveTime": { + "Default": { params: &projectsv1alpha1.RepositoryFileParameters{}, - observation: &projectsv1alpha1.RepositoryFileObservation{}, defaultPoll: time.Minute, - want: true, + want: time.Minute, }, - "SkipBeforeInterval": { - params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: &oneHour}, - observation: &projectsv1alpha1.RepositoryFileObservation{ - LastObserveTime: &metav1.Time{Time: now.Add(-30 * time.Minute)}, - }, - defaultPoll: time.Minute, - want: false, - }, - "ObserveAfterInterval": { - params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: &oneHour}, - observation: &projectsv1alpha1.RepositoryFileObservation{ - LastObserveTime: &metav1.Time{Time: now.Add(-2 * time.Hour)}, - }, + "Custom": { + params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: &oneHour}, defaultPoll: time.Minute, - want: true, + want: time.Hour, }, - "InvalidInterval": { + "Invalid": { params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: stringPtr("nope")}, - observation: &projectsv1alpha1.RepositoryFileObservation{LastObserveTime: &metav1.Time{Time: now}}, defaultPoll: time.Minute, wantErr: true, }, @@ -111,18 +94,18 @@ func TestShouldObserveRepositoryFileNow(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - got, err := ShouldObserveRepositoryFileNow(tc.params, tc.observation, tc.defaultPoll, now) + got, err := RepositoryFileReconcileInterval(tc.params, tc.defaultPoll) if tc.wantErr { if err == nil { - t.Fatal("ShouldObserveRepositoryFileNow() expected error") + t.Fatal("RepositoryFileReconcileInterval() expected error") } return } if err != nil { - t.Fatalf("ShouldObserveRepositoryFileNow() error = %v", err) + t.Fatalf("RepositoryFileReconcileInterval() error = %v", err) } if diff := cmp.Diff(tc.want, got); diff != "" { - t.Fatalf("ShouldObserveRepositoryFileNow(): -want, +got:\n%s", diff) + t.Fatalf("RepositoryFileReconcileInterval(): -want, +got:\n%s", diff) } }) } diff --git a/pkg/cluster/controller/projects/repositoryfiles/zz_controller.go b/pkg/cluster/controller/projects/repositoryfiles/zz_controller.go index ae7cd469..705ff221 100644 --- a/pkg/cluster/controller/projects/repositoryfiles/zz_controller.go +++ b/pkg/cluster/controller/projects/repositoryfiles/zz_controller.go @@ -25,7 +25,6 @@ import ( xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" "github.com/crossplane/crossplane-runtime/v2/pkg/controller" "github.com/crossplane/crossplane-runtime/v2/pkg/event" - "github.com/crossplane/crossplane-runtime/v2/pkg/feature" "github.com/crossplane/crossplane-runtime/v2/pkg/meta" "github.com/crossplane/crossplane-runtime/v2/pkg/reconciler/managed" "github.com/crossplane/crossplane-runtime/v2/pkg/resource" @@ -51,7 +50,6 @@ const ( errResolveContentFailed = "cannot resolve Gitlab repository file content" errProjectIDMissing = "ProjectID is missing" errExternalNameMismatch = "external-name must match spec.forProvider.filePath" - errReconcileIntervalInvalid = "invalid reconcileInterval" errRepositoryFileContentMissing = "exactly one of content or contentSecretRef must be set" errCreateOnlyPolicyConflict = "createOnly conflicts with explicit managementPolicies" ) @@ -60,7 +58,8 @@ const ( func SetupRepositoryFile(mgr ctrl.Manager, o controller.Options) error { name := managed.ControllerName("cluster." + projectsv1alpha1.RepositoryFileGroupKind) - reconcilerOpts := []managed.ReconcilerOption{ + reconcilerOpts := make([]managed.ReconcilerOption, 0, 7) + reconcilerOpts = append(reconcilerOpts, managed.WithExternalConnecter(&connector{ kube: mgr.GetClient(), newGitlabClientFn: projectclients.NewRepositoryFileClient, @@ -68,13 +67,21 @@ func SetupRepositoryFile(mgr ctrl.Manager, o controller.Options) error { }), managed.WithInitializers(managed.NewNameAsExternalName(mgr.GetClient()), createOnlyInitializer{client: mgr.GetClient()}), managed.WithPollInterval(o.PollInterval), + managed.WithPollIntervalHook(func(mg resource.Managed, pollInterval time.Duration) time.Duration { + cr, ok := mg.(*projectsv1alpha1.RepositoryFile) + if !ok { + return pollInterval + } + interval, err := projectclients.RepositoryFileReconcileInterval(&cr.Spec.ForProvider, pollInterval) + if err != nil { + return pollInterval + } + return interval + }), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), - } - - if o.Features.Enabled(feature.EnableBetaManagementPolicies) { - reconcilerOpts = append(reconcilerOpts, managed.WithManagementPolicies()) - } + managed.WithManagementPolicies(), + ) r := managed.NewReconciler(mgr, resource.ManagedKind(projectsv1alpha1.RepositoryFileGroupVersionKind), @@ -160,20 +167,8 @@ func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex return managed.ExternalObservation{}, errors.New(errProjectIDMissing) } - if externalName := meta.GetExternalName(cr); externalName != "" && externalName != cr.Spec.ForProvider.FilePath { - return managed.ExternalObservation{}, errors.New(errExternalNameMismatch) - } - - now := time.Now() - shouldObserve, err := projectclients.ShouldObserveRepositoryFileNow(&cr.Spec.ForProvider, &cr.Status.AtProvider, e.pollInterval, now) - if err != nil { - return managed.ExternalObservation{}, errors.Wrap(err, errReconcileIntervalInvalid) - } - if !shouldObserve { - return managed.ExternalObservation{ - ResourceExists: true, - ResourceUpToDate: true, - }, nil + if err := validateRepositoryFileExternalName(cr); err != nil { + return managed.ExternalObservation{}, err } file, res, err := e.client.GetFile( @@ -191,9 +186,13 @@ func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex current := cr.Spec.ForProvider.DeepCopy() projectclients.LateInitializeRepositoryFile(&cr.Spec.ForProvider, file) - cr.Status.AtProvider = projectclients.GenerateRepositoryFileObservation(file, now) + cr.Status.AtProvider = projectclients.GenerateRepositoryFileObservation(file) cr.Status.SetConditions(xpv1.Available()) + if observation, handled := observeCreateOnlyRepositoryFile(cr, current); handled { + return observation, nil + } + content, err := e.resolveContent(ctx, mg, &cr.Spec.ForProvider) if err != nil { return managed.ExternalObservation{}, errors.Wrap(err, errResolveContentFailed) @@ -206,6 +205,30 @@ func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex }, nil } +func validateRepositoryFileExternalName(cr *projectsv1alpha1.RepositoryFile) error { + externalName := meta.GetExternalName(cr) + if externalName == "" || externalName == cr.Spec.ForProvider.FilePath { + return nil + } + if meta.WasCreated(cr) || (externalName == cr.GetName() && cr.Status.AtProvider.FilePath == "") { + // Newly created resources may still carry metadata.name as external-name + // until Create sets the real file path. Do not block initial creation. + return nil + } + return errors.New(errExternalNameMismatch) +} + +func observeCreateOnlyRepositoryFile(cr *projectsv1alpha1.RepositoryFile, current *projectsv1alpha1.RepositoryFileParameters) (managed.ExternalObservation, bool) { + if !projectclients.RepositoryFileCreateOnly(&cr.Spec.ForProvider) { + return managed.ExternalObservation{}, false + } + return managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + ResourceLateInitialized: !cmp.Equal(current, &cr.Spec.ForProvider), + }, true +} + func (e *external) Create(ctx context.Context, mg resource.Managed) (managed.ExternalCreation, error) { cr, ok := mg.(*projectsv1alpha1.RepositoryFile) if !ok { diff --git a/pkg/cluster/controller/projects/repositoryfiles/zz_controller_test.go b/pkg/cluster/controller/projects/repositoryfiles/zz_controller_test.go index 9b8ddd8a..fb08013a 100644 --- a/pkg/cluster/controller/projects/repositoryfiles/zz_controller_test.go +++ b/pkg/cluster/controller/projects/repositoryfiles/zz_controller_test.go @@ -26,13 +26,11 @@ import ( xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" "github.com/crossplane/crossplane-runtime/v2/pkg/meta" - "github.com/crossplane/crossplane-runtime/v2/pkg/reconciler/managed" "github.com/crossplane/crossplane-runtime/v2/pkg/resource" "github.com/crossplane/crossplane-runtime/v2/pkg/test" "github.com/google/go-cmp/cmp" gitlab "gitlab.com/gitlab-org/api/client-go" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" projectsv1alpha1 "github.com/crossplane-contrib/provider-gitlab/apis/cluster/projects/v1alpha1" @@ -41,8 +39,10 @@ import ( commonpkg "github.com/crossplane-contrib/provider-gitlab/pkg/common" ) +const testContent = "hello" + func TestResolveContent(t *testing.T) { - content := "hello" + content := testContent cr := &projectsv1alpha1.RepositoryFile{} cr.Namespace = "default" @@ -57,7 +57,7 @@ func TestResolveContent(t *testing.T) { } func TestResolveContentFromSecret(t *testing.T) { - content := "hello" + content := testContent cr := &projectsv1alpha1.RepositoryFile{} cr.Namespace = "default" @@ -80,37 +80,33 @@ func TestResolveContentFromSecret(t *testing.T) { } } -func TestObserveIntervalSkip(t *testing.T) { +func TestObserveCreateOnlyIgnoresDrift(t *testing.T) { projectID := "123" - filePath := "README.md" - branch := "main" - content := "hello" - observeAt := metav1.NewTime(time.Now()) - + content := testContent + createOnly := true cr := &projectsv1alpha1.RepositoryFile{ Spec: projectsv1alpha1.RepositoryFileSpec{ ForProvider: projectsv1alpha1.RepositoryFileParameters{ - ProjectID: &projectID, - FilePath: filePath, - Branch: branch, - Content: &content, - ReconcileInterval: stringPtr("1h"), - }, - }, - Status: projectsv1alpha1.RepositoryFileStatus{ - AtProvider: projectsv1alpha1.RepositoryFileObservation{ - LastObserveTime: &observeAt, + ProjectID: &projectID, + FilePath: "README.md", + Branch: "main", + Content: &content, + CreateOnly: &createOnly, }, }, } - e := &external{pollInterval: time.Minute, client: &fake.MockClient{}} + e := &external{client: &fake.MockClient{ + MockGetFile: func(pid any, fileName string, opt *gitlab.GetFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.File, *gitlab.Response, error) { + return &gitlab.File{FilePath: "README.md", Ref: "main", SHA256: "different"}, &gitlab.Response{}, nil + }, + }} obs, err := e.Observe(context.Background(), cr) if err != nil { t.Fatalf("Observe() error = %v", err) } - if diff := cmp.Diff(managed.ExternalObservation{ResourceExists: true, ResourceUpToDate: true}, obs); diff != "" { - t.Fatalf("Observe(): -want, +got:\n%s", diff) + if diff := cmp.Diff(true, obs.ResourceUpToDate); diff != "" { + t.Fatalf("Observe() upToDate: -want, +got:\n%s", diff) } } @@ -153,7 +149,7 @@ func TestCreateOnlyInitializerConflict(t *testing.T) { func TestObserveExternalNameMismatch(t *testing.T) { projectID := "123" - content := "hello" + content := testContent cr := &projectsv1alpha1.RepositoryFile{ Spec: projectsv1alpha1.RepositoryFileSpec{ ForProvider: projectsv1alpha1.RepositoryFileParameters{ @@ -175,7 +171,7 @@ func TestObserveExternalNameMismatch(t *testing.T) { func TestDeleteIgnores404(t *testing.T) { projectID := "123" - content := "hello" + content := testContent cr := &projectsv1alpha1.RepositoryFile{ Spec: projectsv1alpha1.RepositoryFileSpec{ ForProvider: projectsv1alpha1.RepositoryFileParameters{ @@ -201,7 +197,3 @@ func TestDeleteIgnores404(t *testing.T) { t.Fatalf("Delete() condition reason = %s, want %s", got, xpv1.Deleting().Reason) } } - -func stringPtr(s string) *string { - return &s -} diff --git a/pkg/namespaced/clients/projects/repositoryfile.go b/pkg/namespaced/clients/projects/repositoryfile.go index f32b7a6b..9bf7ccb7 100644 --- a/pkg/namespaced/clients/projects/repositoryfile.go +++ b/pkg/namespaced/clients/projects/repositoryfile.go @@ -25,7 +25,6 @@ import ( xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" gitlab "gitlab.com/gitlab-org/api/client-go" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" projectsv1alpha1 "github.com/crossplane-contrib/provider-gitlab/apis/namespaced/projects/v1alpha1" "github.com/crossplane-contrib/provider-gitlab/pkg/common" @@ -51,22 +50,19 @@ func NewRepositoryFileClient(cfg common.Config) RepositoryFileClient { } // GenerateRepositoryFileObservation builds observation from a gitlab file. -func GenerateRepositoryFileObservation(file *gitlab.File, observedAt time.Time) projectsv1alpha1.RepositoryFileObservation { - observation := projectsv1alpha1.RepositoryFileObservation{ - LastObserveTime: &metav1.Time{Time: observedAt}, - } +func GenerateRepositoryFileObservation(file *gitlab.File) projectsv1alpha1.RepositoryFileObservation { if file == nil { - return observation + return projectsv1alpha1.RepositoryFileObservation{} } - observation.FilePath = file.FilePath - observation.BlobID = file.BlobID - observation.CommitID = file.CommitID - observation.LastCommitID = file.LastCommitID - observation.SHA256 = file.SHA256 - observation.Size = file.Size - - return observation + return projectsv1alpha1.RepositoryFileObservation{ + FilePath: file.FilePath, + BlobID: file.BlobID, + CommitID: file.CommitID, + LastCommitID: file.LastCommitID, + SHA256: file.SHA256, + Size: file.Size, + } } // LateInitializeRepositoryFile fills empty optional fields from the external file. @@ -170,24 +166,6 @@ func IsRepositoryFileUpToDate(p *projectsv1alpha1.RepositoryFileParameters, exte return RepositoryFileContentSHA256(p, content) == external.SHA256 } -// ShouldObserveRepositoryFileNow checks whether observe should call GitLab now. -func ShouldObserveRepositoryFileNow(p *projectsv1alpha1.RepositoryFileParameters, observation *projectsv1alpha1.RepositoryFileObservation, defaultPollInterval time.Duration, now time.Time) (bool, error) { - if observation == nil || observation.LastObserveTime == nil { - return true, nil - } - - interval, err := RepositoryFileReconcileInterval(p, defaultPollInterval) - if err != nil { - return false, err - } - - if interval <= 0 { - return true, nil - } - - return !observation.LastObserveTime.Add(interval).After(now), nil -} - // RepositoryFileReconcileInterval returns configured reconcile interval or default. func RepositoryFileReconcileInterval(p *projectsv1alpha1.RepositoryFileParameters, defaultPollInterval time.Duration) (time.Duration, error) { if p == nil || p.ReconcileInterval == nil || strings.TrimSpace(*p.ReconcileInterval) == "" { diff --git a/pkg/namespaced/clients/projects/repositoryfile_test.go b/pkg/namespaced/clients/projects/repositoryfile_test.go index af205d03..a57ec532 100644 --- a/pkg/namespaced/clients/projects/repositoryfile_test.go +++ b/pkg/namespaced/clients/projects/repositoryfile_test.go @@ -26,7 +26,6 @@ import ( xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" "github.com/google/go-cmp/cmp" gitlab "gitlab.com/gitlab-org/api/client-go" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" projectsv1alpha1 "github.com/crossplane-contrib/provider-gitlab/apis/namespaced/projects/v1alpha1" ) @@ -66,42 +65,26 @@ func TestRepositoryFileContentSHA256(t *testing.T) { } } -func TestShouldObserveRepositoryFileNow(t *testing.T) { - now := time.Now() +func TestRepositoryFileReconcileInterval(t *testing.T) { oneHour := "1h" - cases := map[string]struct { params *projectsv1alpha1.RepositoryFileParameters - observation *projectsv1alpha1.RepositoryFileObservation defaultPoll time.Duration - want bool + want time.Duration wantErr bool }{ - "NoLastObserveTime": { + "Default": { params: &projectsv1alpha1.RepositoryFileParameters{}, - observation: &projectsv1alpha1.RepositoryFileObservation{}, defaultPoll: time.Minute, - want: true, + want: time.Minute, }, - "SkipBeforeInterval": { - params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: &oneHour}, - observation: &projectsv1alpha1.RepositoryFileObservation{ - LastObserveTime: &metav1.Time{Time: now.Add(-30 * time.Minute)}, - }, - defaultPoll: time.Minute, - want: false, - }, - "ObserveAfterInterval": { - params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: &oneHour}, - observation: &projectsv1alpha1.RepositoryFileObservation{ - LastObserveTime: &metav1.Time{Time: now.Add(-2 * time.Hour)}, - }, + "Custom": { + params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: &oneHour}, defaultPoll: time.Minute, - want: true, + want: time.Hour, }, - "InvalidInterval": { + "Invalid": { params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: stringPtr("nope")}, - observation: &projectsv1alpha1.RepositoryFileObservation{LastObserveTime: &metav1.Time{Time: now}}, defaultPoll: time.Minute, wantErr: true, }, @@ -109,18 +92,18 @@ func TestShouldObserveRepositoryFileNow(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - got, err := ShouldObserveRepositoryFileNow(tc.params, tc.observation, tc.defaultPoll, now) + got, err := RepositoryFileReconcileInterval(tc.params, tc.defaultPoll) if tc.wantErr { if err == nil { - t.Fatal("ShouldObserveRepositoryFileNow() expected error") + t.Fatal("RepositoryFileReconcileInterval() expected error") } return } if err != nil { - t.Fatalf("ShouldObserveRepositoryFileNow() error = %v", err) + t.Fatalf("RepositoryFileReconcileInterval() error = %v", err) } if diff := cmp.Diff(tc.want, got); diff != "" { - t.Fatalf("ShouldObserveRepositoryFileNow(): -want, +got:\n%s", diff) + t.Fatalf("RepositoryFileReconcileInterval(): -want, +got:\n%s", diff) } }) } diff --git a/pkg/namespaced/controller/projects/repositoryfiles/controller.go b/pkg/namespaced/controller/projects/repositoryfiles/controller.go index e77ee77b..773e167c 100644 --- a/pkg/namespaced/controller/projects/repositoryfiles/controller.go +++ b/pkg/namespaced/controller/projects/repositoryfiles/controller.go @@ -23,7 +23,6 @@ import ( xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" "github.com/crossplane/crossplane-runtime/v2/pkg/controller" "github.com/crossplane/crossplane-runtime/v2/pkg/event" - "github.com/crossplane/crossplane-runtime/v2/pkg/feature" "github.com/crossplane/crossplane-runtime/v2/pkg/meta" "github.com/crossplane/crossplane-runtime/v2/pkg/reconciler/managed" "github.com/crossplane/crossplane-runtime/v2/pkg/resource" @@ -49,7 +48,6 @@ const ( errResolveContentFailed = "cannot resolve Gitlab repository file content" errProjectIDMissing = "ProjectID is missing" errExternalNameMismatch = "external-name must match spec.forProvider.filePath" - errReconcileIntervalInvalid = "invalid reconcileInterval" errRepositoryFileContentMissing = "exactly one of content or contentSecretRef must be set" errCreateOnlyPolicyConflict = "createOnly conflicts with explicit managementPolicies" ) @@ -58,7 +56,8 @@ const ( func SetupRepositoryFile(mgr ctrl.Manager, o controller.Options) error { name := managed.ControllerName(projectsv1alpha1.RepositoryFileGroupKind) - reconcilerOpts := []managed.ReconcilerOption{ + reconcilerOpts := make([]managed.ReconcilerOption, 0, 7) + reconcilerOpts = append(reconcilerOpts, managed.WithExternalConnecter(&connector{ kube: mgr.GetClient(), newGitlabClientFn: projectclients.NewRepositoryFileClient, @@ -66,13 +65,21 @@ func SetupRepositoryFile(mgr ctrl.Manager, o controller.Options) error { }), managed.WithInitializers(managed.NewNameAsExternalName(mgr.GetClient()), createOnlyInitializer{client: mgr.GetClient()}), managed.WithPollInterval(o.PollInterval), + managed.WithPollIntervalHook(func(mg resource.Managed, pollInterval time.Duration) time.Duration { + cr, ok := mg.(*projectsv1alpha1.RepositoryFile) + if !ok { + return pollInterval + } + interval, err := projectclients.RepositoryFileReconcileInterval(&cr.Spec.ForProvider, pollInterval) + if err != nil { + return pollInterval + } + return interval + }), managed.WithLogger(o.Logger.WithValues("controller", name)), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), - } - - if o.Features.Enabled(feature.EnableBetaManagementPolicies) { - reconcilerOpts = append(reconcilerOpts, managed.WithManagementPolicies()) - } + managed.WithManagementPolicies(), + ) r := managed.NewReconciler(mgr, resource.ManagedKind(projectsv1alpha1.RepositoryFileGroupVersionKind), @@ -158,20 +165,8 @@ func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex return managed.ExternalObservation{}, errors.New(errProjectIDMissing) } - if externalName := meta.GetExternalName(cr); externalName != "" && externalName != cr.Spec.ForProvider.FilePath { - return managed.ExternalObservation{}, errors.New(errExternalNameMismatch) - } - - now := time.Now() - shouldObserve, err := projectclients.ShouldObserveRepositoryFileNow(&cr.Spec.ForProvider, &cr.Status.AtProvider, e.pollInterval, now) - if err != nil { - return managed.ExternalObservation{}, errors.Wrap(err, errReconcileIntervalInvalid) - } - if !shouldObserve { - return managed.ExternalObservation{ - ResourceExists: true, - ResourceUpToDate: true, - }, nil + if err := validateRepositoryFileExternalName(cr); err != nil { + return managed.ExternalObservation{}, err } file, res, err := e.client.GetFile( @@ -189,9 +184,13 @@ func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex current := cr.Spec.ForProvider.DeepCopy() projectclients.LateInitializeRepositoryFile(&cr.Spec.ForProvider, file) - cr.Status.AtProvider = projectclients.GenerateRepositoryFileObservation(file, now) + cr.Status.AtProvider = projectclients.GenerateRepositoryFileObservation(file) cr.Status.SetConditions(xpv1.Available()) + if observation, handled := observeCreateOnlyRepositoryFile(cr, current); handled { + return observation, nil + } + content, err := e.resolveContent(ctx, mg, &cr.Spec.ForProvider) if err != nil { return managed.ExternalObservation{}, errors.Wrap(err, errResolveContentFailed) @@ -204,6 +203,30 @@ func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex }, nil } +func validateRepositoryFileExternalName(cr *projectsv1alpha1.RepositoryFile) error { + externalName := meta.GetExternalName(cr) + if externalName == "" || externalName == cr.Spec.ForProvider.FilePath { + return nil + } + if meta.WasCreated(cr) || (externalName == cr.GetName() && cr.Status.AtProvider.FilePath == "") { + // Newly created resources may still carry metadata.name as external-name + // until Create sets the real file path. Do not block initial creation. + return nil + } + return errors.New(errExternalNameMismatch) +} + +func observeCreateOnlyRepositoryFile(cr *projectsv1alpha1.RepositoryFile, current *projectsv1alpha1.RepositoryFileParameters) (managed.ExternalObservation, bool) { + if !projectclients.RepositoryFileCreateOnly(&cr.Spec.ForProvider) { + return managed.ExternalObservation{}, false + } + return managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + ResourceLateInitialized: !cmp.Equal(current, &cr.Spec.ForProvider), + }, true +} + func (e *external) Create(ctx context.Context, mg resource.Managed) (managed.ExternalCreation, error) { cr, ok := mg.(*projectsv1alpha1.RepositoryFile) if !ok { diff --git a/pkg/namespaced/controller/projects/repositoryfiles/controller_test.go b/pkg/namespaced/controller/projects/repositoryfiles/controller_test.go index e4e90888..8bb393da 100644 --- a/pkg/namespaced/controller/projects/repositoryfiles/controller_test.go +++ b/pkg/namespaced/controller/projects/repositoryfiles/controller_test.go @@ -24,13 +24,11 @@ import ( xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" "github.com/crossplane/crossplane-runtime/v2/pkg/meta" - "github.com/crossplane/crossplane-runtime/v2/pkg/reconciler/managed" "github.com/crossplane/crossplane-runtime/v2/pkg/resource" "github.com/crossplane/crossplane-runtime/v2/pkg/test" "github.com/google/go-cmp/cmp" gitlab "gitlab.com/gitlab-org/api/client-go" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" projectsv1alpha1 "github.com/crossplane-contrib/provider-gitlab/apis/namespaced/projects/v1alpha1" @@ -39,8 +37,10 @@ import ( "github.com/crossplane-contrib/provider-gitlab/pkg/namespaced/clients/projects/fake" ) +const testContent = "hello" + func TestResolveContent(t *testing.T) { - content := "hello" + content := testContent cr := &projectsv1alpha1.RepositoryFile{} cr.Namespace = "default" @@ -55,7 +55,7 @@ func TestResolveContent(t *testing.T) { } func TestResolveContentFromSecret(t *testing.T) { - content := "hello" + content := testContent cr := &projectsv1alpha1.RepositoryFile{} cr.Namespace = "default" @@ -78,37 +78,33 @@ func TestResolveContentFromSecret(t *testing.T) { } } -func TestObserveIntervalSkip(t *testing.T) { +func TestObserveCreateOnlyIgnoresDrift(t *testing.T) { projectID := "123" - filePath := "README.md" - branch := "main" - content := "hello" - observeAt := metav1.NewTime(time.Now()) - + content := testContent + createOnly := true cr := &projectsv1alpha1.RepositoryFile{ Spec: projectsv1alpha1.RepositoryFileSpec{ ForProvider: projectsv1alpha1.RepositoryFileParameters{ - ProjectID: &projectID, - FilePath: filePath, - Branch: branch, - Content: &content, - ReconcileInterval: stringPtr("1h"), - }, - }, - Status: projectsv1alpha1.RepositoryFileStatus{ - AtProvider: projectsv1alpha1.RepositoryFileObservation{ - LastObserveTime: &observeAt, + ProjectID: &projectID, + FilePath: "README.md", + Branch: "main", + Content: &content, + CreateOnly: &createOnly, }, }, } - e := &external{pollInterval: time.Minute, client: &fake.MockClient{}} + e := &external{client: &fake.MockClient{ + MockGetFile: func(pid any, fileName string, opt *gitlab.GetFileOptions, options ...gitlab.RequestOptionFunc) (*gitlab.File, *gitlab.Response, error) { + return &gitlab.File{FilePath: "README.md", Ref: "main", SHA256: "different"}, &gitlab.Response{}, nil + }, + }} obs, err := e.Observe(context.Background(), cr) if err != nil { t.Fatalf("Observe() error = %v", err) } - if diff := cmp.Diff(managed.ExternalObservation{ResourceExists: true, ResourceUpToDate: true}, obs); diff != "" { - t.Fatalf("Observe(): -want, +got:\n%s", diff) + if diff := cmp.Diff(true, obs.ResourceUpToDate); diff != "" { + t.Fatalf("Observe() upToDate: -want, +got:\n%s", diff) } } @@ -151,7 +147,7 @@ func TestCreateOnlyInitializerConflict(t *testing.T) { func TestObserveExternalNameMismatch(t *testing.T) { projectID := "123" - content := "hello" + content := testContent cr := &projectsv1alpha1.RepositoryFile{ Spec: projectsv1alpha1.RepositoryFileSpec{ ForProvider: projectsv1alpha1.RepositoryFileParameters{ @@ -173,7 +169,7 @@ func TestObserveExternalNameMismatch(t *testing.T) { func TestDeleteIgnores404(t *testing.T) { projectID := "123" - content := "hello" + content := testContent cr := &projectsv1alpha1.RepositoryFile{ Spec: projectsv1alpha1.RepositoryFileSpec{ ForProvider: projectsv1alpha1.RepositoryFileParameters{ @@ -199,7 +195,3 @@ func TestDeleteIgnores404(t *testing.T) { t.Fatalf("Delete() condition reason = %s, want %s", got, xpv1.Deleting().Reason) } } - -func stringPtr(s string) *string { - return &s -}