Skip to content

Commit 405c8b8

Browse files
committed
Implement ExternalArtifact reconciliation
Signed-off-by: Stefan Prodan <[email protected]>
1 parent 3d6179c commit 405c8b8

File tree

9 files changed

+243
-13
lines changed

9 files changed

+243
-13
lines changed

api/v1/reference_types.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type CrossNamespaceSourceReference struct {
2828
APIVersion string `json:"apiVersion,omitempty"`
2929

3030
// Kind of the referent.
31-
// +kubebuilder:validation:Enum=OCIRepository;GitRepository;Bucket
31+
// +kubebuilder:validation:Enum=OCIRepository;GitRepository;Bucket;ExternalArtifact
3232
// +required
3333
Kind string `json:"kind"`
3434

config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,7 @@ spec:
494494
- OCIRepository
495495
- GitRepository
496496
- Bucket
497+
- ExternalArtifact
497498
type: string
498499
name:
499500
description: Name of the referent.

config/default/kustomization.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1
22
kind: Kustomization
33
namespace: kustomize-system
44
resources:
5-
- https://github.com/fluxcd/source-controller/releases/download/v1.7.0-rc.1/source-controller.crds.yaml
6-
- https://github.com/fluxcd/source-controller/releases/download/v1.7.0-rc.1/source-controller.deployment.yaml
5+
- https://github.com/fluxcd/source-controller/releases/download/v1.7.0-rc.2/source-controller.crds.yaml
6+
- https://github.com/fluxcd/source-controller/releases/download/v1.7.0-rc.2/source-controller.deployment.yaml
77
- ../crd
88
- ../rbac
99
- ../manager

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ require (
3232
github.com/fluxcd/pkg/ssa v0.53.0
3333
github.com/fluxcd/pkg/tar v0.14.0
3434
github.com/fluxcd/pkg/testserver v0.13.0
35-
github.com/fluxcd/source-controller/api v1.7.0-rc.1
35+
github.com/fluxcd/source-controller/api v1.7.0-rc.2
3636
github.com/getsops/sops/v3 v3.10.2
3737
github.com/google/cel-go v0.26.1
3838
github.com/hashicorp/vault/api v1.20.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,8 @@ github.com/fluxcd/pkg/tar v0.14.0 h1:9Gku8FIvPt2bixKldZnzXJ/t+7SloxePlzyVGOK8GVQ
217217
github.com/fluxcd/pkg/tar v0.14.0/go.mod h1:+rOWYk93qLEJ8WwmkvJOkB8i0dna1mrwJFybE8i9Udo=
218218
github.com/fluxcd/pkg/testserver v0.13.0 h1:xEpBcEYtD7bwvZ+i0ZmChxKkDo/wfQEV3xmnzVybSSg=
219219
github.com/fluxcd/pkg/testserver v0.13.0/go.mod h1:akRYv3FLQUsme15na9ihECRG6hBuqni4XEY9W8kzs8E=
220-
github.com/fluxcd/source-controller/api v1.7.0-rc.1 h1:FPTZJqLFJQHjP53m1IXN1JzuE0s6KPAU2JepFuXAlDE=
221-
github.com/fluxcd/source-controller/api v1.7.0-rc.1/go.mod h1:sbJibK4Ik+2AuTRRLXPA+n2u6nLUIGaxC07ava+RqeM=
220+
github.com/fluxcd/source-controller/api v1.7.0-rc.2 h1:ny21QMsZ1Gs5t5Rx7Pd1s0xc5UT7B4hGySzX+mhWHnw=
221+
github.com/fluxcd/source-controller/api v1.7.0-rc.2/go.mod h1:sbJibK4Ik+2AuTRRLXPA+n2u6nLUIGaxC07ava+RqeM=
222222
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
223223
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
224224
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=

internal/controller/kustomization_controller.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,16 @@ func (r *KustomizationReconciler) getSource(ctx context.Context,
662662
return src, fmt.Errorf("unable to get source '%s': %w", namespacedName, err)
663663
}
664664
src = &bucket
665+
case sourcev1.ExternalArtifactKind:
666+
var ea sourcev1.ExternalArtifact
667+
err := r.Client.Get(ctx, namespacedName, &ea)
668+
if err != nil {
669+
if apierrors.IsNotFound(err) {
670+
return src, err
671+
}
672+
return src, fmt.Errorf("unable to get source '%s': %w", namespacedName, err)
673+
}
674+
src = &ea
665675
default:
666676
return src, fmt.Errorf("source `%s` kind '%s' not supported",
667677
obj.Spec.SourceRef.Name, obj.Spec.SourceRef.Kind)
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package controller
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"os"
23+
"path/filepath"
24+
"testing"
25+
"time"
26+
27+
"github.com/fluxcd/pkg/apis/meta"
28+
"github.com/fluxcd/pkg/testserver"
29+
sourcev1 "github.com/fluxcd/source-controller/api/v1"
30+
. "github.com/onsi/gomega"
31+
"github.com/opencontainers/go-digest"
32+
apimeta "k8s.io/apimachinery/pkg/api/meta"
33+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
34+
"k8s.io/apimachinery/pkg/types"
35+
"sigs.k8s.io/controller-runtime/pkg/client"
36+
37+
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1"
38+
)
39+
40+
func TestKustomizationReconciler_ExternalArtifact(t *testing.T) {
41+
g := NewWithT(t)
42+
id := "ea-" + randStringRunes(5)
43+
revision := "v1.0.0"
44+
45+
err := createNamespace(id)
46+
g.Expect(err).NotTo(HaveOccurred(), "failed to create test namespace")
47+
48+
err = createKubeConfigSecret(id)
49+
g.Expect(err).NotTo(HaveOccurred(), "failed to create kubeconfig secret")
50+
51+
manifests := func(name string, data string) []testserver.File {
52+
return []testserver.File{
53+
{
54+
Name: "secret.yaml",
55+
Body: fmt.Sprintf(`---
56+
apiVersion: v1
57+
kind: Secret
58+
metadata:
59+
name: %[1]s
60+
stringData:
61+
key: "%[2]s"
62+
`, name, data),
63+
},
64+
}
65+
}
66+
67+
artifact, err := testServer.ArtifactFromFiles(manifests(id, randStringRunes(5)))
68+
g.Expect(err).NotTo(HaveOccurred(), "failed to create artifact from files")
69+
70+
sourceNamespace := fmt.Sprintf("source-%v", id)
71+
err = createNamespace(sourceNamespace)
72+
g.Expect(err).NotTo(HaveOccurred(), "failed to create source namespace")
73+
74+
eaName := types.NamespacedName{
75+
Name: randStringRunes(5),
76+
Namespace: sourceNamespace,
77+
}
78+
79+
err = applyExternalArtifact(eaName, artifact, revision)
80+
g.Expect(err).NotTo(HaveOccurred())
81+
82+
kustomizationKey := types.NamespacedName{
83+
Name: fmt.Sprintf("ea-%s", randStringRunes(5)),
84+
Namespace: id,
85+
}
86+
kustomization := &kustomizev1.Kustomization{
87+
ObjectMeta: metav1.ObjectMeta{
88+
Name: kustomizationKey.Name,
89+
Namespace: kustomizationKey.Namespace,
90+
},
91+
Spec: kustomizev1.KustomizationSpec{
92+
Interval: metav1.Duration{Duration: reconciliationInterval},
93+
Path: "./",
94+
KubeConfig: &meta.KubeConfigReference{
95+
SecretRef: &meta.SecretKeyReference{
96+
Name: "kubeconfig",
97+
},
98+
},
99+
SourceRef: kustomizev1.CrossNamespaceSourceReference{
100+
Name: eaName.Name,
101+
Namespace: eaName.Namespace,
102+
Kind: sourcev1.ExternalArtifactKind,
103+
},
104+
TargetNamespace: id,
105+
Wait: true,
106+
},
107+
}
108+
109+
g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed())
110+
111+
resultK := &kustomizev1.Kustomization{}
112+
readyCondition := &metav1.Condition{}
113+
114+
t.Run("reconciles from external artifact source", func(t *testing.T) {
115+
g.Eventually(func() bool {
116+
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
117+
readyCondition = apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition)
118+
return resultK.Status.LastAppliedRevision == revision
119+
}, timeout, time.Second).Should(BeTrue())
120+
121+
g.Expect(readyCondition.Reason).To(Equal(meta.ReconciliationSucceededReason))
122+
g.Expect(resultK.Status.LastAppliedRevision).To(Equal(revision))
123+
124+
events := getEvents(resultK.GetName(), map[string]string{"kustomize.toolkit.fluxcd.io/revision": revision})
125+
g.Expect(len(events) > 2).To(BeTrue())
126+
g.Expect(events[0].Reason).To(BeIdenticalTo(meta.ProgressingReason))
127+
g.Expect(events[0].Message).To(ContainSubstring("created"))
128+
g.Expect(events[1].Reason).To(BeIdenticalTo(meta.ProgressingReason))
129+
g.Expect(events[1].Message).To(ContainSubstring("check passed"))
130+
g.Expect(events[2].Reason).To(BeIdenticalTo(meta.ReconciliationSucceededReason))
131+
g.Expect(events[2].Message).To(ContainSubstring("finished"))
132+
})
133+
134+
t.Run("watches for external artifact revision change", func(t *testing.T) {
135+
newRev := "v2.0.0"
136+
err = applyExternalArtifact(eaName, artifact, newRev)
137+
g.Expect(err).NotTo(HaveOccurred())
138+
139+
g.Eventually(func() bool {
140+
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
141+
return resultK.Status.LastAppliedRevision == newRev
142+
}, timeout, time.Second).Should(BeTrue())
143+
144+
g.Expect(resultK.Status.History).To(HaveLen(1))
145+
g.Expect(resultK.Status.History[0].TotalReconciliations).To(BeEquivalentTo(2))
146+
g.Expect(resultK.Status.History[0].LastReconciledStatus).To(Equal(meta.ReconciliationSucceededReason))
147+
g.Expect(resultK.Status.History[0].Metadata).To(ContainElements(newRev))
148+
})
149+
}
150+
151+
func applyExternalArtifact(objKey client.ObjectKey, artifactName string, revision string) error {
152+
ea := &sourcev1.ExternalArtifact{
153+
TypeMeta: metav1.TypeMeta{
154+
Kind: sourcev1.ExternalArtifactKind,
155+
APIVersion: sourcev1.GroupVersion.String(),
156+
},
157+
ObjectMeta: metav1.ObjectMeta{
158+
Name: objKey.Name,
159+
Namespace: objKey.Namespace,
160+
},
161+
}
162+
163+
b, _ := os.ReadFile(filepath.Join(testServer.Root(), artifactName))
164+
dig := digest.SHA256.FromBytes(b)
165+
166+
url := fmt.Sprintf("%s/%s", testServer.URL(), artifactName)
167+
168+
status := sourcev1.ExternalArtifactStatus{
169+
Conditions: []metav1.Condition{
170+
{
171+
Type: meta.ReadyCondition,
172+
Status: metav1.ConditionTrue,
173+
LastTransitionTime: metav1.Now(),
174+
Reason: meta.SucceededReason,
175+
},
176+
},
177+
Artifact: &meta.Artifact{
178+
Path: url,
179+
URL: url,
180+
Revision: revision,
181+
Digest: dig.String(),
182+
LastUpdateTime: metav1.Now(),
183+
},
184+
}
185+
186+
patchOpts := []client.PatchOption{
187+
client.ForceOwnership,
188+
client.FieldOwner("kustomize-controller"),
189+
}
190+
191+
if err := k8sClient.Patch(context.Background(), ea, client.Apply, patchOpts...); err != nil {
192+
return err
193+
}
194+
195+
ea.ManagedFields = nil
196+
ea.Status = status
197+
198+
statusOpts := &client.SubResourcePatchOptions{
199+
PatchOptions: client.PatchOptions{
200+
FieldManager: "source-controller",
201+
},
202+
}
203+
204+
if err := k8sClient.Status().Patch(context.Background(), ea, client.Apply, statusOpts); err != nil {
205+
return err
206+
}
207+
return nil
208+
}

