diff --git a/e2e/adapter/adapter_failover.go b/e2e/adapter/adapter_failover.go index 1d944394..364bb496 100644 --- a/e2e/adapter/adapter_failover.go +++ b/e2e/adapter/adapter_failover.go @@ -2,8 +2,6 @@ package adapter import ( "context" - "os" - "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" //nolint:staticcheck // dot import for test readability @@ -13,43 +11,24 @@ import ( ) var _ = ginkgo.Describe("[Suite: adapter-failures][negative] Adapter framework can detect and report failures to cluster API endpoints", - ginkgo.Label(labels.Tier1), + ginkgo.Label(labels.Tier1, labels.Adapter), func() { var ( h *helper.Helper - chartPath string baseDeployOpts helper.AdapterDeploymentOptions ) ginkgo.BeforeEach(func(ctx context.Context) { h = helper.New() - // Clone adapter Helm chart repository (shared across all tests) - ginkgo.By("Clone adapter Helm chart repository") - var cleanupChart func() error - var err error - chartPath, cleanupChart, err = h.CloneHelmChart(ctx, helper.HelmChartCloneOptions{ - Component: "adapter", - RepoURL: h.Cfg.AdapterDeployment.ChartRepo, - Ref: h.Cfg.AdapterDeployment.ChartRef, - ChartPath: h.Cfg.AdapterDeployment.ChartPath, - WorkDir: helper.TestWorkDir, - }) + // Clone Adapter Chart + path, err := helper.AdapterGitClone.CloneChartOnce(ctx) Expect(err).NotTo(HaveOccurred(), "failed to clone adapter Helm chart") - ginkgo.GinkgoWriter.Printf("Cloned adapter chart to: %s\n", chartPath) - - // Ensure chart cleanup after test - ginkgo.DeferCleanup(func(ctx context.Context) { - ginkgo.By("Cleanup cloned Helm chart") - if err := cleanupChart(); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup chart: %v\n", err) - } - }) + ginkgo.GinkgoWriter.Printf("Cloned adapter chart to: %s\n", path) // Set up base deployment options with common fields baseDeployOpts = helper.AdapterDeploymentOptions{ - Namespace: h.Cfg.Namespace, - ChartPath: chartPath, + ChartPath: path, ResourceType: helper.ResourceTypeClusters, } }) @@ -58,40 +37,20 @@ var _ = ginkgo.Describe("[Suite: adapter-failures][negative] Adapter framework c func(ctx context.Context) { // Test-specific adapter configuration adapterName := "cl-invalid-resource" - - // Set environment variable for envsubst expansion in values.yaml - err := os.Setenv("ADAPTER_NAME", adapterName) - Expect(err).NotTo(HaveOccurred(), "failed to set ADAPTER_NAME environment variable") - ginkgo.DeferCleanup(func() { - _ = os.Unsetenv("ADAPTER_NAME") - }) - // Generate unique release name for this deployment releaseName := helper.GenerateAdapterReleaseName(helper.ResourceTypeClusters, adapterName) // Deploy the test adapter with invalid K8s resource configuration - ginkgo.By("Deploy test adapter with invalid K8s resource configuration") + ginkgo.By("Deploy test adapter with invalid K8s resource configuration.") - // Create deployment options from base and add test-specific fields deployOpts := baseDeployOpts deployOpts.ReleaseName = releaseName deployOpts.AdapterName = adapterName - err = h.DeployAdapter(ctx, deployOpts) - // Ensure adapter cleanup happens after this test + err := h.InstallAdapter(ctx, deployOpts) ginkgo.DeferCleanup(func(ctx context.Context) { - ginkgo.By("Uninstall test adapter") - if err := h.UninstallAdapter(ctx, releaseName, h.Cfg.Namespace); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to uninstall adapter %s: %v\n", releaseName, err) - } else { - ginkgo.GinkgoWriter.Printf("Successfully uninstalled adapter: %s\n", releaseName) - } - - if h.Cfg.BrokerType == "googlepubsub" { - ginkgo.By("Clean up Pub/Sub subscription and dlq topic for adapter") - if err := h.DeletePubSubResourcesForAdapter(ctx, adapterName, deployOpts.ResourceType); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to delete Pub/Sub subscription and dlq topic for adapter %s: %v\n", adapterName, err) - } + if err := h.UninstallAdapter(ctx, deployOpts); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to uninstall adapter %s: %v\n", deployOpts.AdapterName, err) } }) Expect(err).NotTo(HaveOccurred(), "failed to deploy test adapter") diff --git a/e2e/adapter/adapter_with_maestro.go b/e2e/adapter/adapter_with_maestro.go index 7eb8e3f1..375e5f38 100644 --- a/e2e/adapter/adapter_with_maestro.go +++ b/e2e/adapter/adapter_with_maestro.go @@ -3,7 +3,6 @@ package adapter import ( "context" "fmt" - "os" "time" "github.com/onsi/ginkgo/v2" @@ -424,104 +423,51 @@ var _ = ginkgo.Describe("[Suite: adapter][maestro-transport] Adapter Framework - ) var _ = ginkgo.Describe("[Suite: adapter][maestro-transport][negative] Adapter Framework - Maestro Transport Negative Scenarios", - ginkgo.Label(labels.Tier1), + ginkgo.Label(labels.Tier1, labels.Adapter), func() { var ( h *helper.Helper - clusterID string - adapterRelease string // Track deployed adapter release name for cleanup - adapterName string // Track deployed adapter name for cleanup - chartPath string baseDeployOpts helper.AdapterDeploymentOptions ) ginkgo.BeforeEach(func(ctx context.Context) { h = helper.New() - adapterRelease = "" - clusterID = "" - adapterName = "" - - // Clone adapter Helm chart repository (shared across negative tests) - ginkgo.By("Clone adapter Helm chart repository for negative tests") - var cleanupChart func() error - var err error - chartPath, cleanupChart, err = h.CloneHelmChart(ctx, helper.HelmChartCloneOptions{ - Component: "adapter", - RepoURL: h.Cfg.AdapterDeployment.ChartRepo, - Ref: h.Cfg.AdapterDeployment.ChartRef, - ChartPath: h.Cfg.AdapterDeployment.ChartPath, - WorkDir: helper.TestWorkDir, - }) + // Clone Adapter Chart + path, err := helper.AdapterGitClone.CloneChartOnce(ctx) Expect(err).NotTo(HaveOccurred(), "failed to clone adapter Helm chart") - ginkgo.GinkgoWriter.Printf("Cloned adapter chart to: %s\n", chartPath) + ginkgo.GinkgoWriter.Printf("Cloned adapter chart to: %s\n", path) // Set up base deployment options with common fields baseDeployOpts = helper.AdapterDeploymentOptions{ - Namespace: h.Cfg.Namespace, - ChartPath: chartPath, + ChartPath: path, ResourceType: helper.ResourceTypeClusters, } - // Ensure chart cleanup after test - ginkgo.DeferCleanup(func(ctx context.Context) { - ginkgo.By("Cleanup cloned Helm chart") - if err := cleanupChart(); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup chart: %v\n", err) - } - }) - - // Register adapter and cluster cleanup (vars captured by reference; values set in each It) - ginkgo.DeferCleanup(func(ctx context.Context) { - if adapterRelease != "" { - ginkgo.By("Uninstall adapter " + adapterRelease) - if err := h.UninstallAdapter(ctx, adapterRelease, h.Cfg.Namespace); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to uninstall adapter %s: %v\n", adapterRelease, err) - } - } - if clusterID != "" { - ginkgo.By("Cleanup test cluster " + clusterID) - if err := h.CleanupTestCluster(ctx, clusterID); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup cluster %s: %v\n", clusterID, err) - } - } - if adapterName != "" { - if h.Cfg.BrokerType == "googlepubsub" { - ginkgo.By("Clean up Pub/Sub subscription and dlq topic for adapter") - if err := h.DeletePubSubResourcesForAdapter(ctx, adapterName, baseDeployOpts.ResourceType); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to delete Pub/Sub subscription and dlq topic for adapter %s: %v\n", adapterName, err) - } - } - } - }) }) ginkgo.It("should fail when targeting unregistered Maestro consumer and report appropriate error", func(ctx context.Context) { // Test-specific adapter configuration - adapterName = "cl-m-unreg-consumer" - err := os.Setenv("ADAPTER_NAME", adapterName) - Expect(err).NotTo(HaveOccurred(), "failed to set ADAPTER_NAME environment variable") - ginkgo.DeferCleanup(func() { - _ = os.Unsetenv("ADAPTER_NAME") - }) + adapterName := "cl-m-unreg-consumer" // Generate unique release name for this deployment releaseName := helper.GenerateAdapterReleaseName(helper.ResourceTypeClusters, adapterName) - // Deploy the test adapter configured to target unregistered consumer + // Purge even queue before deploying test adapter ginkgo.By("Purge adapter event queue to start from a clean state") if err := h.PurgeAdapterQueue(ctx, adapterName); err != nil { ginkgo.GinkgoWriter.Printf("Warning: failed to purge queue for %s: %v\n", adapterName, err) } ginkgo.By("Deploy test adapter with unregistered consumer configuration") - - // Create deployment options from base and add test-specific fields deployOpts := baseDeployOpts deployOpts.ReleaseName = releaseName deployOpts.AdapterName = adapterName - // Set adapterRelease BEFORE deployment so cleanup will run even if deployment fails - adapterRelease = releaseName - err = h.DeployAdapter(ctx, deployOpts) + err := h.InstallAdapter(ctx, deployOpts) + ginkgo.DeferCleanup(func(ctx context.Context) { + if err := h.UninstallAdapter(ctx, deployOpts); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to uninstall adapter %s: %v\n", deployOpts.AdapterName, err) + } + }) Expect(err).NotTo(HaveOccurred(), "failed to deploy test adapter") ginkgo.GinkgoWriter.Printf("Successfully deployed adapter: %s (release: %s)\n", adapterName, releaseName) @@ -531,7 +477,13 @@ var _ = ginkgo.Describe("[Suite: adapter][maestro-transport][negative] Adapter F Expect(err).NotTo(HaveOccurred(), "failed to create cluster") Expect(cluster.Id).NotTo(BeNil(), "cluster ID should be generated") Expect(cluster.Name).NotTo(BeEmpty(), "cluster name should be present") - clusterID = *cluster.Id + clusterID := *cluster.Id + ginkgo.DeferCleanup(func(ctx context.Context) { + ginkgo.By("Cleanup test cluster " + clusterID) + if err := h.CleanupTestCluster(ctx, clusterID); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup cluster %s: %v\n", clusterID, err) + } + }) ginkgo.GinkgoWriter.Printf("Created cluster ID: %s, Name: %s\n", clusterID, cluster.Name) ginkgo.By("Verify adapter reports failure for unregistered consumer") @@ -643,15 +595,10 @@ var _ = ginkgo.Describe("[Suite: adapter][maestro-transport][negative] Adapter F ginkgo.It("should fail to discover ManifestWork when discovery name does not match created resource", func(ctx context.Context) { // Test-specific adapter configuration - adapterName = "cl-m-wrong-ds" - // Set environment variable for envsubst expansion in values.yaml - err := os.Setenv("ADAPTER_NAME", adapterName) - Expect(err).NotTo(HaveOccurred(), "failed to set ADAPTER_NAME environment variable") - ginkgo.DeferCleanup(func() { - _ = os.Unsetenv("ADAPTER_NAME") - }) + adapterName := "cl-m-wrong-ds" // Generate unique release name for this deployment releaseName := helper.GenerateAdapterReleaseName(helper.ResourceTypeClusters, adapterName) + // Deploy the test adapter with wrong main discovery configuration ginkgo.By("Purge adapter event queue to start from a clean state") if err := h.PurgeAdapterQueue(ctx, adapterName); err != nil { @@ -660,14 +607,16 @@ var _ = ginkgo.Describe("[Suite: adapter][maestro-transport][negative] Adapter F ginkgo.By("Deploy test adapter with wrong ManifestWork discovery name") - // Create deployment options from base and add test-specific fields deployOpts := baseDeployOpts deployOpts.ReleaseName = releaseName deployOpts.AdapterName = adapterName - // Set adapterRelease BEFORE deployment so cleanup will run even if deployment fails - adapterRelease = releaseName - err = h.DeployAdapter(ctx, deployOpts) + err := h.InstallAdapter(ctx, deployOpts) + ginkgo.DeferCleanup(func(ctx context.Context) { + if err := h.UninstallAdapter(ctx, deployOpts); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to uninstall adapter %s: %v\n", deployOpts.AdapterName, err) + } + }) Expect(err).NotTo(HaveOccurred(), "failed to deploy test adapter") ginkgo.GinkgoWriter.Printf("Successfully deployed adapter: %s (release: %s)\n", adapterName, releaseName) @@ -677,7 +626,13 @@ var _ = ginkgo.Describe("[Suite: adapter][maestro-transport][negative] Adapter F Expect(err).NotTo(HaveOccurred(), "failed to create cluster") Expect(cluster.Id).NotTo(BeNil(), "cluster ID should be generated") Expect(cluster.Name).NotTo(BeEmpty(), "cluster name should be present") - clusterID = *cluster.Id + clusterID := *cluster.Id + ginkgo.DeferCleanup(func(ctx context.Context) { + ginkgo.By("Cleanup test cluster " + clusterID) + if err := h.CleanupTestCluster(ctx, clusterID); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup cluster %s: %v\n", clusterID, err) + } + }) ginkgo.GinkgoWriter.Printf("Created cluster ID: %s, Name: %s\n", clusterID, cluster.Name) // Verify ManifestWork was created by the test adapter despite wrong discovery config @@ -829,15 +784,7 @@ var _ = ginkgo.Describe("[Suite: adapter][maestro-transport][negative] Adapter F ginkgo.It("should fail nested discovery when resource names are wrong", func(ctx context.Context) { - // Test-specific adapter configuration - adapterName = "cl-m-wrong-nest" - // Set environment variable for envsubst expansion in values.yaml - err := os.Setenv("ADAPTER_NAME", adapterName) - Expect(err).NotTo(HaveOccurred(), "failed to set ADAPTER_NAME environment variable") - ginkgo.DeferCleanup(func() { - _ = os.Unsetenv("ADAPTER_NAME") - }) - + adapterName := "cl-m-wrong-nest" // Generate unique release name for this deployment releaseName := helper.GenerateAdapterReleaseName(helper.ResourceTypeClusters, adapterName) @@ -854,10 +801,12 @@ var _ = ginkgo.Describe("[Suite: adapter][maestro-transport][negative] Adapter F deployOpts.ReleaseName = releaseName deployOpts.AdapterName = adapterName - // Set adapterRelease BEFORE deployment so cleanup will run even if deployment fails - adapterRelease = releaseName - - err = h.DeployAdapter(ctx, deployOpts) + err := h.InstallAdapter(ctx, deployOpts) + ginkgo.DeferCleanup(func(ctx context.Context) { + if err := h.UninstallAdapter(ctx, deployOpts); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to uninstall adapter %s: %v\n", deployOpts.AdapterName, err) + } + }) Expect(err).NotTo(HaveOccurred(), "failed to deploy test adapter") ginkgo.GinkgoWriter.Printf("Successfully deployed adapter: %s (release: %s)\n", adapterName, releaseName) @@ -867,7 +816,13 @@ var _ = ginkgo.Describe("[Suite: adapter][maestro-transport][negative] Adapter F Expect(err).NotTo(HaveOccurred(), "failed to create cluster") Expect(cluster.Id).NotTo(BeNil(), "cluster ID should be generated") Expect(cluster.Name).NotTo(BeEmpty(), "cluster name should be present") - clusterID = *cluster.Id + clusterID := *cluster.Id + ginkgo.DeferCleanup(func(ctx context.Context) { + ginkgo.By("Cleanup test cluster " + clusterID) + if err := h.CleanupTestCluster(ctx, clusterID); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup cluster %s: %v\n", clusterID, err) + } + }) ginkgo.GinkgoWriter.Printf("Created cluster ID: %s, Name: %s\n", clusterID, cluster.Name) // Construct namespace name AFTER cluster is created @@ -996,14 +951,7 @@ var _ = ginkgo.Describe("[Suite: adapter][maestro-transport][negative] Adapter F ginkgo.It("should fail post-action when status API is unreachable", func(ctx context.Context) { // Use cl-m-bad-api adapter with overridden API URL - adapterName = "cl-m-bad-api" - // Set environment variable for envsubst expansion in values.yaml - err := os.Setenv("ADAPTER_NAME", adapterName) - Expect(err).NotTo(HaveOccurred(), "failed to set ADAPTER_NAME environment variable") - ginkgo.DeferCleanup(func() { - _ = os.Unsetenv("ADAPTER_NAME") - }) - + adapterName := "cl-m-bad-api" // Generate unique release name for this deployment releaseName := helper.GenerateAdapterReleaseName(helper.ResourceTypeClusters, adapterName) @@ -1015,17 +963,16 @@ var _ = ginkgo.Describe("[Suite: adapter][maestro-transport][negative] Adapter F ginkgo.By("Deploy test adapter with unreachable API URL configuration") - // Create deployment options with overridden API URL deployOpts := baseDeployOpts deployOpts.ReleaseName = releaseName deployOpts.AdapterName = adapterName - // Override hyperfleetApi.baseUrl to make it unreachable - deployOpts.SetValues = map[string]string{ - "adapterConfig.hyperfleetApi.baseUrl": "http://invalid-hyperfleet-api-endpoint.local:9999", - } - adapterRelease = releaseName - err = h.DeployAdapter(ctx, deployOpts) + err := h.InstallAdapter(ctx, deployOpts) + ginkgo.DeferCleanup(func(ctx context.Context) { + if err := h.UninstallAdapter(ctx, deployOpts); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to uninstall adapter %s: %v\n", deployOpts.AdapterName, err) + } + }) Expect(err).NotTo(HaveOccurred(), "failed to deploy test adapter") ginkgo.GinkgoWriter.Printf("Successfully deployed adapter: %s (release: %s)\n", adapterName, releaseName) @@ -1035,7 +982,13 @@ var _ = ginkgo.Describe("[Suite: adapter][maestro-transport][negative] Adapter F Expect(err).NotTo(HaveOccurred(), "failed to create cluster") Expect(cluster.Id).NotTo(BeNil(), "cluster ID should be generated") Expect(cluster.Name).NotTo(BeEmpty(), "cluster name should be present") - clusterID = *cluster.Id + clusterID := *cluster.Id + ginkgo.DeferCleanup(func(ctx context.Context) { + ginkgo.By("Cleanup test cluster " + clusterID) + if err := h.CleanupTestCluster(ctx, clusterID); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup cluster %s: %v\n", clusterID, err) + } + }) ginkgo.GinkgoWriter.Printf("Created cluster ID: %s, Name: %s\n", clusterID, cluster.Name) // Construct namespace name AFTER cluster is created diff --git a/e2e/cluster/adapter_failure.go b/e2e/cluster/adapter_failure.go index fd0487d2..82da2a65 100644 --- a/e2e/cluster/adapter_failure.go +++ b/e2e/cluster/adapter_failure.go @@ -2,7 +2,6 @@ package cluster import ( "context" - "os" "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" //nolint:staticcheck // dot import for test readability @@ -14,43 +13,24 @@ import ( var _ = ginkgo.Describe("[Suite: cluster][negative] Cluster Can Reflect Adapter Failure in Top-Level Status", ginkgo.Serial, // Serial: deploys temp adapter subscribing to all events, causes cross-talk - ginkgo.Label(labels.Tier1, labels.Negative), + ginkgo.Label(labels.Tier1, labels.Negative, labels.Adapter), func() { var ( h *helper.Helper - chartPath string baseDeployOpts helper.AdapterDeploymentOptions ) ginkgo.BeforeEach(func(ctx context.Context) { h = helper.New() - // Clone adapter Helm chart repository (shared across all tests in this Describe) - ginkgo.By("Clone adapter Helm chart repository") - var cleanupChart func() error - var err error - chartPath, cleanupChart, err = h.CloneHelmChart(ctx, helper.HelmChartCloneOptions{ - Component: "adapter", - RepoURL: h.Cfg.AdapterDeployment.ChartRepo, - Ref: h.Cfg.AdapterDeployment.ChartRef, - ChartPath: h.Cfg.AdapterDeployment.ChartPath, - WorkDir: helper.TestWorkDir, - }) + // Clone Adapter Chart + path, err := helper.AdapterGitClone.CloneChartOnce(ctx) Expect(err).NotTo(HaveOccurred(), "failed to clone adapter Helm chart") - ginkgo.GinkgoWriter.Printf("Cloned adapter chart to: %s\n", chartPath) - - // Ensure chart cleanup after test - ginkgo.DeferCleanup(func(ctx context.Context) { - ginkgo.By("Cleanup cloned Helm chart") - if err := cleanupChart(); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup chart: %v\n", err) - } - }) + ginkgo.GinkgoWriter.Printf("Cloned adapter chart to: %s\n", path) // Set up base deployment options with common fields baseDeployOpts = helper.AdapterDeploymentOptions{ - Namespace: h.Cfg.Namespace, - ChartPath: chartPath, + ChartPath: path, ResourceType: helper.ResourceTypeClusters, } }) @@ -58,14 +38,6 @@ var _ = ginkgo.Describe("[Suite: cluster][negative] Cluster Can Reflect Adapter ginkgo.It("should not block cluster reconciliation when non-required adapter has param extraction failure", func(ctx context.Context) { adapterName := "cl-param-error" - - // Set environment variable for envsubst expansion in values.yaml - err := os.Setenv("ADAPTER_NAME", adapterName) - Expect(err).NotTo(HaveOccurred(), "failed to set ADAPTER_NAME environment variable") - ginkgo.DeferCleanup(func() { - _ = os.Unsetenv("ADAPTER_NAME") - }) - // Generate unique release name for this deployment releaseName := helper.GenerateAdapterReleaseName(helper.ResourceTypeClusters, adapterName) @@ -76,25 +48,14 @@ var _ = ginkgo.Describe("[Suite: cluster][negative] Cluster Can Reflect Adapter deployOpts.ReleaseName = releaseName deployOpts.AdapterName = adapterName - err = h.DeployAdapter(ctx, deployOpts) - // Ensure adapter cleanup happens after this test + err := h.InstallAdapter(ctx, deployOpts) ginkgo.DeferCleanup(func(ctx context.Context) { - ginkgo.By("Uninstall cl-param-error adapter") - if err := h.UninstallAdapter(ctx, releaseName, h.Cfg.Namespace); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to uninstall adapter %s: %v\n", releaseName, err) - } else { - ginkgo.GinkgoWriter.Printf("Successfully uninstalled adapter: %s\n", releaseName) - } - - if h.Cfg.BrokerType == "googlepubsub" { - ginkgo.By("Clean up Pub/Sub subscription and dlq topic for adapter") - if err := h.DeletePubSubResourcesForAdapter(ctx, adapterName, deployOpts.ResourceType); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to delete Pub/Sub subscription and dlq topic for adapter %s: %v\n", adapterName, err) - } + if err := h.UninstallAdapter(ctx, deployOpts); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to uninstall adapter %s: %v\n", deployOpts.AdapterName, err) } }) - Expect(err).NotTo(HaveOccurred(), "failed to deploy cl-param-error adapter") - ginkgo.GinkgoWriter.Printf("Deployed cl-param-error adapter: release=%s\n", releaseName) + Expect(err).NotTo(HaveOccurred(), "failed to deploy test adapter") + ginkgo.GinkgoWriter.Printf("Successfully deployed adapter: %s (release: %s)\n", adapterName, releaseName) // Create cluster after adapter is deployed ginkgo.By("Submit an API request to create a Cluster resource") diff --git a/e2e/cluster/crash_recovery.go b/e2e/cluster/crash_recovery.go index 7165f524..4c5a2e8a 100644 --- a/e2e/cluster/crash_recovery.go +++ b/e2e/cluster/crash_recovery.go @@ -2,7 +2,6 @@ package cluster import ( "context" - "os" "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" //nolint:staticcheck // dot import for test readability @@ -14,60 +13,29 @@ import ( var _ = ginkgo.Describe("[Suite: cluster][negative] Cluster Can Reach Correct Status After Adapter Crash and Recovery", ginkgo.Serial, // Serial: kills and restarts adapter pod, disrupts concurrent specs - ginkgo.Label(labels.Tier2, labels.Negative), + ginkgo.Label(labels.Tier2, labels.Negative, labels.Adapter), func() { var ( - h *helper.Helper - adapterChartPath string - apiChartPath string - baseDeployOpts helper.AdapterDeploymentOptions + h *helper.Helper + baseDeployOpts helper.AdapterDeploymentOptions + apiPath string ) ginkgo.BeforeEach(func(ctx context.Context) { h = helper.New() - // Clone adapter Helm chart - ginkgo.By("Clone adapter Helm chart repository") - var cleanupAdapterChart func() error - var err error - adapterChartPath, cleanupAdapterChart, err = h.CloneHelmChart(ctx, helper.HelmChartCloneOptions{ - Component: "adapter", - RepoURL: h.Cfg.AdapterDeployment.ChartRepo, - Ref: h.Cfg.AdapterDeployment.ChartRef, - ChartPath: h.Cfg.AdapterDeployment.ChartPath, - WorkDir: helper.TestWorkDir, - }) - Expect(err).NotTo(HaveOccurred(), "failed to clone adapter Helm chart") - - ginkgo.DeferCleanup(func(ctx context.Context) { - ginkgo.By("Cleanup cloned adapter Helm chart") - if err := cleanupAdapterChart(); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup adapter chart: %v\n", err) - } - }) + // Clone Adapter Chart + path, err := helper.AdapterGitClone.CloneChartOnce(ctx) + Expect(err).NotTo(HaveOccurred(), "failed to clone adapter helm chart") + ginkgo.GinkgoWriter.Printf("Cloned adapter chart to: %s\n", path) - // Clone API Helm chart (needed to upgrade required adapters config) - ginkgo.By("Clone API Helm chart repository") - var cleanupAPIChart func() error - apiChartPath, cleanupAPIChart, err = h.CloneHelmChart(ctx, helper.HelmChartCloneOptions{ - Component: "api", - RepoURL: h.Cfg.APIDeployment.ChartRepo, - Ref: h.Cfg.APIDeployment.ChartRef, - ChartPath: h.Cfg.APIDeployment.ChartPath, - WorkDir: helper.TestWorkDir, - }) - Expect(err).NotTo(HaveOccurred(), "failed to clone API Helm chart") - - ginkgo.DeferCleanup(func(ctx context.Context) { - ginkgo.By("Cleanup cloned API Helm chart") - if err := cleanupAPIChart(); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup API chart: %v\n", err) - } - }) + apiPath, err = helper.APIGitClone.CloneChartOnce(ctx) + Expect(err).NotTo(HaveOccurred(), "failed to clone api helm chart") + ginkgo.GinkgoWriter.Printf("Cloned api chart to: %s\n", apiPath) + // Set up base deployment options with common fields baseDeployOpts = helper.AdapterDeploymentOptions{ - Namespace: h.Cfg.Namespace, - ChartPath: adapterChartPath, + ChartPath: path, ResourceType: helper.ResourceTypeClusters, } }) @@ -76,39 +44,24 @@ var _ = ginkgo.Describe("[Suite: cluster][negative] Cluster Can Reach Correct St func(ctx context.Context) { adapterName := "cl-crash" - err := os.Setenv("ADAPTER_NAME", adapterName) - Expect(err).NotTo(HaveOccurred(), "failed to set ADAPTER_NAME environment variable") - ginkgo.DeferCleanup(func() { - _ = os.Unsetenv("ADAPTER_NAME") - }) - releaseName := helper.GenerateAdapterReleaseName(helper.ResourceTypeClusters, adapterName) - // Step 1a: Deploy dedicated crash-adapter - ginkgo.By("Deploy dedicated crash-adapter") + ginkgo.By("Deploy test crash adapter") + deployOpts := baseDeployOpts deployOpts.ReleaseName = releaseName deployOpts.AdapterName = adapterName - err = h.DeployAdapter(ctx, deployOpts) - // Register adapter cleanup (executed AFTER API config restore due to LIFO) + err := h.InstallAdapter(ctx, deployOpts) ginkgo.DeferCleanup(func(ctx context.Context) { - ginkgo.By("Uninstall crash-adapter") - if err := h.UninstallAdapter(ctx, releaseName, h.Cfg.Namespace); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to uninstall adapter %s: %v\n", releaseName, err) - } - - if h.Cfg.BrokerType == "googlepubsub" { - ginkgo.By("Clean up Pub/Sub subscription and dlq topic for adapter") - if err := h.DeletePubSubResourcesForAdapter(ctx, adapterName, deployOpts.ResourceType); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to delete Pub/Sub subscription and dlq topic for adapter %s: %v\n", adapterName, err) - } + if err := h.UninstallAdapter(ctx, deployOpts); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to uninstall adapter %s: %v\n", deployOpts.AdapterName, err) } }) - Expect(err).NotTo(HaveOccurred(), "failed to deploy crash-adapter") - ginkgo.GinkgoWriter.Printf("Deployed crash-adapter: release=%s\n", releaseName) + Expect(err).NotTo(HaveOccurred(), "failed to deploy test adapter") + ginkgo.GinkgoWriter.Printf("Successfully deployed adapter: %s (release: %s)\n", adapterName, releaseName) - // Step 1b: Upgrade API to add crash-adapter to required adapters + // Upgrade API to add crash-adapter to required adapters ginkgo.By("Upgrade API to add crash-adapter to required adapters") originalAdapters := h.GetAPIRequiredClusterAdapters() updatedAdapters := append(append([]string{}, originalAdapters...), adapterName) @@ -116,12 +69,12 @@ var _ = ginkgo.Describe("[Suite: cluster][negative] Cluster Can Reach Correct St // Register API config restore AFTER adapter cleanup registration (LIFO → executes FIRST) ginkgo.DeferCleanup(func(ctx context.Context) { ginkgo.By("Restore API required adapters to original config") - if err := h.RestoreAPIRequiredAdaptersWithRetry(ctx, apiChartPath, h.Cfg.Namespace, originalAdapters, 3); err != nil { + if err := h.RestoreAPIRequiredAdaptersWithRetry(ctx, apiPath, h.Cfg.Namespace, originalAdapters, 3); err != nil { ginkgo.GinkgoWriter.Printf("CRITICAL: %v\n", err) } }) - err = h.UpgradeAPIRequiredAdapters(ctx, apiChartPath, h.Cfg.Namespace, updatedAdapters) + err = h.UpgradeAPIRequiredAdapters(ctx, apiPath, h.Cfg.Namespace, updatedAdapters) Expect(err).NotTo(HaveOccurred(), "failed to upgrade API with crash-adapter in required adapters") // Step 1c: Find deployment name and scale down to simulate crash diff --git a/e2e/cluster/stuck_deletion.go b/e2e/cluster/stuck_deletion.go index 38aacc31..7bf204e5 100644 --- a/e2e/cluster/stuck_deletion.go +++ b/e2e/cluster/stuck_deletion.go @@ -3,7 +3,6 @@ package cluster import ( "context" "net/http" - "os" "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" //nolint:staticcheck // dot import for test readability @@ -15,58 +14,29 @@ import ( var _ = ginkgo.Describe("[Suite: cluster][negative] Stuck Deletion -- Adapter Unable to Finalize Prevents Hard-Delete", ginkgo.Serial, // Serial: deploys stuck adapter that blocks deletion of all clusters - ginkgo.Label(labels.Tier2, labels.Negative), + ginkgo.Label(labels.Tier2, labels.Negative, labels.Adapter), func() { var ( - h *helper.Helper - adapterChartPath string - apiChartPath string - baseDeployOpts helper.AdapterDeploymentOptions + h *helper.Helper + baseDeployOpts helper.AdapterDeploymentOptions + apiPath string ) ginkgo.BeforeEach(func(ctx context.Context) { h = helper.New() - ginkgo.By("Clone adapter Helm chart repository") - var cleanupAdapterChart func() error - var err error - adapterChartPath, cleanupAdapterChart, err = h.CloneHelmChart(ctx, helper.HelmChartCloneOptions{ - Component: "adapter", - RepoURL: h.Cfg.AdapterDeployment.ChartRepo, - Ref: h.Cfg.AdapterDeployment.ChartRef, - ChartPath: h.Cfg.AdapterDeployment.ChartPath, - WorkDir: helper.TestWorkDir, - }) + // Clone Adapter Chart + path, err := helper.AdapterGitClone.CloneChartOnce(ctx) Expect(err).NotTo(HaveOccurred(), "failed to clone adapter Helm chart") + ginkgo.GinkgoWriter.Printf("Cloned adapter chart to: %s\n", path) - ginkgo.DeferCleanup(func(ctx context.Context) { - ginkgo.By("Cleanup cloned adapter Helm chart") - if err := cleanupAdapterChart(); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup adapter chart: %v\n", err) - } - }) - - ginkgo.By("Clone API Helm chart repository") - var cleanupAPIChart func() error - apiChartPath, cleanupAPIChart, err = h.CloneHelmChart(ctx, helper.HelmChartCloneOptions{ - Component: "api", - RepoURL: h.Cfg.APIDeployment.ChartRepo, - Ref: h.Cfg.APIDeployment.ChartRef, - ChartPath: h.Cfg.APIDeployment.ChartPath, - WorkDir: helper.TestWorkDir, - }) - Expect(err).NotTo(HaveOccurred(), "failed to clone API Helm chart") - - ginkgo.DeferCleanup(func(ctx context.Context) { - ginkgo.By("Cleanup cloned API Helm chart") - if err := cleanupAPIChart(); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup API chart: %v\n", err) - } - }) + apiPath, err = helper.APIGitClone.CloneChartOnce(ctx) + Expect(err).NotTo(HaveOccurred(), "failed to clone api helm chart") + ginkgo.GinkgoWriter.Printf("Cloned api chart to: %s\n", apiPath) + // Set up base deployment options with common fields baseDeployOpts = helper.AdapterDeploymentOptions{ - Namespace: h.Cfg.Namespace, - ChartPath: adapterChartPath, + ChartPath: path, ResourceType: helper.ResourceTypeClusters, } }) @@ -74,35 +44,22 @@ var _ = ginkgo.Describe("[Suite: cluster][negative] Stuck Deletion -- Adapter Un ginkgo.It("should prevent hard-delete when an adapter cannot finalize", func(ctx context.Context) { adapterName := "cl-stuck" - - err := os.Setenv("ADAPTER_NAME", adapterName) - Expect(err).NotTo(HaveOccurred(), "failed to set ADAPTER_NAME environment variable") - ginkgo.DeferCleanup(func() { - _ = os.Unsetenv("ADAPTER_NAME") - }) - releaseName := helper.GenerateAdapterReleaseName(helper.ResourceTypeClusters, adapterName) - ginkgo.By("Deploy dedicated stuck-adapter") + ginkgo.By("Deploy test stuck adapter") + deployOpts := baseDeployOpts deployOpts.ReleaseName = releaseName deployOpts.AdapterName = adapterName - err = h.DeployAdapter(ctx, deployOpts) + err := h.InstallAdapter(ctx, deployOpts) ginkgo.DeferCleanup(func(ctx context.Context) { - ginkgo.By("Uninstall stuck-adapter") - if err := h.UninstallAdapter(ctx, releaseName, h.Cfg.Namespace); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to uninstall adapter %s: %v\n", releaseName, err) - } - if h.Cfg.BrokerType == "googlepubsub" { - ginkgo.By("Clean up Pub/Sub subscription and dlq topic for adapter") - if err := h.DeletePubSubResourcesForAdapter(ctx, adapterName, deployOpts.ResourceType); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to delete Pub/Sub subscription and dlq topic for adapter %s: %v\n", adapterName, err) - } + if err := h.UninstallAdapter(ctx, deployOpts); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to uninstall adapter %s: %v\n", deployOpts.AdapterName, err) } }) - Expect(err).NotTo(HaveOccurred(), "failed to deploy stuck-adapter") - ginkgo.GinkgoWriter.Printf("Deployed stuck-adapter: release=%s\n", releaseName) + Expect(err).NotTo(HaveOccurred(), "failed to deploy test adapter") + ginkgo.GinkgoWriter.Printf("Successfully deployed adapter: %s (release: %s)\n", adapterName, releaseName) ginkgo.By("Upgrade API to add stuck-adapter to required adapters") originalAdapters := h.GetAPIRequiredClusterAdapters() @@ -111,12 +68,12 @@ var _ = ginkgo.Describe("[Suite: cluster][negative] Stuck Deletion -- Adapter Un // Register API config restore AFTER adapter cleanup registration (LIFO → executes FIRST) ginkgo.DeferCleanup(func(ctx context.Context) { ginkgo.By("Restore API required adapters to original config") - if err := h.RestoreAPIRequiredAdaptersWithRetry(ctx, apiChartPath, h.Cfg.Namespace, originalAdapters, 3); err != nil { + if err := h.RestoreAPIRequiredAdaptersWithRetry(ctx, apiPath, h.Cfg.Namespace, originalAdapters, 3); err != nil { ginkgo.GinkgoWriter.Printf("CRITICAL: %v\n", err) } }) - err = h.UpgradeAPIRequiredAdapters(ctx, apiChartPath, h.Cfg.Namespace, updatedAdapters) + err = h.UpgradeAPIRequiredAdapters(ctx, apiPath, h.Cfg.Namespace, updatedAdapters) Expect(err).NotTo(HaveOccurred(), "failed to upgrade API with stuck-adapter in required adapters") deploymentName, err := h.GetDeploymentName(ctx, h.Cfg.Namespace, releaseName) diff --git a/pkg/e2e/suite.go b/pkg/e2e/suite.go index bf853826..02955cec 100644 --- a/pkg/e2e/suite.go +++ b/pkg/e2e/suite.go @@ -66,6 +66,25 @@ var _ = ginkgo.BeforeSuite(func(ctx ginkgo.SpecContext) { adapterDeploymentList := helper.InitAdapterDeploymentList() helper.SetAdapterDeploymentList(adapterDeploymentList) + // Initialize adapter and api clones - setup no actual cloning happens + + // Initialize the gitClone for the adapter chart + helper.AdapterGitClone = helper.NewGitClone(&helper.HelmChartCloneOptions{ + Component: "adapter", + RepoURL: cfg.AdapterDeployment.ChartRepo, + Ref: cfg.AdapterDeployment.ChartRef, + RepoPath: cfg.AdapterDeployment.ChartPath, + WorkDir: ".test-work", + }) + // Initialize the gitClone for the api chart + helper.APIGitClone = helper.NewGitClone(&helper.HelmChartCloneOptions{ + Component: "api", + RepoURL: cfg.APIDeployment.ChartRepo, + Ref: cfg.APIDeployment.ChartRef, + RepoPath: cfg.APIDeployment.ChartPath, + WorkDir: ".test-work", + }) + logger.Info("starting hyperfleet-e2e test suite - each test creates temporary resources") }) diff --git a/pkg/helper/adapter.go b/pkg/helper/adapter.go index 04ffa9d8..44505aa2 100644 --- a/pkg/helper/adapter.go +++ b/pkg/helper/adapter.go @@ -3,84 +3,63 @@ package helper import ( "bytes" "context" - "crypto/rand" "crypto/sha256" "fmt" - "math/big" + "gopkg.in/yaml.v3" "os" "os/exec" "path/filepath" "strings" "sync" + "text/template" "time" pubsubadmin "cloud.google.com/go/pubsub/v2/apiv1" pubsubpb "cloud.google.com/go/pubsub/v2/apiv1/pubsubpb" + "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/helper/helm" "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/logger" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -type AdapterDeployment struct { +// AdapterDeploymentOptions contains configuration for deploying an adapter via Helm +type AdapterDeploymentOptions struct { ReleaseName string + ChartPath string AdapterName string ResourceType string } +// AdapterDeploymentList tracks adapters deployed during the test run for cleanup. +// Keys are adapter names, values are resource types (e.g. "clusters", "nodepools"). type AdapterDeploymentList struct { mu sync.RWMutex - items []AdapterDeployment + items map[string]string } -func (l *AdapterDeploymentList) Add(deployment AdapterDeployment) { +func (l *AdapterDeploymentList) Add(adapterName, resourceType string) { l.mu.Lock() defer l.mu.Unlock() - l.items = append(l.items, deployment) + l.items[adapterName] = resourceType } -// Snapshot returns a thread-safe copy of all adapter deployments -func (l *AdapterDeploymentList) Snapshot() []AdapterDeployment { +func (l *AdapterDeploymentList) Snapshot() map[string]string { l.mu.RLock() defer l.mu.RUnlock() - snapshot := make([]AdapterDeployment, len(l.items)) - copy(snapshot, l.items) + snapshot := make(map[string]string, len(l.items)) + for k, v := range l.items { + snapshot[k] = v + } return snapshot } func InitAdapterDeploymentList() *AdapterDeploymentList { return &AdapterDeploymentList{ - items: make([]AdapterDeployment, 0), + items: make(map[string]string), } } -// generateRandomString generates a random alphanumeric string of the specified length -func generateRandomString(length int) string { - const charset = "abcdefghijklmnopqrstuvwxyz0123456789" - b := make([]byte, length) - for i := range b { - n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) - if err != nil { - // Fallback: use current time nanoseconds for basic randomness - b[i] = charset[(time.Now().UnixNano()+int64(i))%int64(len(charset))] - } else { - b[i] = charset[n.Int64()] - } - } - return string(b) -} - -// AdapterDeploymentOptions contains configuration for deploying an adapter via Helm -type AdapterDeploymentOptions struct { - ReleaseName string - Namespace string - ChartPath string - AdapterName string - Timeout time.Duration - SetValues map[string]string // Additional Helm --set values - ResourceType string -} - // GenerateAdapterReleaseName generates a deterministic Helm release name for an adapter deployment. // The release name format is: adapter-- // Deterministic naming allows helm upgrade --install to upgrade in place and avoids duplicate releases. @@ -90,261 +69,119 @@ const maxReleaseNameLength = 48 func GenerateAdapterReleaseName(resourceType, adapterName string) string { releaseName := fmt.Sprintf("adapter-%s-%s", resourceType, adapterName) - if len(releaseName) > maxReleaseNameLength { hash := fmt.Sprintf("%x", sha256.Sum256([]byte(releaseName)))[:8] truncLen := maxReleaseNameLength - len(hash) - 1 releaseName = releaseName[:truncLen] + "-" + hash } - return releaseName } -// DeployAdapter deploys an adapter using Helm upgrade --install -// This is a common function that can be reused across test cases -// The release name must be provided via opts.ReleaseName - use GenerateAdapterReleaseName() to create a unique name -func (h *Helper) DeployAdapter(ctx context.Context, opts AdapterDeploymentOptions) error { - // Validate required fields - if opts.Namespace == "" { - return fmt.Errorf("AdapterDeploymentOptions.Namespace is required") - } - if opts.ChartPath == "" { - return fmt.Errorf("AdapterDeploymentOptions.ChartPath is required") - } +func (h *Helper) InstallAdapter(ctx context.Context, opts AdapterDeploymentOptions) error { if opts.AdapterName == "" { return fmt.Errorf("AdapterDeploymentOptions.AdapterName is required") } if opts.ReleaseName == "" { - return fmt.Errorf("AdapterDeploymentOptions.ReleaseName is required - use GenerateAdapterReleaseName() to create a unique name") + return fmt.Errorf("AdapterDeploymentOptions.ReleaseName is required") } - - // Set default timeout if not specified - if opts.Timeout == 0 { - opts.Timeout = 5 * time.Minute - } - - releaseName := opts.ReleaseName - - logger.Info("deploying adapter via Helm", - "adapter_name", opts.AdapterName, - "release_name", releaseName, - "namespace", opts.Namespace) - - // Copy adapter config folder to chart directory - sourceAdapterDir := filepath.Join(h.Cfg.TestDataDir, AdapterConfigsDir, opts.AdapterName) - destAdapterDir := filepath.Join(opts.ChartPath, opts.AdapterName) - - // Remove existing adapter config directory if it exists - if _, err := os.Stat(destAdapterDir); err == nil { - logger.Info("removing existing adapter config directory", "path", destAdapterDir) - if err := os.RemoveAll(destAdapterDir); err != nil { - return fmt.Errorf("failed to remove existing adapter config directory: %w", err) - } + if opts.ChartPath == "" { + return fmt.Errorf("AdapterDeploymentOptions.ChartPath is required") } - - // Copy adapter config directory to chart - logger.Info("copying adapter config", "from", sourceAdapterDir, "to", destAdapterDir) - if err := copyDir(sourceAdapterDir, destAdapterDir); err != nil { - return fmt.Errorf("failed to copy adapter config directory: %w", err) + if opts.ResourceType == "" { + return fmt.Errorf("AdapterDeploymentOptions.ResourceType is required") } - // Determine the values.yaml file path in the copied adapter directory - valuesFilePath := filepath.Join(destAdapterDir, "values.yaml") - - // Default BROKER_TYPE to googlepubsub if not set so envsubst produces a valid value - if os.Getenv("BROKER_TYPE") == "" { - if err := os.Setenv("BROKER_TYPE", "googlepubsub"); err != nil { - return fmt.Errorf("failed to set default BROKER_TYPE: %w", err) - } - defer func() { _ = os.Unsetenv("BROKER_TYPE") }() - } - - // Compute extra environment variables for the envsubst subprocess. - // These are scoped to the subprocess and do not mutate the process-global environment. - var extraEnv []string - - // When using GCP Pub/Sub, ensure the subscription is created if it doesn't exist. - // This is required for adapters deployed for the first time (no pre-existing subscription). - if os.Getenv("BROKER_TYPE") == "googlepubsub" && os.Getenv("ADAPTER_GOOGLEPUBSUB_CREATE_SUBSCRIPTION_IF_MISSING") == "" { - extraEnv = append(extraEnv, "ADAPTER_GOOGLEPUBSUB_CREATE_SUBSCRIPTION_IF_MISSING=true") - } - - // Resolve the in-cluster HyperFleet API URL for adapters running inside Kubernetes. - // The external LoadBalancer IP (HYPERFLEET_API_URL) is not routable from within GKE pods. - // We look up the hyperfleet-api service across all namespaces and construct the FQDN so - // that adapters deployed to the test namespace can reach the API regardless of where it runs. - if os.Getenv("ADAPTER_HYPERFLEET_API_URL") == "" && h.K8sClient != nil { - if internalURL, err := h.resolveInternalAPIURL(ctx); err == nil && internalURL != "" { - extraEnv = append(extraEnv, "ADAPTER_HYPERFLEET_API_URL="+internalURL) - logger.Info("resolved in-cluster HyperFleet API URL for adapters", "url", internalURL) - } else { - logger.Info("could not resolve in-cluster API URL, falling back to HYPERFLEET_API_URL", - "error", err) - } + data := map[string]interface{}{ + "BrokerType": h.Cfg.BrokerType, + "ProjectId": h.Cfg.GCPProjectID, + "Namespace": h.Cfg.Namespace, + "RunId": h.Cfg.RunID, + "AdapterName": opts.AdapterName, + "ImageRegistry": h.Cfg.AdapterDeployment.ImageRegistry, + "AdapterImageRepo": h.Cfg.AdapterDeployment.ImageRepo, + "ImagePullPolicy": os.Getenv("IMAGE_PULL_POLICY"), + "AdapterImageTag": h.Cfg.AdapterDeployment.ImageTag, + "AdapterGooglepubsubCreateTopicIfMissing": "true", + "AdapterGooglepubsubCreateSubscriptionIfMissing": "true", + "RabbitmqUrl": os.Getenv("RABBITMQ_URL"), } - // Expand environment variables in values.yaml in-place using envsubst - logger.Info("expanding environment variables in values.yaml in-place", "values_file", valuesFilePath) - - expandedContent, err := expandEnvVarsInYAMLToBytes(ctx, valuesFilePath, extraEnv) + baseTemplateFilePath := fmt.Sprintf("%s/adapter-configs/%s-base.tmpl", h.Cfg.TestDataDir, opts.ResourceType) + valuesFilePath := fmt.Sprintf("%s/adapter-configs/%s.yaml", h.Cfg.TestDataDir, opts.AdapterName) + releaseValues, err := parseTemplateWithValues(data, baseTemplateFilePath, valuesFilePath) if err != nil { - return fmt.Errorf("failed to expand environment variables in values.yaml: %w", err) + return fmt.Errorf("failed to parse template with values for adapter %s: %w", opts.AdapterName, err) } - if err := os.WriteFile(valuesFilePath, expandedContent, 0600); err != nil { - return fmt.Errorf("failed to overwrite values.yaml with expanded content: %w", err) - } - - logger.Info("successfully expanded environment variables in values.yaml") - // Expand environment variables in adapter-config.yaml in-place using envsubst. - // This allows adapter configs to reference env vars like ${HYPERFLEET_API_URL} - // so the correct API endpoint is injected at deploy time regardless of namespace. - adapterConfigPath := filepath.Join(destAdapterDir, "adapter-config.yaml") - if _, statErr := os.Stat(adapterConfigPath); statErr == nil { - expandedAdapterConfig, err := expandEnvVarsInYAMLToBytes(ctx, adapterConfigPath, extraEnv) - if err != nil { - return fmt.Errorf("failed to expand environment variables in adapter-config.yaml: %w", err) - } - if err := os.WriteFile(adapterConfigPath, expandedAdapterConfig, 0600); err != nil { - return fmt.Errorf("failed to overwrite adapter-config.yaml with expanded content: %w", err) - } - logger.Info("successfully expanded environment variables in adapter-config.yaml") + releaseValues["fullnameOverride"] = opts.ReleaseName + labels := map[string]string{ + "e2e.hyperfleet.io/run-id": h.Cfg.RunID, } - // Build Helm command with values file - helmArgs := []string{ - "upgrade", "--install", - releaseName, - opts.ChartPath, - "--namespace", opts.Namespace, - "--create-namespace", - "--wait", - "--timeout", opts.Timeout.String(), - "-f", valuesFilePath, + logger.Info("Release Values", releaseValues) + helmClient := helm.NewHelmClient(h.Cfg.Namespace) + if err := helmClient.InstallRelease(ctx, opts.ReleaseName, opts.ChartPath, releaseValues, labels); err != nil { + return fmt.Errorf("failed to install adapter %s (release %s): %w", opts.AdapterName, opts.ReleaseName, err) } - // Append conditional --set flags - helmArgs = append(helmArgs, h.adapterHelmSetArgs(releaseName, opts)...) - - logger.Info("executing Helm command", "args", helmArgs) + h.AdapterDeploymentList.Add(opts.AdapterName, opts.ResourceType) - // Create context with timeout - cmdCtx, cancel := context.WithTimeout(ctx, opts.Timeout+30*time.Second) - defer cancel() - - // Execute Helm command - cmd := exec.CommandContext(cmdCtx, "helm", helmArgs...) // #nosec G204 -- helmArgs is constructed from trusted config - output, err := cmd.CombinedOutput() - if err != nil { - logger.Error("helm upgrade failed", "error", err, "output", string(output)) - - // Collect diagnostic information when deployment fails - h.saveDiagnosticLogs(ctx, opts.AdapterName, releaseName, opts.Namespace) - - return fmt.Errorf("helm upgrade failed: %w (output: %s)", err, string(output)) - } - - // Add adapter deployment to list for cleanup - h.AdapterDeploymentList.Add(AdapterDeployment{ - ReleaseName: releaseName, - AdapterName: opts.AdapterName, - ResourceType: opts.ResourceType, - }) - - logger.Info("adapter deployed successfully", - "release_name", releaseName, - "output", string(output)) + logger.Info("adapter installed successfully", + "adapter_name", opts.AdapterName, + "release_name", opts.ReleaseName) return nil } -// adapterHelmSetArgs builds the conditional --set flags for adapter Helm deployments. -// Extracted for testability - DeployAdapter calls this to append flags after the base args. -func (h *Helper) adapterHelmSetArgs(releaseName string, opts AdapterDeploymentOptions) []string { - var args []string - - // Ensure consistent release naming - args = append(args, "--set", fmt.Sprintf("fullnameOverride=%s", releaseName)) - - // Add run-id label for resource tracking and cleanup - if h.Cfg.RunID != "" { - args = append(args, "--labels", fmt.Sprintf("e2e.hyperfleet.io/run-id=%s", h.Cfg.RunID)) - } - - // Override image pull policy if set (e.g. IfNotPresent for local kind clusters) - if policy := os.Getenv("IMAGE_PULL_POLICY"); policy != "" { - args = append(args, "--set", fmt.Sprintf("image.pullPolicy=%s", policy)) +func (h *Helper) UninstallAdapter(ctx context.Context, opts AdapterDeploymentOptions) error { + var errs []error + helmClient := helm.NewHelmClient(h.Cfg.Namespace) + err := helmClient.UninstallRelease(opts.ReleaseName) + if err != nil { + logger.Error("failed to uninstall release", "release", opts.ReleaseName, "error", err) + errs = append(errs, fmt.Errorf("uninstall release %s: %w", opts.ReleaseName, err)) } - - // Enable adapter API auth when JWT is enabled on the API server - if h.Cfg.Identity.TokenRequest.IsEnabled() { - args = append(args, "--set", "adapterConfig.hyperfleetApi.auth.enabled=true") + h.cleanupClusterScopedResources(ctx, opts.ReleaseName) + err = h.DeletePubSubResourcesForAdapter(ctx, opts.AdapterName, opts.ResourceType) + if err != nil { + logger.Error("failed to delete pubsub resources", "adapter", opts.AdapterName, "error", err) + errs = append(errs, fmt.Errorf("delete pubsub resources for %s: %w", opts.AdapterName, err)) } - - // Add additional --set values if provided - for key, value := range opts.SetValues { - args = append(args, "--set", fmt.Sprintf("%s=%s", key, value)) + if len(errs) > 0 { + return fmt.Errorf("failed to uninstall adapter and cleanup resources: %v", errs) } - - return args + return nil } -// resolveInternalAPIURL looks up the hyperfleet-api Kubernetes service in the configured -// namespace and returns an in-cluster FQDN URL that adapters deployed in any namespace can use. -// This is needed because the external LoadBalancer IP is not routable from within GKE pods. -func (h *Helper) resolveInternalAPIURL(ctx context.Context) (string, error) { - ns := h.Cfg.Namespace - svc, err := h.K8sClient.CoreV1().Services(ns).Get(ctx, "hyperfleet-api", metav1.GetOptions{}) +func parseTemplateWithValues(data map[string]interface{}, baseTemplateFilePath string, baseValuesFilePath string) (map[string]interface{}, error) { + // Parse the base template file + tmpl, err := template.ParseFiles(baseTemplateFilePath) if err != nil { - return "", fmt.Errorf("failed to get hyperfleet-api service in namespace %q: %w", ns, err) + return nil, fmt.Errorf("failed to parse template file %s: %w", baseTemplateFilePath, err) } - if len(svc.Spec.Ports) == 0 { - return "", fmt.Errorf("hyperfleet-api service has no ports") - } - port := svc.Spec.Ports[0].Port - return fmt.Sprintf("http://hyperfleet-api.%s.svc.cluster.local:%d", ns, port), nil -} -// UninstallAdapter uninstalls an adapter using Helm uninstall -// This is a common function that can be reused across test cases -func (h *Helper) UninstallAdapter(ctx context.Context, releaseName, namespace string) error { - logger.Info("uninstalling adapter via Helm", - "release_name", releaseName, - "namespace", namespace) - - // Create context with timeout - cmdCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) - defer cancel() + // Execute template and write to buffer + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("failed to execute template: %w", err) + } - // Execute Helm uninstall command - cmd := exec.CommandContext(cmdCtx, "helm", "uninstall", releaseName, - "-n", namespace, - "--wait", - "--timeout", "5m") + // Parse the resulting YAML into a map + values := make(map[string]interface{}) + if err := yaml.Unmarshal(buf.Bytes(), &values); err != nil { + return nil, fmt.Errorf("failed to parse rendered YAML: %w", err) + } - output, err := cmd.CombinedOutput() + // Unmarshal the base values filepath into the values map + adapterConfig, err := os.ReadFile(filepath.Clean(baseValuesFilePath)) if err != nil { - // Check if the error is because the release doesn't exist - if strings.Contains(string(output), "not found") { - logger.Info("adapter release not found, skipping uninstall", "release_name", releaseName) - // Clean up orphaned cluster-scoped resources even when release is not found - // This handles cases like interrupted installs or manual deletions - h.cleanupClusterScopedResources(ctx, releaseName) - return nil - } - logger.Error("helm uninstall failed", "error", err, "output", string(output)) - return fmt.Errorf("helm uninstall failed: %w (output: %s)", err, string(output)) + return nil, fmt.Errorf("failed to read %s: %w", baseValuesFilePath, err) + } + if err := yaml.Unmarshal(adapterConfig, &values); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", baseValuesFilePath, err) } - logger.Info("adapter uninstalled successfully", - "release_name", releaseName, - "output", string(output)) - - // Clean up any orphaned cluster-scoped resources (ClusterRoles, ClusterRoleBindings) - // These can be left behind if a previous test run failed or was interrupted - h.cleanupClusterScopedResources(ctx, releaseName) - - return nil + return values, nil } // cleanupClusterScopedResources removes orphaned cluster-scoped resources that may be left @@ -381,157 +218,6 @@ func (h *Helper) cleanupClusterScopedResources(ctx context.Context, releaseName } } -// saveDiagnosticLogs saves diagnostic information when adapter deployment fails -// Saves to /-/ directory -// outputDir is configured via OUTPUT_DIR env var or config file (defaults to "output") -func (h *Helper) saveDiagnosticLogs(ctx context.Context, adapterName, releaseName, namespace string) { - // Generate output directory with adapter name and random suffix - randomSuffix := generateRandomString(4) - outputDir := filepath.Join(h.Cfg.OutputDir, fmt.Sprintf("%s-%s", adapterName, randomSuffix)) - - // Create output directory - if err := os.MkdirAll(outputDir, 0750); err != nil { - logger.Error("failed to create diagnostic output directory", - "error", err, - "output_dir", outputDir) - return - } - - logger.Info("saving diagnostic logs", - "adapter_name", adapterName, - "release_name", releaseName, - "namespace", namespace, - "output_dir", outputDir) - - cmdCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - // 1. Get pods using client-go - pods, err := h.K8sClient.CoreV1().Pods(namespace).List(cmdCtx, metav1.ListOptions{ - LabelSelector: fmt.Sprintf("app.kubernetes.io/instance=%s", releaseName), - }) - if err != nil { - logger.Error("failed to list pods", "error", err) - return - } - - if len(pods.Items) == 0 { - logger.Info("no pods found for release", "release_name", releaseName) - return - } - - logger.Info("found pods for release", - "total_pods", len(pods.Items), - "release_name", releaseName) - - // Save logs and description for unhealthy pods only - for _, pod := range pods.Items { - // Check if pod is healthy (Running and all containers ready) - isHealthy := pod.Status.Phase == "Running" - if isHealthy && len(pod.Status.ContainerStatuses) > 0 { - for _, cs := range pod.Status.ContainerStatuses { - if !cs.Ready { - isHealthy = false - break - } - } - } - - // Skip healthy pods - if isHealthy { - logger.Info("skipping healthy pod", "pod", pod.Name) - continue - } - - podName := pod.Name - logger.Info("saving logs for unhealthy pod", - "pod", podName, - "phase", pod.Status.Phase) - - // Save pod logs using kubectl command - podLogFile := filepath.Join(outputDir, fmt.Sprintf("%s.log", podName)) - podLogCmd := exec.CommandContext(cmdCtx, "kubectl", "logs", // #nosec G204 -- podName and namespace are from trusted k8s API - podName, - "-n", namespace, - "--tail=200") - - var logContent string - logContent += fmt.Sprintf("$ %s\n\n", podLogCmd.String()) - logOutput, err := podLogCmd.CombinedOutput() - if err != nil { - logContent += fmt.Sprintf("Error: %v\n", err) - logContent += string(logOutput) - } else { - logContent += string(logOutput) - } - - if err := os.WriteFile(podLogFile, []byte(logContent), 0600); err != nil { - logger.Error("failed to write pod log file", - "pod", podName, - "error", err) - } else { - logger.Info("saved pod logs", - "pod", podName, - "file", podLogFile) - } - - // Save pod description using kubectl describe command - podDescFile := filepath.Join(outputDir, fmt.Sprintf("%s-describe.txt", podName)) - podDescCmd := exec.CommandContext(cmdCtx, "kubectl", "describe", "pod", // #nosec G204 -- podName and namespace are from trusted k8s API - podName, - "-n", namespace) - - var descContent string - descContent += fmt.Sprintf("$ %s\n\n", podDescCmd.String()) - descOutput, err := podDescCmd.CombinedOutput() - if err != nil { - descContent += fmt.Sprintf("Error: %v\n", err) - descContent += string(descOutput) - } else { - descContent += string(descOutput) - } - - if err := os.WriteFile(podDescFile, []byte(descContent), 0600); err != nil { - logger.Error("failed to write pod description file", - "pod", podName, - "error", err) - } else { - logger.Info("saved pod description", - "pod", podName, - "file", podDescFile) - } - } - - logger.Info("diagnostic logs saved successfully", "output_dir", outputDir) -} - -// expandEnvVarsInYAMLToBytes expands environment variables in a YAML file using envsubst -// Returns the expanded content as bytes -func expandEnvVarsInYAMLToBytes(ctx context.Context, yamlPath string, extraEnv []string) ([]byte, error) { - // Read the YAML file - content, err := os.ReadFile(yamlPath) // #nosec G304 -- yamlPath is constructed from trusted config - if err != nil { - return nil, fmt.Errorf("failed to read YAML file: %w", err) - } - - // Use envsubst command to expand environment variables - cmd := exec.CommandContext(ctx, "envsubst") - cmd.Stdin = bytes.NewReader(content) - if len(extraEnv) > 0 { - cmd.Env = append(os.Environ(), extraEnv...) - } - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("envsubst failed: %w (stderr: %s)", err, stderr.String()) - } - - return stdout.Bytes(), nil -} - // PurgeAdapterQueue purges all pending messages from the broker queue for the given adapter. // This is used before deploying a test adapter to avoid processing a stale message backlog // accumulated while the adapter was uninstalled between test runs. @@ -733,58 +419,3 @@ func DeletePubSubTopic(ctx context.Context, topicID string, projectID string) er logger.Info("Pub/Sub topic deleted successfully", "topic", topicID) return nil } - -// copyDir recursively copies a directory tree -func copyDir(src, dst string) error { - // Get source directory info - srcInfo, err := os.Stat(src) - if err != nil { - return err - } - - // Create destination directory - if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil { - return err - } - - // Read source directory contents - entries, err := os.ReadDir(src) - if err != nil { - return err - } - - // Copy each entry - for _, entry := range entries { - srcPath := filepath.Join(src, entry.Name()) - dstPath := filepath.Join(dst, entry.Name()) - - if entry.IsDir() { - // Recursively copy subdirectory - if err := copyDir(srcPath, dstPath); err != nil { - return err - } - } else { - // Copy file - if err := copyFile(srcPath, dstPath); err != nil { - return err - } - } - } - - return nil -} - -// copyFile copies a single file -func copyFile(src, dst string) error { - srcData, err := os.ReadFile(src) // #nosec G304 -- src is constructed from trusted config - if err != nil { - return err - } - - srcInfo, err := os.Stat(src) - if err != nil { - return err - } - - return os.WriteFile(dst, srcData, srcInfo.Mode()) -} diff --git a/pkg/helper/adapter_test.go b/pkg/helper/adapter_test.go index e2794ca4..4125cbc3 100644 --- a/pkg/helper/adapter_test.go +++ b/pkg/helper/adapter_test.go @@ -8,14 +8,8 @@ import ( "strings" "testing" - k8sclient "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/client/kubernetes" - "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/config" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - k8sruntime "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/kubernetes/fake" ) // hashSuffixPattern matches the deterministic hash appended on truncation: @@ -114,89 +108,6 @@ func TestGenerateAdapterReleaseName_Deterministic(t *testing.T) { } } -func newHelperWithService(ns string, svc *corev1.Service) *Helper { - var objs []k8sruntime.Object - if svc != nil { - objs = append(objs, svc) - } - return &Helper{ - Cfg: &config.Config{Namespace: ns}, - K8sClient: &k8sclient.Client{Interface: fake.NewClientset(objs...)}, - } -} - -func TestResolveInternalAPIURL(t *testing.T) { - const ns = "hyperfleet-system" - - svcWithPort := func(port int32) *corev1.Service { - return &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{Name: "hyperfleet-api", Namespace: ns}, - Spec: corev1.ServiceSpec{ - Ports: []corev1.ServicePort{{Port: port}}, - }, - } - } - - tests := []struct { - name string - svc *corev1.Service - wantURL string - wantErrMsg string - }{ - { - name: "service found with port", - svc: svcWithPort(8000), - wantURL: fmt.Sprintf("http://hyperfleet-api.%s.svc.cluster.local:8000", ns), - }, - { - name: "service not found", - svc: nil, - wantErrMsg: `failed to get hyperfleet-api service in namespace "hyperfleet-system"`, - }, - { - name: "service found but no ports", - svc: &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{Name: "hyperfleet-api", Namespace: ns}, - Spec: corev1.ServiceSpec{}, - }, - wantErrMsg: "hyperfleet-api service has no ports", - }, - { - // A hyperfleet-api service in a different namespace must not be found - // when h.Cfg.Namespace is set — Get is scoped to the configured namespace. - name: "service in wrong namespace is not found", - svc: &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{Name: "hyperfleet-api", Namespace: "other-ns"}, - Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 8000}}}, - }, - wantErrMsg: `failed to get hyperfleet-api service in namespace "hyperfleet-system"`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := newHelperWithService(ns, tt.svc) - got, err := h.resolveInternalAPIURL(context.Background()) - - if tt.wantErrMsg != "" { - if err == nil { - t.Fatalf("expected error containing %q, got nil", tt.wantErrMsg) - } - if !strings.Contains(err.Error(), tt.wantErrMsg) { - t.Errorf("error %q does not contain %q", err.Error(), tt.wantErrMsg) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != tt.wantURL { - t.Errorf("got %q, want %q", got, tt.wantURL) - } - }) - } -} - // TestGenerateAdapterReleaseName_LongNameCollision asserts that two distinct // long names sharing a long common prefix produce distinct release names. // The hash suffix is what guarantees uniqueness once the base is truncated. @@ -380,69 +291,3 @@ func TestDeletePubSubSubscription(t *testing.T) { }) } } - -func TestAdapterHelmSetArgs(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - tokenRequestSA string // ServiceAccountName - non-empty enables JWT - runID string - setValues map[string]string - wantContains []string // substrings that must appear in joined args - wantAbsent []string // substrings that must NOT appear - }{ - { - name: "includes auth flag when JWT is enabled", - tokenRequestSA: "hyperfleet-e2e-sa", - wantContains: []string{"adapterConfig.hyperfleetApi.auth.enabled=true"}, - }, - { - name: "omits auth flag when JWT is disabled", - wantAbsent: []string{"adapterConfig.hyperfleetApi.auth.enabled"}, - }, - { - name: "includes fullnameOverride", - wantContains: []string{"fullnameOverride=test-release"}, - }, - { - name: "includes run-id label when set", - runID: "abc-123", - wantContains: []string{"e2e.hyperfleet.io/run-id=abc-123"}, - }, - { - name: "omits run-id label when empty", - wantAbsent: []string{"e2e.hyperfleet.io/run-id"}, - }, - { - name: "includes custom set values", - setValues: map[string]string{"image.tag": "latest"}, - wantContains: []string{"image.tag=latest"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - cfg := &config.Config{RunID: tt.runID} - cfg.Identity.TokenRequest.ServiceAccountName = tt.tokenRequestSA - - h := &Helper{Cfg: cfg} - opts := AdapterDeploymentOptions{SetValues: tt.setValues} - args := h.adapterHelmSetArgs("test-release", opts) - joined := strings.Join(args, " ") - - for _, want := range tt.wantContains { - if !strings.Contains(joined, want) { - t.Errorf("expected args to contain %q, got: %v", want, args) - } - } - for _, absent := range tt.wantAbsent { - if strings.Contains(joined, absent) { - t.Errorf("expected args NOT to contain %q, got: %v", absent, args) - } - } - }) - } -} diff --git a/pkg/helper/cleanup.go b/pkg/helper/cleanup.go index 5afa2c72..17b3e3b7 100644 --- a/pkg/helper/cleanup.go +++ b/pkg/helper/cleanup.go @@ -98,7 +98,7 @@ func CleanupKubeResources() { defer cancel() // Step 1: Remove all helm releases installed with the given label selector - helmClient := helm.NewClient(c.cfg.Namespace) + helmClient := helm.NewHelmClient(c.cfg.Namespace) releases, err := helmClient.ListReleasesBySelector(c.labelSelectorListOptions.LabelSelector) if err != nil { // Failed to list releases, so skipping uninstall @@ -107,7 +107,7 @@ func CleanupKubeResources() { } else { logger.Info("found helm releases", "count", len(releases)) for _, release := range releases { - err := helmClient.UninstallRelease(ctx, release, c.cfg.Namespace) + err := helmClient.UninstallRelease(release) if err != nil { logger.Error("failed to uninstall helm release", "name", release, "error", err) continue @@ -128,9 +128,7 @@ func (c *CleanupHelper) SweepPubsubTestAdapterResources(ctx context.Context) err // Get a snapshot of the adapter deployment list to avoid race conditions deployments := c.adapterDeploymentList.Snapshot() var errorList []string - for _, deployment := range deployments { - resourceType := deployment.ResourceType - adapterName := deployment.AdapterName + for adapterName, resourceType := range deployments { namespace := c.cfg.Namespace projectID := c.cfg.GCPProjectID topicID := fmt.Sprintf("%s-%s-%s-dlq", namespace, resourceType, adapterName) diff --git a/pkg/helper/git.go b/pkg/helper/git.go index a3147d4b..3b3b0693 100644 --- a/pkg/helper/git.go +++ b/pkg/helper/git.go @@ -7,78 +7,85 @@ import ( "os" "os/exec" "path/filepath" + "sync" "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/logger" ) +var ( + APIGitClone *GitCloneChart + AdapterGitClone *GitCloneChart +) + // HelmChartCloneOptions contains configuration for cloning a Helm chart repository type HelmChartCloneOptions struct { // Component is the component name (e.g., "adapter", "api", "sentinel") Component string - // RepoURL is the Git repository URL RepoURL string - // Ref is the branch or tag to clone // Note: Commit SHAs are not supported due to git clone --branch limitations Ref string - // ChartPath is the path within the repository to the chart directory // This will be used for sparse checkout to minimize download size - ChartPath string - + RepoPath string // WorkDir is the base work directory for cloning // If empty, uses "./test-work" in current directory WorkDir string } +func NewGitClone(HelmChartCloneOptions *HelmChartCloneOptions) *GitCloneChart { + return &GitCloneChart{ + chartOptions: HelmChartCloneOptions, + } +} + +type GitCloneChart struct { + chartOptions *HelmChartCloneOptions + cloneOnce sync.Once + clonedPath string + err error +} + +func (g *GitCloneChart) CloneChartOnce(ctx context.Context) (string, error) { + g.cloneOnce.Do(func() { + g.clonedPath, g.err = CloneHelmChart(ctx, *g.chartOptions) + }) + return g.clonedPath, g.err +} + // CloneHelmChart clones a Helm chart repository using sparse checkout to minimize download size. -// It returns the full path to the cloned chart and a cleanup function. -func (h *Helper) CloneHelmChart(ctx context.Context, opts HelmChartCloneOptions) (chartPath string, cleanup func() error, err error) { - // Validate required fields +// It returns the full path to the cloned chart directory. +func CloneHelmChart(ctx context.Context, opts HelmChartCloneOptions) (string, error) { if opts.Component == "" { - return "", nil, fmt.Errorf("component is required") + return "", fmt.Errorf("component is required") } if opts.RepoURL == "" { - return "", nil, fmt.Errorf("repoURL is required") + return "", fmt.Errorf("repoURL is required") } if opts.Ref == "" { - return "", nil, fmt.Errorf("ref is required") + return "", fmt.Errorf("ref is required") } - if opts.ChartPath == "" { - return "", nil, fmt.Errorf("ChartPath is required") + if opts.RepoPath == "" { + return "", fmt.Errorf("ChartPath is required") } - // Set default work directory if not specified workDir := opts.WorkDir if workDir == "" { - // Default to ./.test-work in current directory cwd, err := os.Getwd() if err != nil { - return "", nil, fmt.Errorf("failed to get current directory: %w", err) + return "", fmt.Errorf("failed to get current directory: %w", err) } workDir = filepath.Join(cwd, TestWorkDir) } - // Ensure work directory exists before cloning if err := os.MkdirAll(workDir, 0750); err != nil { - return "", nil, fmt.Errorf("failed to create work directory: %w", err) + return "", fmt.Errorf("failed to create work directory: %w", err) } - // Create an isolated component-specific directory per invocation - // This prevents race conditions when parallel tests clone the same component componentDir, err := os.MkdirTemp(workDir, opts.Component+"-") if err != nil { - return "", nil, fmt.Errorf("failed to create component work directory: %w", err) - } - - // Cleanup function to remove the cloned repository - cleanup = func() error { - logger.Info("cleaning up cloned Helm chart", "path", componentDir) - if err := os.RemoveAll(componentDir); err != nil { - return fmt.Errorf("failed to remove cloned chart directory: %w", err) - } - return nil + return "", fmt.Errorf("failed to create component work directory: %w", err) } // Redact credentials from RepoURL before logging @@ -92,7 +99,7 @@ func (h *Helper) CloneHelmChart(ctx context.Context, opts HelmChartCloneOptions) "component", opts.Component, "repo", redactedRepo, "ref", opts.Ref, - "chart_path", opts.ChartPath, + "repo_path", opts.RepoPath, "dest", componentDir) // Step 1: Clone with sparse checkout (no files yet) @@ -107,49 +114,45 @@ func (h *Helper) CloneHelmChart(ctx context.Context, opts HelmChartCloneOptions) componentDir) if output, err := cmd.CombinedOutput(); err != nil { - _ = cleanup() - return "", nil, fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output)) + _ = os.RemoveAll(componentDir) + return "", fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output)) } // Step 2: Configure sparse checkout - only checkout the chart path - logger.Info("configuring sparse checkout", "sparse_path", opts.ChartPath) + logger.Info("configuring sparse checkout", "sparse_path", opts.RepoPath) - // Initialize sparse checkout (no cone mode) cmd = exec.CommandContext(ctx, "git", "sparse-checkout", "init", "--no-cone") cmd.Dir = componentDir if output, err := cmd.CombinedOutput(); err != nil { - _ = cleanup() - return "", nil, fmt.Errorf("sparse-checkout init failed: %w\nOutput: %s", err, string(output)) + _ = os.RemoveAll(componentDir) + return "", fmt.Errorf("sparse-checkout init failed: %w\nOutput: %s", err, string(output)) } - // Set sparse checkout path - cmd = exec.CommandContext(ctx, "git", "sparse-checkout", "set", opts.ChartPath) // #nosec G204 -- opts.ChartPath is from trusted config + cmd = exec.CommandContext(ctx, "git", "sparse-checkout", "set", opts.RepoPath) // #nosec G204 -- opts.ChartPath is from trusted config cmd.Dir = componentDir if output, err := cmd.CombinedOutput(); err != nil { - _ = cleanup() - return "", nil, fmt.Errorf("sparse-checkout set failed: %w\nOutput: %s", err, string(output)) + _ = os.RemoveAll(componentDir) + return "", fmt.Errorf("sparse-checkout set failed: %w\nOutput: %s", err, string(output)) } - // Checkout the files logger.Info("checking out files") cmd = exec.CommandContext(ctx, "git", "checkout", opts.Ref) // #nosec G204 -- opts.Ref is from trusted config cmd.Dir = componentDir if output, err := cmd.CombinedOutput(); err != nil { - _ = cleanup() - return "", nil, fmt.Errorf("git checkout failed: %w\nOutput: %s", err, string(output)) + _ = os.RemoveAll(componentDir) + return "", fmt.Errorf("git checkout failed: %w\nOutput: %s", err, string(output)) } - // Verify Chart.yaml exists in the cloned chart directory - fullChartPath := filepath.Join(componentDir, opts.ChartPath) + fullChartPath := filepath.Join(componentDir, opts.RepoPath) chartYamlPath := filepath.Join(fullChartPath, "Chart.yaml") if _, err := os.Stat(chartYamlPath); err != nil { - _ = cleanup() - return "", nil, fmt.Errorf("chart.yaml not found at %s (verify ChartPath is correct): %w", fullChartPath, err) + _ = os.RemoveAll(componentDir) + return "", fmt.Errorf("chart.yaml not found at %s (verify ChartPath is correct): %w", fullChartPath, err) } logger.Info("Helm chart cloned successfully", "component", opts.Component, "chart_path", fullChartPath) - return fullChartPath, cleanup, nil + return fullChartPath, nil } diff --git a/pkg/helper/helm/helm.go b/pkg/helper/helm/helm.go index 538ed27a..26310048 100644 --- a/pkg/helper/helm/helm.go +++ b/pkg/helper/helm/helm.go @@ -3,36 +3,37 @@ package helm import ( "context" "fmt" - "os/exec" "sync" "time" "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart/loader" "helm.sh/helm/v3/pkg/cli" "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/logger" ) -// Client wraps Helm SDK functionality -type Client struct { +// HelmClient wraps Helm SDK functionality for E2E test cleanup operations +type HelmClient struct { settings *cli.EnvSettings - namespace string actionConfig *action.Configuration configOnce sync.Once configErr error } -// NewClient creates a new Helm client using default environment settings -func NewClient(namespace string) *Client { - return &Client{ - settings: cli.New(), - namespace: namespace, +// NewHelmClient creates a new Helm client using default environment settings +func NewHelmClient(namespace string) *HelmClient { + envSettings := cli.New() + envSettings.SetNamespace(namespace) + + return &HelmClient{ + settings: envSettings, } } // initActionConfig initializes Helm action configuration once and caches it // Subsequent calls return the cached config -func (c *Client) initActionConfig() (*action.Configuration, error) { +func (c *HelmClient) initActionConfig() (*action.Configuration, error) { c.configOnce.Do(func() { actionConfig := new(action.Configuration) @@ -40,7 +41,7 @@ func (c *Client) initActionConfig() (*action.Configuration, error) { helmDriver := "" // Initialize with REST client getter, namespace, and driver - if err := actionConfig.Init(c.settings.RESTClientGetter(), c.namespace, helmDriver, func(format string, v ...interface{}) { + if err := actionConfig.Init(c.settings.RESTClientGetter(), c.settings.Namespace(), helmDriver, func(format string, v ...interface{}) { logger.Info(fmt.Sprintf(format, v...)) }); err != nil { c.configErr = fmt.Errorf("failed to init Helm action config: %w", err) @@ -48,7 +49,7 @@ func (c *Client) initActionConfig() (*action.Configuration, error) { } c.actionConfig = actionConfig - logger.Info("initialized Helm action config", "namespace", c.namespace) + logger.Info("initialized Helm action config", "namespace", c.settings.Namespace()) }) return c.actionConfig, c.configErr @@ -57,7 +58,7 @@ func (c *Client) initActionConfig() (*action.Configuration, error) { // ListReleases lists all Helm releases across client namespace with the given label selector // labelSelector uses Kubernetes label selector format (e.g., "e2e.hyperfleet.io/run-id=test-123") // Returns a list of release names -func (c *Client) ListReleasesBySelector(labelSelector string) ([]string, error) { +func (c *HelmClient) ListReleasesBySelector(labelSelector string) ([]string, error) { // Initialize action config for all namespaces (empty string means all) actionConfig, err := c.initActionConfig() if err != nil { @@ -78,7 +79,7 @@ func (c *Client) ListReleasesBySelector(labelSelector string) ([]string, error) releases := []string{} for _, rel := range results { // check that helm list is only listing releases in namespace - if rel.Namespace != c.namespace { + if rel.Namespace != c.settings.Namespace() { logger.Warn("helm incorrectly listing releases outside namespace") continue } @@ -89,29 +90,65 @@ func (c *Client) ListReleasesBySelector(labelSelector string) ([]string, error) return releases, nil } -// UninstallRelease uninstalls the helm release. This workflow matches the way the adapters are currently installed. -// Future work can be done to move helm releases to be installed with helm sdk -func (c *Client) UninstallRelease(ctx context.Context, releaseName, namespace string) error { - logger.Info("uninstalling helm release", - "release_name", releaseName, - "namespace", namespace) +// InstallRelease installs a Helm chart from a local path with values from a template file +// fileValues is a slice of "key=filepath" entries that will be loaded and set as values (like --set-file) +func (c *HelmClient) InstallRelease(ctx context.Context, releaseName string, chartPath string, releaseValues map[string]interface{}, + labels map[string]string) error { + actionConfig, err := c.initActionConfig() + if err != nil { + return err + } - // Create context with timeout - cmdCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) - defer cancel() + // Set up install action + installClient := action.NewInstall(actionConfig) + installClient.DryRunOption = "none" + installClient.ReleaseName = releaseName + installClient.Namespace = c.settings.Namespace() + installClient.CreateNamespace = true + installClient.Wait = true + installClient.Timeout = 5 * time.Minute + installClient.Labels = labels + + // Load the chart from local filesystem + chart, err := loader.Load(chartPath) + if err != nil { + return fmt.Errorf("failed to load chart from %s: %w", chartPath, err) + } + + // Install the chart with dedicated releaseValues + release, err := installClient.RunWithContext(ctx, chart, releaseValues) + if err != nil { + return fmt.Errorf("failed to install release: %w", err) + } - // Execute Helm uninstall command - cmd := exec.CommandContext(cmdCtx, "helm", "uninstall", releaseName, - "-n", namespace, - "--wait", - "--timeout", "5m") + logger.Info("successfully installed release", + "name", release.Name, + "version", release.Version, + "namespace", release.Namespace) - output, err := cmd.CombinedOutput() + return nil +} +// UninstallRelease uninstalls the helm release. This workflow matches the way the adapters are currently installed. +// Future work can be done to move helm releases to be installed with helm sdk +func (c *HelmClient) UninstallRelease(releaseName string) error { + actionConfig, err := c.initActionConfig() if err != nil { - return fmt.Errorf("failed to uninstall release: %w (output: %s)", err, string(output)) + return err } - logger.Info("helm uninstall completed", "release", releaseName, "namespace", namespace) + uninstallClient := action.NewUninstall(actionConfig) + uninstallClient.DeletionPropagation = "foreground" // "background" or "orphan" + + result, err := uninstallClient.Run(releaseName) + if err != nil { + return fmt.Errorf("failed to run uninstall action: %w", err) + } + if result != nil && result.Release != nil { + logger.Info("helm uninstall completed", + "name", result.Release.Name, + "version", result.Release.Version, + "namespace", result.Release.Namespace) + } return nil } diff --git a/pkg/labels/labels.go b/pkg/labels/labels.go index c2a46d9f..87fc5516 100644 --- a/pkg/labels/labels.go +++ b/pkg/labels/labels.go @@ -32,3 +32,7 @@ const ( Disruptive = "disruptive" // Destructive testing: fault injection Slow = "slow" // Long-running: execution time exceeds 5-10 minutes ) + +const ( + Adapter = "adapter" +) diff --git a/testdata/adapter-configs/cl-crash.yaml b/testdata/adapter-configs/cl-crash.yaml new file mode 100644 index 00000000..9af37e8d --- /dev/null +++ b/testdata/adapter-configs/cl-crash.yaml @@ -0,0 +1,151 @@ + +rbac: + resources: + - namespaces + +adapterConfig: + yaml: + adapter: + name: cl-crash + + debug_config: false + log: + level: debug + + clients: + hyperfleet_api: + base_url: CHANGE_ME + version: v1 + timeout: 2s + retry_attempts: 3 + retry_backoff: exponential + + broker: + subscription_id: CHANGE_ME + topic: CHANGE_ME + + kubernetes: + api_version: "v1" + +adapterTaskConfig: + yaml: + params: + - name: "clusterId" + source: "event.id" + type: "string" + required: true + - name: "runId" + source: "env.RUN_ID" + type: "string" + required: true + + - name: "clusterStatus" + source: + api_call: + method: "GET" + url: "/clusters/{{ .clusterId }}" + timeout: 10s + retry_attempts: 3 + retry_backoff: "exponential" + + - name: "clusterName" + source: "clusterStatus.name" + + - name: "generationSpec" + source: "clusterStatus.generation" + + - name: "clusterNotReconciled" + source: + expression: | + clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 + ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].status != "True" + : true + + - name: "clusterReconciledTTL" + source: + expression: | + (timestamp(now()) - timestamp( + clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 + ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].last_transition_time + : now() + )).getSeconds() > 300 + + preconditions: + - name: "validationCheck" + expression: | + clusterNotReconciled || clusterReconciledTTL + + resources: + - name: "clusterNamespace" + transport: + client: "kubernetes" + manifest: + apiVersion: v1 + kind: Namespace + metadata: + name: "{{ .clusterId }}-cl-crash" + labels: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/cluster-name: "{{ .clusterName }}" + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + hyperfleet.io/generation: "{{ .generationSpec }}" + discovery: + namespace: "*" + by_selectors: + label_selector: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + + post: + payloads: + - name: "clusterStatusPayload" + build: + adapter: "{{ .adapter.name }}" + conditions: + - type: "Applied" + status: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "True" : "False" + reason: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" + ? "NamespaceCreated" + : "NamespacePending" + message: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" + ? "Namespace created successfully" + : "Namespace creation in progress" + - type: "Available" + status: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "True" : "False" + reason: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "NamespaceReady" : "NamespaceNotReady" + message: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "Namespace is active and ready" : "Namespace is not active and ready" + - type: "Health" + status: + expression: | + adapter.?executionStatus.orValue("") == "success" ? "True" : "False" + reason: + expression: | + adapter.?errorReason.orValue("") != "" ? adapter.?errorReason.orValue("") : "Healthy" + message: + expression: | + adapter.?errorMessage.orValue("") != "" ? adapter.?errorMessage.orValue("") : "All adapter operations in progress or completed successfully" + observed_generation: + expression: "generationSpec" + observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" + + post_actions: + - name: "reportClusterStatus" + api_call: + method: "PUT" + url: "/clusters/{{ .clusterId }}/statuses" + headers: + - name: "Content-Type" + value: "application/json" + body: "{{ .clusterStatusPayload }}" diff --git a/testdata/adapter-configs/cl-crash/adapter-config.yaml b/testdata/adapter-configs/cl-crash/adapter-config.yaml deleted file mode 100644 index f9b9c3e5..00000000 --- a/testdata/adapter-configs/cl-crash/adapter-config.yaml +++ /dev/null @@ -1,21 +0,0 @@ -adapter: - name: cl-crash - -debug_config: false -log: - level: debug - -clients: - hyperfleet_api: - base_url: CHANGE_ME - version: v1 - timeout: 2s - retry_attempts: 3 - retry_backoff: exponential - - broker: - subscription_id: CHANGE_ME - topic: CHANGE_ME - - kubernetes: - api_version: "v1" diff --git a/testdata/adapter-configs/cl-crash/adapter-task-config.yaml b/testdata/adapter-configs/cl-crash/adapter-task-config.yaml deleted file mode 100644 index b65b5923..00000000 --- a/testdata/adapter-configs/cl-crash/adapter-task-config.yaml +++ /dev/null @@ -1,123 +0,0 @@ -# Minimal adapter task config for crash recovery testing -# Creates a namespace as the only resource - simple and fast to verify - -params: - - name: "clusterId" - source: "event.id" - type: "string" - required: true - - name: "runId" - source: "env.RUN_ID" - type: "string" - required: true - - - name: "clusterStatus" - source: - api_call: - method: "GET" - url: "/clusters/{{ .clusterId }}" - timeout: 10s - retry_attempts: 3 - retry_backoff: "exponential" - - - name: "clusterName" - source: "clusterStatus.name" - - - name: "generationSpec" - source: "clusterStatus.generation" - - - name: "clusterNotReconciled" - source: - expression: | - clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 - ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].status != "True" - : true - - - name: "clusterReconciledTTL" - source: - expression: | - (timestamp(now()) - timestamp( - clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 - ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].last_transition_time - : now() - )).getSeconds() > 300 - -preconditions: - - name: "validationCheck" - expression: | - clusterNotReconciled || clusterReconciledTTL - -resources: - - name: "clusterNamespace" - transport: - client: "kubernetes" - manifest: - apiVersion: v1 - kind: Namespace - metadata: - name: "{{ .clusterId }}-cl-crash" - labels: - hyperfleet.io/cluster-id: "{{ .clusterId }}" - hyperfleet.io/cluster-name: "{{ .clusterName }}" - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - hyperfleet.io/generation: "{{ .generationSpec }}" - discovery: - namespace: "*" - by_selectors: - label_selector: - hyperfleet.io/cluster-id: "{{ .clusterId }}" - -post: - payloads: - - name: "clusterStatusPayload" - build: - adapter: "{{ .adapter.name }}" - conditions: - - type: "Applied" - status: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "True" : "False" - reason: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" - ? "NamespaceCreated" - : "NamespacePending" - message: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" - ? "Namespace created successfully" - : "Namespace creation in progress" - - type: "Available" - status: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "True" : "False" - reason: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "NamespaceReady" : "NamespaceNotReady" - message: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "Namespace is active and ready" : "Namespace is not active and ready" - - type: "Health" - status: - expression: | - adapter.?executionStatus.orValue("") == "success" ? "True" : "False" - reason: - expression: | - adapter.?errorReason.orValue("") != "" ? adapter.?errorReason.orValue("") : "Healthy" - message: - expression: | - adapter.?errorMessage.orValue("") != "" ? adapter.?errorMessage.orValue("") : "All adapter operations in progress or completed successfully" - observed_generation: - expression: "generationSpec" - observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" - - post_actions: - - name: "reportClusterStatus" - api_call: - method: "PUT" - url: "/clusters/{{ .clusterId }}/statuses" - headers: - - name: "Content-Type" - value: "application/json" - body: "{{ .clusterStatusPayload }}" diff --git a/testdata/adapter-configs/cl-crash/values.yaml b/testdata/adapter-configs/cl-crash/values.yaml deleted file mode 100644 index e64635b5..00000000 --- a/testdata/adapter-configs/cl-crash/values.yaml +++ /dev/null @@ -1,45 +0,0 @@ -adapterConfig: - create: true - files: - adapter-config.yaml: cl-crash/adapter-config.yaml - log: - level: debug - -adapterTaskConfig: - create: true - files: - task-config.yaml: cl-crash/adapter-task-config.yaml - -broker: - type: ${BROKER_TYPE} - create: true - googlepubsub: - projectId: ${GCP_PROJECT_ID} - subscriptionId: ${NAMESPACE}-clusters-${ADAPTER_NAME} - topic: ${NAMESPACE}-clusters - deadLetterTopic: ${NAMESPACE}-clusters-dlq - createTopicIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_TOPIC_IF_MISSING} - createSubscriptionIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_SUBSCRIPTION_IF_MISSING} - expirationTTL: "1d" - rabbitmq: - url: ${RABBITMQ_URL} - queue: ${NAMESPACE}-clusters-${ADAPTER_NAME} - exchange: ${NAMESPACE}-clusters - routingKey: ${ADAPTER_NAME} - -image: - registry: ${IMAGE_REGISTRY} - repository: ${ADAPTER_IMAGE_REPO} - pullPolicy: Always - tag: ${ADAPTER_IMAGE_TAG} - -rbac: - resources: - - namespaces - -labels: - e2e.hyperfleet.io/run-id: ${RUN_ID} - -env: - - name: RUN_ID - value: ${RUN_ID} diff --git a/testdata/adapter-configs/cl-invalid-resource.yaml b/testdata/adapter-configs/cl-invalid-resource.yaml new file mode 100644 index 00000000..a4010bd5 --- /dev/null +++ b/testdata/adapter-configs/cl-invalid-resource.yaml @@ -0,0 +1,170 @@ + +rbac: + resources: + - namespaces + - configmaps + +adapterConfig: + yaml: + adapter: + name: cl-invalid-resource + debug_config: false + log: + level: debug + clients: + hyperfleet_api: + base_url: http://hyperfleet-api:8000 + version: v1 + timeout: 2s + retry_attempts: 3 + retry_backoff: exponential + broker: + subscription_id: CHANGE_ME + topic: CHANGE_ME + kubernetes: + api_version: "v1" + +adapterTaskConfig: + yaml: + params: + - name: "clusterId" + source: "event.id" + type: "string" + required: true + - name: "runId" + source: "env.RUN_ID" + type: "string" + required: true + - name: "ci" + source: "env.CI" + type: "string" + required: false + default: "false" + + - name: "clusterStatus" + source: + api_call: + method: "GET" + url: "/clusters/{{ .clusterId }}" + timeout: 10s + retry_attempts: 3 + retry_backoff: "exponential" + + - name: "clusterName" + source: "clusterStatus.name" + + - name: "generationSpec" + source: "clusterStatus.generation" + + - name: "reconciledConditionStatus" + source: + expression: | + clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 + ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].status + : "False" + + preconditions: + - name: "validationCheck" + expression: | + reconciledConditionStatus == "False" + + # Resources with INVALID K8s manifest - this will cause API server rejection + resources: + - name: "invalidConfigMap" + transport: + client: "kubernetes" + manifest: + apiVersion: v1 + kind: ConfigMap + metadata: + # Invalid name: contains uppercase letters which violate DNS-1123 subdomain naming rules + name: "INVALID-NAME-{{ .clusterId }}" + namespace: "{{ .clusterId }}" + labels: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/cluster-name: "{{ .clusterName }}" + e2e.hyperfleet.io/ci: "{{ .ci }}" + e2e.hyperfleet.io/managed-by: "test-framework" + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + hyperfleet.io/generation: "{{ .generationSpec }}" + data: + test-key: "test-value" + discovery: + namespace: "{{ .clusterId }}" + by_selectors: + label_selector: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + + # Post-processing to report the status (will report failure) + post: + payloads: + - name: "clusterStatusPayload" + build: + adapter: "{{ .adapter.name }}" + conditions: + # Applied: Resource creation attempt + - type: "Applied" + status: + expression: | + resources.?invalidConfigMap.?metadata.?name.orValue("") != "" ? "True" : "False" + reason: + expression: | + resources.?invalidConfigMap.?metadata.?name.orValue("") != "" + ? "ResourceCreated" + : "ResourceFailed" + message: + expression: | + resources.?invalidConfigMap.?metadata.?name.orValue("") != "" + ? "ConfigMap created successfully" + : "ConfigMap creation failed" + # Available: Check resource readiness + - type: "Available" + status: + expression: | + resources.?invalidConfigMap.?metadata.?name.orValue("") != "" ? "True" : "False" + reason: + expression: | + adapter.?errorReason.orValue("") != "" + ? adapter.?errorReason.orValue("") + : (resources.?invalidConfigMap.?metadata.?name.orValue("") != "" + ? "ResourceReady" + : "ResourceNotReady") + message: + expression: | + adapter.?errorMessage.orValue("") != "" + ? adapter.?errorMessage.orValue("") + : (resources.?invalidConfigMap.?metadata.?name.orValue("") != "" + ? "ConfigMap is ready" + : "ConfigMap is not ready") + # Health: Adapter execution status (runtime) + - type: "Health" + status: + expression: | + adapter.?executionStatus.orValue("") == "success" ? "True" : "False" + reason: + expression: | + adapter.?errorReason.orValue("") != "" ? adapter.?errorReason.orValue("") : "Healthy" + message: + expression: | + adapter.?errorMessage.orValue("") != "" ? adapter.?errorMessage.orValue("") : "All adapter operations completed successfully" + # Event generation ID metadata field needs to use expression to avoid interpolation issues + observed_generation: + expression: "generationSpec" + observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" + + data: + configmap: + name: + expression: | + resources.?invalidConfigMap.?metadata.?name.orValue("") + + post_actions: + - name: "reportClusterStatus" + api_call: + method: "PUT" + url: "/clusters/{{ .clusterId }}/statuses" + headers: + - name: "Content-Type" + value: "application/json" + body: "{{ .clusterStatusPayload }}" diff --git a/testdata/adapter-configs/cl-invalid-resource/adapter-config.yaml b/testdata/adapter-configs/cl-invalid-resource/adapter-config.yaml deleted file mode 100644 index 050e8e1d..00000000 --- a/testdata/adapter-configs/cl-invalid-resource/adapter-config.yaml +++ /dev/null @@ -1,23 +0,0 @@ -adapter: - name: cl-invalid-resource - #version: "0.1.0" - -# Log the full merged configuration after load (default: false) -debug_config: false -log: - level: debug - -clients: - hyperfleet_api: - base_url: http://hyperfleet-api:8000 - version: v1 - timeout: 2s - retry_attempts: 3 - retry_backoff: exponential - - broker: - subscription_id: CHANGE_ME - topic: CHANGE_ME - - kubernetes: - api_version: "v1" diff --git a/testdata/adapter-configs/cl-invalid-resource/adapter-task-config.yaml b/testdata/adapter-configs/cl-invalid-resource/adapter-task-config.yaml deleted file mode 100644 index 030211e6..00000000 --- a/testdata/adapter-configs/cl-invalid-resource/adapter-task-config.yaml +++ /dev/null @@ -1,145 +0,0 @@ -# Test adapter configuration with invalid K8s resource to test error detection - -# Parameters with all required variables -params: - - name: "clusterId" - source: "event.id" - type: "string" - required: true - - name: "runId" - source: "env.RUN_ID" - type: "string" - required: true - - name: "ci" - source: "env.CI" - type: "string" - required: false - default: "false" - - - name: "clusterStatus" - source: - api_call: - method: "GET" - url: "/clusters/{{ .clusterId }}" - timeout: 10s - retry_attempts: 3 - retry_backoff: "exponential" - - - name: "clusterName" - source: "clusterStatus.name" - - - name: "generationSpec" - source: "clusterStatus.generation" - - - name: "reconciledConditionStatus" - source: - expression: | - clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 - ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].status - : "False" - -preconditions: - - name: "validationCheck" - expression: | - reconciledConditionStatus == "False" - -# Resources with INVALID K8s manifest - this will cause API server rejection -resources: - - name: "invalidConfigMap" - transport: - client: "kubernetes" - manifest: - apiVersion: v1 - kind: ConfigMap - metadata: - # Invalid name: contains uppercase letters which violate DNS-1123 subdomain naming rules - name: "INVALID-NAME-{{ .clusterId }}" - namespace: "{{ .clusterId }}" - labels: - hyperfleet.io/cluster-id: "{{ .clusterId }}" - hyperfleet.io/cluster-name: "{{ .clusterName }}" - e2e.hyperfleet.io/ci: "{{ .ci }}" - e2e.hyperfleet.io/managed-by: "test-framework" - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - hyperfleet.io/generation: "{{ .generationSpec }}" - data: - test-key: "test-value" - discovery: - namespace: "{{ .clusterId }}" - by_selectors: - label_selector: - hyperfleet.io/cluster-id: "{{ .clusterId }}" - -# Post-processing to report the status (will report failure) -post: - payloads: - - name: "clusterStatusPayload" - build: - adapter: "{{ .adapter.name }}" - conditions: - # Applied: Resource creation attempt - - type: "Applied" - status: - expression: | - resources.?invalidConfigMap.?metadata.?name.orValue("") != "" ? "True" : "False" - reason: - expression: | - resources.?invalidConfigMap.?metadata.?name.orValue("") != "" - ? "ResourceCreated" - : "ResourceFailed" - message: - expression: | - resources.?invalidConfigMap.?metadata.?name.orValue("") != "" - ? "ConfigMap created successfully" - : "ConfigMap creation failed" - # Available: Check resource readiness - - type: "Available" - status: - expression: | - resources.?invalidConfigMap.?metadata.?name.orValue("") != "" ? "True" : "False" - reason: - expression: | - adapter.?errorReason.orValue("") != "" - ? adapter.?errorReason.orValue("") - : (resources.?invalidConfigMap.?metadata.?name.orValue("") != "" - ? "ResourceReady" - : "ResourceNotReady") - message: - expression: | - adapter.?errorMessage.orValue("") != "" - ? adapter.?errorMessage.orValue("") - : (resources.?invalidConfigMap.?metadata.?name.orValue("") != "" - ? "ConfigMap is ready" - : "ConfigMap is not ready") - # Health: Adapter execution status (runtime) - - type: "Health" - status: - expression: | - adapter.?executionStatus.orValue("") == "success" ? "True" : "False" - reason: - expression: | - adapter.?errorReason.orValue("") != "" ? adapter.?errorReason.orValue("") : "Healthy" - message: - expression: | - adapter.?errorMessage.orValue("") != "" ? adapter.?errorMessage.orValue("") : "All adapter operations completed successfully" - # Event generation ID metadata field needs to use expression to avoid interpolation issues - observed_generation: - expression: "generationSpec" - observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" - - data: - configmap: - name: - expression: | - resources.?invalidConfigMap.?metadata.?name.orValue("") - - post_actions: - - name: "reportClusterStatus" - api_call: - method: "PUT" - url: "/clusters/{{ .clusterId }}/statuses" - headers: - - name: "Content-Type" - value: "application/json" - body: "{{ .clusterStatusPayload }}" diff --git a/testdata/adapter-configs/cl-invalid-resource/values.yaml b/testdata/adapter-configs/cl-invalid-resource/values.yaml deleted file mode 100644 index 7a8dffc1..00000000 --- a/testdata/adapter-configs/cl-invalid-resource/values.yaml +++ /dev/null @@ -1,46 +0,0 @@ -adapterConfig: - create: true - files: - adapter-config.yaml: cl-invalid-resource/adapter-config.yaml - log: - level: debug - -adapterTaskConfig: - create: true - files: - task-config.yaml: cl-invalid-resource/adapter-task-config.yaml - -broker: - type: ${BROKER_TYPE} - create: true - googlepubsub: - projectId: ${GCP_PROJECT_ID} - subscriptionId: ${NAMESPACE}-clusters-${ADAPTER_NAME} - topic: ${NAMESPACE}-clusters - deadLetterTopic: ${NAMESPACE}-clusters-dlq - createTopicIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_TOPIC_IF_MISSING} - createSubscriptionIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_SUBSCRIPTION_IF_MISSING} - expirationTTL: "1d" - rabbitmq: - url: ${RABBITMQ_URL} - queue: ${NAMESPACE}-clusters-${ADAPTER_NAME} - exchange: ${NAMESPACE}-clusters - routingKey: ${ADAPTER_NAME} - -image: - registry: ${IMAGE_REGISTRY} - repository: ${ADAPTER_IMAGE_REPO} - pullPolicy: Always - tag: ${ADAPTER_IMAGE_TAG} - -rbac: - resources: - - namespaces - - configmaps - -labels: - e2e.hyperfleet.io/run-id: ${RUN_ID} - -env: - - name: RUN_ID - value: ${RUN_ID} diff --git a/testdata/adapter-configs/cl-m-bad-api.yaml b/testdata/adapter-configs/cl-m-bad-api.yaml new file mode 100644 index 00000000..872f66ff --- /dev/null +++ b/testdata/adapter-configs/cl-m-bad-api.yaml @@ -0,0 +1,382 @@ +rbac: + resources: + - namespaces + - configmaps + - configmaps/status + +adapterConfig: + yaml: + adapter: + name: cl-m-bad-api + # Log the full merged configuration after load (default: false) + debug_config: true + log: + level: debug + + clients: + hyperfleet_api: + # invalid base_url of hyperfleet_api + base_url: http://invalid-hyperfleet-api-endpoint.local:9999 + version: v1 + timeout: 2s + retry_attempts: 3 + retry_backoff: exponential + + broker: + # These values are overridden at deploy time via env vars from Helm values + subscription_id: CHANGE_ME + topic: CHANGE_ME + + maestro: + grpc_server_address: "maestro-grpc.maestro.svc.cluster.local:8090" + + # HTTPS server address for REST API operations (optional) + # Environment variable: HYPERFLEET_MAESTRO_HTTP_SERVER_ADDRESS + http_server_address: "http://maestro.maestro.svc.cluster.local:8000" + + # Source identifier for CloudEvents routing (must be unique across adapters) + # Environment variable: HYPERFLEET_MAESTRO_SOURCE_ID + source_id: "cl-m-bad-api" + + # Client identifier (defaults to source_id if not specified) + # Environment variable: HYPERFLEET_MAESTRO_CLIENT_ID + client_id: "cl-m-bad-api-client" + insecure: true + + # Authentication configuration + #auth: + # type: "tls" # TLS certificate-based mTLS + # + # tls_config: + # # gRPC TLS configuration + # # Certificate paths (mounted from Kubernetes secrets) + # # Environment variable: HYPERFLEET_MAESTRO_CA_FILE + # ca_file: "/etc/maestro/certs/grpc/ca.crt" + # + # # Environment variable: HYPERFLEET_MAESTRO_CERT_FILE + # cert_file: "/etc/maestro/certs/grpc/client.crt" + # + # # Environment variable: HYPERFLEET_MAESTRO_KEY_FILE + # key_file: "/etc/maestro/certs/grpc/client.key" + # + # # Server name for TLS verification + # # Environment variable: HYPERFLEET_MAESTRO_SERVER_NAME + # server_name: "maestro-grpc.maestro.svc.cluster.local" + # + # # HTTP API TLS configuration (may use different CA than gRPC) + # # If not set, falls back to ca_file for backwards compatibility + # # Environment variable: HYPERFLEET_MAESTRO_HTTP_CA_FILE + # http_ca_file: "/etc/maestro/certs/https/ca.crt" + +adapterTaskConfig: + yaml: + params: + - name: "clusterId" + source: "event.id" + type: "string" + required: true + - name: "runId" + source: "env.RUN_ID" + type: "string" + required: true + - name: "generation" + source: "event.generation" + type: "int" + required: true + - name: "namespace" + source: "env.NAMESPACE" + type: "string" + + + # Preconditions without API calls (API URL is unreachable in this test) + # Use only parameter-based values to avoid API dependency + preconditions: + - name: "clusterReconciled" + # Simple expression that always passes + expression: "true" + + # Resources with valid K8s manifests + resources: + - name: "resource0" + transport: + client: "maestro" + maestro: + target_cluster: "cluster1" + + # ManifestWork is a kind of manifest that can be used to create resources on the cluster. + # It is a collection of resources that are created together. + manifest: + apiVersion: work.open-cluster-management.io/v1 + kind: ManifestWork + metadata: + # ManifestWork name - must be unique within consumer namespace + name: "{{ .clusterId }}-{{ .adapter.name }}" + + # Labels for identification, filtering, and management + labels: + # HyperFleet tracking labels + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/adapter: "{{ .adapter.name }}" + hyperfleet.io/component: "infrastructure" + hyperfleet.io/generation: "{{ .generation }}" + hyperfleet.io/resource-group: "cluster-setup" + + # Maestro-specific labels + maestro.io/source-id: "{{ .adapter.name }}" + maestro.io/resource-type: "manifestwork" + maestro.io/priority: "normal" + + # Standard Kubernetes application labels + app.kubernetes.io/name: "aro-hcp-cluster" + app.kubernetes.io/instance: "{{ .clusterId }}" + app.kubernetes.io/version: "v1.0.0" + app.kubernetes.io/component: "infrastructure" + app.kubernetes.io/part-of: "hyperfleet" + app.kubernetes.io/managed-by: "cl-maestro" + app.kubernetes.io/created-by: "{{ .adapter.name }}" + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + # Tracking and lifecycle + hyperfleet.io/created-by: "cl-maestro-framework" + hyperfleet.io/managed-by: "{{ .adapter.name }}" + hyperfleet.io/generation: "{{ .generation }}" + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/cluster-name: "{{ .clusterId }}" + hyperfleet.io/deployment-time: "2024-01-01T00:00:00Z" + + # Maestro-specific annotations + maestro.io/applied-time: "2024-01-01T00:00:00Z" + maestro.io/source-adapter: "{{ .adapter.name }}" + + # Documentation + description: "Complete cluster setup including namespace, configuration, and RBAC" + + # ManifestWork specification + spec: + # ============================================================================ + # Workload - Contains the Kubernetes manifests to deploy + # ============================================================================ + workload: + # Kubernetes manifests array - injected by framework from business logic config + manifests: + - apiVersion: v1 + kind: Namespace + metadata: + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + labels: + app.kubernetes.io/component: adapter-task-config + app.kubernetes.io/instance: "{{ .adapter.name }}" + app.kubernetes.io/name: cl-maestro + app.kubernetes.io/transport: maestro + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + hyperfleet.io/generation: "{{ .generation }}" + - apiVersion: v1 + kind: ConfigMap + data: + cluster_id: "{{ .clusterId }}" + cluster_name: "{{ .clusterId }}" + metadata: + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" + namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + labels: + app.kubernetes.io/component: adapter-task-config + app.kubernetes.io/instance: "{{ .adapter.name }}" + app.kubernetes.io/name: cl-maestro + app.kubernetes.io/version: 1.0.0 + app.kubernetes.io/transport: maestro + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + hyperfleet.io/generation: "{{ .generation }}" + + # ============================================================================ + # Delete Options - How resources should be removed + # ============================================================================ + deleteOption: + # Propagation policy for resource deletion + # - "Foreground": Wait for dependent resources to be deleted first + # - "Background": Delete immediately, let cluster handle dependents + # - "Orphan": Leave resources on cluster when ManifestWork is deleted + propagationPolicy: "Foreground" + + # Grace period for graceful deletion (seconds) + gracePeriodSeconds: 30 + + # ============================================================================ + # Manifest Configurations - Per-resource settings for update and feedback + # ============================================================================ + manifestConfigs: + - resourceIdentifier: + group: "" # Core API group (empty for v1 resources) + resource: "namespaces" # Resource type + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" # Specific resource name + updateStrategy: + type: "ServerSideApply" # Use server-side apply for namespaces + feedbackRules: + - type: "JSONPaths" # Use JSON path expressions for status feedback + jsonPaths: + - name: "phase" + path: ".status.phase" + # ======================================================================== + # Configuration for Namespace resources + # ======================================================================== + - resourceIdentifier: + group: "" # Core API group (empty for v1 resources) + resource: "configmaps" # Resource type + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" # Specific resource name + namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + updateStrategy: + type: "ServerSideApply" # Use server-side apply for namespaces + serverSideApply: + fieldManager: "cl-maestro" # Field manager name for conflict resolution + force: false # Don't force conflicts (fail on conflicts) + feedbackRules: + - type: "JSONPaths" # Use JSON path expressions for status feedback + jsonPaths: + - name: "data" + path: ".data" + - name: "resourceVersion" + path: ".metadata.resourceVersion" + # Discover the ResourceBundle (ManifestWork) by name from Maestro + discovery: + by_name: "{{ .clusterId }}-{{ .adapter.name }}" + + # Discover nested resources deployed by the ManifestWork + nested_discoveries: + - name: "namespace0" + discovery: + by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + - name: "configmap0" + discovery: + by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" + + post: + payloads: + - name: "statusPayload" + build: + adapter: "{{ .adapter.name }}" + conditions: + # Applied: Check if ManifestWork exists and has type="Applied", status="True" + - type: "Applied" + status: + expression: | + has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].status : "False" + reason: + expression: | + has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].reason : "ManifestWorkNotDiscovered" + message: + expression: | + has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].message : "ManifestWork not discovered from Maestro or no Applied condition" + + # Available: Check if nested discovered manifests are available on the spoke cluster + # Each nested discovery is enriched with top-level "conditions" from status.resourceStatus.manifests[] + - type: "Available" + status: + expression: | + has(resources.namespace0) && has(resources.namespace0.conditions) + && resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") + && has(resources.configmap0) && has(resources.configmap0.conditions) + && resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") + ? "True" + : "False" + reason: + expression: | + !(has(resources.namespace0) && has(resources.namespace0.conditions)) + ? "NamespaceNotDiscovered" + : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") + ? "NamespaceNotAvailable" + : !(has(resources.configmap0) && has(resources.configmap0.conditions)) + ? "ConfigMapNotDiscovered" + : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") + ? "ConfigMapNotAvailable" + : "AllResourcesAvailable" + message: + expression: | + !(has(resources.namespace0) && has(resources.namespace0.conditions)) + ? "Namespace not discovered from ManifestWork" + : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") + ? "Namespace not yet available on spoke cluster" + : !(has(resources.configmap0) && has(resources.configmap0.conditions)) + ? "ConfigMap not discovered from ManifestWork" + : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") + ? "ConfigMap not yet available on spoke cluster" + : "All manifests (namespace, configmap) are available on spoke cluster" + + # Health: Adapter execution status — surfaces errors from any phase + - type: "Health" + status: + expression: | + adapter.?executionStatus.orValue("") == "success" + && !adapter.?resourcesSkipped.orValue(false) + ? "True" + : "False" + reason: + expression: | + adapter.?executionStatus.orValue("") != "success" + ? "ExecutionFailed:" + adapter.?executionError.?phase.orValue("unknown") + : adapter.?resourcesSkipped.orValue(false) + ? "ResourcesSkipped" + : "Healthy" + message: + expression: | + adapter.?executionStatus.orValue("") != "success" + ? "Adapter failed at phase [" + + adapter.?executionError.?phase.orValue("unknown") + + "] step [" + + adapter.?executionError.?step.orValue("unknown") + + "]: " + + adapter.?executionError.?message.orValue(adapter.?errorMessage.orValue("no details")) + : adapter.?resourcesSkipped.orValue(false) + ? "Resources skipped: " + adapter.?skipReason.orValue("unknown reason") + : "Adapter execution completed successfully" + + observed_generation: + expression: "generation" + observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" + + # Extract data from discovered ManifestWork from Maestro + data: + manifestwork: + name: + expression: | + has(resources.resource0) && has(resources.resource0.metadata) + ? resources.resource0.metadata.name + : "" + consumer: + expression: | + has(resources.resource0) && has(resources.resource0.metadata) + ? resources.resource0.metadata.namespace + : "cluster1" + configmap: + name: + expression: | + has(resources.configmap0) && has(resources.configmap0.metadata) + ? resources.configmap0.metadata.name + : "" + clusterId: + expression: | + has(resources.configmap0) && has(resources.configmap0.data) && has(resources.configmap0.data.cluster_id) + ? resources.configmap0.data.cluster_id + : clusterId + namespace: + name: + expression: | + has(resources.namespace0) && has(resources.namespace0.metadata) + ? resources.namespace0.metadata.name + : "" + phase: + expression: | + has(resources.namespace0) && has(resources.namespace0.statusFeedback) && has(resources.namespace0.statusFeedback.values) + && resources.namespace0.statusFeedback.values.exists(v, has(v.name) && v.name == "phase" && has(v.fieldValue)) + ? resources.namespace0.statusFeedback.values.filter(v, v.name == "phase")[0].fieldValue.string + : "Unknown" + + post_actions: + - name: "reportClusterStatus" + api_call: + method: "PUT" + url: "/clusters/{{ .clusterId }}/statuses" + headers: + - name: "Content-Type" + value: "application/json" + body: "{{ .statusPayload }}" diff --git a/testdata/adapter-configs/cl-m-bad-api/adapter-config.yaml b/testdata/adapter-configs/cl-m-bad-api/adapter-config.yaml deleted file mode 100644 index 24d94aa8..00000000 --- a/testdata/adapter-configs/cl-m-bad-api/adapter-config.yaml +++ /dev/null @@ -1,65 +0,0 @@ -# Example HyperFleet Adapter deployment configuration -# This configuration is for testing post-action failure when API is unreachable -# The API URL will be overridden at deployment time to test status reporting failure -adapter: - name: cl-m-bad-api - #version: "0.1.0" - -# Log the full merged configuration after load (default: false) -debug_config: true -log: - level: debug - -clients: - hyperfleet_api: - base_url: http://hyperfleet-api:8000 - version: v1 - timeout: 2s - retry_attempts: 3 - retry_backoff: exponential - - broker: - # These values are overridden at deploy time via env vars from Helm values - subscription_id: CHANGE_ME - topic: CHANGE_ME - - maestro: - grpc_server_address: "maestro-grpc.maestro.svc.cluster.local:8090" - - # HTTPS server address for REST API operations (optional) - # Environment variable: HYPERFLEET_MAESTRO_HTTP_SERVER_ADDRESS - http_server_address: "http://maestro.maestro.svc.cluster.local:8000" - - # Source identifier for CloudEvents routing (must be unique across adapters) - # Environment variable: HYPERFLEET_MAESTRO_SOURCE_ID - source_id: "cl-m-bad-api" - - # Client identifier (defaults to source_id if not specified) - # Environment variable: HYPERFLEET_MAESTRO_CLIENT_ID - client_id: "cl-m-bad-api-client" - insecure: true - - # Authentication configuration - #auth: - # type: "tls" # TLS certificate-based mTLS - # - # tls_config: - # # gRPC TLS configuration - # # Certificate paths (mounted from Kubernetes secrets) - # # Environment variable: HYPERFLEET_MAESTRO_CA_FILE - # ca_file: "/etc/maestro/certs/grpc/ca.crt" - # - # # Environment variable: HYPERFLEET_MAESTRO_CERT_FILE - # cert_file: "/etc/maestro/certs/grpc/client.crt" - # - # # Environment variable: HYPERFLEET_MAESTRO_KEY_FILE - # key_file: "/etc/maestro/certs/grpc/client.key" - # - # # Server name for TLS verification - # # Environment variable: HYPERFLEET_MAESTRO_SERVER_NAME - # server_name: "maestro-grpc.maestro.svc.cluster.local" - # - # # HTTP API TLS configuration (may use different CA than gRPC) - # # If not set, falls back to ca_file for backwards compatibility - # # Environment variable: HYPERFLEET_MAESTRO_HTTP_CA_FILE - # http_ca_file: "/etc/maestro/certs/https/ca.crt" diff --git a/testdata/adapter-configs/cl-m-bad-api/adapter-task-config.yaml b/testdata/adapter-configs/cl-m-bad-api/adapter-task-config.yaml deleted file mode 100644 index d6ebe896..00000000 --- a/testdata/adapter-configs/cl-m-bad-api/adapter-task-config.yaml +++ /dev/null @@ -1,314 +0,0 @@ -# Example HyperFleet Adapter task configuration - -# Parameters with all required variables -params: - - - name: "clusterId" - source: "event.id" - type: "string" - required: true - - name: "runId" - source: "env.RUN_ID" - type: "string" - required: true - - name: "generation" - source: "event.generation" - type: "int" - required: true - - name: "namespace" - source: "env.NAMESPACE" - type: "string" - - -# Preconditions without API calls (API URL is unreachable in this test) -# Use only parameter-based values to avoid API dependency -preconditions: - - name: "clusterReconciled" - # Simple expression that always passes - expression: "true" - -# Resources with valid K8s manifests -resources: - - name: "resource0" - transport: - client: "maestro" - maestro: - target_cluster: "cluster1" - - # ManifestWork is a kind of manifest that can be used to create resources on the cluster. - # It is a collection of resources that are created together. - manifest: - apiVersion: work.open-cluster-management.io/v1 - kind: ManifestWork - metadata: - # ManifestWork name - must be unique within consumer namespace - name: "{{ .clusterId }}-{{ .adapter.name }}" - - # Labels for identification, filtering, and management - labels: - # HyperFleet tracking labels - hyperfleet.io/cluster-id: "{{ .clusterId }}" - hyperfleet.io/adapter: "{{ .adapter.name }}" - hyperfleet.io/component: "infrastructure" - hyperfleet.io/generation: "{{ .generation }}" - hyperfleet.io/resource-group: "cluster-setup" - - # Maestro-specific labels - maestro.io/source-id: "{{ .adapter.name }}" - maestro.io/resource-type: "manifestwork" - maestro.io/priority: "normal" - - # Standard Kubernetes application labels - app.kubernetes.io/name: "aro-hcp-cluster" - app.kubernetes.io/instance: "{{ .clusterId }}" - app.kubernetes.io/version: "v1.0.0" - app.kubernetes.io/component: "infrastructure" - app.kubernetes.io/part-of: "hyperfleet" - app.kubernetes.io/managed-by: "cl-maestro" - app.kubernetes.io/created-by: "{{ .adapter.name }}" - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - # Tracking and lifecycle - hyperfleet.io/created-by: "cl-maestro-framework" - hyperfleet.io/managed-by: "{{ .adapter.name }}" - hyperfleet.io/generation: "{{ .generation }}" - hyperfleet.io/cluster-id: "{{ .clusterId }}" - hyperfleet.io/cluster-name: "{{ .clusterId }}" - hyperfleet.io/deployment-time: "2024-01-01T00:00:00Z" - - # Maestro-specific annotations - maestro.io/applied-time: "2024-01-01T00:00:00Z" - maestro.io/source-adapter: "{{ .adapter.name }}" - - # Documentation - description: "Complete cluster setup including namespace, configuration, and RBAC" - - # ManifestWork specification - spec: - # ============================================================================ - # Workload - Contains the Kubernetes manifests to deploy - # ============================================================================ - workload: - # Kubernetes manifests array - injected by framework from business logic config - manifests: - - apiVersion: v1 - kind: Namespace - metadata: - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - labels: - app.kubernetes.io/component: adapter-task-config - app.kubernetes.io/instance: "{{ .adapter.name }}" - app.kubernetes.io/name: cl-maestro - app.kubernetes.io/transport: maestro - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - hyperfleet.io/generation: "{{ .generation }}" - - apiVersion: v1 - kind: ConfigMap - data: - cluster_id: "{{ .clusterId }}" - cluster_name: "{{ .clusterId }}" - metadata: - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" - namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - labels: - app.kubernetes.io/component: adapter-task-config - app.kubernetes.io/instance: "{{ .adapter.name }}" - app.kubernetes.io/name: cl-maestro - app.kubernetes.io/version: 1.0.0 - app.kubernetes.io/transport: maestro - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - hyperfleet.io/generation: "{{ .generation }}" - - # ============================================================================ - # Delete Options - How resources should be removed - # ============================================================================ - deleteOption: - # Propagation policy for resource deletion - # - "Foreground": Wait for dependent resources to be deleted first - # - "Background": Delete immediately, let cluster handle dependents - # - "Orphan": Leave resources on cluster when ManifestWork is deleted - propagationPolicy: "Foreground" - - # Grace period for graceful deletion (seconds) - gracePeriodSeconds: 30 - - # ============================================================================ - # Manifest Configurations - Per-resource settings for update and feedback - # ============================================================================ - manifestConfigs: - - resourceIdentifier: - group: "" # Core API group (empty for v1 resources) - resource: "namespaces" # Resource type - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" # Specific resource name - updateStrategy: - type: "ServerSideApply" # Use server-side apply for namespaces - feedbackRules: - - type: "JSONPaths" # Use JSON path expressions for status feedback - jsonPaths: - - name: "phase" - path: ".status.phase" - # ======================================================================== - # Configuration for Namespace resources - # ======================================================================== - - resourceIdentifier: - group: "" # Core API group (empty for v1 resources) - resource: "configmaps" # Resource type - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" # Specific resource name - namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - updateStrategy: - type: "ServerSideApply" # Use server-side apply for namespaces - serverSideApply: - fieldManager: "cl-maestro" # Field manager name for conflict resolution - force: false # Don't force conflicts (fail on conflicts) - feedbackRules: - - type: "JSONPaths" # Use JSON path expressions for status feedback - jsonPaths: - - name: "data" - path: ".data" - - name: "resourceVersion" - path: ".metadata.resourceVersion" - # Discover the ResourceBundle (ManifestWork) by name from Maestro - discovery: - by_name: "{{ .clusterId }}-{{ .adapter.name }}" - - # Discover nested resources deployed by the ManifestWork - nested_discoveries: - - name: "namespace0" - discovery: - by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - - name: "configmap0" - discovery: - by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" - -post: - payloads: - - name: "statusPayload" - build: - adapter: "{{ .adapter.name }}" - conditions: - # Applied: Check if ManifestWork exists and has type="Applied", status="True" - - type: "Applied" - status: - expression: | - has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].status : "False" - reason: - expression: | - has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].reason : "ManifestWorkNotDiscovered" - message: - expression: | - has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].message : "ManifestWork not discovered from Maestro or no Applied condition" - - # Available: Check if nested discovered manifests are available on the spoke cluster - # Each nested discovery is enriched with top-level "conditions" from status.resourceStatus.manifests[] - - type: "Available" - status: - expression: | - has(resources.namespace0) && has(resources.namespace0.conditions) - && resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") - && has(resources.configmap0) && has(resources.configmap0.conditions) - && resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") - ? "True" - : "False" - reason: - expression: | - !(has(resources.namespace0) && has(resources.namespace0.conditions)) - ? "NamespaceNotDiscovered" - : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") - ? "NamespaceNotAvailable" - : !(has(resources.configmap0) && has(resources.configmap0.conditions)) - ? "ConfigMapNotDiscovered" - : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") - ? "ConfigMapNotAvailable" - : "AllResourcesAvailable" - message: - expression: | - !(has(resources.namespace0) && has(resources.namespace0.conditions)) - ? "Namespace not discovered from ManifestWork" - : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") - ? "Namespace not yet available on spoke cluster" - : !(has(resources.configmap0) && has(resources.configmap0.conditions)) - ? "ConfigMap not discovered from ManifestWork" - : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") - ? "ConfigMap not yet available on spoke cluster" - : "All manifests (namespace, configmap) are available on spoke cluster" - - # Health: Adapter execution status — surfaces errors from any phase - - type: "Health" - status: - expression: | - adapter.?executionStatus.orValue("") == "success" - && !adapter.?resourcesSkipped.orValue(false) - ? "True" - : "False" - reason: - expression: | - adapter.?executionStatus.orValue("") != "success" - ? "ExecutionFailed:" + adapter.?executionError.?phase.orValue("unknown") - : adapter.?resourcesSkipped.orValue(false) - ? "ResourcesSkipped" - : "Healthy" - message: - expression: | - adapter.?executionStatus.orValue("") != "success" - ? "Adapter failed at phase [" - + adapter.?executionError.?phase.orValue("unknown") - + "] step [" - + adapter.?executionError.?step.orValue("unknown") - + "]: " - + adapter.?executionError.?message.orValue(adapter.?errorMessage.orValue("no details")) - : adapter.?resourcesSkipped.orValue(false) - ? "Resources skipped: " + adapter.?skipReason.orValue("unknown reason") - : "Adapter execution completed successfully" - - observed_generation: - expression: "generation" - observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" - - # Extract data from discovered ManifestWork from Maestro - data: - manifestwork: - name: - expression: | - has(resources.resource0) && has(resources.resource0.metadata) - ? resources.resource0.metadata.name - : "" - consumer: - expression: | - has(resources.resource0) && has(resources.resource0.metadata) - ? resources.resource0.metadata.namespace - : "cluster1" - configmap: - name: - expression: | - has(resources.configmap0) && has(resources.configmap0.metadata) - ? resources.configmap0.metadata.name - : "" - clusterId: - expression: | - has(resources.configmap0) && has(resources.configmap0.data) && has(resources.configmap0.data.cluster_id) - ? resources.configmap0.data.cluster_id - : clusterId - namespace: - name: - expression: | - has(resources.namespace0) && has(resources.namespace0.metadata) - ? resources.namespace0.metadata.name - : "" - phase: - expression: | - has(resources.namespace0) && has(resources.namespace0.statusFeedback) && has(resources.namespace0.statusFeedback.values) - && resources.namespace0.statusFeedback.values.exists(v, has(v.name) && v.name == "phase" && has(v.fieldValue)) - ? resources.namespace0.statusFeedback.values.filter(v, v.name == "phase")[0].fieldValue.string - : "Unknown" - - post_actions: - - name: "reportClusterStatus" - api_call: - method: "PUT" - url: "/clusters/{{ .clusterId }}/statuses" - headers: - - name: "Content-Type" - value: "application/json" - body: "{{ .statusPayload }}" diff --git a/testdata/adapter-configs/cl-m-bad-api/values.yaml b/testdata/adapter-configs/cl-m-bad-api/values.yaml deleted file mode 100644 index d8c494df..00000000 --- a/testdata/adapter-configs/cl-m-bad-api/values.yaml +++ /dev/null @@ -1,47 +0,0 @@ -adapterConfig: - create: true - files: - adapter-config.yaml: cl-m-bad-api/adapter-config.yaml - log: - level: debug - -adapterTaskConfig: - create: true - files: - task-config.yaml: cl-m-bad-api/adapter-task-config.yaml - -broker: - type: ${BROKER_TYPE} - create: true - googlepubsub: - projectId: ${GCP_PROJECT_ID} - subscriptionId: ${NAMESPACE}-clusters-${ADAPTER_NAME} - topic: ${NAMESPACE}-clusters - deadLetterTopic: ${NAMESPACE}-clusters-dlq - createTopicIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_TOPIC_IF_MISSING} - createSubscriptionIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_SUBSCRIPTION_IF_MISSING} - expirationTTL: "1d" - rabbitmq: - url: ${RABBITMQ_URL} - queue: ${NAMESPACE}-clusters-${ADAPTER_NAME} - exchange: ${NAMESPACE}-clusters - routingKey: ${ADAPTER_NAME} - -image: - registry: ${IMAGE_REGISTRY} - repository: ${ADAPTER_IMAGE_REPO} - pullPolicy: Always - tag: ${ADAPTER_IMAGE_TAG} - -rbac: - resources: - - namespaces - - configmaps - - configmaps/status - -labels: - e2e.hyperfleet.io/run-id: ${RUN_ID} - -env: - - name: RUN_ID - value: ${RUN_ID} diff --git a/testdata/adapter-configs/cl-m-unreg-consumer.yaml b/testdata/adapter-configs/cl-m-unreg-consumer.yaml new file mode 100644 index 00000000..7db789cb --- /dev/null +++ b/testdata/adapter-configs/cl-m-unreg-consumer.yaml @@ -0,0 +1,407 @@ +rbac: + resources: + - namespaces + - configmaps + - configmaps/status + +adapterConfig: + yaml: + adapter: + name: cl-m-unreg-consumer + #version: "0.1.0" + + # Log the full merged configuration after load (default: false) + debug_config: true + log: + level: debug + + clients: + hyperfleet_api: + base_url: http://hyperfleet-api:8000 + version: v1 + timeout: 2s + retry_attempts: 3 + retry_backoff: exponential + + broker: + # These values are overridden at deploy time via env vars from Helm values + subscription_id: CHANGE_ME + topic: CHANGE_ME + + maestro: + grpc_server_address: "maestro-grpc.maestro.svc.cluster.local:8090" + + # HTTPS server address for REST API operations (optional) + # Environment variable: HYPERFLEET_MAESTRO_HTTP_SERVER_ADDRESS + http_server_address: "http://maestro.maestro.svc.cluster.local:8000" + + # Source identifier for CloudEvents routing (must be unique across adapters) + # Environment variable: HYPERFLEET_MAESTRO_SOURCE_ID + source_id: "cl-m-unreg-consumer" + + # Client identifier (defaults to source_id if not specified) + # Environment variable: HYPERFLEET_MAESTRO_CLIENT_ID + client_id: "cl-m-unreg-consumer-client" + insecure: true + + # Authentication configuration + #auth: + # type: "tls" # TLS certificate-based mTLS + # + # tls_config: + # # gRPC TLS configuration + # # Certificate paths (mounted from Kubernetes secrets) + # # Environment variable: HYPERFLEET_MAESTRO_CA_FILE + # ca_file: "/etc/maestro/certs/grpc/ca.crt" + # + # # Environment variable: HYPERFLEET_MAESTRO_CERT_FILE + # cert_file: "/etc/maestro/certs/grpc/client.crt" + # + # # Environment variable: HYPERFLEET_MAESTRO_KEY_FILE + # key_file: "/etc/maestro/certs/grpc/client.key" + # + # # Server name for TLS verification + # # Environment variable: HYPERFLEET_MAESTRO_SERVER_NAME + # server_name: "maestro-grpc.maestro.svc.cluster.local" + # + # # HTTP API TLS configuration (may use different CA than gRPC) + # # If not set, falls back to ca_file for backwards compatibility + # # Environment variable: HYPERFLEET_MAESTRO_HTTP_CA_FILE + # http_ca_file: "/etc/maestro/certs/https/ca.crt" + +adapterTaskConfig: + yaml: + params: + - name: "clusterId" + source: "event.id" + type: "string" + required: true + - name: "runId" + source: "env.RUN_ID" + type: "string" + required: true + - name: "generation" + source: "event.generation" + type: "int" + required: true + - name: "namespace" + source: "env.NAMESPACE" + type: "string" + + - name: "clusterStatus" + source: + api_call: + method: "GET" + url: "/clusters/{{ .clusterId }}" + timeout: 10s + retry_attempts: 3 + retry_backoff: "exponential" + + - name: "clusterName" + source: "clusterStatus.name" + + - name: "timestamp" + source: "clusterStatus.created_time" + + - name: "reconciledConditionStatus" + source: + expression: | + clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 + ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].status + : "False" + + - name: "placementClusterName" + source: + expression: '"unregistered-consumer"' # Points to non-existent consumer to test apply failure + + # Preconditions with valid operators and CEL expressions + preconditions: + - name: "validationCheck" + expression: | + reconciledConditionStatus == "False" + + # Resources with valid K8s manifests + resources: + - name: "resource0" + transport: + client: "maestro" + maestro: + target_cluster: "{{ .placementClusterName }}" + + # ManifestWork is a kind of manifest that can be used to create resources on the cluster. + # It is a collection of resources that are created together. + manifest: + apiVersion: work.open-cluster-management.io/v1 + kind: ManifestWork + metadata: + # ManifestWork name - must be unique within consumer namespace + name: "{{ .clusterId }}-{{ .adapter.name }}" + + # Labels for identification, filtering, and management + labels: + # HyperFleet tracking labels + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/adapter: "{{ .adapter.name }}" + hyperfleet.io/component: "infrastructure" + hyperfleet.io/generation: "{{ .generation }}" + hyperfleet.io/resource-group: "cluster-setup" + + # Maestro-specific labels + maestro.io/source-id: "{{ .adapter.name }}" + maestro.io/resource-type: "manifestwork" + maestro.io/priority: "normal" + + # Standard Kubernetes application labels + app.kubernetes.io/name: "aro-hcp-cluster" + app.kubernetes.io/instance: "{{ .clusterId }}" + app.kubernetes.io/version: "v1.0.0" + app.kubernetes.io/component: "infrastructure" + app.kubernetes.io/part-of: "hyperfleet" + app.kubernetes.io/managed-by: "cl-maestro" + app.kubernetes.io/created-by: "{{ .adapter.name }}" + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + # Tracking and lifecycle + hyperfleet.io/created-by: "cl-maestro-framework" + hyperfleet.io/managed-by: "{{ .adapter.name }}" + hyperfleet.io/generation: "{{ .generation }}" + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/cluster-name: "{{ .clusterName }}" + hyperfleet.io/deployment-time: "{{ .timestamp }}" + + # Maestro-specific annotations + maestro.io/applied-time: "{{ .timestamp }}" + maestro.io/source-adapter: "{{ .adapter.name }}" + + # Documentation + description: "Complete cluster setup including namespace, configuration, and RBAC" + + # ManifestWork specification + spec: + # ============================================================================ + # Workload - Contains the Kubernetes manifests to deploy + # ============================================================================ + workload: + # Kubernetes manifests array - injected by framework from business logic config + manifests: + - apiVersion: v1 + kind: Namespace + metadata: + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + labels: + app.kubernetes.io/component: adapter-task-config + app.kubernetes.io/instance: "{{ .adapter.name }}" + app.kubernetes.io/name: cl-maestro + app.kubernetes.io/transport: maestro + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + hyperfleet.io/generation: "{{ .generation }}" + - apiVersion: v1 + kind: ConfigMap + data: + cluster_id: "{{ .clusterId }}" + cluster_name: "{{ .clusterName }}" + metadata: + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" + namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + labels: + app.kubernetes.io/component: adapter-task-config + app.kubernetes.io/instance: "{{ .adapter.name }}" + app.kubernetes.io/name: cl-maestro + app.kubernetes.io/version: 1.0.0 + app.kubernetes.io/transport: maestro + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + hyperfleet.io/generation: "{{ .generation }}" + + # ============================================================================ + # Delete Options - How resources should be removed + # ============================================================================ + deleteOption: + # Propagation policy for resource deletion + # - "Foreground": Wait for dependent resources to be deleted first + # - "Background": Delete immediately, let cluster handle dependents + # - "Orphan": Leave resources on cluster when ManifestWork is deleted + propagationPolicy: "Foreground" + + # Grace period for graceful deletion (seconds) + gracePeriodSeconds: 30 + + # ============================================================================ + # Manifest Configurations - Per-resource settings for update and feedback + # ============================================================================ + manifestConfigs: + - resourceIdentifier: + group: "" # Core API group (empty for v1 resources) + resource: "namespaces" # Resource type + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" # Specific resource name + updateStrategy: + type: "ServerSideApply" # Use server-side apply for namespaces + feedbackRules: + - type: "JSONPaths" # Use JSON path expressions for status feedback + jsonPaths: + - name: "phase" + path: ".status.phase" + # ======================================================================== + # Configuration for Namespace resources + # ======================================================================== + - resourceIdentifier: + group: "" # Core API group (empty for v1 resources) + resource: "configmaps" # Resource type + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" # Specific resource name + namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + updateStrategy: + type: "ServerSideApply" # Use server-side apply for namespaces + serverSideApply: + fieldManager: "cl-maestro" # Field manager name for conflict resolution + force: false # Don't force conflicts (fail on conflicts) + feedbackRules: + - type: "JSONPaths" # Use JSON path expressions for status feedback + jsonPaths: + - name: "data" + path: ".data" + - name: "resourceVersion" + path: ".metadata.resourceVersion" + # Discover the ResourceBundle (ManifestWork) by name from Maestro + discovery: + by_name: "{{ .clusterId }}-{{ .adapter.name }}" + + # Discover nested resources deployed by the ManifestWork + nested_discoveries: + - name: "namespace0" + discovery: + by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + - name: "configmap0" + discovery: + by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" + + post: + payloads: + - name: "statusPayload" + build: + adapter: "{{ .adapter.name }}" + conditions: + # Applied: Check if ManifestWork exists and has type="Applied", status="True" + - type: "Applied" + status: + expression: | + has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].status : "False" + reason: + expression: | + has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].reason : "ManifestWorkNotDiscovered" + message: + expression: | + has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].message : "ManifestWork not discovered from Maestro or no Applied condition" + + # Available: Check if nested discovered manifests are available on the spoke cluster + # Each nested discovery is enriched with top-level "conditions" from status.resourceStatus.manifests[] + - type: "Available" + status: + expression: | + has(resources.namespace0) && has(resources.namespace0.conditions) + && resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") + && has(resources.configmap0) && has(resources.configmap0.conditions) + && resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") + ? "True" + : "False" + reason: + expression: | + !(has(resources.namespace0) && has(resources.namespace0.conditions)) + ? "NamespaceNotDiscovered" + : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") + ? "NamespaceNotAvailable" + : !(has(resources.configmap0) && has(resources.configmap0.conditions)) + ? "ConfigMapNotDiscovered" + : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") + ? "ConfigMapNotAvailable" + : "AllResourcesAvailable" + message: + expression: | + !(has(resources.namespace0) && has(resources.namespace0.conditions)) + ? "Namespace not discovered from ManifestWork" + : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") + ? "Namespace not yet available on spoke cluster" + : !(has(resources.configmap0) && has(resources.configmap0.conditions)) + ? "ConfigMap not discovered from ManifestWork" + : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") + ? "ConfigMap not yet available on spoke cluster" + : "All manifests (namespace, configmap) are available on spoke cluster" + + # Health: Adapter execution status — surfaces errors from any phase + - type: "Health" + status: + expression: | + adapter.?executionStatus.orValue("") == "success" + && !adapter.?resourcesSkipped.orValue(false) + ? "True" + : "False" + reason: + expression: | + adapter.?executionStatus.orValue("") != "success" + ? "ExecutionFailed:" + adapter.?executionError.?phase.orValue("unknown") + : adapter.?resourcesSkipped.orValue(false) + ? "ResourcesSkipped" + : "Healthy" + message: + expression: | + adapter.?executionStatus.orValue("") != "success" + ? "Adapter failed at phase [" + + adapter.?executionError.?phase.orValue("unknown") + + "] step [" + + adapter.?executionError.?step.orValue("unknown") + + "]: " + + adapter.?executionError.?message.orValue(adapter.?errorMessage.orValue("no details")) + : adapter.?resourcesSkipped.orValue(false) + ? "Resources skipped: " + adapter.?skipReason.orValue("unknown reason") + : "Adapter execution completed successfully" + + observed_generation: + expression: "generation" + observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" + + # Extract data from discovered ManifestWork from Maestro + data: + manifestwork: + name: + expression: | + has(resources.resource0) && has(resources.resource0.metadata) + ? resources.resource0.metadata.name + : "" + consumer: + expression: | + has(resources.resource0) && has(resources.resource0.metadata) + ? resources.resource0.metadata.namespace + : placementClusterName + configmap: + name: + expression: | + has(resources.configmap0) && has(resources.configmap0.metadata) + ? resources.configmap0.metadata.name + : "" + clusterId: + expression: | + has(resources.configmap0) && has(resources.configmap0.data) && has(resources.configmap0.data.cluster_id) + ? resources.configmap0.data.cluster_id + : clusterId + namespace: + name: + expression: | + has(resources.namespace0) && has(resources.namespace0.metadata) + ? resources.namespace0.metadata.name + : "" + phase: + expression: | + has(resources.namespace0) && has(resources.namespace0.statusFeedback) && has(resources.namespace0.statusFeedback.values) + && resources.namespace0.statusFeedback.values.exists(v, has(v.name) && v.name == "phase" && has(v.fieldValue)) + ? resources.namespace0.statusFeedback.values.filter(v, v.name == "phase")[0].fieldValue.string + : "Unknown" + + post_actions: + - name: "reportClusterStatus" + api_call: + method: "PUT" + url: "/clusters/{{ .clusterId }}/statuses" + headers: + - name: "Content-Type" + value: "application/json" + body: "{{ .statusPayload }}" diff --git a/testdata/adapter-configs/cl-m-unreg-consumer/adapter-config.yaml b/testdata/adapter-configs/cl-m-unreg-consumer/adapter-config.yaml deleted file mode 100644 index 427c3531..00000000 --- a/testdata/adapter-configs/cl-m-unreg-consumer/adapter-config.yaml +++ /dev/null @@ -1,65 +0,0 @@ -# Example HyperFleet Adapter deployment configuration -# This configuration is for testing Maestro transport with an UNREGISTERED consumer -# to validate error handling when ManifestWork apply fails -adapter: - name: cl-m-unreg-consumer - #version: "0.1.0" - -# Log the full merged configuration after load (default: false) -debug_config: true -log: - level: debug - -clients: - hyperfleet_api: - base_url: http://hyperfleet-api:8000 - version: v1 - timeout: 2s - retry_attempts: 3 - retry_backoff: exponential - - broker: - # These values are overridden at deploy time via env vars from Helm values - subscription_id: CHANGE_ME - topic: CHANGE_ME - - maestro: - grpc_server_address: "maestro-grpc.maestro.svc.cluster.local:8090" - - # HTTPS server address for REST API operations (optional) - # Environment variable: HYPERFLEET_MAESTRO_HTTP_SERVER_ADDRESS - http_server_address: "http://maestro.maestro.svc.cluster.local:8000" - - # Source identifier for CloudEvents routing (must be unique across adapters) - # Environment variable: HYPERFLEET_MAESTRO_SOURCE_ID - source_id: "cl-m-unreg-consumer" - - # Client identifier (defaults to source_id if not specified) - # Environment variable: HYPERFLEET_MAESTRO_CLIENT_ID - client_id: "cl-m-unreg-consumer-client" - insecure: true - - # Authentication configuration - #auth: - # type: "tls" # TLS certificate-based mTLS - # - # tls_config: - # # gRPC TLS configuration - # # Certificate paths (mounted from Kubernetes secrets) - # # Environment variable: HYPERFLEET_MAESTRO_CA_FILE - # ca_file: "/etc/maestro/certs/grpc/ca.crt" - # - # # Environment variable: HYPERFLEET_MAESTRO_CERT_FILE - # cert_file: "/etc/maestro/certs/grpc/client.crt" - # - # # Environment variable: HYPERFLEET_MAESTRO_KEY_FILE - # key_file: "/etc/maestro/certs/grpc/client.key" - # - # # Server name for TLS verification - # # Environment variable: HYPERFLEET_MAESTRO_SERVER_NAME - # server_name: "maestro-grpc.maestro.svc.cluster.local" - # - # # HTTP API TLS configuration (may use different CA than gRPC) - # # If not set, falls back to ca_file for backwards compatibility - # # Environment variable: HYPERFLEET_MAESTRO_HTTP_CA_FILE - # http_ca_file: "/etc/maestro/certs/https/ca.crt" diff --git a/testdata/adapter-configs/cl-m-unreg-consumer/adapter-task-config.yaml b/testdata/adapter-configs/cl-m-unreg-consumer/adapter-task-config.yaml deleted file mode 100644 index f810b82c..00000000 --- a/testdata/adapter-configs/cl-m-unreg-consumer/adapter-task-config.yaml +++ /dev/null @@ -1,338 +0,0 @@ -# Example HyperFleet Adapter task configuration - -# Parameters with all required variables -params: - - - name: "clusterId" - source: "event.id" - type: "string" - required: true - - name: "runId" - source: "env.RUN_ID" - type: "string" - required: true - - name: "generation" - source: "event.generation" - type: "int" - required: true - - name: "namespace" - source: "env.NAMESPACE" - type: "string" - - - name: "clusterStatus" - source: - api_call: - method: "GET" - url: "/clusters/{{ .clusterId }}" - timeout: 10s - retry_attempts: 3 - retry_backoff: "exponential" - - - name: "clusterName" - source: "clusterStatus.name" - - - name: "timestamp" - source: "clusterStatus.created_time" - - - name: "reconciledConditionStatus" - source: - expression: | - clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 - ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].status - : "False" - - - name: "placementClusterName" - source: - expression: '"unregistered-consumer"' # Points to non-existent consumer to test apply failure - -# Preconditions with valid operators and CEL expressions -preconditions: - - name: "validationCheck" - expression: | - reconciledConditionStatus == "False" - -# Resources with valid K8s manifests -resources: - - name: "resource0" - transport: - client: "maestro" - maestro: - target_cluster: "{{ .placementClusterName }}" - - # ManifestWork is a kind of manifest that can be used to create resources on the cluster. - # It is a collection of resources that are created together. - manifest: - apiVersion: work.open-cluster-management.io/v1 - kind: ManifestWork - metadata: - # ManifestWork name - must be unique within consumer namespace - name: "{{ .clusterId }}-{{ .adapter.name }}" - - # Labels for identification, filtering, and management - labels: - # HyperFleet tracking labels - hyperfleet.io/cluster-id: "{{ .clusterId }}" - hyperfleet.io/adapter: "{{ .adapter.name }}" - hyperfleet.io/component: "infrastructure" - hyperfleet.io/generation: "{{ .generation }}" - hyperfleet.io/resource-group: "cluster-setup" - - # Maestro-specific labels - maestro.io/source-id: "{{ .adapter.name }}" - maestro.io/resource-type: "manifestwork" - maestro.io/priority: "normal" - - # Standard Kubernetes application labels - app.kubernetes.io/name: "aro-hcp-cluster" - app.kubernetes.io/instance: "{{ .clusterId }}" - app.kubernetes.io/version: "v1.0.0" - app.kubernetes.io/component: "infrastructure" - app.kubernetes.io/part-of: "hyperfleet" - app.kubernetes.io/managed-by: "cl-maestro" - app.kubernetes.io/created-by: "{{ .adapter.name }}" - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - # Tracking and lifecycle - hyperfleet.io/created-by: "cl-maestro-framework" - hyperfleet.io/managed-by: "{{ .adapter.name }}" - hyperfleet.io/generation: "{{ .generation }}" - hyperfleet.io/cluster-id: "{{ .clusterId }}" - hyperfleet.io/cluster-name: "{{ .clusterName }}" - hyperfleet.io/deployment-time: "{{ .timestamp }}" - - # Maestro-specific annotations - maestro.io/applied-time: "{{ .timestamp }}" - maestro.io/source-adapter: "{{ .adapter.name }}" - - # Documentation - description: "Complete cluster setup including namespace, configuration, and RBAC" - - # ManifestWork specification - spec: - # ============================================================================ - # Workload - Contains the Kubernetes manifests to deploy - # ============================================================================ - workload: - # Kubernetes manifests array - injected by framework from business logic config - manifests: - - apiVersion: v1 - kind: Namespace - metadata: - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - labels: - app.kubernetes.io/component: adapter-task-config - app.kubernetes.io/instance: "{{ .adapter.name }}" - app.kubernetes.io/name: cl-maestro - app.kubernetes.io/transport: maestro - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - hyperfleet.io/generation: "{{ .generation }}" - - apiVersion: v1 - kind: ConfigMap - data: - cluster_id: "{{ .clusterId }}" - cluster_name: "{{ .clusterName }}" - metadata: - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" - namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - labels: - app.kubernetes.io/component: adapter-task-config - app.kubernetes.io/instance: "{{ .adapter.name }}" - app.kubernetes.io/name: cl-maestro - app.kubernetes.io/version: 1.0.0 - app.kubernetes.io/transport: maestro - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - hyperfleet.io/generation: "{{ .generation }}" - - # ============================================================================ - # Delete Options - How resources should be removed - # ============================================================================ - deleteOption: - # Propagation policy for resource deletion - # - "Foreground": Wait for dependent resources to be deleted first - # - "Background": Delete immediately, let cluster handle dependents - # - "Orphan": Leave resources on cluster when ManifestWork is deleted - propagationPolicy: "Foreground" - - # Grace period for graceful deletion (seconds) - gracePeriodSeconds: 30 - - # ============================================================================ - # Manifest Configurations - Per-resource settings for update and feedback - # ============================================================================ - manifestConfigs: - - resourceIdentifier: - group: "" # Core API group (empty for v1 resources) - resource: "namespaces" # Resource type - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" # Specific resource name - updateStrategy: - type: "ServerSideApply" # Use server-side apply for namespaces - feedbackRules: - - type: "JSONPaths" # Use JSON path expressions for status feedback - jsonPaths: - - name: "phase" - path: ".status.phase" - # ======================================================================== - # Configuration for Namespace resources - # ======================================================================== - - resourceIdentifier: - group: "" # Core API group (empty for v1 resources) - resource: "configmaps" # Resource type - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" # Specific resource name - namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - updateStrategy: - type: "ServerSideApply" # Use server-side apply for namespaces - serverSideApply: - fieldManager: "cl-maestro" # Field manager name for conflict resolution - force: false # Don't force conflicts (fail on conflicts) - feedbackRules: - - type: "JSONPaths" # Use JSON path expressions for status feedback - jsonPaths: - - name: "data" - path: ".data" - - name: "resourceVersion" - path: ".metadata.resourceVersion" - # Discover the ResourceBundle (ManifestWork) by name from Maestro - discovery: - by_name: "{{ .clusterId }}-{{ .adapter.name }}" - - # Discover nested resources deployed by the ManifestWork - nested_discoveries: - - name: "namespace0" - discovery: - by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - - name: "configmap0" - discovery: - by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" - -post: - payloads: - - name: "statusPayload" - build: - adapter: "{{ .adapter.name }}" - conditions: - # Applied: Check if ManifestWork exists and has type="Applied", status="True" - - type: "Applied" - status: - expression: | - has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].status : "False" - reason: - expression: | - has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].reason : "ManifestWorkNotDiscovered" - message: - expression: | - has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].message : "ManifestWork not discovered from Maestro or no Applied condition" - - # Available: Check if nested discovered manifests are available on the spoke cluster - # Each nested discovery is enriched with top-level "conditions" from status.resourceStatus.manifests[] - - type: "Available" - status: - expression: | - has(resources.namespace0) && has(resources.namespace0.conditions) - && resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") - && has(resources.configmap0) && has(resources.configmap0.conditions) - && resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") - ? "True" - : "False" - reason: - expression: | - !(has(resources.namespace0) && has(resources.namespace0.conditions)) - ? "NamespaceNotDiscovered" - : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") - ? "NamespaceNotAvailable" - : !(has(resources.configmap0) && has(resources.configmap0.conditions)) - ? "ConfigMapNotDiscovered" - : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") - ? "ConfigMapNotAvailable" - : "AllResourcesAvailable" - message: - expression: | - !(has(resources.namespace0) && has(resources.namespace0.conditions)) - ? "Namespace not discovered from ManifestWork" - : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") - ? "Namespace not yet available on spoke cluster" - : !(has(resources.configmap0) && has(resources.configmap0.conditions)) - ? "ConfigMap not discovered from ManifestWork" - : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") - ? "ConfigMap not yet available on spoke cluster" - : "All manifests (namespace, configmap) are available on spoke cluster" - - # Health: Adapter execution status — surfaces errors from any phase - - type: "Health" - status: - expression: | - adapter.?executionStatus.orValue("") == "success" - && !adapter.?resourcesSkipped.orValue(false) - ? "True" - : "False" - reason: - expression: | - adapter.?executionStatus.orValue("") != "success" - ? "ExecutionFailed:" + adapter.?executionError.?phase.orValue("unknown") - : adapter.?resourcesSkipped.orValue(false) - ? "ResourcesSkipped" - : "Healthy" - message: - expression: | - adapter.?executionStatus.orValue("") != "success" - ? "Adapter failed at phase [" - + adapter.?executionError.?phase.orValue("unknown") - + "] step [" - + adapter.?executionError.?step.orValue("unknown") - + "]: " - + adapter.?executionError.?message.orValue(adapter.?errorMessage.orValue("no details")) - : adapter.?resourcesSkipped.orValue(false) - ? "Resources skipped: " + adapter.?skipReason.orValue("unknown reason") - : "Adapter execution completed successfully" - - observed_generation: - expression: "generation" - observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" - - # Extract data from discovered ManifestWork from Maestro - data: - manifestwork: - name: - expression: | - has(resources.resource0) && has(resources.resource0.metadata) - ? resources.resource0.metadata.name - : "" - consumer: - expression: | - has(resources.resource0) && has(resources.resource0.metadata) - ? resources.resource0.metadata.namespace - : placementClusterName - configmap: - name: - expression: | - has(resources.configmap0) && has(resources.configmap0.metadata) - ? resources.configmap0.metadata.name - : "" - clusterId: - expression: | - has(resources.configmap0) && has(resources.configmap0.data) && has(resources.configmap0.data.cluster_id) - ? resources.configmap0.data.cluster_id - : clusterId - namespace: - name: - expression: | - has(resources.namespace0) && has(resources.namespace0.metadata) - ? resources.namespace0.metadata.name - : "" - phase: - expression: | - has(resources.namespace0) && has(resources.namespace0.statusFeedback) && has(resources.namespace0.statusFeedback.values) - && resources.namespace0.statusFeedback.values.exists(v, has(v.name) && v.name == "phase" && has(v.fieldValue)) - ? resources.namespace0.statusFeedback.values.filter(v, v.name == "phase")[0].fieldValue.string - : "Unknown" - - post_actions: - - name: "reportClusterStatus" - api_call: - method: "PUT" - url: "/clusters/{{ .clusterId }}/statuses" - headers: - - name: "Content-Type" - value: "application/json" - body: "{{ .statusPayload }}" diff --git a/testdata/adapter-configs/cl-m-unreg-consumer/values.yaml b/testdata/adapter-configs/cl-m-unreg-consumer/values.yaml deleted file mode 100644 index 9639adb7..00000000 --- a/testdata/adapter-configs/cl-m-unreg-consumer/values.yaml +++ /dev/null @@ -1,47 +0,0 @@ -adapterConfig: - create: true - files: - adapter-config.yaml: cl-m-unreg-consumer/adapter-config.yaml - log: - level: debug - -adapterTaskConfig: - create: true - files: - task-config.yaml: cl-m-unreg-consumer/adapter-task-config.yaml - -broker: - type: ${BROKER_TYPE} - create: true - googlepubsub: - projectId: ${GCP_PROJECT_ID} - subscriptionId: ${NAMESPACE}-clusters-${ADAPTER_NAME} - topic: ${NAMESPACE}-clusters - deadLetterTopic: ${NAMESPACE}-clusters-dlq - createTopicIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_TOPIC_IF_MISSING} - createSubscriptionIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_SUBSCRIPTION_IF_MISSING} - expirationTTL: "1d" - rabbitmq: - url: ${RABBITMQ_URL} - queue: ${NAMESPACE}-clusters-${ADAPTER_NAME} - exchange: ${NAMESPACE}-clusters - routingKey: ${ADAPTER_NAME} - -image: - registry: ${IMAGE_REGISTRY} - repository: ${ADAPTER_IMAGE_REPO} - pullPolicy: Always - tag: ${ADAPTER_IMAGE_TAG} - -rbac: - resources: - - namespaces - - configmaps - - configmaps/status - -labels: - e2e.hyperfleet.io/run-id: ${RUN_ID} - -env: - - name: RUN_ID - value: ${RUN_ID} diff --git a/testdata/adapter-configs/cl-m-wrong-ds.yaml b/testdata/adapter-configs/cl-m-wrong-ds.yaml new file mode 100644 index 00000000..19e2ffec --- /dev/null +++ b/testdata/adapter-configs/cl-m-wrong-ds.yaml @@ -0,0 +1,406 @@ +rbac: + resources: + - namespaces + - configmaps + - configmaps/status + +adapterConfig: + yaml: + adapter: + name: cl-m-wrong-ds + debug_config: true + log: + level: debug + + clients: + hyperfleet_api: + base_url: http://hyperfleet-api:8000 + version: v1 + timeout: 2s + retry_attempts: 3 + retry_backoff: exponential + + broker: + # These values are overridden at deploy time via env vars from Helm values + subscription_id: CHANGE_ME + topic: CHANGE_ME + + maestro: + grpc_server_address: "maestro-grpc.maestro.svc.cluster.local:8090" + + # HTTPS server address for REST API operations (optional) + # Environment variable: HYPERFLEET_MAESTRO_HTTP_SERVER_ADDRESS + http_server_address: "http://maestro.maestro.svc.cluster.local:8000" + + # Source identifier for CloudEvents routing (must be unique across adapters) + # Environment variable: HYPERFLEET_MAESTRO_SOURCE_ID + source_id: "cl-m-wrong-ds" + + # Client identifier (defaults to source_id if not specified) + # Environment variable: HYPERFLEET_MAESTRO_CLIENT_ID + client_id: "cl-m-wrong-ds-client" + insecure: true + + # Authentication configuration + #auth: + # type: "tls" # TLS certificate-based mTLS + # + # tls_config: + # # gRPC TLS configuration + # # Certificate paths (mounted from Kubernetes secrets) + # # Environment variable: HYPERFLEET_MAESTRO_CA_FILE + # ca_file: "/etc/maestro/certs/grpc/ca.crt" + # + # # Environment variable: HYPERFLEET_MAESTRO_CERT_FILE + # cert_file: "/etc/maestro/certs/grpc/client.crt" + # + # # Environment variable: HYPERFLEET_MAESTRO_KEY_FILE + # key_file: "/etc/maestro/certs/grpc/client.key" + # + # # Server name for TLS verification + # # Environment variable: HYPERFLEET_MAESTRO_SERVER_NAME + # server_name: "maestro-grpc.maestro.svc.cluster.local" + # + # # HTTP API TLS configuration (may use different CA than gRPC) + # # If not set, falls back to ca_file for backwards compatibility + # # Environment variable: HYPERFLEET_MAESTRO_HTTP_CA_FILE + # http_ca_file: "/etc/maestro/certs/https/ca.crt" + +adapterTaskConfig: + yaml: + params: + - name: "clusterId" + source: "event.id" + type: "string" + required: true + - name: "runId" + source: "env.RUN_ID" + type: "string" + required: true + - name: "generation" + source: "event.generation" + type: "int" + required: true + - name: "namespace" + source: "env.NAMESPACE" + type: "string" + + - name: "clusterStatus" + source: + api_call: + method: "GET" + url: "/clusters/{{ .clusterId }}" + timeout: 10s + retry_attempts: 3 + retry_backoff: "exponential" + + - name: "clusterName" + source: "clusterStatus.name" + + - name: "timestamp" + source: "clusterStatus.created_time" + + - name: "reconciledConditionStatus" + source: + expression: | + clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 + ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].status + : "False" + + - name: "placementClusterName" + source: + expression: '"cluster1"' # TBC coming from placement adapter + + # Preconditions with valid operators and CEL expressions + preconditions: + - name: "validationCheck" + expression: | + reconciledConditionStatus == "False" + + # Resources with valid K8s manifests + resources: + - name: "resource0" + transport: + client: "maestro" + maestro: + target_cluster: "{{ .placementClusterName }}" + + # ManifestWork is a kind of manifest that can be used to create resources on the cluster. + # It is a collection of resources that are created together. + manifest: + apiVersion: work.open-cluster-management.io/v1 + kind: ManifestWork + metadata: + # ManifestWork name - must be unique within consumer namespace + name: "{{ .clusterId }}-{{ .adapter.name }}" + + # Labels for identification, filtering, and management + labels: + # HyperFleet tracking labels + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/adapter: "{{ .adapter.name }}" + hyperfleet.io/component: "infrastructure" + hyperfleet.io/generation: "{{ .generation }}" + hyperfleet.io/resource-group: "cluster-setup" + + # Maestro-specific labels + maestro.io/source-id: "{{ .adapter.name }}" + maestro.io/resource-type: "manifestwork" + maestro.io/priority: "normal" + + # Standard Kubernetes application labels + app.kubernetes.io/name: "aro-hcp-cluster" + app.kubernetes.io/instance: "{{ .clusterId }}" + app.kubernetes.io/version: "v1.0.0" + app.kubernetes.io/component: "infrastructure" + app.kubernetes.io/part-of: "hyperfleet" + app.kubernetes.io/managed-by: "cl-maestro" + app.kubernetes.io/created-by: "{{ .adapter.name }}" + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + # Tracking and lifecycle + hyperfleet.io/created-by: "cl-maestro-framework" + hyperfleet.io/managed-by: "{{ .adapter.name }}" + hyperfleet.io/generation: "{{ .generation }}" + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/cluster-name: "{{ .clusterName }}" + hyperfleet.io/deployment-time: "{{ .timestamp }}" + + # Maestro-specific annotations + maestro.io/applied-time: "{{ .timestamp }}" + maestro.io/source-adapter: "{{ .adapter.name }}" + + # Documentation + description: "Complete cluster setup including namespace, configuration, and RBAC" + + # ManifestWork specification + spec: + # ============================================================================ + # Workload - Contains the Kubernetes manifests to deploy + # ============================================================================ + workload: + # Kubernetes manifests array - injected by framework from business logic config + manifests: + - apiVersion: v1 + kind: Namespace + metadata: + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + labels: + app.kubernetes.io/component: adapter-task-config + app.kubernetes.io/instance: "{{ .adapter.name }}" + app.kubernetes.io/name: cl-maestro + app.kubernetes.io/transport: maestro + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + hyperfleet.io/generation: "{{ .generation }}" + - apiVersion: v1 + kind: ConfigMap + data: + cluster_id: "{{ .clusterId }}" + cluster_name: "{{ .clusterName }}" + metadata: + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" + namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + labels: + app.kubernetes.io/component: adapter-task-config + app.kubernetes.io/instance: "{{ .adapter.name }}" + app.kubernetes.io/name: cl-maestro + app.kubernetes.io/version: 1.0.0 + app.kubernetes.io/transport: maestro + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + hyperfleet.io/generation: "{{ .generation }}" + + # ============================================================================ + # Delete Options - How resources should be removed + # ============================================================================ + deleteOption: + # Propagation policy for resource deletion + # - "Foreground": Wait for dependent resources to be deleted first + # - "Background": Delete immediately, let cluster handle dependents + # - "Orphan": Leave resources on cluster when ManifestWork is deleted + propagationPolicy: "Foreground" + + # Grace period for graceful deletion (seconds) + gracePeriodSeconds: 30 + + # ============================================================================ + # Manifest Configurations - Per-resource settings for update and feedback + # ============================================================================ + manifestConfigs: + - resourceIdentifier: + group: "" # Core API group (empty for v1 resources) + resource: "namespaces" # Resource type + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" # Specific resource name + updateStrategy: + type: "ServerSideApply" # Use server-side apply for namespaces + feedbackRules: + - type: "JSONPaths" # Use JSON path expressions for status feedback + jsonPaths: + - name: "phase" + path: ".status.phase" + # ======================================================================== + # Configuration for Namespace resources + # ======================================================================== + - resourceIdentifier: + group: "" # Core API group (empty for v1 resources) + resource: "configmaps" # Resource type + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" # Specific resource name + namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + updateStrategy: + type: "ServerSideApply" # Use server-side apply for namespaces + serverSideApply: + fieldManager: "cl-maestro" # Field manager name for conflict resolution + force: false # Don't force conflicts (fail on conflicts) + feedbackRules: + - type: "JSONPaths" # Use JSON path expressions for status feedback + jsonPaths: + - name: "data" + path: ".data" + - name: "resourceVersion" + path: ".metadata.resourceVersion" + # Discover the ResourceBundle (ManifestWork) by name from Maestro + # NOTE: This discovery name is intentionally WRONG to test main discovery failure + discovery: + by_name: "{{ .clusterId }}-{{ .adapter.name }}-wrong" + + # Discover nested resources deployed by the ManifestWork + # These are correct, but won't be reached if main discovery fails + nested_discoveries: + - name: "namespace0" + discovery: + by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + - name: "configmap0" + discovery: + by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" + + post: + payloads: + - name: "statusPayload" + build: + adapter: "{{ .adapter.name }}" + conditions: + # Applied: Check if ManifestWork exists and has type="Applied", status="True" + - type: "Applied" + status: + expression: | + has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].status : "False" + reason: + expression: | + has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].reason : "ManifestWorkNotDiscovered" + message: + expression: | + has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].message : "ManifestWork not discovered from Maestro or no Applied condition" + + # Available: Check if nested discovered manifests are available on the spoke cluster + # Each nested discovery is enriched with top-level "conditions" from status.resourceStatus.manifests[] + - type: "Available" + status: + expression: | + has(resources.namespace0) && has(resources.namespace0.conditions) + && resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") + && has(resources.configmap0) && has(resources.configmap0.conditions) + && resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") + ? "True" + : "False" + reason: + expression: | + !(has(resources.namespace0) && has(resources.namespace0.conditions)) + ? "NamespaceNotDiscovered" + : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") + ? "NamespaceNotAvailable" + : !(has(resources.configmap0) && has(resources.configmap0.conditions)) + ? "ConfigMapNotDiscovered" + : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") + ? "ConfigMapNotAvailable" + : "AllResourcesAvailable" + message: + expression: | + !(has(resources.namespace0) && has(resources.namespace0.conditions)) + ? "Namespace not discovered from ManifestWork" + : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") + ? "Namespace not yet available on spoke cluster" + : !(has(resources.configmap0) && has(resources.configmap0.conditions)) + ? "ConfigMap not discovered from ManifestWork" + : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") + ? "ConfigMap not yet available on spoke cluster" + : "All manifests (namespace, configmap) are available on spoke cluster" + + # Health: Adapter execution status — surfaces errors from any phase + - type: "Health" + status: + expression: | + adapter.?executionStatus.orValue("") == "success" + && !adapter.?resourcesSkipped.orValue(false) + ? "True" + : "False" + reason: + expression: | + adapter.?executionStatus.orValue("") != "success" + ? "ExecutionFailed:" + adapter.?executionError.?phase.orValue("unknown") + : adapter.?resourcesSkipped.orValue(false) + ? "ResourcesSkipped" + : "Healthy" + message: + expression: | + adapter.?executionStatus.orValue("") != "success" + ? "Adapter failed at phase [" + + adapter.?executionError.?phase.orValue("unknown") + + "] step [" + + adapter.?executionError.?step.orValue("unknown") + + "]: " + + adapter.?executionError.?message.orValue(adapter.?errorMessage.orValue("no details")) + : adapter.?resourcesSkipped.orValue(false) + ? "Resources skipped: " + adapter.?skipReason.orValue("unknown reason") + : "Adapter execution completed successfully" + + observed_generation: + expression: "generation" + observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" + + # Extract data from discovered ManifestWork from Maestro + data: + manifestwork: + name: + expression: | + has(resources.resource0) && has(resources.resource0.metadata) + ? resources.resource0.metadata.name + : "" + consumer: + expression: | + has(resources.resource0) && has(resources.resource0.metadata) + ? resources.resource0.metadata.namespace + : placementClusterName + configmap: + name: + expression: | + has(resources.configmap0) && has(resources.configmap0.metadata) + ? resources.configmap0.metadata.name + : "" + clusterId: + expression: | + has(resources.configmap0) && has(resources.configmap0.data) && has(resources.configmap0.data.cluster_id) + ? resources.configmap0.data.cluster_id + : clusterId + namespace: + name: + expression: | + has(resources.namespace0) && has(resources.namespace0.metadata) + ? resources.namespace0.metadata.name + : "" + phase: + expression: | + has(resources.namespace0) && has(resources.namespace0.statusFeedback) && has(resources.namespace0.statusFeedback.values) + && resources.namespace0.statusFeedback.values.exists(v, has(v.name) && v.name == "phase" && has(v.fieldValue)) + ? resources.namespace0.statusFeedback.values.filter(v, v.name == "phase")[0].fieldValue.string + : "Unknown" + + post_actions: + - name: "reportClusterStatus" + api_call: + method: "PUT" + url: "/clusters/{{ .clusterId }}/statuses" + headers: + - name: "Content-Type" + value: "application/json" + body: "{{ .statusPayload }}" diff --git a/testdata/adapter-configs/cl-m-wrong-ds/adapter-config.yaml b/testdata/adapter-configs/cl-m-wrong-ds/adapter-config.yaml deleted file mode 100644 index ed15355b..00000000 --- a/testdata/adapter-configs/cl-m-wrong-ds/adapter-config.yaml +++ /dev/null @@ -1,65 +0,0 @@ -# Example HyperFleet Adapter deployment configuration -# This configuration is for testing Maestro transport with WRONG main discovery name -# to validate that adapter fails when it cannot find the ManifestWork it created -adapter: - name: cl-m-wrong-ds - #version: "0.1.0" - -# Log the full merged configuration after load (default: false) -debug_config: true -log: - level: debug - -clients: - hyperfleet_api: - base_url: http://hyperfleet-api:8000 - version: v1 - timeout: 2s - retry_attempts: 3 - retry_backoff: exponential - - broker: - # These values are overridden at deploy time via env vars from Helm values - subscription_id: CHANGE_ME - topic: CHANGE_ME - - maestro: - grpc_server_address: "maestro-grpc.maestro.svc.cluster.local:8090" - - # HTTPS server address for REST API operations (optional) - # Environment variable: HYPERFLEET_MAESTRO_HTTP_SERVER_ADDRESS - http_server_address: "http://maestro.maestro.svc.cluster.local:8000" - - # Source identifier for CloudEvents routing (must be unique across adapters) - # Environment variable: HYPERFLEET_MAESTRO_SOURCE_ID - source_id: "cl-m-wrong-ds" - - # Client identifier (defaults to source_id if not specified) - # Environment variable: HYPERFLEET_MAESTRO_CLIENT_ID - client_id: "cl-m-wrong-ds-client" - insecure: true - - # Authentication configuration - #auth: - # type: "tls" # TLS certificate-based mTLS - # - # tls_config: - # # gRPC TLS configuration - # # Certificate paths (mounted from Kubernetes secrets) - # # Environment variable: HYPERFLEET_MAESTRO_CA_FILE - # ca_file: "/etc/maestro/certs/grpc/ca.crt" - # - # # Environment variable: HYPERFLEET_MAESTRO_CERT_FILE - # cert_file: "/etc/maestro/certs/grpc/client.crt" - # - # # Environment variable: HYPERFLEET_MAESTRO_KEY_FILE - # key_file: "/etc/maestro/certs/grpc/client.key" - # - # # Server name for TLS verification - # # Environment variable: HYPERFLEET_MAESTRO_SERVER_NAME - # server_name: "maestro-grpc.maestro.svc.cluster.local" - # - # # HTTP API TLS configuration (may use different CA than gRPC) - # # If not set, falls back to ca_file for backwards compatibility - # # Environment variable: HYPERFLEET_MAESTRO_HTTP_CA_FILE - # http_ca_file: "/etc/maestro/certs/https/ca.crt" diff --git a/testdata/adapter-configs/cl-m-wrong-ds/adapter-task-config.yaml b/testdata/adapter-configs/cl-m-wrong-ds/adapter-task-config.yaml deleted file mode 100644 index b64c44ab..00000000 --- a/testdata/adapter-configs/cl-m-wrong-ds/adapter-task-config.yaml +++ /dev/null @@ -1,340 +0,0 @@ -# Example HyperFleet Adapter task configuration - -# Parameters with all required variables -params: - - - name: "clusterId" - source: "event.id" - type: "string" - required: true - - name: "runId" - source: "env.RUN_ID" - type: "string" - required: true - - name: "generation" - source: "event.generation" - type: "int" - required: true - - name: "namespace" - source: "env.NAMESPACE" - type: "string" - - - name: "clusterStatus" - source: - api_call: - method: "GET" - url: "/clusters/{{ .clusterId }}" - timeout: 10s - retry_attempts: 3 - retry_backoff: "exponential" - - - name: "clusterName" - source: "clusterStatus.name" - - - name: "timestamp" - source: "clusterStatus.created_time" - - - name: "reconciledConditionStatus" - source: - expression: | - clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 - ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].status - : "False" - - - name: "placementClusterName" - source: - expression: '"cluster1"' # TBC coming from placement adapter - -# Preconditions with valid operators and CEL expressions -preconditions: - - name: "validationCheck" - expression: | - reconciledConditionStatus == "False" - -# Resources with valid K8s manifests -resources: - - name: "resource0" - transport: - client: "maestro" - maestro: - target_cluster: "{{ .placementClusterName }}" - - # ManifestWork is a kind of manifest that can be used to create resources on the cluster. - # It is a collection of resources that are created together. - manifest: - apiVersion: work.open-cluster-management.io/v1 - kind: ManifestWork - metadata: - # ManifestWork name - must be unique within consumer namespace - name: "{{ .clusterId }}-{{ .adapter.name }}" - - # Labels for identification, filtering, and management - labels: - # HyperFleet tracking labels - hyperfleet.io/cluster-id: "{{ .clusterId }}" - hyperfleet.io/adapter: "{{ .adapter.name }}" - hyperfleet.io/component: "infrastructure" - hyperfleet.io/generation: "{{ .generation }}" - hyperfleet.io/resource-group: "cluster-setup" - - # Maestro-specific labels - maestro.io/source-id: "{{ .adapter.name }}" - maestro.io/resource-type: "manifestwork" - maestro.io/priority: "normal" - - # Standard Kubernetes application labels - app.kubernetes.io/name: "aro-hcp-cluster" - app.kubernetes.io/instance: "{{ .clusterId }}" - app.kubernetes.io/version: "v1.0.0" - app.kubernetes.io/component: "infrastructure" - app.kubernetes.io/part-of: "hyperfleet" - app.kubernetes.io/managed-by: "cl-maestro" - app.kubernetes.io/created-by: "{{ .adapter.name }}" - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - # Tracking and lifecycle - hyperfleet.io/created-by: "cl-maestro-framework" - hyperfleet.io/managed-by: "{{ .adapter.name }}" - hyperfleet.io/generation: "{{ .generation }}" - hyperfleet.io/cluster-id: "{{ .clusterId }}" - hyperfleet.io/cluster-name: "{{ .clusterName }}" - hyperfleet.io/deployment-time: "{{ .timestamp }}" - - # Maestro-specific annotations - maestro.io/applied-time: "{{ .timestamp }}" - maestro.io/source-adapter: "{{ .adapter.name }}" - - # Documentation - description: "Complete cluster setup including namespace, configuration, and RBAC" - - # ManifestWork specification - spec: - # ============================================================================ - # Workload - Contains the Kubernetes manifests to deploy - # ============================================================================ - workload: - # Kubernetes manifests array - injected by framework from business logic config - manifests: - - apiVersion: v1 - kind: Namespace - metadata: - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - labels: - app.kubernetes.io/component: adapter-task-config - app.kubernetes.io/instance: "{{ .adapter.name }}" - app.kubernetes.io/name: cl-maestro - app.kubernetes.io/transport: maestro - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - hyperfleet.io/generation: "{{ .generation }}" - - apiVersion: v1 - kind: ConfigMap - data: - cluster_id: "{{ .clusterId }}" - cluster_name: "{{ .clusterName }}" - metadata: - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" - namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - labels: - app.kubernetes.io/component: adapter-task-config - app.kubernetes.io/instance: "{{ .adapter.name }}" - app.kubernetes.io/name: cl-maestro - app.kubernetes.io/version: 1.0.0 - app.kubernetes.io/transport: maestro - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - hyperfleet.io/generation: "{{ .generation }}" - - # ============================================================================ - # Delete Options - How resources should be removed - # ============================================================================ - deleteOption: - # Propagation policy for resource deletion - # - "Foreground": Wait for dependent resources to be deleted first - # - "Background": Delete immediately, let cluster handle dependents - # - "Orphan": Leave resources on cluster when ManifestWork is deleted - propagationPolicy: "Foreground" - - # Grace period for graceful deletion (seconds) - gracePeriodSeconds: 30 - - # ============================================================================ - # Manifest Configurations - Per-resource settings for update and feedback - # ============================================================================ - manifestConfigs: - - resourceIdentifier: - group: "" # Core API group (empty for v1 resources) - resource: "namespaces" # Resource type - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" # Specific resource name - updateStrategy: - type: "ServerSideApply" # Use server-side apply for namespaces - feedbackRules: - - type: "JSONPaths" # Use JSON path expressions for status feedback - jsonPaths: - - name: "phase" - path: ".status.phase" - # ======================================================================== - # Configuration for Namespace resources - # ======================================================================== - - resourceIdentifier: - group: "" # Core API group (empty for v1 resources) - resource: "configmaps" # Resource type - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" # Specific resource name - namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - updateStrategy: - type: "ServerSideApply" # Use server-side apply for namespaces - serverSideApply: - fieldManager: "cl-maestro" # Field manager name for conflict resolution - force: false # Don't force conflicts (fail on conflicts) - feedbackRules: - - type: "JSONPaths" # Use JSON path expressions for status feedback - jsonPaths: - - name: "data" - path: ".data" - - name: "resourceVersion" - path: ".metadata.resourceVersion" - # Discover the ResourceBundle (ManifestWork) by name from Maestro - # NOTE: This discovery name is intentionally WRONG to test main discovery failure - discovery: - by_name: "{{ .clusterId }}-{{ .adapter.name }}-wrong" - - # Discover nested resources deployed by the ManifestWork - # These are correct, but won't be reached if main discovery fails - nested_discoveries: - - name: "namespace0" - discovery: - by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - - name: "configmap0" - discovery: - by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" - -post: - payloads: - - name: "statusPayload" - build: - adapter: "{{ .adapter.name }}" - conditions: - # Applied: Check if ManifestWork exists and has type="Applied", status="True" - - type: "Applied" - status: - expression: | - has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].status : "False" - reason: - expression: | - has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].reason : "ManifestWorkNotDiscovered" - message: - expression: | - has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].message : "ManifestWork not discovered from Maestro or no Applied condition" - - # Available: Check if nested discovered manifests are available on the spoke cluster - # Each nested discovery is enriched with top-level "conditions" from status.resourceStatus.manifests[] - - type: "Available" - status: - expression: | - has(resources.namespace0) && has(resources.namespace0.conditions) - && resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") - && has(resources.configmap0) && has(resources.configmap0.conditions) - && resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") - ? "True" - : "False" - reason: - expression: | - !(has(resources.namespace0) && has(resources.namespace0.conditions)) - ? "NamespaceNotDiscovered" - : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") - ? "NamespaceNotAvailable" - : !(has(resources.configmap0) && has(resources.configmap0.conditions)) - ? "ConfigMapNotDiscovered" - : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") - ? "ConfigMapNotAvailable" - : "AllResourcesAvailable" - message: - expression: | - !(has(resources.namespace0) && has(resources.namespace0.conditions)) - ? "Namespace not discovered from ManifestWork" - : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") - ? "Namespace not yet available on spoke cluster" - : !(has(resources.configmap0) && has(resources.configmap0.conditions)) - ? "ConfigMap not discovered from ManifestWork" - : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") - ? "ConfigMap not yet available on spoke cluster" - : "All manifests (namespace, configmap) are available on spoke cluster" - - # Health: Adapter execution status — surfaces errors from any phase - - type: "Health" - status: - expression: | - adapter.?executionStatus.orValue("") == "success" - && !adapter.?resourcesSkipped.orValue(false) - ? "True" - : "False" - reason: - expression: | - adapter.?executionStatus.orValue("") != "success" - ? "ExecutionFailed:" + adapter.?executionError.?phase.orValue("unknown") - : adapter.?resourcesSkipped.orValue(false) - ? "ResourcesSkipped" - : "Healthy" - message: - expression: | - adapter.?executionStatus.orValue("") != "success" - ? "Adapter failed at phase [" - + adapter.?executionError.?phase.orValue("unknown") - + "] step [" - + adapter.?executionError.?step.orValue("unknown") - + "]: " - + adapter.?executionError.?message.orValue(adapter.?errorMessage.orValue("no details")) - : adapter.?resourcesSkipped.orValue(false) - ? "Resources skipped: " + adapter.?skipReason.orValue("unknown reason") - : "Adapter execution completed successfully" - - observed_generation: - expression: "generation" - observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" - - # Extract data from discovered ManifestWork from Maestro - data: - manifestwork: - name: - expression: | - has(resources.resource0) && has(resources.resource0.metadata) - ? resources.resource0.metadata.name - : "" - consumer: - expression: | - has(resources.resource0) && has(resources.resource0.metadata) - ? resources.resource0.metadata.namespace - : placementClusterName - configmap: - name: - expression: | - has(resources.configmap0) && has(resources.configmap0.metadata) - ? resources.configmap0.metadata.name - : "" - clusterId: - expression: | - has(resources.configmap0) && has(resources.configmap0.data) && has(resources.configmap0.data.cluster_id) - ? resources.configmap0.data.cluster_id - : clusterId - namespace: - name: - expression: | - has(resources.namespace0) && has(resources.namespace0.metadata) - ? resources.namespace0.metadata.name - : "" - phase: - expression: | - has(resources.namespace0) && has(resources.namespace0.statusFeedback) && has(resources.namespace0.statusFeedback.values) - && resources.namespace0.statusFeedback.values.exists(v, has(v.name) && v.name == "phase" && has(v.fieldValue)) - ? resources.namespace0.statusFeedback.values.filter(v, v.name == "phase")[0].fieldValue.string - : "Unknown" - - post_actions: - - name: "reportClusterStatus" - api_call: - method: "PUT" - url: "/clusters/{{ .clusterId }}/statuses" - headers: - - name: "Content-Type" - value: "application/json" - body: "{{ .statusPayload }}" diff --git a/testdata/adapter-configs/cl-m-wrong-ds/values.yaml b/testdata/adapter-configs/cl-m-wrong-ds/values.yaml deleted file mode 100644 index 2d07acc0..00000000 --- a/testdata/adapter-configs/cl-m-wrong-ds/values.yaml +++ /dev/null @@ -1,47 +0,0 @@ -adapterConfig: - create: true - files: - adapter-config.yaml: cl-m-wrong-ds/adapter-config.yaml - log: - level: debug - -adapterTaskConfig: - create: true - files: - task-config.yaml: cl-m-wrong-ds/adapter-task-config.yaml - -broker: - type: ${BROKER_TYPE} - create: true - googlepubsub: - projectId: ${GCP_PROJECT_ID} - subscriptionId: ${NAMESPACE}-clusters-${ADAPTER_NAME} - topic: ${NAMESPACE}-clusters - deadLetterTopic: ${NAMESPACE}-clusters-dlq - createTopicIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_TOPIC_IF_MISSING} - createSubscriptionIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_SUBSCRIPTION_IF_MISSING} - expirationTTL: "1d" - rabbitmq: - url: ${RABBITMQ_URL} - queue: ${NAMESPACE}-clusters-${ADAPTER_NAME} - exchange: ${NAMESPACE}-clusters - routingKey: ${ADAPTER_NAME} - -image: - registry: ${IMAGE_REGISTRY} - repository: ${ADAPTER_IMAGE_REPO} - pullPolicy: Always - tag: ${ADAPTER_IMAGE_TAG} - -rbac: - resources: - - namespaces - - configmaps - - configmaps/status - -labels: - e2e.hyperfleet.io/run-id: ${RUN_ID} - -env: - - name: RUN_ID - value: ${RUN_ID} diff --git a/testdata/adapter-configs/cl-m-wrong-nest.yaml b/testdata/adapter-configs/cl-m-wrong-nest.yaml new file mode 100644 index 00000000..a036582c --- /dev/null +++ b/testdata/adapter-configs/cl-m-wrong-nest.yaml @@ -0,0 +1,406 @@ +rbac: + resources: + - namespaces + - configmaps + - configmaps/status + +adapterConfig: + yaml: + adapter: + name: cl-m-wrong-nest + # Log the full merged configuration after load (default: false) + debug_config: true + log: + level: debug + + clients: + hyperfleet_api: + base_url: http://hyperfleet-api:8000 + version: v1 + timeout: 2s + retry_attempts: 3 + retry_backoff: exponential + + broker: + # These values are overridden at deploy time via env vars from Helm values + subscription_id: CHANGE_ME + topic: CHANGE_ME + + maestro: + grpc_server_address: "maestro-grpc.maestro.svc.cluster.local:8090" + + # HTTPS server address for REST API operations (optional) + # Environment variable: HYPERFLEET_MAESTRO_HTTP_SERVER_ADDRESS + http_server_address: "http://maestro.maestro.svc.cluster.local:8000" + + # Source identifier for CloudEvents routing (must be unique across adapters) + # Environment variable: HYPERFLEET_MAESTRO_SOURCE_ID + source_id: "cl-m-wrong-nest" + + # Client identifier (defaults to source_id if not specified) + # Environment variable: HYPERFLEET_MAESTRO_CLIENT_ID + client_id: "cl-m-wrong-nest-client" + insecure: true + + # Authentication configuration + #auth: + # type: "tls" # TLS certificate-based mTLS + # + # tls_config: + # # gRPC TLS configuration + # # Certificate paths (mounted from Kubernetes secrets) + # # Environment variable: HYPERFLEET_MAESTRO_CA_FILE + # ca_file: "/etc/maestro/certs/grpc/ca.crt" + # + # # Environment variable: HYPERFLEET_MAESTRO_CERT_FILE + # cert_file: "/etc/maestro/certs/grpc/client.crt" + # + # # Environment variable: HYPERFLEET_MAESTRO_KEY_FILE + # key_file: "/etc/maestro/certs/grpc/client.key" + # + # # Server name for TLS verification + # # Environment variable: HYPERFLEET_MAESTRO_SERVER_NAME + # server_name: "maestro-grpc.maestro.svc.cluster.local" + # + # # HTTP API TLS configuration (may use different CA than gRPC) + # # If not set, falls back to ca_file for backwards compatibility + # # Environment variable: HYPERFLEET_MAESTRO_HTTP_CA_FILE + # http_ca_file: "/etc/maestro/certs/https/ca.crt" + +adapterTaskConfig: + yaml: + params: + - name: "clusterId" + source: "event.id" + type: "string" + required: true + - name: "runId" + source: "env.RUN_ID" + type: "string" + required: true + - name: "generation" + source: "event.generation" + type: "int" + required: true + - name: "namespace" + source: "env.NAMESPACE" + type: "string" + + - name: "clusterStatus" + source: + api_call: + method: "GET" + url: "/clusters/{{ .clusterId }}" + timeout: 10s + retry_attempts: 3 + retry_backoff: "exponential" + + - name: "clusterName" + source: "clusterStatus.name" + + - name: "timestamp" + source: "clusterStatus.created_time" + + - name: "reconciledConditionStatus" + source: + expression: | + clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 + ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].status + : "False" + + - name: "placementClusterName" + source: + expression: '"cluster1"' # TBC coming from placement adapter + + # Preconditions with valid operators and CEL expressions + preconditions: + - name: "validationCheck" + expression: | + reconciledConditionStatus == "False" + + # Resources with valid K8s manifests + resources: + - name: "resource0" + transport: + client: "maestro" + maestro: + target_cluster: "{{ .placementClusterName }}" + + # ManifestWork is a kind of manifest that can be used to create resources on the cluster. + # It is a collection of resources that are created together. + manifest: + apiVersion: work.open-cluster-management.io/v1 + kind: ManifestWork + metadata: + # ManifestWork name - must be unique within consumer namespace + name: "{{ .clusterId }}-{{ .adapter.name }}" + + # Labels for identification, filtering, and management + labels: + # HyperFleet tracking labels + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/adapter: "{{ .adapter.name }}" + hyperfleet.io/component: "infrastructure" + hyperfleet.io/generation: "{{ .generation }}" + hyperfleet.io/resource-group: "cluster-setup" + + # Maestro-specific labels + maestro.io/source-id: "{{ .adapter.name }}" + maestro.io/resource-type: "manifestwork" + maestro.io/priority: "normal" + + # Standard Kubernetes application labels + app.kubernetes.io/name: "aro-hcp-cluster" + app.kubernetes.io/instance: "{{ .clusterId }}" + app.kubernetes.io/version: "v1.0.0" + app.kubernetes.io/component: "infrastructure" + app.kubernetes.io/part-of: "hyperfleet" + app.kubernetes.io/managed-by: "cl-maestro" + app.kubernetes.io/created-by: "{{ .adapter.name }}" + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + # Tracking and lifecycle + hyperfleet.io/created-by: "cl-maestro-framework" + hyperfleet.io/managed-by: "{{ .adapter.name }}" + hyperfleet.io/generation: "{{ .generation }}" + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/cluster-name: "{{ .clusterName }}" + hyperfleet.io/deployment-time: "{{ .timestamp }}" + + # Maestro-specific annotations + maestro.io/applied-time: "{{ .timestamp }}" + maestro.io/source-adapter: "{{ .adapter.name }}" + + # Documentation + description: "Complete cluster setup including namespace, configuration, and RBAC" + + # ManifestWork specification + spec: + # ============================================================================ + # Workload - Contains the Kubernetes manifests to deploy + # ============================================================================ + workload: + # Kubernetes manifests array - injected by framework from business logic config + manifests: + - apiVersion: v1 + kind: Namespace + metadata: + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + labels: + app.kubernetes.io/component: adapter-task-config + app.kubernetes.io/instance: "{{ .adapter.name }}" + app.kubernetes.io/name: cl-maestro + app.kubernetes.io/transport: maestro + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + hyperfleet.io/generation: "{{ .generation }}" + - apiVersion: v1 + kind: ConfigMap + data: + cluster_id: "{{ .clusterId }}" + cluster_name: "{{ .clusterName }}" + metadata: + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" + namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + labels: + app.kubernetes.io/component: adapter-task-config + app.kubernetes.io/instance: "{{ .adapter.name }}" + app.kubernetes.io/name: cl-maestro + app.kubernetes.io/version: 1.0.0 + app.kubernetes.io/transport: maestro + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + hyperfleet.io/generation: "{{ .generation }}" + + # ============================================================================ + # Delete Options - How resources should be removed + # ============================================================================ + deleteOption: + # Propagation policy for resource deletion + # - "Foreground": Wait for dependent resources to be deleted first + # - "Background": Delete immediately, let cluster handle dependents + # - "Orphan": Leave resources on cluster when ManifestWork is deleted + propagationPolicy: "Foreground" + + # Grace period for graceful deletion (seconds) + gracePeriodSeconds: 30 + + # ============================================================================ + # Manifest Configurations - Per-resource settings for update and feedback + # ============================================================================ + manifestConfigs: + - resourceIdentifier: + group: "" # Core API group (empty for v1 resources) + resource: "namespaces" # Resource type + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" # Specific resource name + updateStrategy: + type: "ServerSideApply" # Use server-side apply for namespaces + feedbackRules: + - type: "JSONPaths" # Use JSON path expressions for status feedback + jsonPaths: + - name: "phase" + path: ".status.phase" + # ======================================================================== + # Configuration for Namespace resources + # ======================================================================== + - resourceIdentifier: + group: "" # Core API group (empty for v1 resources) + resource: "configmaps" # Resource type + name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" # Specific resource name + namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" + updateStrategy: + type: "ServerSideApply" # Use server-side apply for namespaces + serverSideApply: + fieldManager: "cl-maestro" # Field manager name for conflict resolution + force: false # Don't force conflicts (fail on conflicts) + feedbackRules: + - type: "JSONPaths" # Use JSON path expressions for status feedback + jsonPaths: + - name: "data" + path: ".data" + - name: "resourceVersion" + path: ".metadata.resourceVersion" + # Discover the ResourceBundle (ManifestWork) by name from Maestro + discovery: + by_name: "{{ .clusterId }}-{{ .adapter.name }}" + + # Discover nested resources deployed by the ManifestWork + # NOTE: These discovery names are intentionally WRONG for testing discovery failure + nested_discoveries: + - name: "namespace0" + discovery: + by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace-wrong" + - name: "configmap0" + discovery: + by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap-wrong" + + post: + payloads: + - name: "statusPayload" + build: + adapter: "{{ .adapter.name }}" + conditions: + # Applied: Check if ManifestWork exists and has type="Applied", status="True" + - type: "Applied" + status: + expression: | + has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].status : "False" + reason: + expression: | + has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].reason : "ManifestWorkNotDiscovered" + message: + expression: | + has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].message : "ManifestWork not discovered from Maestro or no Applied condition" + + # Available: Check if nested discovered manifests are available on the spoke cluster + # Each nested discovery is enriched with top-level "conditions" from status.resourceStatus.manifests[] + - type: "Available" + status: + expression: | + has(resources.namespace0) && has(resources.namespace0.conditions) + && resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") + && has(resources.configmap0) && has(resources.configmap0.conditions) + && resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") + ? "True" + : "False" + reason: + expression: | + !(has(resources.namespace0) && has(resources.namespace0.conditions)) + ? "NamespaceNotDiscovered" + : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") + ? "NamespaceNotAvailable" + : !(has(resources.configmap0) && has(resources.configmap0.conditions)) + ? "ConfigMapNotDiscovered" + : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") + ? "ConfigMapNotAvailable" + : "AllResourcesAvailable" + message: + expression: | + !(has(resources.namespace0) && has(resources.namespace0.conditions)) + ? "Namespace not discovered from ManifestWork" + : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") + ? "Namespace not yet available on spoke cluster" + : !(has(resources.configmap0) && has(resources.configmap0.conditions)) + ? "ConfigMap not discovered from ManifestWork" + : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") + ? "ConfigMap not yet available on spoke cluster" + : "All manifests (namespace, configmap) are available on spoke cluster" + + # Health: Adapter execution status — surfaces errors from any phase + - type: "Health" + status: + expression: | + adapter.?executionStatus.orValue("") == "success" + && !adapter.?resourcesSkipped.orValue(false) + ? "True" + : "False" + reason: + expression: | + adapter.?executionStatus.orValue("") != "success" + ? "ExecutionFailed:" + adapter.?executionError.?phase.orValue("unknown") + : adapter.?resourcesSkipped.orValue(false) + ? "ResourcesSkipped" + : "Healthy" + message: + expression: | + adapter.?executionStatus.orValue("") != "success" + ? "Adapter failed at phase [" + + adapter.?executionError.?phase.orValue("unknown") + + "] step [" + + adapter.?executionError.?step.orValue("unknown") + + "]: " + + adapter.?executionError.?message.orValue(adapter.?errorMessage.orValue("no details")) + : adapter.?resourcesSkipped.orValue(false) + ? "Resources skipped: " + adapter.?skipReason.orValue("unknown reason") + : "Adapter execution completed successfully" + + observed_generation: + expression: "generation" + observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" + + # Extract data from discovered ManifestWork from Maestro + data: + manifestwork: + name: + expression: | + has(resources.resource0) && has(resources.resource0.metadata) + ? resources.resource0.metadata.name + : "" + consumer: + expression: | + has(resources.resource0) && has(resources.resource0.metadata) + ? resources.resource0.metadata.namespace + : placementClusterName + configmap: + name: + expression: | + has(resources.configmap0) && has(resources.configmap0.metadata) + ? resources.configmap0.metadata.name + : "" + clusterId: + expression: | + has(resources.configmap0) && has(resources.configmap0.data) && has(resources.configmap0.data.cluster_id) + ? resources.configmap0.data.cluster_id + : clusterId + namespace: + name: + expression: | + has(resources.namespace0) && has(resources.namespace0.metadata) + ? resources.namespace0.metadata.name + : "" + phase: + expression: | + has(resources.namespace0) && has(resources.namespace0.statusFeedback) && has(resources.namespace0.statusFeedback.values) + && resources.namespace0.statusFeedback.values.exists(v, has(v.name) && v.name == "phase" && has(v.fieldValue)) + ? resources.namespace0.statusFeedback.values.filter(v, v.name == "phase")[0].fieldValue.string + : "Unknown" + + post_actions: + - name: "reportClusterStatus" + api_call: + method: "PUT" + url: "/clusters/{{ .clusterId }}/statuses" + headers: + - name: "Content-Type" + value: "application/json" + body: "{{ .statusPayload }}" diff --git a/testdata/adapter-configs/cl-m-wrong-nest/adapter-config.yaml b/testdata/adapter-configs/cl-m-wrong-nest/adapter-config.yaml deleted file mode 100644 index 596fc3f7..00000000 --- a/testdata/adapter-configs/cl-m-wrong-nest/adapter-config.yaml +++ /dev/null @@ -1,65 +0,0 @@ -# Example HyperFleet Adapter deployment configuration -# This configuration is for testing Maestro transport with WRONG nested discovery names -# to validate error handling when ManifestWork is found but nested resources cannot be discovered -adapter: - name: cl-m-wrong-nest - #version: "0.1.0" - -# Log the full merged configuration after load (default: false) -debug_config: true -log: - level: debug - -clients: - hyperfleet_api: - base_url: http://hyperfleet-api:8000 - version: v1 - timeout: 2s - retry_attempts: 3 - retry_backoff: exponential - - broker: - # These values are overridden at deploy time via env vars from Helm values - subscription_id: CHANGE_ME - topic: CHANGE_ME - - maestro: - grpc_server_address: "maestro-grpc.maestro.svc.cluster.local:8090" - - # HTTPS server address for REST API operations (optional) - # Environment variable: HYPERFLEET_MAESTRO_HTTP_SERVER_ADDRESS - http_server_address: "http://maestro.maestro.svc.cluster.local:8000" - - # Source identifier for CloudEvents routing (must be unique across adapters) - # Environment variable: HYPERFLEET_MAESTRO_SOURCE_ID - source_id: "cl-m-wrong-nest" - - # Client identifier (defaults to source_id if not specified) - # Environment variable: HYPERFLEET_MAESTRO_CLIENT_ID - client_id: "cl-m-wrong-nest-client" - insecure: true - - # Authentication configuration - #auth: - # type: "tls" # TLS certificate-based mTLS - # - # tls_config: - # # gRPC TLS configuration - # # Certificate paths (mounted from Kubernetes secrets) - # # Environment variable: HYPERFLEET_MAESTRO_CA_FILE - # ca_file: "/etc/maestro/certs/grpc/ca.crt" - # - # # Environment variable: HYPERFLEET_MAESTRO_CERT_FILE - # cert_file: "/etc/maestro/certs/grpc/client.crt" - # - # # Environment variable: HYPERFLEET_MAESTRO_KEY_FILE - # key_file: "/etc/maestro/certs/grpc/client.key" - # - # # Server name for TLS verification - # # Environment variable: HYPERFLEET_MAESTRO_SERVER_NAME - # server_name: "maestro-grpc.maestro.svc.cluster.local" - # - # # HTTP API TLS configuration (may use different CA than gRPC) - # # If not set, falls back to ca_file for backwards compatibility - # # Environment variable: HYPERFLEET_MAESTRO_HTTP_CA_FILE - # http_ca_file: "/etc/maestro/certs/https/ca.crt" diff --git a/testdata/adapter-configs/cl-m-wrong-nest/adapter-task-config.yaml b/testdata/adapter-configs/cl-m-wrong-nest/adapter-task-config.yaml deleted file mode 100644 index 8b63d8c5..00000000 --- a/testdata/adapter-configs/cl-m-wrong-nest/adapter-task-config.yaml +++ /dev/null @@ -1,339 +0,0 @@ -# Example HyperFleet Adapter task configuration - -# Parameters with all required variables -params: - - - name: "clusterId" - source: "event.id" - type: "string" - required: true - - name: "runId" - source: "env.RUN_ID" - type: "string" - required: true - - name: "generation" - source: "event.generation" - type: "int" - required: true - - name: "namespace" - source: "env.NAMESPACE" - type: "string" - - - name: "clusterStatus" - source: - api_call: - method: "GET" - url: "/clusters/{{ .clusterId }}" - timeout: 10s - retry_attempts: 3 - retry_backoff: "exponential" - - - name: "clusterName" - source: "clusterStatus.name" - - - name: "timestamp" - source: "clusterStatus.created_time" - - - name: "reconciledConditionStatus" - source: - expression: | - clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 - ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].status - : "False" - - - name: "placementClusterName" - source: - expression: '"cluster1"' # TBC coming from placement adapter - -# Preconditions with valid operators and CEL expressions -preconditions: - - name: "validationCheck" - expression: | - reconciledConditionStatus == "False" - -# Resources with valid K8s manifests -resources: - - name: "resource0" - transport: - client: "maestro" - maestro: - target_cluster: "{{ .placementClusterName }}" - - # ManifestWork is a kind of manifest that can be used to create resources on the cluster. - # It is a collection of resources that are created together. - manifest: - apiVersion: work.open-cluster-management.io/v1 - kind: ManifestWork - metadata: - # ManifestWork name - must be unique within consumer namespace - name: "{{ .clusterId }}-{{ .adapter.name }}" - - # Labels for identification, filtering, and management - labels: - # HyperFleet tracking labels - hyperfleet.io/cluster-id: "{{ .clusterId }}" - hyperfleet.io/adapter: "{{ .adapter.name }}" - hyperfleet.io/component: "infrastructure" - hyperfleet.io/generation: "{{ .generation }}" - hyperfleet.io/resource-group: "cluster-setup" - - # Maestro-specific labels - maestro.io/source-id: "{{ .adapter.name }}" - maestro.io/resource-type: "manifestwork" - maestro.io/priority: "normal" - - # Standard Kubernetes application labels - app.kubernetes.io/name: "aro-hcp-cluster" - app.kubernetes.io/instance: "{{ .clusterId }}" - app.kubernetes.io/version: "v1.0.0" - app.kubernetes.io/component: "infrastructure" - app.kubernetes.io/part-of: "hyperfleet" - app.kubernetes.io/managed-by: "cl-maestro" - app.kubernetes.io/created-by: "{{ .adapter.name }}" - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - # Tracking and lifecycle - hyperfleet.io/created-by: "cl-maestro-framework" - hyperfleet.io/managed-by: "{{ .adapter.name }}" - hyperfleet.io/generation: "{{ .generation }}" - hyperfleet.io/cluster-id: "{{ .clusterId }}" - hyperfleet.io/cluster-name: "{{ .clusterName }}" - hyperfleet.io/deployment-time: "{{ .timestamp }}" - - # Maestro-specific annotations - maestro.io/applied-time: "{{ .timestamp }}" - maestro.io/source-adapter: "{{ .adapter.name }}" - - # Documentation - description: "Complete cluster setup including namespace, configuration, and RBAC" - - # ManifestWork specification - spec: - # ============================================================================ - # Workload - Contains the Kubernetes manifests to deploy - # ============================================================================ - workload: - # Kubernetes manifests array - injected by framework from business logic config - manifests: - - apiVersion: v1 - kind: Namespace - metadata: - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - labels: - app.kubernetes.io/component: adapter-task-config - app.kubernetes.io/instance: "{{ .adapter.name }}" - app.kubernetes.io/name: cl-maestro - app.kubernetes.io/transport: maestro - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - hyperfleet.io/generation: "{{ .generation }}" - - apiVersion: v1 - kind: ConfigMap - data: - cluster_id: "{{ .clusterId }}" - cluster_name: "{{ .clusterName }}" - metadata: - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" - namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - labels: - app.kubernetes.io/component: adapter-task-config - app.kubernetes.io/instance: "{{ .adapter.name }}" - app.kubernetes.io/name: cl-maestro - app.kubernetes.io/version: 1.0.0 - app.kubernetes.io/transport: maestro - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - hyperfleet.io/generation: "{{ .generation }}" - - # ============================================================================ - # Delete Options - How resources should be removed - # ============================================================================ - deleteOption: - # Propagation policy for resource deletion - # - "Foreground": Wait for dependent resources to be deleted first - # - "Background": Delete immediately, let cluster handle dependents - # - "Orphan": Leave resources on cluster when ManifestWork is deleted - propagationPolicy: "Foreground" - - # Grace period for graceful deletion (seconds) - gracePeriodSeconds: 30 - - # ============================================================================ - # Manifest Configurations - Per-resource settings for update and feedback - # ============================================================================ - manifestConfigs: - - resourceIdentifier: - group: "" # Core API group (empty for v1 resources) - resource: "namespaces" # Resource type - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" # Specific resource name - updateStrategy: - type: "ServerSideApply" # Use server-side apply for namespaces - feedbackRules: - - type: "JSONPaths" # Use JSON path expressions for status feedback - jsonPaths: - - name: "phase" - path: ".status.phase" - # ======================================================================== - # Configuration for Namespace resources - # ======================================================================== - - resourceIdentifier: - group: "" # Core API group (empty for v1 resources) - resource: "configmaps" # Resource type - name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap" # Specific resource name - namespace: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace" - updateStrategy: - type: "ServerSideApply" # Use server-side apply for namespaces - serverSideApply: - fieldManager: "cl-maestro" # Field manager name for conflict resolution - force: false # Don't force conflicts (fail on conflicts) - feedbackRules: - - type: "JSONPaths" # Use JSON path expressions for status feedback - jsonPaths: - - name: "data" - path: ".data" - - name: "resourceVersion" - path: ".metadata.resourceVersion" - # Discover the ResourceBundle (ManifestWork) by name from Maestro - discovery: - by_name: "{{ .clusterId }}-{{ .adapter.name }}" - - # Discover nested resources deployed by the ManifestWork - # NOTE: These discovery names are intentionally WRONG for testing discovery failure - nested_discoveries: - - name: "namespace0" - discovery: - by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-namespace-wrong" - - name: "configmap0" - discovery: - by_name: "{{ .clusterId | lower }}-{{ .adapter.name }}-configmap-wrong" - -post: - payloads: - - name: "statusPayload" - build: - adapter: "{{ .adapter.name }}" - conditions: - # Applied: Check if ManifestWork exists and has type="Applied", status="True" - - type: "Applied" - status: - expression: | - has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].status : "False" - reason: - expression: | - has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].reason : "ManifestWorkNotDiscovered" - message: - expression: | - has(resources.resource0) && has(resources.resource0.status) && has(resources.resource0.status.conditions) && resources.resource0.status.conditions.filter(c, has(c.type) && c.type == "Applied").size() > 0 ? resources.resource0.status.conditions.filter(c, c.type == "Applied")[0].message : "ManifestWork not discovered from Maestro or no Applied condition" - - # Available: Check if nested discovered manifests are available on the spoke cluster - # Each nested discovery is enriched with top-level "conditions" from status.resourceStatus.manifests[] - - type: "Available" - status: - expression: | - has(resources.namespace0) && has(resources.namespace0.conditions) - && resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") - && has(resources.configmap0) && has(resources.configmap0.conditions) - && resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") - ? "True" - : "False" - reason: - expression: | - !(has(resources.namespace0) && has(resources.namespace0.conditions)) - ? "NamespaceNotDiscovered" - : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") - ? "NamespaceNotAvailable" - : !(has(resources.configmap0) && has(resources.configmap0.conditions)) - ? "ConfigMapNotDiscovered" - : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") - ? "ConfigMapNotAvailable" - : "AllResourcesAvailable" - message: - expression: | - !(has(resources.namespace0) && has(resources.namespace0.conditions)) - ? "Namespace not discovered from ManifestWork" - : !resources.namespace0.conditions.exists(c, has(c.type) && c.type == "Available" && has(c.status) && c.status == "True") - ? "Namespace not yet available on spoke cluster" - : !(has(resources.configmap0) && has(resources.configmap0.conditions)) - ? "ConfigMap not discovered from ManifestWork" - : !resources.configmap0.conditions.exists(c, c.type == "Available" && has(c.status) && c.status == "True") - ? "ConfigMap not yet available on spoke cluster" - : "All manifests (namespace, configmap) are available on spoke cluster" - - # Health: Adapter execution status — surfaces errors from any phase - - type: "Health" - status: - expression: | - adapter.?executionStatus.orValue("") == "success" - && !adapter.?resourcesSkipped.orValue(false) - ? "True" - : "False" - reason: - expression: | - adapter.?executionStatus.orValue("") != "success" - ? "ExecutionFailed:" + adapter.?executionError.?phase.orValue("unknown") - : adapter.?resourcesSkipped.orValue(false) - ? "ResourcesSkipped" - : "Healthy" - message: - expression: | - adapter.?executionStatus.orValue("") != "success" - ? "Adapter failed at phase [" - + adapter.?executionError.?phase.orValue("unknown") - + "] step [" - + adapter.?executionError.?step.orValue("unknown") - + "]: " - + adapter.?executionError.?message.orValue(adapter.?errorMessage.orValue("no details")) - : adapter.?resourcesSkipped.orValue(false) - ? "Resources skipped: " + adapter.?skipReason.orValue("unknown reason") - : "Adapter execution completed successfully" - - observed_generation: - expression: "generation" - observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" - - # Extract data from discovered ManifestWork from Maestro - data: - manifestwork: - name: - expression: | - has(resources.resource0) && has(resources.resource0.metadata) - ? resources.resource0.metadata.name - : "" - consumer: - expression: | - has(resources.resource0) && has(resources.resource0.metadata) - ? resources.resource0.metadata.namespace - : placementClusterName - configmap: - name: - expression: | - has(resources.configmap0) && has(resources.configmap0.metadata) - ? resources.configmap0.metadata.name - : "" - clusterId: - expression: | - has(resources.configmap0) && has(resources.configmap0.data) && has(resources.configmap0.data.cluster_id) - ? resources.configmap0.data.cluster_id - : clusterId - namespace: - name: - expression: | - has(resources.namespace0) && has(resources.namespace0.metadata) - ? resources.namespace0.metadata.name - : "" - phase: - expression: | - has(resources.namespace0) && has(resources.namespace0.statusFeedback) && has(resources.namespace0.statusFeedback.values) - && resources.namespace0.statusFeedback.values.exists(v, has(v.name) && v.name == "phase" && has(v.fieldValue)) - ? resources.namespace0.statusFeedback.values.filter(v, v.name == "phase")[0].fieldValue.string - : "Unknown" - - post_actions: - - name: "reportClusterStatus" - api_call: - method: "PUT" - url: "/clusters/{{ .clusterId }}/statuses" - headers: - - name: "Content-Type" - value: "application/json" - body: "{{ .statusPayload }}" diff --git a/testdata/adapter-configs/cl-m-wrong-nest/values.yaml b/testdata/adapter-configs/cl-m-wrong-nest/values.yaml deleted file mode 100644 index 049a0a4a..00000000 --- a/testdata/adapter-configs/cl-m-wrong-nest/values.yaml +++ /dev/null @@ -1,46 +0,0 @@ -adapterConfig: - create: true - files: - adapter-config.yaml: cl-m-wrong-nest/adapter-config.yaml - log: - level: debug - -adapterTaskConfig: - create: true - files: - task-config.yaml: cl-m-wrong-nest/adapter-task-config.yaml - -broker: - type: ${BROKER_TYPE} - create: true - googlepubsub: - projectId: ${GCP_PROJECT_ID} - subscriptionId: ${NAMESPACE}-clusters-${ADAPTER_NAME} - topic: ${NAMESPACE}-clusters - deadLetterTopic: ${NAMESPACE}-clusters-dlq - createTopicIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_TOPIC_IF_MISSING} - createSubscriptionIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_SUBSCRIPTION_IF_MISSING} - expirationTTL: "1d" - rabbitmq: - url: ${RABBITMQ_URL} - queue: ${NAMESPACE}-clusters-${ADAPTER_NAME} - exchange: ${NAMESPACE}-clusters - routingKey: ${ADAPTER_NAME} - -image: - registry: ${IMAGE_REGISTRY} - repository: ${ADAPTER_IMAGE_REPO} - pullPolicy: Always - tag: ${ADAPTER_IMAGE_TAG} -rbac: - resources: - - namespaces - - configmaps - - configmaps/status - -labels: - e2e.hyperfleet.io/run-id: ${RUN_ID} - -env: - - name: RUN_ID - value: ${RUN_ID} diff --git a/testdata/adapter-configs/cl-param-error.yaml b/testdata/adapter-configs/cl-param-error.yaml new file mode 100644 index 00000000..df252814 --- /dev/null +++ b/testdata/adapter-configs/cl-param-error.yaml @@ -0,0 +1,151 @@ +rbac: + resources: + - namespaces + +adapterConfig: + yaml: + adapter: + name: cl-param-error + + debug_config: false + log: + level: debug + + clients: + hyperfleet_api: + base_url: http://hyperfleet-api:8000 + version: v1 + timeout: 2s + retry_attempts: 3 + retry_backoff: exponential + + broker: + subscription_id: CHANGE_ME + topic: CHANGE_ME + + kubernetes: + api_version: "v1" + +adapterTaskConfig: + yaml: + params: + - name: "clusterId" + source: "event.id" + type: "string" + required: true + - name: "runId" + source: "env.RUN_ID" + type: "string" + required: true + - name: "ci" + source: "env.CI" + type: "string" + required: false + default: "false" + + # API call with INVALID endpoint URL to simulate param extraction failure + - name: "clusterStatus" + required: true + source: + api_call: + method: "GET" + url: "http://invalid-service:8080/api/nonexistent" + timeout: 5s + retry_attempts: 1 + retry_backoff: "exponential" + + - name: "clusterName" + source: "clusterStatus.name" + + # Preconditions — params-phase failure above prevents reaching this + preconditions: [] + + # Resources (will never be reached due to precondition failure) + resources: + - name: "clusterNamespace" + transport: + client: "kubernetes" + manifest: + apiVersion: v1 + kind: Namespace + metadata: + name: "{{ .clusterId }}" + labels: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + e2e.hyperfleet.io/ci: "{{ .ci }}" + e2e.hyperfleet.io/managed-by: "test-framework" + e2e.hyperfleet.io/run-id: "{{ .runId }}" + discovery: + namespace: "*" + by_selectors: + label_selector: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + + # Post-processing with status reporting + post: + payloads: + - name: "clusterStatusPayload" + build: + adapter: "{{ .adapter.name }}" + conditions: + - type: "Applied" + status: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "True" : "False" + reason: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" + ? "NamespaceCreated" + : "NamespacePending" + message: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" + ? "Namespace created successfully" + : "Namespace creation in progress" + - type: "Available" + status: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "True" : "False" + reason: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" + ? "NamespaceReady" + : "NamespaceNotReady" + message: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" + ? "Namespace is active and ready" + : "Namespace is not active and ready" + - type: "Health" + status: + expression: | + adapter.?executionStatus.orValue("") == "success" + && !(adapter.?resourcesSkipped.orValue(false) && adapter.?errorReason.orValue("") != "") + ? "True" : "False" + reason: + expression: | + adapter.?executionStatus.orValue("") != "success" + ? "ExecutionFailed" + : adapter.?errorReason.orValue("") != "" + ? adapter.?errorReason.orValue("") + : "Healthy" + message: + expression: | + adapter.?executionStatus.orValue("") != "success" + ? adapter.?errorMessage.orValue("Adapter execution failed") + : adapter.?errorMessage.orValue("") != "" + ? adapter.?errorMessage.orValue("") + : "All adapter operations completed successfully" + observed_generation: + expression: "1" + observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" + + post_actions: + - name: "reportClusterStatus" + api_call: + method: "PUT" + url: "/clusters/{{ .clusterId }}/statuses" + headers: + - name: "Content-Type" + value: "application/json" + body: "{{ .clusterStatusPayload }}" diff --git a/testdata/adapter-configs/cl-param-error/adapter-config.yaml b/testdata/adapter-configs/cl-param-error/adapter-config.yaml deleted file mode 100644 index 6eb328ce..00000000 --- a/testdata/adapter-configs/cl-param-error/adapter-config.yaml +++ /dev/null @@ -1,21 +0,0 @@ -adapter: - name: cl-param-error - -debug_config: false -log: - level: debug - -clients: - hyperfleet_api: - base_url: http://hyperfleet-api:8000 - version: v1 - timeout: 2s - retry_attempts: 3 - retry_backoff: exponential - - broker: - subscription_id: CHANGE_ME - topic: CHANGE_ME - - kubernetes: - api_version: "v1" diff --git a/testdata/adapter-configs/cl-param-error/adapter-task-config.yaml b/testdata/adapter-configs/cl-param-error/adapter-task-config.yaml deleted file mode 100644 index 68e3fb7d..00000000 --- a/testdata/adapter-configs/cl-param-error/adapter-task-config.yaml +++ /dev/null @@ -1,126 +0,0 @@ -# Test adapter configuration with invalid API URL to test error detection -# The param-phase api_call references a non-existent service, causing the adapter -# to fail before reaching the precondition/resource phases. - -# Parameters with all required variables -params: - - name: "clusterId" - source: "event.id" - type: "string" - required: true - - name: "runId" - source: "env.RUN_ID" - type: "string" - required: true - - name: "ci" - source: "env.CI" - type: "string" - required: false - default: "false" - - # API call with INVALID endpoint URL to simulate param extraction failure - - name: "clusterStatus" - required: true - source: - api_call: - method: "GET" - url: "http://invalid-service:8080/api/nonexistent" - timeout: 5s - retry_attempts: 1 - retry_backoff: "exponential" - - - name: "clusterName" - source: "clusterStatus.name" - -# Preconditions — params-phase failure above prevents reaching this -preconditions: [] - -# Resources (will never be reached due to precondition failure) -resources: - - name: "clusterNamespace" - transport: - client: "kubernetes" - manifest: - apiVersion: v1 - kind: Namespace - metadata: - name: "{{ .clusterId }}" - labels: - hyperfleet.io/cluster-id: "{{ .clusterId }}" - e2e.hyperfleet.io/ci: "{{ .ci }}" - e2e.hyperfleet.io/managed-by: "test-framework" - e2e.hyperfleet.io/run-id: "{{ .runId }}" - discovery: - namespace: "*" - by_selectors: - label_selector: - hyperfleet.io/cluster-id: "{{ .clusterId }}" - -# Post-processing with status reporting -post: - payloads: - - name: "clusterStatusPayload" - build: - adapter: "{{ .adapter.name }}" - conditions: - - type: "Applied" - status: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "True" : "False" - reason: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" - ? "NamespaceCreated" - : "NamespacePending" - message: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" - ? "Namespace created successfully" - : "Namespace creation in progress" - - type: "Available" - status: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "True" : "False" - reason: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" - ? "NamespaceReady" - : "NamespaceNotReady" - message: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" - ? "Namespace is active and ready" - : "Namespace is not active and ready" - - type: "Health" - status: - expression: | - adapter.?executionStatus.orValue("") == "success" - && !(adapter.?resourcesSkipped.orValue(false) && adapter.?errorReason.orValue("") != "") - ? "True" : "False" - reason: - expression: | - adapter.?executionStatus.orValue("") != "success" - ? "ExecutionFailed" - : adapter.?errorReason.orValue("") != "" - ? adapter.?errorReason.orValue("") - : "Healthy" - message: - expression: | - adapter.?executionStatus.orValue("") != "success" - ? adapter.?errorMessage.orValue("Adapter execution failed") - : adapter.?errorMessage.orValue("") != "" - ? adapter.?errorMessage.orValue("") - : "All adapter operations completed successfully" - observed_generation: - expression: "1" - observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" - - post_actions: - - name: "reportClusterStatus" - api_call: - method: "PUT" - url: "/clusters/{{ .clusterId }}/statuses" - headers: - - name: "Content-Type" - value: "application/json" - body: "{{ .clusterStatusPayload }}" diff --git a/testdata/adapter-configs/cl-param-error/values.yaml b/testdata/adapter-configs/cl-param-error/values.yaml deleted file mode 100644 index 25cf5ab1..00000000 --- a/testdata/adapter-configs/cl-param-error/values.yaml +++ /dev/null @@ -1,51 +0,0 @@ -adapterConfig: - create: true - files: - adapter-config.yaml: cl-param-error/adapter-config.yaml - log: - level: debug - -adapterTaskConfig: - create: true - files: - task-config.yaml: cl-param-error/adapter-task-config.yaml - -broker: - type: ${BROKER_TYPE} - create: true - googlepubsub: - projectId: ${GCP_PROJECT_ID} - subscriptionId: ${NAMESPACE}-clusters-${ADAPTER_NAME} - topic: ${NAMESPACE}-clusters - deadLetterTopic: ${NAMESPACE}-clusters-dlq - createTopicIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_TOPIC_IF_MISSING} - createSubscriptionIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_SUBSCRIPTION_IF_MISSING} - expirationTTL: "1d" - rabbitmq: - url: ${RABBITMQ_URL} - queue: ${NAMESPACE}-clusters-${ADAPTER_NAME} - exchange: ${NAMESPACE}-clusters - routingKey: ${ADAPTER_NAME} - -image: - registry: ${IMAGE_REGISTRY} - repository: ${ADAPTER_IMAGE_REPO} - pullPolicy: Always - tag: ${ADAPTER_IMAGE_TAG} - -rbac: - resources: - - namespaces - -labels: - e2e.hyperfleet.io/run-id: ${RUN_ID} - -env: - - name: SIMULATE_RESULT - value: success - - name: NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - - name: RUN_ID - value: ${RUN_ID} diff --git a/testdata/adapter-configs/cl-stuck.yaml b/testdata/adapter-configs/cl-stuck.yaml new file mode 100644 index 00000000..ac9c27d6 --- /dev/null +++ b/testdata/adapter-configs/cl-stuck.yaml @@ -0,0 +1,180 @@ +rbac: + resources: + - namespaces + +adapterConfig: + yaml: + adapter: + name: cl-stuck + + debug_config: false + log: + level: debug + + clients: + hyperfleet_api: + base_url: CHANGE_ME + version: v1 + timeout: 2s + retry_attempts: 3 + retry_backoff: exponential + + broker: + subscription_id: CHANGE_ME + topic: CHANGE_ME + + kubernetes: + api_version: "v1" + +adapterTaskConfig: + yaml: + params: + - name: "clusterId" + source: "event.id" + type: "string" + required: true + - name: "runId" + source: "env.RUN_ID" + type: "string" + required: true + + - name: "clusterStatus" + source: + api_call: + method: "GET" + url: "/clusters/{{ .clusterId }}" + timeout: 10s + retry_attempts: 3 + retry_backoff: "exponential" + + - name: "clusterName" + source: "clusterStatus.name" + + - name: "generationSpec" + source: "clusterStatus.generation" + + - name: "is_deleting" + source: + expression: "clusterStatus.?deleted_time.hasValue()" + + - name: "clusterNotReconciled" + source: + expression: | + clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 + ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].status != "True" + : true + + - name: "clusterReconciledTTL" + source: + expression: | + (timestamp(now()) - timestamp( + clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 + ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].last_transition_time + : now() + )).getSeconds() > 300 + + preconditions: + - name: "validationCheck" + expression: | + is_deleting || clusterNotReconciled || clusterReconciledTTL + + resources: + - name: "clusterNamespace" + transport: + client: "kubernetes" + manifest: + apiVersion: v1 + kind: Namespace + metadata: + name: "{{ .clusterId }}-cl-stuck" + labels: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/cluster-name: "{{ .clusterName }}" + e2e.hyperfleet.io/run-id: "{{ .runId }}" + annotations: + hyperfleet.io/generation: "{{ .generationSpec }}" + discovery: + namespace: "*" + by_selectors: + label_selector: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + lifecycle: + delete: + propagationPolicy: Foreground + when: + expression: "is_deleting" + + post: + payloads: + - name: "clusterStatusPayload" + build: + adapter: "{{ .adapter.name }}" + conditions: + - type: "Applied" + status: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "True" : "False" + reason: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" + ? "NamespaceCreated" + : "NamespacePending" + message: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" + ? "Namespace created successfully" + : "Namespace creation in progress" + - type: "Available" + status: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "True" : "False" + reason: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "NamespaceReady" : "NamespaceNotReady" + message: + expression: | + resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "Namespace is active and ready" : "Namespace is not active and ready" + - type: "Health" + status: + expression: | + adapter.?executionStatus.orValue("") == "success" ? "True" : "False" + reason: + expression: | + adapter.?errorReason.orValue("") != "" ? adapter.?errorReason.orValue("") : "Healthy" + message: + expression: | + adapter.?errorMessage.orValue("") != "" ? adapter.?errorMessage.orValue("") : "All adapter operations in progress or completed successfully" + - type: "Finalized" + status: + expression: | + is_deleting + && adapter.?executionStatus.orValue("") == "success" + && !adapter.?resourcesSkipped.orValue(false) + && !resources.?clusterNamespace.hasValue() + ? "True" + : "False" + reason: + expression: | + !is_deleting ? "NotDeleting" + : !resources.?clusterNamespace.hasValue() + ? "CleanupConfirmed" + : "CleanupInProgress" + message: + expression: | + !is_deleting ? "Resource not marked for deletion" + : !resources.?clusterNamespace.hasValue() + ? "All resources deleted; cleanup confirmed" + : "Deletion in progress; waiting for namespace to be removed" + observed_generation: + expression: "generationSpec" + observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" + + post_actions: + - name: "reportClusterStatus" + api_call: + method: "PUT" + url: "/clusters/{{ .clusterId }}/statuses" + headers: + - name: "Content-Type" + value: "application/json" + body: "{{ .clusterStatusPayload }}" diff --git a/testdata/adapter-configs/cl-stuck/adapter-config.yaml b/testdata/adapter-configs/cl-stuck/adapter-config.yaml deleted file mode 100644 index 8977fa7c..00000000 --- a/testdata/adapter-configs/cl-stuck/adapter-config.yaml +++ /dev/null @@ -1,21 +0,0 @@ -adapter: - name: cl-stuck - -debug_config: false -log: - level: debug - -clients: - hyperfleet_api: - base_url: CHANGE_ME - version: v1 - timeout: 2s - retry_attempts: 3 - retry_backoff: exponential - - broker: - subscription_id: CHANGE_ME - topic: CHANGE_ME - - kubernetes: - api_version: "v1" diff --git a/testdata/adapter-configs/cl-stuck/adapter-task-config.yaml b/testdata/adapter-configs/cl-stuck/adapter-task-config.yaml deleted file mode 100644 index 6e0b91d8..00000000 --- a/testdata/adapter-configs/cl-stuck/adapter-task-config.yaml +++ /dev/null @@ -1,153 +0,0 @@ -# Minimal adapter task config for stuck deletion testing -# Creates a namespace as the only resource - simple and fast to verify - -params: - - name: "clusterId" - source: "event.id" - type: "string" - required: true - - name: "runId" - source: "env.RUN_ID" - type: "string" - required: true - - - name: "clusterStatus" - source: - api_call: - method: "GET" - url: "/clusters/{{ .clusterId }}" - timeout: 10s - retry_attempts: 3 - retry_backoff: "exponential" - - - name: "clusterName" - source: "clusterStatus.name" - - - name: "generationSpec" - source: "clusterStatus.generation" - - - name: "is_deleting" - source: - expression: "clusterStatus.?deleted_time.hasValue()" - - - name: "clusterNotReconciled" - source: - expression: | - clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 - ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].status != "True" - : true - - - name: "clusterReconciledTTL" - source: - expression: | - (timestamp(now()) - timestamp( - clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled").size() > 0 - ? clusterStatus.?status.?conditions.orValue([]).filter(c, c.type == "Reconciled")[0].last_transition_time - : now() - )).getSeconds() > 300 - -preconditions: - - name: "validationCheck" - expression: | - is_deleting || clusterNotReconciled || clusterReconciledTTL - -resources: - - name: "clusterNamespace" - transport: - client: "kubernetes" - manifest: - apiVersion: v1 - kind: Namespace - metadata: - name: "{{ .clusterId }}-cl-stuck" - labels: - hyperfleet.io/cluster-id: "{{ .clusterId }}" - hyperfleet.io/cluster-name: "{{ .clusterName }}" - e2e.hyperfleet.io/run-id: "{{ .runId }}" - annotations: - hyperfleet.io/generation: "{{ .generationSpec }}" - discovery: - namespace: "*" - by_selectors: - label_selector: - hyperfleet.io/cluster-id: "{{ .clusterId }}" - lifecycle: - delete: - propagationPolicy: Foreground - when: - expression: "is_deleting" - -post: - payloads: - - name: "clusterStatusPayload" - build: - adapter: "{{ .adapter.name }}" - conditions: - - type: "Applied" - status: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "True" : "False" - reason: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" - ? "NamespaceCreated" - : "NamespacePending" - message: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" - ? "Namespace created successfully" - : "Namespace creation in progress" - - type: "Available" - status: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "True" : "False" - reason: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "NamespaceReady" : "NamespaceNotReady" - message: - expression: | - resources.?clusterNamespace.?status.?phase.orValue("") == "Active" ? "Namespace is active and ready" : "Namespace is not active and ready" - - type: "Health" - status: - expression: | - adapter.?executionStatus.orValue("") == "success" ? "True" : "False" - reason: - expression: | - adapter.?errorReason.orValue("") != "" ? adapter.?errorReason.orValue("") : "Healthy" - message: - expression: | - adapter.?errorMessage.orValue("") != "" ? adapter.?errorMessage.orValue("") : "All adapter operations in progress or completed successfully" - - type: "Finalized" - status: - expression: | - is_deleting - && adapter.?executionStatus.orValue("") == "success" - && !adapter.?resourcesSkipped.orValue(false) - && !resources.?clusterNamespace.hasValue() - ? "True" - : "False" - reason: - expression: | - !is_deleting ? "NotDeleting" - : !resources.?clusterNamespace.hasValue() - ? "CleanupConfirmed" - : "CleanupInProgress" - message: - expression: | - !is_deleting ? "Resource not marked for deletion" - : !resources.?clusterNamespace.hasValue() - ? "All resources deleted; cleanup confirmed" - : "Deletion in progress; waiting for namespace to be removed" - observed_generation: - expression: "generationSpec" - observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" - - post_actions: - - name: "reportClusterStatus" - api_call: - method: "PUT" - url: "/clusters/{{ .clusterId }}/statuses" - headers: - - name: "Content-Type" - value: "application/json" - body: "{{ .clusterStatusPayload }}" diff --git a/testdata/adapter-configs/cl-stuck/values.yaml b/testdata/adapter-configs/cl-stuck/values.yaml deleted file mode 100644 index 3417a9c1..00000000 --- a/testdata/adapter-configs/cl-stuck/values.yaml +++ /dev/null @@ -1,45 +0,0 @@ -adapterConfig: - create: true - files: - adapter-config.yaml: cl-stuck/adapter-config.yaml - log: - level: debug - -adapterTaskConfig: - create: true - files: - task-config.yaml: cl-stuck/adapter-task-config.yaml - -broker: - type: ${BROKER_TYPE} - create: true - googlepubsub: - projectId: ${GCP_PROJECT_ID} - subscriptionId: ${NAMESPACE}-clusters-${ADAPTER_NAME} - topic: ${NAMESPACE}-clusters - deadLetterTopic: ${NAMESPACE}-clusters-dlq - createTopicIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_TOPIC_IF_MISSING} - createSubscriptionIfMissing: ${ADAPTER_GOOGLEPUBSUB_CREATE_SUBSCRIPTION_IF_MISSING} - expirationTTL: "1d" - rabbitmq: - url: ${RABBITMQ_URL} - queue: ${NAMESPACE}-clusters-${ADAPTER_NAME} - exchange: ${NAMESPACE}-clusters - routingKey: ${ADAPTER_NAME} - -image: - registry: ${IMAGE_REGISTRY} - repository: ${ADAPTER_IMAGE_REPO} - pullPolicy: Always - tag: ${ADAPTER_IMAGE_TAG} - -rbac: - resources: - - namespaces - -labels: - e2e.hyperfleet.io/run-id: ${RUN_ID} - -env: - - name: RUN_ID - value: ${RUN_ID} diff --git a/testdata/adapter-configs/clusters-base.tmpl b/testdata/adapter-configs/clusters-base.tmpl new file mode 100644 index 00000000..52c82dca --- /dev/null +++ b/testdata/adapter-configs/clusters-base.tmpl @@ -0,0 +1,28 @@ +broker: + type: {{ .BrokerType | quote }} + create: true + googlepubsub: + projectId: {{ .ProjectId | quote }} + subscriptionId: {{ .Namespace }}-clusters-{{ .AdapterName | quote }} + topic: {{ .Namespace }}-clusters | quote }} + deadLetterTopic: {{ .Namespace }}-clusters-dlq | quote }} + createTopicIfMissing: {{ .AdapterGooglepubsubCreateTopicIfMissing }} + createSubscriptionIfMissing: {{ .AdapterGooglepubsubCreateSubscriptionIfMissing }} + rabbitmq: + url: {{ .RabbitmqUrl | quote }} + queue: {{ .Namespace }}-clusters-{{ .AdapterName | quote }} + exchange: {{ .Namespace }}-clusters | quote }} + routingKey: {{ .AdapterName | quote }} + +image: + registry: {{ .ImageRegistry | quote }} + repository: {{ .AdapterImageRepo | quote}} + pullPolicy: {{ .ImagePullPolicy }} + tag: {{ .AdapterImageTag | quote }} + +labels: + e2e.hyperfleet.io/run-id: {{ .RunId | quote}} + +env: + - name: RUN_ID + value: {{ .RunId | quote }}