Skip to content

Commit f9d7ab3

Browse files
authored
Merge pull request kubernetes-sigs#325 from yuwenma/applyset
Prune logic for ApplySetApplier
2 parents 6ba29ca + 4ef4e90 commit f9d7ab3

File tree

30 files changed

+1569
-117
lines changed

30 files changed

+1569
-117
lines changed

applylib/applyset/applyset.go

Lines changed: 234 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,19 @@ import (
2020
"context"
2121
"encoding/json"
2222
"fmt"
23+
"reflect"
2324
"sync"
2425

2526
"k8s.io/apimachinery/pkg/api/meta"
2627
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
28+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
29+
"k8s.io/apimachinery/pkg/runtime/schema"
2730
"k8s.io/apimachinery/pkg/types"
31+
"k8s.io/apimachinery/pkg/util/sets"
2832
"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"
2936
)
3037

3138
// ApplySet is a set of objects that we want to apply to the cluster.
@@ -37,37 +44,68 @@ import (
3744
// * We expose a "try once" method to better support running from a controller.
3845
//
3946
// TODO: Pluggable health functions.
40-
// TODO: Pruning
4147
type ApplySet struct {
4248
// client is the dynamic kubernetes client used to apply objects to the k8s cluster.
4349
client dynamic.Interface
50+
// ParentClient is the controller runtime client used to apply parent.
51+
parentClient client.Client
4452
// restMapper is used to map object kind to resources, and to know if objects are cluster-scoped.
4553
restMapper meta.RESTMapper
4654
// patchOptions holds the options used when applying, in particular the fieldManager
4755
patchOptions metav1.PatchOptions
4856

57+
// deleteOptions holds the options used when pruning
58+
deleteOptions metav1.DeleteOptions
59+
4960
// mutex guards trackers
5061
mutex sync.Mutex
5162
// trackers is a (mutable) pointer to the (immutable) objectTrackerList, containing a list of objects we are applying.
5263
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
5374
}
5475

5576
// Options holds the parameters for building an ApplySet.
5677
type Options struct {
5778
// Client is the dynamic kubernetes client used to apply objects to the k8s cluster.
5879
Client dynamic.Interface
80+
// ParentClient is the controller runtime client used to apply parent.
81+
ParentClient client.Client
5982
// RESTMapper is used to map object kind to resources, and to know if objects are cluster-scoped.
6083
RESTMapper meta.RESTMapper
6184
// 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
6390
}
6491

6592
// New constructs a new ApplySet
6693
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+
}
67100
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,
71109
}
72110
a.trackers = &objectTrackerList{}
73111
return a, nil
@@ -85,6 +123,11 @@ func (a *ApplySet) SetDesiredObjects(objects []ApplyableObject) error {
85123
return nil
86124
}
87125

126+
type restMappingResult struct {
127+
restMapping *meta.RESTMapping
128+
err error
129+
}
130+
88131
// ApplyOnce will make one attempt to apply all objects and observe their health.
89132
// It does not wait for the objects to become healthy, but will report their health.
90133
//
@@ -100,6 +143,43 @@ func (a *ApplySet) ApplyOnce(ctx context.Context) (*ApplyResults, error) {
100143
a.mutex.Unlock()
101144

102145
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+
}
103183

104184
for i := range trackers.items {
105185
tracker := &trackers.items[i]
@@ -110,11 +190,23 @@ func (a *ApplySet) ApplyOnce(ctx context.Context) (*ApplyResults, error) {
110190
gvk := obj.GroupVersionKind()
111191
nn := types.NamespacedName{Namespace: ns, Name: name}
112192

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))
116203
continue
117204
}
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+
118210
gvr := restMapping.Resource
119211

120212
var dynamicResource dynamic.ResourceInterface
@@ -140,7 +232,6 @@ func (a *ApplySet) ApplyOnce(ctx context.Context) (*ApplyResults, error) {
140232
// Internal error ... this is panic-level
141233
return nil, fmt.Errorf("unknown scope for gvk %s: %q", gvk, restMapping.Scope.Name())
142234
}
143-
144235
j, err := json.Marshal(obj)
145236
if err != nil {
146237
// TODO: Differentiate between server-fixable vs client-fixable errors?
@@ -153,11 +244,144 @@ func (a *ApplySet) ApplyOnce(ctx context.Context) (*ApplyResults, error) {
153244
results.applyError(gvk, nn, fmt.Errorf("error from apply: %w", err))
154245
continue
155246
}
156-
247+
visitedUids.Insert(lastApplied.GetUID())
157248
tracker.lastApplied = lastApplied
158249
results.applySuccess(gvk, nn)
159250
tracker.isHealthy = isHealthy(lastApplied)
160251
results.reportHealth(gvk, nn, tracker.isHealthy)
161252
}
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+
}
162270
return results, nil
163271
}
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+
}

applylib/applyset/applyset_test.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@ import (
3131
func TestApplySet(t *testing.T) {
3232
h := testutils.NewHarness(t)
3333

34-
existing := ``
34+
// The parent should exist in the cluster
35+
existing := `
36+
apiVersion: v1
37+
kind: ConfigMap
38+
metadata:
39+
name: test
40+
namespace: default
41+
`
3542

3643
apply := `
3744
apiVersion: v1
@@ -60,11 +67,20 @@ data:
6067

6168
force := true
6269
patchOptions.Force = &force
63-
70+
parent := h.ParseObjects(existing)[0]
71+
parentGVK := parent.GroupVersionKind()
72+
restmapping, err := h.RESTMapper().RESTMapping(parentGVK.GroupKind(), parentGVK.Version)
73+
if err != nil {
74+
h.Fatalf("error building parent restmappaing: %v", err)
75+
}
6476
s, err := New(Options{
65-
RESTMapper: h.RESTMapper(),
66-
Client: h.DynamicClient(),
67-
PatchOptions: patchOptions,
77+
Parent: NewParentRef(parent, "test", "default", restmapping),
78+
RESTMapper: h.RESTMapper(),
79+
Client: h.DynamicClient(),
80+
ParentClient: h.Client(),
81+
PatchOptions: patchOptions,
82+
DeleteOptions: metav1.DeleteOptions{},
83+
Prune: true,
6884
})
6985
if err != nil {
7086
h.Fatalf("error building applyset object: %v", err)

0 commit comments

Comments
 (0)