Skip to content

Commit 049a805

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

File tree

9 files changed

+239
-13
lines changed

9 files changed

+239
-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: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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+
eaName := types.NamespacedName{
71+
Name: randStringRunes(5),
72+
Namespace: id,
73+
}
74+
75+
err = applyExternalArtifact(eaName, artifact, revision)
76+
g.Expect(err).NotTo(HaveOccurred())
77+
78+
kustomizationKey := types.NamespacedName{
79+
Name: fmt.Sprintf("ea-%s", randStringRunes(5)),
80+
Namespace: id,
81+
}
82+
kustomization := &kustomizev1.Kustomization{
83+
ObjectMeta: metav1.ObjectMeta{
84+
Name: kustomizationKey.Name,
85+
Namespace: kustomizationKey.Namespace,
86+
},
87+
Spec: kustomizev1.KustomizationSpec{
88+
Interval: metav1.Duration{Duration: time.Hour},
89+
Path: "./",
90+
KubeConfig: &meta.KubeConfigReference{
91+
SecretRef: &meta.SecretKeyReference{
92+
Name: "kubeconfig",
93+
},
94+
},
95+
SourceRef: kustomizev1.CrossNamespaceSourceReference{
96+
Name: eaName.Name,
97+
Namespace: eaName.Namespace,
98+
Kind: sourcev1.ExternalArtifactKind,
99+
},
100+
TargetNamespace: id,
101+
Wait: true,
102+
},
103+
}
104+
105+
g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed())
106+
107+
resultK := &kustomizev1.Kustomization{}
108+
readyCondition := &metav1.Condition{}
109+
110+
t.Run("reconciles from external artifact source", func(t *testing.T) {
111+
g.Eventually(func() bool {
112+
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
113+
readyCondition = apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition)
114+
return resultK.Status.LastAppliedRevision == revision
115+
}, timeout, time.Second).Should(BeTrue())
116+
117+
g.Expect(readyCondition.Reason).To(Equal(meta.ReconciliationSucceededReason))
118+
g.Expect(resultK.Status.LastAppliedRevision).To(Equal(revision))
119+
120+
events := getEvents(resultK.GetName(), map[string]string{"kustomize.toolkit.fluxcd.io/revision": revision})
121+
g.Expect(len(events) > 2).To(BeTrue())
122+
g.Expect(events[0].Reason).To(BeIdenticalTo(meta.ProgressingReason))
123+
g.Expect(events[0].Message).To(ContainSubstring("created"))
124+
g.Expect(events[1].Reason).To(BeIdenticalTo(meta.ProgressingReason))
125+
g.Expect(events[1].Message).To(ContainSubstring("check passed"))
126+
g.Expect(events[2].Reason).To(BeIdenticalTo(meta.ReconciliationSucceededReason))
127+
g.Expect(events[2].Message).To(ContainSubstring("finished"))
128+
})
129+
130+
t.Run("watches for external artifact revision change", func(t *testing.T) {
131+
newRev := "v2.0.0"
132+
err = applyExternalArtifact(eaName, artifact, newRev)
133+
g.Expect(err).NotTo(HaveOccurred())
134+
135+
g.Eventually(func() bool {
136+
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
137+
return resultK.Status.LastAppliedRevision == newRev
138+
}, timeout, time.Second).Should(BeTrue())
139+
140+
g.Expect(resultK.Status.History).To(HaveLen(1))
141+
g.Expect(resultK.Status.History[0].TotalReconciliations).To(BeEquivalentTo(2))
142+
g.Expect(resultK.Status.History[0].LastReconciledStatus).To(Equal(meta.ReconciliationSucceededReason))
143+
g.Expect(resultK.Status.History[0].Metadata).To(ContainElements(newRev))
144+
})
145+
}
146+
147+
func applyExternalArtifact(objKey client.ObjectKey, artifactName string, revision string) error {
148+
ea := &sourcev1.ExternalArtifact{
149+
TypeMeta: metav1.TypeMeta{
150+
Kind: sourcev1.ExternalArtifactKind,
151+
APIVersion: sourcev1.GroupVersion.String(),
152+
},
153+
ObjectMeta: metav1.ObjectMeta{
154+
Name: objKey.Name,
155+
Namespace: objKey.Namespace,
156+
},
157+
}
158+
159+
b, _ := os.ReadFile(filepath.Join(testServer.Root(), artifactName))
160+
dig := digest.SHA256.FromBytes(b)
161+
162+
url := fmt.Sprintf("%s/%s", testServer.URL(), artifactName)
163+
164+
status := sourcev1.ExternalArtifactStatus{
165+
Conditions: []metav1.Condition{
166+
{
167+
Type: meta.ReadyCondition,
168+
Status: metav1.ConditionTrue,
169+
LastTransitionTime: metav1.Now(),
170+
Reason: meta.SucceededReason,
171+
},
172+
},
173+
Artifact: &meta.Artifact{
174+
Path: url,
175+
URL: url,
176+
Revision: revision,
177+
Digest: dig.String(),
178+
LastUpdateTime: metav1.Now(),
179+
},
180+
}
181+
182+
patchOpts := []client.PatchOption{
183+
client.ForceOwnership,
184+
client.FieldOwner("kustomize-controller"),
185+
}
186+
187+
if err := k8sClient.Patch(context.Background(), ea, client.Apply, patchOpts...); err != nil {
188+
return err
189+
}
190+
191+
ea.ManagedFields = nil
192+
ea.Status = status
193+
194+
statusOpts := &client.SubResourcePatchOptions{
195+
PatchOptions: client.PatchOptions{
196+
FieldManager: "source-controller",
197+
},
198+
}
199+
200+
if err := k8sClient.Status().Patch(context.Background(), ea, client.Apply, statusOpts); err != nil {
201+
return err
202+
}
203+
return nil
204+
}

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)