diff --git a/.gitignore b/.gitignore index 9b03930..05a6358 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ tmp +external-crds # Binaries for programs and plugins *.exe diff --git a/Taskfile.yaml b/Taskfile.yaml index 98ac42c..f803004 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -4,6 +4,23 @@ includes: shared: taskfile: hack/common/Taskfile_controller.yaml flatten: true + excludes: # put task names in here which are overwritten in this file + - generate:code + vars: + NESTED_MODULES: "" + API_DIRS: "{{.ROOT_DIR}}/api/..." + MANIFEST_OUT: "{{.ROOT_DIR}}/api/crds/manifests" + CODE_DIRS: "{{.ROOT_DIR}}/cmd/... {{.ROOT_DIR}}/internal/... {{.ROOT_DIR}}/api/..." + COMPONENTS: "usage-operator" + REPO_URL: "https://github.com/openmcp-project/usage-operator" + GENERATE_DOCS_INDEX: "true" + CHART_COMPONENTS: "[]" + ENVTEST_REQUIRED: "true" + common: # imported a second time so that overwriting task definitions can call the overwritten task with a 'c:' prefix + taskfile: hack/common/Taskfile_controller.yaml + internal: true + aliases: + - c excludes: [] # put task names in here which are overwritten in this file vars: NESTED_MODULES: "" @@ -15,3 +32,22 @@ includes: GENERATE_DOCS_INDEX: "true" CHART_COMPONENTS: "[]" ENVTEST_REQUIRED: "true" + +tasks: + generate:code: # overwrites shared code task to add external API fetching + desc: " Generate code (mainly DeepCopy functions) and fetches external APIs." + aliases: + - gen:code + - g:code + run: once + cmds: + - task: download-crds + - task: c:generate:code + + download-crds: + internal: true + cmds: + - curl -s https://api.github.com/repos/openmcp-project/mcp-operator/contents/api/crds/manifests | jq -r '.[] | select(.type=="file") | .download_url' | xargs -n 1 curl -s -O -J + - curl -s https://api.github.com/repos/openmcp-project/project-workspace-operator/contents/api/crds/manifests | jq -r '.[] | select(.type=="file") | .download_url' | xargs -n 1 curl -s -O -J + dir: external-crds + desc: "Download CRD files from mcp-operator GitHub repository" diff --git a/api/crds/manifests/usage.openmcp.cloud_mcpusages.yaml b/api/crds/manifests/usage.openmcp.cloud_mcpusages.yaml index 7464e4a..c33130b 100644 --- a/api/crds/manifests/usage.openmcp.cloud_mcpusages.yaml +++ b/api/crds/manifests/usage.openmcp.cloud_mcpusages.yaml @@ -87,7 +87,6 @@ spec: type: string required: - charging_target - - daily_usage - mcp - project - workspace diff --git a/api/usage/v1/mcpusage_types.go b/api/usage/v1/mcpusage_types.go index 078e82f..76a33be 100644 --- a/api/usage/v1/mcpusage_types.go +++ b/api/usage/v1/mcpusage_types.go @@ -32,7 +32,7 @@ type MCPUsageSpec struct { Project string `json:"project"` Workspace string `json:"workspace"` MCP string `json:"mcp"` - Usage []DailyUsage `json:"daily_usage"` + Usage []DailyUsage `json:"daily_usage,omitempty"` LastUsageCaptured metav1.Time `json:"last_usage_captured,omitempty"` MCPCreatedAt metav1.Time `json:"mcp_created_at,omitempty"` MCPDeletedAt metav1.Time `json:"mcp_deleted_at,omitempty"` diff --git a/cmd/usage-operator/app/run.go b/cmd/usage-operator/app/run.go index 4b3a8b9..80d2584 100644 --- a/cmd/usage-operator/app/run.go +++ b/cmd/usage-operator/app/run.go @@ -275,7 +275,7 @@ func (o *RunOptions) Run(ctx context.Context) error { return fmt.Errorf("unable to create manager: %w", err) } - usageTracker, err := usage.NewUsageTracker(&o.Log, mgr.GetClient()) + usageTracker, err := usage.NewUsageTracker(mgr.GetClient()) if err != nil { return fmt.Errorf("unable to create usage tracker: %w", err) } diff --git a/go.mod b/go.mod index 73614ac..ff43583 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/openmcp-project/usage-operator go 1.24.4 require ( + github.com/go-logr/logr v1.4.3 github.com/google/uuid v1.6.0 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.37.0 @@ -33,7 +34,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect diff --git a/internal/controller/managedcontrolplane_controller.go b/internal/controller/managedcontrolplane_controller.go index 4fe794e..12cc79c 100644 --- a/internal/controller/managedcontrolplane_controller.go +++ b/internal/controller/managedcontrolplane_controller.go @@ -21,7 +21,7 @@ import ( "errors" "regexp" - "github.com/openmcp-project/controller-utils/pkg/logging" + "github.com/go-logr/logr" corev1alpha1 "github.com/openmcp-project/mcp-operator/api/core/v1alpha1" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" @@ -52,7 +52,7 @@ type ManagedControlPlaneReconciler struct { // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/reconcile func (r *ManagedControlPlaneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - log, err := logging.FromContext(ctx) + log, err := logr.FromContext(ctx) if err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } @@ -75,10 +75,10 @@ func (r *ManagedControlPlaneReconciler) Reconcile(ctx context.Context, req ctrl. project := matches[1] workspace := matches[2] - log.Info("mcp '" + mcp.Name + "' status '" + string(mcp.Status.Status) + "'") + log.Info("reconcile", "mcp", mcp.Name, "status", string(mcp.Status.Status)) if mcp.GetDeletionTimestamp() != nil || mcp.Status.Status == corev1alpha1.MCPStatusDeleting { - log.Info("mcp '" + mcp.Name + "' was deleted. Tracking it...") + log.Info("mcp was deleted", "mcp", mcp.Name) err := r.UsageTracker.DeletionEvent(ctx, project, workspace, mcp.Name) if err != nil { log.Error(err, "error when tracking deletion") diff --git a/internal/controller/managedcontrolplane_controller_test.go b/internal/controller/managedcontrolplane_controller_test.go index cd9d348..20b9ee3 100644 --- a/internal/controller/managedcontrolplane_controller_test.go +++ b/internal/controller/managedcontrolplane_controller_test.go @@ -17,16 +17,141 @@ limitations under the License. package controller import ( + "context" + "strings" + "time" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + corev1alpha1 "github.com/openmcp-project/mcp-operator/api/core/v1alpha1" + pwcorev1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + + v1 "github.com/openmcp-project/usage-operator/api/usage/v1" +) + +const ( + ProjectName = "project" + WorkspaceName = "workspace" + MCPName = "test-mcp" + + ChargingTarget = "12345678" + + timeout = time.Second * 10 + duration = time.Second * 10 + interval = time.Millisecond * 250 ) -var _ = Describe("ManagedControlPlane Controller", func() { +var ( + projectNamespaceName string + workspaceNamespaceName string + + mcpUsageName string +) + +var _ = Describe("ManagedControlPlane Controller", Ordered, func() { Context("When reconciling a resource", func() { + BeforeAll(func() { + ctx := context.Background() + projectNamespaceName = "project-" + ProjectName + workspaceNamespaceName = projectNamespaceName + "--ws-" + WorkspaceName + namespaces := []corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: projectNamespaceName, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceNamespaceName, + }, + }, + } + Expect(k8sClient.Create(ctx, &namespaces[0])).To(Succeed()) + Expect(k8sClient.Create(ctx, &namespaces[1])).To(Succeed()) + + project := pwcorev1alpha1.Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: ProjectName, + Labels: map[string]string{ + "openmcp.cloud.sap/charging-target": ChargingTarget, + }, + }, + } + Expect(k8sClient.Create(ctx, &project)).To(Succeed()) + workspace := pwcorev1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: WorkspaceName, + Namespace: projectNamespaceName, + }, + } + Expect(k8sClient.Create(ctx, &workspace)).To(Succeed()) + mcp := corev1alpha1.ManagedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: MCPName, + Namespace: workspaceNamespaceName, + }, + } + Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + }) + + It("should create a mcp usage resource based on a ManagedControlPlane resource", func() { + ctx := context.Background() + + var mcpUsages v1.MCPUsageList + Eventually(func(g Gomega) { + g.Expect(k8sClient.List(ctx, &mcpUsages)).To(Succeed()) + + g.Expect(mcpUsages.Items).Should(HaveLen(1)) + + mcpUsageName = mcpUsages.Items[0].Name + + g.Expect(mcpUsages.Items[0].Spec.ChargingTarget).Should(Equal(ChargingTarget)) + }, timeout, interval).Should(Succeed()) + }) + + It("should have set the right charging target", func() { + ctx := context.Background() + + mcpUsage := v1.MCPUsage{ + ObjectMeta: metav1.ObjectMeta{ + Name: mcpUsageName, + }, + } + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcpUsage), &mcpUsage)).Should(Succeed()) + + Expect(mcpUsage.Spec.ChargingTarget).Should(Equal(ChargingTarget)) + }) + + It("should mark a mcp usage resource as deleted when ManagedControlPlane is deleted", func() { + ctx := context.Background() + + mcpUsage := v1.MCPUsage{ + ObjectMeta: metav1.ObjectMeta{ + Name: mcpUsageName, + }, + } + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcpUsage), &mcpUsage)).Should(Succeed()) + + var mcp corev1alpha1.ManagedControlPlane + Expect(k8sClient.Get(ctx, client.ObjectKey{ + Name: strings.ToLower(MCPName), + Namespace: workspaceNamespaceName, + }, &mcp)).Should(Succeed()) + mcp.Status.Status = corev1alpha1.MCPStatusDeleting + Expect(k8sClient.Status().Update(ctx, &mcp)).Should(Succeed()) + + var mcpUsages v1.MCPUsageList + Eventually(func(g Gomega) { + g.Expect(k8sClient.List(ctx, &mcpUsages)).To(Succeed()) - It("should successfully reconcile the resource", func() { + g.Expect(mcpUsages.Items).Should(HaveLen(1)) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. + g.Expect(mcpUsages.Items[0].Spec.MCPDeletedAt.IsZero()).Should(BeFalse()) + }, timeout, interval).Should(Succeed()) }) }) }) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index f1f5173..baa657e 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -22,16 +22,23 @@ import ( "path/filepath" "testing" + ctrl "sigs.k8s.io/controller-runtime" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1alpha1 "github.com/openmcp-project/mcp-operator/api/core/v1alpha1" + pwcorev1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" + + v1 "github.com/openmcp-project/usage-operator/api/usage/v1" + "github.com/openmcp-project/usage-operator/internal/usage" // +kubebuilder:scaffold:imports ) @@ -60,12 +67,21 @@ var _ = BeforeSuite(func() { var err error err = corev1alpha1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) + err = pwcorev1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + err = v1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) // +kubebuilder:scaffold:scheme By("bootstrapping test environment") testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "api", "crds", "manifests"), + // Add external CRD directories here: + filepath.Join("..", "..", "external-crds"), + // Add more paths as needed + }, ErrorIfCRDPathMissing: false, } @@ -82,6 +98,27 @@ var _ = BeforeSuite(func() { k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) Expect(err).NotTo(HaveOccurred()) Expect(k8sClient).NotTo(BeNil()) + + k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + }) + Expect(err).ToNot(HaveOccurred()) + + usageTracker, err := usage.NewUsageTracker(k8sClient) + Expect(err).NotTo(HaveOccurred()) + + err = (&ManagedControlPlaneReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + UsageTracker: usageTracker, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + + go func() { + defer GinkgoRecover() + err = k8sManager.Start(ctx) + Expect(err).ToNot(HaveOccurred(), "failed to run manager") + }() }) var _ = AfterSuite(func() { diff --git a/internal/helper/chargingtarget_test.go b/internal/helper/chargingtarget_test.go new file mode 100644 index 0000000..aa7db86 --- /dev/null +++ b/internal/helper/chargingtarget_test.go @@ -0,0 +1,126 @@ +package helper + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1alpha1 "github.com/openmcp-project/mcp-operator/api/core/v1alpha1" + pwcorev1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + ProjectName = "project" + WorkspaceName = "workspace" + MCPName = "test-mcp" + + ChargingTargetLabelKey = "openmcp.cloud.sap/charging-target" + ChargingTarget = "12345678" +) + +var ( + projectNamespaceName string + workspaceNamespaceName string +) + +var _ = Describe("Charging Target Resolver", Ordered, func() { + BeforeAll(func() { + ctx := context.Background() + projectNamespaceName = "project-" + ProjectName + workspaceNamespaceName = projectNamespaceName + "--ws-" + WorkspaceName + namespaces := []corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: projectNamespaceName, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceNamespaceName, + }, + }, + } + Expect(k8sClient.Create(ctx, &namespaces[0])).To(Succeed()) + Expect(k8sClient.Create(ctx, &namespaces[1])).To(Succeed()) + + project := pwcorev1alpha1.Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: ProjectName, + Labels: map[string]string{ + ChargingTargetLabelKey: ChargingTarget, + }, + }, + } + Expect(k8sClient.Create(ctx, &project)).To(Succeed()) + workspace := pwcorev1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: WorkspaceName, + Namespace: projectNamespaceName, + }, + } + Expect(k8sClient.Create(ctx, &workspace)).To(Succeed()) + mcp := corev1alpha1.ManagedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: MCPName, + Namespace: workspaceNamespaceName, + }, + } + Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + }) + + It("Should resolve the charging target", func() { + ctx := context.Background() + resolvedChargingTarget, err := ResolveChargingTarget(ctx, k8sClient, ProjectName, WorkspaceName, MCPName) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(resolvedChargingTarget).Should(Equal(ChargingTarget)) + }) + + It("Should resolve the workspace charging target, if set", func() { + ctx := context.Background() + + workspace := pwcorev1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: WorkspaceName, + Namespace: projectNamespaceName, + }, + } + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&workspace), &workspace)).Should(Succeed()) + + workspace.SetLabels(map[string]string{ + ChargingTargetLabelKey: "9876543", + }) + Expect(k8sClient.Update(ctx, &workspace)).Should(Succeed()) + + resolvedChargingTarget, err := ResolveChargingTarget(ctx, k8sClient, ProjectName, WorkspaceName, MCPName) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(resolvedChargingTarget).Should(Equal("9876543")) + }) + + It("Should resolve the mcp charging target, if set", func() { + ctx := context.Background() + + mcp := corev1alpha1.ManagedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: MCPName, + Namespace: workspaceNamespaceName, + }, + } + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).Should(Succeed()) + + mcp.SetLabels(map[string]string{ + ChargingTargetLabelKey: "14689283", + }) + Expect(k8sClient.Update(ctx, &mcp)).Should(Succeed()) + + resolvedChargingTarget, err := ResolveChargingTarget(ctx, k8sClient, ProjectName, WorkspaceName, MCPName) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(resolvedChargingTarget).Should(Equal("14689283")) + }) +}) diff --git a/internal/helper/suite_test.go b/internal/helper/suite_test.go new file mode 100644 index 0000000..82a9ef8 --- /dev/null +++ b/internal/helper/suite_test.go @@ -0,0 +1,124 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helper + +import ( + "context" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1alpha1 "github.com/openmcp-project/mcp-operator/api/core/v1alpha1" + pwcorev1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + // +kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + ctx context.Context + cancel context.CancelFunc + testEnv *envtest.Environment + cfg *rest.Config + k8sClient client.Client +) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Helper Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + err = corev1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + err = pwcorev1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "api", "crds", "manifests"), + // Add external CRD directories here: + filepath.Join("..", "..", "external-crds"), + // Add more paths as needed + }, + ErrorIfCRDPathMissing: false, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if getFirstFoundEnvTestBinaryDir() != "" { + testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() + } + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. +// ENVTEST-based tests depend on specific binaries, usually located in paths set by +// controller-runtime. When running tests directly (e.g., via an IDE) without using +// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. +// +// This function streamlines the process by finding the required binaries, similar to +// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are +// properly set up, run 'make setup-envtest' beforehand. +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} diff --git a/internal/usage/helper.go b/internal/usage/helper.go index b273061..7a4846e 100644 --- a/internal/usage/helper.go +++ b/internal/usage/helper.go @@ -94,8 +94,6 @@ func MergeDailyUsages(a []v1.DailyUsage, b []v1.DailyUsage) []v1.DailyUsage { for dateStr, totalUsage := range aggregatedUsage { t, err := time.Parse("2006-01-02", dateStr) if err != nil { - // This error should ideally not happen if dateKey is always valid. - // In a real application, you might log this or handle it more robustly. continue } mergedList = append(mergedList, v1.DailyUsage{ diff --git a/internal/usage/helper_test.go b/internal/usage/helper_test.go index fb7c78d..4f5f232 100644 --- a/internal/usage/helper_test.go +++ b/internal/usage/helper_test.go @@ -5,6 +5,9 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1 "github.com/openmcp-project/usage-operator/api/usage/v1" ) var _ = Describe("Helper Module", func() { @@ -21,16 +24,69 @@ var _ = Describe("Helper Module", func() { result := calculateUsage(start, end) - Ω(result).Should(HaveLen(8)) + Expect(result).Should(HaveLen(8)) - Ω(result[0].Date.Time.Equal(endDate)).Should(BeTrue(), "first date must equal end date") - Ω(result[0].Usage.Duration).Should(Equal(firstDayDuration), "first day must have the right duration") + Expect(result[0].Date.Time.Equal(endDate)).Should(BeTrue(), "first date must equal end date") + Expect(result[0].Usage.Duration).Should(Equal(firstDayDuration), "first day must have the right duration") for _, usage := range result[1:] { - Ω(usage.Usage.Duration).Should(Equal(24 * time.Hour)) + Expect(usage.Usage.Duration).Should(Equal(24 * time.Hour)) } reversed := calculateUsage(end, start) - Ω(result).Should(Equal(reversed), "the calculation must be reversed the same") + Expect(result).Should(Equal(reversed), "the calculation must be reversed the same") + }) + + It("should merge dailyusage", func() { + dailyUsage1 := []v1.DailyUsage{ + { + Date: metav1.NewTime(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)), + Usage: metav1.Duration{ + Duration: 4 * time.Hour, + }, + }, + { + Date: metav1.NewTime(time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)), + Usage: metav1.Duration{ + Duration: 4 * time.Hour, + }, + }, + } + + dailyUsage2 := []v1.DailyUsage{ + { + Date: metav1.NewTime(time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)), + Usage: metav1.Duration{ + Duration: 20 * time.Hour, + }, + }, + { + Date: metav1.NewTime(time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC)), + Usage: metav1.Duration{ + Duration: 8 * time.Hour, + }, + }, + } + + mergedUsages := MergeDailyUsages(dailyUsage1, dailyUsage2) + + Expect(mergedUsages).Should(HaveLen(3)) + + Expect(mergedUsages[1].Date).Should(Equal(metav1.NewTime(time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)))) + Expect(mergedUsages[1].Usage.Hours()).Should(Equal(24.0)) + }) + }) + Context("ObjectKey Generation", func() { + It("should generate the same objectkey with the same input", func() { + project := "Testproject" + workspace := "Testworkspace" + mcp := "Test" + + key1, err := GetObjectKey(project, workspace, mcp) + Expect(err).ShouldNot(HaveOccurred()) + key2, err := GetObjectKey(project, workspace, mcp) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(key1.Name).Should(Equal(key2.Name)) }) }) }) diff --git a/internal/usage/suite_test.go b/internal/usage/suite_test.go index 77f4d63..3cc12d6 100644 --- a/internal/usage/suite_test.go +++ b/internal/usage/suite_test.go @@ -2,24 +2,39 @@ package usage import ( "context" + "os" + "path/filepath" "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + corev1alpha1 "github.com/openmcp-project/mcp-operator/api/core/v1alpha1" + pwcorev1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + + v1 "github.com/openmcp-project/usage-operator/api/usage/v1" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" + // +kubebuilder:scaffold:imports ) var ( - ctx context.Context - cancel context.CancelFunc + ctx context.Context + cancel context.CancelFunc + testEnv *envtest.Environment + cfg *rest.Config + k8sClient client.Client ) func TestUsageModule(t *testing.T) { RegisterFailHandler(Fail) - RunSpecs(t, "Usage Module") + RunSpecs(t, "Tracking Module") } var _ = BeforeSuite(func() { @@ -27,8 +42,68 @@ var _ = BeforeSuite(func() { ctx, cancel = context.WithCancel(context.TODO()) + var err error + err = corev1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + err = pwcorev1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + err = v1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "api", "crds", "manifests"), + // Add external CRD directories here: + filepath.Join("..", "..", "external-crds"), + // Add more paths as needed + }, + ErrorIfCRDPathMissing: false, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if getFirstFoundEnvTestBinaryDir() != "" { + testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() + } + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) }) var _ = AfterSuite(func() { + By("tearing down the test environment") cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) }) + +// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. +// ENVTEST-based tests depend on specific binaries, usually located in paths set by +// controller-runtime. When running tests directly (e.g., via an IDE) without using +// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. +// +// This function streamlines the process by finding the required binaries, similar to +// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are +// properly set up, run 'make setup-envtest' beforehand. +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} diff --git a/internal/usage/tracking.go b/internal/usage/tracking.go index ddc1c60..98d730c 100644 --- a/internal/usage/tracking.go +++ b/internal/usage/tracking.go @@ -12,7 +12,8 @@ import ( "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/openmcp-project/controller-utils/pkg/logging" + "github.com/go-logr/logr" + logf "sigs.k8s.io/controller-runtime/pkg/log" v1 "github.com/openmcp-project/usage-operator/api/usage/v1" "github.com/openmcp-project/usage-operator/internal/helper" @@ -20,19 +21,18 @@ import ( type UsageTracker struct { client client.Client - log *logging.Logger } -func NewUsageTracker(log *logging.Logger, client client.Client) (*UsageTracker, error) { +func NewUsageTracker(client client.Client) (*UsageTracker, error) { return &UsageTracker{ - log: log, client: client, }, nil - } -func (u *UsageTracker) initLogger(name, project, workspace, mcp_name string) logging.Logger { - return u.log.WithName("tracker-"+name).WithValues( +func (u *UsageTracker) initLogger(ctx context.Context, name, project, workspace, mcp_name string) logr.Logger { + log := logf.FromContext(ctx) + + return log.WithName(name).WithValues( "project", project, "workspace", workspace, "mcp", mcp_name, @@ -40,7 +40,7 @@ func (u *UsageTracker) initLogger(name, project, workspace, mcp_name string) log } func (u *UsageTracker) CreateOrUpdateEvent(ctx context.Context, project string, workspace string, mcp_name string) error { - log := u.initLogger("creation-update", project, workspace, mcp_name) + log := u.initLogger(ctx, "creation-update", project, workspace, mcp_name) objectKey, err := GetObjectKey(project, workspace, mcp_name) if err != nil { @@ -57,7 +57,7 @@ func (u *UsageTracker) CreateOrUpdateEvent(ctx context.Context, project string, mcpUsage.Name = objectKey.Name if k8serrors.IsNotFound(err) { // element does not exist, we need to create it - log.Debug("no mcp usage element found. Creating a new one", "objectKey", objectKey) + log.Info("no mcp usage element found. Creating a new one", "objectKey", objectKey) now := metav1.NewTime(time.Now().UTC()) mcpUsage = v1.MCPUsage{ @@ -82,20 +82,20 @@ func (u *UsageTracker) CreateOrUpdateEvent(ctx context.Context, project string, } else { // check if mcpUsage element wants to be deleted if !mcpUsage.Spec.MCPDeletedAt.IsZero() { - log.Debug("mcp was deleted in the past, update last usage captured and proceed") + log.Info("mcp was deleted in the past, update last usage captured and proceed") // MCP was deleted, now created with the same name, update lastUsageCapture mcpUsage.Spec.LastUsageCaptured = metav1.NewTime(time.Now().UTC()) err = u.client.Update(ctx, &mcpUsage) if err != nil { if k8serrors.IsConflict(err) { - fmt.Printf("Conflict detected for McpUsage %s, retrying...\n", mcpUsage.Name) + log.Info("conflict detected when updating resource", "MCPUsage", mcpUsage.Name) return err } return fmt.Errorf("error when updating status for MCPUsage resource: %w", err) } } else { // event was fired one time to much? do nothing and return later - log.Debug("create or update event was fired again but MCPUsage is already valid, ignore it") + log.Info("create or update event was fired again but MCPUsage is already valid, ignore it") } } @@ -107,7 +107,7 @@ func (u *UsageTracker) CreateOrUpdateEvent(ctx context.Context, project string, return fmt.Errorf("error when updating mcp usage resource: %w", err) } - log.Debug("update charging target for mcpusage element") + log.Info("update charging target for mcpusage element") // ALWAYS: Check charging target and override it to make sure always the latest charging target is there. err = u.UpdateChargingTarget(ctx, project, workspace, mcp_name) if err != nil { @@ -118,7 +118,8 @@ func (u *UsageTracker) CreateOrUpdateEvent(ctx context.Context, project string, } func (u *UsageTracker) UpdateChargingTarget(ctx context.Context, project string, workspace string, mcp_name string) error { - log := u.initLogger("update-charging-target", project, workspace, mcp_name) + log := u.initLogger(ctx, "charging_target", project, workspace, mcp_name) + objectKey, err := GetObjectKey(project, workspace, mcp_name) if err != nil { return fmt.Errorf("error getting object key: %w", err) @@ -146,7 +147,7 @@ func (u *UsageTracker) UpdateChargingTarget(ctx context.Context, project string, err = u.client.Update(ctx, &mcpUsage) if err != nil { if k8serrors.IsConflict(err) { - fmt.Printf("Conflict detected for McpUsage %s, retrying...\n", mcpUsage.Name) + log.Info("Conflict detected for MCPUsage, retrying...", "MCPUsageName", mcpUsage.Name) return err } return fmt.Errorf("error at updating MCPUsage status resource for %s %s %s: %w", project, workspace, mcp_name, err) @@ -159,7 +160,7 @@ func (u *UsageTracker) UpdateChargingTarget(ctx context.Context, project string, } func (u *UsageTracker) DeletionEvent(ctx context.Context, project string, workspace string, mcp_name string) error { - _ = u.initLogger("deletion", project, workspace, mcp_name) + _ = u.initLogger(ctx, "deletion", project, workspace, mcp_name) objectKey, err := GetObjectKey(project, workspace, mcp_name) if err != nil { @@ -187,7 +188,8 @@ func (u *UsageTracker) DeletionEvent(ctx context.Context, project string, worksp } func (u *UsageTracker) ScheduledEvent(ctx context.Context) error { - log := u.log.WithName("scheduled") + log := logf.FromContext(ctx).WithName("scheduled") + var mcpUsages v1.MCPUsageList err := u.client.List(ctx, &mcpUsages) if err != nil { @@ -226,7 +228,7 @@ func (u *UsageTracker) ScheduledEvent(ctx context.Context) error { err = u.client.Update(ctx, &mcpUsage) if err != nil { if k8serrors.IsConflict(err) { - fmt.Printf("Conflict detected for McpUsage %s, retrying...\n", mcpUsage.Name) + log.Error(err, "Conflict detected for McpUsage, retrying...\n", "mcpUsage", mcpUsage.Name) return err } return fmt.Errorf("failed to update McpUsage %s: %w", mcpUsage.Name, err) @@ -234,6 +236,10 @@ func (u *UsageTracker) ScheduledEvent(ctx context.Context) error { return nil }) + + if err != nil { + errs = errors.Join(errs, err) + } } if errs != nil { @@ -244,7 +250,7 @@ func (u *UsageTracker) ScheduledEvent(ctx context.Context) error { } func (u *UsageTracker) GarbageCollection(ctx context.Context) error { - _ = u.log.WithName("scheduled") + log := logf.FromContext(ctx).WithName("garbage") var mcpUsages v1.MCPUsageList err := u.client.List(ctx, &mcpUsages) @@ -256,6 +262,8 @@ func (u *UsageTracker) GarbageCollection(ctx context.Context) error { oneMonth := time.Hour * 24 * 32 latestTimestamp := now.Add(-oneMonth) + log.Info("garbage collect old entries", "before", latestTimestamp) + var errs error for _, mcpUsage := range mcpUsages.Items { err = retry.RetryOnConflict(retry.DefaultRetry, func() error { @@ -276,7 +284,7 @@ func (u *UsageTracker) GarbageCollection(ctx context.Context) error { err = u.client.Update(ctx, &mcpUsage) if err != nil { if k8serrors.IsConflict(err) { - fmt.Printf("Conflict detected for McpUsage %s, retrying...\n", mcpUsage.Name) + log.Error(err, "Conflict detected for McpUsage, retrying...\n", "mcpUsage", mcpUsage.Name) return err } return fmt.Errorf("failed to update McpUsage %s: %w", mcpUsage.Name, err) diff --git a/internal/usage/tracking_test.go b/internal/usage/tracking_test.go new file mode 100644 index 0000000..4504888 --- /dev/null +++ b/internal/usage/tracking_test.go @@ -0,0 +1,137 @@ +package usage + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/controller-runtime/pkg/client" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1 "github.com/openmcp-project/usage-operator/api/usage/v1" +) + +const ( + projectName = "project" + workspaceName = "workspace" + mcpName = "mcp-test" + + mcpUsageName = "test" +) + +var _ = Describe("Tracking Module", Ordered, func() { + BeforeAll(func() { + ctx := context.Background() + creationTime := metav1.NewTime(metav1.Now().Add(-time.Hour * 4)) + mcpUsage := v1.MCPUsage{ + ObjectMeta: metav1.ObjectMeta{ + Name: mcpUsageName, + }, + Spec: v1.MCPUsageSpec{ + ChargingTarget: "missing", + Project: projectName, + Workspace: workspaceName, + MCP: mcpName, + MCPCreatedAt: creationTime, + LastUsageCaptured: creationTime, + }, + } + Expect(k8sClient.Create(ctx, &mcpUsage)).Should(Succeed()) + }) + + It("Check scheduled Event", func() { + ctx := context.Background() + + usageTracker, err := NewUsageTracker(k8sClient) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(usageTracker.ScheduledEvent(ctx)).Should(Succeed()) + + mcpUsage := v1.MCPUsage{ + ObjectMeta: metav1.ObjectMeta{ + Name: mcpUsageName, + }, + } + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcpUsage), &mcpUsage)).Should(Succeed()) + + Expect(mcpUsage.Spec.Usage).ShouldNot(BeEmpty()) + }) + + It("garbage collect old usage data", func() { + ctx := context.Background() + + mcpUsage := v1.MCPUsage{ + ObjectMeta: metav1.ObjectMeta{ + Name: mcpUsageName, + }, + } + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcpUsage), &mcpUsage)).Should(Succeed()) + + now := metav1.Now() + mcpUsage.Spec.Usage = []v1.DailyUsage{ + { + Date: metav1.NewTime(now.Add(-time.Hour * 4)), + Usage: metav1.Duration{ + Duration: time.Hour * 4, + }, + }, + { + Date: metav1.NewTime(now.Add(-time.Hour * 24 * 40)), + Usage: metav1.Duration{ + Duration: time.Hour * 4, + }, + }, + } + Expect(k8sClient.Update(ctx, &mcpUsage)).Should(Succeed()) + + usageTracker, err := NewUsageTracker(k8sClient) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(usageTracker.GarbageCollection(ctx)).Should(Succeed()) + + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcpUsage), &mcpUsage)).Should(Succeed()) + Expect(mcpUsage.Spec.Usage).Should(HaveLen(1)) + }) + + It("should create an mcp usage resource", func() { + ctx := context.Background() + + usageTracker, err := NewUsageTracker(k8sClient) + Expect(err).ShouldNot(HaveOccurred()) + + objectKey, err := GetObjectKey(projectName, workspaceName, mcpName) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(usageTracker.CreateOrUpdateEvent(ctx, projectName, workspaceName, mcpName)).Should(Succeed()) + + var mcpUsage v1.MCPUsage + Expect(k8sClient.Get(ctx, objectKey, &mcpUsage)).Should(Succeed()) + + Expect(mcpUsage.Spec.Project).Should(Equal(projectName)) + Expect(mcpUsage.Spec.Workspace).Should(Equal(workspaceName)) + Expect(mcpUsage.Spec.MCP).Should(Equal(mcpName)) + }) + + It("should delete mcp usage resource", func() { + ctx := context.Background() + + usageTracker, err := NewUsageTracker(k8sClient) + Expect(err).ShouldNot(HaveOccurred()) + + objectKey, err := GetObjectKey(projectName, workspaceName, mcpName) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(usageTracker.CreateOrUpdateEvent(ctx, projectName, workspaceName, mcpName)).Should(Succeed()) + Expect(usageTracker.DeletionEvent(ctx, projectName, workspaceName, mcpName)).Should(Succeed()) + + var mcpUsage v1.MCPUsage + Expect(k8sClient.Get(ctx, objectKey, &mcpUsage)).Should(Succeed()) + + Expect(mcpUsage.Spec.MCPDeletedAt.IsZero()).Should(BeFalse()) + + // It should also handle events for already deleted mcps + Expect(usageTracker.CreateOrUpdateEvent(ctx, projectName, workspaceName, mcpName)).Should(Succeed()) + }) +})