|
1 | 1 | package applier |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bytes" |
4 | 5 | "context" |
5 | 6 | "testing" |
| 7 | + |
6 | 8 | "time" |
7 | 9 |
|
| 10 | + "github.com/google/go-cmp/cmp" |
| 11 | + |
8 | 12 | v1 "k8s.io/api/apps/v1" |
9 | 13 | apiequality "k8s.io/apimachinery/pkg/api/equality" |
10 | 14 | apierrors "k8s.io/apimachinery/pkg/api/errors" |
11 | 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
12 | 16 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" |
13 | 17 | "k8s.io/apimachinery/pkg/runtime" |
14 | 18 | "k8s.io/apimachinery/pkg/runtime/serializer" |
| 19 | + "k8s.io/apimachinery/pkg/types" |
15 | 20 | clienttesting "k8s.io/client-go/testing" |
16 | 21 | fakework "open-cluster-management.io/api/client/work/clientset/versioned/fake" |
17 | 22 | workinformers "open-cluster-management.io/api/client/work/informers/externalversions" |
18 | 23 | workapiv1 "open-cluster-management.io/api/work/v1" |
| 24 | + "sigs.k8s.io/controller-runtime/pkg/client" |
| 25 | + "sigs.k8s.io/controller-runtime/pkg/client/fake" |
19 | 26 | ) |
20 | 27 |
|
21 | 28 | // assertActions asserts the actual actions have the expected action verb |
@@ -50,6 +57,7 @@ func newUnstructured(apiVersion, kind, namespace, name string) *unstructured.Uns |
50 | 57 |
|
51 | 58 | func newFakeWork(name, namespace string, obj runtime.Object) *workapiv1.ManifestWork { |
52 | 59 | rawObject, _ := runtime.Encode(unstructured.UnstructuredJSONScheme, obj) |
| 60 | + rawObject = bytes.TrimRight(rawObject, "\n") |
53 | 61 |
|
54 | 62 | return &workapiv1.ManifestWork{ |
55 | 63 | ObjectMeta: metav1.ObjectMeta{ |
@@ -183,6 +191,119 @@ func TestWorkApplierWithTypedClient(t *testing.T) { |
183 | 191 | assertActions(t, fakeWorkClient.Actions(), "delete") |
184 | 192 | } |
185 | 193 |
|
| 194 | +func getWork(t *testing.T, c client.Client, namespace, name string) *workapiv1.ManifestWork { |
| 195 | + t.Helper() |
| 196 | + work := &workapiv1.ManifestWork{} |
| 197 | + if err := c.Get(context.TODO(), types.NamespacedName{Namespace: namespace, Name: name}, work); err != nil { |
| 198 | + t.Fatalf("failed to get work %s/%s: %v", namespace, name, err) |
| 199 | + } |
| 200 | + return work |
| 201 | +} |
| 202 | + |
| 203 | +func assertWorkState(t *testing.T, c client.Client, namespace, name string, desired *workapiv1.ManifestWork) { |
| 204 | + t.Helper() |
| 205 | + actual := getWork(t, c, namespace, name) |
| 206 | + if diff := cmp.Diff(desired.Spec, actual.Spec); diff != "" { |
| 207 | + t.Fatalf("spec of %s/%s mismatch (-want +got):\n%s", namespace, name, diff) |
| 208 | + } |
| 209 | + if diff := cmp.Diff(desired.Annotations, actual.Annotations); diff != "" { |
| 210 | + t.Fatalf("annotations of %s/%s mismatch (-want +got):\n%s", namespace, name, diff) |
| 211 | + } |
| 212 | +} |
| 213 | + |
| 214 | +func TestWorkApplierWithRuntimeClient(t *testing.T) { |
| 215 | + scheme := runtime.NewScheme() |
| 216 | + if err := workapiv1.Install(scheme); err != nil { |
| 217 | + t.Fatalf("failed to add work scheme: %v", err) |
| 218 | + } |
| 219 | + |
| 220 | + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() |
| 221 | + workApplier := NewWorkApplierWithRuntimeClient(fakeClient) |
| 222 | + ctx := context.TODO() |
| 223 | + |
| 224 | + baseWork := newFakeWork("test", "test", newUnstructured("batch/v1", "Job", "default", "test")) |
| 225 | + desired := baseWork.DeepCopy() |
| 226 | + |
| 227 | + // Create: apply a MW that doesn't exist, verify it's persisted |
| 228 | + if _, err := workApplier.Apply(ctx, desired.DeepCopy()); err != nil { |
| 229 | + t.Fatalf("failed to create work: %v", err) |
| 230 | + } |
| 231 | + assertWorkState(t, fakeClient, "test", "test", desired) |
| 232 | + |
| 233 | + // Update: change the desired spec, verify the object is patched |
| 234 | + desired.Spec.DeleteOption = &workapiv1.DeleteOption{PropagationPolicy: workapiv1.DeletePropagationPolicyTypeForeground} |
| 235 | + if _, err := workApplier.Apply(ctx, desired.DeepCopy()); err != nil { |
| 236 | + t.Fatalf("failed to update work: %v", err) |
| 237 | + } |
| 238 | + assertWorkState(t, fakeClient, "test", "test", desired) |
| 239 | + |
| 240 | + // Annotation add: verify annotations are patched |
| 241 | + desired.SetAnnotations(map[string]string{workapiv1.ManifestConfigSpecHashAnnotationKey: "hash"}) |
| 242 | + if _, err := workApplier.Apply(ctx, desired.DeepCopy()); err != nil { |
| 243 | + t.Fatalf("failed to add annotation: %v", err) |
| 244 | + } |
| 245 | + assertWorkState(t, fakeClient, "test", "test", desired) |
| 246 | + |
| 247 | + // Annotation remove: verify annotations are cleared |
| 248 | + desired.Annotations = nil |
| 249 | + if _, err := workApplier.Apply(ctx, desired.DeepCopy()); err != nil { |
| 250 | + t.Fatalf("failed to remove annotation: %v", err) |
| 251 | + } |
| 252 | + assertWorkState(t, fakeClient, "test", "test", desired) |
| 253 | + |
| 254 | + // Cache hit: same desired, verify no write via unchanged resourceVersion |
| 255 | + rvBefore := getWork(t, fakeClient, "test", "test").ResourceVersion |
| 256 | + if _, err := workApplier.Apply(ctx, desired.DeepCopy()); err != nil { |
| 257 | + t.Fatalf("failed to re-apply unchanged work: %v", err) |
| 258 | + } |
| 259 | + rvAfter := getWork(t, fakeClient, "test", "test").ResourceVersion |
| 260 | + if rvBefore != rvAfter { |
| 261 | + t.Fatalf("expected no write, but resourceVersion changed from %s to %s", rvBefore, rvAfter) |
| 262 | + } |
| 263 | + |
| 264 | + // Cache hit when generation unchanged: externally modify the spec without |
| 265 | + // bumping generation. The cache still sees matching generation + desired |
| 266 | + // hash, so it skips the apply. |
| 267 | + tampered := getWork(t, fakeClient, "test", "test").DeepCopy() |
| 268 | + tampered.Spec.DeleteOption = &workapiv1.DeleteOption{PropagationPolicy: workapiv1.DeletePropagationPolicyTypeOrphan} |
| 269 | + if err := fakeClient.Update(ctx, tampered); err != nil { |
| 270 | + t.Fatalf("failed to externally modify work: %v", err) |
| 271 | + } |
| 272 | + rvBefore = getWork(t, fakeClient, "test", "test").ResourceVersion |
| 273 | + if _, err := workApplier.Apply(ctx, desired.DeepCopy()); err != nil { |
| 274 | + t.Fatalf("failed to re-apply after external modification without generation bump: %v", err) |
| 275 | + } |
| 276 | + rvAfter = getWork(t, fakeClient, "test", "test").ResourceVersion |
| 277 | + if rvBefore != rvAfter { |
| 278 | + t.Fatalf("expected no write when generation unchanged, but resourceVersion changed from %s to %s", rvBefore, rvAfter) |
| 279 | + } |
| 280 | + |
| 281 | + // External modification with generation bump: simulate a real API server |
| 282 | + // spec change (e.g., kubectl edit or another addon manager). |
| 283 | + // The applier should revert the work back to its desired state. |
| 284 | + tampered.Generation++ |
| 285 | + if err := fakeClient.Update(ctx, tampered); err != nil { |
| 286 | + t.Fatalf("failed to externally modify work: %v", err) |
| 287 | + } |
| 288 | + if _, err := workApplier.Apply(ctx, desired.DeepCopy()); err != nil { |
| 289 | + t.Fatalf("failed to restore drifted work: %v", err) |
| 290 | + } |
| 291 | + assertWorkState(t, fakeClient, "test", "test", desired) |
| 292 | + |
| 293 | + // Delete: verify object is removed |
| 294 | + if err := workApplier.Delete(ctx, "test", "test"); err != nil { |
| 295 | + t.Fatalf("failed to delete work: %v", err) |
| 296 | + } |
| 297 | + if err := fakeClient.Get(ctx, types.NamespacedName{Name: "test", Namespace: "test"}, &workapiv1.ManifestWork{}); !apierrors.IsNotFound(err) { |
| 298 | + t.Fatalf("expected NotFound after delete, got: %v", err) |
| 299 | + } |
| 300 | + |
| 301 | + // Delete nonexistent: verify idempotency (no error) |
| 302 | + if err := workApplier.Delete(ctx, "test", "nonexistent"); err != nil { |
| 303 | + t.Fatalf("expected no error deleting nonexistent work, got: %v", err) |
| 304 | + } |
| 305 | +} |
| 306 | + |
186 | 307 | var deploymentJson = `{ |
187 | 308 | "apiVersion": "apps/v1", |
188 | 309 | "kind": "Deployment", |
|
0 commit comments