From b84b82646f1440b2a38ae4d5d41c9c96d8f7825c Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Mon, 26 Jan 2026 15:27:54 +0100 Subject: [PATCH 1/4] remove client from applier, since applier has to use the per-shard virtual workspace URL of the initws VW instead On-behalf-of: @SAP christoph.mewes@sap.com --- cmd/init-agent/main.go | 6 +-- .../controller/initcontroller/controller.go | 26 +++++------ .../controller/initcontroller/reconciler.go | 7 +-- internal/manifest/applier.go | 44 ++++--------------- 4 files changed, 25 insertions(+), 58 deletions(-) diff --git a/cmd/init-agent/main.go b/cmd/init-agent/main.go index 7f9d502..b160170 100644 --- a/cmd/init-agent/main.go +++ b/cmd/init-agent/main.go @@ -103,9 +103,9 @@ func run(ctx context.Context, log *zap.SugaredLogger, opts *Options) error { return fmt.Errorf("failed to setup source factory: %w", err) } - // clusterApplier controls how the manifests of an init source are applied in + // manifestApplier controls how the manifests of an init source are applied in // the target workspace - clusterApplier := manifest.NewClusterApplier(clusterClient) + manifestApplier := manifest.NewApplier() // create the ctrl-runtime manager mgr, err := setupManager(ctx, cfg, opts) @@ -119,7 +119,7 @@ func run(ctx context.Context, log *zap.SugaredLogger, opts *Options) error { // wrap this controller creation in a closure to prevent giving all the initcontroller // dependencies to the targetcontroller newInitController := func(remoteManager mcmanager.Manager, targetProvider initcontroller.InitTargetProvider, initializer kcpcorev1alpha1.LogicalClusterInitializer) error { - return initcontroller.Create(remoteManager, targetProvider, sourceFactory, clusterApplier, initializer, log, numInitWorkers) + return initcontroller.Create(remoteManager, targetProvider, sourceFactory, manifestApplier, initializer, log, numInitWorkers) } if err := targetcontroller.Add(ctx, mgr, log, opts.InitTargetSelector, clusterClient, newInitController); err != nil { diff --git a/internal/controller/initcontroller/controller.go b/internal/controller/initcontroller/controller.go index b24f0b4..0b3fe41 100644 --- a/internal/controller/initcontroller/controller.go +++ b/internal/controller/initcontroller/controller.go @@ -41,12 +41,12 @@ const ( type InitTargetProvider func(ctx context.Context) (*initializationv1alpha1.InitTarget, error) type Reconciler struct { - remoteManager mcmanager.Manager - targetProvider InitTargetProvider - log *zap.SugaredLogger - sourceFactory *source.Factory - clusterApplier manifest.ClusterApplier - initializer kcpcorev1alpha1.LogicalClusterInitializer + remoteManager mcmanager.Manager + targetProvider InitTargetProvider + log *zap.SugaredLogger + sourceFactory *source.Factory + manifestApplier manifest.Applier + initializer kcpcorev1alpha1.LogicalClusterInitializer } // Create creates a new controller and importantly does *not* add it to the manager, @@ -55,7 +55,7 @@ func Create( remoteManager mcmanager.Manager, targetProvider InitTargetProvider, sourceFactory *source.Factory, - clusterApplier manifest.ClusterApplier, + manifestApplier manifest.Applier, initializer kcpcorev1alpha1.LogicalClusterInitializer, log *zap.SugaredLogger, numWorkers int, @@ -70,11 +70,11 @@ func Create( }). For(&kcpcorev1alpha1.LogicalCluster{}). Complete(&Reconciler{ - remoteManager: remoteManager, - targetProvider: targetProvider, - log: log.Named(ControllerName), - sourceFactory: sourceFactory, - clusterApplier: clusterApplier, - initializer: initializer, + remoteManager: remoteManager, + targetProvider: targetProvider, + log: log.Named(ControllerName), + sourceFactory: sourceFactory, + manifestApplier: manifestApplier, + initializer: initializer, }) } diff --git a/internal/controller/initcontroller/reconciler.go b/internal/controller/initcontroller/reconciler.go index 77fa07c..ce8f08f 100644 --- a/internal/controller/initcontroller/reconciler.go +++ b/internal/controller/initcontroller/reconciler.go @@ -96,11 +96,6 @@ func (r *Reconciler) reconcile(ctx context.Context, logger *zap.SugaredLogger, c return requeue, fmt.Errorf("failed to get InitTarget: %w", err) } - applier, err := r.clusterApplier.Cluster(initialize.ClusterFromContext(ctx)) - if err != nil { - return requeue, fmt.Errorf("failed to construct manifests applier: %w", err) - } - for idx, ref := range target.Spec.Sources { sourceLog := logger.With("init-target", target.Name, "source-idx", idx) sourceCtx := log.WithLog(ctx, sourceLog) @@ -117,7 +112,7 @@ func (r *Reconciler) reconcile(ctx context.Context, logger *zap.SugaredLogger, c sourceLog.Debugf("Source yielded %d manifests", len(objects)) - srcNeedRequeue, err := applier.Apply(sourceCtx, objects) + srcNeedRequeue, err := r.manifestApplier.Apply(sourceCtx, client, objects) if err != nil { return requeue, fmt.Errorf("failed to apply source #%d: %w", idx, err) } diff --git a/internal/manifest/applier.go b/internal/manifest/applier.go index 1b5d808..e4868ad 100644 --- a/internal/manifest/applier.go +++ b/internal/manifest/applier.go @@ -21,57 +21,29 @@ import ( "errors" "strings" - "github.com/kcp-dev/init-agent/internal/kcp" "github.com/kcp-dev/init-agent/internal/log" - "github.com/kcp-dev/logicalcluster/v3" - apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" ) -type ClusterApplier interface { - Cluster(cluster logicalcluster.Name) (Applier, error) -} - type Applier interface { - Apply(ctx context.Context, objs []*unstructured.Unstructured) (requeue bool, err error) + Apply(ctx context.Context, client ctrlruntimeclient.Client, objs []*unstructured.Unstructured) (requeue bool, err error) } -type clusterApplier struct { - cc kcp.ClusterClient -} +type applier struct{} -func NewClusterApplier(cc kcp.ClusterClient) ClusterApplier { - return &clusterApplier{cc: cc} -} - -func (a *clusterApplier) Cluster(cluster logicalcluster.Name) (Applier, error) { - client, err := a.cc.Cluster(cluster, nil) // using unstructured, no scheme needed - if err != nil { - return nil, err - } - - return &applier{client: client}, nil -} - -type applier struct { - client ctrlruntimeclient.Client -} - -func NewApplier(client ctrlruntimeclient.Client) Applier { - return &applier{ - client: client, - } +func NewApplier() Applier { + return &applier{} } -func (a *applier) Apply(ctx context.Context, objs []*unstructured.Unstructured) (requeue bool, err error) { +func (a *applier) Apply(ctx context.Context, client ctrlruntimeclient.Client, objs []*unstructured.Unstructured) (requeue bool, err error) { SortObjectsByHierarchy(objs) for _, object := range objs { - err := a.applyObject(ctx, object) + err := a.applyObject(ctx, client, object) if err != nil { if errors.Is(err, &meta.NoKindMatchError{}) { return true, nil @@ -84,7 +56,7 @@ func (a *applier) Apply(ctx context.Context, objs []*unstructured.Unstructured) return false, nil } -func (a *applier) applyObject(ctx context.Context, obj *unstructured.Unstructured) error { +func (a *applier) applyObject(ctx context.Context, client ctrlruntimeclient.Client, obj *unstructured.Unstructured) error { gvk := obj.GroupVersionKind() key := ctrlruntimeclient.ObjectKeyFromObject(obj).String() @@ -94,7 +66,7 @@ func (a *applier) applyObject(ctx context.Context, obj *unstructured.Unstructure logger := log.FromContext(ctx) logger.Debugw("Applying object", "obj-key", key, "obj-gvk", gvk) - if err := a.client.Create(ctx, obj); err != nil { + if err := client.Create(ctx, obj); err != nil { if !apierrors.IsAlreadyExists(err) { return err } From 47aded1c419afabd23752aab687308743ac26b62 Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Mon, 26 Jan 2026 15:35:22 +0100 Subject: [PATCH 2/4] add basic e2e test case On-behalf-of: @SAP christoph.mewes@sap.com --- go.mod | 5 +- go.sum | 2 + hack/ci/run-e2e-tests.sh | 81 ++++++++++ hack/ci/testdata/e2e-kcp.tokens | 1 + hack/run-e2e-tests.sh | 83 +++++++++++ test/e2e/clusterinit/basic_test.go | 217 +++++++++++++++++++++++++++ test/utils/fixtures.go | 165 +++++++++++++++++++++ test/utils/process.go | 118 +++++++++++++++ test/utils/utils.go | 227 +++++++++++++++++++++++++++++ 9 files changed, 897 insertions(+), 2 deletions(-) create mode 100755 hack/ci/run-e2e-tests.sh create mode 100644 hack/ci/testdata/e2e-kcp.tokens create mode 100755 hack/run-e2e-tests.sh create mode 100644 test/e2e/clusterinit/basic_test.go create mode 100644 test/utils/fixtures.go create mode 100644 test/utils/process.go create mode 100644 test/utils/utils.go diff --git a/go.mod b/go.mod index 1b3c743..8256362 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ replace github.com/kcp-dev/init-agent/sdk => ./sdk require ( github.com/Masterminds/sprig/v3 v3.3.0 + github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 github.com/kcp-dev/init-agent/sdk v0.0.0-00010101000000-000000000000 github.com/kcp-dev/logicalcluster/v3 v3.0.5 @@ -14,6 +15,7 @@ require ( github.com/spf13/pflag v1.0.10 go.uber.org/zap v1.27.1 k8s.io/api v0.34.2 + k8s.io/apiextensions-apiserver v0.34.2 k8s.io/apimachinery v0.34.2 k8s.io/client-go v0.34.2 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 @@ -32,7 +34,6 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.22.0 // indirect github.com/go-openapi/jsonreference v0.21.1 // indirect github.com/go-openapi/swag v0.24.1 // indirect @@ -52,6 +53,7 @@ require ( github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -92,7 +94,6 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.34.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect diff --git a/go.sum b/go.sum index 5ad771c..508604e 100644 --- a/go.sum +++ b/go.sum @@ -84,6 +84,8 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= diff --git a/hack/ci/run-e2e-tests.sh b/hack/ci/run-e2e-tests.sh new file mode 100755 index 0000000..22f8099 --- /dev/null +++ b/hack/ci/run-e2e-tests.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +# Copyright 2026 The kcp Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail +source hack/lib.sh + +# have a place to store things +if [ -z "${ARTIFACTS:-}" ]; then + ARTIFACTS=.e2e/artifacts + mkdir -p "$ARTIFACTS" +fi + +echodate "Build artifacts will be placed in $ARTIFACTS." +export ARTIFACTS="$(realpath "$ARTIFACTS")" + +# build the agent, we will start it many times during the tests +echodate "Building the init-agent…" +make build + +# start a shared kcp process +KCP="$(UGET_PRINT_PATH=relative make --no-print-directory install-kcp)" + +KCP_ROOT_DIRECTORY=.kcp.e2e +KCP_LOGFILE="$ARTIFACTS/kcp.log" +KCP_TOKENFILE=hack/ci/testdata/e2e-kcp.tokens + +echodate "Starting kcp…" +rm -rf "$KCP_ROOT_DIRECTORY" "$KCP_LOGFILE" +"$KCP" start \ + -v4 \ + --token-auth-file "$KCP_TOKENFILE" \ + --root-directory "$KCP_ROOT_DIRECTORY" 1>"$KCP_LOGFILE" 2>&1 & + +stop_kcp() { + echodate "Stopping kcp processes (set \$KEEP_KCP=true to not do this)…" + pkill -e kcp +} + +if [[ -v KEEP_KCP ]] && $KEEP_KCP; then + echodate "\$KEEP_KCP is set, will not stop kcp once the script is finished." +else + append_trap stop_kcp EXIT +fi + +# make the token available to the Go tests +export KCP_AGENT_TOKEN="$(grep e2e "$KCP_TOKENFILE" | cut -f1 -d,)" + +# Wait for kcp to be ready; this env name is also hardcoded in the Go tests. +export KCP_KUBECONFIG="$KCP_ROOT_DIRECTORY/admin.kubeconfig" + +# the tenancy API becomes available pretty late during startup, so it's a good readiness check +KUBECTL="$(UGET_PRINT_PATH=relative make --no-print-directory install-kubectl)" +if ! retry_linear 3 20 "$KUBECTL" --kubeconfig "$KCP_KUBECONFIG" get workspaces; then + echodate "kcp never became ready." + exit 1 +fi + +# makes it easier to reference these files from various _test.go files. +export ROOT_DIRECTORY="$(realpath .)" +export KCP_KUBECONFIG="$(realpath "$KCP_KUBECONFIG")" +export AGENT_BINARY="$(realpath _build/init-agent)" + +# time to run the tests +echodate "Running e2e tests…" +WHAT="${WHAT:-./test/e2e/...}" +(set -x; go test -tags e2e -timeout 2h -v $WHAT) + +echodate "Done. :-)" diff --git a/hack/ci/testdata/e2e-kcp.tokens b/hack/ci/testdata/e2e-kcp.tokens new file mode 100644 index 0000000..4193465 --- /dev/null +++ b/hack/ci/testdata/e2e-kcp.tokens @@ -0,0 +1 @@ +topphemmelig,init-agent-e2e,1111-2222-3333-4444,"init-agents" diff --git a/hack/run-e2e-tests.sh b/hack/run-e2e-tests.sh new file mode 100755 index 0000000..b69172c --- /dev/null +++ b/hack/run-e2e-tests.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +# Copyright 2026 The kcp Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +cd "$(dirname $0)/.." + +source hack/lib.sh + +export ARTIFACTS=.e2e + +rm -rf "$ARTIFACTS" +mkdir -p "$ARTIFACTS" + +KCP_ROOT_DIRECTORY="$ARTIFACTS/kcp" +KCP_LOGFILE="$ARTIFACTS/kcp.log" +KCP_TOKENFILE=hack/ci/testdata/e2e-kcp.tokens +KCP_PID=0 + +echodate "Starting kcp…" +KCP="$(UGET_PRINT_PATH=relative make --no-print-directory install-kcp)" + +rm -rf "$KCP_ROOT_DIRECTORY" "$KCP_LOGFILE" +"$KCP" start \ + -v4 \ + --token-auth-file "$KCP_TOKENFILE" \ + --root-directory "$KCP_ROOT_DIRECTORY" 1>"$KCP_LOGFILE" 2>&1 & +KCP_PID=$! + +stop_kcp() { + echodate "Stopping kcp (set \$KEEP_KCP=true to not do this)…" + kill -TERM $KCP_PID + wait $KCP_PID +} + +if [[ -v KEEP_KCP ]] && $KEEP_KCP; then + echodate "\$KEEP_KCP is set, will not stop kcp once the script is finished." +else + append_trap stop_kcp EXIT +fi + +# make the token available to the Go tests +export KCP_AGENT_TOKEN="$(grep e2e "$KCP_TOKENFILE" | cut -f1 -d,)" + +# Wait for kcp to be ready; this env name is also hardcoded in the Go tests. +export KCP_KUBECONFIG="$KCP_ROOT_DIRECTORY/admin.kubeconfig" + +# the tenancy API becomes available pretty late during startup, so it's a good readiness check +KUBECTL="$(UGET_PRINT_PATH=relative make --no-print-directory install-kubectl)" +if ! retry_linear 3 20 "$KUBECTL" --kubeconfig "$KCP_KUBECONFIG" get workspaces; then + echodate "kcp never became ready." + exit 1 +fi + +# makes it easier to reference these files from various _test.go files. +export ROOT_DIRECTORY="$(realpath .)" +export KCP_KUBECONFIG="$(realpath "$KCP_KUBECONFIG")" +export AGENT_BINARY="$(realpath _build/init-agent)" + +# The tests require ARTIFACTS to be absolute. +ARTIFACTS="$(realpath "$ARTIFACTS")" + +# time to run the tests +echodate "Running e2e tests…" + +WHAT="${WHAT:-./test/e2e/...}" +TEST_ARGS="${TEST_ARGS:--timeout 30m -v}" +E2E_PARALLELISM=${E2E_PARALLELISM:-2} + +(set -x; go test -tags e2e -parallel $E2E_PARALLELISM $TEST_ARGS "$WHAT") diff --git a/test/e2e/clusterinit/basic_test.go b/test/e2e/clusterinit/basic_test.go new file mode 100644 index 0000000..3e8824e --- /dev/null +++ b/test/e2e/clusterinit/basic_test.go @@ -0,0 +1,217 @@ +//go:build e2e + +/* +Copyright 2026 The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package clusterinit + +import ( + "context" + "slices" + "strings" + "testing" + "time" + + "github.com/go-logr/logr" + + initializationv1alpha1 "github.com/kcp-dev/init-agent/sdk/apis/initialization/v1alpha1" + "github.com/kcp-dev/init-agent/test/utils" + + "github.com/kcp-dev/logicalcluster/v3" + kcptenancyinitialization "github.com/kcp-dev/sdk/apis/tenancy/initialization" + kcptenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + ctrlruntime "sigs.k8s.io/controller-runtime" +) + +var ( + rootCluster = logicalcluster.NewPath("root") +) + +func TestInitializeNewCluster(t *testing.T) { + const ( + targetWorkspace = "my-new-workspace" // the workspace to initialize + initAgentWorkspace = "my-init-agent" // workspace that contains InitTargets/Templates + wstWorkspace = "workspace-types-here" // workspace that contains the WorkspaceType + ) + + ctx := t.Context() + ctrlruntime.SetLogger(logr.Discard()) + + // create dummy workspace and WST in it + t.Log("Creating WorkspaceType…") + kcpClusterClient := utils.GetKcpAdminClusterClient(t) + rootClient := kcpClusterClient.Cluster(rootCluster) + + wstCluster := utils.CreateAndWaitForWorkspace(t, ctx, rootClient, wstWorkspace) + wstClient := kcpClusterClient.Cluster(wstCluster.Path()) + + wst := &kcptenancyv1alpha1.WorkspaceType{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-workspace-type", + }, + Spec: kcptenancyv1alpha1.WorkspaceTypeSpec{ + Initializer: true, + }, + } + + if err := wstClient.Create(ctx, wst); err != nil { + t.Fatalf("Failed to create WorkspaceType: %v", err) + } + + initializer := kcptenancyinitialization.InitializerForType(wst) + + utils.GrantWorkspaceAccess(t, ctx, wstClient, utils.Subject(), rbacv1.PolicyRule{ + APIGroups: []string{"tenancy.kcp.io"}, + Resources: []string{"workspacetypes"}, + Verbs: []string{"list", "watch"}, + }, rbacv1.PolicyRule{ + // we could also grant permissions to initialize for all WorkspaceTypes, but this + // more strict for the test + APIGroups: []string{"tenancy.kcp.io"}, + Resources: []string{"workspacetypes"}, + ResourceNames: []string{wst.Name}, + Verbs: []string{"get", "initialize"}, + }) + + // create init-agent ws + t.Log("Creating init-agent workspace…") + initAgentCluster := utils.CreateAndWaitForWorkspace(t, ctx, rootClient, initAgentWorkspace) + + initAgentClient := kcpClusterClient.Cluster(initAgentCluster.Path()) + utils.GrantWorkspaceAccess(t, ctx, initAgentClient, utils.Subject(), rbacv1.PolicyRule{ + APIGroups: []string{"initialization.kcp.io"}, + Resources: []string{"inittargets", "inittemplates"}, + Verbs: []string{"get", "list", "watch"}, + }) + + // install CRDs there + t.Log("Installing CRDs…") + utils.ApplyCRD(t, ctx, initAgentClient, "deploy/crd/kcp.io/initialization.kcp.io_inittargets.yaml") + utils.ApplyCRD(t, ctx, initAgentClient, "deploy/crd/kcp.io/initialization.kcp.io_inittemplates.yaml") + + // create InitTarget and InitTemplates + t.Logf("Creating init-agent configuration…") + + initTemplate := &initializationv1alpha1.InitTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-template", + }, + Spec: initializationv1alpha1.InitTemplateSpec{ + Template: strings.TrimSpace(` +apiVersion: v1 +kind: ConfigMap +metadata: + namespace: foobar + name: info +data: + cluster: "{{ .ClusterName }}" + workspace: "{{ .ClusterPath }}" + +--- +apiVersion: v1 +kind: Namespace +metadata: + name: foobar +`), + }, + } + + if err := initAgentClient.Create(ctx, initTemplate); err != nil { + t.Fatalf("Failed to create InitTemplate: %v", err) + } + + initTarget := &initializationv1alpha1.InitTarget{ + ObjectMeta: metav1.ObjectMeta{ + Name: "init-my-workspace-type", + }, + Spec: initializationv1alpha1.InitTargetSpec{ + WorkspaceTypeReference: initializationv1alpha1.WorkspaceTypeReference{ + Path: rootCluster.Join(wstWorkspace).String(), + Name: wst.Name, + }, + Sources: []initializationv1alpha1.InitSource{ + { + Template: &initializationv1alpha1.TemplateInitSource{ + Name: initTemplate.Name, + }, + }, + }, + }, + } + + if err := initAgentClient.Create(ctx, initTarget); err != nil { + t.Fatalf("Failed to create InitTarget: %v", err) + } + + // start agent + agentKubeconfig := utils.CreateKcpAgentKubeconfig(t, "") // no need to give a path, the agent will auto-update it + utils.RunAgent(ctx, t, agentKubeconfig, rootCluster.Join(initAgentWorkspace).String(), "") + + // create final target workspace using that WST from the earlier step + targetWs := &kcptenancyv1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: targetWorkspace, + }, + Spec: kcptenancyv1alpha1.WorkspaceSpec{ + Type: &kcptenancyv1alpha1.WorkspaceTypeReference{ + Path: rootCluster.Join(wstWorkspace).String(), + Name: kcptenancyv1alpha1.WorkspaceTypeName(wst.Name), + }, + }, + } + + t.Logf("Creating workspace %s…", targetWorkspace) + if err := rootClient.Create(ctx, targetWs); err != nil { + t.Fatalf("Failed to create %q workspace: %v", targetWorkspace, err) + } + + // wait for the agent to do its work and initialize the cluster and ultimately remove the initializer + err := wait.PollUntilContextTimeout(ctx, 500*time.Millisecond, 30*time.Second, false, func(ctx context.Context) (done bool, err error) { + err = rootClient.Get(ctx, types.NamespacedName{Name: targetWorkspace}, targetWs) + if err != nil { + return false, err + } + + return !slices.Contains(targetWs.Status.Initializers, initializer), nil + }) + if err != nil { + t.Fatalf("Failed to wait for workspace to be initialized: %v", err) + } + + // connect into the new workspace and verify the generated content + targetClient := kcpClusterClient.Cluster(rootCluster.Join(targetWorkspace)) + + cm := &corev1.ConfigMap{} + key := types.NamespacedName{Namespace: "foobar", Name: "info"} // as per the template in the InitTemplate + + if err := targetClient.Get(ctx, key, cm); err != nil { + t.Fatalf("Failed to find ConfigMap in target workspace: %v", err) + } + + if key, expected := "workspace", rootCluster.Join(targetWorkspace).String(); cm.Data[key] != expected { + t.Errorf("Expected ConfigMap to contain %q in the %q key, but got %q.", expected, key, cm.Data[key]) + } + + if key, expected := "cluster", targetWs.Spec.Cluster; cm.Data[key] != expected { + t.Errorf("Expected ConfigMap to contain %q in the %q key, but got %q.", expected, key, cm.Data[key]) + } +} diff --git a/test/utils/fixtures.go b/test/utils/fixtures.go new file mode 100644 index 0000000..26b291b --- /dev/null +++ b/test/utils/fixtures.go @@ -0,0 +1,165 @@ +/* +Copyright 2026 The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kcp-dev/logicalcluster/v3" + kcpcorev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" + kcptenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1" + + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apiextensions-apiserver/pkg/apihelpers" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apimachinery/pkg/util/yaml" + ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Subject returns the appropriate RBC subject for the init-agent to use. kcp is +// started with a custom token file that defines this special user. +func Subject() rbacv1.Subject { + return rbacv1.Subject{ + Kind: "User", + Name: "init-agent-e2e", + } +} + +func CreateAndWaitForWorkspace(t *testing.T, ctx context.Context, client ctrlruntimeclient.Client, workspaceName string) logicalcluster.Name { + t.Helper() + + testWs := &kcptenancyv1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceName, + }, + } + + t.Logf("Creating workspace %s…", workspaceName) + if err := client.Create(ctx, testWs); err != nil { + t.Fatalf("Failed to create %q workspace: %v", workspaceName, err) + } + + err := wait.PollUntilContextTimeout(ctx, 500*time.Millisecond, 30*time.Second, false, func(ctx context.Context) (done bool, err error) { + err = client.Get(ctx, ctrlruntimeclient.ObjectKeyFromObject(testWs), testWs) + if err != nil { + return false, err + } + + return testWs.Status.Phase == kcpcorev1alpha1.LogicalClusterPhaseReady, nil + }) + if err != nil { + t.Fatalf("Failed to wait for workspace to become ready: %v", err) + } + + return logicalcluster.Name(testWs.Spec.Cluster) +} + +func GrantWorkspaceAccess(t *testing.T, ctx context.Context, client ctrlruntimeclient.Client, rbacSubject rbacv1.Subject, extraRules ...rbacv1.PolicyRule) { + t.Helper() + + clusterRoleName := fmt.Sprintf("access-workspace:%s", strings.ToLower(rbacSubject.Name)) + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleName, + }, + Rules: append([]rbacv1.PolicyRule{ + { + Verbs: []string{"access"}, + NonResourceURLs: []string{"/"}, + }, + }, extraRules...), + } + + if err := client.Create(ctx, clusterRole); err != nil { + t.Fatalf("Failed to create ClusterRole: %v", err) + } + + clusterRoleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "workspace-access-", + }, + Subjects: []rbacv1.Subject{rbacSubject}, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: clusterRoleName, + }, + } + + if err := client.Create(ctx, clusterRoleBinding); err != nil { + t.Fatalf("Failed to create ClusterRoleBinding: %v", err) + } +} + +func ApplyCRD(t *testing.T, ctx context.Context, client ctrlruntimeclient.Client, filename string) { + t.Helper() + + crd := loadCRD(t, filename) + + existingCRD := &apiextensionsv1.CustomResourceDefinition{} + if err := client.Get(ctx, ctrlruntimeclient.ObjectKeyFromObject(crd), existingCRD); err != nil { + if err := client.Create(ctx, crd); err != nil { + t.Fatalf("Failed to create CRD: %v", err) + } + + err := wait.PollUntilContextTimeout(ctx, 500*time.Millisecond, 10*time.Second, false, func(ctx context.Context) (done bool, err error) { + err = client.Get(ctx, ctrlruntimeclient.ObjectKeyFromObject(crd), crd) + if err != nil { + return false, err + } + + return apihelpers.IsCRDConditionTrue(crd, apiextensionsv1.Established), nil + }) + if err != nil { + t.Fatalf("Failed to wait for CRD to become ready: %v", err) + } + } else { + existingCRD.Spec = crd.Spec + + if err := client.Update(ctx, existingCRD); err != nil { + t.Fatalf("Failed to update CRD: %v", err) + } + } +} + +func loadCRD(t *testing.T, filename string) *apiextensionsv1.CustomResourceDefinition { + t.Helper() + + rootDirectory := requiredEnv(t, "ROOT_DIRECTORY") + + f, err := os.Open(filepath.Join(rootDirectory, filename)) + if err != nil { + t.Fatalf("Failed to read CRD: %v", err) + } + defer f.Close() + + crd := &apiextensionsv1.CustomResourceDefinition{} + dec := yaml.NewYAMLOrJSONDecoder(f, 1024) + if err := dec.Decode(crd); err != nil { + t.Fatalf("Failed to decode CRD: %v", err) + } + + return crd +} diff --git a/test/utils/process.go b/test/utils/process.go new file mode 100644 index 0000000..b485fec --- /dev/null +++ b/test/utils/process.go @@ -0,0 +1,118 @@ +/* +Copyright 2026 The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func requiredEnv(t *testing.T, name string) string { + t.Helper() + + value := os.Getenv(name) + if value == "" { + t.Fatalf("No $%s environment variable specified.", name) + } + + return value +} + +func ArtifactsDirectory(t *testing.T) string { + return requiredEnv(t, "ARTIFACTS") +} + +func AgentBinary(t *testing.T) string { + return requiredEnv(t, "AGENT_BINARY") +} + +var nonalpha = regexp.MustCompile(`[^a-z0-9_-]`) +var testCounters = map[string]int{} + +func uniqueLogfile(t *testing.T, basename string) string { + testName := strings.ToLower(t.Name()) + testName = nonalpha.ReplaceAllLiteralString(testName, "_") + testName = strings.Trim(testName, "_") + + if basename != "" { + testName += "_" + basename + } + + counter := testCounters[testName] + testCounters[testName]++ + + return fmt.Sprintf("%s_%02d.log", testName, counter) +} + +func RunAgent( + ctx context.Context, + t *testing.T, + kcpKubeconfig string, + configWorkspace string, + labelSelector string, +) context.CancelFunc { + t.Helper() + + t.Log("Running init-agent…") + + args := []string{ + "--enable-leader-election=false", + "--kubeconfig", kcpKubeconfig, + "--config-workspace", configWorkspace, + "--log-format", "Console", + "--log-debug=true", + "--health-address", "0", + "--metrics-address", "0", + } + + if labelSelector != "" { + args = append(args, "--init-target-selector", labelSelector) + } + + logFile := filepath.Join(ArtifactsDirectory(t), uniqueLogfile(t, "")) + log, err := os.Create(logFile) + if err != nil { + t.Fatalf("Failed to create logfile: %v", err) + } + + localCtx, cancel := context.WithCancel(ctx) + + cmd := exec.CommandContext(localCtx, AgentBinary(t), args...) + cmd.Stdout = log + cmd.Stderr = log + + if err := cmd.Start(); err != nil { + t.Fatalf("Failed to start init-agent: %v", err) + } + + cancelAndWait := func() { + cancel() + _ = cmd.Wait() + + log.Close() + } + + t.Cleanup(cancelAndWait) + + return cancelAndWait +} diff --git a/test/utils/utils.go b/test/utils/utils.go new file mode 100644 index 0000000..0843161 --- /dev/null +++ b/test/utils/utils.go @@ -0,0 +1,227 @@ +/* +Copyright 2026 The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "fmt" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "testing" + + initializationv1alpha1 "github.com/kcp-dev/init-agent/sdk/apis/initialization/v1alpha1" + + mcclient "github.com/kcp-dev/multicluster-provider/client" + kcpapisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" + kcptenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/yaml" + yamlutil "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/scale/scheme" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func GetKcpAdminKubeconfig(t *testing.T) string { + return requiredEnv(t, "KCP_KUBECONFIG") +} + +func must(t *testing.T, err error) { + t.Helper() + + if err != nil { + t.Fatal(err) + } +} + +func newScheme(t *testing.T) *runtime.Scheme { + t.Helper() + + sc := runtime.NewScheme() + must(t, scheme.AddToScheme(sc)) + must(t, corev1.AddToScheme(sc)) + must(t, rbacv1.AddToScheme(sc)) + must(t, kcptenancyv1alpha1.AddToScheme(sc)) + must(t, kcpapisv1alpha1.AddToScheme(sc)) + must(t, initializationv1alpha1.AddToScheme(sc)) + must(t, apiextensionsv1.AddToScheme(sc)) + + return sc +} + +var clusterPathSuffix = regexp.MustCompile(`/clusters/[a-z0-9:*]+$`) + +func GetKcpAdminClusterClient(t *testing.T) mcclient.ClusterClient { + t.Helper() + return GetClusterClient(t, GetKcpAdminKubeconfig(t)) +} + +func GetClusterClient(t *testing.T, kubeconfig string) mcclient.ClusterClient { + t.Helper() + + config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + t.Fatalf("Failed to get load kubeconfig %q: %v", kubeconfig, err) + } + + // remove any pre-existing /clusters/... suffix, a cluster-aware client needs + // to point to the base URL (either of kcp or a virtual workspace) + config.Host = clusterPathSuffix.ReplaceAllLiteralString(config.Host, "") + + client, err := mcclient.New(config, ctrlruntimeclient.Options{ + Scheme: newScheme(t), + }) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + return client +} + +func GetKcpAdminClient(t *testing.T) ctrlruntimeclient.Client { + t.Helper() + return GetClient(t, GetKcpAdminKubeconfig(t)) +} + +func GetClient(t *testing.T, kubeconfig string) ctrlruntimeclient.Client { + t.Helper() + + config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + t.Fatalf("Failed to get load kubeconfig %q: %v", kubeconfig, err) + } + + client, err := ctrlruntimeclient.New(config, ctrlruntimeclient.Options{ + Scheme: newScheme(t), + }) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + return client +} + +func CreateKcpAgentKubeconfig(t *testing.T, path string) string { + t.Helper() + + agentToken := requiredEnv(t, "KCP_AGENT_TOKEN") + + kubeconfig, err := clientcmd.LoadFromFile(GetKcpAdminKubeconfig(t)) + if err != nil { + t.Fatalf("Failed to load admin kcp kubeconfig: %v", err) + } + + // drop everything but the currently selected context + if err := clientcmdapi.MinifyConfig(kubeconfig); err != nil { + t.Fatalf("Failed to minify admin kcp kubeconfig: %v", err) + } + + // update server URL if desired + if path != "" { + for name, cluster := range kubeconfig.Clusters { + parsed, err := url.Parse(cluster.Server) + if err != nil { + // Given how ultra lax url.Parse is, this basically never happens. + t.Fatalf("Failed to parse %q as URL: %v", cluster.Server, err) + } + + kubeconfig.Clusters[name].Server = fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, path) + } + } + + // use the agent's token + for name := range kubeconfig.AuthInfos { + kubeconfig.AuthInfos[name].Token = agentToken + } + + // write the kubeconfig to a temporary file + encodedKubeconfig, err := clientcmd.Write(*kubeconfig) + if err != nil { + t.Fatalf("Failed to encode agent kubeconfig: %v", err) + } + + kubeconfigFile, err := os.CreateTemp(os.TempDir(), "kubeconfig*") + if err != nil { + t.Fatalf("Failed to create agent kubeconfig file: %v", err) + } + defer kubeconfigFile.Close() + + if _, err := kubeconfigFile.Write(encodedKubeconfig); err != nil { + t.Fatalf("Failed to write agent kubeconfig file: %v", err) + } + + // ensure the kubeconfig is removed after the test + t.Cleanup(func() { + os.Remove(kubeconfigFile.Name()) + }) + + return kubeconfigFile.Name() +} + +func ToUnstructured(t *testing.T, obj any) *unstructured.Unstructured { + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + t.Fatalf("Failed to convert object to unstructurd: %v", err) + } + + return &unstructured.Unstructured{Object: raw} +} + +func YAMLToUnstructured(t *testing.T, data string) *unstructured.Unstructured { + t.Helper() + + decoder := yamlutil.NewYAMLOrJSONDecoder(strings.NewReader(data), 100) + + var rawObj runtime.RawExtension + if err := decoder.Decode(&rawObj); err != nil { + t.Fatalf("Failed to decode: %v", err) + } + + obj, _, err := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme).Decode(rawObj.Raw, nil, nil) + if err != nil { + t.Fatalf("Failed to decode: %v", err) + } + + return ToUnstructured(t, obj) +} + +func KCPMinor() int { + version := os.Getenv("KCP_VERSION") + if version == "" { + panic("No $KCP_VERSION environment variable defined.") + } + + parts := strings.SplitN(version, ".", 3) + if len(parts) != 3 { + panic("Invalid $KCP_VERSION, must be X.Y.Z.") + } + + minor, err := strconv.ParseInt(parts[1], 10, 32) + if err != nil { + panic(fmt.Sprintf("Invalid $KCP_VERSION: not parseable: %v", err)) + } + + return int(minor) +} From d815e485c0ae6eca372db609bd36195cb125ad97 Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Mon, 26 Jan 2026 15:42:36 +0100 Subject: [PATCH 3/4] accept HashiCorp MPL exceptions On-behalf-of: @SAP christoph.mewes@sap.com --- .wwhrd.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.wwhrd.yml b/.wwhrd.yml index 43bd564..1a4ac8c 100644 --- a/.wwhrd.yml +++ b/.wwhrd.yml @@ -25,3 +25,8 @@ allowlist: - BSD-2-Clause-FreeBSD - BSD-3-Clause - ISC + +exceptions: + - github.com/hashicorp/golang-lru/v2 # MPL 2.0 + - github.com/hashicorp/golang-lru/v2/internal # MPL 2.0 + - github.com/hashicorp/golang-lru/v2/simplelru # MPL 2.0 From da83a3c87a335ca83890e5eeb2aba498e6726498 Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Mon, 26 Jan 2026 17:18:31 +0100 Subject: [PATCH 4/4] add tests for sorting objects On-behalf-of: @SAP christoph.mewes@sap.com --- internal/manifest/sort_test.go | 103 +++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 internal/manifest/sort_test.go diff --git a/internal/manifest/sort_test.go b/internal/manifest/sort_test.go new file mode 100644 index 0000000..2d35a70 --- /dev/null +++ b/internal/manifest/sort_test.go @@ -0,0 +1,103 @@ +/* +Copyright 2026 The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package manifest + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestSortObjectsByHierarchy(t *testing.T) { + crd := newUnstructured("apiextensions.k8s.io/v1", "CustomResourceDefinition", "test-crd") + apiExport := newUnstructured("apis.kcp.io/v1alpha1", "APIExport", "test-export") + apiBinding := newUnstructured("apis.kcp.io/v1alpha1", "APIBinding", "test-binding") + namespace := newUnstructured("v1", "Namespace", "test-ns") + configMap := newUnstructured("v1", "ConfigMap", "test-cm") + deployment := newUnstructured("apps/v1", "Deployment", "test-deploy") + service := newUnstructured("v1", "Service", "test-svc") + + testcases := []struct { + name string + input []*unstructured.Unstructured + expected []*unstructured.Unstructured + }{ + { + name: "empty input", + input: []*unstructured.Unstructured{}, + expected: []*unstructured.Unstructured{}, + }, + { + name: "single object", + input: []*unstructured.Unstructured{configMap}, + expected: []*unstructured.Unstructured{configMap}, + }, + { + name: "already sorted", + input: []*unstructured.Unstructured{crd, apiExport, apiBinding, namespace, configMap}, + expected: []*unstructured.Unstructured{crd, apiExport, apiBinding, namespace, configMap}, + }, + { + name: "reverse order", + input: []*unstructured.Unstructured{configMap, namespace, apiBinding, apiExport, crd}, + expected: []*unstructured.Unstructured{crd, apiExport, apiBinding, namespace, configMap}, + }, + { + name: "mixed order", + input: []*unstructured.Unstructured{namespace, configMap, crd, deployment, apiBinding, apiExport}, + expected: []*unstructured.Unstructured{crd, apiExport, apiBinding, namespace, configMap, deployment}, + }, + { + name: "multiple objects of same kind", + input: []*unstructured.Unstructured{configMap, crd, configMap, crd}, + expected: []*unstructured.Unstructured{crd, crd, configMap, configMap}, + }, + { + name: "only regular objects", + input: []*unstructured.Unstructured{service, configMap, deployment}, + expected: []*unstructured.Unstructured{service, configMap, deployment}, + }, + } + + for _, tt := range testcases { + t.Run(tt.name, func(t *testing.T) { + SortObjectsByHierarchy(tt.input) + + if len(tt.input) != len(tt.expected) { + t.Fatalf("Expected %d objects, got %d", len(tt.expected), len(tt.input)) + } + + for i, obj := range tt.input { + if obj != tt.expected[i] { + t.Fatalf("At index %d: expected %s, got %s", i, tt.expected[i].GetKind(), obj.GetKind()) + } + } + }) + } +} + +func newUnstructured(apiVersion, kind, name string) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]any{ + "name": name, + }, + }, + } +}