@@ -20,12 +20,19 @@ import (
20
20
"context"
21
21
"encoding/json"
22
22
"fmt"
23
+ "reflect"
23
24
"sync"
24
25
25
26
"k8s.io/apimachinery/pkg/api/meta"
26
27
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
28
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
29
+ "k8s.io/apimachinery/pkg/runtime/schema"
27
30
"k8s.io/apimachinery/pkg/types"
31
+ "k8s.io/apimachinery/pkg/util/sets"
28
32
"k8s.io/client-go/dynamic"
33
+ "k8s.io/klog/v2"
34
+ "sigs.k8s.io/controller-runtime/pkg/client"
35
+ kubectlapply "sigs.k8s.io/kubebuilder-declarative-pattern/applylib/third_party/forked/github.com/kubernetes/kubectl/pkg/cmd/apply"
29
36
)
30
37
31
38
// ApplySet is a set of objects that we want to apply to the cluster.
@@ -37,37 +44,68 @@ import (
37
44
// * We expose a "try once" method to better support running from a controller.
38
45
//
39
46
// TODO: Pluggable health functions.
40
- // TODO: Pruning
41
47
type ApplySet struct {
42
48
// client is the dynamic kubernetes client used to apply objects to the k8s cluster.
43
49
client dynamic.Interface
50
+ // ParentClient is the controller runtime client used to apply parent.
51
+ parentClient client.Client
44
52
// restMapper is used to map object kind to resources, and to know if objects are cluster-scoped.
45
53
restMapper meta.RESTMapper
46
54
// patchOptions holds the options used when applying, in particular the fieldManager
47
55
patchOptions metav1.PatchOptions
48
56
57
+ // deleteOptions holds the options used when pruning
58
+ deleteOptions metav1.DeleteOptions
59
+
49
60
// mutex guards trackers
50
61
mutex sync.Mutex
51
62
// trackers is a (mutable) pointer to the (immutable) objectTrackerList, containing a list of objects we are applying.
52
63
trackers * objectTrackerList
64
+
65
+ // whether to prune the previously objects that are no longer in the current deployment manifest list.
66
+ // Finding the objects to prune is done via "apply-set" labels and annotations. See KEP
67
+ // https://github.com/KnVerey/enhancements/blob/b347756461679f62cf985e7a6b0fd0bc28ea9fd2/keps/sig-cli/3659-kubectl-apply-prune/README.md#optional-hint-annotations
68
+ prune bool
69
+ // Parent provides the necessary methods to determine a ApplySet parent object, which can be used to find out all the on-track
70
+ // deployment manifests.
71
+ parent Parent
72
+ // If not given, the tooling value will be the `Parent` Kind.
73
+ tooling string
53
74
}
54
75
55
76
// Options holds the parameters for building an ApplySet.
56
77
type Options struct {
57
78
// Client is the dynamic kubernetes client used to apply objects to the k8s cluster.
58
79
Client dynamic.Interface
80
+ // ParentClient is the controller runtime client used to apply parent.
81
+ ParentClient client.Client
59
82
// RESTMapper is used to map object kind to resources, and to know if objects are cluster-scoped.
60
83
RESTMapper meta.RESTMapper
61
84
// PatchOptions holds the options used when applying, in particular the fieldManager
62
- PatchOptions metav1.PatchOptions
85
+ PatchOptions metav1.PatchOptions
86
+ DeleteOptions metav1.DeleteOptions
87
+ Prune bool
88
+ Parent Parent
89
+ Tooling string
63
90
}
64
91
65
92
// New constructs a new ApplySet
66
93
func New (options Options ) (* ApplySet , error ) {
94
+ parent := options .Parent
95
+ parentRef := & kubectlapply.ApplySetParentRef {Name : parent .Name (), Namespace : parent .Namespace (), RESTMapping : parent .RESTMapping ()}
96
+ kapplyset := kubectlapply .NewApplySet (parentRef , kubectlapply.ApplySetTooling {Name : options .Tooling }, options .RESTMapper )
97
+ if options .PatchOptions .FieldManager == "" {
98
+ options .PatchOptions .FieldManager = kapplyset .FieldManager ()
99
+ }
67
100
a := & ApplySet {
68
- client : options .Client ,
69
- restMapper : options .RESTMapper ,
70
- patchOptions : options .PatchOptions ,
101
+ parentClient : options .ParentClient ,
102
+ client : options .Client ,
103
+ restMapper : options .RESTMapper ,
104
+ patchOptions : options .PatchOptions ,
105
+ deleteOptions : options .DeleteOptions ,
106
+ prune : options .Prune ,
107
+ parent : parent ,
108
+ tooling : options .Tooling ,
71
109
}
72
110
a .trackers = & objectTrackerList {}
73
111
return a , nil
@@ -85,6 +123,11 @@ func (a *ApplySet) SetDesiredObjects(objects []ApplyableObject) error {
85
123
return nil
86
124
}
87
125
126
+ type restMappingResult struct {
127
+ restMapping * meta.RESTMapping
128
+ err error
129
+ }
130
+
88
131
// ApplyOnce will make one attempt to apply all objects and observe their health.
89
132
// It does not wait for the objects to become healthy, but will report their health.
90
133
//
@@ -100,6 +143,43 @@ func (a *ApplySet) ApplyOnce(ctx context.Context) (*ApplyResults, error) {
100
143
a .mutex .Unlock ()
101
144
102
145
results := & ApplyResults {total : len (trackers .items )}
146
+ visitedUids := sets .New [types.UID ]()
147
+
148
+ // We initialize a new Kubectl ApplySet for each ApplyOnce run. This is because kubectl Applyset is designed for
149
+ // single actuation and not for reconciliation.
150
+ // Note: The Kubectl ApplySet will share the RESTMapper with k-d-p/ApplySet, which caches all the manifests in the past.
151
+ parentRef := & kubectlapply.ApplySetParentRef {Name : a .parent .Name (), Namespace : a .parent .Namespace (), RESTMapping : a .parent .RESTMapping ()}
152
+ kapplyset := kubectlapply .NewApplySet (parentRef , kubectlapply.ApplySetTooling {Name : a .tooling }, a .restMapper )
153
+
154
+ // Cache the current RESTMappings to avoid re-fetching the bad ones.
155
+ restMappings := make (map [schema.GroupVersionKind ]restMappingResult )
156
+ for i := range trackers .items {
157
+ tracker := & trackers .items [i ]
158
+ obj := tracker .desired
159
+
160
+ gvk := obj .GroupVersionKind ()
161
+
162
+ result , found := restMappings [gvk ]
163
+ if ! found {
164
+ restMapping , err := a .restMapper .RESTMapping (gvk .GroupKind (), gvk .Version )
165
+ result = restMappingResult {
166
+ restMapping : restMapping ,
167
+ err : err ,
168
+ }
169
+ restMappings [gvk ] = result
170
+ }
171
+
172
+ // TODO: Check error is NotFound and not some transient error?
173
+ restMapping := result .restMapping
174
+ if restMapping != nil {
175
+ // cache the GVK in kubectlapply. kubectlapply will use this to calculate
176
+ // the latest parent "applyset.kubernetes.io/contains-group-resources" annotation after applying.
177
+ kapplyset .AddResource (restMapping , obj .GetNamespace ())
178
+ }
179
+ }
180
+ if err := a .WithParent (ctx , kapplyset ); err != nil {
181
+ return results , fmt .Errorf ("unable to update Parent: %w" , err )
182
+ }
103
183
104
184
for i := range trackers .items {
105
185
tracker := & trackers .items [i ]
@@ -110,11 +190,23 @@ func (a *ApplySet) ApplyOnce(ctx context.Context) (*ApplyResults, error) {
110
190
gvk := obj .GroupVersionKind ()
111
191
nn := types.NamespacedName {Namespace : ns , Name : name }
112
192
113
- restMapping , err := a .restMapper .RESTMapping (gvk .GroupKind (), gvk .Version )
114
- if err != nil {
115
- results .applyError (gvk , nn , fmt .Errorf ("error getting rest mapping for %v: %w" , gvk , err ))
193
+ restMappingResult := restMappings [gvk ]
194
+ if restMappingResult .err != nil {
195
+ results .applyError (gvk , nn , fmt .Errorf ("error getting rest mapping for %v: %w" , gvk , restMappingResult .err ))
196
+ continue
197
+ }
198
+
199
+ restMapping := restMappingResult .restMapping
200
+ if restMapping == nil {
201
+ // Should be impossible
202
+ results .applyError (gvk , nn , fmt .Errorf ("rest mapping result not found for %v" , gvk ))
116
203
continue
117
204
}
205
+
206
+ if err := a .updateManifestLabel (obj , kapplyset .LabelsForMember ()); err != nil {
207
+ return results , fmt .Errorf ("unable to update label for %v/%v %v: %w" , obj .GetName (), obj .GetNamespace (), gvk , err )
208
+ }
209
+
118
210
gvr := restMapping .Resource
119
211
120
212
var dynamicResource dynamic.ResourceInterface
@@ -140,7 +232,6 @@ func (a *ApplySet) ApplyOnce(ctx context.Context) (*ApplyResults, error) {
140
232
// Internal error ... this is panic-level
141
233
return nil , fmt .Errorf ("unknown scope for gvk %s: %q" , gvk , restMapping .Scope .Name ())
142
234
}
143
-
144
235
j , err := json .Marshal (obj )
145
236
if err != nil {
146
237
// TODO: Differentiate between server-fixable vs client-fixable errors?
@@ -153,11 +244,144 @@ func (a *ApplySet) ApplyOnce(ctx context.Context) (*ApplyResults, error) {
153
244
results .applyError (gvk , nn , fmt .Errorf ("error from apply: %w" , err ))
154
245
continue
155
246
}
156
-
247
+ visitedUids . Insert ( lastApplied . GetUID ())
157
248
tracker .lastApplied = lastApplied
158
249
results .applySuccess (gvk , nn )
159
250
tracker .isHealthy = isHealthy (lastApplied )
160
251
results .reportHealth (gvk , nn , tracker .isHealthy )
161
252
}
253
+
254
+ // We want to be more cautions on pruning and only do it if all manifests are applied.
255
+ if a .prune && results .applyFailCount == 0 {
256
+ klog .V (4 ).Infof ("Prune is enabled" )
257
+ pruneObjects , err := kapplyset .FindAllObjectsToPrune (ctx , a .client , visitedUids )
258
+ if err != nil {
259
+ return results , err
260
+ }
261
+ if err = a .deleteObjects (ctx , pruneObjects , results ); err != nil {
262
+ return results , err
263
+ }
264
+ // "latest" mode updates the parent "applyset.kubernetes.io/contains-group-resources" annotations
265
+ // to only contain the current manifest GVRs.
266
+ if err := a .updateParentLabelsAndAnnotations (ctx , kapplyset , "latest" ); err != nil {
267
+ klog .Errorf ("update parent failed %v" , err )
268
+ }
269
+ }
162
270
return results , nil
163
271
}
272
+
273
+ // updateManifestLabel adds the "applyset.kubernetes.io/part-of: Parent-ID" label to the manifest.
274
+ func (a * ApplySet ) updateManifestLabel (obj ApplyableObject , applysetLabels map [string ]string ) error {
275
+ u , ok := obj .(* unstructured.Unstructured )
276
+ if ! ok {
277
+ return fmt .Errorf ("unable to convert `ApplyableObject` to `unstructured.Unstructured` %v/%v %v" ,
278
+ obj .GetName (), obj .GetNamespace (), obj .GroupVersionKind ().String ())
279
+ }
280
+ labels := u .GetLabels ()
281
+ if labels == nil {
282
+ labels = make (map [string ]string )
283
+ }
284
+ for k , v := range applysetLabels {
285
+ labels [k ] = v
286
+ }
287
+ u .SetLabels (labels )
288
+ return nil
289
+ }
290
+
291
+ // updateParentLabelsAndAnnotations updates the parent labels and annotations.
292
+ func (a * ApplySet ) updateParentLabelsAndAnnotations (ctx context.Context , kapplyset * kubectlapply.ApplySet , mode kubectlapply.ApplySetUpdateMode ) error {
293
+ parent , err := meta .Accessor (a .parent .GetSubject ())
294
+ if err != nil {
295
+ return err
296
+ }
297
+
298
+ original , err := meta .Accessor (a .parent .GetSubject ().DeepCopyObject ())
299
+ if err != nil {
300
+ return err
301
+ }
302
+ partialParent := kapplyset .BuildParentPatch (mode )
303
+
304
+ // update annotation
305
+ annotations := parent .GetAnnotations ()
306
+ if annotations == nil {
307
+ annotations = make (map [string ]string )
308
+ }
309
+ for k , v := range partialParent .Annotations {
310
+ annotations [k ] = v
311
+ }
312
+ parent .SetAnnotations (annotations )
313
+
314
+ // update labels
315
+ labels := parent .GetLabels ()
316
+ if labels == nil {
317
+ labels = make (map [string ]string )
318
+ }
319
+ for k , v := range partialParent .Labels {
320
+ labels [k ] = v
321
+ }
322
+ parent .SetLabels (labels )
323
+
324
+ // update parent in the cluster.
325
+ if ! reflect .DeepEqual (original .GetLabels (), parent .GetLabels ()) || ! reflect .DeepEqual (original .GetAnnotations (), parent .GetAnnotations ()) {
326
+ if err := a .parentClient .Update (ctx , parent .(client.Object )); err != nil {
327
+ return fmt .Errorf ("error updating parent %w" , err )
328
+ }
329
+ }
330
+ return nil
331
+ }
332
+
333
+ func (a * ApplySet ) deleteObjects (ctx context.Context , pruneObjects []kubectlapply.PruneObject , results * ApplyResults ) error {
334
+ for i := range pruneObjects {
335
+ pruneObject := & pruneObjects [i ]
336
+ name := pruneObject .Name
337
+ namespace := pruneObject .Namespace
338
+ mapping := pruneObject .Mapping
339
+ gvk := pruneObject .Object .GetObjectKind ().GroupVersionKind ()
340
+ nn := types.NamespacedName {Namespace : namespace , Name : name }
341
+
342
+ if err := a .client .Resource (mapping .Resource ).Namespace (namespace ).Delete (ctx , name , a .deleteOptions ); err != nil {
343
+ results .pruneError (gvk , nn , fmt .Errorf ("error from delete: %w" , err ))
344
+ } else {
345
+ klog .Infof ("pruned resource %v" , pruneObject .String ())
346
+ results .pruneSuccess (gvk , nn )
347
+ }
348
+ }
349
+ return nil
350
+ }
351
+
352
+ // WithParent guarantees the parent has the right applyset labels.
353
+ // It uses "superset" mode to determine the "applyset.kubernetes.io/contains-group-resources" which contains both
354
+ //
355
+ // previous manifests GVRs and the current manifests GVRs.
356
+ func (a * ApplySet ) WithParent (ctx context.Context , kapplyset * kubectlapply.ApplySet ) error {
357
+ parent := a .parent .GetSubject ()
358
+ object , err := meta .Accessor (parent )
359
+ if err != nil {
360
+ return err
361
+ }
362
+ //kubectlapply requires the tooling and id to exist.
363
+ {
364
+ annotations := object .GetAnnotations ()
365
+ if annotations == nil {
366
+ annotations = make (map [string ]string )
367
+ }
368
+ annotations [kubectlapply .ApplySetToolingAnnotation ] = a .tooling
369
+ if _ , ok := annotations [kubectlapply .ApplySetGRsAnnotation ]; ! ok {
370
+ annotations [kubectlapply .ApplySetGRsAnnotation ] = ""
371
+ }
372
+ object .SetAnnotations (annotations )
373
+
374
+ labels := object .GetLabels ()
375
+ if labels == nil {
376
+ labels = make (map [string ]string )
377
+ }
378
+ labels [kubectlapply .ApplySetParentIDLabel ] = kapplyset .ID ()
379
+ object .SetLabels (labels )
380
+ }
381
+ // This is not a cluster fetch. It builds up the parents labels and annotations information in kapplyset.
382
+ if err := kapplyset .FetchParent (a .parent .GetSubject ()); err != nil {
383
+ return err
384
+ }
385
+
386
+ return a .updateParentLabelsAndAnnotations (ctx , kapplyset , "superset" )
387
+ }
0 commit comments