internal/controller/kustomization_indexers.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,12 @@ import (
2222

2323
"github.com/fluxcd/pkg/apis/meta"
2424
"github.com/fluxcd/pkg/runtime/conditions"
25+
"github.com/fluxcd/pkg/runtime/dependency"
2526
ctrl "sigs.k8s.io/controller-runtime"
2627
"sigs.k8s.io/controller-runtime/pkg/client"
2728
"sigs.k8s.io/controller-runtime/pkg/handler"
2829
"sigs.k8s.io/controller-runtime/pkg/reconcile"
2930

30-
"github.com/fluxcd/pkg/runtime/dependency"
31-
3231
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1"
3332
)
3433

internal/controller/kustomization_manager.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,20 @@ type KustomizationReconcilerOptions struct {
4949
// changes in those sources, as well as for ConfigMaps and Secrets that the Kustomizations depend on.
5050
func (r *KustomizationReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opts KustomizationReconcilerOptions) error {
5151
const (
52-
indexOCIRepository = ".metadata.ociRepository"
53-
indexGitRepository = ".metadata.gitRepository"
54-
indexBucket = ".metadata.bucket"
55-
indexConfigMap = ".metadata.configMap"
56-
indexSecret = ".metadata.secret"
52+
indexExternalArtifact = ".metadata.externalArtifact"
53+
indexOCIRepository = ".metadata.ociRepository"
54+
indexGitRepository = ".metadata.gitRepository"
55+
indexBucket = ".metadata.bucket"
56+
indexConfigMap = ".metadata.configMap"
57+
indexSecret = ".metadata.secret"
5758
)
5859

60+
// Index the Kustomizations by the ExternalArtifact references they (may) point at.
61+
if err := mgr.GetCache().IndexField(ctx, &kustomizev1.Kustomization{}, indexExternalArtifact,
62+
r.indexBy(sourcev1.ExternalArtifactKind)); err != nil {
63+
return fmt.Errorf("failed creating index %s: %w", indexExternalArtifact, err)
64+
}
65+
5966
// Index the Kustomizations by the OCIRepository references they (may) point at.
6067
if err := mgr.GetCache().IndexField(ctx, &kustomizev1.Kustomization{}, indexOCIRepository,
6168
r.indexBy(sourcev1.OCIRepositoryKind)); err != nil {
@@ -129,6 +136,11 @@ func (r *KustomizationReconciler) SetupWithManager(ctx context.Context, mgr ctrl
129136
For(&kustomizev1.Kustomization{}, builder.WithPredicates(
130137
predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}),
131138
)).
139+
Watches(
140+
&sourcev1.ExternalArtifact{},
141+
handler.EnqueueRequestsFromMapFunc(r.requestsForRevisionChangeOf(indexExternalArtifact)),
142+
builder.WithPredicates(SourceRevisionChangePredicate{}),
143+
).
132144
Watches(
133145
&sourcev1.OCIRepository{},
134146
handler.EnqueueRequestsFromMapFunc(r.requestsForRevisionChangeOf(indexOCIRepository)),

0 commit comments

Comments
 (0)