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/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..ae6655e9 100644 --- a/apis/cluster/projects/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/projects/v1alpha1/zz_generated.deepcopy.go @@ -3263,6 +3263,204 @@ 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 +} + +// 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) + out.AtProvider = in.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..56788fe0 --- /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 + // +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"` +} + +// 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"` +} + +// 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: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" +// +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..305467b5 --- /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 + // +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"` +} + +// 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"` +} + +// 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: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" +// +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..a995c670 100644 --- a/apis/namespaced/projects/v1alpha1/zz_generated.deepcopy.go +++ b/apis/namespaced/projects/v1alpha1/zz_generated.deepcopy.go @@ -3263,6 +3263,204 @@ 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 +} + +// 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) + out.AtProvider = in.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-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 new file mode 100644 index 00000000..40a1f6ab --- /dev/null +++ b/examples/projects/repositoryfile.yaml @@ -0,0 +1,20 @@ +apiVersion: projects.gitlab.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: 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 new file mode 100644 index 00000000..382f4cdd --- /dev/null +++ b/package/crds/projects.gitlab.crossplane.io_repositoryfiles.yaml @@ -0,0 +1,431 @@ +--- +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. + pattern: ^([0-9]+(ns|us|µs|ms|s|m|h))+$ + 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 + 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 + x-kubernetes-validations: + - 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) || size(self.spec.managementPolicies) + != 1 || self.spec.managementPolicies[0] != ''*''' + 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..8f9c2022 --- /dev/null +++ b/package/crds/projects.gitlab.m.crossplane.io_repositoryfiles.yaml @@ -0,0 +1,390 @@ +--- +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. + pattern: ^([0-9]+(ns|us|µs|ms|s|m|h))+$ + 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 + 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 + x-kubernetes-validations: + - 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) || size(self.spec.managementPolicies) + != 1 || self.spec.managementPolicies[0] != ''*''' + 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..99a0fe66 --- /dev/null +++ b/pkg/cluster/clients/projects/zz_repositoryfile.go @@ -0,0 +1,265 @@ +/* +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" + + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + gitlab "gitlab.com/gitlab-org/api/client-go" + + 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) projectsv1alpha1.RepositoryFileObservation { + if file == nil { + return projectsv1alpha1.RepositoryFileObservation{} + } + + 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. +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 +} + +// 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 +} + +// 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..9c8080f8 --- /dev/null +++ b/pkg/cluster/clients/projects/zz_repositoryfile_test.go @@ -0,0 +1,246 @@ +/* +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" + + 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 TestRepositoryFileReconcileInterval(t *testing.T) { + oneHour := "1h" + cases := map[string]struct { + params *projectsv1alpha1.RepositoryFileParameters + defaultPoll time.Duration + want time.Duration + wantErr bool + }{ + "Default": { + params: &projectsv1alpha1.RepositoryFileParameters{}, + defaultPoll: time.Minute, + want: time.Minute, + }, + "Custom": { + params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: &oneHour}, + defaultPoll: time.Minute, + want: time.Hour, + }, + "Invalid": { + params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: stringPtr("nope")}, + defaultPoll: time.Minute, + wantErr: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := RepositoryFileReconcileInterval(tc.params, tc.defaultPoll) + if tc.wantErr { + if err == nil { + t.Fatal("RepositoryFileReconcileInterval() expected error") + } + return + } + if err != nil { + t.Fatalf("RepositoryFileReconcileInterval() error = %v", err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("RepositoryFileReconcileInterval(): -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 new file mode 100644 index 00000000..705ff221 --- /dev/null +++ b/pkg/cluster/controller/projects/repositoryfiles/zz_controller.go @@ -0,0 +1,341 @@ +/* +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/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" + errRepositoryFileContentMissing = "exactly one of content or contentSecretRef must be set" + errCreateOnlyPolicyConflict = "createOnly conflicts with explicit managementPolicies" +) + +// SetupRepositoryFile adds a controller that reconciles RepositoryFiles. +func SetupRepositoryFile(mgr ctrl.Manager, o controller.Options) error { + name := managed.ControllerName("cluster." + projectsv1alpha1.RepositoryFileGroupKind) + + reconcilerOpts := make([]managed.ReconcilerOption, 0, 7) + reconcilerOpts = append(reconcilerOpts, + managed.WithExternalConnecter(&connector{ + kube: mgr.GetClient(), + newGitlabClientFn: projectclients.NewRepositoryFileClient, + pollInterval: o.PollInterval, + }), + 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))), + 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 +} + +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 { + return managed.ExternalObservation{}, errors.New(errNotRepositoryFile) + } + + if cr.Spec.ForProvider.ProjectID == nil { + return managed.ExternalObservation{}, errors.New(errProjectIDMissing) + } + + if err := validateRepositoryFileExternalName(cr); err != nil { + return managed.ExternalObservation{}, err + } + + 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) + 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) + } + + return managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: projectclients.IsRepositoryFileUpToDate(&cr.Spec.ForProvider, file, content), + ResourceLateInitialized: !cmp.Equal(current, &cr.Spec.ForProvider), + }, 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 { + 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) + } + + 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/repositoryfiles/zz_controller_test.go b/pkg/cluster/controller/projects/repositoryfiles/zz_controller_test.go new file mode 100644 index 00000000..fb08013a --- /dev/null +++ b/pkg/cluster/controller/projects/repositoryfiles/zz_controller_test.go @@ -0,0 +1,199 @@ +/* +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/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" + "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" +) + +const testContent = "hello" + +func TestResolveContent(t *testing.T) { + content := testContent + 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 := testContent + 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 TestObserveCreateOnlyIgnoresDrift(t *testing.T) { + projectID := "123" + content := testContent + createOnly := true + cr := &projectsv1alpha1.RepositoryFile{ + Spec: projectsv1alpha1.RepositoryFileSpec{ + ForProvider: projectsv1alpha1.RepositoryFileParameters{ + ProjectID: &projectID, + FilePath: "README.md", + Branch: "main", + Content: &content, + CreateOnly: &createOnly, + }, + }, + } + + 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(true, obs.ResourceUpToDate); diff != "" { + t.Fatalf("Observe() upToDate: -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 := testContent + 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 := testContent + 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) + } +} 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..9bf7ccb7 --- /dev/null +++ b/pkg/namespaced/clients/projects/repositoryfile.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. +*/ + +package projects + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" + "time" + + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + gitlab "gitlab.com/gitlab-org/api/client-go" + + 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) projectsv1alpha1.RepositoryFileObservation { + if file == nil { + return projectsv1alpha1.RepositoryFileObservation{} + } + + 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. +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 +} + +// 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 +} + +// 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 new file mode 100644 index 00000000..a57ec532 --- /dev/null +++ b/pkg/namespaced/clients/projects/repositoryfile_test.go @@ -0,0 +1,244 @@ +/* +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" + + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + "github.com/google/go-cmp/cmp" + gitlab "gitlab.com/gitlab-org/api/client-go" + + 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 TestRepositoryFileReconcileInterval(t *testing.T) { + oneHour := "1h" + cases := map[string]struct { + params *projectsv1alpha1.RepositoryFileParameters + defaultPoll time.Duration + want time.Duration + wantErr bool + }{ + "Default": { + params: &projectsv1alpha1.RepositoryFileParameters{}, + defaultPoll: time.Minute, + want: time.Minute, + }, + "Custom": { + params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: &oneHour}, + defaultPoll: time.Minute, + want: time.Hour, + }, + "Invalid": { + params: &projectsv1alpha1.RepositoryFileParameters{ReconcileInterval: stringPtr("nope")}, + defaultPoll: time.Minute, + wantErr: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := RepositoryFileReconcileInterval(tc.params, tc.defaultPoll) + if tc.wantErr { + if err == nil { + t.Fatal("RepositoryFileReconcileInterval() expected error") + } + return + } + if err != nil { + t.Fatalf("RepositoryFileReconcileInterval() error = %v", err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("RepositoryFileReconcileInterval(): -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/namespaced/controller/projects/repositoryfiles/controller.go b/pkg/namespaced/controller/projects/repositoryfiles/controller.go new file mode 100644 index 00000000..773e167c --- /dev/null +++ b/pkg/namespaced/controller/projects/repositoryfiles/controller.go @@ -0,0 +1,339 @@ +/* +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/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" + errRepositoryFileContentMissing = "exactly one of content or contentSecretRef must be set" + errCreateOnlyPolicyConflict = "createOnly conflicts with explicit managementPolicies" +) + +// SetupRepositoryFile adds a controller that reconciles RepositoryFiles. +func SetupRepositoryFile(mgr ctrl.Manager, o controller.Options) error { + name := managed.ControllerName(projectsv1alpha1.RepositoryFileGroupKind) + + reconcilerOpts := make([]managed.ReconcilerOption, 0, 7) + reconcilerOpts = append(reconcilerOpts, + managed.WithExternalConnecter(&connector{ + kube: mgr.GetClient(), + newGitlabClientFn: projectclients.NewRepositoryFileClient, + pollInterval: o.PollInterval, + }), + 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))), + 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 +} + +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 { + return managed.ExternalObservation{}, errors.New(errNotRepositoryFile) + } + + if cr.Spec.ForProvider.ProjectID == nil { + return managed.ExternalObservation{}, errors.New(errProjectIDMissing) + } + + if err := validateRepositoryFileExternalName(cr); err != nil { + return managed.ExternalObservation{}, err + } + + 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) + 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) + } + + return managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: projectclients.IsRepositoryFileUpToDate(&cr.Spec.ForProvider, file, content), + ResourceLateInitialized: !cmp.Equal(current, &cr.Spec.ForProvider), + }, 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 { + 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) + } + + 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..8bb393da --- /dev/null +++ b/pkg/namespaced/controller/projects/repositoryfiles/controller_test.go @@ -0,0 +1,197 @@ +/* +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/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" + "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" +) + +const testContent = "hello" + +func TestResolveContent(t *testing.T) { + content := testContent + 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 := testContent + 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 TestObserveCreateOnlyIgnoresDrift(t *testing.T) { + projectID := "123" + content := testContent + createOnly := true + cr := &projectsv1alpha1.RepositoryFile{ + Spec: projectsv1alpha1.RepositoryFileSpec{ + ForProvider: projectsv1alpha1.RepositoryFileParameters{ + ProjectID: &projectID, + FilePath: "README.md", + Branch: "main", + Content: &content, + CreateOnly: &createOnly, + }, + }, + } + + 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(true, obs.ResourceUpToDate); diff != "" { + t.Fatalf("Observe() upToDate: -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 := testContent + 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 := testContent + 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) + } +} 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,