diff --git a/e2e/testcases/presync_test.go b/e2e/testcases/presync_test.go new file mode 100644 index 000000000..cdd0bd7e6 --- /dev/null +++ b/e2e/testcases/presync_test.go @@ -0,0 +1,198 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package e2e + +import ( + "fmt" + "testing" + + "kpt.dev/configsync/e2e/nomostest" + "kpt.dev/configsync/e2e/nomostest/ntopts" + "kpt.dev/configsync/e2e/nomostest/policy" + "kpt.dev/configsync/e2e/nomostest/registryproviders" + nomostesting "kpt.dev/configsync/e2e/nomostest/testing" + "kpt.dev/configsync/e2e/nomostest/testpredicates" + "kpt.dev/configsync/pkg/api/configsync" + "kpt.dev/configsync/pkg/api/configsync/v1beta1" + "kpt.dev/configsync/pkg/core" + "kpt.dev/configsync/pkg/core/k8sobjects" + "kpt.dev/configsync/pkg/kinds" + "kpt.dev/configsync/pkg/metadata" + "kpt.dev/configsync/pkg/reposync" + "kpt.dev/configsync/pkg/rootsync" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestAddPreSyncAnnotationRepoSync(t *testing.T) { + repoSyncID := core.RepoSyncID(configsync.RepoSyncName, namespaceRepo) + nt := nomostest.New(t, nomostesting.SyncSource, + ntopts.RequireOCIProvider, + ntopts.SyncWithGitSource(repoSyncID), + ntopts.RepoSyncPermissions(policy.RBACAdmin(), policy.CoreAdmin())) + rootSyncGitRepo := nt.SyncSourceGitReadWriteRepository(nomostest.DefaultRootSyncID) + repoSyncKey := repoSyncID.ObjectKey + gitSource := nt.SyncSources[repoSyncID] + bookinfoRole := k8sobjects.RoleObject(core.Name("bookinfo-admin")) + image, err := nt.BuildAndPushOCIImage(repoSyncKey, registryproviders.ImageInputObjects(nt.Scheme, bookinfoRole)) + if err != nil { + nt.T.Fatal(err) + } + nt.T.Log("Create RepoSync with OCI image") + repoSyncOCI := nt.RepoSyncObjectOCI(repoSyncKey, image.OCIImageID().WithoutDigest(), "", image.Digest) + nt.Must(rootSyncGitRepo.Add(nomostest.StructuredNSPath(repoSyncID.Namespace, repoSyncID.Name), repoSyncOCI)) + nt.Must(rootSyncGitRepo.CommitAndPush("Set the RepoSync to sync from OCI")) + nt.Must(nt.WatchForAllSyncs()) + + err = nt.Watcher.WatchObject(kinds.RepoSyncV1Beta1(), repoSyncID.Name, repoSyncID.Namespace, + []testpredicates.Predicate{ + checkRepoSyncPreSyncAnnotations(), + }) + if err != nil { + nt.T.Fatalf("Source annotation not updated for RepoSync %v", err) + } + nt.T.Log("Set the RepoSync to sync from Git") + nt.SyncSources[repoSyncID] = gitSource + repoSyncGit := nomostest.RepoSyncObjectV1Beta1FromNonRootRepo(nt, repoSyncID.ObjectKey) + nt.Must(rootSyncGitRepo.Add(nomostest.StructuredNSPath(repoSyncID.Namespace, repoSyncID.Name), repoSyncGit)) + nt.Must(rootSyncGitRepo.CommitAndPush("Set the RepoSync to sync from Git")) + nt.Must(nt.WatchForAllSyncs()) + + err = nt.Watcher.WatchObject(kinds.RepoSyncV1Beta1(), repoSyncID.Name, repoSyncID.Namespace, + []testpredicates.Predicate{ + testpredicates.MissingAnnotation(metadata.ImageURLAnnotationKey), + testpredicates.MissingAnnotation(metadata.SourceCommitAnnotationKey), + }) + if err != nil { + nt.T.Fatalf("Source annotations still exist when RepoSync is syncing from Git %v", err) + } +} + +func TestAddPreSyncAnnotationRootSync(t *testing.T) { + rootSyncID := nomostest.DefaultRootSyncID + rootSyncKey := rootSyncID.ObjectKey + nt := nomostest.New(t, nomostesting.SyncSource, + ntopts.SyncWithGitSource(rootSyncID, ntopts.Unstructured), + ntopts.RequireOCIProvider, + ) + gitSource := nt.SyncSources[rootSyncID] + bookinfoRole := k8sobjects.RoleObject(core.Name("bookinfo-admin")) + image, err := nt.BuildAndPushOCIImage(rootSyncKey, registryproviders.ImageInputObjects(nt.Scheme, bookinfoRole)) + if err != nil { + nt.T.Fatal(err) + } + nt.T.Log("Create RootSync with OCI image") + rootSyncOCI := nt.RootSyncObjectOCI(rootSyncKey.Name, image.OCIImageID().WithoutDigest(), "", image.Digest) + nt.Must(nt.KubeClient.Apply(rootSyncOCI)) + nt.Must(nt.WatchForAllSyncs()) + + err = nt.Watcher.WatchObject(kinds.RootSyncV1Beta1(), rootSyncID.Name, rootSyncID.Namespace, + []testpredicates.Predicate{ + checkRootSyncPreSyncAnnotations(), + }) + if err != nil { + nt.T.Fatalf("Source annotation not updated for RootSync %v", err) + } + + nt.T.Log("Set the RootSync to sync from Git") + nt.SyncSources[rootSyncID] = gitSource + rootSyncGit := nomostest.RootSyncObjectV1Beta1FromRootRepo(nt, rootSyncID.Name) + nt.Must(nt.KubeClient.Apply(rootSyncGit)) + nt.Must(nt.WatchForAllSyncs()) + + err = nt.Watcher.WatchObject(kinds.RootSyncV1Beta1(), rootSyncID.Name, rootSyncID.Namespace, + []testpredicates.Predicate{ + testpredicates.MissingAnnotation(metadata.ImageURLAnnotationKey), + testpredicates.MissingAnnotation(metadata.SourceCommitAnnotationKey), + }) + if err != nil { + nt.T.Fatalf("Source annotations still exist when RootSync is syncing from Git %v", err) + } +} + +func checkRootSyncPreSyncAnnotations() testpredicates.Predicate { + return func(o client.Object) error { + if o == nil { + return testpredicates.ErrObjectNotFound + } + rs, ok := o.(*v1beta1.RootSync) + if !ok { + return testpredicates.WrongTypeErr(o, &v1beta1.RootSync{}) + } + syncingCondition := rootsync.GetCondition(rs.Status.Conditions, v1beta1.RootSyncSyncing) + syncingCommit := syncingCondition.Commit + syncingImage := rs.Spec.Oci.Image + commitAnnotation, ok := o.GetAnnotations()[metadata.SourceCommitAnnotationKey] + if !ok { + return fmt.Errorf("object %q does not have annotation %q", o.GetName(), metadata.SourceCommitAnnotationKey) + } + repoAnnotation, ok := o.GetAnnotations()[metadata.ImageURLAnnotationKey] + if !ok { + return fmt.Errorf("object %q does not have annotation %q", o.GetName(), metadata.ImageURLAnnotationKey) + } + if syncingCommit != commitAnnotation { + return fmt.Errorf("object %s has commit annotation %s, but is being synced with commit %s", + o.GetName(), + commitAnnotation, + syncingCommit, + ) + } + if syncingImage != repoAnnotation { + return fmt.Errorf("object %s has commit annotation %s, but is being synced with commit %s", + o.GetName(), + commitAnnotation, + syncingCommit, + ) + } + return nil + } +} + +func checkRepoSyncPreSyncAnnotations() testpredicates.Predicate { + return func(o client.Object) error { + if o == nil { + return testpredicates.ErrObjectNotFound + } + rs, ok := o.(*v1beta1.RepoSync) + if !ok { + return testpredicates.WrongTypeErr(o, &v1beta1.RepoSync{}) + } + syncingCondition := reposync.GetCondition(rs.Status.Conditions, v1beta1.RepoSyncSyncing) + syncingCommit := syncingCondition.Commit + syncingImage := rs.Spec.Oci.Image + commitAnnotation, ok := o.GetAnnotations()[metadata.SourceCommitAnnotationKey] + if !ok { + return fmt.Errorf("object %q does not have annotation %q", o.GetName(), metadata.SourceCommitAnnotationKey) + } + repoAnnotation, ok := o.GetAnnotations()[metadata.ImageURLAnnotationKey] + if !ok { + return fmt.Errorf("object %q does not have annotation %q", o.GetName(), metadata.ImageURLAnnotationKey) + } + if syncingCommit != commitAnnotation { + return fmt.Errorf("object %s has commit annotation %s, but is being synced with commit %s", + o.GetName(), + commitAnnotation, + syncingCommit, + ) + } + if syncingImage != repoAnnotation { + return fmt.Errorf("object %s has commit annotation %s, but is being synced with commit %s", + o.GetName(), + commitAnnotation, + syncingCommit, + ) + } + return nil + } +} diff --git a/pkg/metadata/annotations.go b/pkg/metadata/annotations.go index 37cc36d26..b7b7c5f24 100644 --- a/pkg/metadata/annotations.go +++ b/pkg/metadata/annotations.go @@ -144,6 +144,16 @@ const ( // reconciler-manager reads it. If set to true, the reconciler-manager will // create the reconciler with the Namespace controller in the reconciler container. DynamicNSSelectorEnabledAnnotationKey = configsync.ConfigSyncPrefix + "dynamic-ns-selector-enabled" + + // ImageURLAnnotationKey is the annotation key applied to R*Sync objects + // when pre-sync is either disabled or successfully completed. + // This annotation stores the repository or image URL. + ImageURLAnnotationKey = configsync.ConfigSyncPrefix + "image-url" + + // ImageURLAnnotationKey is the annotation key applied to R*Sync objects + // when pre-sync is either disabled or successfully completed. + // This annotation stores the Git commit hash or image digest. + SourceCommitAnnotationKey = configsync.ConfigSyncPrefix + "source-commit" ) // Lifecycle annotations diff --git a/pkg/parse/namespace.go b/pkg/parse/namespace.go index 93a4ba6dc..280a38a31 100644 --- a/pkg/parse/namespace.go +++ b/pkg/parse/namespace.go @@ -174,6 +174,39 @@ func (p *namespace) setSourceStatusWithRetries(ctx context.Context, newStatus *S return nil } +func (p *namespace) setSourceAnnotations(ctx context.Context, commit string) error { + rs := &v1beta1.RepoSync{} + if err := p.Client.Get(ctx, reposync.ObjectKey(p.Scope, p.SyncName), rs); err != nil { + return status.APIServerError(err, "failed to get RepoSync for parser") + } + existing := rs.DeepCopy() + currentSourceCommit := rs.GetAnnotations()[metadata.SourceCommitAnnotationKey] + currentImageUrl := rs.GetAnnotations()[metadata.ImageURLAnnotationKey] + if commit != currentSourceCommit || p.Options.SourceRepo != currentImageUrl { + core.SetAnnotation(rs, metadata.SourceCommitAnnotationKey, commit) + core.SetAnnotation(rs, metadata.ImageURLAnnotationKey, p.Options.SourceRepo) + return p.Client.Patch(ctx, rs, client.MergeFrom(existing), client.FieldOwner(configsync.FieldManager)) + } + return nil +} + +func (p *namespace) resetSourceAnnotations(ctx context.Context) error { + rs := &v1beta1.RepoSync{} + if err := p.Client.Get(ctx, reposync.ObjectKey(p.Scope, p.SyncName), rs); err != nil { + return status.APIServerError(err, "failed to get RepoSync for parser") + } + existing := rs.DeepCopy() + _, commitAnnotationFound := rs.GetAnnotations()[metadata.SourceCommitAnnotationKey] + _, urlAnnotationFound := rs.GetAnnotations()[metadata.ImageURLAnnotationKey] + if commitAnnotationFound || urlAnnotationFound { + core.RemoveAnnotations(rs, metadata.SourceCommitAnnotationKey) + core.RemoveAnnotations(rs, metadata.ImageURLAnnotationKey) + return p.Client.Patch(ctx, rs, client.MergeFrom(existing), client.FieldOwner(configsync.FieldManager)) + } + return nil + return nil +} + func (p *namespace) setRequiresRendering(ctx context.Context, renderingRequired bool) error { rs := &v1beta1.RepoSync{} if err := p.Client.Get(ctx, reposync.ObjectKey(p.Scope, p.SyncName), rs); err != nil { diff --git a/pkg/parse/opts.go b/pkg/parse/opts.go index 4ed504953..6b248063d 100644 --- a/pkg/parse/opts.go +++ b/pkg/parse/opts.go @@ -106,6 +106,8 @@ type Parser interface { K8sClient() client.Client // setRequiresRendering sets the requires-rendering annotation on the RSync setRequiresRendering(ctx context.Context, renderingRequired bool) error + setSourceAnnotations(ctx context.Context, commit string) error + resetSourceAnnotations(ctx context.Context) error } func (o *Options) k8sClient() client.Client { diff --git a/pkg/parse/root.go b/pkg/parse/root.go index 138fb0277..4cf053862 100644 --- a/pkg/parse/root.go +++ b/pkg/parse/root.go @@ -261,6 +261,38 @@ func setSourceStatusFields(source *v1beta1.SourceStatus, newStatus *SourceStatus source.LastUpdate = newStatus.LastUpdate } +func (p *root) setSourceAnnotations(ctx context.Context, commit string) error { + rs := &v1beta1.RootSync{} + if err := p.Client.Get(ctx, rootsync.ObjectKey(p.SyncName), rs); err != nil { + return status.APIServerError(err, "failed to get RootSync for parser") + } + existing := rs.DeepCopy() + currentSourceCommit := rs.GetAnnotations()[metadata.SourceCommitAnnotationKey] + currentImageUrl := rs.GetAnnotations()[metadata.ImageURLAnnotationKey] + if commit != currentSourceCommit || p.Options.SourceRepo != currentImageUrl { + core.SetAnnotation(rs, metadata.SourceCommitAnnotationKey, commit) + core.SetAnnotation(rs, metadata.ImageURLAnnotationKey, p.Options.SourceRepo) + return p.Client.Patch(ctx, rs, client.MergeFrom(existing), client.FieldOwner(configsync.FieldManager)) + } + return nil +} + +func (p *root) resetSourceAnnotations(ctx context.Context) error { + rs := &v1beta1.RootSync{} + if err := p.Client.Get(ctx, rootsync.ObjectKey(p.SyncName), rs); err != nil { + return status.APIServerError(err, "failed to get RootSync for parser") + } + existing := rs.DeepCopy() + _, commitAnnotationFound := rs.GetAnnotations()[metadata.SourceCommitAnnotationKey] + _, urlAnnotationFound := rs.GetAnnotations()[metadata.ImageURLAnnotationKey] + if commitAnnotationFound || urlAnnotationFound { + core.RemoveAnnotations(rs, metadata.SourceCommitAnnotationKey) + core.RemoveAnnotations(rs, metadata.ImageURLAnnotationKey) + return p.Client.Patch(ctx, rs, client.MergeFrom(existing), client.FieldOwner(configsync.FieldManager)) + } + return nil +} + func (p *root) setRequiresRendering(ctx context.Context, renderingRequired bool) error { rs := &v1beta1.RootSync{} if err := p.Client.Get(ctx, rootsync.ObjectKey(p.SyncName), rs); err != nil { diff --git a/pkg/parse/run.go b/pkg/parse/run.go index 1e46c064c..5c56151b4 100644 --- a/pkg/parse/run.go +++ b/pkg/parse/run.go @@ -247,6 +247,21 @@ func run(ctx context.Context, p Parser, trigger string, state *reconcilerState) // pull the source commit and directory with retries within 5 minutes. gs.Commit, syncDir, gs.Errs = hydrate.SourceCommitAndDirWithRetry(util.SourceRetryBackoff, opts.SourceType, opts.SourceDir, opts.SyncDir, opts.ReconcilerName) + // Add pre-sync annotations to the object. + // If updating the object fails, it's likely due to a signature verification error + // from the webhook. In this case, add the error as a source error. + if gs.Errs == nil { + var err error + if opts.SourceType == "git" { + err = p.resetSourceAnnotations(ctx) + } else { + err = p.setSourceAnnotations(ctx, gs.Commit) + } + if err != nil { + gs.Errs = status.Append(gs.Errs, status.SourceError.Wrap(err).Build()) + } + } + // Generate source spec from Reconciler config gs.Spec = SourceSpecFromFileSource(opts.FileSource, opts.SourceType, gs.Commit)