diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 0777fbe6..6020334b 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -62,135 +62,135 @@ jobs: bin/k8s key: ${{ steps.cache-k8s-restore.outputs.cache-primary-key }} - e2e-tests: - runs-on: large_runner - steps: - - name: Self Hosted Runner Post Job Cleanup Action - uses: TooMuch4U/actions-clean@v2.2 - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: '${{ github.workspace }}/go.mod' - cache: false - - - name: Get Go environment - run: | - echo "go_cache=$(go env GOCACHE)" >> $GITHUB_ENV - echo "go_modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - - name: Set up cache - # https://github.com/actions/setup-go/issues/358 - cache is not working with setup-go for multiple jobs - uses: actions/cache@v4 - with: - path: | - ${{ env.go_cache }} - ${{ env.go_modcache }} - bin/k8s - key: ${{ env.cache_name }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}-${{ hashFiles('**/go.mod') }} - restore-keys: | - ${{ env.cache_name }}-${{ runner.os }}-go- - env: - cache_name: integration-test - - - name: Create k8s Kind Cluster - uses: helm/kind-action@v1 - with: - config: test/e2e/config/kind-config.yaml - - - name: Install internal image registry - run: | - kubectl apply -f test/e2e/config/image-registry.yaml - kubectl wait pod -l app=registry --for condition=Ready --timeout 5m - kubectl wait pod -l app=protected-registry1 --for condition=Ready --timeout 5m - kubectl wait pod -l app=protected-registry2 --for condition=Ready --timeout 5m - - - name: Install external CRDs - run: kubectl apply --server-side -k https://github.com/openfluxcd/artifact//config/crd?ref=v0.1.1 - - - name: Checkout helm-controller - uses: actions/checkout@v4 - with: - repository: openfluxcd/helm-controller - path: helm-controller - - # TODO: Create helm-controller image in public repository to omit rebuilds - - name: Install helm-controller - env: - IMG: localhost:31000/helm-controller:latest - run: | - make -C helm-controller docker-build - make -C helm-controller docker-push - make -C helm-controller install - make -C helm-controller deploy - kubectl wait deployment.apps/helm-controller --for condition=Available --namespace helm-system --timeout 5m - kubectl logs --tail -1 -l app=helm-controller -n helm-system -f --ignore-errors &> helm-controller.log & - - - name: Checkout kustomize-controller - uses: actions/checkout@v4 - with: - repository: openfluxcd/kustomize-controller - path: kustomize-controller - - # TODO: Create kustomize-controller image in public repository to omit rebuilds - - name: Install kustomize-controller - env: - IMG: localhost:31000/kustomize-controller:latest - run: | - make -C kustomize-controller docker-build - make -C kustomize-controller docker-push - make -C kustomize-controller install - make -C kustomize-controller deploy - kubectl wait deployment.apps/kustomize-controller --for condition=Available --namespace kustomize-system --timeout 5m - kubectl logs --tail -1 -l app=kustomize-controller -n kustomize-system -f --ignore-errors &> kustomize-controller.log & - - # TODO: Replace once the release with the 'skipDigestGeneration' field in the component constructor is available - # uses: open-component-model/ocm-setup-action@main - # with: - # version: v0.19.0-rc.1 - - name: Set up cache for ocm (temporarily) - uses: actions/cache@v4 - with: - path: | - ocm/bin - key: ${{ env.cache_name }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}-${{ hashFiles('**/go.mod') }} - restore-keys: | - ${{ env.cache_name }}-${{ runner.os }}-go- - env: - cache_name: dummy-cache - - name: Checkout OCM (temporarily) - uses: actions/checkout@v4 - with: - repository: open-component-model/ocm - path: ocm - - name: Build OCM (temporarily) - run: | - make -C ocm bin/ocm - echo "${{ github.workspace }}/ocm/bin" >> "$GITHUB_PATH" - - - name: Run e2e test - env: - RESOURCE_TIMEOUT: 5m - HELM_CHART: ghcr.io/stefanprodan/charts/podinfo:6.7.1 - IMAGE_REFERENCE: ghcr.io/stefanprodan/podinfo:6.7.1 - CONTROLLER_LOG_PATH: ./ocm-k8s-toolkit-controller.log - IMAGE_REGISTRY_URL: http://localhost:31000 - INTERNAL_IMAGE_REGISTRY_URL: http://registry-internal.default.svc.cluster.local:5000 - PROTECTED_REGISTRY_URL: http://localhost:31001 - INTERNAL_PROTECTED_REGISTRY_URL: http://protected-registry1-internal.default.svc.cluster.local:5001 - PROTECTED_REGISTRY_URL2: http://localhost:31002 - INTERNAL_PROTECTED_REGISTRY_URL2: http://protected-registry2-internal.default.svc.cluster.local:5002 - run: make test-e2e - - - name: Publish logs on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: controller-logs - # Currently, it is planned that the integration tests runs on every commit on a PR. Therefore, we could - # produce a lot of logs. To note clutter the storage, the retention-days are reduced to 1. - retention-days: 1 - path: | - helm-controller.log - kustomize-controller.log - ocm-k8s-toolkit-controller.log \ No newline at end of file +# e2e-tests: +# runs-on: large_runner +# steps: +# - name: Self Hosted Runner Post Job Cleanup Action +# uses: TooMuch4U/actions-clean@v2.2 +# - name: Checkout +# uses: actions/checkout@v4 +# - name: Setup Go +# uses: actions/setup-go@v5 +# with: +# go-version-file: '${{ github.workspace }}/go.mod' +# cache: false +# +# - name: Get Go environment +# run: | +# echo "go_cache=$(go env GOCACHE)" >> $GITHUB_ENV +# echo "go_modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV +# - name: Set up cache +# # https://github.com/actions/setup-go/issues/358 - cache is not working with setup-go for multiple jobs +# uses: actions/cache@v4 +# with: +# path: | +# ${{ env.go_cache }} +# ${{ env.go_modcache }} +# bin/k8s +# key: ${{ env.cache_name }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}-${{ hashFiles('**/go.mod') }} +# restore-keys: | +# ${{ env.cache_name }}-${{ runner.os }}-go- +# env: +# cache_name: integration-test +# +# - name: Create k8s Kind Cluster +# uses: helm/kind-action@v1 +# with: +# config: test/e2e/config/kind-config.yaml +# +# - name: Install internal image registry +# run: | +# kubectl apply -f test/e2e/config/image-registry.yaml +# kubectl wait pod -l app=registry --for condition=Ready --timeout 5m +# kubectl wait pod -l app=protected-registry1 --for condition=Ready --timeout 5m +# kubectl wait pod -l app=protected-registry2 --for condition=Ready --timeout 5m +# +# - name: Install external CRDs +# run: kubectl apply --server-side -k https://github.com/openfluxcd/artifact//config/crd?ref=v0.1.1 +# +# - name: Checkout helm-controller +# uses: actions/checkout@v4 +# with: +# repository: openfluxcd/helm-controller +# path: helm-controller +# +# # TODO: Create helm-controller image in public repository to omit rebuilds +# - name: Install helm-controller +# env: +# IMG: localhost:31000/helm-controller:latest +# run: | +# make -C helm-controller docker-build +# make -C helm-controller docker-push +# make -C helm-controller install +# make -C helm-controller deploy +# kubectl wait deployment.apps/helm-controller --for condition=Available --namespace helm-system --timeout 5m +# kubectl logs --tail -1 -l app=helm-controller -n helm-system -f --ignore-errors &> helm-controller.log & +# +# - name: Checkout kustomize-controller +# uses: actions/checkout@v4 +# with: +# repository: openfluxcd/kustomize-controller +# path: kustomize-controller +# +# # TODO: Create kustomize-controller image in public repository to omit rebuilds +# - name: Install kustomize-controller +# env: +# IMG: localhost:31000/kustomize-controller:latest +# run: | +# make -C kustomize-controller docker-build +# make -C kustomize-controller docker-push +# make -C kustomize-controller install +# make -C kustomize-controller deploy +# kubectl wait deployment.apps/kustomize-controller --for condition=Available --namespace kustomize-system --timeout 5m +# kubectl logs --tail -1 -l app=kustomize-controller -n kustomize-system -f --ignore-errors &> kustomize-controller.log & +# +# # TODO: Replace once the release with the 'skipDigestGeneration' field in the component constructor is available +# # uses: open-component-model/ocm-setup-action@main +# # with: +# # version: v0.19.0-rc.1 +# - name: Set up cache for ocm (temporarily) +# uses: actions/cache@v4 +# with: +# path: | +# ocm/bin +# key: ${{ env.cache_name }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}-${{ hashFiles('**/go.mod') }} +# restore-keys: | +# ${{ env.cache_name }}-${{ runner.os }}-go- +# env: +# cache_name: dummy-cache +# - name: Checkout OCM (temporarily) +# uses: actions/checkout@v4 +# with: +# repository: open-component-model/ocm +# path: ocm +# - name: Build OCM (temporarily) +# run: | +# make -C ocm bin/ocm +# echo "${{ github.workspace }}/ocm/bin" >> "$GITHUB_PATH" +# +# - name: Run e2e test +# env: +# RESOURCE_TIMEOUT: 5m +# HELM_CHART: ghcr.io/stefanprodan/charts/podinfo:6.7.1 +# IMAGE_REFERENCE: ghcr.io/stefanprodan/podinfo:6.7.1 +# CONTROLLER_LOG_PATH: ./ocm-k8s-toolkit-controller.log +# IMAGE_REGISTRY_URL: http://localhost:31000 +# INTERNAL_IMAGE_REGISTRY_URL: http://registry-internal.default.svc.cluster.local:5000 +# PROTECTED_REGISTRY_URL: http://localhost:31001 +# INTERNAL_PROTECTED_REGISTRY_URL: http://protected-registry1-internal.default.svc.cluster.local:5001 +# PROTECTED_REGISTRY_URL2: http://localhost:31002 +# INTERNAL_PROTECTED_REGISTRY_URL2: http://protected-registry2-internal.default.svc.cluster.local:5002 +# run: make test-e2e +# +# - name: Publish logs on failure +# if: failure() +# uses: actions/upload-artifact@v4 +# with: +# name: controller-logs +# # Currently, it is planned that the integration tests runs on every commit on a PR. Therefore, we could +# # produce a lot of logs. To note clutter the storage, the retention-days are reduced to 1. +# retention-days: 1 +# path: | +# helm-controller.log +# kustomize-controller.log +# ocm-k8s-toolkit-controller.log \ No newline at end of file diff --git a/Makefile b/Makefile index a6ed61f3..2d0f9556 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,10 @@ else GOBIN=$(shell go env GOBIN) endif +OS ?= $(shell go env GOOS) +ARCH ?= $(shell go env GOARCH) + + # CONTAINER_TOOL defines the container tool to be used for building images. # Be aware that the target commands are only tested with Docker which is # scaffolded by default. However, you might want to replace it to use other @@ -64,7 +68,7 @@ vet: ## Run go vet against code. go vet ./... .PHONY: test -test: manifests generate envtest ## Run tests. +test: manifests generate envtest zot-registry ## Run tests. KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out # Utilize Kind or modify the e2e tests to load the image locally, enabling compatibility with other vendors. @@ -178,6 +182,7 @@ KUSTOMIZE_VERSION ?= v5.4.1 CONTROLLER_TOOLS_VERSION ?= v0.16.0 ENVTEST_VERSION ?= release-0.18 GOLANGCI_LINT_VERSION ?= v1.61.0 +ZOT_VERSION ?= v2.1.2 ## ZOT OCI Registry ZOT_VERSION ?= v2.1.2 @@ -213,6 +218,12 @@ deploy-cert-manager: ## Deploy cert-manager to the K8s cluster specified in ~/.k undeploy-cert-manager: ## Undeploy cert-manager from the K8s cluster specified in ~/.kube/config. $(KUBECTL) delete --ignore-not-found=$(IGNORE_NOT_FOUND) -f $(CERT-MANAGER_YAML) +.PHONY: zot-registry +zot-registry: $(LOCALBIN) # Download zot registry binary locally if necessary. + wget "https://github.com/project-zot/zot/releases/download/$(ZOT_VERSION)/zot-$(OS)-$(ARCH)-minimal" \ + -O $(LOCALBIN)/zot-registry \ + && chmod u+x $(LOCALBIN)/zot-registry + .PHONY: golangci-lint golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. $(GOLANGCI_LINT): $(LOCALBIN) diff --git a/PROJECT b/PROJECT index c8ec47cf..f172fb06 100644 --- a/PROJECT +++ b/PROJECT @@ -71,4 +71,13 @@ resources: kind: Replication path: github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1 version: v1alpha1 -version: "3" \ No newline at end of file +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: ocm.software + group: delivery + kind: Snapshot + path: github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1 + version: v1alpha1 +version: "3" diff --git a/api/v1alpha1/common_types.go b/api/v1alpha1/common_types.go index 7e67d0f8..861c058f 100644 --- a/api/v1alpha1/common_types.go +++ b/api/v1alpha1/common_types.go @@ -113,3 +113,15 @@ type ResourceInfo struct { // +required Digest string `json:"digest,omitempty"` } + +type BlobInfo struct { + // Digest is the digest of the blob in the form of ':'. + Digest string `json:"digest"` + + // Tag/Version of the blob + Tag string `json:"tag"` + + // Size is the number of bytes of the blob. + // Can be used to determine how to file should be handled when downloaded (memory/disk) + Size int64 `json:"size"` +} diff --git a/api/v1alpha1/component_types.go b/api/v1alpha1/component_types.go index c005248a..3fa2d179 100644 --- a/api/v1alpha1/component_types.go +++ b/api/v1alpha1/component_types.go @@ -100,11 +100,11 @@ type ComponentStatus struct { // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` - // ArtifactRef references the generated artifact containing a list of + // SnapshotRef references the generated snapshot containing a list of // component descriptors. This list can be used by other controllers to // avoid re-downloading (and potentially also re-verifying) the components. // +optional - ArtifactRef corev1.LocalObjectReference `json:"artifactRef,omitempty"` + SnapshotRef corev1.LocalObjectReference `json:"snapshotRef,omitempty"` // Component specifies the concrete version of the component that was // fetched after based on the semver constraints during the last successful @@ -180,6 +180,10 @@ func (in *Component) GetVerifications() []Verification { return in.Spec.Verify } +func (in *Component) GetSnapshotName() string { + return in.Status.SnapshotRef.Name +} + // +kubebuilder:object:root=true // ComponentList contains a list of Component. diff --git a/api/v1alpha1/condition_types.go b/api/v1alpha1/condition_types.go index d7f0f2d3..01ac870c 100644 --- a/api/v1alpha1/condition_types.go +++ b/api/v1alpha1/condition_types.go @@ -17,22 +17,16 @@ limitations under the License. package v1alpha1 const ( - // SecretFetchFailedReason is used when the controller failed to fetch its secrets. - SecretFetchFailedReason = "SecretFetchFailed" - // ConfigFetchFailedReason is used when the controller failed to fetch its configs. ConfigFetchFailedReason = "ConfigFetchFailed" - // VerificationsInvalidReason is used when the controller failed to gather the verification information. - VerificationsInvalidReason = "VerificationsInvalid" - // ConfigureContextFailedReason is used when the controller failed to create an authenticated context. ConfigureContextFailedReason = "ConfigureContextFailed" // CheckVersionFailedReason is used when the controller failed to check for new versions. CheckVersionFailedReason = "CheckVersionFailed" - // RepositorySpecInvalidReason is used when the referenced repository spec cannot be unmarshaled and therefore is + // RepositorySpecInvalidReason is used when the referenced repository spec cannot be unmarshalled and therefore is // invalid. RepositorySpecInvalidReason = "RepositorySpecInvalid" @@ -42,7 +36,7 @@ const ( // ComponentIsNotReadyReason is used when the referenced component is not Ready yet. ComponentIsNotReadyReason = "ComponentIsNotReady" - // ComponentIsNotReadyReason is used when the referenced component is not Ready yet. + // ReplicationFailedReason is used when the referenced component is not Ready yet. ReplicationFailedReason = "ReplicationFailed" // VerificationFailedReason is used when the signature verification of a component failed. @@ -57,14 +51,32 @@ const ( // GetComponentVersionFailedReason is used when the component cannot be fetched. GetComponentVersionFailedReason = "GetComponentVersionFailed" - // StorageReconcileFailedReason is used when there was a problem reconciling the artifact storage. - StorageReconcileFailedReason = "StorageReconcileFailed" + // MarshalFailedReason is used when we fail to marshal a struct. + MarshalFailedReason = "MarshalFailed" + + // CreateOCIRepositoryNameFailedReason is used when we fail to create an OCI repository name. + CreateOCIRepositoryNameFailedReason = "CreateOCIRepositoryNameFailed" + + // CreateOCIRepositoryFailedReason is used when we fail to create an OCI repository. + CreateOCIRepositoryFailedReason = "CreateOCIRepositoryFailed" + + // OCIRepositoryExistsFailedReason is used when we fail to check the existence of an OCI repository. + OCIRepositoryExistsFailedReason = "OCIRepositoryExistsFailed" + + // DeleteSnapshotFailedReason is used when we fail to delete an OCI repository. + DeleteSnapshotFailedReason = "DeleteOCIRepositoryFailed" - // ReconcileArtifactFailedReason is used when we fail in creating an Artifact. - ReconcileArtifactFailedReason = "ReconcileArtifactFailed" + // CreateSnapshotFailedReason is used when we fail to create a snapshot. + CreateSnapshotFailedReason = "CreateSnapshotFailed" - // GetArtifactFailedReason is used when we fail in getting an Artifact. - GetArtifactFailedReason = "GetArtifactFailed" + // GetSnapshotFailedReason is used when we fail in getting a Snapshot. + GetSnapshotFailedReason = "GetSnapshotFailed" + + // SnapshotReadyFailedReason is used when the snapshot is not ready. + SnapshotReadyFailedReason = "SnapshotReadyFailed" + + // PushSnapshotFailedReason is used when we fail to push a snapshot. + PushSnapshotFailedReason = "PushSnapshotFailed" // ResolveResourceFailedReason is used when we fail in resolving a resource. ResolveResourceFailedReason = "ResolveResourceFailed" @@ -72,8 +84,20 @@ const ( // GetResourceAccessFailedReason is used when we fail in getting a resource access(es). GetResourceAccessFailedReason = "GetResourceAccessFailed" - // GetComponentForArtifactFailedReason is used when we fail in getting a component for an artifact. - GetComponentForArtifactFailedReason = "GetComponentForArtifactFailed" + // GetBlobAccessFailedReason is used when we fail to get a blob access. + GetBlobAccessFailedReason = "GetBlobAccessFailed" + + // VerifyResourceFailedReason is used when we fail to verify a resource. + VerifyResourceFailedReason = "VerifyResourceFailed" + + // GetResourceFailedReason is used when we fail to get the resource. + GetResourceFailedReason = "GetResourceFailed" + + // GetComponentForSnapshotFailedReason is used when we fail in getting a component for a snapshot. + GetComponentForSnapshotFailedReason = "GetComponentForSnapshotFailed" + + // CompressGzipFailedReason is used when we fail to compress to gzip. + CompressGzipFailedReason = "CompressGzipFailed" // StatusSetFailedReason is used when we fail to set the component status. StatusSetFailedReason = "StatusSetFailed" @@ -84,14 +108,17 @@ const ( // ConfigurationFailedReason is used when a resource was not able to be configured. ConfigurationFailedReason = "ConfigurationFailed" - // LocalizationRuleGenerationFailedReason is used when the controller failed to localize an artifact. + // CreateTGZFailedReason is used when a TGZ creation failed. + CreateTGZFailedReason = "CreateTGZFailed" + + // LocalizationRuleGenerationFailedReason is used when the controller failed to localize an snapshot. LocalizationRuleGenerationFailedReason = "LocalizationRuleGenerationFailed" // LocalizationIsNotReadyReason is used when a controller is waiting to get the localization result. LocalizationIsNotReadyReason = "LocalizationIsNotReady" - // UniqueIDGenerationFailedReason is used when the controller failed to generate a unique identifier for a pending artifact. - // This can happen if the artifact is based on multiple other sources but these sources could not be used + // UniqueIDGenerationFailedReason is used when the controller failed to generate a unique identifier for a pending snapshot. + // This can happen if the snapshot is based on multiple other sources but these sources could not be used // to determine a unique identifier. UniqueIDGenerationFailedReason = "UniqueIDGenerationFailed" diff --git a/api/v1alpha1/configuredresource_types.go b/api/v1alpha1/configuredresource_types.go index d44807b5..587661df 100644 --- a/api/v1alpha1/configuredresource_types.go +++ b/api/v1alpha1/configuredresource_types.go @@ -21,6 +21,7 @@ import ( "github.com/fluxcd/pkg/apis/meta" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -90,15 +91,10 @@ type ConfiguredResourceStatus struct { ObservedGeneration int64 `json:"observedGeneration,omitempty"` Conditions []metav1.Condition `json:"conditions,omitempty"` - // The configuration reconcile loop generates an artifact, which contains the + // The configuration reconcile loop generates a snapshot, which contains the // ConfiguredResourceSpec.Target ConfigurationReference after configuration. - // It is filled once the Artifact is created and the configuration completed. - ArtifactRef *ObjectKey `json:"artifactRef,omitempty"` - - // Digest contains a technical identifier for the artifact. This technical identifier - // can be used to track changes on the ArtifactRef as it is a combination of the origin - // ConfiguredResourceSpec.Config applied to the ConfiguredResourceSpec.Target. - Digest string `json:"digest,omitempty"` + // It is filled once the Snapshot is created and the configuration completed. + SnapshotRef corev1.LocalObjectReference `json:"snapshotRef,omitempty"` } // +kubebuilder:object:root=true @@ -113,6 +109,10 @@ type ConfiguredResource struct { Status ConfiguredResourceStatus `json:"status,omitempty"` } +func (in *ConfiguredResource) GetSnapshotName() string { + return in.Status.SnapshotRef.Name +} + // +kubebuilder:object:root=true // ConfiguredResourceList contains a list of ConfiguredResource. diff --git a/api/v1alpha1/constants.go b/api/v1alpha1/constants.go index 45891aa8..0b321b9d 100644 --- a/api/v1alpha1/constants.go +++ b/api/v1alpha1/constants.go @@ -24,9 +24,6 @@ const ( OCMConfigKey = ".ocmconfig" // OCMLabelDowngradable defines the secret. OCMLabelDowngradable = "ocm.software/ocm-k8s-toolkit/downgradable" - // OCMComponentDescriptorList defines the file name of the component descriptor list exposed as artifact by the - // component controller. - OCMComponentDescriptorList = "component-descriptor-list.yaml" ) // Log levels. @@ -37,11 +34,13 @@ const ( // Finalizers for controllers. const ( - // ArtifactFinalizer is the finalizer that is added to artifacts created by the ocm controllers. - ArtifactFinalizer = "finalizers.ocm.software/artifact" + // SnapshotFinalizer is the finalizter that is added to snapshot created by the ocm controllers. + SnapshotFinalizer = "finalizers.ocm.software/snapshot" ) -// External CRDs. +// OCI Schema. const ( - ArtifactCrd = "https://github.com/openfluxcd/artifact/releases/download/v0.1.1/openfluxcd.ocm.software_artifacts.yaml" + OCISchemaVersion = 2 + // Based on https://github.com/opencontainers/distribution-spec/blob/7872490e9d4943b20f11e21475bc13fd2e02b7d8/spec.md?plain=1#L157. + OCIRepositoryNameConstraints = "[a-z0-9]+((\\.|_|__|-+)[a-z0-9]+)*(\\/[a-z0-9]+((\\.|_|__|-+)[a-z0-9]+)*)*" ) diff --git a/api/v1alpha1/localizedresource_types.go b/api/v1alpha1/localizedresource_types.go index 395d60d4..029b6a12 100644 --- a/api/v1alpha1/localizedresource_types.go +++ b/api/v1alpha1/localizedresource_types.go @@ -3,6 +3,7 @@ package v1alpha1 import ( "fmt" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -73,6 +74,10 @@ func (in *LocalizedResource) SetTarget(v *ConfigurationReference) { v.DeepCopyInto(&in.Spec.Target) } +func (in *LocalizedResource) GetSnapshotName() string { + return in.Status.SnapshotRef.Name +} + type LocalizedResourceSpec struct { // Target that is to be localized Target ConfigurationReference `json:"target"` @@ -91,15 +96,15 @@ type LocalizedResourceStatus struct { ObservedGeneration int64 `json:"observedGeneration,omitempty"` Conditions []metav1.Condition `json:"conditions,omitempty"` - // The LocalizedResource reports an ArtifactRef which contains the content of the Resource after Localization - ArtifactRef *ObjectKey `json:"artifactRef,omitempty"` + // The LocalizedResource reports an SnapshotRef which contains the content of the Resource after Localization + SnapshotRef corev1.LocalObjectReference `json:"snapshotRef,omitempty"` // The LocalizedResource reports a ConfiguredResourceRef which contains a reference to the ConfiguredResource - // that is responsible for generating the ArtifactRef. + // that is responsible for generating the SnapshotRef. ConfiguredResourceRef *ObjectKey `json:"configuredResourceRef,omitempty"` // ConfigRef is a reference to the Configuration that was generated by the Localization process - // and is used to setup the ConfiguredResource responsible for generating the ArtifactRef. + // and is used to setup the ConfiguredResource responsible for generating the SnapshotRef. ConfigRef *ObjectKey `json:"configRef,omitempty"` // A unique digest of the combination of the config and target resources applied through a LocalizationStrategy diff --git a/api/v1alpha1/resource_types.go b/api/v1alpha1/resource_types.go index b8ec88a1..8fd47664 100644 --- a/api/v1alpha1/resource_types.go +++ b/api/v1alpha1/resource_types.go @@ -62,10 +62,10 @@ type ResourceStatus struct { // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` - // ArtifactRef points to the Artifact which represents the output of the + // SnapshotRef points to the Snapshot which represents the output of the // last successful Resource sync. // +optional - ArtifactRef corev1.LocalObjectReference `json:"artifactRef,omitempty"` + SnapshotRef corev1.LocalObjectReference `json:"snapshotRef,omitempty"` // +optional Resource *ResourceInfo `json:"resource,omitempty"` @@ -131,6 +131,10 @@ func (in *Resource) GetEffectiveOCMConfig() []OCMConfiguration { return in.Status.EffectiveOCMConfig } +func (in *Resource) GetSnapshotName() string { + return in.Status.SnapshotRef.Name +} + // +kubebuilder:object:root=true // ResourceList contains a list of Resource. diff --git a/api/v1alpha1/snapshot_types.go b/api/v1alpha1/snapshot_types.go new file mode 100644 index 00000000..975d545e --- /dev/null +++ b/api/v1alpha1/snapshot_types.go @@ -0,0 +1,101 @@ +package v1alpha1 + +import ( + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// SnapshotWriter defines any object which produces a snapshot +// +k8s:deepcopy-gen=false +type SnapshotWriter interface { + client.Object + GetSnapshotName() string + GetKind() string +} + +// SnapshotSpec defines the desired state of Snapshot. +type SnapshotSpec struct { + // OCI repository name + // +required + Repository string `json:"repository"` + + // Manifest digest (required to delete the manifest and prepare OCI artifact for GC) + // +required + Digest string `json:"digest"` + + // Blob + // +required + Blob *BlobInfo `json:"blob"` + + // Suspend stops all operations on this object. + // +optional + Suspend bool `json:"suspend,omitempty"` +} + +// SnapshotStatus defines the observed state of Snapshot. +type SnapshotStatus struct { + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // ObservedGeneration is the last reconciled generation. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +func (in *Snapshot) GetVID() map[string]string { + vid := fmt.Sprintf("%s:%s", in.GetNamespace(), in.GetName()) + metadata := make(map[string]string) + metadata[GroupVersion.Group+"/snapshot_version"] = vid + + return metadata +} + +func (in *Snapshot) SetObservedGeneration(v int64) { + in.Status.ObservedGeneration = v +} + +// GetDigest returns the last reconciled digest for the snapshot. +func (in Snapshot) GetDigest() string { + return in.Spec.Digest +} + +// GetConditions returns the status conditions of the object. +func (in Snapshot) GetConditions() []metav1.Condition { + return in.Status.Conditions +} + +// SetConditions sets the status conditions on the object. +func (in *Snapshot) SetConditions(conditions []metav1.Condition) { + in.Status.Conditions = conditions +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=snap +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="" +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="" + +// Snapshot is the Schema for the snapshots API. +type Snapshot struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec SnapshotSpec `json:"spec,omitempty"` + Status SnapshotStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// SnapshotList contains a list of Snapshot. +type SnapshotList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Snapshot `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Snapshot{}, &SnapshotList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d7b6a68d..636a6712 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -11,6 +11,21 @@ import ( "ocm.software/ocm/api/ocm/compdesc/meta/v1" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BlobInfo) DeepCopyInto(out *BlobInfo) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BlobInfo. +func (in *BlobInfo) DeepCopy() *BlobInfo { + if in == nil { + return nil + } + out := new(BlobInfo) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Component) DeepCopyInto(out *Component) { *out = *in @@ -127,7 +142,7 @@ func (in *ComponentStatus) DeepCopyInto(out *ComponentStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } - out.ArtifactRef = in.ArtifactRef + out.SnapshotRef = in.SnapshotRef in.Component.DeepCopyInto(&out.Component) if in.EffectiveOCMConfig != nil { in, out := &in.EffectiveOCMConfig, &out.EffectiveOCMConfig @@ -379,11 +394,7 @@ func (in *ConfiguredResourceStatus) DeepCopyInto(out *ConfiguredResourceStatus) (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.ArtifactRef != nil { - in, out := &in.ArtifactRef, &out.ArtifactRef - *out = new(ObjectKey) - **out = **in - } + out.SnapshotRef = in.SnapshotRef } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfiguredResourceStatus. @@ -710,11 +721,7 @@ func (in *LocalizedResourceStatus) DeepCopyInto(out *LocalizedResourceStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.ArtifactRef != nil { - in, out := &in.ArtifactRef, &out.ArtifactRef - *out = new(ObjectKey) - **out = **in - } + out.SnapshotRef = in.SnapshotRef if in.ConfiguredResourceRef != nil { in, out := &in.ConfiguredResourceRef, &out.ConfiguredResourceRef *out = new(ObjectKey) @@ -1247,7 +1254,7 @@ func (in *ResourceStatus) DeepCopyInto(out *ResourceStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } - out.ArtifactRef = in.ArtifactRef + out.SnapshotRef = in.SnapshotRef if in.Resource != nil { in, out := &in.Resource, &out.Resource *out = new(ResourceInfo) @@ -1270,6 +1277,107 @@ func (in *ResourceStatus) DeepCopy() *ResourceStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Snapshot) DeepCopyInto(out *Snapshot) { + *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 Snapshot. +func (in *Snapshot) DeepCopy() *Snapshot { + if in == nil { + return nil + } + out := new(Snapshot) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Snapshot) 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 *SnapshotList) DeepCopyInto(out *SnapshotList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Snapshot, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SnapshotList. +func (in *SnapshotList) DeepCopy() *SnapshotList { + if in == nil { + return nil + } + out := new(SnapshotList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *SnapshotList) 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 *SnapshotSpec) DeepCopyInto(out *SnapshotSpec) { + *out = *in + if in.Blob != nil { + in, out := &in.Blob, &out.Blob + *out = new(BlobInfo) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SnapshotSpec. +func (in *SnapshotSpec) DeepCopy() *SnapshotSpec { + if in == nil { + return nil + } + out := new(SnapshotSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SnapshotStatus) DeepCopyInto(out *SnapshotStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SnapshotStatus. +func (in *SnapshotStatus) DeepCopy() *SnapshotStatus { + if in == nil { + return nil + } + out := new(SnapshotStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TransferStatus) DeepCopyInto(out *TransferStatus) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index cff8706f..bed80e92 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,24 +17,22 @@ limitations under the License. package main import ( + // +kubebuilder:scaffold:imports "context" "crypto/tls" "flag" "os" - "time" // to ensure that exec-entrypoint and run can make use of them. // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) _ "k8s.io/client-go/plugin/pkg/client/auth" "github.com/fluxcd/pkg/runtime/events" - "github.com/openfluxcd/controller-manager/server" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/webhook" - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" @@ -49,11 +47,11 @@ import ( "github.com/open-component-model/ocm-k8s-toolkit/internal/controller/ocmrepository" "github.com/open-component-model/ocm-k8s-toolkit/internal/controller/replication" "github.com/open-component-model/ocm-k8s-toolkit/internal/controller/resource" + "github.com/open-component-model/ocm-k8s-toolkit/internal/controller/snapshot" "github.com/open-component-model/ocm-k8s-toolkit/pkg/ocm" + snapshotRegistry "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" ) -// +kubebuilder:scaffold:imports - var ( scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") @@ -63,24 +61,19 @@ func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(v1alpha1.AddToScheme(scheme)) - utilruntime.Must(artifactv1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } //nolint:funlen // this is the main function func main() { var ( - metricsAddr string - enableLeaderElection bool - probeAddr string - secureMetrics bool - enableHTTP2 bool - artifactRetentionTTL = 60 * time.Second - artifactRetentionRecords = 2 - storagePath string - storageAddr string - storageAdvAddr string - eventsAddr string + metricsAddr string + enableLeaderElection bool + probeAddr string + secureMetrics bool + enableHTTP2 bool + eventsAddr string + registryAddr string ) flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metric endpoint binds to. "+ "Use the port :8080. If not set, it will be 0 in order to disable the metrics server") @@ -92,10 +85,8 @@ func main() { "If set the metrics endpoint is served securely") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") - flag.StringVar(&storageAddr, "storage-addr", ":9090", "The address the static file server binds to.") - flag.StringVar(&storageAdvAddr, "storage-adv-addr", "", "The advertised address of the static file server.") - flag.StringVar(&storagePath, "storage-path", "/data", "The local storage path.") flag.StringVar(&eventsAddr, "events-addr", "", "The address of the events receiver.") + flag.StringVar(®istryAddr, "registry-addr", "ocm-k8s-toolkit-zot-registry.ocm-k8s-toolkit-system.svc.cluster.local:5000", "The address of the registry.") opts := zap.Options{ Development: true, @@ -171,10 +162,15 @@ func main() { os.Exit(1) } - storage, artifactServer, err := server.NewArtifactStore(mgr.GetClient(), mgr.GetScheme(), - storagePath, storageAddr, storageAdvAddr, artifactRetentionTTL, artifactRetentionRecords) + registry, err := snapshotRegistry.NewRegistry(registryAddr) + registry.PlainHTTP = true if err != nil { - setupLog.Error(err, "unable to initialize storage") + setupLog.Error(err, "unable to initialize registry object") + os.Exit(1) + } + + if err := registry.Ping(ctx); err != nil { + setupLog.Error(err, "unable to ping OCI registry") os.Exit(1) } @@ -184,7 +180,7 @@ func main() { Scheme: mgr.GetScheme(), EventRecorder: eventsRecorder, }, - Storage: storage, + Registry: registry, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Component") os.Exit(1) @@ -196,7 +192,7 @@ func main() { Scheme: mgr.GetScheme(), EventRecorder: eventsRecorder, }, - Storage: storage, + Registry: registry, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Resource") os.Exit(1) @@ -208,8 +204,8 @@ func main() { Scheme: mgr.GetScheme(), EventRecorder: eventsRecorder, }, - Storage: storage, - LocalizationClient: locclient.NewClientWithLocalStorage(mgr.GetClient(), storage, mgr.GetScheme()), + Registry: registry, + LocalizationClient: locclient.NewClientWithRegistry(mgr.GetClient(), registry, mgr.GetScheme()), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "LocalizedResource") os.Exit(1) @@ -221,8 +217,8 @@ func main() { Scheme: mgr.GetScheme(), EventRecorder: eventsRecorder, }, - Storage: storage, - ConfigClient: cfgclient.NewClientWithLocalStorage(mgr.GetClient(), storage, mgr.GetScheme()), + Registry: registry, + ConfigClient: cfgclient.NewClientWithRegistry(mgr.GetClient(), registry, mgr.GetScheme()), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ConfiguredResource") os.Exit(1) @@ -238,6 +234,19 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Replication") os.Exit(1) } + + if err = (&snapshot.Reconciler{ + BaseReconciler: &ocm.BaseReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + EventRecorder: eventsRecorder, + }, + Registry: registry, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Snapshot") + os.Exit(1) + } + // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { @@ -254,10 +263,6 @@ func main() { // entire process will terminate if we lose leadership, so we don't need // to handle that. <-mgr.Elected() - - if err := artifactServer.Start(ctx); err != nil { - setupLog.Error(err, "unable to start artifact server") - } }() setupLog.Info("starting manager") diff --git a/config/crd/bases/delivery.ocm.software_components.yaml b/config/crd/bases/delivery.ocm.software_components.yaml index 5325f24f..5a092f95 100644 --- a/config/crd/bases/delivery.ocm.software_components.yaml +++ b/config/crd/bases/delivery.ocm.software_components.yaml @@ -180,23 +180,6 @@ spec: status: description: ComponentStatus defines the observed state of Component. properties: - artifactRef: - description: |- - ArtifactRef references the generated artifact containing a list of - component descriptors. This list can be used by other controllers to - avoid re-downloading (and potentially also re-verifying) the components. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic component: description: |- Component specifies the concrete version of the component that was @@ -327,6 +310,23 @@ spec: object. format: int64 type: integer + snapshotRef: + description: |- + SnapshotRef references the generated snapshot containing a list of + component descriptors. This list can be used by other controllers to + avoid re-downloading (and potentially also re-verifying) the components. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic type: object required: - spec diff --git a/config/crd/bases/delivery.ocm.software_configuredresources.yaml b/config/crd/bases/delivery.ocm.software_configuredresources.yaml index 43ea0560..5c7c91d4 100644 --- a/config/crd/bases/delivery.ocm.software_configuredresources.yaml +++ b/config/crd/bases/delivery.ocm.software_configuredresources.yaml @@ -99,19 +99,6 @@ spec: status: description: ConfiguredResourceStatus defines the observed state of ConfiguredResource. properties: - artifactRef: - description: |- - The configuration reconcile loop generates an artifact, which contains the - ConfiguredResourceSpec.Target ConfigurationReference after configuration. - It is filled once the Artifact is created and the configuration completed. - properties: - name: - type: string - namespace: - type: string - required: - - name - type: object conditions: items: description: Condition contains details for one aspect of the current @@ -168,15 +155,26 @@ spec: - type type: object type: array - digest: - description: |- - Digest contains a technical identifier for the artifact. This technical identifier - can be used to track changes on the ArtifactRef as it is a combination of the origin - ConfiguredResourceSpec.Config applied to the ConfiguredResourceSpec.Target. - type: string observedGeneration: format: int64 type: integer + snapshotRef: + description: |- + The configuration reconcile loop generates a snapshot, which contains the + ConfiguredResourceSpec.Target ConfigurationReference after configuration. + It is filled once the Snapshot is created and the configuration completed. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic type: object type: object served: true diff --git a/config/crd/bases/delivery.ocm.software_localizedresources.yaml b/config/crd/bases/delivery.ocm.software_localizedresources.yaml index 873e405e..a3a814a8 100644 --- a/config/crd/bases/delivery.ocm.software_localizedresources.yaml +++ b/config/crd/bases/delivery.ocm.software_localizedresources.yaml @@ -98,17 +98,6 @@ spec: type: object status: properties: - artifactRef: - description: The LocalizedResource reports an ArtifactRef which contains - the content of the Resource after Localization - properties: - name: - type: string - namespace: - type: string - required: - - name - type: object conditions: items: description: Condition contains details for one aspect of the current @@ -168,7 +157,7 @@ spec: configRef: description: |- ConfigRef is a reference to the Configuration that was generated by the Localization process - and is used to setup the ConfiguredResource responsible for generating the ArtifactRef. + and is used to setup the ConfiguredResource responsible for generating the SnapshotRef. properties: name: type: string @@ -180,7 +169,7 @@ spec: configuredResourceRef: description: |- The LocalizedResource reports a ConfiguredResourceRef which contains a reference to the ConfiguredResource - that is responsible for generating the ArtifactRef. + that is responsible for generating the SnapshotRef. properties: name: type: string @@ -196,6 +185,21 @@ spec: observedGeneration: format: int64 type: integer + snapshotRef: + description: The LocalizedResource reports an SnapshotRef which contains + the content of the Resource after Localization + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic type: object type: object served: true diff --git a/config/crd/bases/delivery.ocm.software_resources.yaml b/config/crd/bases/delivery.ocm.software_resources.yaml index 9bb536e1..877d74e6 100644 --- a/config/crd/bases/delivery.ocm.software_resources.yaml +++ b/config/crd/bases/delivery.ocm.software_resources.yaml @@ -149,22 +149,6 @@ spec: status: description: ResourceStatus defines the observed state of Resource. properties: - artifactRef: - description: |- - ArtifactRef points to the Artifact which represents the output of the - last successful Resource sync. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic conditions: description: Conditions holds the conditions for the Resource. items: @@ -300,6 +284,22 @@ spec: - name - type type: object + snapshotRef: + description: |- + SnapshotRef points to the Snapshot which represents the output of the + last successful Resource sync. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic type: object required: - spec diff --git a/config/crd/bases/delivery.ocm.software_snapshots.yaml b/config/crd/bases/delivery.ocm.software_snapshots.yaml new file mode 100644 index 00000000..5afbb696 --- /dev/null +++ b/config/crd/bases/delivery.ocm.software_snapshots.yaml @@ -0,0 +1,154 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.0 + name: snapshots.delivery.ocm.software +spec: + group: delivery.ocm.software + names: + kind: Snapshot + listKind: SnapshotList + plural: snapshots + shortNames: + - snap + singular: snapshot + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: Snapshot is the Schema for the snapshots API. + 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: SnapshotSpec defines the desired state of Snapshot. + properties: + blob: + description: Blob + properties: + digest: + description: Digest is the digest of the blob in the form of ':'. + type: string + size: + description: |- + Size is the number of bytes of the blob. + Can be used to determine how to file should be handled when downloaded (memory/disk) + format: int64 + type: integer + tag: + description: Tag/Version of the blob + type: string + required: + - digest + - size + - tag + type: object + digest: + description: Manifest digest (required to delete the manifest and + prepare OCI artifact for GC) + type: string + repository: + description: OCI repository name + type: string + suspend: + description: Suspend stops all operations on this object. + type: boolean + required: + - blob + - digest + - repository + type: object + status: + description: SnapshotStatus defines the observed state of Snapshot. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + 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 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + description: ObservedGeneration is the last reconciled generation. + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 73f37caa..0f5cad00 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -10,6 +10,7 @@ resources: - bases/delivery.ocm.software_localizedresources.yaml - bases/delivery.ocm.software_configuredresources.yaml - bases/delivery.ocm.software_resourceconfigs.yaml +- bases/delivery.ocm.software_snapshots.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index c78c5884..c151df93 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -36,7 +36,6 @@ spec: args: # - --leader-elect We dont need it for testing - --health-probe-bind-address=:8081 - - --storage-adv-addr=ocm-k8s-toolkit-artifact-service.ocm-k8s-toolkit-system.svc.cluster.local. - --zap-log-level=4 image: controller:latest name: manager diff --git a/config/manager/service.yaml b/config/manager/service.yaml deleted file mode 100644 index 76d3cf2e..00000000 --- a/config/manager/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: artifact-service -spec: - type: ClusterIP - selector: - control-plane: controller-manager - ports: - - name: http - port: 80 - protocol: TCP - targetPort: http diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 087bea97..9e818c94 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -13,6 +13,8 @@ resources: # default, aiding admins in cluster management. Those roles are # not used by the Project itself. You can comment the following lines # if you do not want those helpers be installed with your Project. +- snapshot_editor_role.yaml +- snapshot_viewer_role.yaml - replication_editor_role.yaml - replication_viewer_role.yaml - configuredresource_editor_role.yaml @@ -22,4 +24,5 @@ resources: - component_editor_role.yaml - component_viewer_role.yaml - ocmrepository_editor_role.yaml -- ocmrepository_viewer_role.yaml \ No newline at end of file +- ocmrepository_viewer_role.yaml + diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index ad84bd88..1f312844 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -36,6 +36,7 @@ rules: - ocmrepositories - replications - resources + - snapshots verbs: - create - delete @@ -52,7 +53,7 @@ rules: - localizedresources/finalizers - ocmrepositories/finalizers - replications/finalizers - - resources/finalizers + - snapshots/finalizers verbs: - update - apiGroups: @@ -64,6 +65,7 @@ rules: - ocmrepositories/status - replications/status - resources/status + - snapshots/status verbs: - get - patch @@ -87,29 +89,3 @@ rules: - patch - update - watch -- apiGroups: - - openfluxcd.ocm.software - resources: - - artifacts - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - openfluxcd.ocm.software - resources: - - artifacts/finalizers - verbs: - - update -- apiGroups: - - openfluxcd.ocm.software - resources: - - artifacts/status - verbs: - - get - - patch - - update diff --git a/config/rbac/snapshot_editor_role.yaml b/config/rbac/snapshot_editor_role.yaml new file mode 100644 index 00000000..3c99c21a --- /dev/null +++ b/config/rbac/snapshot_editor_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to edit snapshots. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: ocm-k8s-toolkit + app.kubernetes.io/managed-by: kustomize + name: snapshot-editor-role +rules: +- apiGroups: + - delivery.ocm.software + resources: + - snapshots + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - delivery.ocm.software + resources: + - snapshots/status + verbs: + - get diff --git a/config/rbac/snapshot_viewer_role.yaml b/config/rbac/snapshot_viewer_role.yaml new file mode 100644 index 00000000..906f8d6b --- /dev/null +++ b/config/rbac/snapshot_viewer_role.yaml @@ -0,0 +1,23 @@ +# permissions for end users to view snapshots. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: ocm-k8s-toolkit + app.kubernetes.io/managed-by: kustomize + name: snapshot-viewer-role +rules: +- apiGroups: + - delivery.ocm.software + resources: + - snapshots + verbs: + - get + - list + - watch +- apiGroups: + - delivery.ocm.software + resources: + - snapshots/status + verbs: + - get diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 520434b7..cf502cfa 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -4,4 +4,5 @@ resources: - delivery_v1alpha1_component.yaml - delivery_v1alpha1_resource.yaml - delivery_v1alpha1_replication.yaml +- delivery_v1alpha1_snapshot.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/zot/configmap.yaml b/config/zot/configmap.yaml index cc477a45..8b2f5e07 100644 --- a/config/zot/configmap.yaml +++ b/config/zot/configmap.yaml @@ -21,4 +21,4 @@ data: "log": { "level": "debug" } - } \ No newline at end of file + } diff --git a/config/zot/kustomization.yaml b/config/zot/kustomization.yaml index fd24fea3..4649066b 100644 --- a/config/zot/kustomization.yaml +++ b/config/zot/kustomization.yaml @@ -1,10 +1,10 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: -- configmap.yaml -- deployment.yaml -- service.yaml + - configmap.yaml + - deployment.yaml + - service.yaml images: -- name: zot-minimal - newName: ghcr.io/project-zot/zot-minimal - newTag: latest + - name: zot-minimal + newName: ghcr.io/project-zot/zot-minimal + newTag: latest \ No newline at end of file diff --git a/config/zot/service.yaml b/config/zot/service.yaml index e5773366..ecad7f86 100644 --- a/config/zot/service.yaml +++ b/config/zot/service.yaml @@ -23,4 +23,4 @@ spec: # targetPort: 5000 # nodePort: 31000 # selector: -# app: zot +# app: zot \ No newline at end of file diff --git a/go.mod b/go.mod index 386dcb29..1ff2a608 100644 --- a/go.mod +++ b/go.mod @@ -15,14 +15,14 @@ require ( github.com/fluxcd/pkg/runtime v0.53.1 github.com/fluxcd/pkg/tar v0.11.0 github.com/google/go-containerregistry v0.20.3 - github.com/mandelsoft/filepath v0.0.0-20240223090642-3e2777258aa3 github.com/mandelsoft/goutils v0.0.0-20241005173814-114fa825bbdc github.com/mandelsoft/vfs v0.4.4 + github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/onsi/ginkgo/v2 v2.22.2 github.com/onsi/gomega v1.36.2 github.com/opencontainers/go-digest v1.0.0 + github.com/opencontainers/image-spec v1.1.0 github.com/openfluxcd/artifact v0.1.1 - github.com/openfluxcd/controller-manager v0.1.2 github.com/stretchr/testify v1.10.0 github.com/ulikunitz/xz v0.5.12 k8s.io/api v0.32.2 @@ -30,6 +30,7 @@ require ( k8s.io/apimachinery v0.32.2 k8s.io/client-go v0.32.2 ocm.software/ocm v0.20.0 + oras.land/oras-go/v2 v2.5.0 sigs.k8s.io/controller-runtime v0.20.2 sigs.k8s.io/yaml v1.4.0 ) @@ -146,10 +147,6 @@ require ( github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fluxcd/pkg/apis/acl v0.6.0 // indirect - github.com/fluxcd/pkg/lockedfile v0.3.0 // indirect - github.com/fluxcd/pkg/sourceignore v0.7.0 // indirect - github.com/fluxcd/source-controller/api v1.3.0 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fvbommel/sortorder v1.1.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect @@ -202,7 +199,7 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/gowebpki/jcs v1.0.1 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -223,12 +220,12 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.17.11 // indirect - github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/letsencrypt/boulder v0.0.0-20241010192615-6692160cedfa // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/magiconair/properties v1.8.9 // indirect github.com/mailru/easyjson v0.9.0 // indirect + github.com/mandelsoft/filepath v0.0.0-20240223090642-3e2777258aa3 // indirect github.com/mandelsoft/logging v0.0.0-20240618075559-fdca28a87b0a // indirect github.com/mandelsoft/spiff v1.7.0-beta-6 // indirect github.com/marstr/guid v1.1.0 // indirect @@ -258,8 +255,6 @@ require ( github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/oleiade/reflections v1.1.0 // indirect - github.com/opencontainers/go-digest/blake3 v0.0.0-20240426182413-22b78e47854a // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect github.com/opencontainers/runtime-spec v1.2.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pborman/uuid v1.2.1 // indirect @@ -321,21 +316,19 @@ require ( github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xlab/treeprint v1.2.0 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect - github.com/zeebo/assert v1.3.0 // indirect - github.com/zeebo/blake3 v0.2.3 // indirect github.com/zeebo/errs v1.4.0 // indirect go.mongodb.org/mongo-driver v1.17.1 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect go.opentelemetry.io/otel v1.33.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 // indirect go.opentelemetry.io/otel/metric v1.33.0 // indirect go.opentelemetry.io/otel/sdk v1.33.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.31.0 // indirect go.opentelemetry.io/otel/trace v1.33.0 // indirect - go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.opentelemetry.io/proto/otlp v1.4.0 // indirect go.step.sm/crypto v0.56.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect @@ -371,7 +364,6 @@ require ( k8s.io/kubectl v0.32.1 // indirect k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect oras.land/oras-go v1.2.6 // indirect - oras.land/oras-go/v2 v2.5.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/kustomize/api v0.19.0 // indirect sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect diff --git a/go.sum b/go.sum index c64a5642..6d0092f1 100644 --- a/go.sum +++ b/go.sum @@ -386,26 +386,16 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fluxcd/cli-utils v0.36.0-flux.12 h1:8cD6SmaKa/lGo0KCu0XWiGrXJMLMBQwSsnoP0cG+Gjw= github.com/fluxcd/cli-utils v0.36.0-flux.12/go.mod h1:Nb/zMqsJAzjz4/HIsEc2LTqxC6eC0rV26t4hkJT/F9o= -github.com/fluxcd/pkg/apis/acl v0.6.0 h1:rllf5uQLzTow81ZCslkQ6LPpDNqVQr6/fWaNksdUEtc= -github.com/fluxcd/pkg/apis/acl v0.6.0/go.mod h1:IVDZx3MAoDWjlLrJHMF9Z27huFuXAEQlnbWw0M6EcTs= github.com/fluxcd/pkg/apis/event v0.16.0 h1:ffKc/3erowPnh72lFszz7sPQhLZ7bhqNrq+pu1Pb+JE= github.com/fluxcd/pkg/apis/event v0.16.0/go.mod h1:D/QQi5lHT9/Ur3OMFLJO71D4KDQHbJ5s8dQV3h1ZAT0= github.com/fluxcd/pkg/apis/meta v1.10.0 h1:rqbAuyl5ug7A5jjRf/rNwBXmNl6tJ9wG2iIsriwnQUk= github.com/fluxcd/pkg/apis/meta v1.10.0/go.mod h1:n7NstXHDaleAUMajcXTVkhz0MYkvEXy1C/eLI/t1xoI= -github.com/fluxcd/pkg/lockedfile v0.3.0 h1:tZkBAffcxyt4zMigHIKc54cKgN5I/kFF005gyWZdyds= -github.com/fluxcd/pkg/lockedfile v0.3.0/go.mod h1:5iCYXAs953LlXZq7nTId9ZSGnHVvTfZ0mDmrDE49upk= github.com/fluxcd/pkg/runtime v0.53.1 h1:S+QRSoiU+LH1sTvJLNvT1x3E5hBq/sjOsRHazA7OqTo= github.com/fluxcd/pkg/runtime v0.53.1/go.mod h1:8vkIhS1AhkmjC98LRm5xM+CRG5KySFTXpJWk+ZdtT4I= -github.com/fluxcd/pkg/sourceignore v0.7.0 h1:qQrB2o543wA1o4vgR62ufwkAaDp8+f8Wdj1HKDlmDrU= -github.com/fluxcd/pkg/sourceignore v0.7.0/go.mod h1:A4GuZt2seJJkBm3kMiIx9nheoYZs98KTMr/A6/2fIro= github.com/fluxcd/pkg/ssa v0.43.0 h1:XmADD3C0erYZayKfGI0WTsMlW9TtS4bp5gy4Axo1dcA= github.com/fluxcd/pkg/ssa v0.43.0/go.mod h1:MjkaOr4/5C8wkwsdVLMmfS64lDZOgJP4VNxmmJL0Iuc= github.com/fluxcd/pkg/tar v0.11.0 h1:pjf/rzr6HNAPiuxT59mtba9tfBtdNiSQ/UqduG8vZ2I= github.com/fluxcd/pkg/tar v0.11.0/go.mod h1:+kiP25NqibWMpFWgizyPEMqnMJIux7bCgEy+4pfxyI4= -github.com/fluxcd/pkg/testserver v0.7.0 h1:kNVAn+3bAF2rfR9cT6SxzgEz2o84i+o7zKY3XRKTXmk= -github.com/fluxcd/pkg/testserver v0.7.0/go.mod h1:Ih5IK3Y5G3+a6c77BTqFkdPDCY1Yj1A1W5cXQqkCs9s= -github.com/fluxcd/source-controller/api v1.3.0 h1:Z5Lq0aJY87yg0cQDEuwGLKS60GhdErCHtsi546HUt10= -github.com/fluxcd/source-controller/api v1.3.0/go.mod h1:+tfd0vltjcVs/bbnq9AlYR9AAHSVfM/Z4v4TpQmdJf4= github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -593,8 +583,8 @@ github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU= github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -679,9 +669,6 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= -github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= -github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -741,6 +728,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v0.0.0-20150613213606-2caf8efc9366/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= @@ -813,8 +802,6 @@ github.com/open-policy-agent/opa v0.68.0 h1:Jl3U2vXRjwk7JrHmS19U3HZO5qxQRinQbJ2e github.com/open-policy-agent/opa v0.68.0/go.mod h1:5E5SvaPwTpwt2WM177I9Z3eT7qUpmOGjk1ZdHs+TZ4w= github.com/opencontainers/go-digest v1.0.1-0.20220411205349-bde1400a84be h1:f2PlhC9pm5sqpBZFvnAoKj+KzXRzbjFMA+TqXfJdgho= github.com/opencontainers/go-digest v1.0.1-0.20220411205349-bde1400a84be/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/go-digest/blake3 v0.0.0-20240426182413-22b78e47854a h1:xwooQrLddjfeKhucuLS4ElD3TtuuRwF8QWC9eHrnbxY= -github.com/opencontainers/go-digest/blake3 v0.0.0-20240426182413-22b78e47854a/go.mod h1:kqQaIc6bZstKgnGpL7GD5dWoLKbA6mH1Y9ULjGImBnM= github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= @@ -822,8 +809,6 @@ github.com/opencontainers/runtime-spec v1.2.0 h1:z97+pHb3uELt/yiAWD691HNHQIF07bE github.com/opencontainers/runtime-spec v1.2.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/openfluxcd/artifact v0.1.1 h1:sSpaUYAbvXty+NRldYhVqIGK+7pyfow/IM+IrwrLRHI= github.com/openfluxcd/artifact v0.1.1/go.mod h1:A+2bRh4vjyFK5A/mtfefqXA0weNSnazkkMJPJ4SMzm8= -github.com/openfluxcd/controller-manager v0.1.2 h1:gYurNX4Ya2cu2WV6QwLwoBZsnCtJFIGfec7flyG4zVI= -github.com/openfluxcd/controller-manager v0.1.2/go.mod h1:13nw6eXYMuk6UYUqdJ+/oS1MGgYXa0zIitU1cw+f2Fc= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= @@ -1055,15 +1040,8 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= -github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= -github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg= -github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ= github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= -github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= -github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= github.com/zmap/zcrypto v0.0.0-20231219022726-a1f61fb1661c h1:U1b4THKcgOpJ+kILupuznNwPiURtwVW3e9alJvji9+s= github.com/zmap/zcrypto v0.0.0-20231219022726-a1f61fb1661c/go.mod h1:GSDpFDD4TASObxvfZfvpZZ3OWHIUHMlhVWlkOe4ewVk= github.com/zmap/zlint/v3 v3.6.0 h1:vTEaDRtYN0d/1Ax60T+ypvbLQUHwHxbvYRnUMVr35ug= @@ -1086,8 +1064,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 h1:FZ6 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0/go.mod h1:MdEu/mC6j3D+tTEfvI15b5Ci2Fn7NneJ71YMoiS3tpI= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0 h1:bflGWrfYyuulcdxf14V6n9+CoQcu5SAAdHmDPAJnlps= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0/go.mod h1:qcTO4xHAxZLaLxPd60TdE88rxtItPHgHWqOhOGRr0as= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 h1:FFeLy03iVTXP6ffeN2iXrxfGsZGCjVx0/4KlizjyBwU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0/go.mod h1:TMu73/k1CP8nBUpDLc71Wj/Kf7ZS9FK5b53VapRsP9o= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU= @@ -1106,8 +1084,8 @@ go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4Jjx go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= -go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= -go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= go.step.sm/crypto v0.56.0 h1:KcFfV76cI9Xaw8bdSc9x55skyuSdcHcTdL37vvVZnvY= go.step.sm/crypto v0.56.0/go.mod h1:snWNloxY9s1W+HsFqcviq55nvzbqqX6LxVt0Vktv5mw= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/internal/controller/component/component_controller.go b/internal/controller/component/component_controller.go index 70c179a4..735d6368 100644 --- a/internal/controller/component/component_controller.go +++ b/internal/controller/component/component_controller.go @@ -20,58 +20,100 @@ import ( "context" "errors" "fmt" - "os" - "path/filepath" - "strings" "github.com/Masterminds/semver/v3" "github.com/fluxcd/pkg/runtime/conditions" "github.com/fluxcd/pkg/runtime/patch" "github.com/mandelsoft/goutils/sliceutils" - "github.com/openfluxcd/controller-manager/storage" + "github.com/opencontainers/go-digest" + "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/types" "ocm.software/ocm/api/datacontext" "ocm.software/ocm/api/ocm/resolvers" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" "sigs.k8s.io/yaml" - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" corev1 "k8s.io/api/core/v1" ocmctx "ocm.software/ocm/api/ocm" ctrl "sigs.k8s.io/controller-runtime" "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" "github.com/open-component-model/ocm-k8s-toolkit/pkg/ocm" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" "github.com/open-component-model/ocm-k8s-toolkit/pkg/status" ) // Reconciler reconciles a Component object. type Reconciler struct { *ocm.BaseReconciler - Storage *storage.Storage + Registry snapshot.RegistryType } var _ ocm.Reconciler = (*Reconciler)(nil) // SetupWithManager sets up the controller with the Manager. func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + const ( + ocmRepositoryKey = "spec.ocmRepositoryRef.name" + ) + + // Create an index to watch for OCMRepository changes. + if err := mgr.GetFieldIndexer().IndexField(context.TODO(), &v1alpha1.Component{}, ocmRepositoryKey, func(rawObj client.Object) []string { + component, ok := rawObj.(*v1alpha1.Component) + if !ok { + return nil + } + + ns := component.Spec.RepositoryRef.Namespace + if ns == "" { + ns = component.GetNamespace() + } + + return []string{fmt.Sprintf("%s/%s", ns, component.Spec.RepositoryRef.Name)} + }); err != nil { + return fmt.Errorf("failed setting index fields: %w", err) + } + return ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.Component{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Watches(&v1alpha1.OCMRepository{}, handler.EnqueueRequestsFromMapFunc(r.findOCMRepositories(ocmRepositoryKey))). + Owns(&v1alpha1.Snapshot{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). Complete(r) } +func (r *Reconciler) findOCMRepositories(key string) handler.MapFunc { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + repository := &v1alpha1.OCMRepositoryList{} + if err := r.List(ctx, repository, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(key, client.ObjectKeyFromObject(obj).String()), + }); err != nil { + return []reconcile.Request{} + } + + requests := make([]reconcile.Request, len(repository.Items)) + for i, item := range repository.Items { + requests[i] = reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: item.GetName(), + Namespace: item.GetNamespace(), + }, + } + } + + return requests + } +} + // +kubebuilder:rbac:groups=delivery.ocm.software,resources=components,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=delivery.ocm.software,resources=components/status,verbs=get;update;patch // +kubebuilder:rbac:groups=delivery.ocm.software,resources=components/finalizers,verbs=update -// +kubebuilder:rbac:groups=openfluxcd.ocm.software,resources=artifacts,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=openfluxcd.ocm.software,resources=artifacts/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=openfluxcd.ocm.software,resources=artifacts/finalizers,verbs=update - // +kubebuilder:rbac:groups="",resources=secrets;configmaps;serviceaccounts,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=serviceaccounts/token,verbs=create // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch @@ -105,7 +147,7 @@ func (r *Reconciler) reconcileExists(ctx context.Context, component *v1alpha1.Co if component.GetDeletionTimestamp() != nil { logger.Info("component is being deleted and cannot be used", "name", component.Name) - return ctrl.Result{}, nil + return ctrl.Result{Requeue: true}, nil } if component.Spec.Suspend { @@ -134,14 +176,20 @@ func (r *Reconciler) reconcile(ctx context.Context, component *v1alpha1.Componen err := errors.New("repository is being deleted, please do not use it") logger.Error(err, "repository is being deleted, please do not use it", "name", component.Spec.RepositoryRef.Name) + // Triggered through cache return ctrl.Result{}, nil } + // Note: Marking the component as not ready, when the ocmrepository is not ready is not completely valid. As the + // was potentially ready, then the ocmrepository changed, but that does not necessarily mean that the component is + // not ready as well. + // However, as the component is hard-dependant on the ocmrepository, we decided to mark it not ready as well. if !conditions.IsReady(repo) { logger.Info("repository is not ready", "name", component.Spec.RepositoryRef.Name) status.MarkNotReady(r.EventRecorder, component, v1alpha1.RepositoryIsNotReadyReason, "repository is not ready yet") - return ctrl.Result{Requeue: true}, nil + // Triggered through cache + return ctrl.Result{}, nil } return r.reconcileOCM(ctx, component, repo) @@ -167,6 +215,7 @@ func (r *Reconciler) reconcileOCM(ctx context.Context, component *v1alpha1.Compo return result, nil } +//nolint:funlen // we do not want to cut function at an arbitrary point func (r *Reconciler) reconcileComponent(ctx context.Context, octx ocmctx.Context, component *v1alpha1.Component, repository *v1alpha1.OCMRepository) (ctrl.Result, error) { logger := log.FromContext(ctx) @@ -178,14 +227,16 @@ func (r *Reconciler) reconcileComponent(ctx context.Context, octx ocmctx.Context if err != nil { status.MarkNotReady(r.GetEventRecorder(), component, v1alpha1.ConfigureContextFailedReason, err.Error()) - return ctrl.Result{}, nil + return ctrl.Result{}, err } + verifications, err := ocm.GetVerifications(ctx, r.GetClient(), component) if err != nil { status.MarkNotReady(r.GetEventRecorder(), component, v1alpha1.ConfigureContextFailedReason, err.Error()) - return ctrl.Result{}, nil + return ctrl.Result{}, err } + err = ocm.ConfigureContext(ctx, octx, r.GetClient(), configs, verifications) if err != nil { status.MarkNotReady(r.GetEventRecorder(), component, v1alpha1.ConfigureContextFailedReason, err.Error()) @@ -198,7 +249,7 @@ func (r *Reconciler) reconcileComponent(ctx context.Context, octx ocmctx.Context logger.Error(err, "failed to parse repository spec") status.MarkNotReady(r.EventRecorder, component, v1alpha1.RepositorySpecInvalidReason, "RepositorySpec is invalid") - return ctrl.Result{}, nil + return ctrl.Result{}, err } repo, err := session.LookupRepository(octx, spec) @@ -238,26 +289,72 @@ func (r *Reconciler) reconcileComponent(ctx context.Context, octx ocmctx.Context return ctrl.Result{}, err } - err = r.Storage.ReconcileStorage(ctx, component) + // Store descriptors and create snapshot + logger.Info("pushing descriptors to storage") + ociRepositoryName, err := snapshot.CreateRepositoryName(component.Spec.RepositoryRef.Name, component.GetName()) if err != nil { - status.MarkNotReady(r.EventRecorder, component, v1alpha1.StorageReconcileFailedReason, err.Error()) + status.MarkNotReady(r.EventRecorder, component, v1alpha1.CreateOCIRepositoryNameFailedReason, err.Error()) - return ctrl.Result{}, fmt.Errorf("failed to reconcileComponent storage: %w", err) + return ctrl.Result{}, err } - err = r.createArtifactForDescriptors(ctx, octx, component, cv, descriptors) + ociRepository, err := r.Registry.NewRepository(ctx, ociRepositoryName) if err != nil { - status.MarkNotReady(r.EventRecorder, component, v1alpha1.ReconcileArtifactFailedReason, err.Error()) + status.MarkNotReady(r.EventRecorder, component, v1alpha1.CreateOCIRepositoryFailedReason, err.Error()) return ctrl.Result{}, err } - // Update status - r.setComponentStatus(component, configs, v1alpha1.ComponentInfo{ + descriptorsBytes, err := yaml.Marshal(descriptors) + if err != nil { + status.MarkNotReady(r.EventRecorder, component, v1alpha1.MarshalFailedReason, err.Error()) + + return ctrl.Result{}, err + } + + manifestDigest, err := ociRepository.PushSnapshot(ctx, version, descriptorsBytes) + if err != nil { + status.MarkNotReady(r.EventRecorder, component, v1alpha1.PushSnapshotFailedReason, err.Error()) + + return ctrl.Result{}, err + } + + logger.Info("creating snapshot") + snapshotCR := snapshot.Create( + component, + ociRepositoryName, + manifestDigest.String(), + &v1alpha1.BlobInfo{ + Digest: digest.FromBytes(descriptorsBytes).String(), + Tag: version, + Size: int64(len(descriptorsBytes)), + }, + ) + + if _, err = controllerutil.CreateOrUpdate(ctx, r.GetClient(), snapshotCR, func() error { + if err := controllerutil.SetControllerReference(component, snapshotCR, r.GetScheme()); err != nil { + return fmt.Errorf("failed to set controller reference: %w", err) + } + + component.Status.SnapshotRef = corev1.LocalObjectReference{ + Name: snapshotCR.GetName(), + } + + return nil + }); err != nil { + status.MarkNotReady(r.EventRecorder, component, v1alpha1.CreateSnapshotFailedReason, err.Error()) + + return ctrl.Result{}, err + } + + logger.Info("updating status") + component.Status.Component = v1alpha1.ComponentInfo{ RepositorySpec: repository.Spec.RepositorySpec, Component: component.Spec.Component, Version: version, - }) + } + + component.Status.EffectiveOCMConfig = configs status.MarkReady(r.EventRecorder, component, "Applied version %s", version) @@ -356,73 +453,3 @@ func (r *Reconciler) verifyComponentVersionAndListDescriptors(ctx context.Contex return descriptors, nil } - -func (r *Reconciler) createArtifactForDescriptors(ctx context.Context, octx ocmctx.Context, - component *v1alpha1.Component, cv ocmctx.ComponentVersionAccess, descriptors *ocm.Descriptors, -) error { - logger := log.FromContext(ctx) - - // Create temp working dir - tmpDir, err := os.MkdirTemp("", fmt.Sprintf("%s-%s-%s-", component.Kind, component.Namespace, component.Name)) - if err != nil { - return reconcile.TerminalError(fmt.Errorf("failed to create temporary working directory: %w", err)) - } - octx.Finalizer().With(func() error { - if err = os.RemoveAll(tmpDir); err != nil { - ctrl.LoggerFrom(ctx).Error(err, "failed to remove temporary working directory") - } - - return nil - }) - - content, err := yaml.Marshal(descriptors) - if err != nil { - return reconcile.TerminalError(fmt.Errorf("failed to marshal content: %w", err)) - } - - const perm = 0o655 - if err := os.WriteFile(filepath.Join(tmpDir, v1alpha1.OCMComponentDescriptorList), content, perm); err != nil { - return reconcile.TerminalError(fmt.Errorf("failed to write file: %w", err)) - } - - revision := r.normalizeComponentVersionName(cv.GetName()) + "-" + cv.GetVersion() - if err := r.Storage.ReconcileArtifact( - ctx, - component, - revision, - tmpDir, - revision+".tar.gz", - func(art *artifactv1.Artifact, _ string) error { - // Archive directory to storage - if err := r.Storage.Archive(art, tmpDir, nil); err != nil { - return fmt.Errorf("unable to archive artifact to storage: %w", err) - } - - component.Status.ArtifactRef = corev1.LocalObjectReference{ - Name: art.Name, - } - - return nil - }, - ); err != nil { - return fmt.Errorf("failed to reconcileComponent artifact: %w", err) - } - - logger.Info("successfully reconciled component", "name", component.Name) - - return nil -} - -func (r *Reconciler) normalizeComponentVersionName(name string) string { - return strings.ReplaceAll(name, "/", "-") -} - -func (r *Reconciler) setComponentStatus( - component *v1alpha1.Component, - configs []v1alpha1.OCMConfiguration, - info v1alpha1.ComponentInfo, -) { - component.Status.Component = info - - component.Status.EffectiveOCMConfig = configs -} diff --git a/internal/controller/component/component_controller_test.go b/internal/controller/component/component_controller_test.go index 8ac65307..a3ab847c 100644 --- a/internal/controller/component/component_controller_test.go +++ b/internal/controller/component/component_controller_test.go @@ -19,33 +19,30 @@ package component import ( "context" "fmt" - "net/http" "os" "time" + "github.com/fluxcd/pkg/apis/meta" . "github.com/mandelsoft/goutils/testutils" + "github.com/mandelsoft/vfs/pkg/vfs" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - . "ocm.software/ocm/api/helper/builder" - - "github.com/fluxcd/pkg/apis/meta" - "github.com/fluxcd/pkg/runtime/conditions" - "github.com/fluxcd/pkg/tar" - "github.com/mandelsoft/filepath/pkg/filepath" - "github.com/mandelsoft/vfs/pkg/osfs" - "github.com/mandelsoft/vfs/pkg/vfs" + "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" - "ocm.software/ocm/api/ocm/extensions/repositories/ctf" - "ocm.software/ocm/api/utils/accessio" + . "ocm.software/ocm/api/helper/builder" "ocm.software/ocm/api/utils/accessobj" - "sigs.k8s.io/controller-runtime/pkg/envtest/komega" "sigs.k8s.io/yaml" - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" + "github.com/fluxcd/pkg/runtime/conditions" + "github.com/mandelsoft/vfs/pkg/osfs" corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" environment "ocm.software/ocm/api/helper/env" + "ocm.software/ocm/api/ocm/extensions/repositories/ctf" + "ocm.software/ocm/api/utils/accessio" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest/komega" "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" "github.com/open-component-model/ocm-k8s-toolkit/pkg/ocm" @@ -143,36 +140,44 @@ var _ = Describe("Component Controller", func() { } Expect(k8sClient.Create(ctx, component)).To(Succeed()) - By("check that artifact has been created successfully") + By("checking that the component has been reconciled successfully") + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(component), component) + if err != nil { + return false + } + return conditions.IsReady(component) + }, "15s").WithContext(ctx).Should(BeTrue()) + By("checking that the snapshot has been created successfully") Eventually(komega.Object(component), "15s").Should( - HaveField("Status.ArtifactRef.Name", Not(BeEmpty()))) + HaveField("Status.SnapshotRef.Name", Not(BeEmpty()))) + snapshotComponent := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: component.GetNamespace(), Name: component.GetSnapshotName()}, snapshotComponent)).To(Succeed()) - artifact := &artifactv1.Artifact{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: component.Namespace, - Name: component.Status.ArtifactRef.Name, - }, - } - Eventually(komega.Get(artifact)).Should(Succeed()) + By("validating the snapshot") + ownersReference := snapshotComponent.GetOwnerReferences() + Expect(len(ownersReference)).To(Equal(1), "expected only one ownersReference") + Expect(ownersReference[0].Name).To(Equal(component.GetName()), "expected to be a ownersReference of the component") - By("check if the component descriptor list can be retrieved from the artifact server") - r := Must(http.Get(artifact.Spec.URL)) - - tmpdir := Must(os.MkdirTemp("/tmp", "descriptors-")) - DeferCleanup(func() error { - return os.RemoveAll(tmpdir) - }) - MustBeSuccessful(tar.Untar(r.Body, tmpdir)) + By("checking that the snapshot contains the correct content") + snapshotRepository := Must(registry.NewRepository(ctx, snapshotComponent.Spec.Repository)) + snapshotComponentContent := Must(snapshotRepository.FetchSnapshot(ctx, snapshotComponent.GetDigest())) + snapshotDescriptors := &ocm.Descriptors{} + MustBeSuccessful(yaml.Unmarshal(snapshotComponentContent, snapshotDescriptors)) repo := Must(ctf.Open(env, accessobj.ACC_WRITABLE, ctfpath, vfs.FileMode(vfs.O_RDWR), env)) cv := Must(repo.LookupComponentVersion(Component, Version1)) - expecteddescs := Must(ocm.ListComponentDescriptors(ctx, cv, repo)) - - data := Must(os.ReadFile(filepath.Join(tmpdir, v1alpha1.OCMComponentDescriptorList))) - descs := &ocm.Descriptors{} - MustBeSuccessful(yaml.Unmarshal(data, descs)) - Expect(descs).To(YAMLEqual(expecteddescs)) + expectedDescriptors := Must(ocm.ListComponentDescriptors(ctx, cv, repo)) + Expect(snapshotDescriptors).To(YAMLEqual(expectedDescriptors)) + + By("delete resources manually") + Expect(k8sClient.Delete(ctx, component)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotComponent)).To(Succeed()) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(component), component) + return errors.IsNotFound(err) + }, "15s").WithContext(ctx).Should(BeTrue()) }) It("does not reconcile when the repository is not ready", func() { @@ -184,7 +189,7 @@ var _ = Describe("Component Controller", func() { component := &v1alpha1.Component{ ObjectMeta: metav1.ObjectMeta{ Namespace: Namespace, - Name: ComponentObj + "-not-ready", + Name: fmt.Sprintf("%s-%d", ComponentObj, testNumber), }, Spec: v1alpha1.ComponentSpec{ RepositoryRef: v1alpha1.ObjectKey{ @@ -199,9 +204,26 @@ var _ = Describe("Component Controller", func() { } Expect(k8sClient.Create(ctx, component)).To(Succeed()) - By("check that no artifact has been created") - Eventually(komega.Object(component), "15s").Should( - HaveField("Status.ArtifactRef.Name", BeEmpty())) + By("checking that the component has not been reconciled successfully") + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(component), component) + if err != nil { + return false + } + + return !conditions.IsReady(component) + }, "15s").WithContext(ctx).Should(BeTrue()) + + By("checking that the snapshot has not been created successfully") + Expect(component).To(HaveField("Status.SnapshotRef.Name", BeEmpty())) + + By("deleting the resources manually") + Expect(k8sClient.Delete(ctx, component)).To(Succeed()) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(component), component) + + return errors.IsNotFound(err) + }, "15s").WithContext(ctx).Should(BeTrue()) }) It("grabs the new version when it becomes available", func() { @@ -224,10 +246,20 @@ var _ = Describe("Component Controller", func() { } Expect(k8sClient.Create(ctx, component)).To(Succeed()) - By("check that artifact has been created successfully") + By("checking that the component has been reconciled successfully") + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(component), component) + if err != nil { + return false + } + return conditions.IsReady(component) + }, "15s").WithContext(ctx).Should(BeTrue()) + By("checking that the snapshot has been created successfully") Eventually(komega.Object(component), "15s").Should( - HaveField("Status.ArtifactRef.Name", Not(BeEmpty()))) + HaveField("Status.SnapshotRef.Name", Not(BeEmpty()))) + snapshotComponent := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: component.GetNamespace(), Name: component.GetSnapshotName()}, snapshotComponent)).To(Succeed()) Expect(k8sClient.Get(ctx, types.NamespacedName{Name: component.Name, Namespace: component.Namespace}, component)).To(Succeed()) Expect(component.Status.Component.Version).To(Equal(Version1)) @@ -246,6 +278,14 @@ var _ = Describe("Component Controller", func() { return component.Status.Component.Version == Version2 }).WithTimeout(15 * time.Second).Should(BeTrue()) + + By("delete resources manually") + Expect(k8sClient.Delete(ctx, component)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotComponent)).To(Succeed()) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(component), component) + return errors.IsNotFound(err) + }, "15s").WithContext(ctx).Should(BeTrue()) }) It("grabs lower version if downgrade is allowed", func() { @@ -281,9 +321,20 @@ var _ = Describe("Component Controller", func() { } Expect(k8sClient.Create(ctx, component)).To(Succeed()) - By("check that artifact has been created successfully") + By("checking that the component has been reconciled successfully") + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(component), component) + if err != nil { + return false + } + return conditions.IsReady(component) + }, "15s").WithContext(ctx).Should(BeTrue()) - Eventually(komega.Object(component), "15s").Should(HaveField("Status.ArtifactRef.Name", Not(BeEmpty()))) + By("checking that the snapshot has been created successfully") + Eventually(komega.Object(component), "15s").Should( + HaveField("Status.SnapshotRef.Name", Not(BeEmpty()))) + snapshotComponent := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: component.GetNamespace(), Name: component.GetSnapshotName()}, snapshotComponent)).To(Succeed()) Expect(k8sClient.Get(ctx, types.NamespacedName{Name: component.Name, Namespace: component.Namespace}, component)).To(Succeed()) Expect(component.Status.Component.Version).To(Equal("0.0.3")) @@ -296,6 +347,14 @@ var _ = Describe("Component Controller", func() { return component.Status.Component.Version == "0.0.2" }).WithTimeout(15 * time.Second).Should(BeTrue()) + + By("delete resources manually") + Expect(k8sClient.Delete(ctx, component)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotComponent)).To(Succeed()) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(component), component) + return errors.IsNotFound(err) + }, "15s").WithContext(ctx).Should(BeTrue()) }) It("does not grab lower version if downgrade is denied", func() { @@ -330,8 +389,20 @@ var _ = Describe("Component Controller", func() { } Expect(k8sClient.Create(ctx, component)).To(Succeed()) - By("check that artifact has been created successfully") - Eventually(komega.Object(component), "15s").Should(HaveField("Status.ArtifactRef.Name", Not(BeEmpty()))) + By("checking that the component has been reconciled successfully") + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(component), component) + if err != nil { + return false + } + return conditions.IsReady(component) + }, "15s").WithContext(ctx).Should(BeTrue()) + + By("checking that the snapshot has been created successfully") + Eventually(komega.Object(component), "15s").Should( + HaveField("Status.SnapshotRef.Name", Not(BeEmpty()))) + snapshotComponent := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: component.GetNamespace(), Name: component.GetSnapshotName()}, snapshotComponent)).To(Succeed()) Expect(k8sClient.Get(ctx, types.NamespacedName{Name: component.Name, Namespace: component.Namespace}, component)).To(Succeed()) Expect(component.Status.Component.Version).To(Equal("0.0.3")) @@ -345,6 +416,14 @@ var _ = Describe("Component Controller", func() { cond := conditions.Get(component, meta.ReadyCondition) return cond.Message == "terminal error: component version cannot be downgraded from version 0.0.3 to version 0.0.2" }).WithTimeout(15 * time.Second).Should(BeTrue()) + + By("delete resources manually") + Expect(k8sClient.Delete(ctx, component)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotComponent)).To(Succeed()) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(component), component) + return errors.IsNotFound(err) + }, "15s").WithContext(ctx).Should(BeTrue()) }) It("can force downgrade even if not allowed by the component", func() { @@ -376,10 +455,20 @@ var _ = Describe("Component Controller", func() { } Expect(k8sClient.Create(ctx, component)).To(Succeed()) - By("check that artifact has been created successfully") + By("checking that the component has been reconciled successfully") + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(component), component) + if err != nil { + return false + } + return conditions.IsReady(component) + }, "15s").WithContext(ctx).Should(BeTrue()) + By("checking that the snapshot has been created successfully") Eventually(komega.Object(component), "15s").Should( - HaveField("Status.ArtifactRef.Name", Not(BeEmpty()))) + HaveField("Status.SnapshotRef.Name", Not(BeEmpty()))) + snapshotComponent := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: component.GetNamespace(), Name: component.GetSnapshotName()}, snapshotComponent)).To(Succeed()) Expect(k8sClient.Get(ctx, types.NamespacedName{Name: component.Name, Namespace: component.Namespace}, component)).To(Succeed()) Expect(component.Status.Component.Version).To(Equal("0.0.3")) @@ -391,7 +480,15 @@ var _ = Describe("Component Controller", func() { Expect(k8sClient.Get(ctx, types.NamespacedName{Name: component.Name, Namespace: component.Namespace}, component)).To(Succeed()) return component.Status.Component.Version == "0.0.2" - }).WithTimeout(15 * time.Second).Should(BeTrue()) + }).WithTimeout(60 * time.Second).Should(BeTrue()) + + By("delete resources manually") + Expect(k8sClient.Delete(ctx, component)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotComponent)).To(Succeed()) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(component), component) + return errors.IsNotFound(err) + }, "15s").WithContext(ctx).Should(BeTrue()) }) }) @@ -585,6 +682,21 @@ var _ = Describe("Component Controller", func() { } Expect(k8sClient.Create(ctx, component)).To(Succeed()) + By("checking that the component has been reconciled successfully") + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(component), component) + if err != nil { + return false + } + return conditions.IsReady(component) + }, "15s").WithContext(ctx).Should(BeTrue()) + + By("checking that the snapshot has been created successfully") + Eventually(komega.Object(component), "15s").Should( + HaveField("Status.SnapshotRef.Name", Not(BeEmpty()))) + snapshotComponent := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: component.GetNamespace(), Name: component.GetSnapshotName()}, snapshotComponent)).To(Succeed()) + Eventually(komega.Object(component), "15s").Should( HaveField("Status.EffectiveOCMConfig", ConsistOf( v1alpha1.OCMConfiguration{ @@ -605,7 +717,16 @@ var _ = Describe("Component Controller", func() { }, Policy: v1alpha1.ConfigurationPolicyDoNotPropagate, }, - ))) + )), + ) + + By("delete resources manually") + Expect(k8sClient.Delete(ctx, component)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotComponent)).To(Succeed()) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(component), component) + return errors.IsNotFound(err) + }, "15s").WithContext(ctx).Should(BeTrue()) }) }) }) diff --git a/internal/controller/component/suite_test.go b/internal/controller/component/suite_test.go index c7c66166..a3e5e645 100644 --- a/internal/controller/component/suite_test.go +++ b/internal/controller/component/suite_test.go @@ -16,38 +16,32 @@ package component import ( "context" "fmt" - "io" - "net/http" "os" + "os/exec" "path/filepath" "runtime" "testing" - "time" . "github.com/mandelsoft/goutils/testutils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - - "github.com/openfluxcd/controller-manager/server" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/controller-runtime/pkg/envtest/komega" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - "sigs.k8s.io/yaml" - - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" - corev1 "k8s.io/api/core/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - ctrl "sigs.k8s.io/controller-runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" metricserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" "github.com/open-component-model/ocm-k8s-toolkit/pkg/ocm" + snapshotPkg "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/test" ) // +kubebuilder:scaffold:imports @@ -55,15 +49,13 @@ import ( // These tests use Ginkgo (BDD-style Go testing framework). Refer to // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. -const ( - ARTIFACT_PATH = "ocm-k8s-artifactstore--*" - ARTIFACT_SERVER = "localhost:8080" -) - var cfg *rest.Config var k8sClient client.Client var k8sManager ctrl.Manager var testEnv *envtest.Environment +var zotCmd *exec.Cmd +var registry *snapshotPkg.Registry +var zotRootDir string func TestControllers(t *testing.T) { RegisterFailHandler(Fail) @@ -76,26 +68,10 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") - // Get external artifact CRD - resp, err := http.Get(v1alpha1.ArtifactCrd) - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() error { - return resp.Body.Close() - }) - - crdByte, err := io.ReadAll(resp.Body) - Expect(err).NotTo(HaveOccurred()) - - artifactCRD := &apiextensionsv1.CustomResourceDefinition{} - err = yaml.Unmarshal(crdByte, artifactCRD) - Expect(err).NotTo(HaveOccurred()) - testEnv = &envtest.Environment{ CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, ErrorIfCRDPathMissing: true, - CRDs: []*apiextensionsv1.CustomResourceDefinition{artifactCRD}, - // The BinaryAssetsDirectory is only required if you want to run the tests directly // without call the makefile target test. If not informed it will look for the // default path defined in controller-runtime which is /usr/local/kubebuilder/. @@ -105,6 +81,8 @@ var _ = BeforeSuite(func() { fmt.Sprintf("1.30.0-%s-%s", runtime.GOOS, runtime.GOARCH)), } + var err error + // cfg is defined in this file globally. cfg, err = testEnv.Start() Expect(err).NotTo(HaveOccurred()) @@ -112,7 +90,6 @@ var _ = BeforeSuite(func() { DeferCleanup(testEnv.Stop) Expect(v1alpha1.AddToScheme(scheme.Scheme)).Should(Succeed()) - Expect(artifactv1.AddToScheme(scheme.Scheme)).Should(Succeed()) Expect(err).NotTo(HaveOccurred()) // +kubebuilder:scaffold:scheme @@ -130,10 +107,13 @@ var _ = BeforeSuite(func() { }) Expect(err).ToNot(HaveOccurred()) - tmpdir := Must(os.MkdirTemp("", ARTIFACT_PATH)) - address := ARTIFACT_SERVER - storage := Must(server.NewStorage(k8sClient, testEnv.Scheme, tmpdir, address, 0, 0)) - artifactServer := Must(server.NewArtifactServer(tmpdir, address, time.Millisecond)) + // Setup zot registry and start it up + zotRootDir = Must(os.MkdirTemp("", "")) + DeferCleanup(func() { + Expect(os.RemoveAll(zotRootDir)).To(Succeed()) + }) + + zotCmd, registry = test.SetupRegistry(filepath.Join("..", "..", "..", "bin", "zot-registry"), zotRootDir, "0.0.0.0", "8080") Expect((&Reconciler{ BaseReconciler: &ocm.BaseReconciler{ @@ -144,7 +124,7 @@ var _ = BeforeSuite(func() { IncludeObject: true, }, }, - Storage: storage, + Registry: registry, }).SetupWithManager(k8sManager)).To(Succeed()) ctx, cancel := context.WithCancel(context.Background()) @@ -157,12 +137,13 @@ var _ = BeforeSuite(func() { } Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) - go func() { - defer GinkgoRecover() - Expect(artifactServer.Start(ctx)).To(Succeed()) - }() go func() { defer GinkgoRecover() Expect(k8sManager.Start(ctx)).To(Succeed()) }() }) + +var _ = AfterSuite(func(ctx context.Context) { + err := zotCmd.Process.Kill() + Expect(err).NotTo(HaveOccurred(), "Failed to stop Zot registry") +}) diff --git a/internal/controller/configuration/client/client.go b/internal/controller/configuration/client/client.go index 68755254..eddec93d 100644 --- a/internal/controller/configuration/client/client.go +++ b/internal/controller/configuration/client/client.go @@ -4,14 +4,13 @@ import ( "context" "fmt" - "github.com/openfluxcd/controller-manager/storage" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" "github.com/open-component-model/ocm-k8s-toolkit/internal/controller/configuration/types" - artifactutil "github.com/open-component-model/ocm-k8s-toolkit/pkg/artifact" + snapshotRegistry "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" ) type Client interface { @@ -24,24 +23,24 @@ type Client interface { GetTarget(ctx context.Context, ref v1alpha1.ConfigurationReference) (target types.ConfigurationTarget, err error) } -func NewClientWithLocalStorage(r client.Reader, s *storage.Storage, scheme *runtime.Scheme) Client { +func NewClientWithRegistry(r client.Reader, registry snapshotRegistry.RegistryType, scheme *runtime.Scheme) Client { factory := serializer.NewCodecFactory(scheme) info, _ := runtime.SerializerInfoForMediaType(factory.SupportedMediaTypes(), runtime.ContentTypeYAML) encoder := factory.EncoderForVersion(info.Serializer, v1alpha1.GroupVersion) return &localStorageBackedClient{ - Reader: r, - Storage: s, - scheme: scheme, - encoder: encoder, + Reader: r, + Registry: registry, + scheme: scheme, + encoder: encoder, } } type localStorageBackedClient struct { client.Reader - *storage.Storage - scheme *runtime.Scheme - encoder runtime.Encoder + Registry snapshotRegistry.RegistryType + scheme *runtime.Scheme + encoder runtime.Encoder } var _ Client = &localStorageBackedClient{} @@ -57,7 +56,7 @@ func (clnt *localStorageBackedClient) GetTarget(ctx context.Context, ref v1alpha case v1alpha1.KindLocalizedResource: fallthrough case v1alpha1.KindResource: - return artifactutil.GetContentBackedByArtifactFromComponent(ctx, clnt.Reader, clnt.Storage, &ref) + return snapshotRegistry.GetContentBackedBySnapshotFromComponent(ctx, clnt.Reader, clnt.Registry, &ref) default: return nil, fmt.Errorf("unsupported configuration target kind: %s", ref.Kind) } @@ -66,7 +65,7 @@ func (clnt *localStorageBackedClient) GetTarget(ctx context.Context, ref v1alpha func (clnt *localStorageBackedClient) GetConfiguration(ctx context.Context, ref v1alpha1.ConfigurationReference) (source types.ConfigurationSource, err error) { switch ref.Kind { case v1alpha1.KindResource: - return artifactutil.GetContentBackedByArtifactFromComponent(ctx, clnt.Reader, clnt.Storage, &ref) + return snapshotRegistry.GetContentBackedBySnapshotFromComponent(ctx, clnt.Reader, clnt.Registry, &ref) case v1alpha1.KindResourceConfig: return GetResourceConfigFromKubernetes(ctx, clnt.Reader, clnt.encoder, ref) default: @@ -90,5 +89,5 @@ func GetResourceConfigFromKubernetes(ctx context.Context, clnt client.Reader, en return nil, fmt.Errorf("failed to fetch localization config %s: %w", reference.Name, err) } - return &artifactutil.ObjectConfig{Object: &cfg, Encoder: encoder}, nil + return &snapshotRegistry.ObjectConfig{Object: &cfg, Encoder: encoder}, nil } diff --git a/internal/controller/configuration/configuration_controller.go b/internal/controller/configuration/configuration_controller.go index 5fdd2a22..c302d56b 100644 --- a/internal/controller/configuration/configuration_controller.go +++ b/internal/controller/configuration/configuration_controller.go @@ -23,21 +23,21 @@ import ( "os" "github.com/fluxcd/pkg/runtime/patch" - "github.com/openfluxcd/controller-manager/storage" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" + corev1 "k8s.io/api/core/v1" ctrl "sigs.k8s.io/controller-runtime" "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" configurationclient "github.com/open-component-model/ocm-k8s-toolkit/internal/controller/configuration/client" - "github.com/open-component-model/ocm-k8s-toolkit/pkg/artifact" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/compression" "github.com/open-component-model/ocm-k8s-toolkit/pkg/index" "github.com/open-component-model/ocm-k8s-toolkit/pkg/ocm" + snapshotRegistry "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" "github.com/open-component-model/ocm-k8s-toolkit/pkg/status" ) @@ -51,7 +51,7 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.ConfiguredResource{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). // Update when the owned artifact containing the configured data changes - Owns(&artifactv1.Artifact{}). + Owns(&v1alpha1.Snapshot{}). // Update when a resource specified as target changes Watches(&v1alpha1.Resource{}, onTargetChange). Watches(&v1alpha1.LocalizedResource{}, onTargetChange). @@ -69,8 +69,8 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { // Reconciler reconciles a ConfiguredResource object. type Reconciler struct { *ocm.BaseReconciler - *storage.Storage ConfigClient configurationclient.Client + Registry snapshotRegistry.RegistryType } // +kubebuilder:rbac:groups=delivery.ocm.software,resources=configuredresources,verbs=get;list;watch;create;update;patch;delete @@ -85,6 +85,8 @@ type Reconciler struct { // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, err error) { + logger := log.FromContext(ctx) + configuration := &v1alpha1.ConfiguredResource{} if err := r.Get(ctx, req.NamespacedName, configuration); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) @@ -94,23 +96,10 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Re return ctrl.Result{}, nil } - if !configuration.GetDeletionTimestamp().IsZero() { - // TODO: This is a temporary solution until a artifact-reconciler is written to handle the deletion of artifacts - if err := ocm.RemoveArtifactForCollectable(ctx, r.Client, r.Storage, configuration); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to remove artifact: %w", err) - } - - if removed := controllerutil.RemoveFinalizer(configuration, v1alpha1.ArtifactFinalizer); removed { - if err := r.Update(ctx, configuration); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to remove finalizer: %w", err) - } - } - - return ctrl.Result{}, nil - } + if configuration.GetDeletionTimestamp() != nil { + logger.Info("configuration is being deleted and cannot be used", "name", configuration.Name) - if added := controllerutil.AddFinalizer(configuration, v1alpha1.ArtifactFinalizer); added { - return ctrl.Result{Requeue: true}, r.Update(ctx, configuration) + return ctrl.Result{Requeue: true}, nil } return r.reconcileWithStatusUpdate(ctx, configuration) @@ -131,13 +120,10 @@ func (r *Reconciler) reconcileWithStatusUpdate(ctx context.Context, localization return result, nil } +//nolint:gocognit // we do not want to cut function at an arbitrary point func (r *Reconciler) reconcileExists(ctx context.Context, configuration *v1alpha1.ConfiguredResource) (ctrl.Result, error) { logger := log.FromContext(ctx) - if err := r.Storage.ReconcileStorage(ctx, configuration); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to reconcile storage: %w", err) - } - if configuration.Spec.Target.Namespace == "" { configuration.Spec.Target.Namespace = configuration.Namespace } @@ -160,28 +146,34 @@ func (r *Reconciler) reconcileExists(ctx context.Context, configuration *v1alpha return ctrl.Result{}, fmt.Errorf("failed to fetch cfg: %w", err) } - digest, revision, filename, err := artifact.UniqueIDsForArtifactContentCombination(cfg, target) + combinedDigest, revision, _, err := snapshotRegistry.UniqueIDsForSnapshotContentCombination(cfg, target) if err != nil { status.MarkNotReady(r.EventRecorder, configuration, v1alpha1.UniqueIDGenerationFailedReason, err.Error()) - return ctrl.Result{}, fmt.Errorf("failed to map digest from config to target: %w", err) + return ctrl.Result{}, fmt.Errorf("failed to map combinedDigest from config to target: %w", err) } - logger.V(1).Info("verifying configuration", "digest", digest, "revision", revision) - hasValidArtifact, err := ocm.ValidateArtifactForCollectable( + // TODO: we cannot use `combinedDigest` to determine a change as the combinedDigest calculation is incorrect + // (it takes a k8s object with managed fields that change on every update). + + // Check if a snapshot of the configuration resource already exists and if it holds the same calculated combinedDigest + // from above + logger.V(1).Info("verifying configuration", "combinedDigest", combinedDigest, "revision", revision) + hasValidSnapshot, err := snapshotRegistry.ValidateSnapshotForOwner( ctx, r.Client, - r.Storage, configuration, - digest, + combinedDigest, ) if err != nil { - return ctrl.Result{}, fmt.Errorf("failed to check if artifact is valid: %w", err) + return ctrl.Result{}, fmt.Errorf("failed to check if snapshot is valid: %w", err) } - var configured string - if !hasValidArtifact { - logger.V(1).Info("configuring", "digest", digest, "revision", revision) + // If no valid snapshot is present (because it never existed or is just not valid), we will configure the target, + // create a snapshot and return. + //nolint:nestif // Ignore as it is not that complex. + if !hasValidSnapshot { + logger.V(1).Info("configuring", "combinedDigest", combinedDigest, "revision", revision) basePath, err := os.MkdirTemp("", "configured-") if err != nil { return ctrl.Result{}, fmt.Errorf("failed to create temporary directory to perform configuration: %w", err) @@ -192,43 +184,79 @@ func (r *Reconciler) reconcileExists(ctx context.Context, configuration *v1alpha } }() - if configured, err = Configure(ctx, r.ConfigClient, cfg, target, basePath); err != nil { + configured, err := Configure(ctx, r.ConfigClient, cfg, target, basePath) + if err != nil { status.MarkNotReady(r.EventRecorder, configuration, v1alpha1.ConfigurationFailedReason, err.Error()) return ctrl.Result{}, fmt.Errorf("failed to configure: %w", err) } - } - configuration.Status.Digest = digest + // Create archive from configured directory and gzip it. + dataTGZ, err := compression.CreateTGZFromPath(configured) + if err != nil { + status.MarkNotReady(r.EventRecorder, configuration, v1alpha1.CreateTGZFailedReason, err.Error()) - if err := r.Storage.ReconcileArtifact( - ctx, - configuration, - revision, - configured, - filename, - func(artifact *artifactv1.Artifact, dir string) error { - if !hasValidArtifact { - // Archive directory to storage - if err := r.Storage.Archive(artifact, dir, nil); err != nil { - return fmt.Errorf("unable to archive artifact to storage: %w", err) + return ctrl.Result{}, fmt.Errorf("failed to create TGZ from path: %w", err) + } + + repositoryName, err := snapshotRegistry.CreateRepositoryName(configuration.GetName()) + if err != nil { + status.MarkNotReady(r.EventRecorder, configuration, v1alpha1.CreateOCIRepositoryNameFailedReason, err.Error()) + + return ctrl.Result{}, fmt.Errorf("failed to create repository name: %w", err) + } + + repository, err := r.Registry.NewRepository(ctx, repositoryName) + if err != nil { + status.MarkNotReady(r.EventRecorder, configuration, v1alpha1.ConfigurationFailedReason, err.Error()) + + return ctrl.Result{}, fmt.Errorf("failed to configure: %w", err) + } + + // TODO: Find out which version should be used to tag the OCI artifact. + // Things to consider: + // - HelmRelease (FluxCD) requires the OCI artifact to have the same tag as the helm chart itself + // - But how to get the helm chart version? (User input, parse from content) + tag := "dummy" + manifestDigest, err := repository.PushSnapshot(ctx, tag, dataTGZ) + if err != nil { + status.MarkNotReady(r.EventRecorder, configuration, v1alpha1.ConfigurationFailedReason, err.Error()) + + return ctrl.Result{}, fmt.Errorf("failed to configure: %w", err) + } + + // We use the combinedDigest calculated above for the blob-info combinedDigest, so we can compare for any changes + snapshotCR := snapshotRegistry.Create( + configuration, + repositoryName, + manifestDigest.String(), + &v1alpha1.BlobInfo{ + Digest: combinedDigest, + Tag: tag, + Size: int64(len(dataTGZ)), + }, + ) + + if _, err = controllerutil.CreateOrUpdate(ctx, r.GetClient(), snapshotCR, func() error { + if snapshotCR.ObjectMeta.CreationTimestamp.IsZero() { + if err := controllerutil.SetControllerReference(configuration, snapshotCR, r.GetScheme()); err != nil { + return fmt.Errorf("failed to set controller reference: %w", err) } } - configuration.Status.ArtifactRef = &v1alpha1.ObjectKey{ - Name: artifact.Name, - Namespace: artifact.Namespace, + configuration.Status.SnapshotRef = corev1.LocalObjectReference{ + Name: snapshotCR.GetName(), } return nil - }, - ); err != nil { - status.MarkNotReady(r.EventRecorder, configuration, v1alpha1.ReconcileArtifactFailedReason, err.Error()) + }); err != nil { + status.MarkNotReady(r.EventRecorder, configuration, v1alpha1.CreateSnapshotFailedReason, err.Error()) - return ctrl.Result{}, fmt.Errorf("failed to reconcile artifact: %w", err) + return ctrl.Result{}, err + } } - logger.Info("configuration successful", "artifact", configuration.Status.ArtifactRef) + logger.Info("configuration successful", "snapshot", configuration.Status.SnapshotRef) status.MarkReady(r.EventRecorder, configuration, "configured successfully") return ctrl.Result{RequeueAfter: configuration.Spec.Interval.Duration}, nil diff --git a/internal/controller/configuration/configuration_controller_test.go b/internal/controller/configuration/configuration_controller_test.go index 84686e8a..3d863d44 100644 --- a/internal/controller/configuration/configuration_controller_test.go +++ b/internal/controller/configuration/configuration_controller_test.go @@ -2,20 +2,22 @@ package configuration import ( "context" + "os" "path/filepath" "time" _ "embed" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - . "sigs.k8s.io/controller-runtime/pkg/envtest/komega" - + "github.com/fluxcd/pkg/runtime/conditions" + . "github.com/mandelsoft/goutils/testutils" "github.com/mandelsoft/vfs/pkg/osfs" "github.com/mandelsoft/vfs/pkg/projectionfs" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -23,6 +25,7 @@ import ( environment "ocm.software/ocm/api/helper/env" "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/compression" "github.com/open-component-model/ocm-k8s-toolkit/pkg/test" ) @@ -58,36 +61,50 @@ var _ = Describe("ConfiguredResource Controller", func() { }) It("should configure an artifact from a resource based on a ResourceConfig", func(ctx SpecContext) { - component := NoOpComponent(ctx, tmp) + By("creating a mock component") + component := NoOpComponent(ctx) + By("creating a mock target resource") fileToConfigure := "test.yaml" fileContentBeforeConfiguration := []byte(`mykey: "value"`) fileContentAfterConfiguration := []byte(`mykey: "substituted"`) dir := filepath.Join(tmp, "test") - test.CreateTGZ(dir, map[string][]byte{ - fileToConfigure: fileContentBeforeConfiguration, - }) + Expect(os.Mkdir(dir, os.ModePerm|os.ModeDir)).To(Succeed()) + + path := filepath.Join(dir, fileToConfigure) + + writer := Must(os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.ModePerm)) + defer func() { + Expect(writer.Close()).To(Succeed()) + }() + + Must(writer.Write(fileContentBeforeConfiguration)) targetResource := test.SetupMockResourceWithData(ctx, TargetResourceObj, Namespace, &test.MockResourceOptions{ - BasePath: tmp, DataPath: dir, ComponentRef: v1alpha1.ObjectKey{ Namespace: Namespace, Name: component.GetName(), }, - Strg: strg, + Registry: registry, Clnt: k8sClient, Recorder: recorder, }, ) - DeferCleanup(func(ctx SpecContext) { - Expect(k8sClient.Delete(ctx, targetResource, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) - }) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(targetResource), targetResource) + if err != nil { + return false + } + return conditions.IsReady(targetResource) + }, "15s").WithContext(ctx).Should(BeTrue()) + + By("creating a resource config") cfg := v1alpha1.ResourceConfig{ ObjectMeta: metav1.ObjectMeta{ Name: ResourceConfig, @@ -114,10 +131,8 @@ var _ = Describe("ConfiguredResource Controller", func() { }, } Expect(k8sClient.Create(ctx, &cfg)).To(Succeed()) - DeferCleanup(func(ctx SpecContext) { - Expect(k8sClient.Delete(ctx, &cfg, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) - }) + By("creating a configured resource") configuredResource := &v1alpha1.ConfiguredResource{ ObjectMeta: metav1.ObjectMeta{ Name: ConfiguredResource, @@ -130,34 +145,59 @@ var _ = Describe("ConfiguredResource Controller", func() { }, } Expect(k8sClient.Create(ctx, configuredResource)).To(Succeed()) - DeferCleanup(func(ctx SpecContext) { - Expect(k8sClient.Delete(ctx, configuredResource, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) - }) - - Eventually(Object(configuredResource), "15s").Should( - HaveField("Status.ArtifactRef.Name", Not(BeEmpty()))) - art := &artifactv1.Artifact{} - art.Name = configuredResource.Status.ArtifactRef.Name - art.Namespace = configuredResource.Namespace - - test.VerifyArtifact(strg, art, map[string]func(data []byte){ - fileToConfigure: func(data []byte) { - Expect(data).To(MatchYAML(fileContentAfterConfiguration)) - }, - }) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(configuredResource), configuredResource) + if err != nil { + return false + } + return conditions.IsReady(configuredResource) && configuredResource.GetSnapshotName() != "" + }, "15s").WithContext(ctx).Should(BeTrue()) + + snapshotResource := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: configuredResource.GetNamespace(), Name: configuredResource.GetSnapshotName()}, snapshotResource)).To(Succeed()) + + snapshotRepository, err := registry.NewRepository(ctx, snapshotResource.Spec.Repository) + Expect(err).NotTo(HaveOccurred()) + snapshotResourceContent, err := snapshotRepository.FetchSnapshot(ctx, snapshotResource.GetDigest()) + Expect(err).NotTo(HaveOccurred()) + dataExtracted, err := compression.ExtractDataFromTGZ(snapshotResourceContent) + Expect(err).NotTo(HaveOccurred()) + Expect(dataExtracted).To(MatchYAML(fileContentAfterConfiguration)) + + By("delete resources manually") + Expect(k8sClient.Delete(ctx, configuredResource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotResource)).To(Succeed()) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(configuredResource), configuredResource) + return errors.IsNotFound(err) + }, "15s").WithContext(ctx).Should(BeTrue()) + + Expect(k8sClient.Delete(ctx, &cfg)).To(Succeed()) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(&cfg), &cfg) + return errors.IsNotFound(err) + }, "15s").WithContext(ctx).Should(BeTrue()) + + Expect(k8sClient.Delete(ctx, targetResource)).To(Succeed()) + snapshotTargetResource := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: targetResource.GetNamespace(), Name: targetResource.GetSnapshotName()}, snapshotTargetResource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotTargetResource)).To(Succeed()) + + Expect(k8sClient.Delete(ctx, component)).To(Succeed()) + snapshotComponent := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: component.GetNamespace(), Name: component.GetSnapshotName()}, snapshotComponent)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotComponent)).To(Succeed()) }) - }) -func NoOpComponent(ctx context.Context, basePath string) *v1alpha1.Component { +func NoOpComponent(ctx context.Context) *v1alpha1.Component { component := test.SetupComponentWithDescriptorList(ctx, "any-component-that-should-not-be-introspected", Namespace, - nil, + []byte("noop"), &test.MockComponentOptions{ - BasePath: basePath, - Strg: strg, + Registry: registry, Client: k8sClient, Recorder: recorder, Info: v1alpha1.ComponentInfo{ @@ -168,8 +208,6 @@ func NoOpComponent(ctx context.Context, basePath string) *v1alpha1.Component { Repository: "repo-that-should-not-be-introspected", }, ) - DeferCleanup(func(ctx SpecContext) { - Expect(k8sClient.Delete(ctx, component, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) - }) + return component } diff --git a/internal/controller/configuration/configure.go b/internal/controller/configuration/configure.go index 8d910121..667da711 100644 --- a/internal/controller/configuration/configure.go +++ b/internal/controller/configuration/configure.go @@ -18,7 +18,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" - "github.com/open-component-model/ocm-k8s-toolkit/pkg/artifact" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" "github.com/open-component-model/ocm-k8s-toolkit/pkg/substitute" "github.com/open-component-model/ocm-k8s-toolkit/pkg/substitute/steps" "github.com/open-component-model/ocm-k8s-toolkit/pkg/util" @@ -55,16 +55,16 @@ func Configure(ctx context.Context, ) (string, error) { logger := log.FromContext(ctx) targetDir := filepath.Join(basePath, "target") - if err := target.UnpackIntoDirectory(targetDir); errors.Is(err, artifact.ErrAlreadyUnpacked) { + if err := target.UnpackIntoDirectory(targetDir); errors.Is(err, snapshot.ErrAlreadyUnpacked) { logger.Info("target was already present, reusing existing directory", "path", targetDir) } else if err != nil { return "", fmt.Errorf("failed to get target directory: %w", err) } - // TODO Workaround because the tarball from artifact storer uses a folder - // named after the util name instead of storing at artifact root level as this is the expected format - // for helm tgz archives. - // See issue: https://github.com/helm/helm/issues/5552 + // TODO: Workaround because the tarball from artifact storer uses a folder + // named after the util name instead of storing at artifact root level as this is the expected format + // for helm tgz archives. + // See issue: https://github.com/helm/helm/issues/5552 useSubDir, subDir, err := util.IsHelmChart(targetDir) if err != nil { return "", fmt.Errorf("failed to determine if target is a helm chart to traverse into subdirectory: %w", err) diff --git a/internal/controller/configuration/suite_test.go b/internal/controller/configuration/suite_test.go index 04f11f25..05825b14 100644 --- a/internal/controller/configuration/suite_test.go +++ b/internal/controller/configuration/suite_test.go @@ -16,36 +16,32 @@ package configuration import ( "context" "fmt" - "io" - "net/http" + "os" + "os/exec" "path/filepath" "runtime" "testing" - "time" + . "github.com/mandelsoft/goutils/testutils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/openfluxcd/controller-manager/server" - "github.com/openfluxcd/controller-manager/storage" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/controller-runtime/pkg/envtest/komega" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - "sigs.k8s.io/yaml" - - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - ctrl "sigs.k8s.io/controller-runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" metricserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" cfgclient "github.com/open-component-model/ocm-k8s-toolkit/internal/controller/configuration/client" "github.com/open-component-model/ocm-k8s-toolkit/pkg/ocm" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/test" ) // +kubebuilder:scaffold:imports @@ -53,16 +49,14 @@ import ( // These tests use Ginkgo (BDD-style Go testing framework). Refer to // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. -const ( - ARTIFACT_SERVER = "localhost:0" -) - var cfg *rest.Config var k8sClient client.Client var k8sManager ctrl.Manager var testEnv *envtest.Environment -var strg *storage.Storage var recorder record.EventRecorder +var zotCmd *exec.Cmd +var registry *snapshot.Registry +var zotRootDir string func TestControllers(t *testing.T) { RegisterFailHandler(Fail) @@ -75,26 +69,10 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") - // Get external artifact CRD - resp, err := http.Get(v1alpha1.ArtifactCrd) - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() error { - return resp.Body.Close() - }) - - crdByte, err := io.ReadAll(resp.Body) - Expect(err).NotTo(HaveOccurred()) - - artifactCRD := &apiextensionsv1.CustomResourceDefinition{} - err = yaml.Unmarshal(crdByte, artifactCRD) - Expect(err).NotTo(HaveOccurred()) - testEnv = &envtest.Environment{ CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, ErrorIfCRDPathMissing: true, - CRDs: []*apiextensionsv1.CustomResourceDefinition{artifactCRD}, - // The BinaryAssetsDirectory is only required if you want to run the tests directly // without call the makefile target test. If not informed it will look for the // default path defined in controller-runtime which is /usr/local/kubebuilder/. @@ -104,6 +82,8 @@ var _ = BeforeSuite(func() { fmt.Sprintf("1.30.0-%s-%s", runtime.GOOS, runtime.GOARCH)), } + var err error + // cfg is defined in this file globally. cfg, err = testEnv.Start() Expect(err).NotTo(HaveOccurred()) @@ -111,7 +91,6 @@ var _ = BeforeSuite(func() { DeferCleanup(testEnv.Stop) Expect(v1alpha1.AddToScheme(scheme.Scheme)).Should(Succeed()) - Expect(artifactv1.AddToScheme(scheme.Scheme)).Should(Succeed()) Expect(err).NotTo(HaveOccurred()) @@ -130,37 +109,38 @@ var _ = BeforeSuite(func() { }) Expect(err).ToNot(HaveOccurred()) - tmpdir := GinkgoT().TempDir() - Expect(err).ToNot(HaveOccurred()) - address := ARTIFACT_SERVER - strg, err = server.NewStorage(k8sClient, testEnv.Scheme, tmpdir, address, 0, 0) - Expect(err).ToNot(HaveOccurred()) - artifactServer, err := server.NewArtifactServer(tmpdir, address, time.Millisecond) - Expect(err).ToNot(HaveOccurred()) - recorder = &record.FakeRecorder{ Events: make(chan string, 32), IncludeObject: true, } + // Setup zot registry and start it up + zotRootDir = Must(os.MkdirTemp("", "")) + DeferCleanup(func() { + Expect(os.RemoveAll(zotRootDir)).To(Succeed()) + }) + + zotCmd, registry = test.SetupRegistry(filepath.Join("..", "..", "..", "bin", "zot-registry"), zotRootDir, "0.0.0.0", "8083") + Expect((&Reconciler{ BaseReconciler: &ocm.BaseReconciler{ Client: k8sClient, Scheme: testEnv.Scheme, EventRecorder: recorder, }, - ConfigClient: cfgclient.NewClientWithLocalStorage(k8sClient, strg, scheme.Scheme), - Storage: strg, + ConfigClient: cfgclient.NewClientWithRegistry(k8sClient, registry, scheme.Scheme), + Registry: registry, }).SetupWithManager(k8sManager)).To(Succeed()) ctx, cancel := context.WithCancel(context.Background()) DeferCleanup(cancel) - go func() { - defer GinkgoRecover() - Expect(artifactServer.Start(ctx)).To(Succeed()) - }() go func() { defer GinkgoRecover() Expect(k8sManager.Start(ctx)).To(Succeed()) }() }) + +var _ = AfterSuite(func(ctx context.Context) { + err := zotCmd.Process.Kill() + Expect(err).NotTo(HaveOccurred(), "Failed to stop Zot registry") +}) diff --git a/internal/controller/configuration/types/configuration_reference.go b/internal/controller/configuration/types/configuration_reference.go index d6d46aab..42b5f8bb 100644 --- a/internal/controller/configuration/types/configuration_reference.go +++ b/internal/controller/configuration/types/configuration_reference.go @@ -1,6 +1,8 @@ package types -import "github.com/open-component-model/ocm-k8s-toolkit/pkg/artifact" +import ( + "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" +) // ConfigurationReference can be used both as a source (ConfigurationSource), // and as a target (ConfigurationTarget) for configuration. @@ -10,12 +12,12 @@ type ConfigurationReference interface { } // ConfigurationSource is a source of localization. -// It contains instructions on how to localize an artifact.Content. +// It contains instructions on how to localize an snapshot.Content. type ConfigurationSource interface { - artifact.Content + snapshot.Content } // ConfigurationTarget is a target for configuration. type ConfigurationTarget interface { - artifact.Content + snapshot.Content } diff --git a/internal/controller/localization/client/client.go b/internal/controller/localization/client/client.go index 40f53fdf..4d36f318 100644 --- a/internal/controller/localization/client/client.go +++ b/internal/controller/localization/client/client.go @@ -4,14 +4,13 @@ import ( "context" "fmt" - "github.com/openfluxcd/controller-manager/storage" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" "github.com/open-component-model/ocm-k8s-toolkit/internal/controller/localization/types" - artifactutil "github.com/open-component-model/ocm-k8s-toolkit/pkg/artifact" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" ) type Client interface { @@ -27,24 +26,24 @@ type Client interface { GetLocalizationConfig(ctx context.Context, ref v1alpha1.ConfigurationReference) (source types.LocalizationConfig, err error) } -func NewClientWithLocalStorage(r client.Reader, s *storage.Storage, scheme *runtime.Scheme) Client { +func NewClientWithRegistry(c client.Client, registry *snapshot.Registry, scheme *runtime.Scheme) Client { factory := serializer.NewCodecFactory(scheme) info, _ := runtime.SerializerInfoForMediaType(factory.SupportedMediaTypes(), runtime.ContentTypeYAML) encoder := factory.EncoderForVersion(info.Serializer, v1alpha1.GroupVersion) return &localStorageBackedClient{ - Reader: r, - Storage: s, - scheme: scheme, - encoder: encoder, + Client: c, + Registry: registry, + scheme: scheme, + encoder: encoder, } } type localStorageBackedClient struct { - client.Reader - *storage.Storage - scheme *runtime.Scheme - encoder runtime.Encoder + client.Client + Registry *snapshot.Registry + scheme *runtime.Scheme + encoder runtime.Encoder } func (clnt *localStorageBackedClient) Scheme() *runtime.Scheme { @@ -63,7 +62,7 @@ func (clnt *localStorageBackedClient) GetLocalizationTarget( case v1alpha1.KindLocalizedResource: fallthrough case v1alpha1.KindResource: - return artifactutil.GetContentBackedByArtifactFromComponent(ctx, clnt.Reader, clnt.Storage, &ref) + return snapshot.GetContentBackedBySnapshotFromComponent(ctx, clnt.Client, clnt.Registry, &ref) default: return nil, fmt.Errorf("unsupported localization target kind: %s", ref.Kind) } @@ -75,9 +74,9 @@ func (clnt *localStorageBackedClient) GetLocalizationConfig( ) (types.LocalizationConfig, error) { switch ref.Kind { case v1alpha1.KindResource: - return artifactutil.GetContentBackedByArtifactFromComponent(ctx, clnt.Reader, clnt.Storage, &ref) + return snapshot.GetContentBackedBySnapshotFromComponent(ctx, clnt.Client, clnt.Registry, &ref) case v1alpha1.KindLocalizationConfig: - return GetLocalizationConfigFromKubernetes(ctx, clnt.Reader, clnt.encoder, ref) + return GetLocalizationConfigFromKubernetes(ctx, clnt.Client, clnt.encoder, ref) default: return nil, fmt.Errorf("unsupported localization config kind: %s", ref.Kind) } @@ -99,5 +98,5 @@ func GetLocalizationConfigFromKubernetes(ctx context.Context, clnt client.Reader return nil, fmt.Errorf("failed to fetch localization config %s: %w", reference.Name, err) } - return &artifactutil.ObjectConfig{Object: &cfg, Encoder: encoder}, nil + return &snapshot.ObjectConfig{Object: &cfg, Encoder: encoder}, nil } diff --git a/internal/controller/localization/localization_controller.go b/internal/controller/localization/localization_controller.go index 1cd8c4f0..0967f38c 100644 --- a/internal/controller/localization/localization_controller.go +++ b/internal/controller/localization/localization_controller.go @@ -9,7 +9,6 @@ import ( "github.com/fluxcd/pkg/runtime/conditions" "github.com/fluxcd/pkg/runtime/patch" "github.com/google/go-containerregistry/pkg/name" - "github.com/openfluxcd/controller-manager/storage" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" "ocm.software/ocm/api/ocm/compdesc" @@ -23,8 +22,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sTypes "k8s.io/apimachinery/pkg/types" ocmctx "ocm.software/ocm/api/ocm" ocmmetav1 "ocm.software/ocm/api/ocm/compdesc/meta/v1" ctrl "sigs.k8s.io/controller-runtime" @@ -32,9 +31,9 @@ import ( "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" localizationclient "github.com/open-component-model/ocm-k8s-toolkit/internal/controller/localization/client" "github.com/open-component-model/ocm-k8s-toolkit/internal/controller/localization/types" - "github.com/open-component-model/ocm-k8s-toolkit/pkg/artifact" "github.com/open-component-model/ocm-k8s-toolkit/pkg/index" "github.com/open-component-model/ocm-k8s-toolkit/pkg/ocm" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" "github.com/open-component-model/ocm-k8s-toolkit/pkg/status" "github.com/open-component-model/ocm-k8s-toolkit/pkg/util" ) @@ -42,8 +41,8 @@ import ( // Reconciler reconciles a LocalizationRules object. type Reconciler struct { *ocm.BaseReconciler - *storage.Storage LocalizationClient localizationclient.Client + Registry snapshot.RegistryType } var _ ocm.Reconciler = (*Reconciler)(nil) @@ -84,6 +83,8 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, err error) { + logger := log.FromContext(ctx) + localization := &v1alpha1.LocalizedResource{} if err := r.Get(ctx, req.NamespacedName, localization); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) @@ -93,25 +94,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Re return ctrl.Result{}, nil } - if !localization.GetDeletionTimestamp().IsZero() { - // TODO: This is a temporary solution until a artifact-reconciler is written to handle the deletion of artifacts - if err := ocm.RemoveArtifactForCollectable(ctx, r.Client, r.Storage, localization); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to remove artifact: %w", err) - } - - if removed := controllerutil.RemoveFinalizer(localization, v1alpha1.ArtifactFinalizer); removed { - if err := r.Update(ctx, localization); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to remove finalizer: %w", err) - } - } - - return ctrl.Result{}, nil - } - - if added := controllerutil.AddFinalizer(localization, v1alpha1.ArtifactFinalizer); added { - if err := r.Update(ctx, localization); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to add finalizer: %w", err) - } + if localization.GetDeletionTimestamp() != nil { + logger.Info("localization is being deleted and cannot be used", "name", localization.Name) return ctrl.Result{Requeue: true}, nil } @@ -137,10 +121,6 @@ func (r *Reconciler) reconcileWithStatusUpdate(ctx context.Context, localization func (r *Reconciler) reconcileExists(ctx context.Context, localization *v1alpha1.LocalizedResource) (ctrl.Result, error) { logger := log.FromContext(ctx) - if err := r.Storage.ReconcileStorage(ctx, localization); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to reconcile storage: %w", err) - } - if localization.Spec.Target.Namespace == "" { localization.Spec.Target.Namespace = localization.Namespace } @@ -152,7 +132,7 @@ func (r *Reconciler) reconcileExists(ctx context.Context, localization *v1alpha1 return ctrl.Result{}, fmt.Errorf("failed to fetch target: %w", err) } - targetBackedByComponent, ok := target.(LocalizableArtifactContent) + targetBackedByComponent, ok := target.(LocalizableSnapshotContent) if !ok { err = fmt.Errorf("target is not backed by a component and cannot be localized") status.MarkNotReady(r.EventRecorder, localization, v1alpha1.TargetFetchFailedReason, err.Error()) @@ -171,7 +151,7 @@ func (r *Reconciler) reconcileExists(ctx context.Context, localization *v1alpha1 return ctrl.Result{}, fmt.Errorf("failed to fetch config: %w", err) } - rules, err := localizeRules(ctx, r.Client, r.Storage, targetBackedByComponent, cfg) + rules, err := localizeRules(ctx, r.Client, r.Registry, targetBackedByComponent, cfg) if err != nil { status.MarkNotReady(r.EventRecorder, localization, v1alpha1.LocalizationRuleGenerationFailedReason, err.Error()) @@ -251,38 +231,36 @@ func (r *Reconciler) reconcileExists(ctx context.Context, localization *v1alpha1 return ctrl.Result{}, fmt.Errorf("configured resource containing localization is not yet ready") } - art := &artifactv1.Artifact{} - if err := r.Get(ctx, client.ObjectKey{ - Namespace: configuredResource.GetNamespace(), - Name: configuredResource.Status.ArtifactRef.Name, - }, art); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to fetch artifact: %w", err) + snapshotCR := &v1alpha1.Snapshot{} + if err := r.Get(ctx, k8sTypes.NamespacedName{Namespace: configuredResource.GetNamespace(), Name: configuredResource.GetSnapshotName()}, snapshotCR); err != nil { + status.MarkNotReady(r.GetEventRecorder(), localization, v1alpha1.GetSnapshotFailedReason, err.Error()) + + return ctrl.Result{}, err } - artOp, err := controllerutil.CreateOrUpdate(ctx, r.Client, art, func() error { - if err := controllerutil.SetOwnerReference(localization, art, r.Scheme); err != nil { - return fmt.Errorf("failed to set indirect owner reference on artifact: %w", err) + snapshotOp, err := controllerutil.CreateOrUpdate(ctx, r.Client, snapshotCR, func() error { + if err := controllerutil.SetOwnerReference(localization, snapshotCR, r.Scheme); err != nil { + return fmt.Errorf("failed to set indirect owner reference on snapshot: %w", err) } - if art.GetAnnotations() == nil { - art.SetAnnotations(map[string]string{}) + if snapshotCR.GetAnnotations() == nil { + snapshotCR.SetAnnotations(map[string]string{}) } - a := art.GetAnnotations() - a["ocm.software/artifact-purpose"] = "localization" + a := snapshotCR.GetAnnotations() + a["ocm.software/snapshot-purpose"] = "localization" a["ocm.software/localization"] = fmt.Sprintf("%s/%s", localization.GetNamespace(), localization.GetName()) - art.SetAnnotations(a) + snapshotCR.SetAnnotations(a) return nil }) if err != nil { - status.MarkNotReady(r.EventRecorder, localization, v1alpha1.ReconcileArtifactFailedReason, err.Error()) + status.MarkNotReady(r.EventRecorder, localization, v1alpha1.CreateSnapshotFailedReason, err.Error()) return ctrl.Result{}, fmt.Errorf("failed to create or update artifact: %w", err) } - logger.V(1).Info(fmt.Sprintf("artifact %s", artOp)) + logger.V(1).Info(fmt.Sprintf("snapshot %s", snapshotOp)) - localization.Status.ArtifactRef = configuredResource.Status.ArtifactRef - localization.Status.Digest = configuredResource.Status.Digest + localization.Status.SnapshotRef = configuredResource.Status.SnapshotRef localization.Status.ConfiguredResourceRef = &v1alpha1.ObjectKey{ Name: configuredResource.GetName(), Namespace: configuredResource.GetNamespace(), @@ -296,8 +274,8 @@ func (r *Reconciler) reconcileExists(ctx context.Context, localization *v1alpha1 func localizeRules( ctx context.Context, c client.Client, - s *storage.Storage, - content LocalizableArtifactContent, + r snapshot.RegistryType, + content LocalizableSnapshotContent, cfg types.LocalizationConfig, ) ( []v1alpha1.ConfigurationRule, @@ -308,7 +286,7 @@ func localizeRules( return nil, fmt.Errorf("failed to parse localization config: %w", err) } - componentSet, componentDescriptor, err := ComponentDescriptorAndSetFromResource(ctx, c, s, content.GetComponent()) + componentSet, componentDescriptor, err := ComponentDescriptorAndSetFromResource(ctx, c, r, content.GetComponent()) if err != nil { return nil, fmt.Errorf("failed to get content descriptor and set: %w", err) } @@ -317,7 +295,7 @@ func localizeRules( localizedRules := make([]v1alpha1.ConfigurationRule, len(rules)) for i, rule := range rules { - // TODO Decide what the hell to do with GoTemplates + // TODO: Decide what the hell to do with GoTemplates if rule.GoTemplate != nil { localizedRules[i] = v1alpha1.ConfigurationRule{ GoTemplate: (*v1alpha1.ConfigurationRuleGoTemplate)(rule.GoTemplate), @@ -355,28 +333,35 @@ func localizeRules( return localizedRules, nil } -// LocalizableArtifactContent is an artifact content that is backed by a component and resource, allowing it +// LocalizableSnapshotContent is an artifact content that is backed by a component and resource, allowing it // to be localized (by resolving relative references from the resource & component into absolute values). -type LocalizableArtifactContent interface { - artifact.Content +type LocalizableSnapshotContent interface { + snapshot.Content GetComponent() *v1alpha1.Component GetResource() *v1alpha1.Resource } func ComponentDescriptorAndSetFromResource( ctx context.Context, - clnt client.Reader, - strg *storage.Storage, + reader client.Reader, + registry snapshot.RegistryType, baseComponent *v1alpha1.Component, ) (compdesc.ComponentVersionResolver, *compdesc.ComponentDescriptor, error) { - art, err := util.GetNamespaced[artifactv1.Artifact](ctx, clnt, baseComponent.Status.ArtifactRef, baseComponent.Namespace) + snapshotResource := &v1alpha1.Snapshot{} + if err := reader.Get(ctx, k8sTypes.NamespacedName{Namespace: baseComponent.GetNamespace(), Name: baseComponent.GetSnapshotName()}, snapshotResource); err != nil { + return nil, nil, fmt.Errorf("failed to get snapshot: %w", err) + } + + repository, err := registry.NewRepository(ctx, snapshotResource.Spec.Repository) if err != nil { - return nil, nil, fmt.Errorf("failed to Get artifact: %w", err) + return nil, nil, fmt.Errorf("failed to create repository: %w", err) } - componentSet, err := ocm.GetComponentSetForArtifact(strg, art) + + componentSet, err := ocm.GetComponentSetForSnapshot(ctx, repository, snapshotResource) if err != nil { - return nil, nil, fmt.Errorf("failed to Get component version set: %w", err) + return nil, nil, fmt.Errorf("failed to get component version set: %w", err) } + componentDescriptor, err := componentSet.LookupComponentVersion(baseComponent.Spec.Component, baseComponent.Status.Component.Version) if err != nil { return nil, nil, fmt.Errorf("failed to lookup component version: %w", err) diff --git a/internal/controller/localization/localization_controller_test.go b/internal/controller/localization/localization_controller_test.go index baa69bd8..a2c863a2 100644 --- a/internal/controller/localization/localization_controller_test.go +++ b/internal/controller/localization/localization_controller_test.go @@ -3,30 +3,26 @@ package localization import ( "bytes" "context" - "os" "path/filepath" "text/template" _ "embed" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - . "sigs.k8s.io/controller-runtime/pkg/envtest/komega" - + "github.com/fluxcd/pkg/runtime/conditions" "github.com/mandelsoft/vfs/pkg/memoryfs" "github.com/mandelsoft/vfs/pkg/osfs" "github.com/mandelsoft/vfs/pkg/projectionfs" "github.com/mandelsoft/vfs/pkg/vfs" - "k8s.io/apimachinery/pkg/runtime/serializer" - "ocm.software/ocm/api/utils/tarutils" - "sigs.k8s.io/controller-runtime/pkg/client" - - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" - corev1 "k8s.io/api/core/v1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/types" ocmbuilder "ocm.software/ocm/api/helper/builder" environment "ocm.software/ocm/api/helper/env" + "ocm.software/ocm/api/utils/tarutils" + "sigs.k8s.io/controller-runtime/pkg/client" "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" "github.com/open-component-model/ocm-k8s-toolkit/pkg/test" @@ -65,30 +61,19 @@ var _ = Describe("Localization Controller", func() { BeforeEach(func() { tmp = GinkgoT().TempDir() - testfs, err := projectionfs.New(osfs.New(), tmp) + testFs, err := projectionfs.New(osfs.New(), tmp) Expect(err).ToNot(HaveOccurred()) - env = ocmbuilder.NewBuilder(environment.FileSystem(testfs)) + env = ocmbuilder.NewBuilder(environment.FileSystem(testFs)) DeferCleanup(env.Cleanup) }) - BeforeEach(func(ctx SpecContext) { - By("creating namespace object") - namespace := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: Namespace, - }, - } - Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) - }) - - It("should localize an artifact from a resource based on a config supplied in a sibling resource", func(ctx SpecContext) { + It("should localize an snapshot from a resource based on a config supplied in a sibling resource", func(ctx SpecContext) { component := test.SetupComponentWithDescriptorList(ctx, ComponentObj, Namespace, descriptorListYAML, &test.MockComponentOptions{ - BasePath: tmp, - Strg: strg, + Registry: registry, Client: k8sClient, Recorder: recorder, Info: v1alpha1.ComponentInfo{ @@ -99,78 +84,67 @@ var _ = Describe("Localization Controller", func() { Repository: RepositoryObj, }, ) - DeferCleanup(func(ctx SpecContext) { - Expect(k8sClient.Delete(ctx, component, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) - }) - var err error targetResource = test.SetupMockResourceWithData(ctx, TargetResourceObj, Namespace, &test.MockResourceOptions{ - BasePath: tmp, DataPath: filepath.Join("testdata", "deployment-instruction-helm"), ComponentRef: v1alpha1.ObjectKey{ Namespace: Namespace, Name: ComponentObj, }, - Strg: strg, + Registry: registry, Clnt: k8sClient, Recorder: recorder, }, ) - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func(ctx SpecContext) { - Expect(k8sClient.Delete(ctx, targetResource, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) - }) + cfgResource = test.SetupMockResourceWithData(ctx, CfgResourceObj, Namespace, &test.MockResourceOptions{ - BasePath: tmp, - Data: bytes.NewReader(configYAML), + Data: bytes.NewReader(configYAML), ComponentRef: v1alpha1.ObjectKey{ Namespace: Namespace, Name: ComponentObj, }, - Strg: strg, + Registry: registry, Clnt: k8sClient, Recorder: recorder, }, ) - DeferCleanup(func(ctx SpecContext) { - Expect(k8sClient.Delete(ctx, cfgResource, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) - }) - localization := SetupLocalizedResource(ctx, map[string]string{ + localization := setupLocalizedResource(ctx, map[string]string{ "Namespace": Namespace, "Name": Localization, "TargetResourceName": targetResource.Name, "ConfigResourceName": cfgResource.Name, }) - DeferCleanup(func(ctx SpecContext) { - Expect(k8sClient.Delete(ctx, localization, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) - }) - Eventually(Object(localization), "15s").Should( - HaveField("Status.ArtifactRef.Name", Not(BeEmpty()))) + By("checking that the resource has been reconciled successfully") + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(localization), localization) + if err != nil { + return false + } + return conditions.IsReady(localization) && localization.GetSnapshotName() != "" + }, "15s").WithContext(ctx).Should(BeTrue()) - art := &artifactv1.Artifact{} - art.Name = localization.Status.ArtifactRef.Name - art.Namespace = localization.Namespace + snapshotLocalization := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: localization.GetNamespace(), Name: localization.GetSnapshotName()}, snapshotLocalization)).To(Succeed()) - Eventually(Object(art), "5s").Should(HaveField("Spec.URL", Not(BeEmpty()))) + // Marks snapshot as ready + conditions.MarkTrue(snapshotLocalization, "Ready", "ready", "message") + Expect(k8sClient.Status().Update(ctx, snapshotLocalization)).To(Succeed()) - localized := strg.LocalPath(art) - Expect(localized).To(BeAnExistingFile()) + repository, err := registry.NewRepository(ctx, snapshotLocalization.Spec.Repository) + Expect(err).ToNot(HaveOccurred()) + data, err := repository.FetchSnapshot(ctx, snapshotLocalization.GetDigest()) + Expect(err).ToNot(HaveOccurred()) memFs := vfs.New(memoryfs.New()) - localizedArchiveData, err := os.OpenFile(localized, os.O_RDONLY, 0o600) - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - Expect(localizedArchiveData.Close()).To(Succeed()) - }) - Expect(tarutils.UnzipTarToFs(memFs, localizedArchiveData)).To(Succeed()) + Expect(tarutils.UnzipTarToFs(memFs, bytes.NewReader(data))).To(Succeed()) valuesData, err := memFs.ReadFile("values.yaml") Expect(err).ToNot(HaveOccurred()) @@ -180,17 +154,39 @@ var _ = Describe("Localization Controller", func() { Expect(err).ToNot(HaveOccurred()) Expect(deploymentData).To(BeEquivalentTo(replacedDeploymentYAML)) + By("delete resources manually") + Expect(k8sClient.Delete(ctx, localization)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotLocalization)).To(Succeed()) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(localization), localization) + return errors.IsNotFound(err) + }, "15s").WithContext(ctx).Should(BeTrue()) + + Expect(k8sClient.Delete(ctx, cfgResource)).To(Succeed()) + snapshotCfgResource := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: cfgResource.GetNamespace(), Name: cfgResource.GetSnapshotName()}, snapshotCfgResource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotCfgResource)).To(Succeed()) + + Expect(k8sClient.Delete(ctx, targetResource)).To(Succeed()) + snapshotTargetResource := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: targetResource.GetNamespace(), Name: targetResource.GetSnapshotName()}, snapshotTargetResource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotTargetResource)).To(Succeed()) + + Expect(k8sClient.Delete(ctx, component)).To(Succeed()) + snapshotComponent := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: component.GetNamespace(), Name: component.GetSnapshotName()}, snapshotComponent)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotComponent)).To(Succeed()) }) }) -func SetupLocalizedResource(ctx context.Context, data map[string]string) *v1alpha1.LocalizedResource { +func setupLocalizedResource(ctx context.Context, data map[string]string) *v1alpha1.LocalizedResource { localizationTemplate, err := template.New("localization").Parse(localizationTemplateKustomizePatch) Expect(err).ToNot(HaveOccurred()) var ltpl bytes.Buffer Expect(localizationTemplate.ExecuteTemplate(<pl, "localization", data)).To(Succeed()) localization := &v1alpha1.LocalizedResource{} - serializer := serializer.NewCodecFactory(k8sClient.Scheme()).UniversalDeserializer() - _, _, err = serializer.Decode(ltpl.Bytes(), nil, localization) + serializerFactory := serializer.NewCodecFactory(k8sClient.Scheme()).UniversalDeserializer() + _, _, err = serializerFactory.Decode(ltpl.Bytes(), nil, localization) Expect(err).To(Not(HaveOccurred())) Expect(k8sClient.Create(ctx, localization)).To(Succeed()) return localization diff --git a/internal/controller/localization/suite_test.go b/internal/controller/localization/suite_test.go index 3e564fe9..070d6e7a 100644 --- a/internal/controller/localization/suite_test.go +++ b/internal/controller/localization/suite_test.go @@ -16,31 +16,27 @@ package localization import ( "context" "fmt" - "io" - "net/http" + "os" + "os/exec" "path/filepath" "runtime" "testing" - "time" + . "github.com/mandelsoft/goutils/testutils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/openfluxcd/controller-manager/server" - "github.com/openfluxcd/controller-manager/storage" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/controller-runtime/pkg/envtest/komega" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - "sigs.k8s.io/yaml" - - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - ctrl "sigs.k8s.io/controller-runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" metricserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" @@ -48,6 +44,8 @@ import ( cfgclient "github.com/open-component-model/ocm-k8s-toolkit/internal/controller/configuration/client" locclient "github.com/open-component-model/ocm-k8s-toolkit/internal/controller/localization/client" "github.com/open-component-model/ocm-k8s-toolkit/pkg/ocm" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/test" ) // +kubebuilder:scaffold:imports @@ -55,16 +53,16 @@ import ( // These tests use Ginkgo (BDD-style Go testing framework). Refer to // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. -const ( - ARTIFACT_SERVER = "localhost:0" -) - var cfg *rest.Config var k8sClient client.Client var k8sManager ctrl.Manager var testEnv *envtest.Environment -var strg *storage.Storage var recorder record.EventRecorder +var zotCmd *exec.Cmd +var registry *snapshot.Registry +var zotRootDir string +var ctx context.Context +var cancel context.CancelFunc func TestControllers(t *testing.T) { RegisterFailHandler(Fail) @@ -77,26 +75,10 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") - // Get external artifact CRD - resp, err := http.Get(v1alpha1.ArtifactCrd) - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() error { - return resp.Body.Close() - }) - - crdByte, err := io.ReadAll(resp.Body) - Expect(err).NotTo(HaveOccurred()) - - artifactCRD := &apiextensionsv1.CustomResourceDefinition{} - err = yaml.Unmarshal(crdByte, artifactCRD) - Expect(err).NotTo(HaveOccurred()) - testEnv = &envtest.Environment{ CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, ErrorIfCRDPathMissing: true, - CRDs: []*apiextensionsv1.CustomResourceDefinition{artifactCRD}, - // The BinaryAssetsDirectory is only required if you want to run the tests directly // without call the makefile target test. If not informed it will look for the // default path defined in controller-runtime which is /usr/local/kubebuilder/. @@ -107,13 +89,12 @@ var _ = BeforeSuite(func() { } // cfg is defined in this file globally. - cfg, err = testEnv.Start() + cfg, err := testEnv.Start() Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) DeferCleanup(testEnv.Stop) Expect(v1alpha1.AddToScheme(scheme.Scheme)).Should(Succeed()) - Expect(artifactv1.AddToScheme(scheme.Scheme)).Should(Succeed()) Expect(err).NotTo(HaveOccurred()) @@ -132,27 +113,40 @@ var _ = BeforeSuite(func() { }) Expect(err).ToNot(HaveOccurred()) - tmpdir := GinkgoT().TempDir() - Expect(err).ToNot(HaveOccurred()) - address := ARTIFACT_SERVER - strg, err = server.NewStorage(k8sClient, testEnv.Scheme, tmpdir, address, 0, 0) - Expect(err).ToNot(HaveOccurred()) - artifactServer, err := server.NewArtifactServer(tmpdir, address, time.Millisecond) - Expect(err).ToNot(HaveOccurred()) - recorder = &record.FakeRecorder{ Events: make(chan string, 32), IncludeObject: true, } + // Setup zot registry and start it up + zotRootDir = Must(os.MkdirTemp("", "")) + DeferCleanup(func() { + Expect(os.RemoveAll(zotRootDir)).To(Succeed()) + }) + + zotCmd, registry = test.SetupRegistry(filepath.Join("..", "..", "..", "bin", "zot-registry"), zotRootDir, "0.0.0.0", "8082") + + ctx, cancel = context.WithCancel(context.Background()) + DeferCleanup(cancel) + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: Namespace, + }, + } + Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, namespace, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + }) + Expect((&Reconciler{ BaseReconciler: &ocm.BaseReconciler{ Client: k8sClient, Scheme: testEnv.Scheme, EventRecorder: recorder, }, - LocalizationClient: locclient.NewClientWithLocalStorage(k8sClient, strg, scheme.Scheme), - Storage: strg, + LocalizationClient: locclient.NewClientWithRegistry(k8sClient, registry, scheme.Scheme), + Registry: registry, }).SetupWithManager(k8sManager)).To(Succeed()) Expect((&configuration.Reconciler{ @@ -161,18 +155,19 @@ var _ = BeforeSuite(func() { Scheme: testEnv.Scheme, EventRecorder: recorder, }, - ConfigClient: cfgclient.NewClientWithLocalStorage(k8sClient, strg, scheme.Scheme), - Storage: strg, + ConfigClient: cfgclient.NewClientWithRegistry(k8sClient, registry, scheme.Scheme), + Registry: registry, }).SetupWithManager(k8sManager)).To(Succeed()) ctx, cancel := context.WithCancel(context.Background()) DeferCleanup(cancel) - go func() { - defer GinkgoRecover() - Expect(artifactServer.Start(ctx)).To(Succeed()) - }() go func() { defer GinkgoRecover() Expect(k8sManager.Start(ctx)).To(Succeed()) }() }) + +var _ = AfterSuite(func(ctx context.Context) { + err := zotCmd.Process.Kill() + Expect(err).NotTo(HaveOccurred(), "Failed to stop Zot registry") +}) diff --git a/internal/controller/localization/types/localization_reference.go b/internal/controller/localization/types/localization_reference.go index 40253ad3..61764d19 100644 --- a/internal/controller/localization/types/localization_reference.go +++ b/internal/controller/localization/types/localization_reference.go @@ -1,6 +1,8 @@ package types -import "github.com/open-component-model/ocm-k8s-toolkit/pkg/artifact" +import ( + "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" +) // LocalizationReference can be used both as a source (LocalizationConfig), // and as a target (LocalizationTarget) for localization. @@ -9,12 +11,12 @@ type LocalizationReference interface { LocalizationTarget } -// LocalizationConfig is a configuration on how to localize an artifact.Content. +// LocalizationConfig is a configuration on how to localize an snapshot.Content. type LocalizationConfig interface { - artifact.Content + snapshot.Content } -// LocalizationTarget is a target artifact.Content for localization. +// LocalizationTarget is a target snapshot.Content for localization. type LocalizationTarget interface { - artifact.Content + snapshot.Content } diff --git a/internal/controller/ocmrepository/controller.go b/internal/controller/ocmrepository/controller.go index 68aca8d3..85dba704 100644 --- a/internal/controller/ocmrepository/controller.go +++ b/internal/controller/ocmrepository/controller.go @@ -96,7 +96,7 @@ func (r *Reconciler) reconcileExists(ctx context.Context, ocmRepo *v1alpha1.OCMR } if ocmRepo.Spec.Suspend { - logger.Info("component is suspended, skipping reconciliation") + logger.Info("OCMRepository is suspended, skipping reconciliation") return ctrl.Result{}, nil } diff --git a/internal/controller/replication/controller.go b/internal/controller/replication/controller.go index 244a01da..98b38522 100644 --- a/internal/controller/replication/controller.go +++ b/internal/controller/replication/controller.go @@ -97,7 +97,7 @@ func (r *Reconciler) reconcileExists(ctx context.Context, replication *v1alpha1. if replication.GetDeletionTimestamp() != nil { logger.Info("replication is being deleted and cannot be used", "name", replication.Name) - return ctrl.Result{}, nil + return ctrl.Result{Requeue: true}, nil } if replication.Spec.Suspend { diff --git a/internal/controller/resource/resource_controller.go b/internal/controller/resource/resource_controller.go index 81b28216..5aeb8971 100644 --- a/internal/controller/resource/resource_controller.go +++ b/internal/controller/resource/resource_controller.go @@ -21,17 +21,13 @@ import ( "encoding/json" "errors" "fmt" - "os" - "path/filepath" - "strings" "github.com/fluxcd/pkg/runtime/conditions" "github.com/fluxcd/pkg/runtime/patch" - "github.com/openfluxcd/controller-manager/storage" + "github.com/opencontainers/go-digest" "k8s.io/apimachinery/pkg/types" "ocm.software/ocm/api/datacontext" "ocm.software/ocm/api/ocm/compdesc" - "ocm.software/ocm/api/ocm/extensions/download" "ocm.software/ocm/api/ocm/resolvers" "ocm.software/ocm/api/ocm/selectors" "ocm.software/ocm/api/ocm/tools/signing" @@ -44,10 +40,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" ocmctx "ocm.software/ocm/api/ocm" v1 "ocm.software/ocm/api/ocm/compdesc/meta/v1" ctrl "sigs.k8s.io/controller-runtime" @@ -55,12 +49,13 @@ import ( "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" "github.com/open-component-model/ocm-k8s-toolkit/pkg/compression" "github.com/open-component-model/ocm-k8s-toolkit/pkg/ocm" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" "github.com/open-component-model/ocm-k8s-toolkit/pkg/status" ) type Reconciler struct { *ocm.BaseReconciler - Storage *storage.Storage + Registry snapshot.RegistryType } var _ ocm.Reconciler = (*Reconciler)(nil) @@ -81,8 +76,8 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.Resource{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). - // Watch for artifacts-events that are owned by the resource controller - Owns(&artifactv1.Artifact{}). + // Watch for snapshot-events that are owned by the resource controller + Owns(&v1alpha1.Snapshot{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). // Watch for component-events that are referenced by resources Watches( &v1alpha1.Component{}, @@ -116,11 +111,6 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { // +kubebuilder:rbac:groups=delivery.ocm.software,resources=resources,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=delivery.ocm.software,resources=resources/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=delivery.ocm.software,resources=resources/finalizers,verbs=update - -// +kubebuilder:rbac:groups=openfluxcd.ocm.software,resources=artifacts,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=openfluxcd.ocm.software,resources=artifacts/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=openfluxcd.ocm.software,resources=artifacts/finalizers,verbs=update func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { resource := &v1alpha1.Resource{} @@ -152,26 +142,8 @@ func (r *Reconciler) reconcileExists(ctx context.Context, resource *v1alpha1.Res return ctrl.Result{}, nil } - if !resource.GetDeletionTimestamp().IsZero() { - // TODO: This is a temporary solution until a artifact-reconciler is written to handle the deletion of artifacts - if err := ocm.RemoveArtifactForCollectable(ctx, r.Client, r.Storage, resource); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to remove artifact: %w", err) - } - - if removed := controllerutil.RemoveFinalizer(resource, v1alpha1.ArtifactFinalizer); removed { - if err := r.Update(ctx, resource); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to remove finalizer: %w", err) - } - } - - return ctrl.Result{}, nil - } - - if added := controllerutil.AddFinalizer(resource, v1alpha1.ArtifactFinalizer); added { - err := r.Update(ctx, resource) - if err != nil { - return ctrl.Result{}, fmt.Errorf("failed to add finalizer: %w", err) - } + if resource.GetDeletionTimestamp() != nil { + logger.Info("resource is being deleted and cannot be used", "name", resource.Name) return ctrl.Result{Requeue: true}, nil } @@ -223,6 +195,7 @@ func (r *Reconciler) reconcileOCM(ctx context.Context, resource *v1alpha1.Resour return result, nil } +//nolint:funlen,cyclop,maintidx // we do not want to cut function at an arbitrary point func (r *Reconciler) reconcileResource(ctx context.Context, octx ocmctx.Context, resource *v1alpha1.Resource, component *v1alpha1.Component) (ctrl.Result, error) { logger := log.FromContext(ctx) logger.V(1).Info("reconciling resource") @@ -237,6 +210,7 @@ func (r *Reconciler) reconcileResource(ctx context.Context, octx ocmctx.Context, return ctrl.Result{}, err } + err = ocm.ConfigureContext(ctx, octx, r.GetClient(), configs) if err != nil { status.MarkNotReady(r.GetEventRecorder(), resource, v1alpha1.ConfigureContextFailedReason, err.Error()) @@ -244,22 +218,32 @@ func (r *Reconciler) reconcileResource(ctx context.Context, octx ocmctx.Context, return ctrl.Result{}, err } - // Get artifact from component that contains component descriptor - artifactComponent := &artifactv1.Artifact{} - if err := r.Get(ctx, types.NamespacedName{ - // TODO: see https://github.com/open-component-model/ocm-project/issues/295 - Namespace: resource.GetNamespace(), - Name: component.Status.ArtifactRef.Name, - }, artifactComponent); err != nil { - status.MarkNotReady(r.EventRecorder, resource, v1alpha1.GetArtifactFailedReason, "Cannot get component artifact") + // Get snapshot from component that contains component descriptor + componentSnapshot := &v1alpha1.Snapshot{} + if err := r.Get(ctx, types.NamespacedName{Namespace: component.GetNamespace(), Name: component.GetSnapshotName()}, componentSnapshot); err != nil { + status.MarkNotReady(r.GetEventRecorder(), resource, v1alpha1.GetSnapshotFailedReason, err.Error()) - return ctrl.Result{}, fmt.Errorf("failed to get component artifact: %w", err) + return ctrl.Result{}, err + } + + if !conditions.IsReady(componentSnapshot) { + status.MarkNotReady(r.GetEventRecorder(), resource, v1alpha1.SnapshotReadyFailedReason, "snapshot not ready") + + return ctrl.Result{}, errors.New("snapshot not ready") + } + + // Create repository from registry for snapshot + repositoryCD, err := r.Registry.NewRepository(ctx, componentSnapshot.Spec.Repository) + if err != nil { + status.MarkNotReady(r.GetEventRecorder(), resource, v1alpha1.CreateOCIRepositoryFailedReason, err.Error()) + + return ctrl.Result{}, err } // Get component descriptor set from artifact - cdSet, err := ocm.GetComponentSetForArtifact(r.Storage, artifactComponent) + cdSet, err := ocm.GetComponentSetForSnapshot(ctx, repositoryCD, componentSnapshot) if err != nil { - status.MarkNotReady(r.EventRecorder, resource, v1alpha1.GetComponentForArtifactFailedReason, err.Error()) + status.MarkNotReady(r.EventRecorder, resource, v1alpha1.GetComponentForSnapshotFailedReason, err.Error()) return ctrl.Result{}, err } @@ -300,30 +284,135 @@ func (r *Reconciler) reconcileResource(ctx context.Context, octx ocmctx.Context, return ctrl.Result{}, err } - // revision is the digest of the resource. It is used to identify the resource in the storage (as filename) and to - // check if the resource is already present in the storage. - revision := resourceAccess.Meta().Digest.Value + if err := verifyResource(ctx, resourceAccess, cv, cd); err != nil { + status.MarkNotReady(r.EventRecorder, resource, v1alpha1.VerifyResourceFailedReason, err.Error()) - // Get the artifact to check if it is already present while reconciling it - artifactStorage := r.Storage.NewArtifactFor(resource.GetKind(), resource.GetObjectMeta(), "", "") - if err := r.Client.Get(ctx, types.NamespacedName{Name: artifactStorage.Name, Namespace: artifactStorage.Namespace}, artifactStorage); err != nil { - if !apierrors.IsNotFound(err) { - status.MarkNotReady(r.EventRecorder, resource, v1alpha1.GetArtifactFailedReason, err.Error()) + return ctrl.Result{}, err + } - return ctrl.Result{}, fmt.Errorf("failed to get artifactStorage: %w", err) - } + // TODO: + // Problem: The current implementation would download the resource every reconcile-loop. This could be + // expensive depending on the resource. + // Additionally, there could be two different components that contain the same resource. Also in this case + // we would re-download the resource, even though it is already in the OCI registry. + // To circumvent this problem, we could use the digest provided by the resource-access (prior to the resource + // download) as the repository-name. Then, we could check if such a repository for the resource already + // exists. + // If so, we cannot not return immediately because we need to create a manifest-file to point to the already + // present resource-layer. Otherwise the GC would delete the resource-layer if the previously present manifest + // would be deleted. + + // The digest from the resource access is used, so it can be used to compare resource with the same name/identity + // on a digest-level. + repositoryResourceName := resourceAccess.Meta().Digest.Value + repositoryResource, err := r.Registry.NewRepository(ctx, repositoryResourceName) + if err != nil { + status.MarkNotReady(r.GetEventRecorder(), resource, v1alpha1.CreateOCIRepositoryFailedReason, err.Error()) + + return ctrl.Result{}, err } - err = reconcileArtifact(ctx, octx, r.Storage, resource, resourceAccess, revision, artifactStorage, func() error { return verifyResource(ctx, resourceAccess, cv, cd) }) + var ( + manifestDigest digest.Digest + blobSize int64 + ) + + // If the resource is of type 'ociArtifact' or its access type is 'ociArtifact', the resource will be copied to the + // internal OCI registry + logger.Info("create snapshot for resource", "name", resource.GetName(), "type", resourceAccess.Meta().GetType()) + resourceAccessSpec, err := resourceAccess.Access() if err != nil { - status.MarkNotReady(r.EventRecorder, resource, v1alpha1.ReconcileArtifactFailedReason, err.Error()) + status.MarkNotReady(r.EventRecorder, resource, v1alpha1.GetResourceAccessFailedReason, err.Error()) + + return ctrl.Result{}, err + } + + if resourceAccessSpec == nil { + status.MarkNotReady(r.EventRecorder, resource, v1alpha1.GetResourceAccessFailedReason, "access spec is nil") + + return ctrl.Result{}, err + } + + if resourceAccessSpec.GetType() == "ociArtifact" { + manifestDigest, err = repositoryResource.CopyOCIArtifactForResourceAccess(ctx, resourceAccess) + if err != nil { + status.MarkNotReady(r.EventRecorder, resource, v1alpha1.PushSnapshotFailedReason, err.Error()) + + return ctrl.Result{}, err + } + + // TODO: How to get the blob size, without downloading the resource? + // Do we need the blob-size, when we copy the resource either way? + // We could use the size stored in the manifest. + blobSize = 0 + } else { + // Get resource content + // No need to close the blob access as it will be closed automatically + blobAccess, err := getBlobAccess(ctx, resourceAccess) + if err != nil { + status.MarkNotReady(r.EventRecorder, resource, v1alpha1.GetBlobAccessFailedReason, err.Error()) + + return ctrl.Result{}, fmt.Errorf("failed to get blob access: %w", err) + } + + resourceContent, err := blobAccess.Get() + if err != nil { + status.MarkNotReady(r.EventRecorder, resource, v1alpha1.GetResourceFailedReason, err.Error()) + + return ctrl.Result{}, fmt.Errorf("failed to get blob: %w", err) + } + + // Compress content to gzip if it is not already compressed to avoid to large blobs + resourceContentCompressed, err := compression.AutoCompressAsGzip(ctx, resourceContent) + if err != nil { + status.MarkNotReady(r.EventRecorder, resource, v1alpha1.CompressGzipFailedReason, err.Error()) + + return ctrl.Result{}, fmt.Errorf("failed to auto compress data: %w", err) + } + + manifestDigest, err = repositoryResource.PushSnapshot(ctx, resourceAccess.Meta().GetVersion(), resourceContentCompressed) + if err != nil { + status.MarkNotReady(r.GetEventRecorder(), resource, v1alpha1.PushSnapshotFailedReason, err.Error()) + + return ctrl.Result{}, fmt.Errorf("failed to push snapshot: %w", err) + } + + blobSize = int64(len(resourceContentCompressed)) + } + + // Create respective snapshot CR + snapshotCR := snapshot.Create( + resource, + repositoryResourceName, + manifestDigest.String(), + &v1alpha1.BlobInfo{ + Digest: resourceAccess.Meta().Digest.Value, + Tag: resourceAccess.Meta().GetVersion(), + Size: blobSize, + }, + ) + + if _, err = controllerutil.CreateOrUpdate(ctx, r.GetClient(), snapshotCR, func() error { + if snapshotCR.ObjectMeta.CreationTimestamp.IsZero() { + if err := controllerutil.SetControllerReference(resource, snapshotCR, r.GetScheme()); err != nil { + return fmt.Errorf("failed to set controller reference: %w", err) + } + } + + resource.Status.SnapshotRef = corev1.LocalObjectReference{ + Name: snapshotCR.GetName(), + } + + return nil + }); err != nil { + status.MarkNotReady(r.EventRecorder, resource, v1alpha1.CreateSnapshotFailedReason, err.Error()) return ctrl.Result{}, err } // Update status if err = setResourceStatus(ctx, configs, resource, resourceAccess); err != nil { - status.MarkNotReady(r.EventRecorder, component, v1alpha1.StatusSetFailedReason, err.Error()) + status.MarkNotReady(r.EventRecorder, resource, v1alpha1.StatusSetFailedReason, err.Error()) return ctrl.Result{}, fmt.Errorf("failed to set resource status: %w", err) } @@ -437,123 +526,6 @@ func verifyResource(ctx context.Context, access ocmctx.ResourceAccess, cv ocmctx return nil } -// downloadResource downloads the resource from the resource access. -func downloadResource(ctx context.Context, octx ocmctx.Context, targetDir string, resource *v1alpha1.Resource, acc ocmctx.ResourceAccess, bAcc blobaccess.BlobAccess, -) (string, error) { - log.FromContext(ctx).V(1).Info("download resource") - - // Using a redirected resource acc to prevent redundant download - accessMock, err := ocm.NewRedirectedResourceAccess(acc, bAcc) - if err != nil { - return "", fmt.Errorf("failed to create redirected resource acc: %w", err) - } - - path, err := download.DownloadResource(octx, accessMock, filepath.Join(targetDir, resource.Name)) - if err != nil { - return "", fmt.Errorf("failed to download resource: %w", err) - } - - return path, nil -} - -// reconcileArtifact will download, verify, and reconcile the artifact in the storage if it is not already present in the storage. -// TODO: https://github.com/open-component-model/ocm-project/issues/297 -func reconcileArtifact( - ctx context.Context, - octx ocmctx.Context, - storage *storage.Storage, - resource *v1alpha1.Resource, - acc ocmctx.ResourceAccess, - revision string, - artifact *artifactv1.Artifact, - verifyFunc func() error, -) (retErr error) { - log.FromContext(ctx).V(1).Info("reconcile artifact") - - // Check if the artifact is already present and located in the storage - localPath := storage.LocalPath(artifact) - - // use the filename which is the revision as the artifact name - artifactPresent := storage.ArtifactExist(artifact) && strings.Split(filepath.Base(localPath), ".")[0] == revision - - // Init variables with default values in case the artifact is present - // If the artifact is present, the dirPath will be the directory of the local path to the directory - dirPath := filepath.Dir(localPath) - // If the artifact is already present, we do not want to archive it again - archiveFunc := func(_ *artifactv1.Artifact, _ string) error { - return nil - } - - // If the artifact is not present, we will verify and download the resource and provide it as artifact - //nolint:nestif // this is our main logic and we rather keep it in here - if !artifactPresent { - // No need to close the blob access as it will be closed automatically - bAcc, err := getBlobAccess(ctx, acc) - if err != nil { - return err - } - - // Check if resource can be verified - if err := verifyFunc(); err != nil { - return err - } - - // Target directory in which the resource is downloaded - tmp, err := os.MkdirTemp("", "resource-*") - if err != nil { - return fmt.Errorf("failed to create temporary directory: %w", err) - } - defer func() { - retErr = errors.Join(retErr, os.RemoveAll(tmp)) - }() - - path, err := downloadResource(ctx, octx, tmp, resource, acc, bAcc) - if err != nil { - return err - } - - // Since the artifact is not already present, an archive function is added to archive the downloaded resource in the storage - archiveFunc = func(art *artifactv1.Artifact, _ string) error { - logger := log.FromContext(ctx).WithValues("artifact", art.Name, "revision", revision, "path", path) - fi, err := os.Stat(path) - if err != nil { - return fmt.Errorf("failed to get file info: %w", err) - } - if fi.IsDir() { - logger.V(1).Info("archiving directory") - // Archive directory to storage - if err := storage.Archive(art, path, nil); err != nil { - return fmt.Errorf("failed to archive: %w", err) - } - } else { - if err := compression.AutoCompressAsGzipAndArchiveFile(ctx, art, storage, path); err != nil { - return fmt.Errorf("failed to auto compress and archive file: %w", err) - } - } - - resource.Status.ArtifactRef = corev1.LocalObjectReference{ - Name: art.Name, - } - - return nil - } - - // Overwrite the default dirPath with the temporary directory path that points to the downloaded resource - dirPath = tmp - } - - if err := storage.ReconcileStorage(ctx, resource); err != nil { - return fmt.Errorf("failed to reconcile resource storage: %w", err) - } - - // Provide artifact in storage - if err := storage.ReconcileArtifact(ctx, resource, revision, dirPath, revision, archiveFunc); err != nil { - return fmt.Errorf("failed to reconcile resource artifact: %w", err) - } - - return nil -} - // setResourceStatus updates the resource status with the all required information. func setResourceStatus(ctx context.Context, configs []v1alpha1.OCMConfiguration, resource *v1alpha1.Resource, resourceAccess ocmctx.ResourceAccess) error { log.FromContext(ctx).V(1).Info("updating resource status") diff --git a/internal/controller/resource/resource_controller_test.go b/internal/controller/resource/resource_controller_test.go index c93e7ba0..d9b10f40 100644 --- a/internal/controller/resource/resource_controller_test.go +++ b/internal/controller/resource/resource_controller_test.go @@ -17,43 +17,49 @@ limitations under the License. package resource import ( + "bytes" + "compress/gzip" "context" + _ "embed" "fmt" "io" - "net/http" "os" - "time" + "github.com/fluxcd/pkg/runtime/conditions" . "github.com/mandelsoft/goutils/testutils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" . "ocm.software/ocm/api/helper/builder" + "ocm.software/ocm/api/ocm/extensions/accessmethods/ociartifact" + "ocm.software/ocm/api/utils/mime" + "sigs.k8s.io/controller-runtime/pkg/envtest/komega" - "github.com/containers/image/v5/pkg/compression" - "github.com/fluxcd/pkg/runtime/conditions" - "github.com/mandelsoft/filepath/pkg/filepath" "github.com/mandelsoft/vfs/pkg/osfs" "github.com/mandelsoft/vfs/pkg/vfs" "ocm.software/ocm/api/ocm/extensions/artifacttypes" "ocm.software/ocm/api/ocm/extensions/repositories/ctf" "ocm.software/ocm/api/utils/accessio" - "ocm.software/ocm/api/utils/accessobj" - "ocm.software/ocm/api/utils/mime" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/envtest/komega" + + "ocm.software/ocm/api/utils/accessobj" "sigs.k8s.io/yaml" - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8smetav1 "k8s.io/apimachinery/pkg/apis/meta/v1" environment "ocm.software/ocm/api/helper/env" v1 "ocm.software/ocm/api/ocm/compdesc/meta/v1" "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" - "github.com/open-component-model/ocm-k8s-toolkit/pkg/ocm" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/compression" + ocmPkg "github.com/open-component-model/ocm-k8s-toolkit/pkg/ocm" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/test" ) +var () + const ( CTFPath = "ocm-k8s-ctfstore--*" Namespace = "test-namespace" @@ -63,180 +69,378 @@ const ( ComponentVersion = "1.0.0" ResourceObj = "test-resource" ResourceVersion = "1.0.0" - ResourceContent = "resource content" + ResourceContent = "some important content" ) var _ = Describe("Resource Controller", func() { var ( - ctx context.Context - cancel context.CancelFunc - env *Builder - ctfPath string + env *Builder + resourceLocalPath string + testNumber int ) + BeforeEach(func() { - ctfPath = Must(os.MkdirTemp("", CTFPath)) + resourceLocalPath = Must(os.MkdirTemp("", CTFPath)) DeferCleanup(func() error { - return os.RemoveAll(ctfPath) + return os.RemoveAll(resourceLocalPath) }) env = NewBuilder(environment.FileSystem(osfs.OsFs)) DeferCleanup(env.Cleanup) - - ctx, cancel = context.WithCancel(context.Background()) - DeferCleanup(cancel) + testNumber++ }) Context("resource controller", func() { - It("can reconcile a resource", func() { - By("creating namespace object") - namespace := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: Namespace, - }, - } - Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) + It("can reconcile a resource: PlainText", func() { + testComponent := fmt.Sprintf("%s-%d", ComponentObj, testNumber) + testResource := fmt.Sprintf("%s-%d", ResourceObj, testNumber) + resourceType := artifacttypes.PLAIN_TEXT + + By("creating an ocm resource from a plain text") + env.OCMCommonTransport(resourceLocalPath, accessio.FormatDirectory, func() { + env.Component(Component, func() { + env.Version(ComponentVersion, func() { + env.Resource(testResource, ResourceVersion, resourceType, v1.LocalRelation, func() { + env.BlobData(mime.MIME_TEXT, []byte(ResourceContent)) + }) + }) + }) + }) - By("preparing a mock component") - prepareComponent(ctx, env, ctfPath) + repo, err := ctf.Open(env, accessobj.ACC_WRITABLE, resourceLocalPath, vfs.FileMode(vfs.O_RDWR), env) + Expect(err).NotTo(HaveOccurred()) + cv, err := repo.LookupComponentVersion(Component, ComponentVersion) + Expect(err).NotTo(HaveOccurred()) + cd, err := ocmPkg.ListComponentDescriptors(ctx, cv, repo) + Expect(err).NotTo(HaveOccurred()) + dataCds, err := yaml.Marshal(cd) + Expect(err).NotTo(HaveOccurred()) + + spec, err := ctf.NewRepositorySpec(ctf.ACC_READONLY, resourceLocalPath) + specData, err := spec.MarshalJSON() + + By("creating a mocked component") + component := test.SetupComponentWithDescriptorList(ctx, testComponent, Namespace, dataCds, &test.MockComponentOptions{ + Registry: registry, + Client: k8sClient, + Recorder: recorder, + Info: v1alpha1.ComponentInfo{ + Component: Component, + Version: ComponentVersion, + RepositorySpec: &apiextensionsv1.JSON{Raw: specData}, + }, + Repository: RepositoryObj, + }) By("creating a resource object") resource := &v1alpha1.Resource{ - ObjectMeta: metav1.ObjectMeta{ + ObjectMeta: k8smetav1.ObjectMeta{ Namespace: Namespace, - Name: ResourceObj, + Name: testResource, }, Spec: v1alpha1.ResourceSpec{ ComponentRef: corev1.LocalObjectReference{ - Name: ComponentObj, + Name: testComponent, }, Resource: v1alpha1.ResourceID{ ByReference: v1alpha1.ResourceReference{ - Resource: v1.NewIdentity(ResourceObj), + Resource: v1.NewIdentity(testResource), }, }, - Interval: metav1.Duration{Duration: time.Minute * 5}, }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) - DeferCleanup(func(ctx SpecContext) { - Expect(k8sClient.Delete(ctx, resource, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) - }) By("checking that the resource has been reconciled successfully") - Eventually(komega.Object(resource), "5m").Should( - HaveField("Status.ObservedGeneration", Equal(int64(1)))) - Expect(resource).To(HaveField("Status.ArtifactRef.Name", Not(BeEmpty()))) - Expect(resource).To(HaveField("Status.Resource.Name", Equal(ResourceObj))) - Expect(resource).To(HaveField("Status.Resource.Type", Equal(artifacttypes.PLAIN_TEXT))) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(resource), resource) + if err != nil { + return false + } + return conditions.IsReady(resource) + }, "15s").WithContext(ctx).Should(BeTrue()) + + By("checking that the snapshot has been created successfully") + Eventually(komega.Object(resource), "15s").Should( + HaveField("Status.SnapshotRef.Name", Not(BeEmpty()))) + snapshotResource := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: resource.GetNamespace(), Name: resource.GetSnapshotName()}, snapshotResource)).To(Succeed()) + + Expect(resource).To(HaveField("Status.Resource.Name", Equal(testResource))) + Expect(resource).To(HaveField("Status.Resource.Type", Equal(resourceType))) Expect(resource).To(HaveField("Status.Resource.Version", Equal(ResourceVersion))) - By("checking that the artifact has been created successfully") - artifact := &artifactv1.Artifact{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: resource.Namespace, - Name: resource.Status.ArtifactRef.Name, + By("checking that the snapshot contains the correct content") + snapshotRepository, err := registry.NewRepository(ctx, snapshotResource.Spec.Repository) + Expect(err).NotTo(HaveOccurred()) + snapshotResourceContentCompressed, err := snapshotRepository.FetchSnapshot(ctx, snapshotResource.GetDigest()) + Expect(err).NotTo(HaveOccurred()) + gzipReader, err := gzip.NewReader(bytes.NewReader(snapshotResourceContentCompressed)) + Expect(err).NotTo(HaveOccurred()) + snapshotResourceContent, err := io.ReadAll(gzipReader) + Expect(err).NotTo(HaveOccurred()) + Expect(string(snapshotResourceContent)).To(Equal(ResourceContent)) + + // Compare other fields + resourceAcc, err := cv.GetResource(v1.NewIdentity(testResource)) + Expect(err).NotTo(HaveOccurred()) + + Expect(snapshotResource.Name).To(Equal(fmt.Sprintf("resource-%s", testResource))) + Expect(snapshotResource.Spec.Blob.Digest).To(Equal(resourceAcc.Meta().Digest.Value)) + Expect(snapshotResource.Spec.Blob.Tag).To(Equal(ResourceVersion)) + Expect(snapshotResource.Spec.Blob.Size).To(Equal(int64(len(snapshotResourceContentCompressed)))) + + By("delete resources manually") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotResource)).To(Succeed()) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(resource), resource) + return errors.IsNotFound(err) + }, "15s").WithContext(ctx).Should(BeTrue()) + + Expect(k8sClient.Delete(ctx, component)).To(Succeed()) + snapshotComponent := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: component.GetNamespace(), Name: component.GetSnapshotName()}, snapshotComponent)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotComponent)).To(Succeed()) + }) + + It("can reconcile a resource: Compressed PlainText", func() { + testComponent := fmt.Sprintf("%s-%d", ComponentObj, testNumber) + testResource := fmt.Sprintf("%s-%d", ResourceObj, testNumber) + resourceType := artifacttypes.PLAIN_TEXT + + // The resource controller will only gzip-compress the content, if it is not already compressed. Thus, we + // expect the content to be only compressed once. + resourceContentCompressed, err := compression.AutoCompressAsGzip(ctx, []byte(ResourceContent)) + Expect(err).NotTo(HaveOccurred()) + + By("creating an ocm resource from a plain text") + env.OCMCommonTransport(resourceLocalPath, accessio.FormatDirectory, func() { + env.Component(Component, func() { + env.Version(ComponentVersion, func() { + env.Resource(testResource, ResourceVersion, resourceType, v1.LocalRelation, func() { + env.BlobData(mime.MIME_TEXT, resourceContentCompressed) + }) + }) + }) + }) + + repo, err := ctf.Open(env, accessobj.ACC_WRITABLE, resourceLocalPath, vfs.FileMode(vfs.O_RDWR), env) + Expect(err).NotTo(HaveOccurred()) + cv, err := repo.LookupComponentVersion(Component, ComponentVersion) + Expect(err).NotTo(HaveOccurred()) + cd, err := ocmPkg.ListComponentDescriptors(ctx, cv, repo) + Expect(err).NotTo(HaveOccurred()) + dataCds, err := yaml.Marshal(cd) + Expect(err).NotTo(HaveOccurred()) + + spec, err := ctf.NewRepositorySpec(ctf.ACC_READONLY, resourceLocalPath) + specData, err := spec.MarshalJSON() + + By("creating a mocked component") + component := test.SetupComponentWithDescriptorList(ctx, testComponent, Namespace, dataCds, &test.MockComponentOptions{ + Registry: registry, + Client: k8sClient, + Recorder: recorder, + Info: v1alpha1.ComponentInfo{ + Component: Component, + Version: ComponentVersion, + RepositorySpec: &apiextensionsv1.JSON{Raw: specData}, + }, + Repository: RepositoryObj, + }) + + By("creating a resource object") + resource := &v1alpha1.Resource{ + ObjectMeta: k8smetav1.ObjectMeta{ + Namespace: Namespace, + Name: testResource, + }, + Spec: v1alpha1.ResourceSpec{ + ComponentRef: corev1.LocalObjectReference{ + Name: testComponent, + }, + Resource: v1alpha1.ResourceID{ + ByReference: v1alpha1.ResourceReference{ + Resource: v1.NewIdentity(testResource), + }, + }, }, } - Eventually(komega.Get(artifact)).Should(Succeed()) + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) - By("checking that the artifact server provides the resource") - r := Must(http.Get(artifact.Spec.URL)) - Expect(r).Should(HaveHTTPStatus(http.StatusOK)) + By("checking that the resource has been reconciled successfully") + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(resource), resource) + if err != nil { + return false + } + return conditions.IsReady(resource) + }, "15s").WithContext(ctx).Should(BeTrue()) - By("checking that the resource content is correct") - reader, decompressed, err := compression.AutoDecompress(r.Body) - Expect(decompressed).To(BeTrue()) - DeferCleanup(func() { - Expect(reader.Close()).To(Succeed()) - }) - Expect(err).To(BeNil()) - resourceContent := Must(io.ReadAll(reader)) + By("checking that the snapshot has been created successfully") + Eventually(komega.Object(resource), "15s").Should( + HaveField("Status.SnapshotRef.Name", Not(BeEmpty()))) + snapshotResource := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: resource.GetNamespace(), Name: resource.GetSnapshotName()}, snapshotResource)).To(Succeed()) + + Expect(resource).To(HaveField("Status.Resource.Name", Equal(testResource))) + Expect(resource).To(HaveField("Status.Resource.Type", Equal(resourceType))) + Expect(resource).To(HaveField("Status.Resource.Version", Equal(ResourceVersion))) + + By("checking that the snapshot contains the correct content") + snapshotRepository, err := registry.NewRepository(ctx, snapshotResource.Spec.Repository) + Expect(err).NotTo(HaveOccurred()) + snapshotResourceContentCompressed, err := snapshotRepository.FetchSnapshot(ctx, snapshotResource.GetDigest()) + Expect(err).NotTo(HaveOccurred()) + gzipReader, err := gzip.NewReader(bytes.NewReader(snapshotResourceContentCompressed)) + Expect(err).NotTo(HaveOccurred()) + snapshotResourceContent, err := io.ReadAll(gzipReader) + Expect(err).NotTo(HaveOccurred()) + Expect(string(snapshotResourceContent)).To(Equal(ResourceContent)) - Expect(string(resourceContent)).To(Equal(ResourceContent)) + // Compare other fields + resourceAcc, err := cv.GetResource(v1.NewIdentity(testResource)) + Expect(err).NotTo(HaveOccurred()) + + Expect(snapshotResource.Name).To(Equal(fmt.Sprintf("resource-%s", testResource))) + Expect(snapshotResource.Spec.Blob.Digest).To(Equal(resourceAcc.Meta().Digest.Value)) + Expect(snapshotResource.Spec.Blob.Tag).To(Equal(ResourceVersion)) + Expect(snapshotResource.Spec.Blob.Size).To(Equal(int64(len(snapshotResourceContentCompressed)))) + + By("delete resources manually") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotResource)).To(Succeed()) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(resource), resource) + return errors.IsNotFound(err) + }, "15s").WithContext(ctx).Should(BeTrue()) + + Expect(k8sClient.Delete(ctx, component)).To(Succeed()) + snapshotComponent := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: component.GetNamespace(), Name: component.GetSnapshotName()}, snapshotComponent)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotComponent)).To(Succeed()) }) - }) -}) -// prepareComponent essentially mocks the behavior of the component reconciler to provider the necessary component and -// artifact for the resource controller. -func prepareComponent(ctx context.Context, env *Builder, ctfPath string) { - By("creating ocm repositories with a component and resource") - env.OCMCommonTransport(ctfPath, accessio.FormatDirectory, func() { - env.Component(Component, func() { - env.Version(ComponentVersion, func() { - env.Resource(ResourceObj, ResourceVersion, artifacttypes.PLAIN_TEXT, v1.LocalRelation, func() { - env.BlobData(mime.MIME_TEXT, []byte(ResourceContent)) + It("can reconcile a resource: OCIArtifact", func() { + testComponent := fmt.Sprintf("%s-%d", ComponentObj, testNumber) + testResource := fmt.Sprintf("%s-%d", ResourceObj, testNumber) + resourceType := artifacttypes.OCI_ARTIFACT + + By("creating an OCI artifact") + repository, err := registry.NewRepository(ctx, testResource) + Expect(err).NotTo(HaveOccurred()) + manifestDigest, err := repository.PushSnapshot(ctx, ResourceVersion, []byte(ResourceContent)) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func(ctx SpecContext) { + Expect(repository.DeleteSnapshot(ctx, manifestDigest.String())).To(Succeed()) + }) + + By("creating an ocm resource from an OCI artifact") + env.OCMCommonTransport(resourceLocalPath, accessio.FormatDirectory, func() { + env.Component(Component, func() { + env.Version(ComponentVersion, func() { + env.Resource(testResource, ResourceVersion, resourceType, v1.LocalRelation, func() { + env.Access(ociartifact.New(fmt.Sprintf("http://%s/%s:%s", repository.GetHost(), repository.GetName(), ResourceVersion))) + }) + }) }) }) - }) - }) - By("creating a component descriptor") - tmpDirCd := Must(os.MkdirTemp("/tmp", "descriptors-")) - DeferCleanup(func() error { - return os.RemoveAll(tmpDirCd) - }) - repo := Must(ctf.Open(env, accessobj.ACC_WRITABLE, ctfPath, vfs.FileMode(vfs.O_RDWR), env)) - cv := Must(repo.LookupComponentVersion(Component, ComponentVersion)) - cd := Must(ocm.ListComponentDescriptors(ctx, cv, repo)) - dataCds := Must(yaml.Marshal(cd)) - MustBeSuccessful(os.WriteFile(filepath.Join(tmpDirCd, v1alpha1.OCMComponentDescriptorList), dataCds, 0o655)) - - By("creating a component object") - component := &v1alpha1.Component{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: Namespace, - Name: ComponentObj, - }, - Spec: v1alpha1.ComponentSpec{ - RepositoryRef: v1alpha1.ObjectKey{ - Namespace: Namespace, - Name: RepositoryObj, - }, - Component: Component, - Semver: ComponentVersion, - Interval: metav1.Duration{Duration: time.Minute * 10}, - }, - } - Expect(k8sClient.Create(ctx, component)).To(Succeed()) - - By("creating an component artifact") - revision := ComponentObj + "-" + ComponentVersion - var artifactName string - Expect(globStorage.ReconcileArtifact(ctx, component, revision, tmpDirCd, revision+".tar.gz", - func(art *artifactv1.Artifact, _ string) error { - // Archive directory to storage - if err := globStorage.Archive(art, tmpDirCd, nil); err != nil { - return fmt.Errorf("unable to archive artifact to storage: %w", err) + repo, err := ctf.Open(env, accessobj.ACC_WRITABLE, resourceLocalPath, vfs.FileMode(vfs.O_RDWR), env) + Expect(err).NotTo(HaveOccurred()) + cv, err := repo.LookupComponentVersion(Component, ComponentVersion) + Expect(err).NotTo(HaveOccurred()) + cd, err := ocmPkg.ListComponentDescriptors(ctx, cv, repo) + Expect(err).NotTo(HaveOccurred()) + dataCds, err := yaml.Marshal(cd) + Expect(err).NotTo(HaveOccurred()) + + spec, err := ctf.NewRepositorySpec(ctf.ACC_READONLY, resourceLocalPath) + specData, err := spec.MarshalJSON() + Expect(err).NotTo(HaveOccurred()) + + By("creating a mocked component") + component := test.SetupComponentWithDescriptorList(ctx, testComponent, Namespace, dataCds, &test.MockComponentOptions{ + Registry: registry, + Client: k8sClient, + Recorder: recorder, + Info: v1alpha1.ComponentInfo{ + Component: Component, + Version: ComponentVersion, + RepositorySpec: &apiextensionsv1.JSON{Raw: specData}, + }, + Repository: RepositoryObj, + }) + + By("creating a resource object") + resource := &v1alpha1.Resource{ + ObjectMeta: k8smetav1.ObjectMeta{ + Namespace: Namespace, + Name: testResource, + }, + Spec: v1alpha1.ResourceSpec{ + ComponentRef: corev1.LocalObjectReference{ + Name: testComponent, + }, + Resource: v1alpha1.ResourceID{ + ByReference: v1alpha1.ResourceReference{ + Resource: v1.NewIdentity(testResource), + }, + }, + }, } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) - artifactName = art.Name - - return nil - }, - )).To(Succeed()) - - By("checking that the artifact has been created successfully") - artifact := &artifactv1.Artifact{ - ObjectMeta: metav1.ObjectMeta{ - Name: artifactName, - Namespace: Namespace, - }, - } - Eventually(komega.Get(artifact)).Should(Succeed()) - - By("updating the component object with the respective status") - baseComponent := component.DeepCopy() - ready := *conditions.TrueCondition("Ready", "ready", "message") - ready.LastTransitionTime = metav1.Time{Time: time.Now()} - baseComponent.Status.Conditions = []metav1.Condition{ready} - baseComponent.Status.ArtifactRef = corev1.LocalObjectReference{Name: artifact.ObjectMeta.Name} - spec := Must(ctf.NewRepositorySpec(ctf.ACC_READONLY, ctfPath)) - specData := Must(spec.MarshalJSON()) - baseComponent.Status.Component = v1alpha1.ComponentInfo{ - RepositorySpec: &apiextensionsv1.JSON{Raw: specData}, - Component: Component, - Version: ComponentVersion, - } - Expect(k8sClient.Status().Update(ctx, baseComponent)).To(Succeed()) -} + By("checking that the resource has been reconciled successfully") + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(resource), resource) + if err != nil { + return false + } + return conditions.IsReady(resource) + }, "15s").WithContext(ctx).Should(BeTrue()) + + By("checking that the snapshot has been created successfully") + Eventually(komega.Object(resource), "15s").Should( + HaveField("Status.SnapshotRef.Name", Not(BeEmpty()))) + snapshotResource := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: resource.GetNamespace(), Name: resource.GetSnapshotName()}, snapshotResource)).To(Succeed()) + + Expect(resource).To(HaveField("Status.SnapshotRef.Name", Not(BeEmpty()))) + Expect(resource).To(HaveField("Status.Resource.Name", Equal(testResource))) + Expect(resource).To(HaveField("Status.Resource.Type", Equal(resourceType))) + Expect(resource).To(HaveField("Status.Resource.Version", Equal(ResourceVersion))) + + By("checking that the snapshot contains the correct content") + snapshotRepository, err := registry.NewRepository(ctx, snapshotResource.Spec.Repository) + Expect(err).NotTo(HaveOccurred()) + snapshotResourceContent, err := snapshotRepository.FetchSnapshot(ctx, snapshotResource.GetDigest()) + Expect(err).NotTo(HaveOccurred()) + Expect(string(snapshotResourceContent)).To(Equal(ResourceContent)) + + // Compare other fields + resourceAcc, err := cv.GetResource(v1.NewIdentity(testResource)) + Expect(err).NotTo(HaveOccurred()) + + Expect(snapshotResource.Name).To(Equal(fmt.Sprintf("resource-%s", testResource))) + Expect(snapshotResource.Spec.Blob.Digest).To(Equal(resourceAcc.Meta().Digest.Value)) + Expect(snapshotResource.Spec.Blob.Tag).To(Equal(ResourceVersion)) + Expect(snapshotResource.Spec.Blob.Size).To(Equal(int64(0))) + + By("delete resources manually") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, snapshotResource)).To(Succeed()) + Eventually(func(ctx context.Context) bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(resource), resource) + return errors.IsNotFound(err) + }, "15s").WithContext(ctx).Should(BeTrue()) + + Expect(k8sClient.Delete(ctx, component)).To(Succeed()) + snapshotComponent := &v1alpha1.Snapshot{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: component.GetNamespace(), Name: component.GetSnapshotName()}, snapshotComponent)).To(Succeed()) + }) + + // TODO: Add more testcases + }) +}) diff --git a/internal/controller/resource/suite_test.go b/internal/controller/resource/suite_test.go index c5f9a2a4..daa447fb 100644 --- a/internal/controller/resource/suite_test.go +++ b/internal/controller/resource/suite_test.go @@ -16,37 +16,32 @@ package resource import ( "context" "fmt" - "io" - "net/http" "os" + "os/exec" "path/filepath" "runtime" "testing" - "time" . "github.com/mandelsoft/goutils/testutils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - - "github.com/openfluxcd/controller-manager/server" - "github.com/openfluxcd/controller-manager/storage" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/controller-runtime/pkg/envtest/komega" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - "sigs.k8s.io/yaml" - - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - ctrl "sigs.k8s.io/controller-runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" metricserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" "github.com/open-component-model/ocm-k8s-toolkit/pkg/ocm" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/test" ) // +kubebuilder:scaffold:imports @@ -54,16 +49,16 @@ import ( // These tests use Ginkgo (BDD-style Go testing framework). Refer to // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. -const ( - ARTIFACT_PATH = "ocm-k8s-artifactstore--*" - ARTIFACT_SERVER = "localhost:8081" -) - var cfg *rest.Config var k8sClient client.Client var k8sManager ctrl.Manager var testEnv *envtest.Environment -var globStorage *storage.Storage +var recorder record.EventRecorder +var zotCmd *exec.Cmd +var registry *snapshot.Registry +var zotRootDir string +var ctx context.Context +var cancel context.CancelFunc func TestControllers(t *testing.T) { RegisterFailHandler(Fail) @@ -76,28 +71,12 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") - // Get external artifact CRD - resp, err := http.Get(v1alpha1.ArtifactCrd) - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() error { - return resp.Body.Close() - }) - - crdByte, err := io.ReadAll(resp.Body) - Expect(err).NotTo(HaveOccurred()) - - artifactCRD := &apiextensionsv1.CustomResourceDefinition{} - err = yaml.Unmarshal(crdByte, artifactCRD) - Expect(err).NotTo(HaveOccurred()) - testEnv = &envtest.Environment{ CRDDirectoryPaths: []string{ filepath.Join("..", "..", "..", "config", "crd", "bases"), }, ErrorIfCRDPathMissing: true, - CRDs: []*apiextensionsv1.CustomResourceDefinition{artifactCRD}, - // The BinaryAssetsDirectory is only required if you want to run the tests directly // without call the makefile target test. If not informed it will look for the // default path defined in controller-runtime which is /usr/local/kubebuilder/. @@ -108,13 +87,12 @@ var _ = BeforeSuite(func() { } // cfg is defined in this file globally. - cfg, err = testEnv.Start() + cfg, err := testEnv.Start() Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) DeferCleanup(testEnv.Stop) Expect(v1alpha1.AddToScheme(scheme.Scheme)).Should(Succeed()) - Expect(artifactv1.AddToScheme(scheme.Scheme)).Should(Succeed()) Expect(err).NotTo(HaveOccurred()) // +kubebuilder:scaffold:scheme @@ -132,32 +110,51 @@ var _ = BeforeSuite(func() { }) Expect(err).ToNot(HaveOccurred()) - tmpdir := Must(os.MkdirTemp("", ARTIFACT_PATH)) - address := ARTIFACT_SERVER - globStorage = Must(server.NewStorage(k8sClient, testEnv.Scheme, tmpdir, address, 0, 0)) - artifactServer := Must(server.NewArtifactServer(tmpdir, address, time.Millisecond)) + recorder = &record.FakeRecorder{ + Events: make(chan string, 32), + IncludeObject: true, + } + + // Setup zot registry and start it up + zotRootDir = Must(os.MkdirTemp("", "")) + DeferCleanup(func() { + Expect(os.RemoveAll(zotRootDir)).To(Succeed()) + }) + + zotCmd, registry = test.SetupRegistry(filepath.Join("..", "..", "..", "bin", "zot-registry"), zotRootDir, "0.0.0.0", "8081") + + ctx, cancel = context.WithCancel(context.Background()) + DeferCleanup(cancel) Expect((&Reconciler{ BaseReconciler: &ocm.BaseReconciler{ - Client: k8sClient, - Scheme: testEnv.Scheme, - EventRecorder: &record.FakeRecorder{ - Events: make(chan string, 32), - IncludeObject: true, - }, + Client: k8sClient, + Scheme: testEnv.Scheme, + EventRecorder: recorder, }, - Storage: globStorage, + Registry: registry, }).SetupWithManager(k8sManager)).To(Succeed()) - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel = context.WithCancel(context.Background()) DeferCleanup(cancel) - go func() { - defer GinkgoRecover() - Expect(artifactServer.Start(ctx)).To(Succeed()) - }() + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: Namespace, + }, + } + Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, namespace, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + }) + go func() { defer GinkgoRecover() Expect(k8sManager.Start(ctx)).To(Succeed()) }() }) + +var _ = AfterSuite(func(ctx context.Context) { + err := zotCmd.Process.Kill() + Expect(err).NotTo(HaveOccurred(), "Failed to stop Zot registry") +}) diff --git a/internal/controller/snapshot/controller.go b/internal/controller/snapshot/controller.go new file mode 100644 index 00000000..bc061510 --- /dev/null +++ b/internal/controller/snapshot/controller.go @@ -0,0 +1,138 @@ +package snapshot + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/fluxcd/pkg/runtime/patch" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + ctrl "sigs.k8s.io/controller-runtime" + + deliveryv1alpha1 "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/ocm" + snapshotRegistry "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/status" +) + +const ( + // TODO: Decide on requeue timer as this is arbitrary. + requeueTimer = 10 * time.Minute +) + +// Reconciler reconciles a Snapshot object. +type Reconciler struct { + *ocm.BaseReconciler + Registry snapshotRegistry.RegistryType +} + +// +kubebuilder:rbac:groups=delivery.ocm.software,resources=snapshots,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=delivery.ocm.software,resources=snapshots/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=delivery.ocm.software,resources=snapshots/finalizers,verbs=update + +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + // Watch for snapshot resources + return ctrl.NewControllerManagedBy(mgr). + For(&deliveryv1alpha1.Snapshot{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Complete(r) +} + +// Reconcile add a finalizer on creation to the snapshot resource and handles the deletion of the snapshot by deleting +// the manifest of the OCI artifact in the OCI registry (The OCI registry GC deletes the blobs if no manifest is +// pointing to it). +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, retErr error) { + logger := log.FromContext(ctx) + logger.Info("Reconciling Snapshot") + + snapshotResource := &deliveryv1alpha1.Snapshot{} + if err := r.Get(ctx, req.NamespacedName, snapshotResource); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if snapshotResource.Spec.Suspend { + return ctrl.Result{}, nil + } + + patchHelper := patch.NewSerialPatcher(snapshotResource, r.Client) + // Always attempt to patch the object and status after each reconciliation. + defer func() { + if err := status.UpdateStatus(ctx, patchHelper, snapshotResource, r.EventRecorder, requeueTimer, retErr); err != nil { + retErr = errors.Join(retErr, err) + } + }() + + //nolint:nestif // Only complex for the linter + if !snapshotResource.GetDeletionTimestamp().IsZero() { + logger.Info("Deleting snapshot") + + repository, err := r.Registry.NewRepository(ctx, snapshotResource.Spec.Repository) + if err != nil { + status.MarkNotReady(r.EventRecorder, snapshotResource, deliveryv1alpha1.CreateOCIRepositoryFailedReason, err.Error()) + + return ctrl.Result{}, fmt.Errorf("failed to create a repository: %w", err) + } + + exists, err := repository.ExistsSnapshot(ctx, snapshotResource.GetDigest()) + if err != nil { + status.MarkNotReady(r.EventRecorder, snapshotResource, deliveryv1alpha1.OCIRepositoryExistsFailedReason, err.Error()) + + return ctrl.Result{}, fmt.Errorf("failed to check if snapshot exists: %w", err) + } + + if exists { + if err := repository.DeleteSnapshot(ctx, snapshotResource.GetDigest()); err != nil { + status.MarkNotReady(r.EventRecorder, snapshotResource, deliveryv1alpha1.DeleteSnapshotFailedReason, err.Error()) + + return ctrl.Result{}, fmt.Errorf("failed to delete snapshot: %w", err) + } + } + + if removed := controllerutil.RemoveFinalizer(snapshotResource, deliveryv1alpha1.SnapshotFinalizer); removed { + if err := r.Update(ctx, snapshotResource); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to remove finalizer: %w", err) + } + } + + return ctrl.Result{Requeue: true}, nil + } + + if added := controllerutil.AddFinalizer(snapshotResource, deliveryv1alpha1.SnapshotFinalizer); added { + err := r.Update(ctx, snapshotResource) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to add finalizer: %w", err) + } + + return ctrl.Result{Requeue: true}, nil + } + + // Verify that snapshot actually exists. + repository, err := r.Registry.NewRepository(ctx, snapshotResource.Spec.Repository) + if err != nil { + status.MarkNotReady(r.EventRecorder, snapshotResource, deliveryv1alpha1.CreateOCIRepositoryFailedReason, err.Error()) + + return ctrl.Result{}, fmt.Errorf("failed to create a repository: %w", err) + } + + exists, err := repository.ExistsSnapshot(ctx, snapshotResource.GetDigest()) + if err != nil { + status.MarkNotReady(r.EventRecorder, snapshotResource, deliveryv1alpha1.OCIRepositoryExistsFailedReason, err.Error()) + + return ctrl.Result{}, fmt.Errorf("failed to check existence of OCI repository: %w", err) + } + + if !exists { + status.MarkNotReady(r.EventRecorder, snapshotResource, deliveryv1alpha1.OCIRepositoryExistsFailedReason, "OCI repository does not exist") + + return ctrl.Result{}, fmt.Errorf("OCI repository does not exist") + } + + status.MarkReady(r.EventRecorder, snapshotResource, "snapshot and OCI repository exist") + + return ctrl.Result{RequeueAfter: requeueTimer}, nil +} diff --git a/internal/controller/snapshot/controller_test.go b/internal/controller/snapshot/controller_test.go new file mode 100644 index 00000000..bdd82221 --- /dev/null +++ b/internal/controller/snapshot/controller_test.go @@ -0,0 +1,81 @@ +package snapshot + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + deliveryv1alpha1 "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/ocm" +) + +// TODO: Create tests +var _ = Describe("Snapshot Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", // TODO(user):Modify as needed + } + snapshot := &deliveryv1alpha1.Snapshot{} + + BeforeEach(func() { + By("creating the custom resource for the Kind Snapshot") + err := k8sClient.Get(ctx, typeNamespacedName, snapshot) + if err != nil && errors.IsNotFound(err) { + resource := &deliveryv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: deliveryv1alpha1.SnapshotSpec{ + Repository: "test-repository", + Digest: "sha256:test-digest", + Blob: &deliveryv1alpha1.BlobInfo{ + Digest: "sha256:test-digest", + Tag: "1.0.0", + Size: 0, + }, + Suspend: false, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + // TODO(user): Cleanup logic after each test, like removing the resource instance. + resource := &deliveryv1alpha1.Snapshot{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance Snapshot") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &Reconciler{ + BaseReconciler: &ocm.BaseReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + }, + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. + // Example: If you expect a certain status condition after reconciliation, verify it here. + }) + }) +}) diff --git a/internal/controller/snapshot/suite_test.go b/internal/controller/snapshot/suite_test.go new file mode 100644 index 00000000..a13a44d8 --- /dev/null +++ b/internal/controller/snapshot/suite_test.go @@ -0,0 +1,81 @@ +package snapshot + +import ( + "context" + "fmt" + "path/filepath" + "runtime" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + deliveryv1alpha1 "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" + // +kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var cfg *rest.Config +var k8sClient client.Client +var testEnv *envtest.Environment +var ctx context.Context +var cancel context.CancelFunc + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "..", "config", "crd", "bases"), + }, + ErrorIfCRDPathMissing: true, + + // The BinaryAssetsDirectory is only required if you want to run the tests directly + // without call the makefile target test. If not informed it will look for the + // default path defined in controller-runtime which is /usr/local/kubebuilder/. + // Note that you must have the required binaries setup under the bin directory to perform + // the tests directly. When we run make test it will be setup and used automatically. + BinaryAssetsDirectory: filepath.Join("..", "..", "..", "bin", "k8s", + fmt.Sprintf("1.30.0-%s-%s", runtime.GOOS, runtime.GOARCH)), + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + err = deliveryv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/pkg/compression/util.go b/pkg/compression/util.go index 82cbef16..3daf6efe 100644 --- a/pkg/compression/util.go +++ b/pkg/compression/util.go @@ -1,40 +1,36 @@ package compression import ( + "archive/tar" "bytes" + "compress/gzip" "context" "errors" "fmt" "io" "os" + "path/filepath" "github.com/containers/image/v5/pkg/compression" "sigs.k8s.io/controller-runtime/pkg/log" - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" + "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" ) -type WriterToStorageFromArtifact interface { - Copy(art *artifactv1.Artifact, reader io.Reader) error +type WriterToStorageFromSnapshot interface { + Copy(snapshot *v1alpha1.Snapshot, reader io.Reader) error } -// AutoCompressAsGzipAndArchiveFile compresses the file if it is not already compressed and archives it in the storage. -// If the file is already compressed as gzip, it will be archived as is. -// If the file is not compressed or not in gzip format, it will be attempting to recompress to gzip and then archive. +// AutoCompressAsGzip compresses the content if it is not already compressed and returns it. +// If the file is already compressed as gzip, it will be returned as is. +// If the file is not compressed or not in gzip format, it will be attempting to recompress to gzip and then return. // This is because some source controllers such as kustomize expect this compression format in their artifacts. -func AutoCompressAsGzipAndArchiveFile(ctx context.Context, art *artifactv1.Artifact, storage WriterToStorageFromArtifact, path string) (retErr error) { - logger := log.FromContext(ctx).WithValues("artifact", art.Name, "path", path) - file, err := os.OpenFile(path, os.O_RDONLY, 0o400) - if err != nil { - return fmt.Errorf("failed to open file: %w", err) - } - defer func() { - retErr = errors.Join(retErr, file.Close()) - }() +func AutoCompressAsGzip(ctx context.Context, data []byte) (_ []byte, retErr error) { + logger := log.FromContext(ctx) - algo, decompressor, reader, err := compression.DetectCompressionFormat(file) + algo, decompressor, reader, err := compression.DetectCompressionFormat(bytes.NewReader(data)) if err != nil { - return fmt.Errorf("failed to detect compression format: %w", err) + return nil, fmt.Errorf("failed to detect compression format: %w", err) } // If the file is @@ -46,7 +42,7 @@ func AutoCompressAsGzipAndArchiveFile(ctx context.Context, art *artifactv1.Artif if decompressor != nil { decompressed, err := decompressor(reader) if err != nil { - return fmt.Errorf("failed to decompress: %w", err) + return nil, fmt.Errorf("failed to decompress: %w", err) } defer func() { retErr = errors.Join(retErr, decompressed.Close()) @@ -54,25 +50,17 @@ func AutoCompressAsGzipAndArchiveFile(ctx context.Context, art *artifactv1.Artif reader = decompressed } - logger.V(1).Info("archiving file, but detected file is not compressed or not in gzip format, recompressing and archiving") - // TODO: this loads the single file into memory which can be expensive, but orchestrating an io.Pipe here is not trivial + logger.V(1).Info("gzip-compress data") var buf bytes.Buffer if err := compressViaBuffer(&buf, reader); err != nil { - return err + return nil, err } - if err := storage.Copy(art, &buf); err != nil { - return fmt.Errorf("failed to copy: %w", err) - } - - return nil - } - logger.V(1).Info("archiving already compressed file from path") - if err := storage.Copy(art, reader); err != nil { - return fmt.Errorf("failed to copy file: %w", err) + return buf.Bytes(), nil } - return nil + // Return as is, if already compressed in gzip format + return data, nil } func compressViaBuffer(buf *bytes.Buffer, reader io.Reader) (retErr error) { @@ -83,9 +71,116 @@ func compressViaBuffer(buf *bytes.Buffer, reader io.Reader) (retErr error) { defer func() { retErr = errors.Join(retErr, compressToBuf.Close()) }() + if _, err := io.Copy(compressToBuf, reader); err != nil { return fmt.Errorf("failed to copy: %w", err) } return nil } + +func ExtractDataFromTGZ(data []byte) (_ []byte, retErr error) { + buf := bytes.NewReader(data) + + gzipReader, err := gzip.NewReader(buf) + if err != nil { + return nil, fmt.Errorf("failed to create gzip reader: %w", err) + } + defer func() { + retErr = gzipReader.Close() + }() + + tarReader := tar.NewReader(gzipReader) + + _, err = tarReader.Next() + if err != nil { + return nil, fmt.Errorf("failed to read tar header: %w", err) + } + + var extractedData bytes.Buffer + //nolint:gosec // TODO: Decision needed + if _, err := io.Copy(&extractedData, tarReader); err != nil { + return nil, fmt.Errorf("failed to extract file content: %w", err) + } + + return extractedData.Bytes(), nil +} + +func CreateTGZFromPath(srcDir string) (_ []byte, retErr error) { + var buf bytes.Buffer + + // Create a gzip writer + gzipWriter := gzip.NewWriter(&buf) + defer func() { + retErr = gzipWriter.Close() + }() + + // Create a tar writer + tarWriter := tar.NewWriter(gzipWriter) + defer func() { + retErr = tarWriter.Close() + }() + + // Walk through the source directory + if err := filepath.Walk(srcDir, func(file string, fileInfo os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("could not read file %s: %w", file, err) + } + + if !fileInfo.Mode().IsRegular() { + return nil + } + + if fileInfo.IsDir() { + return nil + } + + // Create tar fileHeader + fileHeader, err := tar.FileInfoHeader(fileInfo, fileInfo.Name()) + if err != nil { + return fmt.Errorf("could not create tar fileHeader: %w", err) + } + + // Use relative path for fileHeader.Name to preserve folder structure + relPath, err := filepath.Rel(srcDir, file) + if err != nil { + return fmt.Errorf("could not create relative path: %w", err) + } + fileHeader.Name = relPath + + // Write fileHeader + err = tarWriter.WriteHeader(fileHeader) + if err != nil { + return fmt.Errorf("could not write tar fileHeader: %w", err) + } + + // Open the file + f, err := os.Open(file) + if err != nil { + return fmt.Errorf("could not open file %s: %w", file, err) + } + defer func() { + err = f.Close() + }() + + // Copy file data into the tar archive + _, err = io.Copy(tarWriter, f) + if err != nil { + return fmt.Errorf("could not copy file %s: %w", file, err) + } + + return nil + }); err != nil { + return nil, fmt.Errorf("could not walk directory %s: %w", srcDir, err) + } + + if err := tarWriter.Close(); err != nil { + return nil, fmt.Errorf("could not close tar writer: %w", err) + } + + if err := gzipWriter.Close(); err != nil { + return nil, fmt.Errorf("could not close gzip writer: %w", err) + } + + return buf.Bytes(), nil +} diff --git a/pkg/compression/util_test.go b/pkg/compression/util_test.go index 384e09f8..634c7fcc 100644 --- a/pkg/compression/util_test.go +++ b/pkg/compression/util_test.go @@ -5,28 +5,24 @@ import ( "compress/gzip" "context" "io" - "os" - "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/ulikunitz/xz" contcompression "github.com/containers/image/v5/pkg/compression" - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" + "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" "github.com/open-component-model/ocm-k8s-toolkit/pkg/compression" ) -const testfile = "testfile" - var testData = []byte("test") type MockStorage struct { data *bytes.Buffer } -func (m *MockStorage) Copy(_ *artifactv1.Artifact, reader io.Reader) error { +func (m *MockStorage) Copy(_ *v1alpha1.Snapshot, reader io.Reader) error { m.data = new(bytes.Buffer) _, err := io.Copy(m.data, reader) return err @@ -36,49 +32,43 @@ func (m *MockStorage) GetData() *bytes.Buffer { return m.data } -var _ compression.WriterToStorageFromArtifact = &MockStorage{} +var _ compression.WriterToStorageFromSnapshot = &MockStorage{} func TestAutoCompressAndArchiveFile(t *testing.T) { tests := []struct { name string - setup func(t *testing.T) (string, *MockStorage) - validate func(t *testing.T, storage *MockStorage) + setup func(t *testing.T) []byte + validate func(t *testing.T, data []byte) }{ { name: "Uncompressed", - setup: func(t *testing.T) (string, *MockStorage) { - path := filepath.Join(t.TempDir(), testfile) - assert.NoError(t, os.WriteFile(path, testData, 0o644)) - return path, &MockStorage{} + setup: func(t *testing.T) []byte { + return testData }, validate: validateAutoCompressedAsGzip, }, { name: "Precompressed_Gzip", - setup: func(t *testing.T) (string, *MockStorage) { - path := filepath.Join(t.TempDir(), testfile) + setup: func(t *testing.T) []byte { var buf bytes.Buffer compress := gzip.NewWriter(&buf) _, err := io.Copy(compress, bytes.NewReader(testData)) assert.NoError(t, compress.Close()) assert.NoError(t, err) - assert.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644)) - return path, &MockStorage{} + return buf.Bytes() }, validate: validateAutoCompressedAsGzip, }, { name: "Precompressed_Nongzip_Xz", - setup: func(t *testing.T) (string, *MockStorage) { - path := filepath.Join(t.TempDir(), testfile) + setup: func(t *testing.T) []byte { var buf bytes.Buffer compress, err := xz.NewWriter(&buf) assert.NoError(t, err) _, err = io.Copy(compress, bytes.NewReader(testData)) assert.NoError(t, compress.Close()) assert.NoError(t, err) - assert.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644)) - return path, &MockStorage{} + return buf.Bytes() }, validate: validateAutoCompressedAsGzip, }, @@ -86,18 +76,17 @@ func TestAutoCompressAndArchiveFile(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - path, storage := tt.setup(t) - art := &artifactv1.Artifact{} - err := compression.AutoCompressAsGzipAndArchiveFile(context.Background(), art, storage, path) + data := tt.setup(t) + dataCompressed, err := compression.AutoCompressAsGzip(context.Background(), data) assert.NoError(t, err) - tt.validate(t, storage) + tt.validate(t, dataCompressed) }) } } -func validateAutoCompressedAsGzip(t *testing.T, storage *MockStorage) { +func validateAutoCompressedAsGzip(t *testing.T, input []byte) { t.Helper() - algo, decompress, reader, err := contcompression.DetectCompressionFormat(storage.GetData()) + algo, decompress, reader, err := contcompression.DetectCompressionFormat(bytes.NewReader(input)) assert.NoError(t, err) assert.Equal(t, contcompression.Gzip.Name(), algo.Name()) decompressed, err := decompress(reader) diff --git a/pkg/ocm/artifact.go b/pkg/ocm/artifact.go deleted file mode 100644 index 48a85a64..00000000 --- a/pkg/ocm/artifact.go +++ /dev/null @@ -1,138 +0,0 @@ -package ocm - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - - "github.com/openfluxcd/controller-manager/storage" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/yaml" - "ocm.software/ocm/api/ocm/compdesc" - - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" - ctrl "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" -) - -// GetComponentSetForArtifact returns the component descriptor set for the given artifact. -func GetComponentSetForArtifact(storage *storage.Storage, artifact *artifactv1.Artifact) (_ *compdesc.ComponentVersionSet, retErr error) { - tmp, err := os.MkdirTemp("", "component-*") - if err != nil { - return nil, fmt.Errorf("failed to create temporary directory: %w", err) - } - defer func() { - retErr = errors.Join(retErr, os.RemoveAll(tmp)) - }() - - // Instead of using the http-functionality of the storage-server, we use the storage directly for performance reasons. - // This assumes that the controllers and the storage are running in the same pod. - unlock, err := storage.Lock(artifact) - if err != nil { - return nil, fmt.Errorf("failed to lock artifact: %w", err) - } - defer unlock() - - filePath := filepath.Join(tmp, v1alpha1.OCMComponentDescriptorList) - - if err := storage.CopyToPath(artifact, v1alpha1.OCMComponentDescriptorList, filePath); err != nil { - return nil, fmt.Errorf("failed to copy artifact to path: %w", err) - } - - // Read component descriptor list - file, err := os.Open(filePath) - if err != nil { - return nil, fmt.Errorf("failed to open component descriptor: %w", err) - } - defer func() { - retErr = errors.Join(retErr, file.Close()) - }() - - // Get component descriptor set - cds := &Descriptors{} - - if err := yaml.NewYAMLToJSONDecoder(file).Decode(cds); err != nil { - return nil, fmt.Errorf("failed to unmarshal component descriptors: %w", err) - } - - return compdesc.NewComponentVersionSet(cds.List...), nil -} - -// GetAndVerifyArtifactForCollectable gets the artifact for the given collectable and verifies it against the given strg. -// If the artifact is not found, an error is returned. -func GetAndVerifyArtifactForCollectable( - ctx context.Context, - reader ctrl.Reader, - strg *storage.Storage, - collectable storage.Collectable, -) (*artifactv1.Artifact, error) { - artifact := strg.NewArtifactFor(collectable.GetKind(), collectable.GetObjectMeta(), "", "") - if err := reader.Get(ctx, types.NamespacedName{Name: artifact.Name, Namespace: artifact.Namespace}, artifact); err != nil { - return nil, fmt.Errorf("failed to get artifact: %w", err) - } - - // Check the digest of the archive and compare it to the one in the artifact - if err := strg.VerifyArtifact(artifact); err != nil { - return nil, fmt.Errorf("failed to verify artifact: %w", err) - } - - return artifact, nil -} - -// ValidateArtifactForCollectable verifies if the artifact for the given collectable is valid. -// This means that the artifact must be present in the cluster the reader is connected to and -// the artifact must be present in the storage. -// Additionally, the digest of the artifact must be different from the file name of the artifact. -// -// This method can be used to determine if an artifact needs an update or not because an artifact that does not -// fulfill these conditions can be considered out of date (not in the cluster, not in the storage, or mismatching digest). -// -// Prerequisite for this method is that the artifact name is based on its original digest. -func ValidateArtifactForCollectable( - ctx context.Context, - reader ctrl.Reader, - strg *storage.Storage, - collectable storage.Collectable, - digest string, -) (bool, error) { - artifact, err := GetAndVerifyArtifactForCollectable(ctx, reader, strg, collectable) - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - if ctrl.IgnoreNotFound(err) != nil { - return false, fmt.Errorf("failed to get artifact: %w", err) - } - if artifact == nil { - return false, nil - } - - existingFile := filepath.Base(strg.LocalPath(artifact)) - - return existingFile != digest, nil -} - -// RemoveArtifactForCollectable removes the artifact for the given collectable from the given storage. -func RemoveArtifactForCollectable( - ctx context.Context, - client ctrl.Client, - strg *storage.Storage, - collectable storage.Collectable, -) error { - artifact, err := GetAndVerifyArtifactForCollectable(ctx, client, strg, collectable) - if ctrl.IgnoreNotFound(err) != nil { - return fmt.Errorf("failed to get artifact: %w", err) - } - - if artifact != nil { - if err := strg.Remove(artifact); err != nil { - if !os.IsNotExist(err) { - return fmt.Errorf("failed to remove artifact: %w", err) - } - } - } - - return nil -} diff --git a/pkg/ocm/ocm_test.go b/pkg/ocm/ocm_test.go index aba47149..6be98fce 100644 --- a/pkg/ocm/ocm_test.go +++ b/pkg/ocm/ocm_test.go @@ -26,7 +26,6 @@ import ( "ocm.software/ocm/api/utils/mime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -382,7 +381,6 @@ consumers: utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(v1alpha1.AddToScheme(scheme)) - utilruntime.Must(artifactv1.AddToScheme(scheme)) BeforeEach(func() { bldr = fake.NewClientBuilder() diff --git a/pkg/ocm/snapshot.go b/pkg/ocm/snapshot.go new file mode 100644 index 00000000..09f68d8b --- /dev/null +++ b/pkg/ocm/snapshot.go @@ -0,0 +1,28 @@ +package ocm + +import ( + "bytes" + "context" + "fmt" + + "k8s.io/apimachinery/pkg/util/yaml" + "ocm.software/ocm/api/ocm/compdesc" + + "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" +) + +// GetComponentSetForSnapshot returns the component descriptor set for the given snapshot. +func GetComponentSetForSnapshot(ctx context.Context, repository snapshot.RepositoryType, snapshotResource *v1alpha1.Snapshot) (*compdesc.ComponentVersionSet, error) { + data, err := repository.FetchSnapshot(ctx, snapshotResource.GetDigest()) + if err != nil { + return nil, err + } + + cds := &Descriptors{} + if err := yaml.NewYAMLToJSONDecoder(bytes.NewReader(data)).Decode(cds); err != nil { + return nil, fmt.Errorf("failed to unmarshal component descriptors: %w", err) + } + + return compdesc.NewComponentVersionSet(cds.List...), nil +} diff --git a/pkg/artifact/object_config.go b/pkg/snapshot/object_config.go similarity index 98% rename from pkg/artifact/object_config.go rename to pkg/snapshot/object_config.go index 8ad01388..97e93458 100644 --- a/pkg/artifact/object_config.go +++ b/pkg/snapshot/object_config.go @@ -1,4 +1,4 @@ -package artifact +package snapshot import ( "bytes" diff --git a/pkg/snapshot/registry.go b/pkg/snapshot/registry.go new file mode 100644 index 00000000..850bc448 --- /dev/null +++ b/pkg/snapshot/registry.go @@ -0,0 +1,39 @@ +package snapshot + +import ( + "context" + "errors" + + "oras.land/oras-go/v2/registry/remote" +) + +type RegistryType interface { + NewRepository(ctx context.Context, name string) (RepositoryType, error) +} + +type Registry struct { + *remote.Registry +} + +func NewRegistry(url string) (*Registry, error) { + registry, err := remote.NewRegistry(url) + if err != nil { + return nil, err + } + + return &Registry{registry}, nil +} + +func (r *Registry) NewRepository(ctx context.Context, name string) (RepositoryType, error) { + repository, err := r.Repository(ctx, name) + if err != nil { + return nil, err + } + + remoteRepository, ok := repository.(*remote.Repository) + if !ok { + return nil, errors.New("invalid repository type") + } + + return &Repository{remoteRepository}, nil +} diff --git a/pkg/snapshot/repository.go b/pkg/snapshot/repository.go new file mode 100644 index 00000000..358fdb44 --- /dev/null +++ b/pkg/snapshot/repository.go @@ -0,0 +1,280 @@ +package snapshot + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/url" + "regexp" + "strings" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/mitchellh/hashstructure/v2" + "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/specs-go" + "ocm.software/ocm/api/ocm/extensions/accessmethods/ociartifact" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/registry/remote" + "sigs.k8s.io/controller-runtime/pkg/log" + + ociV1 "github.com/opencontainers/image-spec/specs-go/v1" + ocmctx "ocm.software/ocm/api/ocm" + + "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" +) + +// A RepositoryType is a type that can push and fetch blobs. +type RepositoryType interface { + // PushSnapshot is a wrapper to push a single layer OCI artifact with an empty config and a single data layer + // containing the blob. As all snapshots are produced and consumed by us, we do not have to care about the + // configuration. + PushSnapshot(ctx context.Context, reference string, blob []byte) (digest.Digest, error) + + // FetchSnapshot is a wrapper to fetch a single layer OCI artifact with a manifest digest. It expects and returns + // the single data layer. + FetchSnapshot(ctx context.Context, reference string) ([]byte, error) + + // DeleteSnapshot is a wrapper to delete a single layer OCI artifact with a manifest digest. + DeleteSnapshot(ctx context.Context, digest string) error + + // ExistsSnapshot is a wrapper to check if an OCI repository exists using the manifest digest. + ExistsSnapshot(ctx context.Context, manifestDigest string) (bool, error) + + // CopyOCIArtifactForResourceAccess is a wrapper to copy an OCI artifact from an OCM resource access. + CopyOCIArtifactForResourceAccess(ctx context.Context, access ocmctx.ResourceAccess) (digest.Digest, error) + + GetHost() string + GetName() string +} + +// Repository is a wrapper for an OCI repository to provide snapshot methods. +type Repository struct { + *remote.Repository +} + +func (r *Repository) GetHost() string { + return r.Reference.Host() +} + +func (r *Repository) GetName() string { + return r.Reference.Repository +} + +func (r *Repository) PushSnapshot(ctx context.Context, tag string, blob []byte) (digest.Digest, error) { + logger := log.FromContext(ctx) + + // Prepare and upload blob + blobDescriptor := ociV1.Descriptor{ + // The media type is meaningless as we do not use it. Thus, we just set a default one. + MediaType: ociV1.MediaTypeImageLayerGzip, + Digest: digest.FromBytes(blob), + Size: int64(len(blob)), + } + + logger.Info("pushing blob", "descriptor", blobDescriptor) + if err := r.Push(ctx, blobDescriptor, content.NewVerifyReader( + bytes.NewReader(blob), + blobDescriptor, + )); err != nil { + return "", fmt.Errorf("error pushing blob: %w", err) + } + + // Prepare and upload an empty image config. As we do not plan to make use of the image config, the content does + // not matter. + emptyImageConfig := []byte("{}") + + imageConfigDescriptor := ociV1.Descriptor{ + MediaType: ociV1.MediaTypeImageConfig, + Digest: digest.FromBytes(emptyImageConfig), + Size: int64(len(emptyImageConfig)), + } + + logger.Info("pushing empty image config", "descriptor", imageConfigDescriptor) + if err := r.Push(ctx, imageConfigDescriptor, content.NewVerifyReader( + bytes.NewReader(emptyImageConfig), + imageConfigDescriptor, + )); err != nil { + return "", fmt.Errorf("error pushing empty config: %w", err) + } + + // Prepare and upload manifest + manifest := ociV1.Manifest{ + Versioned: specs.Versioned{SchemaVersion: v1alpha1.OCISchemaVersion}, + MediaType: ociV1.MediaTypeImageManifest, + Config: imageConfigDescriptor, + Layers: []ociV1.Descriptor{blobDescriptor}, + } + + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return "", fmt.Errorf("error marshaling manifest: %w", err) + } + + manifestDigest := digest.FromBytes(manifestBytes) + + manifestDescriptor := ociV1.Descriptor{ + MediaType: manifest.MediaType, + Digest: manifestDigest, + Size: int64(len(manifestBytes)), + } + + logger.Info("pushing image manifest", "descriptor", manifestDescriptor) + if err := r.Push(ctx, manifestDescriptor, content.NewVerifyReader( + bytes.NewReader(manifestBytes), + manifestDescriptor, + )); err != nil { + return "", fmt.Errorf("error pushing manifest: %w", err) + } + + logger.Info("tagging image manifest", "tag", tag) + if err := r.Tag(ctx, manifestDescriptor, tag); err != nil { + return "", fmt.Errorf("error tagging manifest: %w", err) + } + + logger.Info("pushed single OCI artifact") + + return manifestDigest, nil +} + +func (r *Repository) FetchSnapshot(ctx context.Context, manifestDigest string) ([]byte, error) { + // Fetch manifest descriptor to get manifest. + manifestDescriptor, _, err := r.FetchReference(ctx, manifestDigest) + if err != nil { + return nil, fmt.Errorf("error fetching manifest: %w", err) + } + + manifestReader, err := r.Fetch(ctx, manifestDescriptor) + if err != nil { + return nil, fmt.Errorf("error fetching manifest: %w", err) + } + + var manifest ociV1.Manifest + if err := json.NewDecoder(manifestReader).Decode(&manifest); err != nil { + return nil, fmt.Errorf("error parsing manifest: %w", err) + } + + // We only expect single layer artifacts. + if len(manifest.Layers) != 1 { + return nil, fmt.Errorf("expected 1 layer, got %d", len(manifest.Layers)) + } + + reader, err := r.Fetch(ctx, manifest.Layers[0]) + if err != nil { + return nil, fmt.Errorf("error fetching layer: %w", err) + } + + return io.ReadAll(reader) +} + +func (r *Repository) DeleteSnapshot(ctx context.Context, manifestDigest string) error { + manifestDescriptor, _, err := r.FetchReference(ctx, manifestDigest) + if err != nil { + return fmt.Errorf("error fetching manifest: %w", err) + } + + return r.Delete(ctx, manifestDescriptor) +} + +func (r *Repository) ExistsSnapshot(ctx context.Context, manifestDigest string) (bool, error) { + manifestDescriptor, _, err := r.FetchReference(ctx, manifestDigest) + if err != nil { + return false, fmt.Errorf("error fetching manifest: %w", err) + } + + return r.Exists(ctx, manifestDescriptor) +} + +func (r *Repository) CopyOCIArtifactForResourceAccess(ctx context.Context, access ocmctx.ResourceAccess) (digest.Digest, error) { + logger := log.FromContext(ctx) + + gloAccess := access.GlobalAccess() + accessSpec, ok := gloAccess.(*ociartifact.AccessSpec) + if !ok { + return "", fmt.Errorf("expected type ociartifact.AccessSpec, but got %T", gloAccess) + } + + var http bool + var refSanitized string + refURL, err := url.Parse(accessSpec.ImageReference) + if err != nil { + return "", fmt.Errorf("error parsing image reference: %w", err) + } + + if refURL.Scheme != "" { + if refURL.Scheme == "http" { + http = true + } + refSanitized = strings.TrimPrefix(accessSpec.ImageReference, refURL.Scheme+"://") + } else { + refSanitized = accessSpec.ImageReference + } + + ref, err := name.ParseReference(refSanitized) + if err != nil { + return "", fmt.Errorf("error parsing image reference: %w", err) + } + + sourceRegistry, err := remote.NewRegistry(ref.Context().RegistryStr()) + if err != nil { + return "", fmt.Errorf("error creating source registry: %w", err) + } + + if http { + sourceRegistry.PlainHTTP = true + } + + sourceRepository, err := sourceRegistry.Repository(ctx, ref.Context().RepositoryStr()) + if err != nil { + return "", fmt.Errorf("error creating source repository: %w", err) + } + + desc, err := oras.Copy(ctx, sourceRepository, ref.Identifier(), r.Repository, ref.Identifier(), oras.CopyOptions{ + CopyGraphOptions: oras.CopyGraphOptions{ + PreCopy: func(_ context.Context, desc ociV1.Descriptor) error { + logger.Info("uploading", "digest", desc.Digest.String(), "mediaType", desc.MediaType) + + return nil + }, + PostCopy: func(_ context.Context, desc ociV1.Descriptor) error { + logger.Info("uploading", "digest", desc.Digest.String(), "mediaType", desc.MediaType) + + return nil + }, + OnCopySkipped: func(_ context.Context, desc ociV1.Descriptor) error { + logger.Info("uploading", "digest", desc.Digest.String(), "mediaType", desc.MediaType) + + return nil + }, + }, + }) + if err != nil { + return "", fmt.Errorf("error copying snapshot: %w", err) + } + + return desc.Digest, nil +} + +// CreateRepositoryName creates a name for an OCI repository and returns a hashed string from the passed arguments. The +// purpose of this function is to sanitize any passed string to an OCI repository compliant name. +func CreateRepositoryName(args ...string) (string, error) { + hash, err := hashstructure.Hash(args, hashstructure.FormatV2, nil) + if err != nil { + return "", fmt.Errorf("failed to hash identity: %w", err) + } + + repositoryName := fmt.Sprintf("sha-%d", hash) + + match, err := regexp.MatchString(v1alpha1.OCIRepositoryNameConstraints, repositoryName) + if err != nil { + return "", fmt.Errorf("failed to check OCI repository repositoryName constraints: %w", err) + } + + if !match { + return "", fmt.Errorf("repositoryName '%s' failed to match OCI repository repositoryName constraints: %w", repositoryName, err) + } + + return repositoryName, nil +} diff --git a/pkg/artifact/resource.go b/pkg/snapshot/resource.go similarity index 66% rename from pkg/artifact/resource.go rename to pkg/snapshot/resource.go index e4829a3a..7544111e 100644 --- a/pkg/artifact/resource.go +++ b/pkg/snapshot/resource.go @@ -1,6 +1,7 @@ -package artifact +package snapshot import ( + "bytes" "context" "errors" "fmt" @@ -10,11 +11,9 @@ import ( "github.com/containers/image/v5/pkg/compression" "github.com/fluxcd/pkg/runtime/conditions" - "github.com/openfluxcd/controller-manager/storage" "sigs.k8s.io/controller-runtime/pkg/client" fluxtar "github.com/fluxcd/pkg/tar" - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" "github.com/open-component-model/ocm-k8s-toolkit/pkg/util" @@ -25,7 +24,7 @@ var ( ErrNotYetReady = errors.New("not yet ready") ) -// Content is an interface that represents the content of an artifact. +// Content is an interface that represents the content of an snapshot. type Content interface { // Open returns a reader for instruction. It can be a tarball, a file, etc. // The caller is responsible for closing the reader. @@ -35,71 +34,67 @@ type Content interface { // It returns an error if the source cannot be unpacked. UnpackIntoDirectory(path string) (err error) - // RevisionAndDigest returns the revision and digest of the artifact content. + // RevisionAndDigest returns the revision and digest of the snapshot content. util.RevisionAndDigest } -func NewContentBackedByComponentResourceArtifact( - storage *storage.Storage, +func NewContentBackedByComponentResourceSnapshot( + registry RegistryType, component *v1alpha1.Component, resource *v1alpha1.Resource, - artifact *artifactv1.Artifact, + snapshot *v1alpha1.Snapshot, ) Content { - return &ContentBackedByStorageAndComponent{ - Storage: storage, + return &ContentBackedBySnapshotAndComponent{ + Registry: registry, Component: component, Resource: resource, - Artifact: artifact, + Snapshot: snapshot, } } -type ContentBackedByStorageAndComponent struct { - Storage *storage.Storage +type ContentBackedBySnapshotAndComponent struct { + Registry RegistryType Component *v1alpha1.Component Resource *v1alpha1.Resource - Artifact *artifactv1.Artifact + Snapshot *v1alpha1.Snapshot } -func (r *ContentBackedByStorageAndComponent) GetDigest() (string, error) { - return r.Artifact.Spec.Digest, nil +func (r *ContentBackedBySnapshotAndComponent) GetDigest() (string, error) { + return r.Snapshot.Spec.Blob.Digest, nil } -func (r *ContentBackedByStorageAndComponent) GetRevision() string { +func (r *ContentBackedBySnapshotAndComponent) GetRevision() string { return fmt.Sprintf( - "artifact %s in revision %s (from resource %s, based on component %s)", - r.Artifact.GetName(), - r.Artifact.Spec.Revision, + "snapshot %s in revision %s (from resource %s, based on component %s)", + r.Snapshot.GetName(), + r.Snapshot.Spec.Blob.Digest, r.Resource.GetName(), r.Component.GetName(), ) } -func (r *ContentBackedByStorageAndComponent) Open() (io.ReadCloser, error) { +func (r *ContentBackedBySnapshotAndComponent) Open() (io.ReadCloser, error) { return r.open() } -func (r *ContentBackedByStorageAndComponent) open() (io.ReadCloser, error) { - path := r.Storage.LocalPath(r.Artifact) - - unlock, err := r.Storage.Lock(r.Artifact) +func (r *ContentBackedBySnapshotAndComponent) open() (io.ReadCloser, error) { + ctx := context.Background() + repository, err := r.Registry.NewRepository(context.Background(), r.Snapshot.Spec.Repository) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to open repository: %w", err) } - readCloser, err := os.OpenFile(path, os.O_RDONLY, 0o600) + data, err := repository.FetchSnapshot(ctx, r.Snapshot.GetDigest()) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to fetch snapshot: %w", err) } - return &lockedReadCloser{ - ReadCloser: readCloser, - unlock: unlock, - }, nil + return io.NopCloser(bytes.NewReader(data)), nil } var _ io.ReadCloser = &lockedReadCloser{} -func (r *ContentBackedByStorageAndComponent) UnpackIntoDirectory(path string) (err error) { +func (r *ContentBackedBySnapshotAndComponent) UnpackIntoDirectory(path string) (err error) { fi, err := os.Stat(path) if err == nil && fi.IsDir() { return ErrAlreadyUnpacked @@ -110,6 +105,9 @@ func (r *ContentBackedByStorageAndComponent) UnpackIntoDirectory(path string) (e } data, err := r.open() + if err != nil { + return fmt.Errorf("failed to get data: %w", err) + } defer func() { err = errors.Join(err, data.Close()) }() @@ -122,12 +120,14 @@ func (r *ContentBackedByStorageAndComponent) UnpackIntoDirectory(path string) (e err = errors.Join(err, decompressed.Close()) }() + // If it is a tar it must be a directory isTar, reader := util.IsTar(decompressed) if isTar { return fluxtar.Untar(reader, path, fluxtar.WithSkipGzip()) } - path = filepath.Join(path, filepath.Base(r.Storage.LocalPath(r.Artifact))) + // If it is not a tar it should be a file + path = filepath.Join(path, r.GetResource().GetName()) file, err := os.Create(path) if err != nil { return fmt.Errorf("failed to unpack file at %s: %w", path, err) @@ -142,11 +142,11 @@ func (r *ContentBackedByStorageAndComponent) UnpackIntoDirectory(path string) (e return nil } -func (r *ContentBackedByStorageAndComponent) GetComponent() *v1alpha1.Component { +func (r *ContentBackedBySnapshotAndComponent) GetComponent() *v1alpha1.Component { return r.Component } -func (r *ContentBackedByStorageAndComponent) GetResource() *v1alpha1.Resource { +func (r *ContentBackedBySnapshotAndComponent) GetResource() *v1alpha1.Resource { return r.Resource } @@ -163,33 +163,33 @@ func (l *lockedReadCloser) Close() error { return l.ReadCloser.Close() } -func GetContentBackedByArtifactFromComponent( +func GetContentBackedBySnapshotFromComponent( ctx context.Context, clnt client.Reader, - strg *storage.Storage, + registry RegistryType, ref *v1alpha1.ConfigurationReference, ) (Content, error) { if ref.APIVersion == "" { ref.APIVersion = v1alpha1.GroupVersion.String() } - component, resource, artifact, err := GetComponentResourceArtifactFromReference(ctx, clnt, strg, ref) + component, resource, snapshotResource, err := GetComponentResourceSnapshotFromReference(ctx, clnt, registry, ref) if err != nil { return nil, err } - return NewContentBackedByComponentResourceArtifact(strg, component, resource, artifact), nil + return NewContentBackedByComponentResourceSnapshot(registry, component, resource, snapshotResource), nil } type ObjectWithTargetReference interface { GetTarget() *v1alpha1.ConfigurationReference } -func GetComponentResourceArtifactFromReference( +func GetComponentResourceSnapshotFromReference( ctx context.Context, clnt client.Reader, - strg *storage.Storage, + registry RegistryType, ref *v1alpha1.ConfigurationReference, -) (*v1alpha1.Component, *v1alpha1.Resource, *artifactv1.Artifact, error) { +) (*v1alpha1.Component, *v1alpha1.Resource, *v1alpha1.Snapshot, error) { var ( resource client.Object err error @@ -230,15 +230,15 @@ func GetComponentResourceArtifactFromReference( return nil, nil, nil, fmt.Errorf("failed to fetch component %s to which resource %s belongs: %w", res.Spec.ComponentRef.Name, ref.Name, err) } - art := &artifactv1.Artifact{} + snapshotResource := &v1alpha1.Snapshot{} if err = clnt.Get(ctx, client.ObjectKey{ Namespace: res.GetNamespace(), - Name: res.Status.ArtifactRef.Name, - }, art); err != nil { - return nil, nil, nil, fmt.Errorf("failed to fetch artifact %s belonging to resource %s: %w", res.Status.ArtifactRef.Name, ref.Name, err) + Name: res.Status.SnapshotRef.Name, + }, snapshotResource); err != nil { + return nil, nil, nil, fmt.Errorf("failed to fetch snapshot %s belonging to resource %s: %w", res.Status.SnapshotRef.Name, ref.Name, err) } - return component, res, art, nil + return component, res, snapshotResource, nil } targetable, ok := resource.(ObjectWithTargetReference) @@ -246,15 +246,15 @@ func GetComponentResourceArtifactFromReference( return nil, nil, nil, fmt.Errorf("unsupported reference type: %T", resource) } - return GetComponentResourceArtifactFromReference(ctx, clnt, strg, targetable.GetTarget()) + return GetComponentResourceSnapshotFromReference(ctx, clnt, registry, targetable.GetTarget()) } -// UniqueIDsForArtifactContentCombination returns a set of unique identifiers for the combination of two Content. +// UniqueIDsForSnapshotContentCombination returns a set of unique identifiers for the combination of two Content. // This compromises of // - the digest of 'a' applied to 'b', machine identifiable and unique // - the revision of 'a' applied to 'b', human-readable // - the archive file name of 'a' applied to 'b'. -func UniqueIDsForArtifactContentCombination(a, b Content) (string, string, string, error) { +func UniqueIDsForSnapshotContentCombination(a, b Content) (string, string, string, error) { revisionAndDigest, err := util.NewMappedRevisionAndDigest(a, b) if err != nil { return "", "", "", fmt.Errorf("unable to create unique revision and digest: %w", err) diff --git a/pkg/snapshot/snapshot.go b/pkg/snapshot/snapshot.go new file mode 100644 index 00000000..718e793a --- /dev/null +++ b/pkg/snapshot/snapshot.go @@ -0,0 +1,84 @@ +package snapshot + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/fluxcd/pkg/runtime/conditions" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" + "sigs.k8s.io/controller-runtime/pkg/client" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" +) + +// generateName generates a name for a snapshot CR. If the name exceeds the character limit, it will be cut off at 256. +func generateName(obj v1alpha1.SnapshotWriter) string { + name := strings.ToLower(fmt.Sprintf("%s-%s", obj.GetKind(), obj.GetName())) + + if len(name) > validation.DNS1123SubdomainMaxLength { + return name[:validation.DNS1123SubdomainMaxLength] + } + + return name +} + +func Create(owner v1alpha1.SnapshotWriter, ociRepository, manifestDigest string, blob *v1alpha1.BlobInfo) *v1alpha1.Snapshot { + return &v1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateName(owner), + Namespace: owner.GetNamespace(), + }, + Spec: v1alpha1.SnapshotSpec{ + Repository: ociRepository, + Digest: manifestDigest, + Blob: blob, + }, + Status: v1alpha1.SnapshotStatus{}, + } +} + +// ValidateSnapshotForOwner verifies if the snapshot for the given collectable is valid. +// This means that the snapshot must be present in the cluster the reader is connected to and +// the snapshot must be present in the OCI registry. +// Additionally, the passed digest must be different from the blob digest stored in the snapshot. +// +// This method can be used to determine if a snapshot needs an update or not because a snapshot that does not +// fulfill these conditions can be considered out of date (not in the cluster, not in the OCI registry, or mismatching +// digest). +func ValidateSnapshotForOwner( + ctx context.Context, + reader client.Reader, + owner v1alpha1.SnapshotWriter, + digest string, +) (bool, error) { + // If the owner cannot return a snapshot name, the snapshot is not created yet. + if owner.GetSnapshotName() == "" { + return false, nil + } + + snapshotResource := &v1alpha1.Snapshot{} + err := reader.Get(ctx, types.NamespacedName{Namespace: owner.GetNamespace(), Name: owner.GetSnapshotName()}, snapshotResource) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if client.IgnoreNotFound(err) != nil { + return false, fmt.Errorf("failed to get snapshot: %w", err) + } + + if snapshotResource == nil { + return false, nil + } + + // Snapshot is only ready, if the repository exists in the OCI registry + if !conditions.IsReady(snapshotResource) { + return false, fmt.Errorf("snapshot %s is not ready", snapshotResource.GetName()) + } + + return snapshotResource.Spec.Blob.Digest != digest, nil +} diff --git a/pkg/test/component.go b/pkg/test/component.go new file mode 100644 index 00000000..e28a4b87 --- /dev/null +++ b/pkg/test/component.go @@ -0,0 +1,102 @@ +package test + +import ( + "context" + "fmt" + "time" + + //nolint:revive,stylecheck // dot import necessary for Ginkgo DSL + . "github.com/onsi/gomega" + + "github.com/fluxcd/pkg/runtime/conditions" + "github.com/fluxcd/pkg/runtime/patch" + "github.com/opencontainers/go-digest" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/status" +) + +type MockComponentOptions struct { + Registry snapshot.RegistryType + Client client.Client + Recorder record.EventRecorder + Info v1alpha1.ComponentInfo + Repository string +} + +func SetupComponentWithDescriptorList( + ctx context.Context, + name, namespace string, + descriptorListData []byte, + options *MockComponentOptions, +) *v1alpha1.Component { + component := &v1alpha1.Component{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: v1alpha1.ComponentSpec{ + RepositoryRef: v1alpha1.ObjectKey{Name: options.Repository, Namespace: namespace}, + Component: options.Info.Component, + }, + } + Expect(options.Client.Create(ctx, component)).To(Succeed()) + + patchHelper := patch.NewSerialPatcher(component, options.Client) + + repositoryName, err := snapshot.CreateRepositoryName(options.Repository, name) + Expect(err).ToNot(HaveOccurred()) + + repository, err := options.Registry.NewRepository(ctx, repositoryName) + Expect(err).ToNot(HaveOccurred()) + + manifestDigest, err := repository.PushSnapshot(ctx, options.Info.Version, descriptorListData) + Expect(err).ToNot(HaveOccurred()) + + snapshotCR := snapshot.Create( + component, + repositoryName, + manifestDigest.String(), + &v1alpha1.BlobInfo{ + Digest: digest.FromBytes(descriptorListData).String(), + Tag: options.Info.Version, + Size: int64(len(descriptorListData)), + }, + ) + + _, err = controllerutil.CreateOrUpdate(ctx, options.Client, snapshotCR, func() error { + if snapshotCR.ObjectMeta.CreationTimestamp.IsZero() { + if err := controllerutil.SetControllerReference(component, snapshotCR, options.Client.Scheme()); err != nil { + return fmt.Errorf("failed to set controller reference: %w", err) + } + } + + component.Status.SnapshotRef = corev1.LocalObjectReference{ + Name: snapshotCR.GetName(), + } + + component.Status.Component = options.Info + + return nil + }) + Expect(err).ToNot(HaveOccurred()) + + // Marks snapshot as ready + conditions.MarkTrue(snapshotCR, "Ready", "ready", "message") + Expect(options.Client.Status().Update(ctx, snapshotCR)).To(Succeed()) + + Eventually(func(ctx context.Context) error { + status.MarkReady(options.Recorder, component, "applied mock component") + + return status.UpdateStatus(ctx, patchHelper, component, options.Recorder, time.Hour, nil) + }).WithContext(ctx).Should(Succeed()) + + return component +} diff --git a/pkg/test/resource.go b/pkg/test/resource.go new file mode 100644 index 00000000..fa830744 --- /dev/null +++ b/pkg/test/resource.go @@ -0,0 +1,125 @@ +package test + +import ( + "context" + "fmt" + "io" + "time" + + //nolint:revive,stylecheck // dot import necessary for Ginkgo DSL + . "github.com/onsi/gomega" + + "github.com/fluxcd/pkg/runtime/conditions" + "github.com/fluxcd/pkg/runtime/patch" + "github.com/opencontainers/go-digest" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "ocm.software/ocm/api/ocm/compdesc/meta/v1" + + "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/compression" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" + "github.com/open-component-model/ocm-k8s-toolkit/pkg/status" +) + +type MockResourceOptions struct { + // option one to create a resource: directly pass the Data + Data io.Reader + // option two to create a resource: pass the path to the Data + DataPath string + + ComponentRef v1alpha1.ObjectKey + + Registry snapshot.RegistryType + Clnt client.Client + Recorder record.EventRecorder +} + +func SetupMockResourceWithData( + ctx context.Context, + name, namespace string, + options *MockResourceOptions, +) *v1alpha1.Resource { + res := &v1alpha1.Resource{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Spec: v1alpha1.ResourceSpec{ + Resource: v1alpha1.ResourceID{ + ByReference: v1alpha1.ResourceReference{ + Resource: v1.NewIdentity(name), + }, + }, + ComponentRef: corev1.LocalObjectReference{ + Name: options.ComponentRef.Name, + }, + }, + } + Expect(options.Clnt.Create(ctx, res)).To(Succeed()) + + patchHelper := patch.NewSerialPatcher(res, options.Clnt) + + var data []byte + var err error + + if options.Data != nil { + data, err = io.ReadAll(options.Data) + Expect(err).ToNot(HaveOccurred()) + } + + if options.DataPath != "" { + data, err = compression.CreateTGZFromPath(options.DataPath) + Expect(err).ToNot(HaveOccurred()) + } + + version := "dummy" + repositoryName, err := snapshot.CreateRepositoryName(options.ComponentRef.Name, name) + Expect(err).ToNot(HaveOccurred()) + repository, err := options.Registry.NewRepository(ctx, repositoryName) + Expect(err).ToNot(HaveOccurred()) + + manifestDigest, err := repository.PushSnapshot(ctx, version, data) + Expect(err).ToNot(HaveOccurred()) + snapshotCR := snapshot.Create( + res, + repositoryName, + manifestDigest.String(), + &v1alpha1.BlobInfo{ + Digest: digest.FromBytes(data).String(), + Tag: version, + Size: int64(len(data)), + }, + ) + + _, err = controllerutil.CreateOrUpdate(ctx, options.Clnt, snapshotCR, func() error { + if snapshotCR.ObjectMeta.CreationTimestamp.IsZero() { + if err := controllerutil.SetControllerReference(res, snapshotCR, options.Clnt.Scheme()); err != nil { + return fmt.Errorf("failed to set controller reference: %w", err) + } + } + + res.Status.SnapshotRef = corev1.LocalObjectReference{ + Name: snapshotCR.GetName(), + } + + return nil + }) + Expect(err).ToNot(HaveOccurred()) + + // Marks snapshot as ready + conditions.MarkTrue(snapshotCR, "Ready", "ready", "message") + Expect(options.Clnt.Status().Update(ctx, snapshotCR)).To(Succeed()) + + Eventually(func(ctx context.Context) error { + status.MarkReady(options.Recorder, res, "applied mock resource") + + return status.UpdateStatus(ctx, patchHelper, res, options.Recorder, time.Hour, nil) + }).WithContext(ctx).Should(Succeed()) + + return res +} diff --git a/pkg/test/util.go b/pkg/test/util.go deleted file mode 100644 index bb0296aa..00000000 --- a/pkg/test/util.go +++ /dev/null @@ -1,233 +0,0 @@ -package test - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "time" - - //nolint:revive,stylecheck // dot import necessary for Ginkgo DSL - . "github.com/onsi/ginkgo/v2" - //nolint:revive,stylecheck // dot import necessary for Ginkgo DSL - . "github.com/onsi/gomega" - //nolint:revive,stylecheck // dot import necessary for Ginkgo DSL - . "sigs.k8s.io/controller-runtime/pkg/envtest/komega" - - "github.com/fluxcd/pkg/runtime/patch" - "github.com/mandelsoft/vfs/pkg/memoryfs" - "github.com/mandelsoft/vfs/pkg/vfs" - "github.com/openfluxcd/controller-manager/storage" - "k8s.io/client-go/tools/record" - "ocm.software/ocm/api/utils/tarutils" - "sigs.k8s.io/controller-runtime/pkg/client" - - artifactv1 "github.com/openfluxcd/artifact/api/v1alpha1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - v1 "ocm.software/ocm/api/ocm/compdesc/meta/v1" - - "github.com/open-component-model/ocm-k8s-toolkit/api/v1alpha1" - "github.com/open-component-model/ocm-k8s-toolkit/pkg/status" -) - -type MockResourceOptions struct { - BasePath string - - // option one to create a resource: directly pass the Data - Data io.Reader - // option two to create a resource: pass the path to the Data - DataPath string - - ComponentRef v1alpha1.ObjectKey - - Strg *storage.Storage - Clnt client.Client - Recorder record.EventRecorder -} - -func SetupMockResourceWithData( - ctx context.Context, - name, namespace string, - options *MockResourceOptions, -) *v1alpha1.Resource { - res := &v1alpha1.Resource{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - Name: name, - }, - Spec: v1alpha1.ResourceSpec{ - Resource: v1alpha1.ResourceID{ - ByReference: v1alpha1.ResourceReference{ - Resource: v1.NewIdentity(name), - }, - }, - ComponentRef: corev1.LocalObjectReference{ - Name: options.ComponentRef.Name, - }, - }, - } - Expect(options.Clnt.Create(ctx, res)).To(Succeed()) - - patchHelper := patch.NewSerialPatcher(res, options.Clnt) - - path := options.BasePath - - err := options.Strg.ReconcileArtifact( - ctx, - res, - name, - path, - fmt.Sprintf("%s.tar.gz", name), - func(artifact *artifactv1.Artifact, _ string) error { - // Archive directory to storage - if options.Data != nil { - if err := options.Strg.Copy(artifact, options.Data); err != nil { - return fmt.Errorf("unable to archive artifact to storage: %w", err) - } - } - if options.DataPath != "" { - abs, err := filepath.Abs(options.DataPath) - if err != nil { - return fmt.Errorf("unable to get absolute path: %w", err) - } - if err := options.Strg.Archive(artifact, abs, nil); err != nil { - return fmt.Errorf("unable to archive artifact to storage: %w", err) - } - } - - res.Status.ArtifactRef = corev1.LocalObjectReference{ - Name: artifact.Name, - } - - return nil - }) - Expect(err).ToNot(HaveOccurred()) - - art := &artifactv1.Artifact{} - art.Name = res.Status.ArtifactRef.Name - art.Namespace = res.Namespace - Eventually(Object(art), "5s").Should(HaveField("Spec.URL", Not(BeEmpty()))) - - Eventually(func(ctx context.Context) error { - status.MarkReady(options.Recorder, res, "applied mock resource") - - return status.UpdateStatus(ctx, patchHelper, res, options.Recorder, time.Hour, nil) - }).WithContext(ctx).Should(Succeed()) - - return res -} - -type MockComponentOptions struct { - BasePath string - Strg *storage.Storage - Client client.Client - Recorder record.EventRecorder - Info v1alpha1.ComponentInfo - Repository string -} - -func SetupComponentWithDescriptorList( - ctx context.Context, - name, namespace string, - descriptorListData []byte, - options *MockComponentOptions, -) *v1alpha1.Component { - dir := filepath.Join(options.BasePath, "descriptor") - CreateTGZ(dir, map[string][]byte{ - v1alpha1.OCMComponentDescriptorList: descriptorListData, - }) - component := &v1alpha1.Component{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - Spec: v1alpha1.ComponentSpec{ - RepositoryRef: v1alpha1.ObjectKey{Name: options.Repository, Namespace: namespace}, - Component: options.Info.Component, - }, - Status: v1alpha1.ComponentStatus{ - ArtifactRef: corev1.LocalObjectReference{ - Name: name, - }, - Component: options.Info, - }, - } - Expect(options.Client.Create(ctx, component)).To(Succeed()) - - patchHelper := patch.NewSerialPatcher(component, options.Client) - - Expect(options.Strg.ReconcileArtifact( - ctx, - component, - name, - options.BasePath, - fmt.Sprintf("%s.tar.gz", name), - func(artifact *artifactv1.Artifact, _ string) error { - if err := options.Strg.Archive(artifact, dir, nil); err != nil { - return fmt.Errorf("unable to archive artifact to storage: %w", err) - } - - component.Status.ArtifactRef = corev1.LocalObjectReference{ - Name: artifact.Name, - } - component.Status.Component = options.Info - - return nil - }), - ).To(Succeed()) - - art := &artifactv1.Artifact{} - art.Name = component.Status.ArtifactRef.Name - art.Namespace = component.Namespace - Eventually(Object(art), "5s").Should(HaveField("Spec.URL", Not(BeEmpty()))) - - Eventually(func(ctx context.Context) error { - status.MarkReady(options.Recorder, component, "applied mock component") - - return status.UpdateStatus(ctx, patchHelper, component, options.Recorder, time.Hour, nil) - }).WithContext(ctx).Should(Succeed()) - - return component -} - -func VerifyArtifact(strg *storage.Storage, art *artifactv1.Artifact, files map[string]func(data []byte)) { - GinkgoHelper() - - art = art.DeepCopy() - - Eventually(Object(art), "5s").Should(HaveField("Spec.URL", Not(BeEmpty()))) - - localized := strg.LocalPath(art) - Expect(localized).To(BeAnExistingFile()) - - memFs := vfs.New(memoryfs.New()) - localizedArchiveData, err := os.OpenFile(localized, os.O_RDONLY, 0o600) - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - Expect(localizedArchiveData.Close()).To(Succeed()) - }) - Expect(tarutils.UnzipTarToFs(memFs, localizedArchiveData)).To(Succeed()) - - for fileName, assert := range files { - data, err := memFs.ReadFile(fileName) - Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("expected %s to be present and be readable", fileName)) - assert(data) - } -} - -func CreateTGZ(tgzPackageDir string, data map[string][]byte) { - GinkgoHelper() - Expect(os.Mkdir(tgzPackageDir, os.ModePerm|os.ModeDir)).To(Succeed()) - for path, data := range data { - path = filepath.Join(tgzPackageDir, path) - writer, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.ModePerm) - Expect(err).ToNot(HaveOccurred()) - defer func() { - Expect(writer.Close()).To(Succeed()) - }() - _, err = writer.Write(data) - Expect(err).ToNot(HaveOccurred()) - } -} diff --git a/pkg/test/zot-registry.go b/pkg/test/zot-registry.go new file mode 100644 index 00000000..ec4e8556 --- /dev/null +++ b/pkg/test/zot-registry.go @@ -0,0 +1,60 @@ +package test + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "time" + + //nolint:revive,stylecheck // dot import necessary for Ginkgo DSL + . "github.com/onsi/gomega" + + "github.com/open-component-model/ocm-k8s-toolkit/pkg/snapshot" +) + +const ( + timeout = 30 * time.Second +) + +func SetupRegistry(binPath, rootDir, address, port string) (*exec.Cmd, *snapshot.Registry) { + config := []byte(fmt.Sprintf(`{"storage":{"rootDirectory":"%s"},"http":{"address":"%s","port": "%s"}}`, rootDir, address, port)) + configFile := filepath.Join(rootDir, "config.json") + err := os.WriteFile(configFile, config, 0o600) + Expect(err).NotTo(HaveOccurred()) + + // Start zot-registry + zotCmd := exec.Command(binPath, "serve", configFile) + err = zotCmd.Start() + Expect(err).NotTo(HaveOccurred(), "Failed to start Zot") + + // Wait for Zot to be ready + Eventually(func() error { + url := fmt.Sprintf("http://%s/v2/", net.JoinHostPort(address, port)) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) + if err != nil { + return err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("could not connect to Zot") + } + + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil + }, timeout).Should(Succeed(), "Zot registry did not start in time") + + registry, err := snapshot.NewRegistry(fmt.Sprintf("%s:%s", address, port)) + Expect(err).NotTo(HaveOccurred()) + registry.PlainHTTP = true + + return zotCmd, registry +} diff --git a/test/utils/utils.go b/test/utils/utils.go index 18462b2d..543ac9a8 100644 --- a/test/utils/utils.go +++ b/test/utils/utils.go @@ -29,6 +29,7 @@ import ( . "github.com/onsi/ginkgo/v2" //nolint:golint,revive,stylecheck // ginkgo... + // TODO: Replacement required! "github.com/openfluxcd/artifact/test/utils" )