From 776471919fa35fe70361eb494a0b4e7f77d5a795 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Fri, 5 Dec 2025 17:06:16 -0800 Subject: [PATCH 01/38] approval controller, metric collector controllers Signed-off-by: Arvind Thirumurugan --- .../README.md | 477 +++++++++++++ .../approval-controller-metric-collector.png | Bin 0 -> 111339 bytes .../approval-request-controller/Makefile | 78 +++ .../approval-request-controller/README.md | 121 ++++ .../apis/metric/v1alpha1/doc.go | 20 + .../apis/metric/v1alpha1/groupversion_info.go | 35 + .../metric/v1alpha1/metriccollector_types.go | 146 ++++ .../v1alpha1/metriccollectorreport_types.go | 86 +++ .../metric/v1alpha1/workloadtracker_types.go | 100 +++ .../metric/v1alpha1/zz_generated.deepcopy.go | 362 ++++++++++ .../approval-request-controller/Chart.yaml | 6 + .../templates/_helpers.tpl | 60 ++ ...leet.io_clusterstagedworkloadtrackers.yaml | 1 + ...netes-fleet.io_metriccollectorreports.yaml | 1 + ....kubernetes-fleet.io_metriccollectors.yaml | 1 + ...netes-fleet.io_stagedworkloadtrackers.yaml | 1 + .../templates/deployment.yaml | 84 +++ .../templates/rbac.yaml | 72 ++ .../templates/serviceaccount.yaml | 13 + .../approval-request-controller/values.yaml | 84 +++ .../cmd/approvalrequestcontroller/main.go | 168 +++++ ...leet.io_clusterstagedworkloadtrackers.yaml | 64 ++ ...netes-fleet.io_metriccollectorreports.yaml | 176 +++++ ....kubernetes-fleet.io_metriccollectors.yaml | 189 ++++++ ...netes-fleet.io_stagedworkloadtrackers.yaml | 64 ++ .../approval-request-controller.Dockerfile | 27 + .../fleet_v1beta1_membercluster.yaml | 41 ++ .../examples/prometheus/configmap.yaml | 42 ++ .../examples/prometheus/deployment.yaml | 48 ++ .../examples/prometheus/prometheus-crp.yaml | 22 + .../examples/prometheus/rbac.yaml | 39 ++ .../examples/prometheus/service.yaml | 16 + .../sample-metric-app/sample-metric-app.yaml | 27 + .../examples/updateRun/example-crp.yaml | 14 + .../examples/updateRun/example-csur.yaml | 10 + .../examples/updateRun/example-csus.yaml | 18 + .../updateRun/example-ns-only-crp.yaml | 15 + .../examples/updateRun/example-rp.yaml | 15 + .../examples/updateRun/example-sur.yaml | 11 + .../examples/updateRun/example-sus.yaml | 19 + .../clusterstagedworkloadtracker.yaml | 8 + .../stagedworkloadtracker.yaml | 9 + .../approval-request-controller/go.mod | 72 ++ .../approval-request-controller/go.sum | 196 ++++++ .../hack/boilerplate.go.txt | 15 + .../install-on-hub.sh | 97 +++ .../pkg/controller/controller.go | 628 ++++++++++++++++++ .../metric-collector/Makefile | 125 ++++ .../metric-collector/README.md | 91 +++ .../charts/metric-collector/Chart.yaml | 16 + .../metric-collector/templates/_helpers.tpl | 60 ++ ....kubernetes-fleet.io_metriccollectors.yaml | 1 + .../templates/deployment.yaml | 155 +++++ .../metric-collector/templates/hub-rbac.yaml | 49 ++ .../templates/rbac-member.yaml | 44 ++ .../templates/serviceaccount.yaml | 13 + .../charts/metric-collector/values.yaml | 134 ++++ .../cmd/metriccollector/main.go | 244 +++++++ .../cmd/metriccollector/metric-app/main.go | 30 + .../docker/metric-app.Dockerfile | 21 + .../docker/metric-collector.Dockerfile | 26 + .../examples/metriccollector-example.yaml | 8 + .../metric-collector/go.mod | 68 ++ .../metric-collector/go.sum | 188 ++++++ .../metric-collector/install-on-member.sh | 178 +++++ .../pkg/controller/collector.go | 206 ++++++ .../pkg/controller/controller.go | 288 ++++++++ 67 files changed, 5743 insertions(+) create mode 100644 approval-controller-metric-collector/README.md create mode 100644 approval-controller-metric-collector/approval-controller-metric-collector.png create mode 100644 approval-controller-metric-collector/approval-request-controller/Makefile create mode 100644 approval-controller-metric-collector/approval-request-controller/README.md create mode 100644 approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/doc.go create mode 100644 approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/groupversion_info.go create mode 100644 approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollector_types.go create mode 100644 approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollectorreport_types.go create mode 100644 approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/workloadtracker_types.go create mode 100644 approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/zz_generated.deepcopy.go create mode 100644 approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/Chart.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/_helpers.tpl create mode 120000 approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml create mode 120000 approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectorreports.yaml create mode 120000 approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml create mode 120000 approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/deployment.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/serviceaccount.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/values.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go create mode 100644 approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectorreports.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectors.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/docker/approval-request-controller.Dockerfile create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/membercluster/fleet_v1beta1_membercluster.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/prometheus/configmap.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/prometheus/deployment.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/prometheus/prometheus-crp.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/prometheus/rbac.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/prometheus/service.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/sample-metric-app/sample-metric-app.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-crp.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csur.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csus.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-ns-only-crp.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-rp.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sur.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sus.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/workloadtracker/clusterstagedworkloadtracker.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/examples/workloadtracker/stagedworkloadtracker.yaml create mode 100644 approval-controller-metric-collector/approval-request-controller/go.mod create mode 100644 approval-controller-metric-collector/approval-request-controller/go.sum create mode 100644 approval-controller-metric-collector/approval-request-controller/hack/boilerplate.go.txt create mode 100755 approval-controller-metric-collector/approval-request-controller/install-on-hub.sh create mode 100644 approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go create mode 100644 approval-controller-metric-collector/metric-collector/Makefile create mode 100644 approval-controller-metric-collector/metric-collector/README.md create mode 100644 approval-controller-metric-collector/metric-collector/charts/metric-collector/Chart.yaml create mode 100644 approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/_helpers.tpl create mode 120000 approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml create mode 100644 approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/deployment.yaml create mode 100644 approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/hub-rbac.yaml create mode 100644 approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/rbac-member.yaml create mode 100644 approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/serviceaccount.yaml create mode 100644 approval-controller-metric-collector/metric-collector/charts/metric-collector/values.yaml create mode 100644 approval-controller-metric-collector/metric-collector/cmd/metriccollector/main.go create mode 100644 approval-controller-metric-collector/metric-collector/cmd/metriccollector/metric-app/main.go create mode 100644 approval-controller-metric-collector/metric-collector/docker/metric-app.Dockerfile create mode 100644 approval-controller-metric-collector/metric-collector/docker/metric-collector.Dockerfile create mode 100644 approval-controller-metric-collector/metric-collector/examples/metriccollector-example.yaml create mode 100644 approval-controller-metric-collector/metric-collector/go.mod create mode 100644 approval-controller-metric-collector/metric-collector/go.sum create mode 100755 approval-controller-metric-collector/metric-collector/install-on-member.sh create mode 100644 approval-controller-metric-collector/metric-collector/pkg/controller/collector.go create mode 100644 approval-controller-metric-collector/metric-collector/pkg/controller/controller.go diff --git a/approval-controller-metric-collector/README.md b/approval-controller-metric-collector/README.md new file mode 100644 index 0000000..414bb98 --- /dev/null +++ b/approval-controller-metric-collector/README.md @@ -0,0 +1,477 @@ +# Approval Controller and Metric Collector Tutorial + +This tutorial demonstrates how to use the Approval Request Controller and Metric Collector with KubeFleet for automated staged rollout approvals based on workload health metrics. + +## Overview + +This directory contains two controllers: +- **approval-request-controller**: Runs on the hub cluster to automate approval decisions for staged updates +- **metric-collector**: Runs on member clusters to collect workload health metrics from Prometheus + +![Approval Controller and Metric Collector Architecture](./approval-controller-metric-collector.png) + +## How It Works + +### Custom Resource Definitions (CRDs) + +This solution introduces three new CRDs that work together with KubeFleet's native resources: + +#### Hub Cluster CRDs + +1. **MetricCollector** (cluster-scoped) + - Defines Prometheus connection details and where to report metrics + - Gets propagated to member clusters via ClusterResourcePlacement (CRP) + - Each member cluster receives a customized version with its specific `reportNamespace` + +2. **MetricCollectorReport** (namespaced) + - Created by metric-collector on member clusters, reported back to hub + - Lives in `fleet-member-` namespaces on the hub + - Contains collected `workload_health` metrics for all workloads in a cluster + - Updated every 30 seconds by the metric collector + +3. **ClusterStagedWorkloadTracker** (cluster-scoped) + - Defines which workloads to monitor for a ClusterStagedUpdateRun + - The name must match the ClusterStagedUpdateRun name + - Specifies namespace, workload name, and expected health status + - Used by approval-request-controller to determine if stage is ready for approval + +4. **StagedWorkloadTracker** (namespaced) + - Defines which workloads to monitor for a StagedUpdateRun + - The name and namespace must match the StagedUpdateRun name and namespace + - Specifies namespace, workload name, and expected health status + - Used by approval-request-controller to determine if stage is ready for approval + +### Automated Approval Flow + +1. **Stage Initialization** + - User creates an UpdateRun (`ClusterStagedUpdateRun` or `StagedUpdateRun`) on the hub + - KubeFleet creates an ApprovalRequest (`ClusterApprovalRequest` or `ApprovalRequest`) for the first stage + - The ApprovalRequest enters "Pending" state, waiting for approval + +2. **Metric Collector Deployment** + - Approval-request-controller watches the CAR + - Creates a `MetricCollector` resource on the hub (cluster-scoped) + - Creates a `ClusterResourceOverride` with per-cluster customization rules + - Each cluster gets a unique `reportNamespace`: `fleet-member-` + - Creates a `ClusterResourcePlacement` (CRP) with `PickFixed` policy + - Targets all clusters in the current stage + - KubeFleet propagates the customized `MetricCollector` to each member cluster + +3. **Metric Collection on Member Clusters** + - Metric-collector controller runs on each member cluster + - Every 30 seconds, it: + - Queries local Prometheus with PromQL: `workload_health` + - Prometheus returns metrics for all pods with `prometheus.io/scrape: "true"` annotation + - Extracts workload health (1.0 = healthy, 0.0 = unhealthy) + - Creates/updates `MetricCollectorReport` on hub in `fleet-member-` namespace + +4. **Health Evaluation** + - Approval-request-controller monitors `MetricCollectorReports` from all stage clusters + - Every 15 seconds, it: + - Fetches the appropriate workload tracker: + - For cluster-scoped: `ClusterStagedWorkloadTracker` with same name as ClusterStagedUpdateRun + - For namespace-scoped: `StagedWorkloadTracker` with same name and namespace as StagedUpdateRun + - For each cluster in the stage: + - Reads its `MetricCollectorReport` from `fleet-member-` namespace + - Verifies all tracked workloads are present and healthy + - If any workload is missing or unhealthy, waits for next cycle + - If ALL workloads across ALL clusters are healthy: + - Sets ApprovalRequest condition `Approved: True` + - KubeFleet proceeds to roll out the stage + +5. **Stage Progression** + - KubeFleet applies the update to the approved stage clusters + - Creates a new ApprovalRequest for the next stage (if any) + - The cycle repeats for each stage + +## Prerequisites + +- Docker or Podman for building images +- kubectl configured with access to your clusters +- Helm 3.x +- KubeFleet installed on hub and member clusters + +## Setup + +### 1. Setup KubeFleet Clusters + +First, set up the KubeFleet hub and member clusters: + +```bash +cd /path/to/kubefleet + +# Checkout main branch +git checkout main +git fetch upstream +git rebase -i upstream/main + +# Set up clusters (creates 1 hub + 3 member clusters) +export MEMBER_CLUSTER_COUNT=3 +make setup-clusters +``` + +This will create: +- 1 hub cluster (context: `kind-hub`) +- 3 member clusters (contexts: `kind-cluster-1`, `kind-cluster-2`, `kind-cluster-3`) + +### 2. Register Member Clusters with Hub + +Switch to hub cluster context and register the member clusters: + +```bash +cd /path/to/approval-controller-metric-collector/approval-request-controller + +# Switch to hub cluster +kubectl config use-context kind-hub + +# Register member clusters with the hub +kubectl apply -f ./examples/membercluster/ + +# Verify clusters are registered +kubectl get cluster -A +``` + +the output should look something like this, + +```bash +NAME JOINED AGE MEMBER-AGENT-LAST-SEEN NODE-COUNT AVAILABLE-CPU AVAILABLE-MEMORY +kind-cluster-1 True 40s 29s 0 0 0 +kind-cluster-2 True 40s 3s 0 0 0 +kind-cluster-3 True 40s 37s 0 0 0 +``` +Wait until all member clusters show as joined. + +### 3. Deploy Prometheus + +Create the prometheus namespace and deploy Prometheus for metrics collection: + +```bash +# Create prometheus namespace +kubectl create ns prometheus + +# Deploy Prometheus (ConfigMap, Deployment, Service, RBAC) +kubectl apply -f ./examples/prometheus/ +``` + +This deploys Prometheus configured to scrape pods from all namespaces with the proper annotations. + +### 4. Deploy Sample Metric Application + +Create the test namespace and deploy the sample application: + +```bash +# Create test namespace +kubectl create ns test-ns + +# Deploy sample metric app (this will be propagated to member clusters) +kubectl apply -f ./examples/sample-metric-app/ +``` + +### 5. Install Approval Request Controller (Hub Cluster) + +Install the approval request controller on the hub cluster: + +```bash +# Run the installation script +./install-on-hub.sh +``` + +The script performs the following: +1. Builds the `approval-request-controller:latest` image +2. Loads the image into the kind hub cluster +3. Verifies that required kubefleet CRDs are installed +4. Installs the controller via Helm with the custom CRDs (MetricCollector, MetricCollectorReport, ClusterStagedWorkloadTracker, StagedWorkloadTracker) +5. Verifies the installation + +### 6. Configure Workload Tracker + +Apply the appropriate workload tracker based on which type of staged update you'll use: + +#### For Cluster-Scoped Updates (ClusterStagedUpdateRun): + +```bash +# Apply ClusterStagedWorkloadTracker +# Important: The name must match your ClusterStagedUpdateRun name +kubectl apply -f ./examples/workloadtracker/clusterstagedworkloadtracker.yaml +``` + +#### For Namespace-Scoped Updates (StagedUpdateRun): + +```bash +# Apply StagedWorkloadTracker +# Important: The name and namespace must match your StagedUpdateRun name and namespace +kubectl apply -f ./examples/workloadtracker/stagedworkloadtracker.yaml +``` + +This tells the approval controller which workloads to track. + +### 7. Install Metric Collector (Member Clusters) + +Install the metric collector on all member clusters: + +```bash +cd ../metric-collector + +# Run the installation script for all member clusters +# This builds both metric-collector and metric-app images and loads them into each cluster +./install-on-member.sh 3 +``` + +The script performs the following for each member cluster: +1. Builds the `metric-collector:latest` image +2. Builds the `metric-app:local` image +3. Loads both images into each kind cluster +4. Creates hub token secret with proper RBAC +5. Installs the metric-collector via Helm + +The `metric-app:local` image is loaded so it's available when you propagate the sample-metric-app deployment from hub to member clusters. + +### 8. Create Staged Update + +You can create staged updates using either cluster-scoped or namespace-scoped resources: + +#### Option A: Cluster-Scoped Staged Update (ClusterStagedUpdateRun) + +Switch back to hub cluster and create a cluster-scoped staged update run: + +```bash +# Switch to hub cluster +kubectl config use-context kind-hub + +cd ../approval-request-controller + +# Apply ClusterStagedUpdateStrategy +kubectl apply -f ./examples/updateRun/example-csus.yaml + +# Apply ClusterResourcePlacement +kubectl apply -f ./examples/updateRun/example-crp.yaml + +# Verify CRP is created +kubectl get crp -A +``` + +Output: +```bash +NAME GEN SCHEDULED SCHEDULED-GEN AVAILABLE AVAILABLE-GEN AGE +example-crp 1 True 1 4s +prometheus-crp 1 True 1 True 1 3m1s +``` + +```bash +# Apply ClusterStagedUpdateRun to start the staged rollout +kubectl apply -f ./examples/updateRun/example-csur.yaml + +# Check the staged update run status +kubectl get csur -A +``` + +Output: +```bash +NAME PLACEMENT RESOURCE-SNAPSHOT-INDEX POLICY-SNAPSHOT-INDEX INITIALIZED PROGRESSING SUCCEEDED AGE +example-cluster-staged-run example-crp 0 0 True True 5s +``` + +#### Option B: Namespace-Scoped Staged Update (StagedUpdateRun) + +Alternatively, you can use namespace-scoped resources: + +```bash +# Switch to hub cluster +kubectl config use-context kind-hub + +cd ../approval-request-controller +``` + +``` bash +# Apply namespace-scoped ClusterResourcePlacement +kubectl apply -f ./examples/updateRun/example-ns-only-crp.yaml + +kubectl get crp -A +``` + +Output: +```bash +NAME GEN SCHEDULED SCHEDULED-GEN AVAILABLE AVAILABLE-GEN AGE +ns-only-crp 1 True 1 True 1 5s +proemetheus-crp 1 True 1 True 1 2m34s +``` + +```bash +# Apply StagedUpdateStrategy +kubectl apply -f ./examples/updateRun/example-sus.yaml + +# Verify SUS is created +kubectl get sus -A +``` + +Output: +```bash +NAMESPACE NAME AGE +test-ns example-staged-strategy 4s +``` + +```bash +# Apply ResourcePlacement +kubectl apply -f ./examples/updateRun/example-rp.yaml + +# Verify RP is created +kubectl get rp -A +``` + +Output: +```bash +NAMESPACE NAME GEN SCHEDULED SCHEDULED-GEN AVAILABLE AVAILABLE-GEN AGE +test-ns example-rp 1 True 1 35s +``` + +```bash +# Apply StagedUpdateRun to start the staged rollout +kubectl apply -f ./examples/updateRun/example-sur.yaml + +# Check the staged update run status +kubectl get sur -A +``` + +Output: +```bash +NAMESPACE NAME PLACEMENT RESOURCE-SNAPSHOT-INDEX POLICY-SNAPSHOT-INDEX INITIALIZED PROGRESSING SUCCEEDED AGE +test-ns example-staged-run example-rp 0 0 True True 5s +``` + +### 9. Monitor the Staged Rollout + +Watch the staged update progress: + +#### For Cluster-Scoped Updates: + +```bash +# Check the staged update run status +kubectl get csur -A + +# Check approval requests (should be auto-approved based on metrics) +kubectl get clusterapprovalrequest -A +``` + +Output: +```bash +NAME UPDATE-RUN STAGE APPROVED AGE +example-cluster-staged-run-after-staging example-cluster-staged-run staging True 2m9s +``` + +```bash +# Check metric collector reports +kubectl get metriccollectorreport -A +``` + +Output: +```bash +NAMESPACE NAME WORKLOADS LAST-COLLECTION AGE +fleet-member-kind-cluster-1 mc-example-cluster-staged-run-staging 1 27s 2m57s +``` + +#### For Namespace-Scoped Updates: + +```bash +# Check the staged update run status +kubectl get sur -A + +# Check approval requests (should be auto-approved based on metrics) +kubectl get approvalrequest -A +``` + +Output: +```bash +NAMESPACE NAME UPDATE-RUN STAGE APPROVED AGE +test-ns example-staged-run-after-staging example-staged-run staging True 64s +``` + +```bash +# Check metric collector reports +kubectl get metriccollectorreport -A +``` + +Output: +```bash +NAMESPACE NAME WORKLOADS LAST-COLLECTION AGE +fleet-member-kind-cluster-1 mc-example-staged-run-staging 1 27s 57s +``` + +The approval controller will automatically approve stages when the metric collectors report that workloads are healthy. +1. Builds the `metric-collector:latest` image +2. Builds the `metric-app:local` image +3. Loads both images into each kind cluster +4. Creates hub token secret with proper RBAC +5. Installs the metric-collector via Helm + +The `metric-app:local` image is pre-loaded so it's available when you propagate the sample-metric-app deployment from hub to member clusters. + +## Verification + +### Check Controller Status + +On the hub cluster: +```bash +kubectl config use-context kind-hub +kubectl get pods -n fleet-system +kubectl logs -n fleet-system deployment/approval-request-controller -f +``` + +On member clusters: +```bash +kubectl config use-context kind-cluster-1 +kubectl get pods -n default +kubectl logs -n default deployment/metric-collector -f +``` + +### Check Metrics Collection + +Verify that MetricCollector resources exist on member clusters: +```bash +kubectl config use-context kind-cluster-1 +kubectl get metriccollector -A +``` + +Verify that MetricCollectorReports are being created on the hub: +```bash +kubectl config use-context kind-hub +kubectl get metriccollectorreport -A +``` + +## Configuration + +### Approval Request Controller +- Located in `approval-request-controller/charts/approval-request-controller/values.yaml` +- Key settings: log level, resource limits, RBAC, CRD installation +- Default Prometheus URL: `http://prometheus.prometheus.svc.cluster.local:9090` +- Reconciliation interval: 15 seconds + +### Metric Collector +- Located in `metric-collector/charts/metric-collector/values.yaml` +- Key settings: hub cluster URL, Prometheus URL, member cluster name +- Metric collection interval: 30 seconds +- Connects to hub using service account token + +## Troubleshooting + +### Controller not starting +- Check that all required CRDs are installed: `kubectl get crds | grep metric.kubernetes-fleet.io` +- Verify RBAC permissions are configured correctly + +### Metrics not being collected +- Verify Prometheus is accessible: `kubectl port-forward -n test-ns svc/prometheus 9090:9090` +- Check metric collector logs for connection errors +- Ensure workloads have Prometheus scrape annotations + +### Approvals not happening +- Check that the workload tracker name matches the update run name: + - For ClusterStagedUpdateRun: ClusterStagedWorkloadTracker name must match + - For StagedUpdateRun: StagedWorkloadTracker name and namespace must match +- Verify workload tracker resources define correct health thresholds +- Verify MetricCollectorReports are being created on the hub +- Review approval-request-controller logs for decision-making details + +## Additional Resources + +- [Approval Request Controller README](./approval-request-controller/README.md) +- [Metric Collector README](./metric-collector/README.md) +- [KubeFleet Documentation](https://github.com/Azure/kubefleet) diff --git a/approval-controller-metric-collector/approval-controller-metric-collector.png b/approval-controller-metric-collector/approval-controller-metric-collector.png new file mode 100644 index 0000000000000000000000000000000000000000..d9ca5e45a5786a4f4b99ab7b836a318258e8d58c GIT binary patch literal 111339 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\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Code Generation + +.PHONY: manifests +manifests: ## Generate CRD manifests + $(CONTROLLER_GEN) crd paths="./apis/..." output:crd:artifacts:config=config/crd/bases + +.PHONY: generate +generate: ## Generate DeepCopy code + $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./apis/..." + +##@ Build + +.PHONY: docker-build +docker-build: ## Build docker image + docker buildx build \ + --file docker/approval-request-controller.Dockerfile \ + --output=type=docker \ + --platform=linux/$(GOARCH) \ + --build-arg GOARCH=$(GOARCH) \ + --tag $(IMAGE_NAME):$(IMAGE_TAG) \ + --build-context kubefleet=.. \ + .. + +.PHONY: docker-push +docker-push: ## Push docker image + docker push $(REGISTRY)$(IMAGE_NAME):$(IMAGE_TAG) + +##@ Development + +.PHONY: run +run: ## Run controller locally + cd .. && go run ./approval-request-controller/cmd/approvalrequestcontroller/main.go + +##@ Deployment + +.PHONY: install +install: ## Install helm chart + helm install approval-request-controller ./charts/approval-request-controller \ + --namespace fleet-system \ + --create-namespace \ + --set image.repository=$(IMAGE_NAME) \ + --set image.tag=$(IMAGE_TAG) + +.PHONY: upgrade +upgrade: ## Upgrade helm chart + helm upgrade approval-request-controller ./charts/approval-request-controller \ + --namespace fleet-system \ + --set image.repository=$(IMAGE_NAME) \ + --set image.tag=$(IMAGE_TAG) + +.PHONY: uninstall +uninstall: ## Uninstall helm chart + helm uninstall approval-request-controller --namespace fleet-system + +##@ Kind + +.PHONY: kind-load +kind-load: docker-build ## Build and load image into kind cluster + kind load docker-image $(IMAGE_NAME):$(IMAGE_TAG) --name hub diff --git a/approval-controller-metric-collector/approval-request-controller/README.md b/approval-controller-metric-collector/approval-request-controller/README.md new file mode 100644 index 0000000..6559919 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/README.md @@ -0,0 +1,121 @@ +# ApprovalRequest Controller + +The ApprovalRequest Controller is a standalone controller that runs on the **hub cluster** to automate approval decisions for staged updates based on workload health metrics. + +## Overview + +This controller is designed to be a standalone component that can run independently from the main kubefleet repository. It: +- Uses kubefleet v0.1.2 as an external dependency +- Includes its own APIs for MetricCollectorReport and WorkloadTracker +- Watches `ApprovalRequest` and `ClusterApprovalRequest` resources (from kubefleet) +- Creates `MetricCollector` resources on member clusters via ClusterResourcePlacement +- Monitors workload health via `MetricCollectorReport` objects +- Automatically approves requests when all tracked workloads are healthy +- Runs every 15 seconds to check health status + +## Architecture + +The controller is designed to run on the hub cluster and: +1. Deploys MetricCollector instances to member clusters using CRP +2. Collects health metrics from MetricCollectorReports +3. Compares metrics against WorkloadTracker specifications +4. Approves ApprovalRequests when all workloads are healthy + +## Installation + +### Prerequisites + +The following CRDs must be installed on the hub cluster (installed by kubefleet hub-agent): +- `approvalrequests.placement.kubernetes-fleet.io` +- `clusterapprovalrequests.placement.kubernetes-fleet.io` +- `clusterresourceplacements.placement.kubernetes-fleet.io` +- `clusterresourceoverrides.placement.kubernetes-fleet.io` +- `clusterstagedupdateruns.placement.kubernetes-fleet.io` +- `stagedupdateruns.placement.kubernetes-fleet.io` + +The following CRDs are installed by this chart: +- `metriccollectors.metric.kubernetes-fleet.io` +- `metriccollectorreports.metric.kubernetes-fleet.io` +- `workloadtrackers.metric.kubernetes-fleet.io` + +### Install via Helm + +```bash +# Build the image +make docker-build IMAGE_NAME=approval-request-controller IMAGE_TAG=latest + +# Load into kind (if using kind) +kind load docker-image approval-request-controller:latest --name hub + +# Install the chart +helm install approval-request-controller ./charts/approval-request-controller \ + --namespace fleet-system \ + --create-namespace +``` + +## Configuration + +The controller watches for: +- `ApprovalRequest` (namespaced) +- `ClusterApprovalRequest` (cluster-scoped) + +Both resources from kubefleet are monitored, and the controller creates `MetricCollector` resources on appropriate member clusters based on the staged update configuration. + +### Health Check Interval + +The controller checks workload health every **15 seconds**. This interval is configurable via the `reconcileInterval` parameter in the Helm chart. + +## API Reference + +### WorkloadTracker + +`WorkloadTracker` is a cluster-scoped custom resource that defines which workloads the approval controller should monitor for health metrics before auto-approving staged rollouts. + +#### Example: Single Workload + +```yaml +apiVersion: metric.kubernetes-fleet.io/v1beta1 +kind: WorkloadTracker +metadata: + name: sample-workload-tracker +workloads: + - name: sample-metric-app + namespace: test-ns +``` + +#### Example: Multiple Workloads + +```yaml +apiVersion: metric.kubernetes-fleet.io/v1beta1 +kind: WorkloadTracker +metadata: + name: multi-workload-tracker +workloads: + - name: frontend + namespace: production + - name: backend-api + namespace: production + - name: worker-service + namespace: production +``` + +#### Usage Notes + +- **Cluster-scoped:** WorkloadTracker is a cluster-scoped resource, not namespaced +- **Optional:** If no WorkloadTracker exists, the controller will skip health checks and won't auto-approve +- **Single instance:** The controller expects one WorkloadTracker per cluster and uses the first one found +- **Health criteria:** All workloads listed must report healthy (metric value = 1.0) before approval +- **Prometheus metrics:** Each workload should expose `workload_health` metrics that the MetricCollector can query + +For a complete example, see: [`./examples/workloadtracker/workloadtracker.yaml`](./examples/workloadtracker/workloadtracker.yaml) + +## Additional Resources + +- **Main Tutorial:** See [`../README.md`](../README.md) for a complete end-to-end tutorial on setting up automated staged rollouts with approval automation +- **Metric Collector:** See [`../metric-collector/README.md`](../metric-collector/README.md) for details on the metric collection component that runs on member clusters +- **KubeFleet Documentation:** [Azure/fleet](https://github.com/Azure/fleet) - Multi-cluster orchestration platform +- **Example Configurations:** + - [`./examples/workloadtracker/`](./examples/workloadtracker/) - WorkloadTracker resource examples + - [`./examples/stagedupdaterun/`](./examples/stagedupdaterun/) - Staged update configuration examples + - [`./examples/prometheus/`](./examples/prometheus/) - Prometheus deployment and configuration for metric collection +``` diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/doc.go b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/doc.go new file mode 100644 index 0000000..59c5de6 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package v1alpha1 contains API Schema definitions for the placement v1beta1 API group +// +kubebuilder:object:generate=true +// +groupName=metric.kubernetes-fleet.io +package v1alpha1 diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/groupversion_info.go b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..23b3d99 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/groupversion_info.go @@ -0,0 +1,35 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// +kubebuilder:object:generate=true +// +groupName=metric.kubernetes-fleet.io +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "metric.kubernetes-fleet.io", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollector_types.go b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollector_types.go new file mode 100644 index 0000000..75503c9 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollector_types.go @@ -0,0 +1,146 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +genclient:nonNamespaced +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope="Cluster",shortName=mc,categories={fleet,fleet-metrics} +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +// +kubebuilder:printcolumn:JSONPath=`.metadata.generation`,name="Gen",type=string +// +kubebuilder:printcolumn:JSONPath=`.status.conditions[?(@.type=="MetricCollectorReady")].status`,name="Ready",type=string +// +kubebuilder:printcolumn:JSONPath=`.status.workloadsMonitored`,name="Workloads",type=integer +// +kubebuilder:printcolumn:JSONPath=`.status.lastCollectionTime`,name="Last-Collection",type=date +// +kubebuilder:printcolumn:JSONPath=`.metadata.creationTimestamp`,name="Age",type=date + +// MetricCollector is used by member-agent to scrape and collect metrics from workloads +// running on the member cluster. It runs on each member cluster and collects metrics +// from Prometheus-compatible endpoints. +type MetricCollector struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // The desired state of MetricCollector. + // +required + Spec MetricCollectorSpec `json:"spec"` + + // The observed status of MetricCollector. + // +optional + Status MetricCollectorStatus `json:"status,omitempty"` +} + +// MetricCollectorSpec defines the desired state of MetricCollector. +type MetricCollectorSpec struct { + // PrometheusURL is the URL of the Prometheus server. + // Example: http://prometheus.test-ns.svc.cluster.local:9090 + // +required + // +kubebuilder:validation:Pattern=`^https?://.*$` + PrometheusURL string `json:"prometheusUrl"` + + // ReportNamespace is the namespace in the hub cluster where the MetricCollectorReport will be created. + // This should be the fleet-member-{clusterName} namespace. + // Example: fleet-member-cluster-1 + // +required + ReportNamespace string `json:"reportNamespace"` +} + +// MetricsEndpointSpec defines how to access the metrics endpoint.ctor. +type MetricCollectorStatus struct { + // Conditions is an array of current observed conditions. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // ObservedGeneration is the generation most recently observed. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // WorkloadsMonitored is the count of workloads being monitored. + // +optional + WorkloadsMonitored int32 `json:"workloadsMonitored,omitempty"` + + // LastCollectionTime is when metrics were last collected. + // +optional + LastCollectionTime *metav1.Time `json:"lastCollectionTime,omitempty"` + + // CollectedMetrics contains the most recent metrics from each workload. + // +optional + CollectedMetrics []WorkloadMetrics `json:"collectedMetrics,omitempty"` +} + +// WorkloadMetrics represents metrics collected from a single workload pod. +type WorkloadMetrics struct { + // Namespace is the namespace of the pod. + // +required + Namespace string `json:"namespace"` + + // ClusterName from the workload_health metric label. + // +required + ClusterName string `json:"clusterName"` + + // WorkloadName from the workload_health metric label (typically the deployment name). + // +required + WorkloadName string `json:"workloadName"` + + // Health indicates if the workload is healthy (true=healthy, false=unhealthy). + // +required + Health bool `json:"health"` +} + +const ( + // MetricCollectorConditionTypeReady indicates the collector is ready. + MetricCollectorConditionTypeReady string = "MetricCollectorReady" + + // MetricCollectorConditionTypeCollecting indicates metrics are being collected. + MetricCollectorConditionTypeCollecting string = "MetricsCollecting" + + // MetricCollectorConditionTypeReported indicates metrics were successfully reported to hub. + MetricCollectorConditionTypeReported string = "MetricsReported" +) + +// +kubebuilder:object:root=true + +// MetricCollectorList contains a list of MetricCollector. +type MetricCollectorList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []MetricCollector `json:"items"` +} + +// GetConditions returns the conditions of the MetricCollector. +func (m *MetricCollector) GetConditions() []metav1.Condition { + return m.Status.Conditions +} + +// SetConditions sets the conditions of the MetricCollector. +func (m *MetricCollector) SetConditions(conditions ...metav1.Condition) { + m.Status.Conditions = conditions +} + +// GetCondition returns the condition of the given MetricCollector. +func (m *MetricCollector) GetCondition(conditionType string) *metav1.Condition { + return meta.FindStatusCondition(m.Status.Conditions, conditionType) +} + +func init() { + SchemeBuilder.Register(&MetricCollector{}, &MetricCollectorList{}) +} diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollectorreport_types.go b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollectorreport_types.go new file mode 100644 index 0000000..d21a6c7 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollectorreport_types.go @@ -0,0 +1,86 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope="Namespaced",shortName=mcr,categories={fleet,fleet-metrics} +// +kubebuilder:storageversion +// +kubebuilder:printcolumn:JSONPath=`.workloadsMonitored`,name="Workloads",type=integer +// +kubebuilder:printcolumn:JSONPath=`.lastCollectionTime`,name="Last-Collection",type=date +// +kubebuilder:printcolumn:JSONPath=`.metadata.creationTimestamp`,name="Age",type=date + +// MetricCollectorReport is created by the MetricCollector controller on the hub cluster +// in the fleet-member-{clusterName} namespace to report collected metrics from a member cluster. +// The controller watches MetricCollector objects on the member cluster, collects metrics, +// and syncs the status to the hub as MetricCollectorReport objects. +// +// Controller workflow: +// 1. MetricCollector reconciles and collects metrics on member cluster +// 2. Metrics include clusterName from workload_health labels +// 3. Controller creates/updates MetricCollectorReport in fleet-member-{clusterName} namespace on hub +// 4. Report name matches MetricCollector name for easy lookup +// +// Namespace: fleet-member-{clusterName} (extracted from CollectedMetrics[0].ClusterName) +// Name: Same as MetricCollector name +// All metrics in CollectedMetrics are guaranteed to have the same ClusterName. +type MetricCollectorReport struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Conditions copied from the MetricCollector status. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // ObservedGeneration is the generation most recently observed from the MetricCollector. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // WorkloadsMonitored is the count of workloads being monitored. + // +optional + WorkloadsMonitored int32 `json:"workloadsMonitored,omitempty"` + + // LastCollectionTime is when metrics were last collected on the member cluster. + // +optional + LastCollectionTime *metav1.Time `json:"lastCollectionTime,omitempty"` + + // CollectedMetrics contains the most recent metrics from each workload. + // All metrics are guaranteed to have the same ClusterName since they're collected from one member cluster. + // +optional + CollectedMetrics []WorkloadMetrics `json:"collectedMetrics,omitempty"` + + // LastReportTime is when this report was last synced to the hub. + // +optional + LastReportTime *metav1.Time `json:"lastReportTime,omitempty"` +} + +// +kubebuilder:object:root=true + +// MetricCollectorReportList contains a list of MetricCollectorReport. +type MetricCollectorReportList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []MetricCollectorReport `json:"items"` +} + +func init() { + SchemeBuilder.Register(&MetricCollectorReport{}, &MetricCollectorReportList{}) +} diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/workloadtracker_types.go b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/workloadtracker_types.go new file mode 100644 index 0000000..56925ee --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/workloadtracker_types.go @@ -0,0 +1,100 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// WorkloadReference represents a workload to be tracked +type WorkloadReference struct { + // Name is the name of the workload + // +required + Name string `json:"name"` + + // Namespace is the namespace of the workload + // +required + Namespace string `json:"namespace"` +} + +// +genclient +// +genclient:nonNamespaced +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope="Cluster",categories={fleet,fleet-placement} +// +kubebuilder:storageversion +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ClusterStagedWorkloadTracker expresses user intent to track certain workloads for a ClusterStagedUpdateRun. +// The name of this resource should match the name of the ClusterStagedUpdateRun it is used for. +// For example, if the ClusterStagedUpdateRun is named "example-cluster-staged-run", the +// ClusterStagedWorkloadTracker should also be named "example-cluster-staged-run". +type ClusterStagedWorkloadTracker struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Workloads is a list of workloads to track + // +optional + Workloads []WorkloadReference `json:"workloads,omitempty"` +} + +// +kubebuilder:object:root=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ClusterStagedWorkloadTrackerList contains a list of ClusterStagedWorkloadTracker +type ClusterStagedWorkloadTrackerList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ClusterStagedWorkloadTracker `json:"items"` +} + +// +genclient +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope="Namespaced",categories={fleet,fleet-placement} +// +kubebuilder:storageversion +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// StagedWorkloadTracker expresses user intent to track certain workloads for a StagedUpdateRun. +// The name and namespace of this resource should match the name and namespace of the StagedUpdateRun it is used for. +// For example, if the StagedUpdateRun is named "example-staged-run" in namespace "test-ns", the +// StagedWorkloadTracker should also be named "example-staged-run" in namespace "test-ns". +type StagedWorkloadTracker struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Workloads is a list of workloads to track + // +optional + Workloads []WorkloadReference `json:"workloads,omitempty"` +} + +// +kubebuilder:object:root=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// StagedWorkloadTrackerList contains a list of StagedWorkloadTracker +type StagedWorkloadTrackerList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []StagedWorkloadTracker `json:"items"` +} + +func init() { + SchemeBuilder.Register( + &ClusterStagedWorkloadTracker{}, + &ClusterStagedWorkloadTrackerList{}, + &StagedWorkloadTracker{}, + &StagedWorkloadTrackerList{}, + ) +} diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/zz_generated.deepcopy.go b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..87e2a92 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,362 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2025 The KubeFleet Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterStagedWorkloadTracker) DeepCopyInto(out *ClusterStagedWorkloadTracker) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.Workloads != nil { + in, out := &in.Workloads, &out.Workloads + *out = make([]WorkloadReference, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterStagedWorkloadTracker. +func (in *ClusterStagedWorkloadTracker) DeepCopy() *ClusterStagedWorkloadTracker { + if in == nil { + return nil + } + out := new(ClusterStagedWorkloadTracker) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterStagedWorkloadTracker) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterStagedWorkloadTrackerList) DeepCopyInto(out *ClusterStagedWorkloadTrackerList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ClusterStagedWorkloadTracker, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterStagedWorkloadTrackerList. +func (in *ClusterStagedWorkloadTrackerList) DeepCopy() *ClusterStagedWorkloadTrackerList { + if in == nil { + return nil + } + out := new(ClusterStagedWorkloadTrackerList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterStagedWorkloadTrackerList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetricCollector) DeepCopyInto(out *MetricCollector) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricCollector. +func (in *MetricCollector) DeepCopy() *MetricCollector { + if in == nil { + return nil + } + out := new(MetricCollector) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MetricCollector) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetricCollectorList) DeepCopyInto(out *MetricCollectorList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MetricCollector, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricCollectorList. +func (in *MetricCollectorList) DeepCopy() *MetricCollectorList { + if in == nil { + return nil + } + out := new(MetricCollectorList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MetricCollectorList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetricCollectorReport) DeepCopyInto(out *MetricCollectorReport) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.LastCollectionTime != nil { + in, out := &in.LastCollectionTime, &out.LastCollectionTime + *out = (*in).DeepCopy() + } + if in.CollectedMetrics != nil { + in, out := &in.CollectedMetrics, &out.CollectedMetrics + *out = make([]WorkloadMetrics, len(*in)) + copy(*out, *in) + } + if in.LastReportTime != nil { + in, out := &in.LastReportTime, &out.LastReportTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricCollectorReport. +func (in *MetricCollectorReport) DeepCopy() *MetricCollectorReport { + if in == nil { + return nil + } + out := new(MetricCollectorReport) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MetricCollectorReport) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetricCollectorReportList) DeepCopyInto(out *MetricCollectorReportList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MetricCollectorReport, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricCollectorReportList. +func (in *MetricCollectorReportList) DeepCopy() *MetricCollectorReportList { + if in == nil { + return nil + } + out := new(MetricCollectorReportList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MetricCollectorReportList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetricCollectorSpec) DeepCopyInto(out *MetricCollectorSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricCollectorSpec. +func (in *MetricCollectorSpec) DeepCopy() *MetricCollectorSpec { + if in == nil { + return nil + } + out := new(MetricCollectorSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetricCollectorStatus) DeepCopyInto(out *MetricCollectorStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.LastCollectionTime != nil { + in, out := &in.LastCollectionTime, &out.LastCollectionTime + *out = (*in).DeepCopy() + } + if in.CollectedMetrics != nil { + in, out := &in.CollectedMetrics, &out.CollectedMetrics + *out = make([]WorkloadMetrics, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricCollectorStatus. +func (in *MetricCollectorStatus) DeepCopy() *MetricCollectorStatus { + if in == nil { + return nil + } + out := new(MetricCollectorStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StagedWorkloadTracker) DeepCopyInto(out *StagedWorkloadTracker) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.Workloads != nil { + in, out := &in.Workloads, &out.Workloads + *out = make([]WorkloadReference, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StagedWorkloadTracker. +func (in *StagedWorkloadTracker) DeepCopy() *StagedWorkloadTracker { + if in == nil { + return nil + } + out := new(StagedWorkloadTracker) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *StagedWorkloadTracker) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StagedWorkloadTrackerList) DeepCopyInto(out *StagedWorkloadTrackerList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]StagedWorkloadTracker, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StagedWorkloadTrackerList. +func (in *StagedWorkloadTrackerList) DeepCopy() *StagedWorkloadTrackerList { + if in == nil { + return nil + } + out := new(StagedWorkloadTrackerList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *StagedWorkloadTrackerList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkloadMetrics) DeepCopyInto(out *WorkloadMetrics) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadMetrics. +func (in *WorkloadMetrics) DeepCopy() *WorkloadMetrics { + if in == nil { + return nil + } + out := new(WorkloadMetrics) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkloadReference) DeepCopyInto(out *WorkloadReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadReference. +func (in *WorkloadReference) DeepCopy() *WorkloadReference { + if in == nil { + return nil + } + out := new(WorkloadReference) + in.DeepCopyInto(out) + return out +} diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/Chart.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/Chart.yaml new file mode 100644 index 0000000..f5e253c --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: approval-request-controller +description: A Helm chart for ApprovalRequest Controller on Hub Cluster +type: application +version: 0.1.0 +appVersion: "1.0" diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/_helpers.tpl b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/_helpers.tpl new file mode 100644 index 0000000..a603fac --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/_helpers.tpl @@ -0,0 +1,60 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "approval-request-controller.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "approval-request-controller.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "approval-request-controller.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "approval-request-controller.labels" -}} +helm.sh/chart: {{ include "approval-request-controller.chart" . }} +{{ include "approval-request-controller.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "approval-request-controller.selectorLabels" -}} +app.kubernetes.io/name: {{ include "approval-request-controller.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "approval-request-controller.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "approval-request-controller.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml new file mode 120000 index 0000000..59e890b --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml @@ -0,0 +1 @@ +../../../../config/crd/bases/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectorreports.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectorreports.yaml new file mode 120000 index 0000000..8060e40 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectorreports.yaml @@ -0,0 +1 @@ +../../../../config/crd/bases/metric.kubernetes-fleet.io_metriccollectorreports.yaml \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml new file mode 120000 index 0000000..433fbc2 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml @@ -0,0 +1 @@ +../../../../config/crd/bases/metric.kubernetes-fleet.io_metriccollectors.yaml \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml new file mode 120000 index 0000000..3d7e91c --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml @@ -0,0 +1 @@ +../../../../config/crd/bases/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/deployment.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/deployment.yaml new file mode 100644 index 0000000..654acba --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/deployment.yaml @@ -0,0 +1,84 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "approval-request-controller.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "approval-request-controller.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.controller.replicas }} + selector: + matchLabels: + {{- include "approval-request-controller.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "approval-request-controller.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "approval-request-controller.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: controller + securityContext: + {{- toYaml .Values.securityContext | nindent 10 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - /approval-request-controller + args: + - --v={{ .Values.controller.logLevel }} + - --metrics-bind-address=:{{ .Values.metrics.port }} + - --health-probe-bind-address=:{{ .Values.healthProbe.port }} + + ports: + {{- if .Values.metrics.enabled }} + - name: metrics + containerPort: {{ .Values.metrics.port }} + protocol: TCP + {{- end }} + {{- if .Values.healthProbe.enabled }} + - name: health + containerPort: {{ .Values.healthProbe.port }} + protocol: TCP + {{- end }} + + {{- if .Values.healthProbe.enabled }} + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 15 + periodSeconds: 20 + + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + {{- end }} + + resources: + {{- toYaml .Values.controller.resources | nindent 10 }} + + {{- with .Values.controller.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.controller.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.controller.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml new file mode 100644 index 0000000..0b80b84 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml @@ -0,0 +1,72 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "approval-request-controller.fullname" . }} + labels: + {{- include "approval-request-controller.labels" . | nindent 4 }} +rules: + # CRD access for checking prerequisites + - apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["get", "list"] + + # ApprovalRequest and ClusterApprovalRequest (KubeFleet resources) + - apiGroups: ["placement.kubernetes-fleet.io"] + resources: ["approvalrequests", "clusterapprovalrequests"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: ["placement.kubernetes-fleet.io"] + resources: ["approvalrequests/status", "clusterapprovalrequests/status"] + verbs: ["update", "patch"] + - apiGroups: ["placement.kubernetes-fleet.io"] + resources: ["approvalrequests/finalizers", "clusterapprovalrequests/finalizers"] + verbs: ["update"] + + # MetricCollector and MetricCollectorReport (our custom resources) + - apiGroups: ["metric.kubernetes-fleet.io"] + resources: ["metriccollectors", "metriccollectorreports"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["metric.kubernetes-fleet.io"] + resources: ["metriccollectors/status", "metriccollectorreports/status"] + verbs: ["update", "patch"] + + # ClusterResourcePlacement and ClusterResourceOverride (KubeFleet resources) + - apiGroups: ["placement.kubernetes-fleet.io"] + resources: ["clusterresourceplacements", "clusterresourceoverrides"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + + # UpdateRuns (KubeFleet resources) + - apiGroups: ["placement.kubernetes-fleet.io"] + resources: ["stagedupdateruns", "clusterstagedupdateruns"] + verbs: ["get", "list", "watch"] + + # WorkloadTracker (our custom resource) + - apiGroups: ["metric.kubernetes-fleet.io"] + resources: ["workloadtrackers"] + verbs: ["get", "list", "watch"] + + # Events + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + + # Leader election + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "create", "update", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "approval-request-controller.fullname" . }} + labels: + {{- include "approval-request-controller.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "approval-request-controller.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "approval-request-controller.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/serviceaccount.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/serviceaccount.yaml new file mode 100644 index 0000000..ba3fdd1 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "approval-request-controller.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "approval-request-controller.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/values.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/values.yaml new file mode 100644 index 0000000..89713c0 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/values.yaml @@ -0,0 +1,84 @@ +# Default values for approval-request-controller +# This is a YAML-formatted file. + +# Controller image configuration +image: + repository: approval-request-controller + pullPolicy: IfNotPresent + tag: "latest" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# Controller configuration +controller: + # Number of replicas + replicas: 1 + + # Log verbosity level (0-10) + logLevel: 2 + + # Resource requests and limits + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + + # Node selector + nodeSelector: {} + + # Tolerations + tolerations: [] + + # Affinity + affinity: {} + +# RBAC configuration +rbac: + create: true + +# ServiceAccount configuration +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +# Pod annotations +podAnnotations: {} + +# Pod security context +podSecurityContext: + runAsNonRoot: true + runAsUser: 65532 + fsGroup: 65532 + +# Container security context +securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + +# Metrics server configuration +metrics: + enabled: true + port: 8080 + +# Health probe configuration +healthProbe: + enabled: true + port: 8081 + +# CRD installation +crds: + # Install MetricCollectorReport CRD + install: true diff --git a/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go b/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go new file mode 100644 index 0000000..3f8bbb9 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go @@ -0,0 +1,168 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "flag" + "fmt" + "os" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + localv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1" + "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller/pkg/controller" + placementv1beta1 "github.com/kubefleet-dev/kubefleet/apis/placement/v1beta1" +) + +var ( + scheme = runtime.NewScheme() +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(placementv1beta1.AddToScheme(scheme)) + utilruntime.Must(localv1alpha1.AddToScheme(scheme)) + utilruntime.Must(apiextensionsv1.AddToScheme(scheme)) +} + +func main() { + var metricsAddr string + var probeAddr string + var logLevel int + + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.IntVar(&logLevel, "v", 2, "Log level (0-10)") + + opts := zap.Options{ + Development: true, + } + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + klog.InfoS("Starting ApprovalRequest Controller", "logLevel", logLevel) + + config := ctrl.GetConfigOrDie() + + // Check required CRDs are installed before starting + if err := checkRequiredCRDs(config); err != nil { + klog.ErrorS(err, "Required CRDs not found") + os.Exit(1) + } + + mgr, err := ctrl.NewManager(config, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: metricsAddr, + }, + HealthProbeBindAddress: probeAddr, + }) + if err != nil { + klog.ErrorS(err, "Unable to create manager") + os.Exit(1) + } + + // Setup ApprovalRequest controller + approvalRequestReconciler := &controller.Reconciler{ + Client: mgr.GetClient(), + } + if err = approvalRequestReconciler.SetupWithManagerForApprovalRequest(mgr); err != nil { + klog.ErrorS(err, "Unable to create controller", "controller", "ApprovalRequest") + os.Exit(1) + } + + // Setup ClusterApprovalRequest controller + clusterApprovalRequestReconciler := &controller.Reconciler{ + Client: mgr.GetClient(), + } + if err = clusterApprovalRequestReconciler.SetupWithManagerForClusterApprovalRequest(mgr); err != nil { + klog.ErrorS(err, "Unable to create controller", "controller", "ClusterApprovalRequest") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + klog.ErrorS(err, "Unable to set up health check") + os.Exit(1) + } + + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + klog.ErrorS(err, "Unable to set up ready check") + os.Exit(1) + } + + klog.InfoS("Starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + klog.ErrorS(err, "Problem running manager") + os.Exit(1) + } +} + +// checkRequiredCRDs checks that all required CRDs are installed +func checkRequiredCRDs(config *rest.Config) error { + requiredCRDs := []string{ + "approvalrequests.placement.kubernetes-fleet.io", + "clusterapprovalrequests.placement.kubernetes-fleet.io", + "metriccollectors.metric.kubernetes-fleet.io", + "metriccollectorreports.metric.kubernetes-fleet.io", + "workloadtrackers.metric.kubernetes-fleet.io", + "clusterresourceplacements.placement.kubernetes-fleet.io", + "clusterresourceoverrides.placement.kubernetes-fleet.io", + "clusterstagedupdateruns.placement.kubernetes-fleet.io", + "stagedupdateruns.placement.kubernetes-fleet.io", + } + + klog.InfoS("Checking for required CRDs", "count", len(requiredCRDs)) + + c, err := client.New(config, client.Options{Scheme: scheme}) + if err != nil { + return err + } + + ctx := context.Background() + missingCRDs := []string{} + + for _, crdName := range requiredCRDs { + crd := &apiextensionsv1.CustomResourceDefinition{} + err := c.Get(ctx, client.ObjectKey{Name: crdName}, crd) + if err != nil { + klog.ErrorS(err, "CRD not found", "crd", crdName) + missingCRDs = append(missingCRDs, crdName) + } else { + klog.V(3).InfoS("CRD found", "crd", crdName) + } + } + + if len(missingCRDs) > 0 { + return fmt.Errorf("missing required CRDs: %v", missingCRDs) + } + + klog.InfoS("All required CRDs are installed") + return nil +} diff --git a/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml b/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml new file mode 100644 index 0000000..d2a3bb6 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml @@ -0,0 +1,64 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.0 + name: clusterstagedworkloadtrackers.metric.kubernetes-fleet.io +spec: + group: metric.kubernetes-fleet.io + names: + categories: + - fleet + - fleet-placement + kind: ClusterStagedWorkloadTracker + listKind: ClusterStagedWorkloadTrackerList + plural: clusterstagedworkloadtrackers + singular: clusterstagedworkloadtracker + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ClusterStagedWorkloadTracker expresses user intent to track certain workloads for a ClusterStagedUpdateRun. + The name of this resource should match the name of the ClusterStagedUpdateRun it is used for. + For example, if the ClusterStagedUpdateRun is named "example-cluster-staged-run", the + ClusterStagedWorkloadTracker should also be named "example-cluster-staged-run". + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + workloads: + description: Workloads is a list of workloads to track + items: + description: WorkloadReference represents a workload to be tracked + properties: + name: + description: Name is the name of the workload + type: string + namespace: + description: Namespace is the namespace of the workload + type: string + required: + - name + - namespace + type: object + type: array + type: object + served: true + storage: true diff --git a/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectorreports.yaml b/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectorreports.yaml new file mode 100644 index 0000000..a530498 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectorreports.yaml @@ -0,0 +1,176 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.0 + name: metriccollectorreports.metric.kubernetes-fleet.io +spec: + group: metric.kubernetes-fleet.io + names: + categories: + - fleet + - fleet-metrics + kind: MetricCollectorReport + listKind: MetricCollectorReportList + plural: metriccollectorreports + shortNames: + - mcr + singular: metriccollectorreport + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .workloadsMonitored + name: Workloads + type: integer + - jsonPath: .lastCollectionTime + name: Last-Collection + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + MetricCollectorReport is created by the MetricCollector controller on the hub cluster + in the fleet-member-{clusterName} namespace to report collected metrics from a member cluster. + The controller watches MetricCollector objects on the member cluster, collects metrics, + and syncs the status to the hub as MetricCollectorReport objects. + + Controller workflow: + 1. MetricCollector reconciles and collects metrics on member cluster + 2. Metrics include clusterName from workload_health labels + 3. Controller creates/updates MetricCollectorReport in fleet-member-{clusterName} namespace on hub + 4. Report name matches MetricCollector name for easy lookup + + Namespace: fleet-member-{clusterName} (extracted from CollectedMetrics[0].ClusterName) + Name: Same as MetricCollector name + All metrics in CollectedMetrics are guaranteed to have the same ClusterName. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + collectedMetrics: + description: |- + CollectedMetrics contains the most recent metrics from each workload. + All metrics are guaranteed to have the same ClusterName since they're collected from one member cluster. + items: + description: WorkloadMetrics represents metrics collected from a single + workload pod. + properties: + clusterName: + description: ClusterName from the workload_health metric label. + type: string + health: + description: Health indicates if the workload is healthy (true=healthy, + false=unhealthy). + type: boolean + namespace: + description: Namespace is the namespace of the pod. + type: string + workloadName: + description: WorkloadName from the workload_health metric label + (typically the deployment name). + type: string + required: + - clusterName + - health + - namespace + - workloadName + type: object + type: array + conditions: + description: Conditions copied from the MetricCollector status. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + lastCollectionTime: + description: LastCollectionTime is when metrics were last collected on + the member cluster. + format: date-time + type: string + lastReportTime: + description: LastReportTime is when this report was last synced to the + hub. + format: date-time + type: string + metadata: + type: object + observedGeneration: + description: ObservedGeneration is the generation most recently observed + from the MetricCollector. + format: int64 + type: integer + workloadsMonitored: + description: WorkloadsMonitored is the count of workloads being monitored. + format: int32 + type: integer + type: object + served: true + storage: true + subresources: {} diff --git a/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectors.yaml b/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectors.yaml new file mode 100644 index 0000000..c97705d --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectors.yaml @@ -0,0 +1,189 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.0 + name: metriccollectors.metric.kubernetes-fleet.io +spec: + group: metric.kubernetes-fleet.io + names: + categories: + - fleet + - fleet-metrics + kind: MetricCollector + listKind: MetricCollectorList + plural: metriccollectors + shortNames: + - mc + singular: metriccollector + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.generation + name: Gen + type: string + - jsonPath: .status.conditions[?(@.type=="MetricCollectorReady")].status + name: Ready + type: string + - jsonPath: .status.workloadsMonitored + name: Workloads + type: integer + - jsonPath: .status.lastCollectionTime + name: Last-Collection + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + MetricCollector is used by member-agent to scrape and collect metrics from workloads + running on the member cluster. It runs on each member cluster and collects metrics + from Prometheus-compatible endpoints. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: The desired state of MetricCollector. + properties: + prometheusUrl: + description: |- + PrometheusURL is the URL of the Prometheus server. + Example: http://prometheus.test-ns.svc.cluster.local:9090 + pattern: ^https?://.*$ + type: string + reportNamespace: + description: |- + ReportNamespace is the namespace in the hub cluster where the MetricCollectorReport will be created. + This should be the fleet-member-{clusterName} namespace. + Example: fleet-member-cluster-1 + type: string + required: + - prometheusUrl + - reportNamespace + type: object + status: + description: The observed status of MetricCollector. + properties: + collectedMetrics: + description: CollectedMetrics contains the most recent metrics from + each workload. + items: + description: WorkloadMetrics represents metrics collected from a + single workload pod. + properties: + clusterName: + description: ClusterName from the workload_health metric label. + type: string + health: + description: Health indicates if the workload is healthy (true=healthy, + false=unhealthy). + type: boolean + namespace: + description: Namespace is the namespace of the pod. + type: string + workloadName: + description: WorkloadName from the workload_health metric label + (typically the deployment name). + type: string + required: + - clusterName + - health + - namespace + - workloadName + type: object + type: array + conditions: + description: Conditions is an array of current observed conditions. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastCollectionTime: + description: LastCollectionTime is when metrics were last collected. + format: date-time + type: string + observedGeneration: + description: ObservedGeneration is the generation most recently observed. + format: int64 + type: integer + workloadsMonitored: + description: WorkloadsMonitored is the count of workloads being monitored. + format: int32 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml b/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml new file mode 100644 index 0000000..d394429 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml @@ -0,0 +1,64 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.0 + name: stagedworkloadtrackers.metric.kubernetes-fleet.io +spec: + group: metric.kubernetes-fleet.io + names: + categories: + - fleet + - fleet-placement + kind: StagedWorkloadTracker + listKind: StagedWorkloadTrackerList + plural: stagedworkloadtrackers + singular: stagedworkloadtracker + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + StagedWorkloadTracker expresses user intent to track certain workloads for a StagedUpdateRun. + The name and namespace of this resource should match the name and namespace of the StagedUpdateRun it is used for. + For example, if the StagedUpdateRun is named "example-staged-run" in namespace "test-ns", the + StagedWorkloadTracker should also be named "example-staged-run" in namespace "test-ns". + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + workloads: + description: Workloads is a list of workloads to track + items: + description: WorkloadReference represents a workload to be tracked + properties: + name: + description: Name is the name of the workload + type: string + namespace: + description: Namespace is the namespace of the workload + type: string + required: + - name + - namespace + type: object + type: array + type: object + served: true + storage: true diff --git a/approval-controller-metric-collector/approval-request-controller/docker/approval-request-controller.Dockerfile b/approval-controller-metric-collector/approval-request-controller/docker/approval-request-controller.Dockerfile new file mode 100644 index 0000000..46a10d0 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/docker/approval-request-controller.Dockerfile @@ -0,0 +1,27 @@ +# Build stage +FROM golang:1.24 AS builder + +WORKDIR /workspace + +# Copy go mod files +COPY approval-request-controller/go.mod approval-request-controller/go.sum* ./ +RUN go mod download + +# Copy source code +COPY approval-request-controller/apis/ apis/ +COPY approval-request-controller/pkg/ pkg/ +COPY approval-request-controller/cmd/ cmd/ + +# Build the controller +ARG GOARCH=amd64 +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${GOARCH} go build \ + -a -o approval-request-controller \ + ./cmd/approvalrequestcontroller + +# Runtime stage +FROM gcr.io/distroless/static:nonroot +WORKDIR / +COPY --from=builder /workspace/approval-request-controller . +USER 65532:65532 + +ENTRYPOINT ["/approval-request-controller"] diff --git a/approval-controller-metric-collector/approval-request-controller/examples/membercluster/fleet_v1beta1_membercluster.yaml b/approval-controller-metric-collector/approval-request-controller/examples/membercluster/fleet_v1beta1_membercluster.yaml new file mode 100644 index 0000000..ceb7d7b --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/membercluster/fleet_v1beta1_membercluster.yaml @@ -0,0 +1,41 @@ +apiVersion: cluster.kubernetes-fleet.io/v1beta1 +kind: MemberCluster +metadata: + name: kind-cluster-1 + labels: + environment: staging + kubernetes-fleet.io/cluster-name: kind-cluster-1 +spec: + identity: + name: fleet-member-agent-cluster-1 + kind: ServiceAccount + namespace: fleet-system + apiGroup: "" +--- +apiVersion: cluster.kubernetes-fleet.io/v1beta1 +kind: MemberCluster +metadata: + name: kind-cluster-2 + labels: + environment: prod + kubernetes-fleet.io/cluster-name: kind-cluster-2 +spec: + identity: + name: fleet-member-agent-cluster-2 + kind: ServiceAccount + namespace: fleet-system + apiGroup: "" +--- +apiVersion: cluster.kubernetes-fleet.io/v1beta1 +kind: MemberCluster +metadata: + name: kind-cluster-3 + labels: + environment: prod + kubernetes-fleet.io/cluster-name: kind-cluster-3 +spec: + identity: + name: fleet-member-agent-cluster-3 + kind: ServiceAccount + namespace: fleet-system + apiGroup: "" \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/examples/prometheus/configmap.yaml b/approval-controller-metric-collector/approval-request-controller/examples/prometheus/configmap.yaml new file mode 100644 index 0000000..e0d33b7 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/prometheus/configmap.yaml @@ -0,0 +1,42 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: prometheus-config + namespace: prometheus +data: + prometheus.yml: | + global: + scrape_interval: 15s + evaluation_interval: 15s + + scrape_configs: + - job_name: 'kubernetes-pods' + kubernetes_sd_configs: + - role: pod + # Scrape pods from all namespaces + relabel_configs: + # Only scrape pods with prometheus.io/scrape annotation + - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape] + action: keep + regex: true + # Use the port from prometheus.io/port annotation or default pod IP + - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port] + action: replace + regex: ([^:]+)(?::\d+)?;(\d+) + replacement: $1:$2 + target_label: __address__ + # Use the path from prometheus.io/path annotation or default /metrics + - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path] + action: replace + target_label: __metrics_path__ + regex: (.+) + # Add pod metadata as labels + - source_labels: [__meta_kubernetes_namespace] + target_label: namespace + - source_labels: [__meta_kubernetes_pod_name] + target_label: pod + - source_labels: [__meta_kubernetes_pod_label_app] + target_label: app + # Add CLUSTER_NAME and WORKLOAD_NAME from env vars if present + - action: labelmap + regex: __meta_kubernetes_pod_label_(.+) diff --git a/approval-controller-metric-collector/approval-request-controller/examples/prometheus/deployment.yaml b/approval-controller-metric-collector/approval-request-controller/examples/prometheus/deployment.yaml new file mode 100644 index 0000000..a922073 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/prometheus/deployment.yaml @@ -0,0 +1,48 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prometheus + namespace: prometheus + labels: + app: prometheus +spec: + replicas: 1 + selector: + matchLabels: + app: prometheus + template: + metadata: + labels: + app: prometheus + spec: + serviceAccountName: prometheus + containers: + - name: prometheus + image: prom/prometheus:v2.47.0 + args: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/usr/share/prometheus/console_libraries' + - '--web.console.templates=/usr/share/prometheus/consoles' + - '--web.enable-lifecycle' + ports: + - name: web + containerPort: 9090 + volumeMounts: + - name: prometheus-config + mountPath: /etc/prometheus + - name: prometheus-storage + mountPath: /prometheus + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: prometheus-config + configMap: + name: prometheus-config + - name: prometheus-storage + emptyDir: {} diff --git a/approval-controller-metric-collector/approval-request-controller/examples/prometheus/prometheus-crp.yaml b/approval-controller-metric-collector/approval-request-controller/examples/prometheus/prometheus-crp.yaml new file mode 100644 index 0000000..a240843 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/prometheus/prometheus-crp.yaml @@ -0,0 +1,22 @@ +apiVersion: placement.kubernetes-fleet.io/v1beta1 +kind: ClusterResourcePlacement +metadata: + name: proemetheus-crp +spec: + resourceSelectors: + - group: "" + version: v1 + kind: Namespace + name: prometheus + - group: "rbac.authorization.k8s.io" + version: v1 + kind: ClusterRole + name: prometheus + - group: "rbac.authorization.k8s.io" + version: v1 + kind: ClusterRoleBinding + name: prometheus + policy: + placementType: PickAll + strategy: + type: RollingUpdate \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/examples/prometheus/rbac.yaml b/approval-controller-metric-collector/approval-request-controller/examples/prometheus/rbac.yaml new file mode 100644 index 0000000..4dd638d --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/prometheus/rbac.yaml @@ -0,0 +1,39 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: prometheus + namespace: prometheus +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: prometheus +rules: + - apiGroups: [""] + resources: + - nodes + - nodes/proxy + - services + - endpoints + - pods + verbs: ["get", "list", "watch"] + - apiGroups: + - extensions + resources: + - ingresses + verbs: ["get", "list", "watch"] + - nonResourceURLs: ["/metrics"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: prometheus +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: prometheus +subjects: + - kind: ServiceAccount + name: prometheus + namespace: prometheus diff --git a/approval-controller-metric-collector/approval-request-controller/examples/prometheus/service.yaml b/approval-controller-metric-collector/approval-request-controller/examples/prometheus/service.yaml new file mode 100644 index 0000000..ff61964 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/prometheus/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: prometheus + namespace: prometheus + labels: + app: prometheus +spec: + type: ClusterIP + ports: + - name: web + port: 9090 + targetPort: 9090 + protocol: TCP + selector: + app: prometheus diff --git a/approval-controller-metric-collector/approval-request-controller/examples/sample-metric-app/sample-metric-app.yaml b/approval-controller-metric-collector/approval-request-controller/examples/sample-metric-app/sample-metric-app.yaml new file mode 100644 index 0000000..5deb993 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/sample-metric-app/sample-metric-app.yaml @@ -0,0 +1,27 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sample-metric-app + namespace: test-ns + labels: + app: sample-metric-app +spec: + replicas: 1 + selector: + matchLabels: + app: sample-metric-app + template: + metadata: + labels: + app: sample-metric-app + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + prometheus.io/path: "/metrics" + spec: + containers: + - name: metric-app + image: metric-app:local + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-crp.yaml b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-crp.yaml new file mode 100644 index 0000000..21a0827 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-crp.yaml @@ -0,0 +1,14 @@ +apiVersion: placement.kubernetes-fleet.io/v1beta1 +kind: ClusterResourcePlacement +metadata: + name: example-crp +spec: + resourceSelectors: + - group: "" + kind: Namespace + name: test-ns + version: v1 + policy: + placementType: PickAll + strategy: + type: External diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csur.yaml b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csur.yaml new file mode 100644 index 0000000..1ed0408 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csur.yaml @@ -0,0 +1,10 @@ +apiVersion: placement.kubernetes-fleet.io/v1beta1 +kind: ClusterStagedUpdateRun +metadata: + name: example-cluster-staged-run +spec: + placementName: example-crp + resourceSnapshotIndex: "0" + stagedRolloutStrategyName: example-cluster-staged-strategy + state: Run + \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csus.yaml b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csus.yaml new file mode 100644 index 0000000..14db148 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csus.yaml @@ -0,0 +1,18 @@ +apiVersion: placement.kubernetes-fleet.io/v1beta1 +kind: ClusterStagedUpdateStrategy +metadata: + name: example-cluster-staged-strategy +spec: + stages: + - name: staging + labelSelector: + matchLabels: + environment: staging + afterStageTasks: + - type: Approval + - name: prod + labelSelector: + matchLabels: + environment: prod + afterStageTasks: + - type: Approval \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-ns-only-crp.yaml b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-ns-only-crp.yaml new file mode 100644 index 0000000..ddff3f0 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-ns-only-crp.yaml @@ -0,0 +1,15 @@ +apiVersion: placement.kubernetes-fleet.io/v1beta1 +kind: ClusterResourcePlacement +metadata: + name: ns-only-crp +spec: + resourceSelectors: + - group: "" + kind: Namespace + name: test-ns + version: v1 + selectionScope: NamespaceOnly + policy: + placementType: PickAll + strategy: + type: RollingUpdate \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-rp.yaml b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-rp.yaml new file mode 100644 index 0000000..0836868 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-rp.yaml @@ -0,0 +1,15 @@ +apiVersion: placement.kubernetes-fleet.io/v1beta1 +kind: ResourcePlacement +metadata: + name: example-rp + namespace: test-ns +spec: + resourceSelectors: + - group: "apps" + kind: Deployment + name: sample-metric-app + version: v1 + policy: + placementType: PickAll + strategy: + type: External \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sur.yaml b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sur.yaml new file mode 100644 index 0000000..9c3b5eb --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sur.yaml @@ -0,0 +1,11 @@ +apiVersion: placement.kubernetes-fleet.io/v1beta1 +kind: StagedUpdateRun +metadata: + name: example-staged-run + namespace: test-ns +spec: + placementName: example-rp + resourceSnapshotIndex: "0" + stagedRolloutStrategyName: example-staged-strategy + state: Run + \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sus.yaml b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sus.yaml new file mode 100644 index 0000000..7b2798b --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sus.yaml @@ -0,0 +1,19 @@ +apiVersion: placement.kubernetes-fleet.io/v1beta1 +kind: StagedUpdateStrategy +metadata: + name: example-staged-strategy + namespace: test-ns +spec: + stages: + - name: staging + labelSelector: + matchLabels: + environment: staging + afterStageTasks: + - type: Approval + - name: prod + labelSelector: + matchLabels: + environment: prod + afterStageTasks: + - type: Approval \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/examples/workloadtracker/clusterstagedworkloadtracker.yaml b/approval-controller-metric-collector/approval-request-controller/examples/workloadtracker/clusterstagedworkloadtracker.yaml new file mode 100644 index 0000000..37c36ec --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/workloadtracker/clusterstagedworkloadtracker.yaml @@ -0,0 +1,8 @@ +apiVersion: metric.kubernetes-fleet.io/v1alpha1 +kind: ClusterStagedWorkloadTracker +metadata: + # The name must match the name of the ClusterStagedUpdateRun it is used for + name: example-cluster-staged-run +workloads: + - name: sample-metric-app + namespace: test-ns diff --git a/approval-controller-metric-collector/approval-request-controller/examples/workloadtracker/stagedworkloadtracker.yaml b/approval-controller-metric-collector/approval-request-controller/examples/workloadtracker/stagedworkloadtracker.yaml new file mode 100644 index 0000000..bb27131 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/examples/workloadtracker/stagedworkloadtracker.yaml @@ -0,0 +1,9 @@ +apiVersion: metric.kubernetes-fleet.io/v1alpha1 +kind: StagedWorkloadTracker +metadata: + # The name and namespace must match the name and namespace of the StagedUpdateRun it is used for + name: example-staged-run + namespace: test-ns +workloads: + - name: sample-metric-app + namespace: test-ns diff --git a/approval-controller-metric-collector/approval-request-controller/go.mod b/approval-controller-metric-collector/approval-request-controller/go.mod new file mode 100644 index 0000000..4e1df5c --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/go.mod @@ -0,0 +1,72 @@ +module github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller + +go 1.24.9 + +require ( + github.com/kubefleet-dev/kubefleet v0.1.2 + k8s.io/apiextensions-apiserver v0.34.1 + k8s.io/apimachinery v0.34.1 + k8s.io/client-go v0.34.1 + k8s.io/klog/v2 v2.130.1 + sigs.k8s.io/controller-runtime v0.22.4 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/onsi/gomega v1.37.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.goms.io/fleet-networking v0.3.3 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.29.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.11.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.34.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/metrics v0.32.3 // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/approval-controller-metric-collector/approval-request-controller/go.sum b/approval-controller-metric-collector/approval-request-controller/go.sum new file mode 100644 index 0000000..90d0995 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/go.sum @@ -0,0 +1,196 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kubefleet-dev/kubefleet v0.1.2 h1:BUOwehI9iBavU6TEbebrSxtFXHwyOcY1eacHyfHEjxo= +github.com/kubefleet-dev/kubefleet v0.1.2/go.mod h1:EYDCdtdM02qQkH3Gm5/K1cHDy26f2LbM7WzVGn2saLs= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.goms.io/fleet-networking v0.3.3 h1:5rwBntaUoLF+E1CzaWAEL4GdvLJPQorKhjgkbLlllPE= +go.goms.io/fleet-networking v0.3.3/go.mod h1:Qgbi8M1fGaz/p5rtb6HJPmTDATWRnMt9HD1gz57WKUc= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= +golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= +k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/metrics v0.32.3 h1:2vsBvw0v8rIIlczZ/lZ8Kcqk9tR6Fks9h+dtFNbc2a4= +k8s.io/metrics v0.32.3/go.mod h1:9R1Wk5cb+qJpCQon9h52mgkVCcFeYxcY+YkumfwHVCU= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= +sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/approval-controller-metric-collector/approval-request-controller/hack/boilerplate.go.txt b/approval-controller-metric-collector/approval-request-controller/hack/boilerplate.go.txt new file mode 100644 index 0000000..1f31a2d --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/hack/boilerplate.go.txt @@ -0,0 +1,15 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ diff --git a/approval-controller-metric-collector/approval-request-controller/install-on-hub.sh b/approval-controller-metric-collector/approval-request-controller/install-on-hub.sh new file mode 100755 index 0000000..66e8bf7 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/install-on-hub.sh @@ -0,0 +1,97 @@ +#!/bin/bash +set -e + +# Configuration +HUB_CONTEXT="kind-hub" +IMAGE_NAME="approval-request-controller" +IMAGE_TAG="latest" +NAMESPACE="fleet-system" +CHART_NAME="approval-request-controller" + +echo "=== Installing ApprovalRequest Controller on hub cluster ===" +echo "Hub cluster: ${HUB_CONTEXT}" +echo "Namespace: ${NAMESPACE}" +echo "" + +# Step 0: Build and load Docker image +echo "Step 0: Building and loading Docker image..." +cd .. +docker buildx build \ + --file approval-request-controller/docker/approval-request-controller.Dockerfile \ + --output=type=docker \ + --platform=linux/$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \ + --tag ${IMAGE_NAME}:${IMAGE_TAG} \ + --build-arg GOARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \ + . +cd approval-request-controller +kind load docker-image ${IMAGE_NAME}:${IMAGE_TAG} --name hub +echo "✓ Docker image built and loaded into kind cluster" +echo "" + +# Step 1: Verify kubefleet CRDs are installed +echo "Step 1: Verifying required kubefleet CRDs..." +REQUIRED_CRDS=( + "approvalrequests.placement.kubernetes-fleet.io" + "clusterapprovalrequests.placement.kubernetes-fleet.io" + "clusterresourceplacements.placement.kubernetes-fleet.io" + "clusterresourceoverrides.placement.kubernetes-fleet.io" + "clusterstagedupdateruns.placement.kubernetes-fleet.io" + "stagedupdateruns.placement.kubernetes-fleet.io" +) + +MISSING_CRDS=() +for crd in "${REQUIRED_CRDS[@]}"; do + if ! kubectl --context=${HUB_CONTEXT} get crd ${crd} &>/dev/null; then + MISSING_CRDS+=("${crd}") + fi +done + +if [ ${#MISSING_CRDS[@]} -ne 0 ]; then + echo "Error: Missing required CRDs from kubefleet hub-agent:" + for crd in "${MISSING_CRDS[@]}"; do + echo " - ${crd}" + done + echo "" + echo "Please ensure kubefleet hub-agent is installed first." + exit 1 +fi + +echo "✓ All required kubefleet CRDs are installed" +echo "" + +# Step 2: Install helm chart on hub cluster (includes MetricCollector, MetricCollectorReport, WorkloadTracker CRDs) +echo "Step 2: Installing helm chart on hub cluster..." +helm upgrade --install ${CHART_NAME} ./charts/${CHART_NAME} \ + --kube-context=${HUB_CONTEXT} \ + --namespace ${NAMESPACE} \ + --create-namespace \ + --set image.repository=${IMAGE_NAME} \ + --set image.tag=${IMAGE_TAG} \ + --set image.pullPolicy=IfNotPresent \ + --set controller.logLevel=2 + +echo "✓ Helm chart installed on hub cluster" +echo "" + +# Step 3: Verify installation +echo "Step 3: Verifying installation..." +echo "Checking CRDs installed by this chart..." +kubectl --context=${HUB_CONTEXT} get crd | grep -E "metriccollectors|metriccollectorreports|workloadtrackers" || echo " (CRDs may take a moment to appear)" + +echo "" +echo "Checking pods in ${NAMESPACE}..." +kubectl --context=${HUB_CONTEXT} get pods -n ${NAMESPACE} -l app.kubernetes.io/name=${CHART_NAME} + +echo "" +echo "=== Installation Complete ===" +echo "" +echo "To check controller logs:" +echo " kubectl --context=${HUB_CONTEXT} logs -n ${NAMESPACE} -l app.kubernetes.io/name=${CHART_NAME} -f" +echo "" +echo "To verify CRDs:" +echo " kubectl --context=${HUB_CONTEXT} get crd | grep placement.kubernetes-fleet.io" +echo "" +echo "Next steps:" +echo " 1. Create a WorkloadTracker to define which workloads to monitor" +echo " 2. ApprovalRequests will be automatically processed when created by staged updates" +echo "" diff --git a/approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go b/approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go new file mode 100644 index 0000000..ad32e61 --- /dev/null +++ b/approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go @@ -0,0 +1,628 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package controller features a controller to reconcile ApprovalRequest objects +// and create MetricCollector resources on member clusters for approved stages. +package controller + +import ( + "context" + "fmt" + "time" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + localv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1" + placementv1beta1 "github.com/kubefleet-dev/kubefleet/apis/placement/v1beta1" + "github.com/kubefleet-dev/kubefleet/pkg/utils" +) + +const ( + // metricCollectorFinalizer is the finalizer added to ApprovalRequest objects + metricCollectorFinalizer = "kubernetes-fleet.io/metric-collector-cleanup" + + // prometheusURL is the default Prometheus URL to use + prometheusURL = "http://prometheus.prometheus.svc.cluster.local:9090" +) + +// Reconciler reconciles an ApprovalRequest object and creates MetricCollector resources +// on member clusters when the approval is granted. +type Reconciler struct { + client.Client + recorder record.EventRecorder +} + +// Reconcile reconciles an ApprovalRequest or ClusterApprovalRequest object. +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + startTime := time.Now() + klog.V(2).InfoS("ApprovalRequest reconciliation starts", "request", req.NamespacedName) + defer func() { + latency := time.Since(startTime).Milliseconds() + klog.V(2).InfoS("ApprovalRequest reconciliation ends", "request", req.NamespacedName, "latency", latency) + }() + + var approvalReqObj placementv1beta1.ApprovalRequestObj + var isClusterScoped bool + + // Check if request has a namespace to determine resource type + if req.Namespace != "" { + // Fetch namespaced ApprovalRequest + approvalReq := &placementv1beta1.ApprovalRequest{} + if err := r.Client.Get(ctx, req.NamespacedName, approvalReq); err != nil { + if errors.IsNotFound(err) { + klog.V(2).InfoS("ApprovalRequest not found, ignoring", "request", req.NamespacedName) + return ctrl.Result{}, nil + } + klog.ErrorS(err, "Failed to get ApprovalRequest", "request", req.NamespacedName) + return ctrl.Result{}, err + } + approvalReqObj = approvalReq + isClusterScoped = false + } else { + // Fetch cluster-scoped ClusterApprovalRequest + clusterApprovalReq := &placementv1beta1.ClusterApprovalRequest{} + if err := r.Client.Get(ctx, types.NamespacedName{Name: req.Name}, clusterApprovalReq); err != nil { + if errors.IsNotFound(err) { + klog.V(2).InfoS("ClusterApprovalRequest not found, ignoring", "request", req.Name) + return ctrl.Result{}, nil + } + klog.ErrorS(err, "Failed to get ClusterApprovalRequest", "request", req.Name) + return ctrl.Result{}, err + } + approvalReqObj = clusterApprovalReq + isClusterScoped = true + } + + return r.reconcileApprovalRequestObj(ctx, approvalReqObj, isClusterScoped) +} + +// reconcileApprovalRequestObj reconciles an ApprovalRequestObj (either ApprovalRequest or ClusterApprovalRequest). +func (r *Reconciler) reconcileApprovalRequestObj(ctx context.Context, approvalReqObj placementv1beta1.ApprovalRequestObj, isClusterScoped bool) (ctrl.Result, error) { + obj := approvalReqObj.(client.Object) + approvalReqRef := klog.KObj(obj) + + // Handle deletion + if !obj.GetDeletionTimestamp().IsZero() { + return r.handleDelete(ctx, approvalReqObj) + } + + // Check if the approval request is already approved or rejected - stop reconciliation if so + approvedCond := meta.FindStatusCondition(approvalReqObj.GetApprovalRequestStatus().Conditions, string(placementv1beta1.ApprovalRequestConditionApproved)) + if approvedCond != nil && approvedCond.Status == metav1.ConditionTrue { + klog.V(2).InfoS("ApprovalRequest has been approved, stopping reconciliation", "approvalRequest", approvalReqRef) + return ctrl.Result{}, nil + } + + // Add finalizer if not present + if !controllerutil.ContainsFinalizer(obj, metricCollectorFinalizer) { + controllerutil.AddFinalizer(obj, metricCollectorFinalizer) + if err := r.Client.Update(ctx, obj); err != nil { + klog.ErrorS(err, "Failed to add finalizer", "approvalRequest", approvalReqRef) + return ctrl.Result{}, err + } + klog.V(2).InfoS("Added finalizer to ApprovalRequest", "approvalRequest", approvalReqRef) + } + + // Get the UpdateRun (ClusterStagedUpdateRun or StagedUpdateRun) + spec := approvalReqObj.GetApprovalRequestSpec() + updateRunName := spec.TargetUpdateRun + stageName := spec.TargetStage + + var stageStatus *placementv1beta1.StageUpdatingStatus + if isClusterScoped { + updateRun := &placementv1beta1.ClusterStagedUpdateRun{} + if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName}, updateRun); err != nil { + klog.ErrorS(err, "Failed to get ClusterStagedUpdateRun", "approvalRequest", approvalReqRef, "updateRun", updateRunName) + return ctrl.Result{}, err + } + + // Find the stage + for i := range updateRun.Status.StagesStatus { + if updateRun.Status.StagesStatus[i].StageName == stageName { + stageStatus = &updateRun.Status.StagesStatus[i] + break + } + } + } else { + updateRun := &placementv1beta1.StagedUpdateRun{} + if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName, Namespace: obj.GetNamespace()}, updateRun); err != nil { + klog.ErrorS(err, "Failed to get StagedUpdateRun", "approvalRequest", approvalReqRef, "updateRun", updateRunName) + return ctrl.Result{}, err + } + + // Find the stage + for i := range updateRun.Status.StagesStatus { + if updateRun.Status.StagesStatus[i].StageName == stageName { + stageStatus = &updateRun.Status.StagesStatus[i] + break + } + } + } + + if stageStatus == nil { + err := fmt.Errorf("stage %s not found in UpdateRun %s", stageName, updateRunName) + klog.ErrorS(err, "Failed to find stage", "approvalRequest", approvalReqRef) + return ctrl.Result{}, err + } + + // Get all cluster names from the stage + clusterNames := make([]string, 0, len(stageStatus.Clusters)) + for _, cluster := range stageStatus.Clusters { + clusterNames = append(clusterNames, cluster.ClusterName) + } + + if len(clusterNames) == 0 { + klog.V(2).InfoS("No clusters in stage, skipping", "approvalRequest", approvalReqRef, "stage", stageName) + return ctrl.Result{}, nil + } + + klog.V(2).InfoS("Found clusters in stage", "approvalRequest", approvalReqRef, "stage", stageName, "clusters", clusterNames) + + // Create or update the MetricCollector resource, CRP, and ResourceOverrides + if err := r.ensureMetricCollectorResources(ctx, obj, clusterNames, updateRunName, stageName); err != nil { + klog.ErrorS(err, "Failed to ensure MetricCollector resources", "approvalRequest", approvalReqRef) + return ctrl.Result{}, err + } + + klog.V(2).InfoS("Successfully ensured MetricCollector resources", "approvalRequest", approvalReqRef, "clusters", clusterNames) + + // Check workload health and approve if all workloads are healthy + if err := r.checkWorkloadHealthAndApprove(ctx, approvalReqObj, clusterNames, updateRunName, stageName); err != nil { + klog.ErrorS(err, "Failed to check workload health", "approvalRequest", approvalReqRef) + return ctrl.Result{RequeueAfter: 15 * time.Second}, err + } + + // Requeue after 15 seconds to check again (will stop if approved in next reconciliation) + return ctrl.Result{RequeueAfter: 15 * time.Second}, nil +} + +// ensureMetricCollectorResources creates the Namespace, MetricCollector, CRP, and ResourceOverrides +func (r *Reconciler) ensureMetricCollectorResources( + ctx context.Context, + approvalReq client.Object, + clusterNames []string, + updateRunName, stageName string, +) error { + // Generate names + metricCollectorName := fmt.Sprintf("mc-%s-%s", updateRunName, stageName) + crpName := fmt.Sprintf("crp-mc-%s-%s", updateRunName, stageName) + roName := fmt.Sprintf("ro-mc-%s-%s", updateRunName, stageName) + + // Create MetricCollector resource (cluster-scoped) on hub + metricCollector := &localv1alpha1.MetricCollector{ + ObjectMeta: metav1.ObjectMeta{ + Name: metricCollectorName, + Labels: map[string]string{ + "app": "metric-collector", + "approval-request": approvalReq.GetName(), + "update-run": updateRunName, + "stage": stageName, + }, + }, + Spec: localv1alpha1.MetricCollectorSpec{ + PrometheusURL: prometheusURL, + // ReportNamespace will be overridden per cluster + ReportNamespace: "placeholder", + }, + } + + // Create or update MetricCollector + existingMC := &localv1alpha1.MetricCollector{} + err := r.Client.Get(ctx, types.NamespacedName{Name: metricCollectorName}, existingMC) + if err != nil { + if errors.IsNotFound(err) { + if err := r.Client.Create(ctx, metricCollector); err != nil { + return fmt.Errorf("failed to create MetricCollector: %w", err) + } + klog.V(2).InfoS("Created MetricCollector", "metricCollector", klog.KObj(metricCollector)) + } else { + return fmt.Errorf("failed to get MetricCollector: %w", err) + } + } + + // Create ResourceOverride with rules for each cluster + overrideRules := make([]placementv1beta1.OverrideRule, 0, len(clusterNames)) + for _, clusterName := range clusterNames { + reportNamespace := fmt.Sprintf(utils.NamespaceNameFormat, clusterName) + + overrideRules = append(overrideRules, placementv1beta1.OverrideRule{ + ClusterSelector: &placementv1beta1.ClusterSelector{ + ClusterSelectorTerms: []placementv1beta1.ClusterSelectorTerm{ + { + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "kubernetes-fleet.io/cluster-name": clusterName, + }, + }, + }, + }, + }, + JSONPatchOverrides: []placementv1beta1.JSONPatchOverride{ + { + Operator: placementv1beta1.JSONPatchOverrideOpReplace, + Path: "/spec/reportNamespace", + Value: apiextensionsv1.JSON{Raw: []byte(fmt.Sprintf(`"%s"`, reportNamespace))}, + }, + }, + }) + } + + // Create ClusterResourceOverride with rules for each cluster + clusterResourceOverride := &placementv1beta1.ClusterResourceOverride{ + ObjectMeta: metav1.ObjectMeta{ + Name: roName, + Labels: map[string]string{ + "approval-request": approvalReq.GetName(), + "update-run": updateRunName, + "stage": stageName, + }, + }, + Spec: placementv1beta1.ClusterResourceOverrideSpec{ + ClusterResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "metric.kubernetes-fleet.io", + Version: "v1alpha1", + Kind: "MetricCollector", + Name: metricCollectorName, + }, + }, + Policy: &placementv1beta1.OverridePolicy{ + OverrideRules: overrideRules, + }, + }, + } + + // Create or update ClusterResourceOverride + existingCRO := &placementv1beta1.ClusterResourceOverride{} + err = r.Client.Get(ctx, types.NamespacedName{Name: roName}, existingCRO) + if err != nil { + if errors.IsNotFound(err) { + if err := r.Client.Create(ctx, clusterResourceOverride); err != nil { + return fmt.Errorf("failed to create ClusterResourceOverride: %w", err) + } + klog.V(2).InfoS("Created ClusterResourceOverride", "clusterResourceOverride", roName) + } else { + return fmt.Errorf("failed to get ClusterResourceOverride: %w", err) + } + } + + // Create ClusterResourcePlacement with PickFixed policy + // CRP resource selector selects the MetricCollector directly + crp := &placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + Labels: map[string]string{ + "approval-request": approvalReq.GetName(), + "update-run": updateRunName, + "stage": stageName, + }, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "metric.kubernetes-fleet.io", + Version: "v1alpha1", + Kind: "MetricCollector", + Name: metricCollectorName, + }, + }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickFixedPlacementType, + ClusterNames: clusterNames, + }, + }, + } + + // Create or update CRP + existingCRP := &placementv1beta1.ClusterResourcePlacement{} + err = r.Client.Get(ctx, types.NamespacedName{Name: crpName}, existingCRP) + if err != nil { + if errors.IsNotFound(err) { + if err := r.Client.Create(ctx, crp); err != nil { + return fmt.Errorf("failed to create ClusterResourcePlacement: %w", err) + } + klog.V(2).InfoS("Created ClusterResourcePlacement", "crp", crpName) + } else { + return fmt.Errorf("failed to get ClusterResourcePlacement: %w", err) + } + } + + return nil +} + +// checkWorkloadHealthAndApprove checks if all workloads specified in ClusterStagedWorkloadTracker or StagedWorkloadTracker are healthy +// across all clusters in the stage, and approves the ApprovalRequest if they are. +func (r *Reconciler) checkWorkloadHealthAndApprove( + ctx context.Context, + approvalReqObj placementv1beta1.ApprovalRequestObj, + clusterNames []string, + updateRunName, stageName string, +) error { + obj := approvalReqObj.(client.Object) + approvalReqRef := klog.KObj(obj) + + klog.V(2).InfoS("Starting workload health check", "approvalRequest", approvalReqRef, "clusters", clusterNames) + + // Get the appropriate WorkloadTracker based on scope + // The WorkloadTracker name matches the UpdateRun name + var workloads []localv1alpha1.WorkloadReference + var workloadTrackerName string + + if obj.GetNamespace() == "" { + // Cluster-scoped: Get ClusterStagedWorkloadTracker with same name as ClusterStagedUpdateRun + clusterWorkloadTracker := &localv1alpha1.ClusterStagedWorkloadTracker{} + if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName}, clusterWorkloadTracker); err != nil { + if errors.IsNotFound(err) { + klog.V(2).InfoS("ClusterStagedWorkloadTracker not found, skipping health check", + "approvalRequest", approvalReqRef, + "updateRun", updateRunName) + return nil + } + klog.ErrorS(err, "Failed to get ClusterStagedWorkloadTracker", "approvalRequest", approvalReqRef, "updateRun", updateRunName) + return fmt.Errorf("failed to get ClusterStagedWorkloadTracker: %w", err) + } + workloads = clusterWorkloadTracker.Workloads + workloadTrackerName = clusterWorkloadTracker.Name + klog.V(2).InfoS("Found ClusterStagedWorkloadTracker", + "approvalRequest", approvalReqRef, + "workloadTracker", workloadTrackerName, + "workloadCount", len(workloads)) + } else { + // Namespace-scoped: Get StagedWorkloadTracker with same name and namespace as StagedUpdateRun + stagedWorkloadTracker := &localv1alpha1.StagedWorkloadTracker{} + if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName, Namespace: obj.GetNamespace()}, stagedWorkloadTracker); err != nil { + if errors.IsNotFound(err) { + klog.V(2).InfoS("StagedWorkloadTracker not found, skipping health check", + "approvalRequest", approvalReqRef, + "updateRun", updateRunName, + "namespace", obj.GetNamespace()) + return nil + } + klog.ErrorS(err, "Failed to get StagedWorkloadTracker", "approvalRequest", approvalReqRef, "updateRun", updateRunName) + return fmt.Errorf("failed to get StagedWorkloadTracker: %w", err) + } + workloads = stagedWorkloadTracker.Workloads + workloadTrackerName = stagedWorkloadTracker.Name + klog.V(2).InfoS("Found StagedWorkloadTracker", + "approvalRequest", approvalReqRef, + "workloadTracker", klog.KObj(stagedWorkloadTracker), + "workloadCount", len(workloads)) + } + + if len(workloads) == 0 { + klog.V(2).InfoS("WorkloadTracker has no workloads defined, skipping health check", + "approvalRequest", approvalReqRef, + "workloadTracker", workloadTrackerName) + return nil + } + + // MetricCollectorReport name is same as MetricCollector name + metricCollectorName := fmt.Sprintf("mc-%s-%s", updateRunName, stageName) + + // Check each cluster for the required workloads + allHealthy := true + unhealthyDetails := []string{} + + for _, clusterName := range clusterNames { + reportNamespace := fmt.Sprintf(utils.NamespaceNameFormat, clusterName) + + klog.V(2).InfoS("Checking MetricCollectorReport", + "approvalRequest", approvalReqRef, + "cluster", clusterName, + "reportName", metricCollectorName, + "reportNamespace", reportNamespace) + + // Get MetricCollectorReport for this cluster + report := &localv1alpha1.MetricCollectorReport{} + err := r.Client.Get(ctx, types.NamespacedName{ + Name: metricCollectorName, + Namespace: reportNamespace, + }, report) + + if err != nil { + if errors.IsNotFound(err) { + klog.V(2).InfoS("MetricCollectorReport not found yet", + "approvalRequest", approvalReqRef, + "cluster", clusterName, + "report", metricCollectorName, + "namespace", reportNamespace) + allHealthy = false + unhealthyDetails = append(unhealthyDetails, fmt.Sprintf("cluster %s: report not found", clusterName)) + continue + } + klog.ErrorS(err, "Failed to get MetricCollectorReport", + "approvalRequest", approvalReqRef, + "cluster", clusterName, + "report", metricCollectorName, + "namespace", reportNamespace) + return fmt.Errorf("failed to get MetricCollectorReport for cluster %s: %w", clusterName, err) + } + + klog.V(2).InfoS("Found MetricCollectorReport", + "approvalRequest", approvalReqRef, + "cluster", clusterName, + "collectedMetrics", len(report.CollectedMetrics), + "workloadsMonitored", report.WorkloadsMonitored) + + // Check if all workloads from WorkloadTracker are present and healthy + for _, trackedWorkload := range workloads { + found := false + healthy := false + + for _, collectedMetric := range report.CollectedMetrics { + if collectedMetric.Namespace == trackedWorkload.Namespace && + collectedMetric.WorkloadName == trackedWorkload.Name { + found = true + healthy = collectedMetric.Health + klog.V(3).InfoS("Workload metric found", + "approvalRequest", approvalReqRef, + "cluster", clusterName, + "workload", trackedWorkload.Name, + "namespace", trackedWorkload.Namespace, + "healthy", healthy) + break + } + } + + if !found { + klog.V(2).InfoS("Workload not found in MetricCollectorReport", + "approvalRequest", approvalReqRef, + "cluster", clusterName, + "workload", trackedWorkload.Name, + "namespace", trackedWorkload.Namespace) + allHealthy = false + unhealthyDetails = append(unhealthyDetails, + fmt.Sprintf("cluster %s: workload %s/%s not found", clusterName, trackedWorkload.Namespace, trackedWorkload.Name)) + } else if !healthy { + klog.V(2).InfoS("Workload is not healthy", + "approvalRequest", approvalReqRef, + "cluster", clusterName, + "workload", trackedWorkload.Name, + "namespace", trackedWorkload.Namespace) + allHealthy = false + unhealthyDetails = append(unhealthyDetails, + fmt.Sprintf("cluster %s: workload %s/%s unhealthy", clusterName, trackedWorkload.Namespace, trackedWorkload.Name)) + } + } + } + + // If all workloads are healthy across all clusters, approve the ApprovalRequest + if allHealthy { + klog.InfoS("All workloads are healthy, approving ApprovalRequest", + "approvalRequest", approvalReqRef, + "clusters", clusterNames, + "workloads", len(workloads)) + + status := approvalReqObj.GetApprovalRequestStatus() + approvedCond := meta.FindStatusCondition(status.Conditions, string(placementv1beta1.ApprovalRequestConditionApproved)) + + // Only update if not already approved + if approvedCond == nil || approvedCond.Status != metav1.ConditionTrue { + meta.SetStatusCondition(&status.Conditions, metav1.Condition{ + Type: string(placementv1beta1.ApprovalRequestConditionApproved), + Status: metav1.ConditionTrue, + ObservedGeneration: obj.GetGeneration(), + Reason: "AllWorkloadsHealthy", + Message: fmt.Sprintf("All %d workloads are healthy across %d clusters", len(workloads), len(clusterNames)), + }) + + approvalReqObj.SetApprovalRequestStatus(*status) + if err := r.Client.Status().Update(ctx, obj); err != nil { + klog.ErrorS(err, "Failed to approve ApprovalRequest", "approvalRequest", approvalReqRef) + return fmt.Errorf("failed to approve ApprovalRequest: %w", err) + } + + klog.InfoS("Successfully approved ApprovalRequest", "approvalRequest", approvalReqRef) + r.recorder.Event(obj, "Normal", "Approved", fmt.Sprintf("All %d workloads are healthy across %d clusters in stage %s", len(workloads), len(clusterNames), stageName)) + } else { + klog.V(2).InfoS("ApprovalRequest already approved", "approvalRequest", approvalReqRef) + } + + // Approval successful or already approved + return nil + } + + // Not all workloads are healthy yet, log details and return nil (reconcile will requeue) + klog.V(2).InfoS("Not all workloads are healthy yet", + "approvalRequest", approvalReqRef, + "unhealthyDetails", unhealthyDetails) + + return nil +} + +// handleDelete handles the deletion of an ApprovalRequest or ClusterApprovalRequest +func (r *Reconciler) handleDelete(ctx context.Context, approvalReqObj placementv1beta1.ApprovalRequestObj) (ctrl.Result, error) { + obj := approvalReqObj.(client.Object) + if !controllerutil.ContainsFinalizer(obj, metricCollectorFinalizer) { + return ctrl.Result{}, nil + } + + approvalReqRef := klog.KObj(obj) + klog.V(2).InfoS("Cleaning up resources for ApprovalRequest", "approvalRequest", approvalReqRef) + + // Delete CRP (it will cascade delete the resources on member clusters) + spec := approvalReqObj.GetApprovalRequestSpec() + updateRunName := spec.TargetUpdateRun + stageName := spec.TargetStage + crpName := fmt.Sprintf("crp-mc-%s-%s", updateRunName, stageName) + metricCollectorName := fmt.Sprintf("mc-%s-%s", updateRunName, stageName) + croName := fmt.Sprintf("ro-mc-%s-%s", updateRunName, stageName) + + crp := &placementv1beta1.ClusterResourcePlacement{} + if err := r.Client.Get(ctx, types.NamespacedName{Name: crpName}, crp); err == nil { + if err := r.Client.Delete(ctx, crp); err != nil && !errors.IsNotFound(err) { + return ctrl.Result{}, fmt.Errorf("failed to delete CRP: %w", err) + } + klog.V(2).InfoS("Deleted ClusterResourcePlacement", "crp", crpName) + } + + // Delete ClusterResourceOverride + cro := &placementv1beta1.ClusterResourceOverride{} + if err := r.Client.Get(ctx, types.NamespacedName{Name: croName}, cro); err == nil { + if err := r.Client.Delete(ctx, cro); err != nil && !errors.IsNotFound(err) { + return ctrl.Result{}, fmt.Errorf("failed to delete ClusterResourceOverride: %w", err) + } + klog.V(2).InfoS("Deleted ClusterResourceOverride", "clusterResourceOverride", croName) + } + + // Delete MetricCollector + metricCollector := &localv1alpha1.MetricCollector{} + if err := r.Client.Get(ctx, types.NamespacedName{Name: metricCollectorName}, metricCollector); err == nil { + if err := r.Client.Delete(ctx, metricCollector); err != nil && !errors.IsNotFound(err) { + return ctrl.Result{}, fmt.Errorf("failed to delete MetricCollector: %w", err) + } + klog.V(2).InfoS("Deleted MetricCollector", "metricCollector", metricCollectorName) + } + + // Remove finalizer + controllerutil.RemoveFinalizer(obj, metricCollectorFinalizer) + if err := r.Client.Update(ctx, obj); err != nil { + klog.ErrorS(err, "Failed to remove finalizer", "approvalRequest", approvalReqRef) + return ctrl.Result{}, err + } + + klog.V(2).InfoS("Successfully cleaned up resources", "approvalRequest", approvalReqRef) + return ctrl.Result{}, nil +} + +// SetupWithManagerForClusterApprovalRequest sets up the controller with the Manager for ClusterApprovalRequest resources. +func (r *Reconciler) SetupWithManagerForClusterApprovalRequest(mgr ctrl.Manager) error { + r.recorder = mgr.GetEventRecorderFor("clusterapprovalrequest-controller") + return ctrl.NewControllerManagedBy(mgr). + Named("clusterapprovalrequest-controller"). + For(&placementv1beta1.ClusterApprovalRequest{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Complete(r) +} + +// SetupWithManagerForApprovalRequest sets up the controller with the Manager for ApprovalRequest resources. +func (r *Reconciler) SetupWithManagerForApprovalRequest(mgr ctrl.Manager) error { + r.recorder = mgr.GetEventRecorderFor("approvalrequest-controller") + return ctrl.NewControllerManagedBy(mgr). + Named("approvalrequest-controller"). + For(&placementv1beta1.ApprovalRequest{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Complete(r) +} diff --git a/approval-controller-metric-collector/metric-collector/Makefile b/approval-controller-metric-collector/metric-collector/Makefile new file mode 100644 index 0000000..e865b83 --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/Makefile @@ -0,0 +1,125 @@ +# Image URL to use for building/pushing image targets +REGISTRY ?= ghcr.io/kubefleet-dev +IMAGE_NAME ?= metric-collector +TAG ?= latest +IMG ?= $(REGISTRY)/$(IMAGE_NAME):$(TAG) + +# Go parameters +GOOS ?= $(shell go env GOOS) +GOARCH ?= $(shell go env GOARCH) + +# Directories +ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) +TOOLS_DIR := hack/tools +TOOLS_BIN_DIR := $(abspath $(TOOLS_DIR)/bin) + +# Binaries +CONTROLLER_GEN_VER := v0.16.0 +CONTROLLER_GEN_BIN := controller-gen +CONTROLLER_GEN := $(abspath $(TOOLS_BIN_DIR)/$(CONTROLLER_GEN_BIN)-$(CONTROLLER_GEN_VER)) + +GOIMPORTS_VER := latest +GOIMPORTS_BIN := goimports +GOIMPORTS := $(abspath $(TOOLS_BIN_DIR)/$(GOIMPORTS_BIN)-$(GOIMPORTS_VER)) + +# Scripts +GO_INSTALL := ../hack/go-install.sh + +# CRD Options +CRD_OPTIONS ?= "crd" + +##@ General + +.PHONY: help +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Tooling + +$(CONTROLLER_GEN): + GOBIN=$(TOOLS_BIN_DIR) $(GO_INSTALL) sigs.k8s.io/controller-tools/cmd/controller-gen $(CONTROLLER_GEN_BIN) $(CONTROLLER_GEN_VER) + +$(GOIMPORTS): + GOBIN=$(TOOLS_BIN_DIR) $(GO_INSTALL) golang.org/x/tools/cmd/goimports $(GOIMPORTS_BIN) $(GOIMPORTS_VER) + +##@ Development + +.PHONY: fmt +fmt: ## Run go fmt against code. + go fmt ./... + +.PHONY: vet +vet: ## Run go vet against code. + go vet ./... + +.PHONY: imports +imports: $(GOIMPORTS) ## Organize imports. + $(GOIMPORTS) -local github.com/kubefleet-dev/standalone-metric-collector -w . + +##@ Build + +.PHONY: build +build: fmt vet ## Build metric-collector binary. + CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o bin/metric-collector ./cmd/metriccollector + +.PHONY: run +run: fmt vet ## Run controller from your host. + go run ./cmd/metriccollector/main.go + +.PHONY: docker-build +docker-build: ## Build docker image. + docker build -t ${IMG} -f docker/metric-collector.Dockerfile . + +.PHONY: docker-push +docker-push: ## Push docker image. + docker push ${IMG} + +.PHONY: docker-build-push +docker-build-push: docker-build docker-push ## Build and push docker image. + +##@ Deployment + +.PHONY: helm-lint +helm-lint: ## Lint helm chart. + helm lint charts/metric-collector + +.PHONY: helm-template +helm-template: ## Template helm chart. + helm template metric-collector charts/metric-collector \ + --set memberCluster.name=cluster-1 \ + --set hubCluster.url=https://hub-cluster:6443 \ + --set prometheus.url=http://prometheus.test-ns:9090 + +.PHONY: helm-package +helm-package: helm-lint ## Package helm chart. + helm package charts/metric-collector -d dist/ + +.PHONY: helm-install +helm-install: ## Install helm chart. + helm upgrade --install metric-collector charts/metric-collector \ + --namespace fleet-system --create-namespace \ + --set memberCluster.name=$(CLUSTER_NAME) \ + --set hubCluster.url=$(HUB_URL) \ + --set prometheus.url=$(PROMETHEUS_URL) \ + --set image.repository=$(REGISTRY)/$(IMAGE_NAME) \ + --set image.tag=$(TAG) + +.PHONY: helm-uninstall +helm-uninstall: ## Uninstall helm chart. + helm uninstall metric-collector --namespace fleet-system + +##@ CRD + +.PHONY: install-crds +install-crds: ## Install CRDs into the K8s cluster. + kubectl apply -f config/crd/bases/ + +.PHONY: uninstall-crds +uninstall-crds: ## Uninstall CRDs from the K8s cluster. + kubectl delete -f config/crd/bases/ + +##@ Cleanup + +.PHONY: clean +clean: ## Clean build artifacts. + rm -rf bin/ dist/ cover.out diff --git a/approval-controller-metric-collector/metric-collector/README.md b/approval-controller-metric-collector/metric-collector/README.md new file mode 100644 index 0000000..5d51ddf --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/README.md @@ -0,0 +1,91 @@ +# Metric Collector + +The Metric Collector is a standalone controller that runs on **member clusters** to collect workload health metrics from Prometheus and report them back to the hub cluster. + +## Overview + +This controller is designed to be a standalone component that can run independently on member clusters. It: +- Watches `MetricCollector` resources deployed to the member cluster +- Queries local Prometheus for `workload_health` metrics +- Creates/updates `MetricCollectorReport` resources on the hub cluster +- Supports both token-based and certificate-based authentication to the hub +- Runs every 30 seconds (configurable) to collect and report metrics + +## Architecture + +The controller runs on member clusters and: +1. Receives `MetricCollector` resources via KubeFleet's ResourcePlacement +2. Queries the local Prometheus endpoint for workload health metrics +3. Parses metrics and extracts workload names, namespaces, and health status +4. Reports collected metrics to the hub cluster in `fleet-member-` namespace +5. Maintains continuous metric collection and reporting + +## Installation + +### Prerequisites + +**On Hub Cluster:** +- KubeFleet hub-agent installed +- `MetricCollectorReport` CRD installed (installed by approval-request-controller) +- RBAC permissions for metric-collector service account to create/update reports + +**On Member Cluster:** +- Prometheus deployed and accessible +- Workloads exposing `workload_health` metrics +- Network connectivity to hub cluster API server + +### Install via Script + +Use the provided installation script to install the metric collector on all member clusters: + +```bash +# Run from the metric-collector directory +./install-on-member.sh 3 # For 3 member clusters +``` + +This script automatically: +1. Builds the `metric-collector:latest` image +2. Builds the `metric-app:local` image (sample app) +3. Loads both images into each kind cluster +4. Creates hub token secret with proper RBAC on hub +5. Installs the metric-collector via Helm on each member + +For detailed step-by-step setup instructions, see the [main tutorial](../README.md). + +## Verification + +### Check Controller Status + +```bash +# Check pod status +kubectl get pods -n default -l app.kubernetes.io/name=metric-collector + +# Check logs +kubectl logs -n default -l app.kubernetes.io/name=metric-collector -f +``` + +### Check MetricCollector Resources + +```bash +# View MetricCollector resources on member cluster +kubectl get metriccollector -A +``` + +### Check Reports on Hub + +```bash +# Switch to hub cluster +kubectl config use-context kind-hub + +# View reports for this cluster +kubectl get metriccollectorreport -n fleet-member-cluster-1 + +# View report details +kubectl describe metriccollectorreport -n fleet-member-cluster-1 +``` + +## Additional Resources + +- [Main Tutorial](../README.md) +- [Approval Request Controller](../approval-request-controller/README.md) +- [KubeFleet Documentation](https://github.com/Azure/kubefleet) diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/Chart.yaml b/approval-controller-metric-collector/metric-collector/charts/metric-collector/Chart.yaml new file mode 100644 index 0000000..2ea221d --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/charts/metric-collector/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: metric-collector +description: MetricCollector for Kubernetes Fleet - Collects workload health metrics and reports to hub cluster +type: application +version: 0.1.0 +appVersion: "latest" +keywords: + - kubernetes + - fleet + - metrics + - monitoring +maintainers: + - name: KubeFleet Team +home: https://github.com/kubefleet-dev/kubefleet +sources: + - https://github.com/kubefleet-dev/kubefleet/tree/main/standalone-metric-collector diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/_helpers.tpl b/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/_helpers.tpl new file mode 100644 index 0000000..653f3de --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/_helpers.tpl @@ -0,0 +1,60 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "metric-collector.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "metric-collector.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "metric-collector.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "metric-collector.labels" -}} +helm.sh/chart: {{ include "metric-collector.chart" . }} +{{ include "metric-collector.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "metric-collector.selectorLabels" -}} +app.kubernetes.io/name: {{ include "metric-collector.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "metric-collector.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "metric-collector.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml b/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml new file mode 120000 index 0000000..efb9228 --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml @@ -0,0 +1 @@ +../../../../../approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectors.yaml \ No newline at end of file diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/deployment.yaml b/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/deployment.yaml new file mode 100644 index 0000000..3e22a23 --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/deployment.yaml @@ -0,0 +1,155 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "metric-collector.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "metric-collector.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.controller.replicas }} + selector: + matchLabels: + {{- include "metric-collector.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "metric-collector.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "metric-collector.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: controller + securityContext: + {{- toYaml .Values.securityContext | nindent 10 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - /metric-collector + args: + - --v={{ .Values.controller.logLevel }} + - --member-qps=100 + - --member-burst=200 + - --hub-qps=100 + - --hub-burst=200 + - --metrics-bind-address=:{{ .Values.metrics.port }} + - --health-probe-bind-address=:{{ .Values.healthProbe.port }} + env: + # Hub cluster connection + - name: HUB_SERVER_URL + value: {{ .Values.hubCluster.url | quote }} + + # Prometheus URL + - name: PROMETHEUS_URL + value: {{ .Values.prometheus.url | quote }} + + {{- if .Values.hubCluster.customHeader }} + - name: HUB_KUBE_HEADER + value: {{ .Values.hubCluster.customHeader | quote }} + {{- end }} + + {{- if .Values.hubCluster.auth.useCertificateAuth }} + # Certificate-based authentication + - name: IDENTITY_CERT + value: /etc/hub-certs/{{ .Values.hubCluster.auth.certSecretKey }} + - name: IDENTITY_KEY + value: /etc/hub-certs/{{ .Values.hubCluster.auth.keySecretKey }} + {{- else }} + # Token-based authentication + - name: CONFIG_PATH + value: /var/run/secrets/hub/{{ .Values.hubCluster.auth.tokenSecretKey }} + {{- end }} + + {{- if .Values.hubCluster.tls.insecure }} + - name: TLS_INSECURE + value: "true" + {{- else if .Values.hubCluster.tls.caSecretName }} + - name: HUB_CERTIFICATE_AUTHORITY + value: /etc/hub-ca/{{ .Values.hubCluster.tls.caSecretKey }} + {{- end }} + + volumeMounts: + {{- if .Values.hubCluster.auth.useCertificateAuth }} + - name: hub-certs + mountPath: /etc/hub-certs + readOnly: true + {{- else }} + - name: hub-token + mountPath: /var/run/secrets/hub + readOnly: true + {{- end }} + + {{- if and (not .Values.hubCluster.tls.insecure) .Values.hubCluster.tls.caSecretName }} + - name: hub-ca + mountPath: /etc/hub-ca + readOnly: true + {{- end }} + + ports: + {{- if .Values.metrics.enabled }} + - name: metrics + containerPort: {{ .Values.metrics.port }} + protocol: TCP + {{- end }} + {{- if .Values.healthProbe.enabled }} + - name: health + containerPort: {{ .Values.healthProbe.port }} + protocol: TCP + {{- end }} + + {{- if .Values.healthProbe.enabled }} + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 15 + periodSeconds: 20 + + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + {{- end }} + + resources: + {{- toYaml .Values.controller.resources | nindent 10 }} + + volumes: + {{- if .Values.hubCluster.auth.useCertificateAuth }} + - name: hub-certs + secret: + secretName: {{ .Values.hubCluster.auth.certSecretName }} + {{- else }} + - name: hub-token + secret: + secretName: {{ .Values.hubCluster.auth.tokenSecretName }} + {{- end }} + + {{- if and (not .Values.hubCluster.tls.insecure) .Values.hubCluster.tls.caSecretName }} + - name: hub-ca + secret: + secretName: {{ .Values.hubCluster.tls.caSecretName }} + {{- end }} + + {{- with .Values.controller.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.controller.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.controller.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/hub-rbac.yaml b/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/hub-rbac.yaml new file mode 100644 index 0000000..af50a2d --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/hub-rbac.yaml @@ -0,0 +1,49 @@ +{{- if .Values.hubCluster.createRBAC }} +# This template generates RBAC resources for the hub cluster +# Apply this on the HUB cluster to grant the metric-collector permissions +# to create/update MetricCollectorReport resources +# +# Usage: +# helm template metric-collector ./charts/metric-collector \ +# --set hubCluster.createRBAC=true \ +# --show-only templates/hub-rbac.yaml | kubectl apply -f - --context=hub-cluster +# +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "metric-collector.fullname" . }}-hub-access + labels: + {{- include "metric-collector.labels" . | nindent 4 }} + app.kubernetes.io/component: hub-rbac + annotations: + helm.sh/resource-policy: keep +rules: + # MetricCollectorReport access + - apiGroups: ["metric.kubernetes-fleet.io"] + resources: ["metriccollectorreports"] + verbs: ["get", "list", "create", "update", "patch", "delete"] + # Namespace access for fleet-{cluster} namespaces + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "metric-collector.fullname" . }}-{{ .Values.memberCluster.name }} + labels: + {{- include "metric-collector.labels" . | nindent 4 }} + app.kubernetes.io/component: hub-rbac + fleet.kubernetes.io/member-cluster: {{ .Values.memberCluster.name }} + annotations: + helm.sh/resource-policy: keep +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "metric-collector.fullname" . }}-hub-access +subjects: + - kind: ServiceAccount + name: {{ .Values.hubCluster.auth.serviceAccountName | default (include "metric-collector.serviceAccountName" .) }} + namespace: {{ .Values.hubCluster.auth.serviceAccountNamespace | default .Release.Namespace }} +{{- end }} diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/rbac-member.yaml b/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/rbac-member.yaml new file mode 100644 index 0000000..c2b7af0 --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/rbac-member.yaml @@ -0,0 +1,44 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "metric-collector.fullname" . }} + labels: + {{- include "metric-collector.labels" . | nindent 4 }} +rules: + # MetricCollector CRD access on member cluster + - apiGroups: ["metric.kubernetes-fleet.io"] + resources: ["metriccollectors"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: ["metric.kubernetes-fleet.io"] + resources: ["metriccollectors/status"] + verbs: ["update", "patch"] + - apiGroups: ["metric.kubernetes-fleet.io"] + resources: ["metriccollectors/finalizers"] + verbs: ["update"] + + # Events + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + + # Leader election + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "create", "update", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "metric-collector.fullname" . }} + labels: + {{- include "metric-collector.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "metric-collector.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "metric-collector.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/serviceaccount.yaml b/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/serviceaccount.yaml new file mode 100644 index 0000000..b5d081d --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "metric-collector.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "metric-collector.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/values.yaml b/approval-controller-metric-collector/metric-collector/charts/metric-collector/values.yaml new file mode 100644 index 0000000..d3d7f70 --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/charts/metric-collector/values.yaml @@ -0,0 +1,134 @@ +# Default values for metric-collector +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# Controller image configuration +image: + repository: metric-collector + pullPolicy: IfNotPresent + tag: "latest" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# Member cluster configuration +memberCluster: + # Name of the member cluster (required) + # This should match the cluster name in the fleet + name: "" + +# Hub cluster connection configuration +hubCluster: + # Hub API server URL (required) + # Example: https://hub-cluster.example.com:6443 + url: "" + + # Set to true to generate hub RBAC resources + # These resources must be applied on the hub cluster + createRBAC: false + + # Authentication configuration + auth: + # Token-based authentication (default) + useTokenAuth: true + tokenSecretName: "hub-token" + tokenSecretKey: "token" + + # Certificate-based authentication + useCertificateAuth: false + certSecretName: "" + certSecretKey: "tls.crt" + keySecretKey: "tls.key" + + # ServiceAccount details for RBAC binding on hub cluster + # Leave empty to use the default serviceAccount from this chart + serviceAccountName: "" + serviceAccountNamespace: "" + + # TLS configuration + tls: + # Skip TLS verification (not recommended for production) + insecure: false + # CA certificate for hub cluster + caSecretName: "" + caSecretKey: "ca.crt" + + # Custom header for hub requests (optional) + customHeader: "" + +# Prometheus configuration +prometheus: + # Prometheus URL (required) + # Example: http://prometheus.monitoring.svc.cluster.local:9090 + url: "" + +# Controller configuration +controller: + # Number of replicas + replicas: 1 + + # Collection interval (how often to scrape metrics) + collectionInterval: "30s" + + # Log verbosity level (0-10) + logLevel: 2 + + # Resource requests and limits + resources: + limits: + cpu: 200m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + + # Node selector + nodeSelector: {} + + # Tolerations + tolerations: [] + + # Affinity + affinity: {} + +# RBAC configuration +rbac: + create: true + +# ServiceAccount configuration +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +# Pod annotations +podAnnotations: {} + +# Pod security context +podSecurityContext: + runAsNonRoot: true + runAsUser: 65532 + fsGroup: 65532 + +# Container security context +securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + +# Metrics server configuration +metrics: + enabled: true + port: 8080 + +# Health probe configuration +healthProbe: + enabled: true + port: 8081 diff --git a/approval-controller-metric-collector/metric-collector/cmd/metriccollector/main.go b/approval-controller-metric-collector/metric-collector/cmd/metriccollector/main.go new file mode 100644 index 0000000..69a4d41 --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/cmd/metriccollector/main.go @@ -0,0 +1,244 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "flag" + "fmt" + "net/http" + "os" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/healthz" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + placementv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1" + metriccollector "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/metric-collector/pkg/controller" +) + +var ( + memberQPS = flag.Int("member-qps", 100, "QPS for member cluster client") + memberBurst = flag.Int("member-burst", 200, "Burst for member cluster client") + hubQPS = flag.Int("hub-qps", 100, "QPS for hub cluster client") + hubBurst = flag.Int("hub-burst", 200, "Burst for hub cluster client") + metricsAddr = flag.String("metrics-bind-address", ":8080", "The address the metric endpoint binds to.") + probeAddr = flag.String("health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + leaderElectionID = flag.String("leader-election-id", "metric-collector-leader", "The leader election ID.") + enableLeaderElect = flag.Bool("leader-elect", true, "Enable leader election for controller manager.") +) + +func main() { + klog.InitFlags(nil) + flag.Parse() + + klog.InfoS("Starting MetricCollector Controller") + + // Get member cluster config (in-cluster) + memberConfig := ctrl.GetConfigOrDie() + memberConfig.QPS = float32(*memberQPS) + memberConfig.Burst = *memberBurst + + // Build hub cluster config + hubConfig, err := buildHubConfig() + if err != nil { + klog.ErrorS(err, "Failed to build hub cluster config") + os.Exit(1) + } + hubConfig.QPS = float32(*hubQPS) + hubConfig.Burst = *hubBurst + + // Start controller with both clients + if err := Start(ctrl.SetupSignalHandler(), hubConfig, memberConfig); err != nil { + klog.ErrorS(err, "Failed to start controller") + os.Exit(1) + } +} + +// buildHubConfig creates hub cluster config from environment variables +// following the same pattern as member-agent +func buildHubConfig() (*rest.Config, error) { + hubURL := os.Getenv("HUB_SERVER_URL") + if hubURL == "" { + return nil, fmt.Errorf("HUB_SERVER_URL environment variable not set") + } + + // Check for custom headers + customHeader := os.Getenv("HUB_KUBE_HEADER") + + // Check TLS insecure flag + tlsInsecure := os.Getenv("TLS_INSECURE") == "true" + + // Initialize hub config + hubConfig := &rest.Config{ + Host: hubURL, + TLSClientConfig: rest.TLSClientConfig{ + Insecure: tlsInsecure, + }, + WrapTransport: func(rt http.RoundTripper) http.RoundTripper { + if customHeader != "" { + return &customHeaderTransport{ + Base: rt, + Header: customHeader, + } + } + return rt + }, + } + + // Check for certificate-based authentication + identityKey := os.Getenv("IDENTITY_KEY") + identityCert := os.Getenv("IDENTITY_CERT") + if identityKey != "" && identityCert != "" { + klog.InfoS("Using certificate-based authentication for hub cluster") + // Read certificate files + certData, err := os.ReadFile(identityCert) + if err != nil { + return nil, fmt.Errorf("failed to read identity cert: %w", err) + } + keyData, err := os.ReadFile(identityKey) + if err != nil { + return nil, fmt.Errorf("failed to read identity key: %w", err) + } + hubConfig.CertData = certData + hubConfig.KeyData = keyData + } else { + // Token-based authentication + klog.InfoS("Using token-based authentication for hub cluster") + configPath := os.Getenv("CONFIG_PATH") + if configPath == "" { + configPath = "/var/run/secrets/hub/token" + } + tokenData, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read hub token from %s: %w", configPath, err) + } + hubConfig.BearerToken = string(tokenData) + } + + // Handle CA certificate + caBundle := os.Getenv("CA_BUNDLE") + hubCA := os.Getenv("HUB_CERTIFICATE_AUTHORITY") + if caBundle != "" { + klog.InfoS("Using CA bundle for hub cluster TLS") + caData, err := os.ReadFile(caBundle) + if err != nil { + return nil, fmt.Errorf("failed to read CA bundle: %w", err) + } + hubConfig.CAData = caData + } else if hubCA != "" { + klog.InfoS("Using hub certificate authority for hub cluster TLS") + caData, err := os.ReadFile(hubCA) + if err != nil { + return nil, fmt.Errorf("failed to read hub CA: %w", err) + } + hubConfig.CAData = caData + } else { + // If no CA specified, try to load system CA pool + klog.InfoS("No CA specified, using insecure connection or system CA pool") + } + + return hubConfig, nil +} + +// customHeaderTransport adds custom headers to requests +type customHeaderTransport struct { + Base http.RoundTripper + Header string +} + +func (t *customHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add("X-Custom-Header", t.Header) + return t.Base.RoundTrip(req) +} + +// Start starts the controller with dual managers for hub and member clusters +func Start(ctx context.Context, hubCfg, memberCfg *rest.Config) error { + // Create scheme with required APIs + scheme := runtime.NewScheme() + if err := placementv1alpha1.AddToScheme(scheme); err != nil { + return fmt.Errorf("failed to add placement API to scheme: %w", err) + } + if err := corev1.AddToScheme(scheme); err != nil { + return fmt.Errorf("failed to add core API to scheme: %w", err) + } + + // Create member cluster manager (where controller runs and watches MetricCollector) + memberMgr, err := ctrl.NewManager(memberCfg, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: *metricsAddr, + }, + HealthProbeBindAddress: *probeAddr, + LeaderElection: *enableLeaderElect, + LeaderElectionID: *leaderElectionID, + }) + if err != nil { + return fmt.Errorf("failed to create member manager: %w", err) + } + + // Create hub cluster client (for writing MetricCollectorReports) + hubClient, err := client.New(hubCfg, client.Options{Scheme: scheme}) + if err != nil { + return fmt.Errorf("failed to create hub client: %w", err) + } + + // Get Prometheus URL from environment + prometheusURL := os.Getenv("PROMETHEUS_URL") + if prometheusURL == "" { + prometheusURL = "http://prometheus.fleet-system.svc.cluster.local:9090" + klog.InfoS("PROMETHEUS_URL not set, using default", "url", prometheusURL) + } + + // Create Prometheus client + prometheusClient := metriccollector.NewPrometheusClient(prometheusURL, "", nil) + + // Setup MetricCollector controller + if err := (&metriccollector.Reconciler{ + MemberClient: memberMgr.GetClient(), + HubClient: hubClient, + PrometheusClient: prometheusClient, + }).SetupWithManager(memberMgr); err != nil { + return fmt.Errorf("failed to setup MetricCollector controller: %w", err) + } + + // Add health checks + if err := memberMgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + return fmt.Errorf("failed to add healthz check: %w", err) + } + if err := memberMgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + return fmt.Errorf("failed to add readyz check: %w", err) + } + + klog.InfoS("Starting MetricCollector controller", + "hubUrl", hubCfg.Host, + "prometheusUrl", prometheusURL, + "metricsAddr", *metricsAddr, + "probeAddr", *probeAddr) + + // Start the manager + if err := memberMgr.Start(ctx); err != nil { + return fmt.Errorf("failed to start manager: %w", err) + } + + return nil +} diff --git a/approval-controller-metric-collector/metric-collector/cmd/metriccollector/metric-app/main.go b/approval-controller-metric-collector/metric-collector/cmd/metriccollector/metric-app/main.go new file mode 100644 index 0000000..17dc094 --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/cmd/metriccollector/metric-app/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +func main() { + // Define a simple gauge metric for health with labels + workloadHealth := prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "workload_health", + Help: "Indicates if the workload is healthy (1=healthy, 0=unhealthy)", + }, + ) + + // Set it to 1 (healthy) with labels + workloadHealth.Set(1) + + // Register metric with Prometheus default registry + prometheus.MustRegister(workloadHealth) + + // Expose metrics endpoint + http.Handle("/metrics", promhttp.Handler()) + + // Start HTTP server + http.ListenAndServe(":8080", nil) +} diff --git a/approval-controller-metric-collector/metric-collector/docker/metric-app.Dockerfile b/approval-controller-metric-collector/metric-collector/docker/metric-app.Dockerfile new file mode 100644 index 0000000..0073ba0 --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/docker/metric-app.Dockerfile @@ -0,0 +1,21 @@ +# Build stage +FROM golang:1.24-alpine AS builder +WORKDIR /workspace + +# Initialize go module for metric-app +RUN go mod init metric-app && \ + go get github.com/prometheus/client_golang/prometheus@latest && \ + go get github.com/prometheus/client_golang/prometheus/promhttp@latest + +# Copy source code +COPY metric-collector/cmd/metriccollector/metric-app/ ./ + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o metric-app main.go + +# Run stage +FROM alpine:3.18 +WORKDIR /app +COPY --from=builder /workspace/metric-app . +EXPOSE 8080 +CMD ["./metric-app"] diff --git a/approval-controller-metric-collector/metric-collector/docker/metric-collector.Dockerfile b/approval-controller-metric-collector/metric-collector/docker/metric-collector.Dockerfile new file mode 100644 index 0000000..fbaa449 --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/docker/metric-collector.Dockerfile @@ -0,0 +1,26 @@ +FROM golang:1.24 AS builder +WORKDIR /workspace + +# Copy approval-request-controller (for APIs) +COPY approval-request-controller/ approval-request-controller/ + +# Copy go mod files +COPY metric-collector/go.mod metric-collector/go.sum* metric-collector/ +WORKDIR /workspace/metric-collector +RUN go mod download + +# Copy source code +COPY metric-collector/cmd/ cmd/ +COPY metric-collector/pkg/ pkg/ + +# Build +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -a -o metric-collector \ + ./cmd/metriccollector + +FROM gcr.io/distroless/static:nonroot +WORKDIR / +COPY --from=builder /workspace/metric-collector . +USER 65532:65532 + +ENTRYPOINT ["/metric-collector"] diff --git a/approval-controller-metric-collector/metric-collector/examples/metriccollector-example.yaml b/approval-controller-metric-collector/metric-collector/examples/metriccollector-example.yaml new file mode 100644 index 0000000..4fe5ec9 --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/examples/metriccollector-example.yaml @@ -0,0 +1,8 @@ +apiVersion: metric.kubernetes-fleet.io/v1alpha1 +kind: MetricCollector +metadata: + name: mc-example-run-staging +spec: + prometheusURL: "http://prometheus.test-ns:9090" + promQLQuery: "workload_health" + reportNamespace: "fleet-member-cluster-1" diff --git a/approval-controller-metric-collector/metric-collector/go.mod b/approval-controller-metric-collector/metric-collector/go.mod new file mode 100644 index 0000000..61410e1 --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/go.mod @@ -0,0 +1,68 @@ +module github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/metric-collector + +go 1.24.9 + +require ( + github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller v0.0.0 + github.com/prometheus/client_golang v1.22.0 + k8s.io/api v0.34.1 + k8s.io/apimachinery v0.34.1 + k8s.io/client-go v0.34.1 + k8s.io/klog/v2 v2.130.1 + sigs.k8s.io/controller-runtime v0.22.4 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.29.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.11.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.34.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) + +replace github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller => ../approval-request-controller diff --git a/approval-controller-metric-collector/metric-collector/go.sum b/approval-controller-metric-collector/metric-collector/go.sum new file mode 100644 index 0000000..f0dda42 --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/go.sum @@ -0,0 +1,188 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= +github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= +golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= +k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= +sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/approval-controller-metric-collector/metric-collector/install-on-member.sh b/approval-controller-metric-collector/metric-collector/install-on-member.sh new file mode 100755 index 0000000..53bf628 --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/install-on-member.sh @@ -0,0 +1,178 @@ +#!/bin/bash +set -e + +# Configuration +HUB_CONTEXT="kind-hub" +MEMBER_CLUSTER_COUNT="${1:-1}" # Default to 1 if not specified +MEMBER_NAMESPACE="default" +PROMETHEUS_URL="http://prometheus.test-ns:9090" +IMAGE_NAME="metric-collector" +IMAGE_TAG="latest" +METRIC_APP_IMAGE_NAME="metric-app" +METRIC_APP_IMAGE_TAG="local" + +# Get hub cluster API server URL dynamically using docker inspect (following kubefleet pattern) +HUB_API_SERVER="https://$(docker inspect hub-control-plane --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'):6443" + +echo "=== Installing MetricCollector on ${MEMBER_CLUSTER_COUNT} member cluster(s) ===" +echo "Hub cluster: ${HUB_CONTEXT}" +echo "Hub API server: ${HUB_API_SERVER}" +echo "" + +# Step 0: Build and load Docker images (once for all clusters) +echo "Step 0: Building Docker images..." + +# Build metric-collector image from parent directory (needs approval-request-controller) +cd .. +docker buildx build \ + --file metric-collector/docker/metric-collector.Dockerfile \ + --output=type=docker \ + --platform=linux/$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \ + --tag ${IMAGE_NAME}:${IMAGE_TAG} \ + --build-arg GOARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \ + --build-arg GOOS=linux \ + . +echo "✓ Metric collector image built" + +# Build metric-app image (still in parent directory) +docker buildx build \ + --file metric-collector/docker/metric-app.Dockerfile \ + --output=type=docker \ + --platform=linux/$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \ + --tag ${METRIC_APP_IMAGE_NAME}:${METRIC_APP_IMAGE_TAG} \ + . +echo "✓ Metric app image built" + +# Return to metric-collector directory +cd metric-collector +echo "" + +# Install on each member cluster +for i in $(seq 1 ${MEMBER_CLUSTER_COUNT}); do + MEMBER_CONTEXT="kind-cluster-${i}" + MEMBER_CLUSTER_NAME="kind-cluster-${i}" + HUB_NAMESPACE="fleet-member-${MEMBER_CLUSTER_NAME}" + + echo "========================================" + echo "Installing on Member Cluster ${i}/${MEMBER_CLUSTER_COUNT}" + echo " Context: ${MEMBER_CONTEXT}" + echo " Cluster Name: ${MEMBER_CLUSTER_NAME}" + echo "========================================" + echo "" + + # Load image into this member cluster + echo "Loading Docker images into ${MEMBER_CONTEXT}..." + kind load docker-image ${IMAGE_NAME}:${IMAGE_TAG} --name cluster-${i} + kind load docker-image ${METRIC_APP_IMAGE_NAME}:${METRIC_APP_IMAGE_TAG} --name cluster-${i} + echo "✓ Images loaded into kind cluster" + echo "" + + # Step 1: Setup RBAC on hub cluster + echo "Step 1: Setting up RBAC on hub cluster..." + kubectl --context=${HUB_CONTEXT} create namespace ${HUB_NAMESPACE} --dry-run=client -o yaml | kubectl --context=${HUB_CONTEXT} apply -f - + kubectl --context=${HUB_CONTEXT} create serviceaccount metric-collector-sa -n ${HUB_NAMESPACE} --dry-run=client -o yaml | kubectl --context=${HUB_CONTEXT} apply -f - + + cat <= 2 { + if valueStr, ok := res.Value[1].(string); ok { + fmt.Sscanf(valueStr, "%f", &health) + } + } + + wm := localv1alpha1.WorkloadMetrics{ + Namespace: namespace, + WorkloadName: workloadName, + Health: health == 1.0, // Convert to boolean: 1.0 = true, 0.0 = false + } + workloadMetrics = append(workloadMetrics, wm) + } + + klog.V(2).InfoS("Collected metrics from Prometheus", "workloads", len(workloadMetrics)) + return workloadMetrics, nil +} + +// buildPromQLQuery builds a PromQL query for workload_health metric +func buildPromQLQuery(mc *localv1alpha1.MetricCollector) string { + // Query all workload_health metrics (MetricCollector is cluster-scoped) + return `workload_health` +} diff --git a/approval-controller-metric-collector/metric-collector/pkg/controller/controller.go b/approval-controller-metric-collector/metric-collector/pkg/controller/controller.go new file mode 100644 index 0000000..f0a2266 --- /dev/null +++ b/approval-controller-metric-collector/metric-collector/pkg/controller/controller.go @@ -0,0 +1,288 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package metriccollector + +import ( + "context" + "fmt" + "time" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/record" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + localv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1" +) + +const ( + // defaultCollectionInterval is the interval for collecting metrics (30 seconds) + defaultCollectionInterval = 30 * time.Second + + // metricCollectorFinalizer is the finalizer for cleaning up MetricCollectorReport + metricCollectorFinalizer = "kubernetes-fleet.io/metric-collector-report-cleanup" +) + +// Reconciler reconciles a MetricCollector object +type Reconciler struct { + // MemberClient is the client to access the member cluster + MemberClient client.Client + + // HubClient is the client to access the hub cluster + HubClient client.Client + + // recorder is the event recorder + recorder record.EventRecorder + + // PrometheusClient is the client to query Prometheus + PrometheusClient PrometheusClient +} + +// Reconcile reconciles a MetricCollector object +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + startTime := time.Now() + klog.V(2).InfoS("MetricCollector reconciliation starts", "metricCollector", req.Name) + defer func() { + latency := time.Since(startTime).Milliseconds() + klog.V(2).InfoS("MetricCollector reconciliation ends", "metricCollector", req.Name, "latency", latency) + }() + + // Fetch the MetricCollector instance (cluster-scoped) + mc := &localv1alpha1.MetricCollector{} + if err := r.MemberClient.Get(ctx, client.ObjectKey{Name: req.Name}, mc); err != nil { + if errors.IsNotFound(err) { + klog.V(2).InfoS("MetricCollector not found, ignoring", "metricCollector", req.Name) + return ctrl.Result{}, nil + } + klog.ErrorS(err, "Failed to get MetricCollector", "metricCollector", req.Name) + return ctrl.Result{}, err + } + + // Handle deletion - cleanup MetricCollectorReport on hub + if !mc.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(mc, metricCollectorFinalizer) { + klog.V(2).InfoS("Cleaning up MetricCollectorReport on hub", "metricCollector", req.Name) + + // Delete MetricCollectorReport from hub cluster + if err := r.deleteReportFromHub(ctx, mc); err != nil { + klog.ErrorS(err, "Failed to delete MetricCollectorReport from hub", "metricCollector", req.Name) + return ctrl.Result{}, err + } + + // Remove finalizer + controllerutil.RemoveFinalizer(mc, metricCollectorFinalizer) + if err := r.MemberClient.Update(ctx, mc); err != nil { + klog.ErrorS(err, "Failed to remove finalizer", "metricCollector", req.Name) + return ctrl.Result{}, err + } + klog.V(2).InfoS("Successfully cleaned up MetricCollectorReport", "metricCollector", req.Name) + } + return ctrl.Result{}, nil + } + + // Add finalizer if not present + if !controllerutil.ContainsFinalizer(mc, metricCollectorFinalizer) { + controllerutil.AddFinalizer(mc, metricCollectorFinalizer) + if err := r.MemberClient.Update(ctx, mc); err != nil { + klog.ErrorS(err, "Failed to add finalizer", "metricCollector", req.Name) + return ctrl.Result{}, err + } + klog.V(2).InfoS("Added finalizer to MetricCollector", "metricCollector", req.Name) + } + + // Collect metrics from Prometheus + collectedMetrics, collectErr := r.collectFromPrometheus(ctx, mc) + + // Update status with collected metrics + now := metav1.Now() + mc.Status.LastCollectionTime = &now + mc.Status.CollectedMetrics = collectedMetrics + mc.Status.WorkloadsMonitored = int32(len(collectedMetrics)) + mc.Status.ObservedGeneration = mc.Generation + + if collectErr != nil { + klog.ErrorS(collectErr, "Failed to collect metrics", "metricCollector", req.Name) + meta.SetStatusCondition(&mc.Status.Conditions, metav1.Condition{ + Type: localv1alpha1.MetricCollectorConditionTypeReady, + Status: metav1.ConditionTrue, + ObservedGeneration: mc.Generation, + Reason: "CollectorConfigured", + Message: "Collector is configured", + }) + meta.SetStatusCondition(&mc.Status.Conditions, metav1.Condition{ + Type: localv1alpha1.MetricCollectorConditionTypeCollecting, + Status: metav1.ConditionFalse, + ObservedGeneration: mc.Generation, + Reason: "CollectionFailed", + Message: fmt.Sprintf("Failed to collect metrics: %v", collectErr), + }) + } else { + klog.V(2).InfoS("Successfully collected metrics", "metricCollector", req.Name, "workloads", len(collectedMetrics)) + meta.SetStatusCondition(&mc.Status.Conditions, metav1.Condition{ + Type: localv1alpha1.MetricCollectorConditionTypeReady, + Status: metav1.ConditionTrue, + ObservedGeneration: mc.Generation, + Reason: "CollectorConfigured", + Message: "Collector is configured and collecting metrics", + }) + meta.SetStatusCondition(&mc.Status.Conditions, metav1.Condition{ + Type: localv1alpha1.MetricCollectorConditionTypeCollecting, + Status: metav1.ConditionTrue, + ObservedGeneration: mc.Generation, + Reason: "MetricsCollected", + Message: fmt.Sprintf("Successfully collected metrics from %d workloads", len(collectedMetrics)), + }) + } + + if err := r.MemberClient.Status().Update(ctx, mc); err != nil { + klog.ErrorS(err, "Failed to update MetricCollector status", "metricCollector", req.Name) + return ctrl.Result{}, err + } + + // Sync MetricCollectorReport to hub cluster + if err := r.syncReportToHub(ctx, mc); err != nil { + klog.ErrorS(err, "Failed to sync MetricCollectorReport to hub", "metricCollector", req.Name) + meta.SetStatusCondition(&mc.Status.Conditions, metav1.Condition{ + Type: localv1alpha1.MetricCollectorConditionTypeReported, + Status: metav1.ConditionFalse, + ObservedGeneration: mc.Generation, + Reason: "ReportSyncFailed", + Message: fmt.Sprintf("Failed to sync report to hub: %v", err), + }) + } else { + meta.SetStatusCondition(&mc.Status.Conditions, metav1.Condition{ + Type: localv1alpha1.MetricCollectorConditionTypeReported, + Status: metav1.ConditionTrue, + ObservedGeneration: mc.Generation, + Reason: "ReportSyncSucceeded", + Message: "Successfully synced metrics to hub cluster", + }) + } + + // Update status with reporting condition + if err := r.MemberClient.Status().Update(ctx, mc); err != nil { + klog.ErrorS(err, "Failed to update MetricCollector status with reporting condition", "metricCollector", req.Name) + return ctrl.Result{}, err + } + + // Requeue after 30 seconds + return ctrl.Result{RequeueAfter: defaultCollectionInterval}, nil +} + +// syncReportToHub syncs the MetricCollectorReport to the hub cluster +func (r *Reconciler) syncReportToHub(ctx context.Context, mc *localv1alpha1.MetricCollector) error { + // Use the reportNamespace from the MetricCollector spec + reportNamespace := mc.Spec.ReportNamespace + if reportNamespace == "" { + return fmt.Errorf("reportNamespace is not set in MetricCollector spec") + } + + // Create or update MetricCollectorReport on hub + report := &localv1alpha1.MetricCollectorReport{ + ObjectMeta: metav1.ObjectMeta{ + Name: mc.Name, + Namespace: reportNamespace, + Labels: map[string]string{ + "metriccollector-name": mc.Name, + }, + }, + } + + // Check if report already exists + existingReport := &localv1alpha1.MetricCollectorReport{} + err := r.HubClient.Get(ctx, client.ObjectKey{Name: mc.Name, Namespace: reportNamespace}, existingReport) + + now := metav1.Now() + if err != nil { + if errors.IsNotFound(err) { + // Create new report + report.Conditions = mc.Status.Conditions + report.ObservedGeneration = mc.Status.ObservedGeneration + report.WorkloadsMonitored = mc.Status.WorkloadsMonitored + report.LastCollectionTime = mc.Status.LastCollectionTime + report.CollectedMetrics = mc.Status.CollectedMetrics + report.LastReportTime = &now + + if err := r.HubClient.Create(ctx, report); err != nil { + klog.ErrorS(err, "Failed to create MetricCollectorReport", "report", klog.KObj(report)) + return err + } + klog.V(2).InfoS("Created MetricCollectorReport on hub", "report", klog.KObj(report), "reportNamespace", reportNamespace) + return nil + } + return err + } + + // Update existing report + existingReport.Labels = report.Labels + existingReport.Conditions = mc.Status.Conditions + existingReport.ObservedGeneration = mc.Status.ObservedGeneration + existingReport.WorkloadsMonitored = mc.Status.WorkloadsMonitored + existingReport.LastCollectionTime = mc.Status.LastCollectionTime + existingReport.CollectedMetrics = mc.Status.CollectedMetrics + existingReport.LastReportTime = &now + + if err := r.HubClient.Update(ctx, existingReport); err != nil { + klog.ErrorS(err, "Failed to update MetricCollectorReport", "report", klog.KObj(existingReport)) + return err + } + klog.V(2).InfoS("Updated MetricCollectorReport on hub", "report", klog.KObj(existingReport), "reportNamespace", reportNamespace) + return nil +} + +// deleteReportFromHub deletes the MetricCollectorReport from the hub cluster +func (r *Reconciler) deleteReportFromHub(ctx context.Context, mc *localv1alpha1.MetricCollector) error { + // Use the reportNamespace from the MetricCollector spec + reportNamespace := mc.Spec.ReportNamespace + if reportNamespace == "" { + klog.V(2).InfoS("reportNamespace is not set, skipping deletion", "metricCollector", mc.Name) + return nil + } + + // Try to delete MetricCollectorReport on hub + report := &localv1alpha1.MetricCollectorReport{} + err := r.HubClient.Get(ctx, client.ObjectKey{Name: mc.Name, Namespace: reportNamespace}, report) + if err != nil { + if errors.IsNotFound(err) { + klog.V(2).InfoS("MetricCollectorReport not found on hub, already deleted", "report", mc.Name, "namespace", reportNamespace) + return nil + } + return fmt.Errorf("failed to get MetricCollectorReport: %w", err) + } + + if err := r.HubClient.Delete(ctx, report); err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete MetricCollectorReport: %w", err) + } + + klog.InfoS("Deleted MetricCollectorReport from hub", "report", mc.Name, "namespace", reportNamespace) + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + r.recorder = mgr.GetEventRecorderFor("metriccollector-controller") + return ctrl.NewControllerManagedBy(mgr). + Named("metriccollector-controller"). + For(&localv1alpha1.MetricCollector{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Complete(r) +} From fc87b21ad3a7d9010a94ca962cbd138d213c40f7 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Wed, 10 Dec 2025 02:03:46 -0800 Subject: [PATCH 02/38] minor fixes Signed-off-by: Arvind Thirumurugan --- approval-controller-metric-collector/README.md | 4 +++- .../charts/approval-request-controller/templates/rbac.yaml | 2 +- .../cmd/approvalrequestcontroller/main.go | 5 +++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/approval-controller-metric-collector/README.md b/approval-controller-metric-collector/README.md index 414bb98..67a5a6f 100644 --- a/approval-controller-metric-collector/README.md +++ b/approval-controller-metric-collector/README.md @@ -118,8 +118,10 @@ This will create: Switch to hub cluster context and register the member clusters: +From the kubefleet-cookbook repo run, + ```bash -cd /path/to/approval-controller-metric-collector/approval-request-controller +cd approval-controller-metric-collector/approval-request-controller # Switch to hub cluster kubectl config use-context kind-hub diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml index 0b80b84..4c70cc3 100644 --- a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml +++ b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml @@ -42,7 +42,7 @@ rules: # WorkloadTracker (our custom resource) - apiGroups: ["metric.kubernetes-fleet.io"] - resources: ["workloadtrackers"] + resources: ["clusterstagedworkloadtrackers, stagedworkloadtrackers"] verbs: ["get", "list", "watch"] # Events diff --git a/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go b/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go index 3f8bbb9..816903a 100644 --- a/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go +++ b/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go @@ -40,7 +40,7 @@ import ( ) var ( - scheme = runtime.NewScheme() + scheme = runtime.NewScheme() ) func init() { @@ -131,7 +131,8 @@ func checkRequiredCRDs(config *rest.Config) error { "clusterapprovalrequests.placement.kubernetes-fleet.io", "metriccollectors.metric.kubernetes-fleet.io", "metriccollectorreports.metric.kubernetes-fleet.io", - "workloadtrackers.metric.kubernetes-fleet.io", + "clusterstagedworkloadtrackers.metric.kubernetes-fleet.io", + "stagedworkloadtrackers.metric.kubernetes-fleet.io", "clusterresourceplacements.placement.kubernetes-fleet.io", "clusterresourceoverrides.placement.kubernetes-fleet.io", "clusterstagedupdateruns.placement.kubernetes-fleet.io", From f3ea6d5680eb715c13f0eb886fc39f068a6ee727 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Wed, 10 Dec 2025 02:38:13 -0800 Subject: [PATCH 03/38] minor fixes Signed-off-by: Arvind Thirumurugan --- approval-controller-metric-collector/README.md | 18 ++++-------------- .../templates/rbac.yaml | 2 +- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/approval-controller-metric-collector/README.md b/approval-controller-metric-collector/README.md index 67a5a6f..5c646a7 100644 --- a/approval-controller-metric-collector/README.md +++ b/approval-controller-metric-collector/README.md @@ -237,11 +237,11 @@ You can create staged updates using either cluster-scoped or namespace-scoped re Switch back to hub cluster and create a cluster-scoped staged update run: ```bash +cd ../approval-request-controller + # Switch to hub cluster kubectl config use-context kind-hub -cd ../approval-request-controller - # Apply ClusterStagedUpdateStrategy kubectl apply -f ./examples/updateRun/example-csus.yaml @@ -278,10 +278,10 @@ example-cluster-staged-run example-crp 0 0 Alternatively, you can use namespace-scoped resources: ```bash +cd ../approval-request-controller + # Switch to hub cluster kubectl config use-context kind-hub - -cd ../approval-request-controller ``` ``` bash @@ -302,16 +302,6 @@ proemetheus-crp 1 True 1 True 1 # Apply StagedUpdateStrategy kubectl apply -f ./examples/updateRun/example-sus.yaml -# Verify SUS is created -kubectl get sus -A -``` - -Output: -```bash -NAMESPACE NAME AGE -test-ns example-staged-strategy 4s -``` - ```bash # Apply ResourcePlacement kubectl apply -f ./examples/updateRun/example-rp.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml index 4c70cc3..2be596f 100644 --- a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml +++ b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml @@ -42,7 +42,7 @@ rules: # WorkloadTracker (our custom resource) - apiGroups: ["metric.kubernetes-fleet.io"] - resources: ["clusterstagedworkloadtrackers, stagedworkloadtrackers"] + resources: ["clusterstagedworkloadtrackers", "stagedworkloadtrackers"] verbs: ["get", "list", "watch"] # Events From 3c8db43150e80960015fa35b0535c410ec4cfa8d Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Wed, 10 Dec 2025 13:47:05 -0800 Subject: [PATCH 04/38] address minor comments Signed-off-by: Arvind Thirumurugan --- .../README.md | 61 +++++++++++++------ .../metric/v1alpha1/metriccollector_types.go | 2 +- .../cmd/approvalrequestcontroller/main.go | 4 +- .../examples/prometheus/prometheus-crp.yaml | 2 +- .../{metriccollector => }/metric-app/main.go | 0 .../docker/metric-app.Dockerfile | 2 +- 6 files changed, 46 insertions(+), 25 deletions(-) rename approval-controller-metric-collector/metric-collector/cmd/{metriccollector => }/metric-app/main.go (100%) diff --git a/approval-controller-metric-collector/README.md b/approval-controller-metric-collector/README.md index 5c646a7..3c394e1 100644 --- a/approval-controller-metric-collector/README.md +++ b/approval-controller-metric-collector/README.md @@ -32,7 +32,7 @@ This solution introduces three new CRDs that work together with KubeFleet's nati 3. **ClusterStagedWorkloadTracker** (cluster-scoped) - Defines which workloads to monitor for a ClusterStagedUpdateRun - The name must match the ClusterStagedUpdateRun name - - Specifies namespace, workload name, and expected health status + - Specifies workload's name, namespace and expected health status - Used by approval-request-controller to determine if stage is ready for approval 4. **StagedWorkloadTracker** (namespaced) @@ -49,7 +49,7 @@ This solution introduces three new CRDs that work together with KubeFleet's nati - The ApprovalRequest enters "Pending" state, waiting for approval 2. **Metric Collector Deployment** - - Approval-request-controller watches the CAR + - Approval-request-controller watches the `ClusterApprovalRequest`, `ApprovalRequest` objects - Creates a `MetricCollector` resource on the hub (cluster-scoped) - Creates a `ClusterResourceOverride` with per-cluster customization rules - Each cluster gets a unique `reportNamespace`: `fleet-member-` @@ -127,6 +127,8 @@ cd approval-controller-metric-collector/approval-request-controller kubectl config use-context kind-hub # Register member clusters with the hub +# This creates MemberCluster resources for kind-cluster-1, kind-cluster-2, and kind-cluster-3 +# Each MemberCluster resource contains the API endpoint and credentials for the member cluster kubectl apply -f ./examples/membercluster/ # Verify clusters are registered @@ -151,7 +153,12 @@ Create the prometheus namespace and deploy Prometheus for metrics collection: # Create prometheus namespace kubectl create ns prometheus -# Deploy Prometheus (ConfigMap, Deployment, Service, RBAC) +# Deploy Prometheus (ConfigMap, Deployment, Service, RBAC, and CRP) +# - ConfigMap: Contains Prometheus scrape configuration +# - Deployment: Runs Prometheus server +# - Service: Exposes Prometheus on port 9090 +# - RBAC: ServiceAccount, ClusterRole, and ClusterRoleBinding for pod discovery +# - CRP: ClusterResourcePlacement to propagate Prometheus to all member clusters kubectl apply -f ./examples/prometheus/ ``` @@ -165,7 +172,9 @@ Create the test namespace and deploy the sample application: # Create test namespace kubectl create ns test-ns -# Deploy sample metric app (this will be propagated to member clusters) +# Deploy sample metric app +# This creates a Deployment with a simple Go app that exposes a /metrics endpoint +# The app reports workload_health=1.0 (healthy) by default kubectl apply -f ./examples/sample-metric-app/ ``` @@ -193,7 +202,9 @@ Apply the appropriate workload tracker based on which type of staged update you' ```bash # Apply ClusterStagedWorkloadTracker -# Important: The name must match your ClusterStagedUpdateRun name +# This defines which workloads to monitor for the staged rollout +# The name "example-cluster-staged-run" must match the ClusterStagedUpdateRun name +# Tracks: sample-metric-app in test-ns namespace kubectl apply -f ./examples/workloadtracker/clusterstagedworkloadtracker.yaml ``` @@ -201,7 +212,9 @@ kubectl apply -f ./examples/workloadtracker/clusterstagedworkloadtracker.yaml ```bash # Apply StagedWorkloadTracker -# Important: The name and namespace must match your StagedUpdateRun name and namespace +# This defines which workloads to monitor for the namespace-scoped staged rollout +# The name "example-staged-run" and namespace "test-ns" must match the StagedUpdateRun +# Tracks: sample-metric-app in test-ns namespace kubectl apply -f ./examples/workloadtracker/stagedworkloadtracker.yaml ``` @@ -243,9 +256,13 @@ cd ../approval-request-controller kubectl config use-context kind-hub # Apply ClusterStagedUpdateStrategy +# Defines the stages for the rollout: staging (cluster-1) -> prod (cluster-2, cluster-3) +# Each stage requires approval before proceeding kubectl apply -f ./examples/updateRun/example-csus.yaml -# Apply ClusterResourcePlacement +# Apply ClusterResourcePlacement for sample-metric-app +# This is the resource that will be updated across stages +# Selects the sample-metric-app deployment in test-ns namespace kubectl apply -f ./examples/updateRun/example-crp.yaml # Verify CRP is created @@ -261,6 +278,9 @@ prometheus-crp 1 True 1 True 1 ```bash # Apply ClusterStagedUpdateRun to start the staged rollout +# This creates the actual update run that progresses through the defined stages +# Name: example-cluster-staged-run (must match ClusterStagedWorkloadTracker) +# References the ClusterResourcePlacement (example-crp) and ClusterStagedUpdateStrategy kubectl apply -f ./examples/updateRun/example-csur.yaml # Check the staged update run status @@ -286,6 +306,9 @@ kubectl config use-context kind-hub ``` bash # Apply namespace-scoped ClusterResourcePlacement +# This CRP is configured to only place resources in the test-ns namespace +# This resource is needed because we cannot propagate Namespace which is a +# cluster-scoped resource via RP kubectl apply -f ./examples/updateRun/example-ns-only-crp.yaml kubectl get crp -A @@ -295,15 +318,18 @@ Output: ```bash NAME GEN SCHEDULED SCHEDULED-GEN AVAILABLE AVAILABLE-GEN AGE ns-only-crp 1 True 1 True 1 5s -proemetheus-crp 1 True 1 True 1 2m34s +prometheus-crp 1 True 1 True 1 2m34s ``` ```bash -# Apply StagedUpdateStrategy +# Apply StagedUpdateStrategy (namespace-scoped) +# Defines the stages: staging (cluster-1) -> prod (cluster-2, cluster-3) +# Each stage requires approval before proceeding kubectl apply -f ./examples/updateRun/example-sus.yaml -```bash -# Apply ResourcePlacement +# Apply ResourcePlacement (namespace-scoped) +# This is the namespace-scoped version that works with the test-ns namespace +# References the ns-only-crp ClusterResourcePlacement kubectl apply -f ./examples/updateRun/example-rp.yaml # Verify RP is created @@ -317,7 +343,11 @@ test-ns example-rp 1 True 1 ``` ```bash -# Apply StagedUpdateRun to start the staged rollout +# Apply StagedUpdateRun to start the staged rollout (namespace-scoped) +# This creates the actual update run that progresses through the defined stages +# Name: example-staged-run (must match StagedWorkloadTracker) +# Namespace: test-ns (must match StagedWorkloadTracker namespace) +# References the ResourcePlacement (example-rp) kubectl apply -f ./examples/updateRun/example-sur.yaml # Check the staged update run status @@ -389,13 +419,6 @@ fleet-member-kind-cluster-1 mc-example-staged-run-staging 1 27s ``` The approval controller will automatically approve stages when the metric collectors report that workloads are healthy. -1. Builds the `metric-collector:latest` image -2. Builds the `metric-app:local` image -3. Loads both images into each kind cluster -4. Creates hub token secret with proper RBAC -5. Installs the metric-collector via Helm - -The `metric-app:local` image is pre-loaded so it's available when you propagate the sample-metric-app deployment from hub to member clusters. ## Verification diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollector_types.go b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollector_types.go index 75503c9..5511c19 100644 --- a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollector_types.go +++ b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollector_types.go @@ -64,7 +64,7 @@ type MetricCollectorSpec struct { ReportNamespace string `json:"reportNamespace"` } -// MetricsEndpointSpec defines how to access the metrics endpoint.ctor. +// MetricCollectorStatus defines the observed state of MetricCollector. type MetricCollectorStatus struct { // Conditions is an array of current observed conditions. // +optional diff --git a/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go b/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go index 816903a..e968d3c 100644 --- a/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go +++ b/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go @@ -53,11 +53,9 @@ func init() { func main() { var metricsAddr string var probeAddr string - var logLevel int flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") - flag.IntVar(&logLevel, "v", 2, "Log level (0-10)") opts := zap.Options{ Development: true, @@ -67,7 +65,7 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) - klog.InfoS("Starting ApprovalRequest Controller", "logLevel", logLevel) + klog.InfoS("Starting ApprovalRequest Controller") config := ctrl.GetConfigOrDie() diff --git a/approval-controller-metric-collector/approval-request-controller/examples/prometheus/prometheus-crp.yaml b/approval-controller-metric-collector/approval-request-controller/examples/prometheus/prometheus-crp.yaml index a240843..8243057 100644 --- a/approval-controller-metric-collector/approval-request-controller/examples/prometheus/prometheus-crp.yaml +++ b/approval-controller-metric-collector/approval-request-controller/examples/prometheus/prometheus-crp.yaml @@ -1,7 +1,7 @@ apiVersion: placement.kubernetes-fleet.io/v1beta1 kind: ClusterResourcePlacement metadata: - name: proemetheus-crp + name: prometheus-crp spec: resourceSelectors: - group: "" diff --git a/approval-controller-metric-collector/metric-collector/cmd/metriccollector/metric-app/main.go b/approval-controller-metric-collector/metric-collector/cmd/metric-app/main.go similarity index 100% rename from approval-controller-metric-collector/metric-collector/cmd/metriccollector/metric-app/main.go rename to approval-controller-metric-collector/metric-collector/cmd/metric-app/main.go diff --git a/approval-controller-metric-collector/metric-collector/docker/metric-app.Dockerfile b/approval-controller-metric-collector/metric-collector/docker/metric-app.Dockerfile index 0073ba0..6b4037e 100644 --- a/approval-controller-metric-collector/metric-collector/docker/metric-app.Dockerfile +++ b/approval-controller-metric-collector/metric-collector/docker/metric-app.Dockerfile @@ -8,7 +8,7 @@ RUN go mod init metric-app && \ go get github.com/prometheus/client_golang/prometheus/promhttp@latest # Copy source code -COPY metric-collector/cmd/metriccollector/metric-app/ ./ +COPY metric-collector/cmd/metric-app/ ./ # Build the application RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o metric-app main.go From e85a7a012d6c1639a74c515637d4d242cfd3a5b9 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Wed, 10 Dec 2025 16:47:05 -0800 Subject: [PATCH 05/38] address minor comment Signed-off-by: Arvind Thirumurugan --- .../approval-request-controller/pkg/controller/controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go b/approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go index ad32e61..9c15d76 100644 --- a/approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go +++ b/approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go @@ -193,7 +193,7 @@ func (r *Reconciler) reconcileApprovalRequestObj(ctx context.Context, approvalRe // Check workload health and approve if all workloads are healthy if err := r.checkWorkloadHealthAndApprove(ctx, approvalReqObj, clusterNames, updateRunName, stageName); err != nil { klog.ErrorS(err, "Failed to check workload health", "approvalRequest", approvalReqRef) - return ctrl.Result{RequeueAfter: 15 * time.Second}, err + return ctrl.Result{}, err } // Requeue after 15 seconds to check again (will stop if approved in next reconciliation) From 23ac827cc93147a90c39242c54fabded66f7b6e3 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Wed, 10 Dec 2025 16:50:47 -0800 Subject: [PATCH 06/38] address minor comment Signed-off-by: Arvind Thirumurugan --- approval-controller-metric-collector/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/approval-controller-metric-collector/README.md b/approval-controller-metric-collector/README.md index 3c394e1..81cff40 100644 --- a/approval-controller-metric-collector/README.md +++ b/approval-controller-metric-collector/README.md @@ -478,6 +478,7 @@ kubectl get metriccollectorreport -A - Ensure workloads have Prometheus scrape annotations ### Approvals not happening +- Check the appropriate Workload tracker object exists - Check that the workload tracker name matches the update run name: - For ClusterStagedUpdateRun: ClusterStagedWorkloadTracker name must match - For StagedUpdateRun: StagedWorkloadTracker name and namespace must match From 018cacb137bb6b0f64a43faa5f97987c3eed4130 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Wed, 10 Dec 2025 17:08:17 -0800 Subject: [PATCH 07/38] add birdeye view section Signed-off-by: Arvind Thirumurugan --- .../README.md | 132 +++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/approval-controller-metric-collector/README.md b/approval-controller-metric-collector/README.md index 81cff40..6fbc6fe 100644 --- a/approval-controller-metric-collector/README.md +++ b/approval-controller-metric-collector/README.md @@ -91,6 +91,129 @@ This solution introduces three new CRDs that work together with KubeFleet's nati - Helm 3.x - KubeFleet installed on hub and member clusters +## Setup Overview + +Before diving into the setup steps, here's a bird's eye view of what you'll be building: + +### Architecture Components + +**Hub Cluster** - The control plane where you'll deploy: +1. **3 Member Clusters** (kind-cluster-1, kind-cluster-2, kind-cluster-3) + - Labeled with `environment=staging` or `environment=prod` + - These labels determine which stage each cluster belongs to during rollouts + +2. **Prometheus** (propagated to all clusters) + - Monitors workload health via `/metrics` endpoints + - Scrapes pods with `prometheus.io/scrape: "true"` annotation + - Provides `workload_health` metric (1.0 = healthy, 0.0 = unhealthy) + +3. **Approval Request Controller** + - Watches `ClusterApprovalRequest` and `ApprovalRequest` objects + - Deploys MetricCollector to stage clusters via ClusterResourcePlacement + - Evaluates workload health from MetricCollectorReports + - Auto-approves stages when all workloads are healthy + +4. **Sample Metric App** (will be rolled out to clusters) + - Simple Go application exposing `/metrics` endpoint + - Reports `workload_health=1.0` by default + - Used to demonstrate health-based approvals + +**Member Clusters** - Where workloads run: +1. **Metric Collector** + - Queries local Prometheus every 30 seconds + - Reports workload health back to hub cluster + - Creates/updates MetricCollectorReport in hub's `fleet-member-` namespace + +2. **Prometheus** (received from hub) + - Runs on each member cluster + - Scrapes local workload metrics + +3. **Sample Metric App** (received from hub) + - Deployed via staged rollout + - Monitored for health during updates + +### WorkloadTracker - The Decision Maker + +The **WorkloadTracker** is a critical resource that tells the approval controller which workloads must be healthy before approving a stage. Without it, the controller doesn't know what to monitor. + +**Two Types:** + +1. **ClusterStagedWorkloadTracker** (for ClusterStagedUpdateRun) + - Cluster-scoped resource on the hub + - Name must exactly match the ClusterStagedUpdateRun name + - Example: If your UpdateRun is named `example-cluster-staged-run`, the tracker must also be named `example-cluster-staged-run` + - Contains a list of workloads (name + namespace) to monitor across all clusters in each stage + +2. **StagedWorkloadTracker** (for StagedUpdateRun) + - Namespace-scoped resource on the hub + - Name and namespace must exactly match the StagedUpdateRun + - Example: If your UpdateRun is `example-staged-run` in namespace `test-ns`, the tracker must be `example-staged-run` in `test-ns` + - Contains a list of workloads to monitor + +**How It Works:** +```yaml +# ClusterStagedWorkloadTracker example +workloads: + - name: sample-metric-app # Deployment name + namespace: test-ns # Namespace where it runs +``` + +When the approval controller evaluates a stage: +1. It fetches the WorkloadTracker that matches the UpdateRun name (and namespace) +2. For each cluster in the stage, it reads the MetricCollectorReport +3. It verifies that every workload listed in the tracker appears in the report with `health=1.0` +4. Only when ALL workloads in ALL clusters are healthy does it approve the stage + +**Critical Rule:** The WorkloadTracker must be created BEFORE starting the UpdateRun. If the controller can't find a matching tracker, it won't approve any stages. + +### The Staged Rollout Flow + +When you create a **ClusterStagedUpdateRun** or **StagedUpdateRun**, here's what happens: + +1. **Stage 1 (staging)**: Rollout starts with `kind-cluster-1` + - KubeFleet creates an ApprovalRequest for the staging stage + - Approval controller deploys MetricCollector to `kind-cluster-1` + - Metric collector reports health metrics back to hub + - When `sample-metric-app` is healthy, approval controller auto-approves + - KubeFleet proceeds with the rollout to `kind-cluster-1` + +2. **Stage 2 (prod)**: After staging succeeds + - KubeFleet creates an ApprovalRequest for the prod stage + - Approval controller deploys MetricCollector to `kind-cluster-2` and `kind-cluster-3` + - Metric collectors report health from both clusters + - When ALL workloads across BOTH prod clusters are healthy, auto-approve + - KubeFleet completes the rollout to production clusters + +### Key Resources You'll Create + +| Resource | Purpose | Where | +|----------|---------|-------| +| **MemberCluster** | Register member clusters with hub, apply stage labels | Hub | +| **ClusterResourcePlacement** | Define what resources to propagate (Prometheus, sample-app) | Hub | +| **StagedUpdateStrategy** | Define stages with label selectors and approval requirements | Hub | +| **WorkloadTracker** | Specify which workloads to monitor for health | Hub | +| **UpdateRun** | Start the staged rollout process | Hub | +| **MetricCollector** | Automatically created by approval controller per stage | Hub → Member | +| **MetricCollectorReport** | Automatically created by metric collector | Member → Hub | + +### What the Installation Scripts Do + +**`install-on-hub.sh`** (Approval Request Controller): +- Builds controller Docker image with multi-arch support +- Loads image into kind hub cluster +- Verifies KubeFleet CRDs are installed +- Installs controller via Helm with custom CRDs (MetricCollector, MetricCollectorReport, WorkloadTracker) +- Sets up RBAC for managing placements, overrides, and approval requests + +**`install-on-member.sh`** (Metric Collector): +- Builds metric-collector and metric-app Docker images +- Loads both images into each kind member cluster +- Creates service account with hub cluster access token +- Installs metric-collector via Helm on each member cluster +- Configures connection to hub API server and local Prometheus + +With this understanding, you're ready to start the setup! + ## Setup ### 1. Setup KubeFleet Clusters @@ -128,7 +251,14 @@ kubectl config use-context kind-hub # Register member clusters with the hub # This creates MemberCluster resources for kind-cluster-1, kind-cluster-2, and kind-cluster-3 -# Each MemberCluster resource contains the API endpoint and credentials for the member cluster +# Each MemberCluster resource contains: +# - API endpoint and credentials for the member cluster +# - Labels for organizing clusters into stages: +# * kind-cluster-1: environment=staging (Stage 1) +# * kind-cluster-2: environment=prod (Stage 2) +# * kind-cluster-3: environment=prod (Stage 2) +# These labels are used by the StagedUpdateStrategy's labelSelector to determine +# which clusters are part of each stage during the UpdateRun kubectl apply -f ./examples/membercluster/ # Verify clusters are registered From b8448b3cd84ed26cd0952ce52393583f2874ce42 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Wed, 10 Dec 2025 17:12:37 -0800 Subject: [PATCH 08/38] add context about kind-clusters Signed-off-by: Arvind Thirumurugan --- approval-controller-metric-collector/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/approval-controller-metric-collector/README.md b/approval-controller-metric-collector/README.md index 6fbc6fe..878b5b3 100644 --- a/approval-controller-metric-collector/README.md +++ b/approval-controller-metric-collector/README.md @@ -218,7 +218,7 @@ With this understanding, you're ready to start the setup! ### 1. Setup KubeFleet Clusters -First, set up the KubeFleet hub and member clusters: +First, set up the KubeFleet hub and member clusters using kind (Kubernetes in Docker): ```bash cd /path/to/kubefleet @@ -228,15 +228,17 @@ git checkout main git fetch upstream git rebase -i upstream/main -# Set up clusters (creates 1 hub + 3 member clusters) +# Set up clusters (creates 1 hub + 3 member kind clusters) export MEMBER_CLUSTER_COUNT=3 make setup-clusters ``` -This will create: +This will create local kind clusters for development and testing: - 1 hub cluster (context: `kind-hub`) - 3 member clusters (contexts: `kind-cluster-1`, `kind-cluster-2`, `kind-cluster-3`) +**Note:** This tutorial uses kind clusters for easy local development. For production deployments, you would use real Kubernetes clusters (AKS, EKS, GKE, etc.) and adapt the installation scripts accordingly. + ### 2. Register Member Clusters with Hub Switch to hub cluster context and register the member clusters: From 097e14afd2d87959deeb373af6d350d0732f8354 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Wed, 10 Dec 2025 17:22:22 -0800 Subject: [PATCH 09/38] fix image Signed-off-by: Arvind Thirumurugan --- .../approval-controller-metric-collector.png | Bin 0 -> 117309 bytes approval-controller-metric-collector/README.md | 2 +- .../approval-controller-metric-collector.png | Bin 111339 -> 0 bytes 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 approval-controller-metric-collector/Images/approval-controller-metric-collector.png delete mode 100644 approval-controller-metric-collector/approval-controller-metric-collector.png diff --git a/approval-controller-metric-collector/Images/approval-controller-metric-collector.png b/approval-controller-metric-collector/Images/approval-controller-metric-collector.png new file mode 100644 index 0000000000000000000000000000000000000000..65b1fc83714411577d7b9b218c1a5662c060e7e9 GIT binary patch literal 117309 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

i~!B(nd~HB$Q&n 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


9Y4z_3OoVogFDK4bTlFV>ET4eF1mJ9| zwv*FoCh>%h^}FJK6Y5AYaSpM_aH=O>$x9>(SYOXNnU3zPbXA-_IyIfZHJ;buUC&Lv zHTkw+TZX${s-1YzC!nsOVs>R(^)tWCB=H2)DL635y4z0}5SXP~cUDffP5Ie{j-50SuSVE51&$uA>@m3z`vP|rorkbI<+$^ z7+IP`g!iaOG+OtRxc0SNqT9%v(wnYh!Z}ohl==2M=X?s!?DKTJ2 z%TrhJ^(?Id4X+cuB;uLU0K32;*Qiw66x_kvQ-xREJSRy@5t3bI<PSyaCJRIYMGMA0ZafmDVc+hu~p6vPn} z=QIn*fAYvd2RG){{OB-iDM|VE&Yzg#)Sm=rN;8~aIQB~5ZzBE%T%?%6KebOiauZxS zxGkK-R~D9!Sr?&>XV#CJ=(vr<2%+H=K)&mjzHA^HU&(fh}$ zwp@!{IVI@Fj_XAefQl0DPI7HjO(6E;SZ?j*ub0 zn|d%wzl5E|D6opuz@IQz3q0q0+yh((ufx3)iyk2uy%sn}%z(|#o)r0WoaY$ghkTc{ zthLsxCDX?ONcDUYbjd2{jlrKU{9P1-M3#4054pN`M~UO6?$6(06k21@O87Eof4$)$gLM2)OWdFlqW%i6u>brC_aT}N-fbvFI$(5UBJ#pyv<8gWoa{yUq^e=`#qM zt7M+5(ZxETj5Lk(9Zok@|Jk(%&kwYR?|puIryRzT8ka|B;=DaA3o1l7F9S& z@>sb}_BjoAsqzOD)SiFzqA1OcT1imd#LPp{ke!K49{q$Y3L9TbsEtV*yK~^)8{kiXYev_BPIn}a-77#>R`OD zQYG@Cvzc#`&o_ospWR&g+z@l}EE0y8L7QBnQprMhp>#>U3KKDG5`p4WGi zbx6g%X$8ed+S4RiC;#u_`_o}^=nZe&nM;31;9#R*?FhkwdE4E;mFWH7Eeh=x6Eq8O z9BUTerYbD`|Lz#VZ>+w(aUvw4b&~xvpt#4iR)TGsw`twsjX8opp92qpT5(@rUxXMP393fC1~`@nO}}>e|1H9QoH*P3T;$i*?lLGZQQS*Fi3196did zxSk;$a{q5@ZPVa&ZjMXOAuLY$Bj-PBRaXtpL18a)n3^}-X7~Sn+$uj@Sk{J%myq4* zP2#~%Xm^24H5ST;$leTP;UdU1?65{v-aFvN@V21YA>2QZwwFTNNBCy<4_@@xw2q^s z7=8BaSu?wt1}`aG_ab2;ME!zZrXS4tHhm%C!MAlo>we?o;r;$SzIk;V4-fA!vhoh$ YHRcKo{-!NafxqCXp3_jtS2hd!AAH!wDgXcg literal 0 HcmV?d00001 diff --git a/approval-controller-metric-collector/README.md b/approval-controller-metric-collector/README.md index 878b5b3..179510f 100644 --- a/approval-controller-metric-collector/README.md +++ b/approval-controller-metric-collector/README.md @@ -8,7 +8,7 @@ This directory contains two controllers: - **approval-request-controller**: Runs on the hub cluster to automate approval decisions for staged updates - **metric-collector**: Runs on member clusters to collect workload health metrics from Prometheus -![Approval Controller and Metric Collector Architecture](./approval-controller-metric-collector.png) +![Approval Controller and Metric Collector Architecture](./images/approval-controller-metric-collector.png) ## How It Works diff --git a/approval-controller-metric-collector/approval-controller-metric-collector.png b/approval-controller-metric-collector/approval-controller-metric-collector.png deleted file mode 100644 index d9ca5e45a5786a4f4b99ab7b836a318258e8d58c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 111339 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 Date: Thu, 11 Dec 2025 15:29:30 -0800 Subject: [PATCH 10/38] ensure script works for all clusters Signed-off-by: Arvind Thirumurugan --- .../README.md | 306 ++++++++++++------ .../templates/deployment.yaml | 1 - .../examples/updateRun/example-csur.yaml | 2 +- .../examples/updateRun/example-sur.yaml | 2 +- .../install-on-hub.sh | 55 ++-- .../charts/metric-collector/values.yaml | 6 + .../metric-collector/install-on-member.sh | 165 ++++++---- 7 files changed, 353 insertions(+), 184 deletions(-) diff --git a/approval-controller-metric-collector/README.md b/approval-controller-metric-collector/README.md index 179510f..a99dbfa 100644 --- a/approval-controller-metric-collector/README.md +++ b/approval-controller-metric-collector/README.md @@ -86,10 +86,122 @@ This solution introduces three new CRDs that work together with KubeFleet's nati ## Prerequisites -- Docker or Podman for building images +- Docker for building images +- Azure CLI (`az`) for ACR operations - kubectl configured with access to your clusters - Helm 3.x - KubeFleet installed on hub and member clusters +- Azure Container Registry (ACR) with anonymous pull enabled + +## Building and Pushing Images to ACR + +Before installing the controllers, you need to build the Docker images and push them to Azure Container Registry (ACR). + +**Critical Note:** Enable anonymous pull on the ACR so that clusters can pull images without authentication. Ensure to disable anonymous pull or delete the ACR after testing. + +### 1. Create ACR with Anonymous Pull + +Create a resource group and ACR with Standard SKU (Basic SKU doesn't support anonymous pull): + +```bash +# Create resource group +az group create --name test-kubefleet-rg --location eastus + +# Create container registry with Standard SKU +az acr create --resource-group test-kubefleet-rg --name myfleetacr --sku Standard + +# Login to ACR +az acr login --name myfleetacr + +# Enable anonymous pull +az acr update --name myfleetacr --anonymous-pull-enabled +``` + +From the `az acr create` output, note down the login server (e.g., `myfleetacr.azurecr.io`). + +### 2. Build and Push Images + +Export registry and tag variables: + +```bash +export REGISTRY="myfleetacr.azurecr.io" +export TAG="latest" + +cd approval-controller-metric-collector +``` + +Build and push the approval-request-controller image: + +```bash +docker buildx build \ + --file approval-request-controller/docker/approval-request-controller.Dockerfile \ + --tag ${REGISTRY}/approval-request-controller:${TAG} \ + --platform=linux/amd64 \ + --push \ + . +``` + +Build and push the metric-collector image: + +```bash +docker buildx build \ + --file metric-collector/docker/metric-collector.Dockerfile \ + --tag ${REGISTRY}/metric-collector:${TAG} \ + --platform=linux/amd64 \ + --push \ + . +``` + +Build and push the metric-app image: + +```bash +docker buildx build \ + --file metric-collector/docker/metric-app.Dockerfile \ + --tag ${REGISTRY}/metric-app:${TAG} \ + --platform=linux/amd64 \ + --push \ + . +``` + +### 3. Verify Images in ACR + +List images in your ACR: + +```bash +az acr repository list --name myfleetacr --output table +``` + +Expected output: +``` +Result +--------------------------- +approval-request-controller +metric-app +metric-collector +``` + +Verify tags for a specific image: + +```bash +az acr repository show-tags --name myfleetacr --repository approval-request-controller --output table +``` + +Expected output: +``` +Result +-------- +latest +``` + +**You're now ready to proceed with the setup!** Your ACR contains all three required images that will be pulled by both kind and production clusters. + +### 4. Cleanup (After Testing) + +When you're done testing, delete the resource group to clean up all resources: + +```bash +az group delete --name test-kubefleet-rg +``` ## Setup Overview @@ -199,15 +311,15 @@ When you create a **ClusterStagedUpdateRun** or **StagedUpdateRun**, here's what ### What the Installation Scripts Do **`install-on-hub.sh`** (Approval Request Controller): -- Builds controller Docker image with multi-arch support -- Loads image into kind hub cluster +- Takes ACR registry URL and hub cluster name as parameters +- Pulls approval-request-controller image from ACR - Verifies KubeFleet CRDs are installed - Installs controller via Helm with custom CRDs (MetricCollector, MetricCollectorReport, WorkloadTracker) - Sets up RBAC for managing placements, overrides, and approval requests **`install-on-member.sh`** (Metric Collector): -- Builds metric-collector and metric-app Docker images -- Loads both images into each kind member cluster +- Takes ACR registry URL, hub cluster, and member cluster names as parameters +- Pulls metric-collector and metric-app images from ACR - Creates service account with hub cluster access token - Installs metric-collector via Helm on each member cluster - Configures connection to hub API server and local Prometheus @@ -216,72 +328,62 @@ With this understanding, you're ready to start the setup! ## Setup -### 1. Setup KubeFleet Clusters - -First, set up the KubeFleet hub and member clusters using kind (Kubernetes in Docker): +### Prerequisites -```bash -cd /path/to/kubefleet +Before starting this tutorial, ensure you have: +- A KubeFleet hub cluster with fleet controllers installed +- Three member clusters joined to the hub cluster +- kubectl configured with access to the hub cluster context -# Checkout main branch -git checkout main -git fetch upstream -git rebase -i upstream/main +### 1. Label Member Clusters for Staged Rollout -# Set up clusters (creates 1 hub + 3 member kind clusters) -export MEMBER_CLUSTER_COUNT=3 -make setup-clusters -``` +The staged rollout uses labels to determine which clusters belong to each stage. Label your three member clusters appropriately: -This will create local kind clusters for development and testing: -- 1 hub cluster (context: `kind-hub`) -- 3 member clusters (contexts: `kind-cluster-1`, `kind-cluster-2`, `kind-cluster-3`) +```bash +# Switch to hub cluster context +kubectl config use-context -**Note:** This tutorial uses kind clusters for easy local development. For production deployments, you would use real Kubernetes clusters (AKS, EKS, GKE, etc.) and adapt the installation scripts accordingly. +# Label the first cluster for staging (Stage 1) +# Replace with your actual cluster name (e.g., kind-cluster-1, aks-cluster-1, etc.) +kubectl label membercluster environment=staging --overwrite +kubectl label membercluster kubernetes-fleet.io/cluster-name= --overwrite -### 2. Register Member Clusters with Hub +# Label the second cluster for production (Stage 2) +# Replace with your actual cluster name +kubectl label membercluster environment=prod --overwrite +kubectl label membercluster kubernetes-fleet.io/cluster-name= --overwrite -Switch to hub cluster context and register the member clusters: +# Label the third cluster for production (Stage 2) +# Replace with your actual cluster name +kubectl label membercluster environment=prod --overwrite +kubectl label membercluster kubernetes-fleet.io/cluster-name= --overwrite -From the kubefleet-cookbook repo run, +# Verify the labels are applied +kubectl get membercluster --show-labels +``` +Expected output: ```bash -cd approval-controller-metric-collector/approval-request-controller - -# Switch to hub cluster -kubectl config use-context kind-hub +NAME JOINED AGE LABELS +cluster-1 True 5m environment=staging,kubernetes-fleet.io/cluster-name=cluster-1,... +cluster-2 True 5m environment=prod,kubernetes-fleet.io/cluster-name=cluster-2,... +cluster-3 True 5m environment=prod,kubernetes-fleet.io/cluster-name=cluster-3,... +``` -# Register member clusters with the hub -# This creates MemberCluster resources for kind-cluster-1, kind-cluster-2, and kind-cluster-3 -# Each MemberCluster resource contains: -# - API endpoint and credentials for the member cluster -# - Labels for organizing clusters into stages: -# * kind-cluster-1: environment=staging (Stage 1) -# * kind-cluster-2: environment=prod (Stage 2) -# * kind-cluster-3: environment=prod (Stage 2) -# These labels are used by the StagedUpdateStrategy's labelSelector to determine -# which clusters are part of each stage during the UpdateRun -kubectl apply -f ./examples/membercluster/ +These labels are used by the `StagedUpdateStrategy` to select clusters for each stage: +- **Stage 1 (staging)**: Selects clusters with `environment=staging` → cluster-1 +- **Stage 2 (prod)**: Selects clusters with `environment=prod` → cluster-2 and cluster-3 -# Verify clusters are registered -kubectl get cluster -A -``` +### 2. Deploy Prometheus -the output should look something like this, +From the kubefleet-cookbook repo, navigate to the approval-request-controller directory and deploy Prometheus for metrics collection: ```bash -NAME JOINED AGE MEMBER-AGENT-LAST-SEEN NODE-COUNT AVAILABLE-CPU AVAILABLE-MEMORY -kind-cluster-1 True 40s 29s 0 0 0 -kind-cluster-2 True 40s 3s 0 0 0 -kind-cluster-3 True 40s 37s 0 0 0 -``` -Wait until all member clusters show as joined. - -### 3. Deploy Prometheus +cd approval-controller-metric-collector/approval-request-controller -Create the prometheus namespace and deploy Prometheus for metrics collection: +# Switch to hub cluster context +kubectl config use-context -```bash # Create prometheus namespace kubectl create ns prometheus @@ -296,7 +398,7 @@ kubectl apply -f ./examples/prometheus/ This deploys Prometheus configured to scrape pods from all namespaces with the proper annotations. -### 4. Deploy Sample Metric Application +### 3. Deploy Sample Metric Application Create the test namespace and deploy the sample application: @@ -307,26 +409,45 @@ kubectl create ns test-ns # Deploy sample metric app # This creates a Deployment with a simple Go app that exposes a /metrics endpoint # The app reports workload_health=1.0 (healthy) by default -kubectl apply -f ./examples/sample-metric-app/ +# Note: Update the image reference in the YAML to use your ACR registry +# Change "image: metric-app:local" to "image: ${REGISTRY}/metric-app:latest" +# You can use sed to update it: +sed "s|image: metric-app:local|image: ${REGISTRY}/metric-app:latest|" \ + ./examples/sample-metric-app/sample-metric-app.yaml | kubectl apply -f - +``` + +**Alternative:** Manually edit `./examples/sample-metric-app/sample-metric-app.yaml` to change: +```yaml +image: metric-app:local +imagePullPolicy: IfNotPresent +``` +to: +```yaml +image: myfleetacr.azurecr.io/metric-app:latest +imagePullPolicy: Always +``` +Then apply: `kubectl apply -f ./examples/sample-metric-app/` ``` -### 5. Install Approval Request Controller (Hub Cluster) +### 4. Install Approval Request Controller (Hub Cluster) -Install the approval request controller on the hub cluster: +Install the approval request controller on the hub cluster using the ACR registry: ```bash +# Set your ACR registry name +export REGISTRY="myfleetacr.azurecr.io" + # Run the installation script -./install-on-hub.sh +./install-on-hub.sh ${REGISTRY} ``` The script performs the following: -1. Builds the `approval-request-controller:latest` image -2. Loads the image into the kind hub cluster -3. Verifies that required kubefleet CRDs are installed -4. Installs the controller via Helm with the custom CRDs (MetricCollector, MetricCollectorReport, ClusterStagedWorkloadTracker, StagedWorkloadTracker) -5. Verifies the installation +1. Pulls the `approval-request-controller` image from your ACR +2. Verifies that required kubefleet CRDs are installed +3. Installs the controller via Helm with the custom CRDs (MetricCollector, MetricCollectorReport, ClusterStagedWorkloadTracker, StagedWorkloadTracker) +4. Verifies the installation -### 6. Configure Workload Tracker +### 5. Configure Workload Tracker Apply the appropriate workload tracker based on which type of staged update you'll use: @@ -349,44 +470,43 @@ kubectl apply -f ./examples/workloadtracker/clusterstagedworkloadtracker.yaml # Tracks: sample-metric-app in test-ns namespace kubectl apply -f ./examples/workloadtracker/stagedworkloadtracker.yaml ``` - -This tells the approval controller which workloads to track. - -### 7. Install Metric Collector (Member Clusters) - -Install the metric collector on all member clusters: +Install the metric collector on all member clusters using the ACR registry: ```bash cd ../metric-collector # Run the installation script for all member clusters -# This builds both metric-collector and metric-app images and loads them into each cluster -./install-on-member.sh 3 -``` - +# Replace with your hub cluster name (e.g., kind-hub, hub) +# Replace , , with your actual cluster names +./install-on-member.sh ${REGISTRY} The script performs the following for each member cluster: -1. Builds the `metric-collector:latest` image -2. Builds the `metric-app:local` image -3. Loads both images into each kind cluster -4. Creates hub token secret with proper RBAC -5. Installs the metric-collector via Helm +1. Verifies the `fleet-member-` namespace exists on the hub (created by KubeFleet) +2. Creates RBAC resources (ServiceAccount, Role, RoleBinding) in the fleet-member namespace on the hub +3. Creates a token secret for hub cluster authentication +4. Installs the metric-collector via Helm on each member cluster +5. Configures the collector to pull images from ACR and connect to hub API server and local Prometheus -The `metric-app:local` image is loaded so it's available when you propagate the sample-metric-app deployment from hub to member clusters. +**Note:** The script expects the `fleet-member-` namespaces to already exist on the hub cluster. These are automatically created by KubeFleet when member clusters join the hub. If you encounter errors about missing namespaces, ensure your member clusters are properly registered with the hub. +./install-on-member.sh ${REGISTRY} kind-hub kind-cluster-1 kind-cluster-2 kind-cluster-3 +``` -### 8. Create Staged Update +The script performs the following for each member cluster: +1. Pulls the `metric-collector` and `metric-app` images from your ACR +2. Creates hub token secret with proper RBAC +3. Installs the metric-collector via Helm +4. Configures connection to hub API server and local Prometheus +```bash +cd ../approval-request-controller -You can create staged updates using either cluster-scoped or namespace-scoped resources: +# Switch to hub cluster context +kubectl config use-context +# Apply ClusterStagedUpdateStrategy #### Option A: Cluster-Scoped Staged Update (ClusterStagedUpdateRun) Switch back to hub cluster and create a cluster-scoped staged update run: ```bash -cd ../approval-request-controller - -# Switch to hub cluster -kubectl config use-context kind-hub - # Apply ClusterStagedUpdateStrategy # Defines the stages for the rollout: staging (cluster-1) -> prod (cluster-2, cluster-3) # Each stage requires approval before proceeding @@ -418,11 +538,11 @@ kubectl apply -f ./examples/updateRun/example-csur.yaml # Check the staged update run status kubectl get csur -A ``` - -Output: ```bash -NAME PLACEMENT RESOURCE-SNAPSHOT-INDEX POLICY-SNAPSHOT-INDEX INITIALIZED PROGRESSING SUCCEEDED AGE -example-cluster-staged-run example-crp 0 0 True True 5s +cd ../approval-request-controller + +# Switch to hub cluster context +kubectl config use-context ``` #### Option B: Namespace-Scoped Staged Update (StagedUpdateRun) @@ -492,7 +612,7 @@ NAMESPACE NAME PLACEMENT RESOURCE-SNAPSHOT-INDEX POLICY-S test-ns example-staged-run example-rp 0 0 True True 5s ``` -### 9. Monitor the Staged Rollout +### 8. Monitor the Staged Rollout Watch the staged update progress: diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/deployment.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/deployment.yaml index 654acba..82d7905 100644 --- a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/deployment.yaml +++ b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/deployment.yaml @@ -35,7 +35,6 @@ spec: command: - /approval-request-controller args: - - --v={{ .Values.controller.logLevel }} - --metrics-bind-address=:{{ .Values.metrics.port }} - --health-probe-bind-address=:{{ .Values.healthProbe.port }} diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csur.yaml b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csur.yaml index 1ed0408..107f5fc 100644 --- a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csur.yaml +++ b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csur.yaml @@ -6,5 +6,5 @@ spec: placementName: example-crp resourceSnapshotIndex: "0" stagedRolloutStrategyName: example-cluster-staged-strategy - state: Run + state: Started \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sur.yaml b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sur.yaml index 9c3b5eb..e045585 100644 --- a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sur.yaml +++ b/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sur.yaml @@ -7,5 +7,5 @@ spec: placementName: example-rp resourceSnapshotIndex: "0" stagedRolloutStrategyName: example-staged-strategy - state: Run + state: Started \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/install-on-hub.sh b/approval-controller-metric-collector/approval-request-controller/install-on-hub.sh index 66e8bf7..4f31a93 100755 --- a/approval-controller-metric-collector/approval-request-controller/install-on-hub.sh +++ b/approval-controller-metric-collector/approval-request-controller/install-on-hub.sh @@ -1,31 +1,48 @@ #!/bin/bash set -e +# Usage: ./install-on-hub.sh +# Example: ./install-on-hub.sh arvindtestacr.azurecr.io kind-hub + +if [ "$#" -lt 2 ]; then + echo "Usage: $0 " + echo "Example: $0 arvindtestacr.azurecr.io kind-hub" + echo "" + echo "Parameters:" + echo " registry - ACR registry URL (e.g., arvindtestacr.azurecr.io)" + echo " hub-cluster - Hub cluster name (e.g., kind-hub)" + exit 1 +fi + # Configuration -HUB_CONTEXT="kind-hub" +REGISTRY="$1" +HUB_CLUSTER="$2" IMAGE_NAME="approval-request-controller" -IMAGE_TAG="latest" +IMAGE_TAG="${IMAGE_TAG:-latest}" NAMESPACE="fleet-system" CHART_NAME="approval-request-controller" +# Get hub cluster context using kubectl config view (following kubefleet pattern) +HUB_CONTEXT=$(kubectl config view -o jsonpath="{.contexts[?(@.context.cluster==\"$HUB_CLUSTER\")].name}") + +if [ -z "$HUB_CONTEXT" ]; then + echo "Error: Could not find context for hub cluster '$HUB_CLUSTER'" + echo "Available clusters:" + kubectl config view -o jsonpath='{.clusters[*].name}' | tr ' ' '\n' + exit 1 +fi + +# Construct full image repository path +IMAGE_REPOSITORY="${REGISTRY}/${IMAGE_NAME}" + echo "=== Installing ApprovalRequest Controller on hub cluster ===" -echo "Hub cluster: ${HUB_CONTEXT}" +echo "Registry: ${REGISTRY}" +echo "Image: ${IMAGE_REPOSITORY}:${IMAGE_TAG}" +echo "Hub cluster: ${HUB_CLUSTER}" +echo "Hub context: ${HUB_CONTEXT}" echo "Namespace: ${NAMESPACE}" echo "" -# Step 0: Build and load Docker image -echo "Step 0: Building and loading Docker image..." -cd .. -docker buildx build \ - --file approval-request-controller/docker/approval-request-controller.Dockerfile \ - --output=type=docker \ - --platform=linux/$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \ - --tag ${IMAGE_NAME}:${IMAGE_TAG} \ - --build-arg GOARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \ - . -cd approval-request-controller -kind load docker-image ${IMAGE_NAME}:${IMAGE_TAG} --name hub -echo "✓ Docker image built and loaded into kind cluster" echo "" # Step 1: Verify kubefleet CRDs are installed @@ -65,9 +82,9 @@ helm upgrade --install ${CHART_NAME} ./charts/${CHART_NAME} \ --kube-context=${HUB_CONTEXT} \ --namespace ${NAMESPACE} \ --create-namespace \ - --set image.repository=${IMAGE_NAME} \ + --set image.repository=${IMAGE_REPOSITORY} \ --set image.tag=${IMAGE_TAG} \ - --set image.pullPolicy=IfNotPresent \ + --set image.pullPolicy=Always \ --set controller.logLevel=2 echo "✓ Helm chart installed on hub cluster" @@ -89,7 +106,7 @@ echo "To check controller logs:" echo " kubectl --context=${HUB_CONTEXT} logs -n ${NAMESPACE} -l app.kubernetes.io/name=${CHART_NAME} -f" echo "" echo "To verify CRDs:" -echo " kubectl --context=${HUB_CONTEXT} get crd | grep placement.kubernetes-fleet.io" +echo " kubectl --context=${HUB_CONTEXT} get crd | grep metric.kubernetes-fleet.io" echo "" echo "Next steps:" echo " 1. Create a WorkloadTracker to define which workloads to monitor" diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/values.yaml b/approval-controller-metric-collector/metric-collector/charts/metric-collector/values.yaml index d3d7f70..8af6dd9 100644 --- a/approval-controller-metric-collector/metric-collector/charts/metric-collector/values.yaml +++ b/approval-controller-metric-collector/metric-collector/charts/metric-collector/values.yaml @@ -8,6 +8,12 @@ image: pullPolicy: IfNotPresent tag: "latest" +# Metric app image configuration (used in sample deployments) +metricApp: + image: + repository: metric-app + tag: "latest" + imagePullSecrets: [] nameOverride: "" fullnameOverride: "" diff --git a/approval-controller-metric-collector/metric-collector/install-on-member.sh b/approval-controller-metric-collector/metric-collector/install-on-member.sh index 53bf628..c69c2f1 100755 --- a/approval-controller-metric-collector/metric-collector/install-on-member.sh +++ b/approval-controller-metric-collector/metric-collector/install-on-member.sh @@ -1,102 +1,127 @@ #!/bin/bash set -e +# Usage: ./install-on-member.sh [member-cluster-2] [member-cluster-3] ... +# Example: ./install-on-member.sh arvindtestacr.azurecr.io kind-hub kind-cluster-1 kind-cluster-2 kind-cluster-3 + +if [ "$#" -lt 3 ]; then + echo "Usage: $0 [member-cluster-2] ..." + echo "Example: $0 arvindtestacr.azurecr.io kind-hub kind-cluster-1 kind-cluster-2 kind-cluster-3" + echo "" + echo "Parameters:" + echo " registry - ACR registry URL (e.g., arvindtestacr.azurecr.io)" + echo " hub-cluster - Hub cluster name (e.g., kind-hub)" + echo " member-clusters - One or more member cluster names" + exit 1 +fi + # Configuration -HUB_CONTEXT="kind-hub" -MEMBER_CLUSTER_COUNT="${1:-1}" # Default to 1 if not specified +REGISTRY="$1" +HUB_CLUSTER="$2" +MEMBER_CLUSTERS=("${@:3}") MEMBER_NAMESPACE="default" PROMETHEUS_URL="http://prometheus.test-ns:9090" -IMAGE_NAME="metric-collector" -IMAGE_TAG="latest" -METRIC_APP_IMAGE_NAME="metric-app" -METRIC_APP_IMAGE_TAG="local" - -# Get hub cluster API server URL dynamically using docker inspect (following kubefleet pattern) -HUB_API_SERVER="https://$(docker inspect hub-control-plane --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'):6443" - -echo "=== Installing MetricCollector on ${MEMBER_CLUSTER_COUNT} member cluster(s) ===" -echo "Hub cluster: ${HUB_CONTEXT}" +IMAGE_TAG="${IMAGE_TAG:-latest}" +METRIC_COLLECTOR_IMAGE="metric-collector" +METRIC_APP_IMAGE="metric-app" + +# Get hub cluster context and API server URL using kubectl config view (following kubefleet pattern) +HUB_CONTEXT=$(kubectl config view -o jsonpath="{.contexts[?(@.context.cluster==\"$HUB_CLUSTER\")].name}") +HUB_API_SERVER=$(kubectl config view -o jsonpath="{.clusters[?(@.name==\"$HUB_CLUSTER\")].cluster.server}") + +if [ -z "$HUB_CONTEXT" ]; then + echo "Error: Could not find context for hub cluster '$HUB_CLUSTER'" + echo "Available clusters:" + kubectl config view -o jsonpath='{.clusters[*].name}' | tr ' ' '\n' + exit 1 +fi + +if [ -z "$HUB_API_SERVER" ]; then + echo "Error: Could not find API server URL for hub cluster '$HUB_CLUSTER'" + exit 1 +fi + +# Construct full image repository paths +METRIC_COLLECTOR_REPOSITORY="${REGISTRY}/${METRIC_COLLECTOR_IMAGE}" +METRIC_APP_REPOSITORY="${REGISTRY}/${METRIC_APP_IMAGE}" + +echo "=== Installing MetricCollector on ${#MEMBER_CLUSTERS[@]} member cluster(s) ===" +echo "Registry: ${REGISTRY}" +echo "Metric Collector Image: ${METRIC_COLLECTOR_REPOSITORY}:${IMAGE_TAG}" +echo "Metric App Image: ${METRIC_APP_REPOSITORY}:${IMAGE_TAG}" +echo "Hub cluster: ${HUB_CLUSTER}" +echo "Hub context: ${HUB_CONTEXT}" echo "Hub API server: ${HUB_API_SERVER}" +echo "Member clusters: ${MEMBER_CLUSTERS[@]}" echo "" -# Step 0: Build and load Docker images (once for all clusters) -echo "Step 0: Building Docker images..." - -# Build metric-collector image from parent directory (needs approval-request-controller) -cd .. -docker buildx build \ - --file metric-collector/docker/metric-collector.Dockerfile \ - --output=type=docker \ - --platform=linux/$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \ - --tag ${IMAGE_NAME}:${IMAGE_TAG} \ - --build-arg GOARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \ - --build-arg GOOS=linux \ - . -echo "✓ Metric collector image built" - -# Build metric-app image (still in parent directory) -docker buildx build \ - --file metric-collector/docker/metric-app.Dockerfile \ - --output=type=docker \ - --platform=linux/$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \ - --tag ${METRIC_APP_IMAGE_NAME}:${METRIC_APP_IMAGE_TAG} \ - . -echo "✓ Metric app image built" - -# Return to metric-collector directory -cd metric-collector echo "" # Install on each member cluster -for i in $(seq 1 ${MEMBER_CLUSTER_COUNT}); do - MEMBER_CONTEXT="kind-cluster-${i}" - MEMBER_CLUSTER_NAME="kind-cluster-${i}" +CLUSTER_INDEX=0 +for MEMBER_CLUSTER in "${MEMBER_CLUSTERS[@]}"; do + CLUSTER_INDEX=$((CLUSTER_INDEX + 1)) + + MEMBER_CONTEXT=$(kubectl config view -o jsonpath="{.contexts[?(@.context.cluster==\"$MEMBER_CLUSTER\")].name}") + MEMBER_CLUSTER_NAME="${MEMBER_CLUSTER}" HUB_NAMESPACE="fleet-member-${MEMBER_CLUSTER_NAME}" + if [ -z "$MEMBER_CONTEXT" ]; then + echo "Error: Could not find context for member cluster '$MEMBER_CLUSTER'" + echo "Available clusters:" + kubectl config view -o jsonpath='{.clusters[*].name}' | tr ' ' '\n' + exit 1 + fi + echo "========================================" - echo "Installing on Member Cluster ${i}/${MEMBER_CLUSTER_COUNT}" + echo "Installing on Member Cluster ${CLUSTER_INDEX}/${#MEMBER_CLUSTERS[@]}" + echo " Cluster: ${MEMBER_CLUSTER}" echo " Context: ${MEMBER_CONTEXT}" echo " Cluster Name: ${MEMBER_CLUSTER_NAME}" echo "========================================" echo "" - # Load image into this member cluster - echo "Loading Docker images into ${MEMBER_CONTEXT}..." - kind load docker-image ${IMAGE_NAME}:${IMAGE_TAG} --name cluster-${i} - kind load docker-image ${METRIC_APP_IMAGE_NAME}:${METRIC_APP_IMAGE_TAG} --name cluster-${i} - echo "✓ Images loaded into kind cluster" - echo "" - # Step 1: Setup RBAC on hub cluster echo "Step 1: Setting up RBAC on hub cluster..." - kubectl --context=${HUB_CONTEXT} create namespace ${HUB_NAMESPACE} --dry-run=client -o yaml | kubectl --context=${HUB_CONTEXT} apply -f - - kubectl --context=${HUB_CONTEXT} create serviceaccount metric-collector-sa -n ${HUB_NAMESPACE} --dry-run=client -o yaml | kubectl --context=${HUB_CONTEXT} apply -f - + + # Verify namespace exists (should be created by KubeFleet when member cluster joins) + if ! kubectl --context=${HUB_CONTEXT} get namespace ${HUB_NAMESPACE} &>/dev/null; then + echo "Error: Namespace ${HUB_NAMESPACE} does not exist on hub cluster" + echo "This namespace should be automatically created by KubeFleet when the member cluster joins the hub" + echo "Please ensure the member cluster is properly registered with the hub" + exit 1 + fi cat < Date: Fri, 12 Dec 2025 01:44:53 -0800 Subject: [PATCH 11/38] simplify metric-collector Signed-off-by: Arvind Thirumurugan --- .../metric/v1alpha1/metriccollector_types.go | 146 ---------- .../v1alpha1/metriccollectorreport_types.go | 62 +++-- .../metric/v1alpha1/zz_generated.deepcopy.go | 95 +------ ...netes-fleet.io_metriccollectorreports.yaml | 239 ++++++++-------- .../metric-collector/templates/hub-rbac.yaml | 58 +++- .../cmd/metriccollector/main.go | 91 +++--- .../{ => metriccollector}/metric-app/main.go | 0 .../metric-collector/go.mod | 3 +- .../metric-collector/go.sum | 12 +- .../metric-collector/install-on-member.sh | 35 ++- .../pkg/controller/collector.go | 58 ---- .../pkg/controller/controller.go | 260 +++++------------- 12 files changed, 371 insertions(+), 688 deletions(-) delete mode 100644 approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollector_types.go rename approval-controller-metric-collector/metric-collector/cmd/{ => metriccollector}/metric-app/main.go (100%) diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollector_types.go b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollector_types.go deleted file mode 100644 index 5511c19..0000000 --- a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollector_types.go +++ /dev/null @@ -1,146 +0,0 @@ -/* -Copyright 2025 The KubeFleet Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package v1alpha1 - -import ( - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// +genclient -// +genclient:nonNamespaced -// +kubebuilder:object:root=true -// +kubebuilder:resource:scope="Cluster",shortName=mc,categories={fleet,fleet-metrics} -// +kubebuilder:subresource:status -// +kubebuilder:storageversion -// +kubebuilder:printcolumn:JSONPath=`.metadata.generation`,name="Gen",type=string -// +kubebuilder:printcolumn:JSONPath=`.status.conditions[?(@.type=="MetricCollectorReady")].status`,name="Ready",type=string -// +kubebuilder:printcolumn:JSONPath=`.status.workloadsMonitored`,name="Workloads",type=integer -// +kubebuilder:printcolumn:JSONPath=`.status.lastCollectionTime`,name="Last-Collection",type=date -// +kubebuilder:printcolumn:JSONPath=`.metadata.creationTimestamp`,name="Age",type=date - -// MetricCollector is used by member-agent to scrape and collect metrics from workloads -// running on the member cluster. It runs on each member cluster and collects metrics -// from Prometheus-compatible endpoints. -type MetricCollector struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - // The desired state of MetricCollector. - // +required - Spec MetricCollectorSpec `json:"spec"` - - // The observed status of MetricCollector. - // +optional - Status MetricCollectorStatus `json:"status,omitempty"` -} - -// MetricCollectorSpec defines the desired state of MetricCollector. -type MetricCollectorSpec struct { - // PrometheusURL is the URL of the Prometheus server. - // Example: http://prometheus.test-ns.svc.cluster.local:9090 - // +required - // +kubebuilder:validation:Pattern=`^https?://.*$` - PrometheusURL string `json:"prometheusUrl"` - - // ReportNamespace is the namespace in the hub cluster where the MetricCollectorReport will be created. - // This should be the fleet-member-{clusterName} namespace. - // Example: fleet-member-cluster-1 - // +required - ReportNamespace string `json:"reportNamespace"` -} - -// MetricCollectorStatus defines the observed state of MetricCollector. -type MetricCollectorStatus struct { - // Conditions is an array of current observed conditions. - // +optional - Conditions []metav1.Condition `json:"conditions,omitempty"` - - // ObservedGeneration is the generation most recently observed. - // +optional - ObservedGeneration int64 `json:"observedGeneration,omitempty"` - - // WorkloadsMonitored is the count of workloads being monitored. - // +optional - WorkloadsMonitored int32 `json:"workloadsMonitored,omitempty"` - - // LastCollectionTime is when metrics were last collected. - // +optional - LastCollectionTime *metav1.Time `json:"lastCollectionTime,omitempty"` - - // CollectedMetrics contains the most recent metrics from each workload. - // +optional - CollectedMetrics []WorkloadMetrics `json:"collectedMetrics,omitempty"` -} - -// WorkloadMetrics represents metrics collected from a single workload pod. -type WorkloadMetrics struct { - // Namespace is the namespace of the pod. - // +required - Namespace string `json:"namespace"` - - // ClusterName from the workload_health metric label. - // +required - ClusterName string `json:"clusterName"` - - // WorkloadName from the workload_health metric label (typically the deployment name). - // +required - WorkloadName string `json:"workloadName"` - - // Health indicates if the workload is healthy (true=healthy, false=unhealthy). - // +required - Health bool `json:"health"` -} - -const ( - // MetricCollectorConditionTypeReady indicates the collector is ready. - MetricCollectorConditionTypeReady string = "MetricCollectorReady" - - // MetricCollectorConditionTypeCollecting indicates metrics are being collected. - MetricCollectorConditionTypeCollecting string = "MetricsCollecting" - - // MetricCollectorConditionTypeReported indicates metrics were successfully reported to hub. - MetricCollectorConditionTypeReported string = "MetricsReported" -) - -// +kubebuilder:object:root=true - -// MetricCollectorList contains a list of MetricCollector. -type MetricCollectorList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []MetricCollector `json:"items"` -} - -// GetConditions returns the conditions of the MetricCollector. -func (m *MetricCollector) GetConditions() []metav1.Condition { - return m.Status.Conditions -} - -// SetConditions sets the conditions of the MetricCollector. -func (m *MetricCollector) SetConditions(conditions ...metav1.Condition) { - m.Status.Conditions = conditions -} - -// GetCondition returns the condition of the given MetricCollector. -func (m *MetricCollector) GetCondition(conditionType string) *metav1.Condition { - return meta.FindStatusCondition(m.Status.Conditions, conditionType) -} - -func init() { - SchemeBuilder.Register(&MetricCollector{}, &MetricCollectorList{}) -} diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollectorreport_types.go b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollectorreport_types.go index d21a6c7..d30e06c 100644 --- a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollectorreport_types.go +++ b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollectorreport_types.go @@ -22,37 +22,45 @@ import ( // +genclient // +kubebuilder:object:root=true +// +kubebuilder:subresource:status // +kubebuilder:resource:scope="Namespaced",shortName=mcr,categories={fleet,fleet-metrics} // +kubebuilder:storageversion -// +kubebuilder:printcolumn:JSONPath=`.workloadsMonitored`,name="Workloads",type=integer -// +kubebuilder:printcolumn:JSONPath=`.lastCollectionTime`,name="Last-Collection",type=date +// +kubebuilder:printcolumn:JSONPath=`.status.workloadsMonitored`,name="Workloads",type=integer +// +kubebuilder:printcolumn:JSONPath=`.status.lastCollectionTime`,name="Last-Collection",type=date // +kubebuilder:printcolumn:JSONPath=`.metadata.creationTimestamp`,name="Age",type=date -// MetricCollectorReport is created by the MetricCollector controller on the hub cluster -// in the fleet-member-{clusterName} namespace to report collected metrics from a member cluster. -// The controller watches MetricCollector objects on the member cluster, collects metrics, -// and syncs the status to the hub as MetricCollectorReport objects. +// MetricCollectorReport is created by the approval-request-controller on the hub cluster +// in the fleet-member-{clusterName} namespace. The metric-collector on the member cluster +// watches these reports and updates their status with collected metrics. // // Controller workflow: -// 1. MetricCollector reconciles and collects metrics on member cluster -// 2. Metrics include clusterName from workload_health labels -// 3. Controller creates/updates MetricCollectorReport in fleet-member-{clusterName} namespace on hub -// 4. Report name matches MetricCollector name for easy lookup +// 1. Approval-controller creates MetricCollectorReport with spec on hub +// 2. Metric-collector watches MetricCollectorReport on hub (in fleet-member-{clusterName} namespace) +// 3. Metric-collector queries Prometheus on member cluster +// 4. Metric-collector updates MetricCollectorReport status on hub with collected metrics // -// Namespace: fleet-member-{clusterName} (extracted from CollectedMetrics[0].ClusterName) -// Name: Same as MetricCollector name -// All metrics in CollectedMetrics are guaranteed to have the same ClusterName. +// Namespace: fleet-member-{clusterName} +// Name: Matches the UpdateRun name type MetricCollectorReport struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - // Conditions copied from the MetricCollector status. - // +optional - Conditions []metav1.Condition `json:"conditions,omitempty"` + Spec MetricCollectorReportSpec `json:"spec,omitempty"` + Status MetricCollectorReportStatus `json:"status,omitempty"` +} + +// MetricCollectorReportSpec defines the configuration for metric collection. +type MetricCollectorReportSpec struct { + // PrometheusURL is the URL of the Prometheus server on the member cluster + // Example: "http://prometheus.fleet-system.svc.cluster.local:9090" + PrometheusURL string `json:"prometheusUrl"` +} - // ObservedGeneration is the generation most recently observed from the MetricCollector. +// MetricCollectorReportStatus contains the collected metrics from the member cluster. +type MetricCollectorReportStatus struct { + // Conditions represent the latest available observations of the report's state. // +optional - ObservedGeneration int64 `json:"observedGeneration,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` // WorkloadsMonitored is the count of workloads being monitored. // +optional @@ -63,13 +71,23 @@ type MetricCollectorReport struct { LastCollectionTime *metav1.Time `json:"lastCollectionTime,omitempty"` // CollectedMetrics contains the most recent metrics from each workload. - // All metrics are guaranteed to have the same ClusterName since they're collected from one member cluster. // +optional CollectedMetrics []WorkloadMetrics `json:"collectedMetrics,omitempty"` +} - // LastReportTime is when this report was last synced to the hub. - // +optional - LastReportTime *metav1.Time `json:"lastReportTime,omitempty"` +// WorkloadMetrics represents metrics collected from a single workload pod. +type WorkloadMetrics struct { + // Namespace of the workload. + // +required + Namespace string `json:"namespace"` + + // WorkloadName from the workload_health metric label. + // +required + WorkloadName string `json:"workloadName"` + + // Health indicates if the workload is healthy (true=healthy, false=unhealthy). + // +required + Health bool `json:"health"` } // +kubebuilder:object:root=true diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/zz_generated.deepcopy.go b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/zz_generated.deepcopy.go index 87e2a92..7e3ca6d 100644 --- a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/zz_generated.deepcopy.go +++ b/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/zz_generated.deepcopy.go @@ -88,7 +88,7 @@ func (in *ClusterStagedWorkloadTrackerList) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MetricCollector) DeepCopyInto(out *MetricCollector) { +func (in *MetricCollectorReport) DeepCopyInto(out *MetricCollectorReport) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) @@ -96,83 +96,6 @@ func (in *MetricCollector) DeepCopyInto(out *MetricCollector) { in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricCollector. -func (in *MetricCollector) DeepCopy() *MetricCollector { - if in == nil { - return nil - } - out := new(MetricCollector) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MetricCollector) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MetricCollectorList) DeepCopyInto(out *MetricCollectorList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]MetricCollector, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricCollectorList. -func (in *MetricCollectorList) DeepCopy() *MetricCollectorList { - if in == nil { - return nil - } - out := new(MetricCollectorList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MetricCollectorList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MetricCollectorReport) DeepCopyInto(out *MetricCollectorReport) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.LastCollectionTime != nil { - in, out := &in.LastCollectionTime, &out.LastCollectionTime - *out = (*in).DeepCopy() - } - if in.CollectedMetrics != nil { - in, out := &in.CollectedMetrics, &out.CollectedMetrics - *out = make([]WorkloadMetrics, len(*in)) - copy(*out, *in) - } - if in.LastReportTime != nil { - in, out := &in.LastReportTime, &out.LastReportTime - *out = (*in).DeepCopy() - } -} - // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricCollectorReport. func (in *MetricCollectorReport) DeepCopy() *MetricCollectorReport { if in == nil { @@ -224,22 +147,22 @@ func (in *MetricCollectorReportList) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MetricCollectorSpec) DeepCopyInto(out *MetricCollectorSpec) { +func (in *MetricCollectorReportSpec) DeepCopyInto(out *MetricCollectorReportSpec) { *out = *in } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricCollectorSpec. -func (in *MetricCollectorSpec) DeepCopy() *MetricCollectorSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricCollectorReportSpec. +func (in *MetricCollectorReportSpec) DeepCopy() *MetricCollectorReportSpec { if in == nil { return nil } - out := new(MetricCollectorSpec) + out := new(MetricCollectorReportSpec) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MetricCollectorStatus) DeepCopyInto(out *MetricCollectorStatus) { +func (in *MetricCollectorReportStatus) DeepCopyInto(out *MetricCollectorReportStatus) { *out = *in if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions @@ -259,12 +182,12 @@ func (in *MetricCollectorStatus) DeepCopyInto(out *MetricCollectorStatus) { } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricCollectorStatus. -func (in *MetricCollectorStatus) DeepCopy() *MetricCollectorStatus { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricCollectorReportStatus. +func (in *MetricCollectorReportStatus) DeepCopy() *MetricCollectorReportStatus { if in == nil { return nil } - out := new(MetricCollectorStatus) + out := new(MetricCollectorReportStatus) in.DeepCopyInto(out) return out } diff --git a/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectorreports.yaml b/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectorreports.yaml index a530498..6e58269 100644 --- a/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectorreports.yaml +++ b/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectorreports.yaml @@ -20,10 +20,10 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: - - jsonPath: .workloadsMonitored + - jsonPath: .status.workloadsMonitored name: Workloads type: integer - - jsonPath: .lastCollectionTime + - jsonPath: .status.lastCollectionTime name: Last-Collection type: date - jsonPath: .metadata.creationTimestamp @@ -33,20 +33,18 @@ spec: schema: openAPIV3Schema: description: |- - MetricCollectorReport is created by the MetricCollector controller on the hub cluster - in the fleet-member-{clusterName} namespace to report collected metrics from a member cluster. - The controller watches MetricCollector objects on the member cluster, collects metrics, - and syncs the status to the hub as MetricCollectorReport objects. + MetricCollectorReport is created by the approval-request-controller on the hub cluster + in the fleet-member-{clusterName} namespace. The metric-collector on the member cluster + watches these reports and updates their status with collected metrics. Controller workflow: - 1. MetricCollector reconciles and collects metrics on member cluster - 2. Metrics include clusterName from workload_health labels - 3. Controller creates/updates MetricCollectorReport in fleet-member-{clusterName} namespace on hub - 4. Report name matches MetricCollector name for easy lookup + 1. Approval-controller creates MetricCollectorReport with spec on hub + 2. Metric-collector watches MetricCollectorReport on hub (in fleet-member-{clusterName} namespace) + 3. Metric-collector queries Prometheus on member cluster + 4. Metric-collector updates MetricCollectorReport status on hub with collected metrics - Namespace: fleet-member-{clusterName} (extracted from CollectedMetrics[0].ClusterName) - Name: Same as MetricCollector name - All metrics in CollectedMetrics are guaranteed to have the same ClusterName. + Namespace: fleet-member-{clusterName} + Name: Matches the UpdateRun name properties: apiVersion: description: |- @@ -55,92 +53,6 @@ spec: may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string - collectedMetrics: - description: |- - CollectedMetrics contains the most recent metrics from each workload. - All metrics are guaranteed to have the same ClusterName since they're collected from one member cluster. - items: - description: WorkloadMetrics represents metrics collected from a single - workload pod. - properties: - clusterName: - description: ClusterName from the workload_health metric label. - type: string - health: - description: Health indicates if the workload is healthy (true=healthy, - false=unhealthy). - type: boolean - namespace: - description: Namespace is the namespace of the pod. - type: string - workloadName: - description: WorkloadName from the workload_health metric label - (typically the deployment name). - type: string - required: - - clusterName - - health - - namespace - - workloadName - type: object - type: array - conditions: - description: Conditions copied from the MetricCollector status. - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array kind: description: |- Kind is a string value representing the REST resource this object represents. @@ -149,28 +61,117 @@ spec: In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds type: string - lastCollectionTime: - description: LastCollectionTime is when metrics were last collected on - the member cluster. - format: date-time - type: string - lastReportTime: - description: LastReportTime is when this report was last synced to the - hub. - format: date-time - type: string metadata: type: object - observedGeneration: - description: ObservedGeneration is the generation most recently observed - from the MetricCollector. - format: int64 - type: integer - workloadsMonitored: - description: WorkloadsMonitored is the count of workloads being monitored. - format: int32 - type: integer + spec: + description: MetricCollectorReportSpec defines the configuration for metric + collection. + properties: + prometheusUrl: + description: |- + PrometheusURL is the URL of the Prometheus server on the member cluster + Example: "http://prometheus.fleet-system.svc.cluster.local:9090" + type: string + required: + - prometheusUrl + type: object + status: + description: MetricCollectorReportStatus contains the collected metrics + from the member cluster. + properties: + collectedMetrics: + description: CollectedMetrics contains the most recent metrics from + each workload. + items: + description: WorkloadMetrics represents metrics collected from a + single workload pod. + properties: + health: + description: Health indicates if the workload is healthy (true=healthy, + false=unhealthy). + type: boolean + namespace: + description: Namespace of the workload. + type: string + workloadName: + description: WorkloadName from the workload_health metric label. + type: string + required: + - health + - namespace + - workloadName + type: object + type: array + conditions: + description: Conditions represent the latest available observations + of the report's state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastCollectionTime: + description: LastCollectionTime is when metrics were last collected + on the member cluster. + format: date-time + type: string + workloadsMonitored: + description: WorkloadsMonitored is the count of workloads being monitored. + format: int32 + type: integer + type: object type: object served: true storage: true - subresources: {} + subresources: + status: {} diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/hub-rbac.yaml b/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/hub-rbac.yaml index af50a2d..1f016de 100644 --- a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/hub-rbac.yaml +++ b/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/hub-rbac.yaml @@ -1,7 +1,7 @@ {{- if .Values.hubCluster.createRBAC }} # This template generates RBAC resources for the hub cluster # Apply this on the HUB cluster to grant the metric-collector permissions -# to create/update MetricCollectorReport resources +# to watch/update MetricCollectorReport resources in the fleet-member- namespace # # Usage: # helm template metric-collector ./charts/metric-collector \ @@ -9,10 +9,12 @@ # --show-only templates/hub-rbac.yaml | kubectl apply -f - --context=hub-cluster # --- +# Role for MetricCollectorReport access in fleet-member- namespace apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole +kind: Role metadata: - name: {{ include "metric-collector.fullname" . }}-hub-access + name: {{ include "metric-collector.fullname" . }}-report-access + namespace: fleet-member-{{ .Values.memberCluster.name }} labels: {{- include "metric-collector.labels" . | nindent 4 }} app.kubernetes.io/component: hub-rbac @@ -22,16 +24,50 @@ rules: # MetricCollectorReport access - apiGroups: ["metric.kubernetes-fleet.io"] resources: ["metriccollectorreports"] - verbs: ["get", "list", "create", "update", "patch", "delete"] - # Namespace access for fleet-{cluster} namespaces - - apiGroups: [""] - resources: ["namespaces"] - verbs: ["get", "list"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: ["metric.kubernetes-fleet.io"] + resources: ["metriccollectorreports/status"] + verbs: ["update", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "metric-collector.fullname" . }}-report-access + namespace: fleet-member-{{ .Values.memberCluster.name }} + labels: + {{- include "metric-collector.labels" . | nindent 4 }} + app.kubernetes.io/component: hub-rbac + fleet.kubernetes.io/member-cluster: {{ .Values.memberCluster.name }} + annotations: + helm.sh/resource-policy: keep +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "metric-collector.fullname" . }}-report-access +subjects: + - kind: ServiceAccount + name: {{ .Values.hubCluster.auth.serviceAccountName | default (include "metric-collector.serviceAccountName" .) }} + namespace: fleet-member-{{ .Values.memberCluster.name }} +--- +# ClusterRole for reading ClusterStagedWorkloadTracker (cluster-scoped) +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "metric-collector.fullname" . }}-workloadtracker-reader + labels: + {{- include "metric-collector.labels" . | nindent 4 }} + app.kubernetes.io/component: hub-rbac + annotations: + helm.sh/resource-policy: keep +rules: + - apiGroups: ["placement.kubernetes-fleet.io"] + resources: ["clusterstagedworkloadtrackers"] + verbs: ["get", "list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: {{ include "metric-collector.fullname" . }}-{{ .Values.memberCluster.name }} + name: {{ include "metric-collector.fullname" . }}-{{ .Values.memberCluster.name }}-workloadtracker labels: {{- include "metric-collector.labels" . | nindent 4 }} app.kubernetes.io/component: hub-rbac @@ -41,9 +77,9 @@ metadata: roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: {{ include "metric-collector.fullname" . }}-hub-access + name: {{ include "metric-collector.fullname" . }}-workloadtracker-reader subjects: - kind: ServiceAccount name: {{ .Values.hubCluster.auth.serviceAccountName | default (include "metric-collector.serviceAccountName" .) }} - namespace: {{ .Values.hubCluster.auth.serviceAccountNamespace | default .Release.Namespace }} + namespace: fleet-member-{{ .Values.memberCluster.name }} {{- end }} diff --git a/approval-controller-metric-collector/metric-collector/cmd/metriccollector/main.go b/approval-controller-metric-collector/metric-collector/cmd/metriccollector/main.go index 69a4d41..ea72026 100644 --- a/approval-controller-metric-collector/metric-collector/cmd/metriccollector/main.go +++ b/approval-controller-metric-collector/metric-collector/cmd/metriccollector/main.go @@ -23,22 +23,21 @@ import ( "net/http" "os" - corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/healthz" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" placementv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1" metriccollector "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/metric-collector/pkg/controller" + placementv1beta1 "github.com/kubefleet-dev/kubefleet/apis/placement/v1beta1" ) var ( - memberQPS = flag.Int("member-qps", 100, "QPS for member cluster client") - memberBurst = flag.Int("member-burst", 200, "Burst for member cluster client") hubQPS = flag.Int("hub-qps", 100, "QPS for hub cluster client") hubBurst = flag.Int("hub-burst", 200, "Burst for hub cluster client") metricsAddr = flag.String("metrics-bind-address", ":8080", "The address the metric endpoint binds to.") @@ -53,10 +52,16 @@ func main() { klog.InfoS("Starting MetricCollector Controller") - // Get member cluster config (in-cluster) - memberConfig := ctrl.GetConfigOrDie() - memberConfig.QPS = float32(*memberQPS) - memberConfig.Burst = *memberBurst + // Get member cluster identity + memberClusterName := os.Getenv("MEMBER_CLUSTER_NAME") + if memberClusterName == "" { + klog.ErrorS(nil, "MEMBER_CLUSTER_NAME environment variable not set") + os.Exit(1) + } + + // Construct hub namespace + hubNamespace := fmt.Sprintf("fleet-member-%s", memberClusterName) + klog.InfoS("Using hub namespace", "namespace", hubNamespace, "memberCluster", memberClusterName) // Build hub cluster config hubConfig, err := buildHubConfig() @@ -67,8 +72,8 @@ func main() { hubConfig.QPS = float32(*hubQPS) hubConfig.Burst = *hubBurst - // Start controller with both clients - if err := Start(ctrl.SetupSignalHandler(), hubConfig, memberConfig); err != nil { + // Start controller + if err := Start(ctrl.SetupSignalHandler(), hubConfig, memberClusterName, hubNamespace); err != nil { klog.ErrorS(err, "Failed to start controller") os.Exit(1) } @@ -171,20 +176,28 @@ func (t *customHeaderTransport) RoundTrip(req *http.Request) (*http.Response, er return t.Base.RoundTrip(req) } -// Start starts the controller with dual managers for hub and member clusters -func Start(ctx context.Context, hubCfg, memberCfg *rest.Config) error { +// Start starts the controller with hub cluster connection +func Start(ctx context.Context, hubCfg *rest.Config, memberClusterName, hubNamespace string) error { // Create scheme with required APIs scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + return fmt.Errorf("failed to add client-go scheme: %w", err) + } if err := placementv1alpha1.AddToScheme(scheme); err != nil { - return fmt.Errorf("failed to add placement API to scheme: %w", err) + return fmt.Errorf("failed to add placement v1alpha1 API to scheme: %w", err) } - if err := corev1.AddToScheme(scheme); err != nil { - return fmt.Errorf("failed to add core API to scheme: %w", err) + if err := placementv1beta1.AddToScheme(scheme); err != nil { + return fmt.Errorf("failed to add placement v1beta1 API to scheme: %w", err) } - // Create member cluster manager (where controller runs and watches MetricCollector) - memberMgr, err := ctrl.NewManager(memberCfg, ctrl.Options{ + // Create hub cluster manager - watches MetricCollectorReport in hub namespace + hubMgr, err := ctrl.NewManager(hubCfg, ctrl.Options{ Scheme: scheme, + Cache: cache.Options{ + DefaultNamespaces: map[string]cache.Config{ + hubNamespace: {}, // Only watch fleet-member- + }, + }, Metrics: metricsserver.Options{ BindAddress: *metricsAddr, }, @@ -193,52 +206,32 @@ func Start(ctx context.Context, hubCfg, memberCfg *rest.Config) error { LeaderElectionID: *leaderElectionID, }) if err != nil { - return fmt.Errorf("failed to create member manager: %w", err) + return fmt.Errorf("failed to create hub manager: %w", err) } - // Create hub cluster client (for writing MetricCollectorReports) - hubClient, err := client.New(hubCfg, client.Options{Scheme: scheme}) - if err != nil { - return fmt.Errorf("failed to create hub client: %w", err) - } - - // Get Prometheus URL from environment - prometheusURL := os.Getenv("PROMETHEUS_URL") - if prometheusURL == "" { - prometheusURL = "http://prometheus.fleet-system.svc.cluster.local:9090" - klog.InfoS("PROMETHEUS_URL not set, using default", "url", prometheusURL) - } - - // Create Prometheus client - prometheusClient := metriccollector.NewPrometheusClient(prometheusURL, "", nil) - - // Setup MetricCollector controller + // Setup MetricCollectorReport controller (watches hub, queries member Prometheus) if err := (&metriccollector.Reconciler{ - MemberClient: memberMgr.GetClient(), - HubClient: hubClient, - PrometheusClient: prometheusClient, - }).SetupWithManager(memberMgr); err != nil { - return fmt.Errorf("failed to setup MetricCollector controller: %w", err) + HubClient: hubMgr.GetClient(), + }).SetupWithManager(hubMgr); err != nil { + return fmt.Errorf("failed to setup controller: %w", err) } // Add health checks - if err := memberMgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + if err := hubMgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { return fmt.Errorf("failed to add healthz check: %w", err) } - if err := memberMgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + if err := hubMgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { return fmt.Errorf("failed to add readyz check: %w", err) } klog.InfoS("Starting MetricCollector controller", "hubUrl", hubCfg.Host, - "prometheusUrl", prometheusURL, + "hubNamespace", hubNamespace, + "memberCluster", memberClusterName, "metricsAddr", *metricsAddr, "probeAddr", *probeAddr) - // Start the manager - if err := memberMgr.Start(ctx); err != nil { - return fmt.Errorf("failed to start manager: %w", err) - } - - return nil + // Start hub manager (watches MetricCollectorReport on hub, queries Prometheus on member) + klog.InfoS("Starting hub manager", "namespace", hubNamespace) + return hubMgr.Start(ctx) } diff --git a/approval-controller-metric-collector/metric-collector/cmd/metric-app/main.go b/approval-controller-metric-collector/metric-collector/cmd/metriccollector/metric-app/main.go similarity index 100% rename from approval-controller-metric-collector/metric-collector/cmd/metric-app/main.go rename to approval-controller-metric-collector/metric-collector/cmd/metriccollector/metric-app/main.go diff --git a/approval-controller-metric-collector/metric-collector/go.mod b/approval-controller-metric-collector/metric-collector/go.mod index 61410e1..1c49a89 100644 --- a/approval-controller-metric-collector/metric-collector/go.mod +++ b/approval-controller-metric-collector/metric-collector/go.mod @@ -3,8 +3,10 @@ module github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-co go 1.24.9 require ( + github.com/kubefleet-dev/kubefleet v0.1.2 github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller v0.0.0 github.com/prometheus/client_golang v1.22.0 + golang.org/x/sync v0.18.0 k8s.io/api v0.34.1 k8s.io/apimachinery v0.34.1 k8s.io/client-go v0.34.1 @@ -46,7 +48,6 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.29.0 // indirect - golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/approval-controller-metric-collector/metric-collector/go.sum b/approval-controller-metric-collector/metric-collector/go.sum index f0dda42..dc0cc21 100644 --- a/approval-controller-metric-collector/metric-collector/go.sum +++ b/approval-controller-metric-collector/metric-collector/go.sum @@ -39,8 +39,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -55,6 +55,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kubefleet-dev/kubefleet v0.1.2 h1:BUOwehI9iBavU6TEbebrSxtFXHwyOcY1eacHyfHEjxo= +github.com/kubefleet-dev/kubefleet v0.1.2/go.mod h1:EYDCdtdM02qQkH3Gm5/K1cHDy26f2LbM7WzVGn2saLs= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= @@ -67,8 +69,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= -github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -98,6 +100,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= diff --git a/approval-controller-metric-collector/metric-collector/install-on-member.sh b/approval-controller-metric-collector/metric-collector/install-on-member.sh index c69c2f1..de4dd9b 100755 --- a/approval-controller-metric-collector/metric-collector/install-on-member.sh +++ b/approval-controller-metric-collector/metric-collector/install-on-member.sh @@ -99,25 +99,52 @@ metadata: name: metric-collector-sa namespace: ${HUB_NAMESPACE} --- +# Role for MetricCollectorReport access in fleet-member namespace apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: - name: metric-collector-role + name: metric-collector-report-role namespace: ${HUB_NAMESPACE} rules: - apiGroups: ["metric.kubernetes-fleet.io"] resources: ["metriccollectorreports"] - verbs: ["get", "list", "watch", "create", "update", "patch"] + verbs: ["get", "list", "watch", "update", "patch"] +- apiGroups: ["metric.kubernetes-fleet.io"] + resources: ["metriccollectorreports/status"] + verbs: ["update", "patch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: - name: metric-collector-rolebinding + name: metric-collector-report-rolebinding namespace: ${HUB_NAMESPACE} roleRef: apiGroup: rbac.authorization.k8s.io kind: Role - name: metric-collector-role + name: metric-collector-report-role +subjects: +- kind: ServiceAccount + name: metric-collector-sa + namespace: ${HUB_NAMESPACE} +--- +# ClusterRole for reading ClusterStagedWorkloadTracker (cluster-scoped) +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: metric-collector-workloadtracker-reader-${MEMBER_CLUSTER_NAME} +rules: +- apiGroups: ["placement.kubernetes-fleet.io"] + resources: ["clusterstagedworkloadtrackers"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: metric-collector-workloadtracker-${MEMBER_CLUSTER_NAME} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: metric-collector-workloadtracker-reader-${MEMBER_CLUSTER_NAME} subjects: - kind: ServiceAccount name: metric-collector-sa diff --git a/approval-controller-metric-collector/metric-collector/pkg/controller/collector.go b/approval-controller-metric-collector/metric-collector/pkg/controller/collector.go index 11fdee0..ef3cde0 100644 --- a/approval-controller-metric-collector/metric-collector/pkg/controller/collector.go +++ b/approval-controller-metric-collector/metric-collector/pkg/controller/collector.go @@ -28,9 +28,6 @@ import ( "time" corev1 "k8s.io/api/core/v1" - "k8s.io/klog/v2" - - localv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1" ) // PrometheusClient is the interface for querying Prometheus @@ -149,58 +146,3 @@ type PrometheusResult struct { Metric map[string]string `json:"metric"` Value []interface{} `json:"value"` // [timestamp, value] } - -// collectFromPrometheus collects metrics from a Prometheus endpoint -func (r *Reconciler) collectFromPrometheus(ctx context.Context, mc *localv1alpha1.MetricCollector) ([]localv1alpha1.WorkloadMetrics, error) { - // Create Prometheus client without auth (simplified) - promClient := NewPrometheusClient(mc.Spec.PrometheusURL, "", nil) - - query := buildPromQLQuery(mc) - klog.V(4).InfoS("Executing PromQL query", "query", query) - - result, err := promClient.Query(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to query Prometheus: %w", err) - } - - // Parse Prometheus response - data, ok := result.(PrometheusData) - if !ok { - return nil, fmt.Errorf("invalid Prometheus response type") - } - - // Extract metrics for each workload - workloadMetrics := make([]localv1alpha1.WorkloadMetrics, 0, len(data.Result)) - for _, res := range data.Result { - namespace := res.Metric["namespace"] - workloadName := res.Metric["app"] - - if namespace == "" || workloadName == "" { - continue - } - - // Extract health value - var health float64 - if len(res.Value) >= 2 { - if valueStr, ok := res.Value[1].(string); ok { - fmt.Sscanf(valueStr, "%f", &health) - } - } - - wm := localv1alpha1.WorkloadMetrics{ - Namespace: namespace, - WorkloadName: workloadName, - Health: health == 1.0, // Convert to boolean: 1.0 = true, 0.0 = false - } - workloadMetrics = append(workloadMetrics, wm) - } - - klog.V(2).InfoS("Collected metrics from Prometheus", "workloads", len(workloadMetrics)) - return workloadMetrics, nil -} - -// buildPromQLQuery builds a PromQL query for workload_health metric -func buildPromQLQuery(mc *localv1alpha1.MetricCollector) string { - // Query all workload_health metrics (MetricCollector is cluster-scoped) - return `workload_health` -} diff --git a/approval-controller-metric-collector/metric-collector/pkg/controller/controller.go b/approval-controller-metric-collector/metric-collector/pkg/controller/controller.go index f0a2266..7d67c92 100644 --- a/approval-controller-metric-collector/metric-collector/pkg/controller/controller.go +++ b/approval-controller-metric-collector/metric-collector/pkg/controller/controller.go @@ -24,12 +24,10 @@ import ( "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/tools/record" "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/predicate" localv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1" @@ -38,251 +36,137 @@ import ( const ( // defaultCollectionInterval is the interval for collecting metrics (30 seconds) defaultCollectionInterval = 30 * time.Second - - // metricCollectorFinalizer is the finalizer for cleaning up MetricCollectorReport - metricCollectorFinalizer = "kubernetes-fleet.io/metric-collector-report-cleanup" ) -// Reconciler reconciles a MetricCollector object +// Reconciler reconciles a MetricCollectorReport object on the hub cluster type Reconciler struct { - // MemberClient is the client to access the member cluster - MemberClient client.Client - - // HubClient is the client to access the hub cluster + // HubClient is the client to access the hub cluster (for MetricCollectorReport and WorkloadTracker) HubClient client.Client - - // recorder is the event recorder - recorder record.EventRecorder - - // PrometheusClient is the client to query Prometheus - PrometheusClient PrometheusClient } -// Reconcile reconciles a MetricCollector object +// Reconcile watches MetricCollectorReport on hub and updates it with metrics from member Prometheus func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { startTime := time.Now() - klog.V(2).InfoS("MetricCollector reconciliation starts", "metricCollector", req.Name) + klog.V(2).InfoS("MetricCollectorReport reconciliation starts", "report", req.NamespacedName) defer func() { latency := time.Since(startTime).Milliseconds() - klog.V(2).InfoS("MetricCollector reconciliation ends", "metricCollector", req.Name, "latency", latency) + klog.V(2).InfoS("MetricCollectorReport reconciliation ends", "report", req.NamespacedName, "latency", latency) }() - // Fetch the MetricCollector instance (cluster-scoped) - mc := &localv1alpha1.MetricCollector{} - if err := r.MemberClient.Get(ctx, client.ObjectKey{Name: req.Name}, mc); err != nil { + // 1. Get MetricCollectorReport from hub cluster + report := &localv1alpha1.MetricCollectorReport{} + if err := r.HubClient.Get(ctx, req.NamespacedName, report); err != nil { if errors.IsNotFound(err) { - klog.V(2).InfoS("MetricCollector not found, ignoring", "metricCollector", req.Name) + klog.V(2).InfoS("MetricCollectorReport not found, ignoring", "report", req.NamespacedName) return ctrl.Result{}, nil } - klog.ErrorS(err, "Failed to get MetricCollector", "metricCollector", req.Name) + klog.ErrorS(err, "Failed to get MetricCollectorReport", "report", req.NamespacedName) return ctrl.Result{}, err } - // Handle deletion - cleanup MetricCollectorReport on hub - if !mc.DeletionTimestamp.IsZero() { - if controllerutil.ContainsFinalizer(mc, metricCollectorFinalizer) { - klog.V(2).InfoS("Cleaning up MetricCollectorReport on hub", "metricCollector", req.Name) - - // Delete MetricCollectorReport from hub cluster - if err := r.deleteReportFromHub(ctx, mc); err != nil { - klog.ErrorS(err, "Failed to delete MetricCollectorReport from hub", "metricCollector", req.Name) - return ctrl.Result{}, err - } + klog.InfoS("Reconciling MetricCollectorReport", "name", report.Name, "namespace", report.Namespace) - // Remove finalizer - controllerutil.RemoveFinalizer(mc, metricCollectorFinalizer) - if err := r.MemberClient.Update(ctx, mc); err != nil { - klog.ErrorS(err, "Failed to remove finalizer", "metricCollector", req.Name) - return ctrl.Result{}, err - } - klog.V(2).InfoS("Successfully cleaned up MetricCollectorReport", "metricCollector", req.Name) - } - return ctrl.Result{}, nil - } + // 2. Get PrometheusURL from report spec (or use default) + prometheusURL := report.Spec.PrometheusURL - // Add finalizer if not present - if !controllerutil.ContainsFinalizer(mc, metricCollectorFinalizer) { - controllerutil.AddFinalizer(mc, metricCollectorFinalizer) - if err := r.MemberClient.Update(ctx, mc); err != nil { - klog.ErrorS(err, "Failed to add finalizer", "metricCollector", req.Name) - return ctrl.Result{}, err - } - klog.V(2).InfoS("Added finalizer to MetricCollector", "metricCollector", req.Name) - } + // 3. Query Prometheus on member cluster for all workload_health metrics + promClient := NewPrometheusClient(prometheusURL, "", nil) + collectedMetrics, collectErr := r.collectAllWorkloadMetrics(ctx, promClient) - // Collect metrics from Prometheus - collectedMetrics, collectErr := r.collectFromPrometheus(ctx, mc) - - // Update status with collected metrics + // 5. Update MetricCollectorReport status on hub now := metav1.Now() - mc.Status.LastCollectionTime = &now - mc.Status.CollectedMetrics = collectedMetrics - mc.Status.WorkloadsMonitored = int32(len(collectedMetrics)) - mc.Status.ObservedGeneration = mc.Generation + report.Status.LastCollectionTime = &now + report.Status.CollectedMetrics = collectedMetrics + report.Status.WorkloadsMonitored = int32(len(collectedMetrics)) if collectErr != nil { - klog.ErrorS(collectErr, "Failed to collect metrics", "metricCollector", req.Name) - meta.SetStatusCondition(&mc.Status.Conditions, metav1.Condition{ - Type: localv1alpha1.MetricCollectorConditionTypeReady, - Status: metav1.ConditionTrue, - ObservedGeneration: mc.Generation, - Reason: "CollectorConfigured", - Message: "Collector is configured", - }) - meta.SetStatusCondition(&mc.Status.Conditions, metav1.Condition{ - Type: localv1alpha1.MetricCollectorConditionTypeCollecting, + klog.ErrorS(collectErr, "Failed to collect metrics", "prometheusUrl", prometheusURL) + meta.SetStatusCondition(&report.Status.Conditions, metav1.Condition{ + Type: "MetricsCollected", Status: metav1.ConditionFalse, - ObservedGeneration: mc.Generation, + ObservedGeneration: report.Generation, Reason: "CollectionFailed", Message: fmt.Sprintf("Failed to collect metrics: %v", collectErr), }) } else { - klog.V(2).InfoS("Successfully collected metrics", "metricCollector", req.Name, "workloads", len(collectedMetrics)) - meta.SetStatusCondition(&mc.Status.Conditions, metav1.Condition{ - Type: localv1alpha1.MetricCollectorConditionTypeReady, + klog.V(2).InfoS("Successfully collected metrics", "report", report.Name, "workloads", len(collectedMetrics)) + meta.SetStatusCondition(&report.Status.Conditions, metav1.Condition{ + Type: "MetricsCollected", Status: metav1.ConditionTrue, - ObservedGeneration: mc.Generation, - Reason: "CollectorConfigured", - Message: "Collector is configured and collecting metrics", - }) - meta.SetStatusCondition(&mc.Status.Conditions, metav1.Condition{ - Type: localv1alpha1.MetricCollectorConditionTypeCollecting, - Status: metav1.ConditionTrue, - ObservedGeneration: mc.Generation, + ObservedGeneration: report.Generation, Reason: "MetricsCollected", Message: fmt.Sprintf("Successfully collected metrics from %d workloads", len(collectedMetrics)), }) } - if err := r.MemberClient.Status().Update(ctx, mc); err != nil { - klog.ErrorS(err, "Failed to update MetricCollector status", "metricCollector", req.Name) - return ctrl.Result{}, err - } - - // Sync MetricCollectorReport to hub cluster - if err := r.syncReportToHub(ctx, mc); err != nil { - klog.ErrorS(err, "Failed to sync MetricCollectorReport to hub", "metricCollector", req.Name) - meta.SetStatusCondition(&mc.Status.Conditions, metav1.Condition{ - Type: localv1alpha1.MetricCollectorConditionTypeReported, - Status: metav1.ConditionFalse, - ObservedGeneration: mc.Generation, - Reason: "ReportSyncFailed", - Message: fmt.Sprintf("Failed to sync report to hub: %v", err), - }) - } else { - meta.SetStatusCondition(&mc.Status.Conditions, metav1.Condition{ - Type: localv1alpha1.MetricCollectorConditionTypeReported, - Status: metav1.ConditionTrue, - ObservedGeneration: mc.Generation, - Reason: "ReportSyncSucceeded", - Message: "Successfully synced metrics to hub cluster", - }) - } - - // Update status with reporting condition - if err := r.MemberClient.Status().Update(ctx, mc); err != nil { - klog.ErrorS(err, "Failed to update MetricCollector status with reporting condition", "metricCollector", req.Name) + if err := r.HubClient.Status().Update(ctx, report); err != nil { + klog.ErrorS(err, "Failed to update MetricCollectorReport status", "report", req.NamespacedName) return ctrl.Result{}, err } - // Requeue after 30 seconds + klog.InfoS("Successfully updated MetricCollectorReport", "metricsCount", len(collectedMetrics), "prometheusUrl", prometheusURL) return ctrl.Result{RequeueAfter: defaultCollectionInterval}, nil } -// syncReportToHub syncs the MetricCollectorReport to the hub cluster -func (r *Reconciler) syncReportToHub(ctx context.Context, mc *localv1alpha1.MetricCollector) error { - // Use the reportNamespace from the MetricCollector spec - reportNamespace := mc.Spec.ReportNamespace - if reportNamespace == "" { - return fmt.Errorf("reportNamespace is not set in MetricCollector spec") - } - - // Create or update MetricCollectorReport on hub - report := &localv1alpha1.MetricCollectorReport{ - ObjectMeta: metav1.ObjectMeta{ - Name: mc.Name, - Namespace: reportNamespace, - Labels: map[string]string{ - "metriccollector-name": mc.Name, - }, - }, - } +// collectAllWorkloadMetrics queries Prometheus for all workload_health metrics +func (r *Reconciler) collectAllWorkloadMetrics(ctx context.Context, promClient PrometheusClient) ([]localv1alpha1.WorkloadMetrics, error) { + var collectedMetrics []localv1alpha1.WorkloadMetrics - // Check if report already exists - existingReport := &localv1alpha1.MetricCollectorReport{} - err := r.HubClient.Get(ctx, client.ObjectKey{Name: mc.Name, Namespace: reportNamespace}, existingReport) + // Query all workload_health metrics (no filtering) + query := "workload_health" - now := metav1.Now() + result, err := promClient.Query(ctx, query) if err != nil { - if errors.IsNotFound(err) { - // Create new report - report.Conditions = mc.Status.Conditions - report.ObservedGeneration = mc.Status.ObservedGeneration - report.WorkloadsMonitored = mc.Status.WorkloadsMonitored - report.LastCollectionTime = mc.Status.LastCollectionTime - report.CollectedMetrics = mc.Status.CollectedMetrics - report.LastReportTime = &now - - if err := r.HubClient.Create(ctx, report); err != nil { - klog.ErrorS(err, "Failed to create MetricCollectorReport", "report", klog.KObj(report)) - return err - } - klog.V(2).InfoS("Created MetricCollectorReport on hub", "report", klog.KObj(report), "reportNamespace", reportNamespace) - return nil - } - return err + klog.ErrorS(err, "Failed to query Prometheus for workload_health metrics") + return nil, err } - // Update existing report - existingReport.Labels = report.Labels - existingReport.Conditions = mc.Status.Conditions - existingReport.ObservedGeneration = mc.Status.ObservedGeneration - existingReport.WorkloadsMonitored = mc.Status.WorkloadsMonitored - existingReport.LastCollectionTime = mc.Status.LastCollectionTime - existingReport.CollectedMetrics = mc.Status.CollectedMetrics - existingReport.LastReportTime = &now - - if err := r.HubClient.Update(ctx, existingReport); err != nil { - klog.ErrorS(err, "Failed to update MetricCollectorReport", "report", klog.KObj(existingReport)) - return err + // Parse Prometheus response + data, ok := result.(PrometheusData) + if !ok { + return nil, fmt.Errorf("invalid Prometheus response type") } - klog.V(2).InfoS("Updated MetricCollectorReport on hub", "report", klog.KObj(existingReport), "reportNamespace", reportNamespace) - return nil -} -// deleteReportFromHub deletes the MetricCollectorReport from the hub cluster -func (r *Reconciler) deleteReportFromHub(ctx context.Context, mc *localv1alpha1.MetricCollector) error { - // Use the reportNamespace from the MetricCollector spec - reportNamespace := mc.Spec.ReportNamespace - if reportNamespace == "" { - klog.V(2).InfoS("reportNamespace is not set, skipping deletion", "metricCollector", mc.Name) - return nil + if len(data.Result) == 0 { + klog.V(4).InfoS("No workload_health metrics found in Prometheus") + return collectedMetrics, nil } - // Try to delete MetricCollectorReport on hub - report := &localv1alpha1.MetricCollectorReport{} - err := r.HubClient.Get(ctx, client.ObjectKey{Name: mc.Name, Namespace: reportNamespace}, report) - if err != nil { - if errors.IsNotFound(err) { - klog.V(2).InfoS("MetricCollectorReport not found on hub, already deleted", "report", mc.Name, "namespace", reportNamespace) - return nil + // Extract metrics from Prometheus result + for _, res := range data.Result { + namespace := res.Metric["namespace"] + workloadName := res.Metric["app"] + + if namespace == "" || workloadName == "" { + continue } - return fmt.Errorf("failed to get MetricCollectorReport: %w", err) - } - if err := r.HubClient.Delete(ctx, report); err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to delete MetricCollectorReport: %w", err) + // Extract health value from Prometheus result + // Prometheus returns values as [timestamp, value_string] array + // We need at least 2 elements: index 0 is timestamp, index 1 is the metric value + var health float64 + if len(res.Value) >= 2 { + if valueStr, ok := res.Value[1].(string); ok { + fmt.Sscanf(valueStr, "%f", &health) + } + } + + workloadMetrics := localv1alpha1.WorkloadMetrics{ + WorkloadName: workloadName, + Namespace: namespace, + Health: health > 0.5, // Convert float to bool: healthy if > 0.5 + } + collectedMetrics = append(collectedMetrics, workloadMetrics) } - klog.InfoS("Deleted MetricCollectorReport from hub", "report", mc.Name, "namespace", reportNamespace) - return nil + klog.V(2).InfoS("Collected workload metrics from Prometheus", "count", len(collectedMetrics)) + return collectedMetrics, nil } // SetupWithManager sets up the controller with the Manager. func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { - r.recorder = mgr.GetEventRecorderFor("metriccollector-controller") return ctrl.NewControllerManagedBy(mgr). Named("metriccollector-controller"). - For(&localv1alpha1.MetricCollector{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + For(&localv1alpha1.MetricCollectorReport{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). Complete(r) } From 69a8ed40485eb56b06fb3e7fa99a8663cdc6e840 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Fri, 12 Dec 2025 16:44:55 -0800 Subject: [PATCH 12/38] simplify approval controller Signed-off-by: Arvind Thirumurugan --- .../cmd/approvalrequestcontroller/main.go | 3 - .../pkg/controller/controller.go | 289 ++++++++---------- .../metric-collector/go.mod | 2 +- 3 files changed, 120 insertions(+), 174 deletions(-) diff --git a/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go b/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go index e968d3c..c5b9ffb 100644 --- a/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go +++ b/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go @@ -127,12 +127,9 @@ func checkRequiredCRDs(config *rest.Config) error { requiredCRDs := []string{ "approvalrequests.placement.kubernetes-fleet.io", "clusterapprovalrequests.placement.kubernetes-fleet.io", - "metriccollectors.metric.kubernetes-fleet.io", "metriccollectorreports.metric.kubernetes-fleet.io", "clusterstagedworkloadtrackers.metric.kubernetes-fleet.io", "stagedworkloadtrackers.metric.kubernetes-fleet.io", - "clusterresourceplacements.placement.kubernetes-fleet.io", - "clusterresourceoverrides.placement.kubernetes-fleet.io", "clusterstagedupdateruns.placement.kubernetes-fleet.io", "stagedupdateruns.placement.kubernetes-fleet.io", } diff --git a/approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go b/approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go index 9c15d76..81b9289 100644 --- a/approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go +++ b/approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go @@ -15,7 +15,7 @@ limitations under the License. */ // Package controller features a controller to reconcile ApprovalRequest objects -// and create MetricCollector resources on member clusters for approved stages. +// and create MetricCollectorReport resources on the hub cluster for metric collection. package controller import ( @@ -23,7 +23,6 @@ import ( "fmt" "time" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -42,15 +41,15 @@ import ( ) const ( - // metricCollectorFinalizer is the finalizer added to ApprovalRequest objects - metricCollectorFinalizer = "kubernetes-fleet.io/metric-collector-cleanup" + // metricCollectorFinalizer is the finalizer added to ApprovalRequest objects for cleanup + metricCollectorFinalizer = "kubernetes-fleet.io/metric-collector-report-cleanup" - // prometheusURL is the default Prometheus URL to use + // prometheusURL is the default Prometheus URL to use for all clusters prometheusURL = "http://prometheus.prometheus.svc.cluster.local:9090" ) -// Reconciler reconciles an ApprovalRequest object and creates MetricCollector resources -// on member clusters when the approval is granted. +// Reconciler reconciles an ApprovalRequest object and creates MetricCollectorReport resources +// on the hub cluster in fleet-member-{clusterName} namespaces. type Reconciler struct { client.Client recorder record.EventRecorder @@ -182,13 +181,13 @@ func (r *Reconciler) reconcileApprovalRequestObj(ctx context.Context, approvalRe klog.V(2).InfoS("Found clusters in stage", "approvalRequest", approvalReqRef, "stage", stageName, "clusters", clusterNames) - // Create or update the MetricCollector resource, CRP, and ResourceOverrides - if err := r.ensureMetricCollectorResources(ctx, obj, clusterNames, updateRunName, stageName); err != nil { - klog.ErrorS(err, "Failed to ensure MetricCollector resources", "approvalRequest", approvalReqRef) + // Create or update MetricCollectorReport resources in fleet-member namespaces + if err := r.ensureMetricCollectorReports(ctx, obj, clusterNames, updateRunName, stageName); err != nil { + klog.ErrorS(err, "Failed to ensure MetricCollectorReport resources", "approvalRequest", approvalReqRef) return ctrl.Result{}, err } - klog.V(2).InfoS("Successfully ensured MetricCollector resources", "approvalRequest", approvalReqRef, "clusters", clusterNames) + klog.V(2).InfoS("Successfully ensured MetricCollectorReport resources", "approvalRequest", approvalReqRef, "clusters", clusterNames) // Check workload health and approve if all workloads are healthy if err := r.checkWorkloadHealthAndApprove(ctx, approvalReqObj, clusterNames, updateRunName, stageName); err != nil { @@ -200,154 +199,67 @@ func (r *Reconciler) reconcileApprovalRequestObj(ctx context.Context, approvalRe return ctrl.Result{RequeueAfter: 15 * time.Second}, nil } -// ensureMetricCollectorResources creates the Namespace, MetricCollector, CRP, and ResourceOverrides -func (r *Reconciler) ensureMetricCollectorResources( +// ensureMetricCollectorReports creates MetricCollectorReport in each fleet-member-{clusterName} namespace +func (r *Reconciler) ensureMetricCollectorReports( ctx context.Context, approvalReq client.Object, clusterNames []string, updateRunName, stageName string, ) error { - // Generate names - metricCollectorName := fmt.Sprintf("mc-%s-%s", updateRunName, stageName) - crpName := fmt.Sprintf("crp-mc-%s-%s", updateRunName, stageName) - roName := fmt.Sprintf("ro-mc-%s-%s", updateRunName, stageName) - - // Create MetricCollector resource (cluster-scoped) on hub - metricCollector := &localv1alpha1.MetricCollector{ - ObjectMeta: metav1.ObjectMeta{ - Name: metricCollectorName, - Labels: map[string]string{ - "app": "metric-collector", - "approval-request": approvalReq.GetName(), - "update-run": updateRunName, - "stage": stageName, - }, - }, - Spec: localv1alpha1.MetricCollectorSpec{ - PrometheusURL: prometheusURL, - // ReportNamespace will be overridden per cluster - ReportNamespace: "placeholder", - }, - } - - // Create or update MetricCollector - existingMC := &localv1alpha1.MetricCollector{} - err := r.Client.Get(ctx, types.NamespacedName{Name: metricCollectorName}, existingMC) - if err != nil { - if errors.IsNotFound(err) { - if err := r.Client.Create(ctx, metricCollector); err != nil { - return fmt.Errorf("failed to create MetricCollector: %w", err) - } - klog.V(2).InfoS("Created MetricCollector", "metricCollector", klog.KObj(metricCollector)) - } else { - return fmt.Errorf("failed to get MetricCollector: %w", err) - } - } + // Generate report name (same for all clusters, different namespaces) + reportName := fmt.Sprintf("mc-%s-%s", updateRunName, stageName) - // Create ResourceOverride with rules for each cluster - overrideRules := make([]placementv1beta1.OverrideRule, 0, len(clusterNames)) + // Create MetricCollectorReport in each fleet-member namespace for _, clusterName := range clusterNames { reportNamespace := fmt.Sprintf(utils.NamespaceNameFormat, clusterName) - overrideRules = append(overrideRules, placementv1beta1.OverrideRule{ - ClusterSelector: &placementv1beta1.ClusterSelector{ - ClusterSelectorTerms: []placementv1beta1.ClusterSelectorTerm{ - { - LabelSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "kubernetes-fleet.io/cluster-name": clusterName, - }, - }, - }, - }, - }, - JSONPatchOverrides: []placementv1beta1.JSONPatchOverride{ - { - Operator: placementv1beta1.JSONPatchOverrideOpReplace, - Path: "/spec/reportNamespace", - Value: apiextensionsv1.JSON{Raw: []byte(fmt.Sprintf(`"%s"`, reportNamespace))}, - }, - }, - }) - } - - // Create ClusterResourceOverride with rules for each cluster - clusterResourceOverride := &placementv1beta1.ClusterResourceOverride{ - ObjectMeta: metav1.ObjectMeta{ - Name: roName, - Labels: map[string]string{ - "approval-request": approvalReq.GetName(), - "update-run": updateRunName, - "stage": stageName, - }, - }, - Spec: placementv1beta1.ClusterResourceOverrideSpec{ - ClusterResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ - { - Group: "metric.kubernetes-fleet.io", - Version: "v1alpha1", - Kind: "MetricCollector", - Name: metricCollectorName, + report := &localv1alpha1.MetricCollectorReport{ + ObjectMeta: metav1.ObjectMeta{ + Name: reportName, + Namespace: reportNamespace, + Labels: map[string]string{ + "approval-request": approvalReq.GetName(), + "update-run": updateRunName, + "stage": stageName, + "cluster": clusterName, }, }, - Policy: &placementv1beta1.OverridePolicy{ - OverrideRules: overrideRules, + Spec: localv1alpha1.MetricCollectorReportSpec{ + PrometheusURL: prometheusURL, }, - }, - } - - // Create or update ClusterResourceOverride - existingCRO := &placementv1beta1.ClusterResourceOverride{} - err = r.Client.Get(ctx, types.NamespacedName{Name: roName}, existingCRO) - if err != nil { - if errors.IsNotFound(err) { - if err := r.Client.Create(ctx, clusterResourceOverride); err != nil { - return fmt.Errorf("failed to create ClusterResourceOverride: %w", err) - } - klog.V(2).InfoS("Created ClusterResourceOverride", "clusterResourceOverride", roName) - } else { - return fmt.Errorf("failed to get ClusterResourceOverride: %w", err) } - } - // Create ClusterResourcePlacement with PickFixed policy - // CRP resource selector selects the MetricCollector directly - crp := &placementv1beta1.ClusterResourcePlacement{ - ObjectMeta: metav1.ObjectMeta{ - Name: crpName, - Labels: map[string]string{ - "approval-request": approvalReq.GetName(), - "update-run": updateRunName, - "stage": stageName, - }, - }, - Spec: placementv1beta1.PlacementSpec{ - ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ - { - Group: "metric.kubernetes-fleet.io", - Version: "v1alpha1", - Kind: "MetricCollector", - Name: metricCollectorName, - }, - }, - Policy: &placementv1beta1.PlacementPolicy{ - PlacementType: placementv1beta1.PickFixedPlacementType, - ClusterNames: clusterNames, - }, - }, - } + // Create or update MetricCollectorReport + existingReport := &localv1alpha1.MetricCollectorReport{} + err := r.Client.Get(ctx, types.NamespacedName{ + Name: reportName, + Namespace: reportNamespace, + }, existingReport) - // Create or update CRP - existingCRP := &placementv1beta1.ClusterResourcePlacement{} - err = r.Client.Get(ctx, types.NamespacedName{Name: crpName}, existingCRP) - if err != nil { - if errors.IsNotFound(err) { - if err := r.Client.Create(ctx, crp); err != nil { - return fmt.Errorf("failed to create ClusterResourcePlacement: %w", err) + if err != nil { + if errors.IsNotFound(err) { + if err := r.Client.Create(ctx, report); err != nil { + return fmt.Errorf("failed to create MetricCollectorReport in %s: %w", reportNamespace, err) + } + klog.V(2).InfoS("Created MetricCollectorReport", + "report", reportName, + "namespace", reportNamespace, + "cluster", clusterName) + } else { + return fmt.Errorf("failed to get MetricCollectorReport in %s: %w", reportNamespace, err) } - klog.V(2).InfoS("Created ClusterResourcePlacement", "crp", crpName) } else { - return fmt.Errorf("failed to get ClusterResourcePlacement: %w", err) + // Update spec if needed + if existingReport.Spec.PrometheusURL != prometheusURL { + existingReport.Spec.PrometheusURL = prometheusURL + if err := r.Client.Update(ctx, existingReport); err != nil { + return fmt.Errorf("failed to update MetricCollectorReport in %s: %w", reportNamespace, err) + } + klog.V(2).InfoS("Updated MetricCollectorReport", + "report", reportName, + "namespace", reportNamespace, + "cluster", clusterName) + } } } @@ -465,15 +377,15 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( klog.V(2).InfoS("Found MetricCollectorReport", "approvalRequest", approvalReqRef, "cluster", clusterName, - "collectedMetrics", len(report.CollectedMetrics), - "workloadsMonitored", report.WorkloadsMonitored) + "collectedMetrics", len(report.Status.CollectedMetrics), + "workloadsMonitored", report.Status.WorkloadsMonitored) // Check if all workloads from WorkloadTracker are present and healthy for _, trackedWorkload := range workloads { found := false healthy := false - for _, collectedMetric := range report.CollectedMetrics { + for _, collectedMetric := range report.Status.CollectedMetrics { if collectedMetric.Namespace == trackedWorkload.Namespace && collectedMetric.WorkloadName == trackedWorkload.Name { found = true @@ -562,40 +474,77 @@ func (r *Reconciler) handleDelete(ctx context.Context, approvalReqObj placementv } approvalReqRef := klog.KObj(obj) - klog.V(2).InfoS("Cleaning up resources for ApprovalRequest", "approvalRequest", approvalReqRef) + klog.V(2).InfoS("Cleaning up MetricCollectorReports for ApprovalRequest", "approvalRequest", approvalReqRef) - // Delete CRP (it will cascade delete the resources on member clusters) + // Get cluster names from UpdateRun to know which reports to delete spec := approvalReqObj.GetApprovalRequestSpec() updateRunName := spec.TargetUpdateRun stageName := spec.TargetStage - crpName := fmt.Sprintf("crp-mc-%s-%s", updateRunName, stageName) - metricCollectorName := fmt.Sprintf("mc-%s-%s", updateRunName, stageName) - croName := fmt.Sprintf("ro-mc-%s-%s", updateRunName, stageName) + reportName := fmt.Sprintf("mc-%s-%s", updateRunName, stageName) - crp := &placementv1beta1.ClusterResourcePlacement{} - if err := r.Client.Get(ctx, types.NamespacedName{Name: crpName}, crp); err == nil { - if err := r.Client.Delete(ctx, crp); err != nil && !errors.IsNotFound(err) { - return ctrl.Result{}, fmt.Errorf("failed to delete CRP: %w", err) + // Fetch UpdateRun to get cluster names + var clusterNames []string + if obj.GetNamespace() == "" { + // Cluster-scoped: Get ClusterStagedUpdateRun + updateRun := &placementv1beta1.ClusterStagedUpdateRun{} + if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName}, updateRun); err != nil { + if !errors.IsNotFound(err) { + klog.ErrorS(err, "Failed to get ClusterStagedUpdateRun for cleanup", "approvalRequest", approvalReqRef) + } + // Continue with finalizer removal even if UpdateRun not found + } else { + // Find the stage + for i := range updateRun.Status.StagesStatus { + if updateRun.Status.StagesStatus[i].StageName == stageName { + for _, cluster := range updateRun.Status.StagesStatus[i].Clusters { + clusterNames = append(clusterNames, cluster.ClusterName) + } + break + } + } } - klog.V(2).InfoS("Deleted ClusterResourcePlacement", "crp", crpName) - } - - // Delete ClusterResourceOverride - cro := &placementv1beta1.ClusterResourceOverride{} - if err := r.Client.Get(ctx, types.NamespacedName{Name: croName}, cro); err == nil { - if err := r.Client.Delete(ctx, cro); err != nil && !errors.IsNotFound(err) { - return ctrl.Result{}, fmt.Errorf("failed to delete ClusterResourceOverride: %w", err) + } else { + // Namespace-scoped: Get StagedUpdateRun + updateRun := &placementv1beta1.StagedUpdateRun{} + if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName, Namespace: obj.GetNamespace()}, updateRun); err != nil { + if !errors.IsNotFound(err) { + klog.ErrorS(err, "Failed to get StagedUpdateRun for cleanup", "approvalRequest", approvalReqRef) + } + // Continue with finalizer removal even if UpdateRun not found + } else { + // Find the stage + for i := range updateRun.Status.StagesStatus { + if updateRun.Status.StagesStatus[i].StageName == stageName { + for _, cluster := range updateRun.Status.StagesStatus[i].Clusters { + clusterNames = append(clusterNames, cluster.ClusterName) + } + break + } + } } - klog.V(2).InfoS("Deleted ClusterResourceOverride", "clusterResourceOverride", croName) } - // Delete MetricCollector - metricCollector := &localv1alpha1.MetricCollector{} - if err := r.Client.Get(ctx, types.NamespacedName{Name: metricCollectorName}, metricCollector); err == nil { - if err := r.Client.Delete(ctx, metricCollector); err != nil && !errors.IsNotFound(err) { - return ctrl.Result{}, fmt.Errorf("failed to delete MetricCollector: %w", err) + // Delete MetricCollectorReport from each fleet-member namespace + for _, clusterName := range clusterNames { + reportNamespace := fmt.Sprintf(utils.NamespaceNameFormat, clusterName) + report := &localv1alpha1.MetricCollectorReport{} + + if err := r.Client.Get(ctx, types.NamespacedName{ + Name: reportName, + Namespace: reportNamespace, + }, report); err == nil { + if err := r.Client.Delete(ctx, report); err != nil && !errors.IsNotFound(err) { + klog.ErrorS(err, "Failed to delete MetricCollectorReport", + "report", reportName, + "namespace", reportNamespace, + "cluster", clusterName) + return ctrl.Result{}, fmt.Errorf("failed to delete MetricCollectorReport in %s: %w", reportNamespace, err) + } + klog.V(2).InfoS("Deleted MetricCollectorReport", + "report", reportName, + "namespace", reportNamespace, + "cluster", clusterName) } - klog.V(2).InfoS("Deleted MetricCollector", "metricCollector", metricCollectorName) } // Remove finalizer @@ -605,7 +554,7 @@ func (r *Reconciler) handleDelete(ctx context.Context, approvalReqObj placementv return ctrl.Result{}, err } - klog.V(2).InfoS("Successfully cleaned up resources", "approvalRequest", approvalReqRef) + klog.V(2).InfoS("Successfully cleaned up MetricCollectorReports", "approvalRequest", approvalReqRef, "clusters", clusterNames) return ctrl.Result{}, nil } diff --git a/approval-controller-metric-collector/metric-collector/go.mod b/approval-controller-metric-collector/metric-collector/go.mod index 1c49a89..2f9f4cf 100644 --- a/approval-controller-metric-collector/metric-collector/go.mod +++ b/approval-controller-metric-collector/metric-collector/go.mod @@ -6,7 +6,6 @@ require ( github.com/kubefleet-dev/kubefleet v0.1.2 github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller v0.0.0 github.com/prometheus/client_golang v1.22.0 - golang.org/x/sync v0.18.0 k8s.io/api v0.34.1 k8s.io/apimachinery v0.34.1 k8s.io/client-go v0.34.1 @@ -48,6 +47,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.29.0 // indirect + golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect From 76de5f33127d09f50cfb62d132c69996df4244e1 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Fri, 12 Dec 2025 16:51:53 -0800 Subject: [PATCH 13/38] update README.md Signed-off-by: Arvind Thirumurugan --- .../README.md | 81 ++++++++----------- 1 file changed, 34 insertions(+), 47 deletions(-) diff --git a/approval-controller-metric-collector/README.md b/approval-controller-metric-collector/README.md index a99dbfa..9fef46c 100644 --- a/approval-controller-metric-collector/README.md +++ b/approval-controller-metric-collector/README.md @@ -6,7 +6,7 @@ This tutorial demonstrates how to use the Approval Request Controller and Metric This directory contains two controllers: - **approval-request-controller**: Runs on the hub cluster to automate approval decisions for staged updates -- **metric-collector**: Runs on member clusters to collect workload health metrics from Prometheus +- **metric-collector**: Runs on member clusters to collect and report workload health metrics ![Approval Controller and Metric Collector Architecture](./images/approval-controller-metric-collector.png) @@ -18,24 +18,19 @@ This solution introduces three new CRDs that work together with KubeFleet's nati #### Hub Cluster CRDs -1. **MetricCollector** (cluster-scoped) - - Defines Prometheus connection details and where to report metrics - - Gets propagated to member clusters via ClusterResourcePlacement (CRP) - - Each member cluster receives a customized version with its specific `reportNamespace` +1. **MetricCollectorReport** (namespaced) + - Created by approval-request-controller in `fleet-member-` namespaces on hub + - Watched and updated by metric-collector running on member clusters + - Contains specification of Prometheus URL and collected `workload_health` metrics + - Updated every 30 seconds by the metric collector with latest health data -2. **MetricCollectorReport** (namespaced) - - Created by metric-collector on member clusters, reported back to hub - - Lives in `fleet-member-` namespaces on the hub - - Contains collected `workload_health` metrics for all workloads in a cluster - - Updated every 30 seconds by the metric collector - -3. **ClusterStagedWorkloadTracker** (cluster-scoped) +2. **ClusterStagedWorkloadTracker** (cluster-scoped) - Defines which workloads to monitor for a ClusterStagedUpdateRun - The name must match the ClusterStagedUpdateRun name - Specifies workload's name, namespace and expected health status - Used by approval-request-controller to determine if stage is ready for approval -4. **StagedWorkloadTracker** (namespaced) +3. **StagedWorkloadTracker** (namespaced) - Defines which workloads to monitor for a StagedUpdateRun - The name and namespace must match the StagedUpdateRun name and namespace - Specifies namespace, workload name, and expected health status @@ -48,22 +43,21 @@ This solution introduces three new CRDs that work together with KubeFleet's nati - KubeFleet creates an ApprovalRequest (`ClusterApprovalRequest` or `ApprovalRequest`) for the first stage - The ApprovalRequest enters "Pending" state, waiting for approval -2. **Metric Collector Deployment** - - Approval-request-controller watches the `ClusterApprovalRequest`, `ApprovalRequest` objects - - Creates a `MetricCollector` resource on the hub (cluster-scoped) - - Creates a `ClusterResourceOverride` with per-cluster customization rules - - Each cluster gets a unique `reportNamespace`: `fleet-member-` - - Creates a `ClusterResourcePlacement` (CRP) with `PickFixed` policy - - Targets all clusters in the current stage - - KubeFleet propagates the customized `MetricCollector` to each member cluster +2. **Metric Collector Report Creation** + - Approval-request-controller watches the `ClusterApprovalRequest` and `ApprovalRequest` objects + - For each cluster in the current stage: + - Creates a `MetricCollectorReport` in `fleet-member-` namespace on hub + - Sets `spec.prometheusUrl` to the Prometheus endpoint + - Each report is specific to one cluster 3. **Metric Collection on Member Clusters** - Metric-collector controller runs on each member cluster + - Watches for `MetricCollectorReport` in its `fleet-member-` namespace on hub - Every 30 seconds, it: - - Queries local Prometheus with PromQL: `workload_health` + - Queries local Prometheus using URL from report spec with PromQL: `workload_health` - Prometheus returns metrics for all pods with `prometheus.io/scrape: "true"` annotation - Extracts workload health (1.0 = healthy, 0.0 = unhealthy) - - Creates/updates `MetricCollectorReport` on hub in `fleet-member-` namespace + - Updates the `MetricCollectorReport` status on hub with collected metrics 4. **Health Evaluation** - Approval-request-controller monitors `MetricCollectorReports` from all stage clusters @@ -72,7 +66,7 @@ This solution introduces three new CRDs that work together with KubeFleet's nati - For cluster-scoped: `ClusterStagedWorkloadTracker` with same name as ClusterStagedUpdateRun - For namespace-scoped: `StagedWorkloadTracker` with same name and namespace as StagedUpdateRun - For each cluster in the stage: - - Reads its `MetricCollectorReport` from `fleet-member-` namespace + - Reads its `MetricCollectorReport` status from `fleet-member-` namespace - Verifies all tracked workloads are present and healthy - If any workload is missing or unhealthy, waits for next cycle - If ALL workloads across ALL clusters are healthy: @@ -221,8 +215,8 @@ Before diving into the setup steps, here's a bird's eye view of what you'll be b 3. **Approval Request Controller** - Watches `ClusterApprovalRequest` and `ApprovalRequest` objects - - Deploys MetricCollector to stage clusters via ClusterResourcePlacement - - Evaluates workload health from MetricCollectorReports + - Creates MetricCollectorReport directly in `fleet-member-` namespaces + - Evaluates workload health from MetricCollectorReport status - Auto-approves stages when all workloads are healthy 4. **Sample Metric App** (will be rolled out to clusters) @@ -232,9 +226,9 @@ Before diving into the setup steps, here's a bird's eye view of what you'll be b **Member Clusters** - Where workloads run: 1. **Metric Collector** - - Queries local Prometheus every 30 seconds - - Reports workload health back to hub cluster - - Creates/updates MetricCollectorReport in hub's `fleet-member-` namespace + - Connects to hub cluster to watch MetricCollectorReport in its namespace + - Queries local Prometheus every 30 seconds using URL from MetricCollectorReport spec + - Updates MetricCollectorReport status on hub with collected health metrics 2. **Prometheus** (received from hub) - Runs on each member cluster @@ -284,15 +278,15 @@ When you create a **ClusterStagedUpdateRun** or **StagedUpdateRun**, here's what 1. **Stage 1 (staging)**: Rollout starts with `kind-cluster-1` - KubeFleet creates an ApprovalRequest for the staging stage - - Approval controller deploys MetricCollector to `kind-cluster-1` - - Metric collector reports health metrics back to hub + - Approval controller creates MetricCollectorReport in `fleet-member-kind-cluster-1` namespace + - Metric collector on `kind-cluster-1` watches its report on hub and updates status with health metrics - When `sample-metric-app` is healthy, approval controller auto-approves - KubeFleet proceeds with the rollout to `kind-cluster-1` 2. **Stage 2 (prod)**: After staging succeeds - KubeFleet creates an ApprovalRequest for the prod stage - - Approval controller deploys MetricCollector to `kind-cluster-2` and `kind-cluster-3` - - Metric collectors report health from both clusters + - Approval controller creates MetricCollectorReports in `fleet-member-kind-cluster-2` and `fleet-member-kind-cluster-3` + - Metric collectors on both clusters watch their reports and update with health data - When ALL workloads across BOTH prod clusters are healthy, auto-approve - KubeFleet completes the rollout to production clusters @@ -305,8 +299,7 @@ When you create a **ClusterStagedUpdateRun** or **StagedUpdateRun**, here's what | **StagedUpdateStrategy** | Define stages with label selectors and approval requirements | Hub | | **WorkloadTracker** | Specify which workloads to monitor for health | Hub | | **UpdateRun** | Start the staged rollout process | Hub | -| **MetricCollector** | Automatically created by approval controller per stage | Hub → Member | -| **MetricCollectorReport** | Automatically created by metric collector | Member → Hub | +| **MetricCollectorReport** | Created by approval controller, updated by metric collector | Hub (fleet-member-* ns) | ### What the Installation Scripts Do @@ -314,15 +307,15 @@ When you create a **ClusterStagedUpdateRun** or **StagedUpdateRun**, here's what - Takes ACR registry URL and hub cluster name as parameters - Pulls approval-request-controller image from ACR - Verifies KubeFleet CRDs are installed -- Installs controller via Helm with custom CRDs (MetricCollector, MetricCollectorReport, WorkloadTracker) -- Sets up RBAC for managing placements, overrides, and approval requests +- Installs controller via Helm with custom CRDs (MetricCollectorReport, WorkloadTrackers) +- Sets up RBAC for managing MetricCollectorReports and reading approval requests **`install-on-member.sh`** (Metric Collector): - Takes ACR registry URL, hub cluster, and member cluster names as parameters -- Pulls metric-collector and metric-app images from ACR -- Creates service account with hub cluster access token +- Pulls metric-collector image from ACR +- Creates service account with hub cluster access token and RBAC for watching/updating MetricCollectorReports - Installs metric-collector via Helm on each member cluster -- Configures connection to hub API server and local Prometheus +- Configures connection to hub API server to watch reports and local Prometheus for metrics With this understanding, you're ready to start the setup! @@ -692,13 +685,7 @@ kubectl logs -n default deployment/metric-collector -f ### Check Metrics Collection -Verify that MetricCollector resources exist on member clusters: -```bash -kubectl config use-context kind-cluster-1 -kubectl get metriccollector -A -``` - -Verify that MetricCollectorReports are being created on the hub: +Verify that MetricCollectorReports are being created and updated on the hub: ```bash kubectl config use-context kind-hub kubectl get metriccollectorreport -A From 16367ff3ec5d8a4e8897686c3378a337541fc2c4 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Sun, 14 Dec 2025 17:06:38 -0800 Subject: [PATCH 14/38] restructure dirs Signed-off-by: Arvind Thirumurugan --- .../approval-request-controller/README.md | 121 ----------- ...leet.io_clusterstagedworkloadtrackers.yaml | 1 - ...netes-fleet.io_metriccollectorreports.yaml | 1 - ....kubernetes-fleet.io_metriccollectors.yaml | 1 - ...netes-fleet.io_stagedworkloadtrackers.yaml | 1 - ....kubernetes-fleet.io_metriccollectors.yaml | 189 ----------------- .../metric-collector/Makefile | 125 ------------ .../metric-collector/README.md | 91 --------- .../examples/metriccollector-example.yaml | 8 - .../metric-collector/go.mod | 69 ------- .../metric-collector/go.sum | 192 ------------------ .../approval-controller-metric-collector.png | Bin .../Makefile | 5 +- .../README.md | 18 +- .../apis/autoapprove}/v1alpha1/doc.go | 2 +- .../v1alpha1/groupversion_info.go | 4 +- .../v1alpha1/metriccollectorreport_types.go | 0 .../v1alpha1/workloadtracker_types.go | 0 .../v1alpha1/zz_generated.deepcopy.go | 0 .../approval-request-controller/Chart.yaml | 0 .../templates/_helpers.tpl | 0 ...leet.io_clusterstagedworkloadtrackers.yaml | 1 + ...netes-fleet.io_metriccollectorreports.yaml | 1 + ...netes-fleet.io_stagedworkloadtrackers.yaml | 1 + .../templates/deployment.yaml | 0 .../templates/rbac.yaml | 6 +- .../templates/serviceaccount.yaml | 0 .../approval-request-controller/values.yaml | 0 .../charts/metric-collector/Chart.yaml | 0 .../metric-collector/templates/_helpers.tpl | 0 ....kubernetes-fleet.io_metriccollectors.yaml | 0 .../templates/deployment.yaml | 0 .../metric-collector/templates/hub-rbac.yaml | 4 +- .../templates/rbac-member.yaml | 6 +- .../templates/serviceaccount.yaml | 0 .../charts/metric-collector/values.yaml | 0 .../cmd/approvalrequestcontroller/main.go | 14 +- .../cmd/metricapp}/main.go | 0 .../cmd/metriccollector/main.go | 6 +- ...leet.io_clusterstagedworkloadtrackers.yaml | 4 +- ...netes-fleet.io_metriccollectorreports.yaml | 4 +- ...netes-fleet.io_stagedworkloadtrackers.yaml | 4 +- .../approval-request-controller.Dockerfile | 8 +- .../docker/metric-app.Dockerfile | 2 +- .../docker/metric-collector.Dockerfile | 13 +- .../fleet_v1beta1_membercluster.yaml | 0 .../examples/prometheus/configmap.yaml | 0 .../examples/prometheus/deployment.yaml | 0 .../examples/prometheus/prometheus-crp.yaml | 0 .../examples/prometheus/rbac.yaml | 0 .../examples/prometheus/service.yaml | 0 .../sample-metric-app/sample-metric-app.yaml | 0 .../examples/updateRun/example-crp.yaml | 0 .../examples/updateRun/example-csur.yaml | 0 .../examples/updateRun/example-csus.yaml | 0 .../updateRun/example-ns-only-crp.yaml | 0 .../examples/updateRun/example-rp.yaml | 0 .../examples/updateRun/example-sur.yaml | 0 .../examples/updateRun/example-sus.yaml | 0 .../clusterstagedworkloadtracker.yaml | 2 +- .../stagedworkloadtracker.yaml | 2 +- .../go.mod | 6 +- .../go.sum | 0 .../hack/boilerplate.go.txt | 0 .../approvalrequest}/controller.go | 6 +- .../controllers/metriccollector}/collector.go | 0 .../metriccollector}/controller.go | 2 +- .../scripts}/install-on-hub.sh | 4 +- .../scripts}/install-on-member.sh | 6 +- 69 files changed, 65 insertions(+), 865 deletions(-) delete mode 100644 approval-controller-metric-collector/approval-request-controller/README.md delete mode 120000 approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml delete mode 120000 approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectorreports.yaml delete mode 120000 approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml delete mode 120000 approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml delete mode 100644 approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectors.yaml delete mode 100644 approval-controller-metric-collector/metric-collector/Makefile delete mode 100644 approval-controller-metric-collector/metric-collector/README.md delete mode 100644 approval-controller-metric-collector/metric-collector/examples/metriccollector-example.yaml delete mode 100644 approval-controller-metric-collector/metric-collector/go.mod delete mode 100644 approval-controller-metric-collector/metric-collector/go.sum rename {approval-controller-metric-collector => approval-request-metric-collector}/Images/approval-controller-metric-collector.png (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/Makefile (94%) rename {approval-controller-metric-collector => approval-request-metric-collector}/README.md (97%) rename {approval-controller-metric-collector/approval-request-controller/apis/metric => approval-request-metric-collector/apis/autoapprove}/v1alpha1/doc.go (93%) rename {approval-controller-metric-collector/approval-request-controller/apis/metric => approval-request-metric-collector/apis/autoapprove}/v1alpha1/groupversion_info.go (87%) rename {approval-controller-metric-collector/approval-request-controller/apis/metric => approval-request-metric-collector/apis/autoapprove}/v1alpha1/metriccollectorreport_types.go (100%) rename {approval-controller-metric-collector/approval-request-controller/apis/metric => approval-request-metric-collector/apis/autoapprove}/v1alpha1/workloadtracker_types.go (100%) rename {approval-controller-metric-collector/approval-request-controller/apis/metric => approval-request-metric-collector/apis/autoapprove}/v1alpha1/zz_generated.deepcopy.go (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/charts/approval-request-controller/Chart.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/charts/approval-request-controller/templates/_helpers.tpl (100%) create mode 120000 approval-request-metric-collector/charts/approval-request-controller/templates/crds/autoapprove.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml create mode 120000 approval-request-metric-collector/charts/approval-request-controller/templates/crds/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml create mode 120000 approval-request-metric-collector/charts/approval-request-controller/templates/crds/autoapprove.kubernetes-fleet.io_stagedworkloadtrackers.yaml rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/charts/approval-request-controller/templates/deployment.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/charts/approval-request-controller/templates/rbac.yaml (94%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/charts/approval-request-controller/templates/serviceaccount.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/charts/approval-request-controller/values.yaml (100%) rename {approval-controller-metric-collector/metric-collector => approval-request-metric-collector}/charts/metric-collector/Chart.yaml (100%) rename {approval-controller-metric-collector/metric-collector => approval-request-metric-collector}/charts/metric-collector/templates/_helpers.tpl (100%) rename {approval-controller-metric-collector/metric-collector => approval-request-metric-collector}/charts/metric-collector/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml (100%) rename {approval-controller-metric-collector/metric-collector => approval-request-metric-collector}/charts/metric-collector/templates/deployment.yaml (100%) rename {approval-controller-metric-collector/metric-collector => approval-request-metric-collector}/charts/metric-collector/templates/hub-rbac.yaml (96%) rename {approval-controller-metric-collector/metric-collector => approval-request-metric-collector}/charts/metric-collector/templates/rbac-member.yaml (88%) rename {approval-controller-metric-collector/metric-collector => approval-request-metric-collector}/charts/metric-collector/templates/serviceaccount.yaml (100%) rename {approval-controller-metric-collector/metric-collector => approval-request-metric-collector}/charts/metric-collector/values.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/cmd/approvalrequestcontroller/main.go (89%) rename {approval-controller-metric-collector/metric-collector/cmd/metriccollector/metric-app => approval-request-metric-collector/cmd/metricapp}/main.go (100%) rename {approval-controller-metric-collector/metric-collector => approval-request-metric-collector}/cmd/metriccollector/main.go (97%) rename approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml => approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml (95%) rename approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectorreports.yaml => approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml (98%) rename approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml => approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_stagedworkloadtrackers.yaml (95%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/docker/approval-request-controller.Dockerfile (69%) rename {approval-controller-metric-collector/metric-collector => approval-request-metric-collector}/docker/metric-app.Dockerfile (92%) rename {approval-controller-metric-collector/metric-collector => approval-request-metric-collector}/docker/metric-collector.Dockerfile (54%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/membercluster/fleet_v1beta1_membercluster.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/prometheus/configmap.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/prometheus/deployment.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/prometheus/prometheus-crp.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/prometheus/rbac.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/prometheus/service.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/sample-metric-app/sample-metric-app.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/updateRun/example-crp.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/updateRun/example-csur.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/updateRun/example-csus.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/updateRun/example-ns-only-crp.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/updateRun/example-rp.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/updateRun/example-sur.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/updateRun/example-sus.yaml (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/workloadtracker/clusterstagedworkloadtracker.yaml (80%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/examples/workloadtracker/stagedworkloadtracker.yaml (82%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/go.mod (93%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/go.sum (100%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector}/hack/boilerplate.go.txt (100%) rename {approval-controller-metric-collector/approval-request-controller/pkg/controller => approval-request-metric-collector/pkg/controllers/approvalrequest}/controller.go (99%) rename {approval-controller-metric-collector/metric-collector/pkg/controller => approval-request-metric-collector/pkg/controllers/metriccollector}/collector.go (100%) rename {approval-controller-metric-collector/metric-collector/pkg/controller => approval-request-metric-collector/pkg/controllers/metriccollector}/controller.go (98%) rename {approval-controller-metric-collector/approval-request-controller => approval-request-metric-collector/scripts}/install-on-hub.sh (95%) rename {approval-controller-metric-collector/metric-collector => approval-request-metric-collector/scripts}/install-on-member.sh (97%) diff --git a/approval-controller-metric-collector/approval-request-controller/README.md b/approval-controller-metric-collector/approval-request-controller/README.md deleted file mode 100644 index 6559919..0000000 --- a/approval-controller-metric-collector/approval-request-controller/README.md +++ /dev/null @@ -1,121 +0,0 @@ -# ApprovalRequest Controller - -The ApprovalRequest Controller is a standalone controller that runs on the **hub cluster** to automate approval decisions for staged updates based on workload health metrics. - -## Overview - -This controller is designed to be a standalone component that can run independently from the main kubefleet repository. It: -- Uses kubefleet v0.1.2 as an external dependency -- Includes its own APIs for MetricCollectorReport and WorkloadTracker -- Watches `ApprovalRequest` and `ClusterApprovalRequest` resources (from kubefleet) -- Creates `MetricCollector` resources on member clusters via ClusterResourcePlacement -- Monitors workload health via `MetricCollectorReport` objects -- Automatically approves requests when all tracked workloads are healthy -- Runs every 15 seconds to check health status - -## Architecture - -The controller is designed to run on the hub cluster and: -1. Deploys MetricCollector instances to member clusters using CRP -2. Collects health metrics from MetricCollectorReports -3. Compares metrics against WorkloadTracker specifications -4. Approves ApprovalRequests when all workloads are healthy - -## Installation - -### Prerequisites - -The following CRDs must be installed on the hub cluster (installed by kubefleet hub-agent): -- `approvalrequests.placement.kubernetes-fleet.io` -- `clusterapprovalrequests.placement.kubernetes-fleet.io` -- `clusterresourceplacements.placement.kubernetes-fleet.io` -- `clusterresourceoverrides.placement.kubernetes-fleet.io` -- `clusterstagedupdateruns.placement.kubernetes-fleet.io` -- `stagedupdateruns.placement.kubernetes-fleet.io` - -The following CRDs are installed by this chart: -- `metriccollectors.metric.kubernetes-fleet.io` -- `metriccollectorreports.metric.kubernetes-fleet.io` -- `workloadtrackers.metric.kubernetes-fleet.io` - -### Install via Helm - -```bash -# Build the image -make docker-build IMAGE_NAME=approval-request-controller IMAGE_TAG=latest - -# Load into kind (if using kind) -kind load docker-image approval-request-controller:latest --name hub - -# Install the chart -helm install approval-request-controller ./charts/approval-request-controller \ - --namespace fleet-system \ - --create-namespace -``` - -## Configuration - -The controller watches for: -- `ApprovalRequest` (namespaced) -- `ClusterApprovalRequest` (cluster-scoped) - -Both resources from kubefleet are monitored, and the controller creates `MetricCollector` resources on appropriate member clusters based on the staged update configuration. - -### Health Check Interval - -The controller checks workload health every **15 seconds**. This interval is configurable via the `reconcileInterval` parameter in the Helm chart. - -## API Reference - -### WorkloadTracker - -`WorkloadTracker` is a cluster-scoped custom resource that defines which workloads the approval controller should monitor for health metrics before auto-approving staged rollouts. - -#### Example: Single Workload - -```yaml -apiVersion: metric.kubernetes-fleet.io/v1beta1 -kind: WorkloadTracker -metadata: - name: sample-workload-tracker -workloads: - - name: sample-metric-app - namespace: test-ns -``` - -#### Example: Multiple Workloads - -```yaml -apiVersion: metric.kubernetes-fleet.io/v1beta1 -kind: WorkloadTracker -metadata: - name: multi-workload-tracker -workloads: - - name: frontend - namespace: production - - name: backend-api - namespace: production - - name: worker-service - namespace: production -``` - -#### Usage Notes - -- **Cluster-scoped:** WorkloadTracker is a cluster-scoped resource, not namespaced -- **Optional:** If no WorkloadTracker exists, the controller will skip health checks and won't auto-approve -- **Single instance:** The controller expects one WorkloadTracker per cluster and uses the first one found -- **Health criteria:** All workloads listed must report healthy (metric value = 1.0) before approval -- **Prometheus metrics:** Each workload should expose `workload_health` metrics that the MetricCollector can query - -For a complete example, see: [`./examples/workloadtracker/workloadtracker.yaml`](./examples/workloadtracker/workloadtracker.yaml) - -## Additional Resources - -- **Main Tutorial:** See [`../README.md`](../README.md) for a complete end-to-end tutorial on setting up automated staged rollouts with approval automation -- **Metric Collector:** See [`../metric-collector/README.md`](../metric-collector/README.md) for details on the metric collection component that runs on member clusters -- **KubeFleet Documentation:** [Azure/fleet](https://github.com/Azure/fleet) - Multi-cluster orchestration platform -- **Example Configurations:** - - [`./examples/workloadtracker/`](./examples/workloadtracker/) - WorkloadTracker resource examples - - [`./examples/stagedupdaterun/`](./examples/stagedupdaterun/) - Staged update configuration examples - - [`./examples/prometheus/`](./examples/prometheus/) - Prometheus deployment and configuration for metric collection -``` diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml deleted file mode 120000 index 59e890b..0000000 --- a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml +++ /dev/null @@ -1 +0,0 @@ -../../../../config/crd/bases/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectorreports.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectorreports.yaml deleted file mode 120000 index 8060e40..0000000 --- a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectorreports.yaml +++ /dev/null @@ -1 +0,0 @@ -../../../../config/crd/bases/metric.kubernetes-fleet.io_metriccollectorreports.yaml \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml deleted file mode 120000 index 433fbc2..0000000 --- a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml +++ /dev/null @@ -1 +0,0 @@ -../../../../config/crd/bases/metric.kubernetes-fleet.io_metriccollectors.yaml \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml b/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml deleted file mode 120000 index 3d7e91c..0000000 --- a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/crds/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml +++ /dev/null @@ -1 +0,0 @@ -../../../../config/crd/bases/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectors.yaml b/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectors.yaml deleted file mode 100644 index c97705d..0000000 --- a/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectors.yaml +++ /dev/null @@ -1,189 +0,0 @@ ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.16.0 - name: metriccollectors.metric.kubernetes-fleet.io -spec: - group: metric.kubernetes-fleet.io - names: - categories: - - fleet - - fleet-metrics - kind: MetricCollector - listKind: MetricCollectorList - plural: metriccollectors - shortNames: - - mc - singular: metriccollector - scope: Cluster - versions: - - additionalPrinterColumns: - - jsonPath: .metadata.generation - name: Gen - type: string - - jsonPath: .status.conditions[?(@.type=="MetricCollectorReady")].status - name: Ready - type: string - - jsonPath: .status.workloadsMonitored - name: Workloads - type: integer - - jsonPath: .status.lastCollectionTime - name: Last-Collection - type: date - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1alpha1 - schema: - openAPIV3Schema: - description: |- - MetricCollector is used by member-agent to scrape and collect metrics from workloads - running on the member cluster. It runs on each member cluster and collects metrics - from Prometheus-compatible endpoints. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: The desired state of MetricCollector. - properties: - prometheusUrl: - description: |- - PrometheusURL is the URL of the Prometheus server. - Example: http://prometheus.test-ns.svc.cluster.local:9090 - pattern: ^https?://.*$ - type: string - reportNamespace: - description: |- - ReportNamespace is the namespace in the hub cluster where the MetricCollectorReport will be created. - This should be the fleet-member-{clusterName} namespace. - Example: fleet-member-cluster-1 - type: string - required: - - prometheusUrl - - reportNamespace - type: object - status: - description: The observed status of MetricCollector. - properties: - collectedMetrics: - description: CollectedMetrics contains the most recent metrics from - each workload. - items: - description: WorkloadMetrics represents metrics collected from a - single workload pod. - properties: - clusterName: - description: ClusterName from the workload_health metric label. - type: string - health: - description: Health indicates if the workload is healthy (true=healthy, - false=unhealthy). - type: boolean - namespace: - description: Namespace is the namespace of the pod. - type: string - workloadName: - description: WorkloadName from the workload_health metric label - (typically the deployment name). - type: string - required: - - clusterName - - health - - namespace - - workloadName - type: object - type: array - conditions: - description: Conditions is an array of current observed conditions. - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - lastCollectionTime: - description: LastCollectionTime is when metrics were last collected. - format: date-time - type: string - observedGeneration: - description: ObservedGeneration is the generation most recently observed. - format: int64 - type: integer - workloadsMonitored: - description: WorkloadsMonitored is the count of workloads being monitored. - format: int32 - type: integer - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} diff --git a/approval-controller-metric-collector/metric-collector/Makefile b/approval-controller-metric-collector/metric-collector/Makefile deleted file mode 100644 index e865b83..0000000 --- a/approval-controller-metric-collector/metric-collector/Makefile +++ /dev/null @@ -1,125 +0,0 @@ -# Image URL to use for building/pushing image targets -REGISTRY ?= ghcr.io/kubefleet-dev -IMAGE_NAME ?= metric-collector -TAG ?= latest -IMG ?= $(REGISTRY)/$(IMAGE_NAME):$(TAG) - -# Go parameters -GOOS ?= $(shell go env GOOS) -GOARCH ?= $(shell go env GOARCH) - -# Directories -ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) -TOOLS_DIR := hack/tools -TOOLS_BIN_DIR := $(abspath $(TOOLS_DIR)/bin) - -# Binaries -CONTROLLER_GEN_VER := v0.16.0 -CONTROLLER_GEN_BIN := controller-gen -CONTROLLER_GEN := $(abspath $(TOOLS_BIN_DIR)/$(CONTROLLER_GEN_BIN)-$(CONTROLLER_GEN_VER)) - -GOIMPORTS_VER := latest -GOIMPORTS_BIN := goimports -GOIMPORTS := $(abspath $(TOOLS_BIN_DIR)/$(GOIMPORTS_BIN)-$(GOIMPORTS_VER)) - -# Scripts -GO_INSTALL := ../hack/go-install.sh - -# CRD Options -CRD_OPTIONS ?= "crd" - -##@ General - -.PHONY: help -help: ## Display this help. - @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) - -##@ Tooling - -$(CONTROLLER_GEN): - GOBIN=$(TOOLS_BIN_DIR) $(GO_INSTALL) sigs.k8s.io/controller-tools/cmd/controller-gen $(CONTROLLER_GEN_BIN) $(CONTROLLER_GEN_VER) - -$(GOIMPORTS): - GOBIN=$(TOOLS_BIN_DIR) $(GO_INSTALL) golang.org/x/tools/cmd/goimports $(GOIMPORTS_BIN) $(GOIMPORTS_VER) - -##@ Development - -.PHONY: fmt -fmt: ## Run go fmt against code. - go fmt ./... - -.PHONY: vet -vet: ## Run go vet against code. - go vet ./... - -.PHONY: imports -imports: $(GOIMPORTS) ## Organize imports. - $(GOIMPORTS) -local github.com/kubefleet-dev/standalone-metric-collector -w . - -##@ Build - -.PHONY: build -build: fmt vet ## Build metric-collector binary. - CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o bin/metric-collector ./cmd/metriccollector - -.PHONY: run -run: fmt vet ## Run controller from your host. - go run ./cmd/metriccollector/main.go - -.PHONY: docker-build -docker-build: ## Build docker image. - docker build -t ${IMG} -f docker/metric-collector.Dockerfile . - -.PHONY: docker-push -docker-push: ## Push docker image. - docker push ${IMG} - -.PHONY: docker-build-push -docker-build-push: docker-build docker-push ## Build and push docker image. - -##@ Deployment - -.PHONY: helm-lint -helm-lint: ## Lint helm chart. - helm lint charts/metric-collector - -.PHONY: helm-template -helm-template: ## Template helm chart. - helm template metric-collector charts/metric-collector \ - --set memberCluster.name=cluster-1 \ - --set hubCluster.url=https://hub-cluster:6443 \ - --set prometheus.url=http://prometheus.test-ns:9090 - -.PHONY: helm-package -helm-package: helm-lint ## Package helm chart. - helm package charts/metric-collector -d dist/ - -.PHONY: helm-install -helm-install: ## Install helm chart. - helm upgrade --install metric-collector charts/metric-collector \ - --namespace fleet-system --create-namespace \ - --set memberCluster.name=$(CLUSTER_NAME) \ - --set hubCluster.url=$(HUB_URL) \ - --set prometheus.url=$(PROMETHEUS_URL) \ - --set image.repository=$(REGISTRY)/$(IMAGE_NAME) \ - --set image.tag=$(TAG) - -.PHONY: helm-uninstall -helm-uninstall: ## Uninstall helm chart. - helm uninstall metric-collector --namespace fleet-system - -##@ CRD - -.PHONY: install-crds -install-crds: ## Install CRDs into the K8s cluster. - kubectl apply -f config/crd/bases/ - -.PHONY: uninstall-crds -uninstall-crds: ## Uninstall CRDs from the K8s cluster. - kubectl delete -f config/crd/bases/ - -##@ Cleanup - -.PHONY: clean -clean: ## Clean build artifacts. - rm -rf bin/ dist/ cover.out diff --git a/approval-controller-metric-collector/metric-collector/README.md b/approval-controller-metric-collector/metric-collector/README.md deleted file mode 100644 index 5d51ddf..0000000 --- a/approval-controller-metric-collector/metric-collector/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# Metric Collector - -The Metric Collector is a standalone controller that runs on **member clusters** to collect workload health metrics from Prometheus and report them back to the hub cluster. - -## Overview - -This controller is designed to be a standalone component that can run independently on member clusters. It: -- Watches `MetricCollector` resources deployed to the member cluster -- Queries local Prometheus for `workload_health` metrics -- Creates/updates `MetricCollectorReport` resources on the hub cluster -- Supports both token-based and certificate-based authentication to the hub -- Runs every 30 seconds (configurable) to collect and report metrics - -## Architecture - -The controller runs on member clusters and: -1. Receives `MetricCollector` resources via KubeFleet's ResourcePlacement -2. Queries the local Prometheus endpoint for workload health metrics -3. Parses metrics and extracts workload names, namespaces, and health status -4. Reports collected metrics to the hub cluster in `fleet-member-` namespace -5. Maintains continuous metric collection and reporting - -## Installation - -### Prerequisites - -**On Hub Cluster:** -- KubeFleet hub-agent installed -- `MetricCollectorReport` CRD installed (installed by approval-request-controller) -- RBAC permissions for metric-collector service account to create/update reports - -**On Member Cluster:** -- Prometheus deployed and accessible -- Workloads exposing `workload_health` metrics -- Network connectivity to hub cluster API server - -### Install via Script - -Use the provided installation script to install the metric collector on all member clusters: - -```bash -# Run from the metric-collector directory -./install-on-member.sh 3 # For 3 member clusters -``` - -This script automatically: -1. Builds the `metric-collector:latest` image -2. Builds the `metric-app:local` image (sample app) -3. Loads both images into each kind cluster -4. Creates hub token secret with proper RBAC on hub -5. Installs the metric-collector via Helm on each member - -For detailed step-by-step setup instructions, see the [main tutorial](../README.md). - -## Verification - -### Check Controller Status - -```bash -# Check pod status -kubectl get pods -n default -l app.kubernetes.io/name=metric-collector - -# Check logs -kubectl logs -n default -l app.kubernetes.io/name=metric-collector -f -``` - -### Check MetricCollector Resources - -```bash -# View MetricCollector resources on member cluster -kubectl get metriccollector -A -``` - -### Check Reports on Hub - -```bash -# Switch to hub cluster -kubectl config use-context kind-hub - -# View reports for this cluster -kubectl get metriccollectorreport -n fleet-member-cluster-1 - -# View report details -kubectl describe metriccollectorreport -n fleet-member-cluster-1 -``` - -## Additional Resources - -- [Main Tutorial](../README.md) -- [Approval Request Controller](../approval-request-controller/README.md) -- [KubeFleet Documentation](https://github.com/Azure/kubefleet) diff --git a/approval-controller-metric-collector/metric-collector/examples/metriccollector-example.yaml b/approval-controller-metric-collector/metric-collector/examples/metriccollector-example.yaml deleted file mode 100644 index 4fe5ec9..0000000 --- a/approval-controller-metric-collector/metric-collector/examples/metriccollector-example.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: metric.kubernetes-fleet.io/v1alpha1 -kind: MetricCollector -metadata: - name: mc-example-run-staging -spec: - prometheusURL: "http://prometheus.test-ns:9090" - promQLQuery: "workload_health" - reportNamespace: "fleet-member-cluster-1" diff --git a/approval-controller-metric-collector/metric-collector/go.mod b/approval-controller-metric-collector/metric-collector/go.mod deleted file mode 100644 index 2f9f4cf..0000000 --- a/approval-controller-metric-collector/metric-collector/go.mod +++ /dev/null @@ -1,69 +0,0 @@ -module github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/metric-collector - -go 1.24.9 - -require ( - github.com/kubefleet-dev/kubefleet v0.1.2 - github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller v0.0.0 - github.com/prometheus/client_golang v1.22.0 - k8s.io/api v0.34.1 - k8s.io/apimachinery v0.34.1 - k8s.io/client-go v0.34.1 - k8s.io/klog/v2 v2.130.1 - sigs.k8s.io/controller-runtime v0.22.4 -) - -require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect - github.com/evanphx/json-patch/v5 v5.9.11 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-openapi/jsonpointer v0.21.1 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/swag v0.23.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/btree v1.1.3 // indirect - github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.9.0 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.62.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect - github.com/x448/float16 v0.8.4 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.29.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.11.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.34.1 // indirect - k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect - sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect - sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect -) - -replace github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller => ../approval-request-controller diff --git a/approval-controller-metric-collector/metric-collector/go.sum b/approval-controller-metric-collector/metric-collector/go.sum deleted file mode 100644 index dc0cc21..0000000 --- a/approval-controller-metric-collector/metric-collector/go.sum +++ /dev/null @@ -1,192 +0,0 @@ -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= -github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= -github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= -github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= -github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= -github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= -github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= -github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kubefleet-dev/kubefleet v0.1.2 h1:BUOwehI9iBavU6TEbebrSxtFXHwyOcY1eacHyfHEjxo= -github.com/kubefleet-dev/kubefleet v0.1.2/go.mod h1:EYDCdtdM02qQkH3Gm5/K1cHDy26f2LbM7WzVGn2saLs= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= -github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= -github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= -github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= -golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= -gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= -gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= -k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= -k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= -k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= -k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= -k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= -k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= -sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= -sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= -sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= -sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/approval-controller-metric-collector/Images/approval-controller-metric-collector.png b/approval-request-metric-collector/Images/approval-controller-metric-collector.png similarity index 100% rename from approval-controller-metric-collector/Images/approval-controller-metric-collector.png rename to approval-request-metric-collector/Images/approval-controller-metric-collector.png diff --git a/approval-controller-metric-collector/approval-request-controller/Makefile b/approval-request-metric-collector/Makefile similarity index 94% rename from approval-controller-metric-collector/approval-request-controller/Makefile rename to approval-request-metric-collector/Makefile index 08e3afa..fc7adf0 100644 --- a/approval-controller-metric-collector/approval-request-controller/Makefile +++ b/approval-request-metric-collector/Makefile @@ -37,8 +37,7 @@ docker-build: ## Build docker image --platform=linux/$(GOARCH) \ --build-arg GOARCH=$(GOARCH) \ --tag $(IMAGE_NAME):$(IMAGE_TAG) \ - --build-context kubefleet=.. \ - .. + . .PHONY: docker-push docker-push: ## Push docker image @@ -48,7 +47,7 @@ docker-push: ## Push docker image .PHONY: run run: ## Run controller locally - cd .. && go run ./approval-request-controller/cmd/approvalrequestcontroller/main.go + go run ./cmd/approvalrequestcontroller/main.go ##@ Deployment diff --git a/approval-controller-metric-collector/README.md b/approval-request-metric-collector/README.md similarity index 97% rename from approval-controller-metric-collector/README.md rename to approval-request-metric-collector/README.md index 9fef46c..3803c01 100644 --- a/approval-controller-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -121,14 +121,14 @@ Export registry and tag variables: export REGISTRY="myfleetacr.azurecr.io" export TAG="latest" -cd approval-controller-metric-collector +cd approval-request-metric-collector ``` Build and push the approval-request-controller image: ```bash docker buildx build \ - --file approval-request-controller/docker/approval-request-controller.Dockerfile \ + --file docker/approval-request-controller.Dockerfile \ --tag ${REGISTRY}/approval-request-controller:${TAG} \ --platform=linux/amd64 \ --push \ @@ -139,7 +139,7 @@ Build and push the metric-collector image: ```bash docker buildx build \ - --file metric-collector/docker/metric-collector.Dockerfile \ + --file docker/metric-collector.Dockerfile \ --tag ${REGISTRY}/metric-collector:${TAG} \ --platform=linux/amd64 \ --push \ @@ -150,7 +150,7 @@ Build and push the metric-app image: ```bash docker buildx build \ - --file metric-collector/docker/metric-app.Dockerfile \ + --file docker/metric-app.Dockerfile \ --tag ${REGISTRY}/metric-app:${TAG} \ --platform=linux/amd64 \ --push \ @@ -369,10 +369,10 @@ These labels are used by the `StagedUpdateStrategy` to select clusters for each ### 2. Deploy Prometheus -From the kubefleet-cookbook repo, navigate to the approval-request-controller directory and deploy Prometheus for metrics collection: +From the kubefleet-cookbook repo, navigate to the approval-request-metric-collector directory and deploy Prometheus for metrics collection: ```bash -cd approval-controller-metric-collector/approval-request-controller +cd approval-request-metric-collector # Switch to hub cluster context kubectl config use-context @@ -694,13 +694,13 @@ kubectl get metriccollectorreport -A ## Configuration ### Approval Request Controller -- Located in `approval-request-controller/charts/approval-request-controller/values.yaml` +- Located in `charts/approval-request-controller/values.yaml` - Key settings: log level, resource limits, RBAC, CRD installation - Default Prometheus URL: `http://prometheus.prometheus.svc.cluster.local:9090` - Reconciliation interval: 15 seconds ### Metric Collector -- Located in `metric-collector/charts/metric-collector/values.yaml` +- Located in `charts/metric-collector/values.yaml` - Key settings: hub cluster URL, Prometheus URL, member cluster name - Metric collection interval: 30 seconds - Connects to hub using service account token @@ -708,7 +708,7 @@ kubectl get metriccollectorreport -A ## Troubleshooting ### Controller not starting -- Check that all required CRDs are installed: `kubectl get crds | grep metric.kubernetes-fleet.io` +- Check that all required CRDs are installed: `kubectl get crds | grep autoapprove.kubernetes-fleet.io` - Verify RBAC permissions are configured correctly ### Metrics not being collected diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/doc.go b/approval-request-metric-collector/apis/autoapprove/v1alpha1/doc.go similarity index 93% rename from approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/doc.go rename to approval-request-metric-collector/apis/autoapprove/v1alpha1/doc.go index 59c5de6..9d38394 100644 --- a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/doc.go +++ b/approval-request-metric-collector/apis/autoapprove/v1alpha1/doc.go @@ -16,5 +16,5 @@ limitations under the License. // Package v1alpha1 contains API Schema definitions for the placement v1beta1 API group // +kubebuilder:object:generate=true -// +groupName=metric.kubernetes-fleet.io +// +groupName=autoapprove.kubernetes-fleet.io package v1alpha1 diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/groupversion_info.go b/approval-request-metric-collector/apis/autoapprove/v1alpha1/groupversion_info.go similarity index 87% rename from approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/groupversion_info.go rename to approval-request-metric-collector/apis/autoapprove/v1alpha1/groupversion_info.go index 23b3d99..6f1fbac 100644 --- a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/groupversion_info.go +++ b/approval-request-metric-collector/apis/autoapprove/v1alpha1/groupversion_info.go @@ -15,7 +15,7 @@ limitations under the License. */ // +kubebuilder:object:generate=true -// +groupName=metric.kubernetes-fleet.io +// +groupName=autoapprove.kubernetes-fleet.io package v1alpha1 import ( @@ -25,7 +25,7 @@ import ( var ( // GroupVersion is group version used to register these objects - GroupVersion = schema.GroupVersion{Group: "metric.kubernetes-fleet.io", Version: "v1alpha1"} + GroupVersion = schema.GroupVersion{Group: "autoapprove.kubernetes-fleet.io", Version: "v1alpha1"} // SchemeBuilder is used to add go types to the GroupVersionKind scheme SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollectorreport_types.go b/approval-request-metric-collector/apis/autoapprove/v1alpha1/metriccollectorreport_types.go similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/metriccollectorreport_types.go rename to approval-request-metric-collector/apis/autoapprove/v1alpha1/metriccollectorreport_types.go diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/workloadtracker_types.go b/approval-request-metric-collector/apis/autoapprove/v1alpha1/workloadtracker_types.go similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/workloadtracker_types.go rename to approval-request-metric-collector/apis/autoapprove/v1alpha1/workloadtracker_types.go diff --git a/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/zz_generated.deepcopy.go b/approval-request-metric-collector/apis/autoapprove/v1alpha1/zz_generated.deepcopy.go similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1/zz_generated.deepcopy.go rename to approval-request-metric-collector/apis/autoapprove/v1alpha1/zz_generated.deepcopy.go diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/Chart.yaml b/approval-request-metric-collector/charts/approval-request-controller/Chart.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/Chart.yaml rename to approval-request-metric-collector/charts/approval-request-controller/Chart.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/_helpers.tpl b/approval-request-metric-collector/charts/approval-request-controller/templates/_helpers.tpl similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/_helpers.tpl rename to approval-request-metric-collector/charts/approval-request-controller/templates/_helpers.tpl diff --git a/approval-request-metric-collector/charts/approval-request-controller/templates/crds/autoapprove.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml b/approval-request-metric-collector/charts/approval-request-controller/templates/crds/autoapprove.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml new file mode 120000 index 0000000..89ed678 --- /dev/null +++ b/approval-request-metric-collector/charts/approval-request-controller/templates/crds/autoapprove.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml @@ -0,0 +1 @@ +../../../../config/crd/bases/autoapprove.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml \ No newline at end of file diff --git a/approval-request-metric-collector/charts/approval-request-controller/templates/crds/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml b/approval-request-metric-collector/charts/approval-request-controller/templates/crds/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml new file mode 120000 index 0000000..32b1524 --- /dev/null +++ b/approval-request-metric-collector/charts/approval-request-controller/templates/crds/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml @@ -0,0 +1 @@ +../../../../config/crd/bases/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml \ No newline at end of file diff --git a/approval-request-metric-collector/charts/approval-request-controller/templates/crds/autoapprove.kubernetes-fleet.io_stagedworkloadtrackers.yaml b/approval-request-metric-collector/charts/approval-request-controller/templates/crds/autoapprove.kubernetes-fleet.io_stagedworkloadtrackers.yaml new file mode 120000 index 0000000..db857c7 --- /dev/null +++ b/approval-request-metric-collector/charts/approval-request-controller/templates/crds/autoapprove.kubernetes-fleet.io_stagedworkloadtrackers.yaml @@ -0,0 +1 @@ +../../../../config/crd/bases/autoapprove.kubernetes-fleet.io_stagedworkloadtrackers.yaml \ No newline at end of file diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/deployment.yaml b/approval-request-metric-collector/charts/approval-request-controller/templates/deployment.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/deployment.yaml rename to approval-request-metric-collector/charts/approval-request-controller/templates/deployment.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml b/approval-request-metric-collector/charts/approval-request-controller/templates/rbac.yaml similarity index 94% rename from approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml rename to approval-request-metric-collector/charts/approval-request-controller/templates/rbac.yaml index 2be596f..c7ff6f4 100644 --- a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/rbac.yaml +++ b/approval-request-metric-collector/charts/approval-request-controller/templates/rbac.yaml @@ -23,10 +23,10 @@ rules: verbs: ["update"] # MetricCollector and MetricCollectorReport (our custom resources) - - apiGroups: ["metric.kubernetes-fleet.io"] + - apiGroups: ["autoapprove.kubernetes-fleet.io"] resources: ["metriccollectors", "metriccollectorreports"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: ["metric.kubernetes-fleet.io"] + - apiGroups: ["autoapprove.kubernetes-fleet.io"] resources: ["metriccollectors/status", "metriccollectorreports/status"] verbs: ["update", "patch"] @@ -41,7 +41,7 @@ rules: verbs: ["get", "list", "watch"] # WorkloadTracker (our custom resource) - - apiGroups: ["metric.kubernetes-fleet.io"] + - apiGroups: ["autoapprove.kubernetes-fleet.io"] resources: ["clusterstagedworkloadtrackers", "stagedworkloadtrackers"] verbs: ["get", "list", "watch"] diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/serviceaccount.yaml b/approval-request-metric-collector/charts/approval-request-controller/templates/serviceaccount.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/templates/serviceaccount.yaml rename to approval-request-metric-collector/charts/approval-request-controller/templates/serviceaccount.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/values.yaml b/approval-request-metric-collector/charts/approval-request-controller/values.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/charts/approval-request-controller/values.yaml rename to approval-request-metric-collector/charts/approval-request-controller/values.yaml diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/Chart.yaml b/approval-request-metric-collector/charts/metric-collector/Chart.yaml similarity index 100% rename from approval-controller-metric-collector/metric-collector/charts/metric-collector/Chart.yaml rename to approval-request-metric-collector/charts/metric-collector/Chart.yaml diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/_helpers.tpl b/approval-request-metric-collector/charts/metric-collector/templates/_helpers.tpl similarity index 100% rename from approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/_helpers.tpl rename to approval-request-metric-collector/charts/metric-collector/templates/_helpers.tpl diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml b/approval-request-metric-collector/charts/metric-collector/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml similarity index 100% rename from approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml rename to approval-request-metric-collector/charts/metric-collector/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/deployment.yaml b/approval-request-metric-collector/charts/metric-collector/templates/deployment.yaml similarity index 100% rename from approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/deployment.yaml rename to approval-request-metric-collector/charts/metric-collector/templates/deployment.yaml diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/hub-rbac.yaml b/approval-request-metric-collector/charts/metric-collector/templates/hub-rbac.yaml similarity index 96% rename from approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/hub-rbac.yaml rename to approval-request-metric-collector/charts/metric-collector/templates/hub-rbac.yaml index 1f016de..5df3812 100644 --- a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/hub-rbac.yaml +++ b/approval-request-metric-collector/charts/metric-collector/templates/hub-rbac.yaml @@ -22,10 +22,10 @@ metadata: helm.sh/resource-policy: keep rules: # MetricCollectorReport access - - apiGroups: ["metric.kubernetes-fleet.io"] + - apiGroups: ["autoapprove.kubernetes-fleet.io"] resources: ["metriccollectorreports"] verbs: ["get", "list", "watch", "update", "patch"] - - apiGroups: ["metric.kubernetes-fleet.io"] + - apiGroups: ["autoapprove.kubernetes-fleet.io"] resources: ["metriccollectorreports/status"] verbs: ["update", "patch"] --- diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/rbac-member.yaml b/approval-request-metric-collector/charts/metric-collector/templates/rbac-member.yaml similarity index 88% rename from approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/rbac-member.yaml rename to approval-request-metric-collector/charts/metric-collector/templates/rbac-member.yaml index c2b7af0..3bd3b5c 100644 --- a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/rbac-member.yaml +++ b/approval-request-metric-collector/charts/metric-collector/templates/rbac-member.yaml @@ -7,13 +7,13 @@ metadata: {{- include "metric-collector.labels" . | nindent 4 }} rules: # MetricCollector CRD access on member cluster - - apiGroups: ["metric.kubernetes-fleet.io"] + - apiGroups: ["autoapprove.kubernetes-fleet.io"] resources: ["metriccollectors"] verbs: ["get", "list", "watch", "update", "patch"] - - apiGroups: ["metric.kubernetes-fleet.io"] + - apiGroups: ["autoapprove.kubernetes-fleet.io"] resources: ["metriccollectors/status"] verbs: ["update", "patch"] - - apiGroups: ["metric.kubernetes-fleet.io"] + - apiGroups: ["autoapprove.kubernetes-fleet.io"] resources: ["metriccollectors/finalizers"] verbs: ["update"] diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/serviceaccount.yaml b/approval-request-metric-collector/charts/metric-collector/templates/serviceaccount.yaml similarity index 100% rename from approval-controller-metric-collector/metric-collector/charts/metric-collector/templates/serviceaccount.yaml rename to approval-request-metric-collector/charts/metric-collector/templates/serviceaccount.yaml diff --git a/approval-controller-metric-collector/metric-collector/charts/metric-collector/values.yaml b/approval-request-metric-collector/charts/metric-collector/values.yaml similarity index 100% rename from approval-controller-metric-collector/metric-collector/charts/metric-collector/values.yaml rename to approval-request-metric-collector/charts/metric-collector/values.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go b/approval-request-metric-collector/cmd/approvalrequestcontroller/main.go similarity index 89% rename from approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go rename to approval-request-metric-collector/cmd/approvalrequestcontroller/main.go index c5b9ffb..f35bba1 100644 --- a/approval-controller-metric-collector/approval-request-controller/cmd/approvalrequestcontroller/main.go +++ b/approval-request-metric-collector/cmd/approvalrequestcontroller/main.go @@ -34,8 +34,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - localv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1" - "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller/pkg/controller" + localv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/apis/autoapprove/v1alpha1" + approvalcontroller "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/pkg/controllers/approvalrequest" placementv1beta1 "github.com/kubefleet-dev/kubefleet/apis/placement/v1beta1" ) @@ -88,7 +88,7 @@ func main() { } // Setup ApprovalRequest controller - approvalRequestReconciler := &controller.Reconciler{ + approvalRequestReconciler := &approvalcontroller.Reconciler{ Client: mgr.GetClient(), } if err = approvalRequestReconciler.SetupWithManagerForApprovalRequest(mgr); err != nil { @@ -97,7 +97,7 @@ func main() { } // Setup ClusterApprovalRequest controller - clusterApprovalRequestReconciler := &controller.Reconciler{ + clusterApprovalRequestReconciler := &approvalcontroller.Reconciler{ Client: mgr.GetClient(), } if err = clusterApprovalRequestReconciler.SetupWithManagerForClusterApprovalRequest(mgr); err != nil { @@ -127,9 +127,9 @@ func checkRequiredCRDs(config *rest.Config) error { requiredCRDs := []string{ "approvalrequests.placement.kubernetes-fleet.io", "clusterapprovalrequests.placement.kubernetes-fleet.io", - "metriccollectorreports.metric.kubernetes-fleet.io", - "clusterstagedworkloadtrackers.metric.kubernetes-fleet.io", - "stagedworkloadtrackers.metric.kubernetes-fleet.io", + "metriccollectorreports.autoapprove.kubernetes-fleet.io", + "clusterstagedworkloadtrackers.autoapprove.kubernetes-fleet.io", + "stagedworkloadtrackers.autoapprove.kubernetes-fleet.io", "clusterstagedupdateruns.placement.kubernetes-fleet.io", "stagedupdateruns.placement.kubernetes-fleet.io", } diff --git a/approval-controller-metric-collector/metric-collector/cmd/metriccollector/metric-app/main.go b/approval-request-metric-collector/cmd/metricapp/main.go similarity index 100% rename from approval-controller-metric-collector/metric-collector/cmd/metriccollector/metric-app/main.go rename to approval-request-metric-collector/cmd/metricapp/main.go diff --git a/approval-controller-metric-collector/metric-collector/cmd/metriccollector/main.go b/approval-request-metric-collector/cmd/metriccollector/main.go similarity index 97% rename from approval-controller-metric-collector/metric-collector/cmd/metriccollector/main.go rename to approval-request-metric-collector/cmd/metriccollector/main.go index ea72026..2a8020f 100644 --- a/approval-controller-metric-collector/metric-collector/cmd/metriccollector/main.go +++ b/approval-request-metric-collector/cmd/metriccollector/main.go @@ -32,8 +32,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/healthz" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - placementv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1" - metriccollector "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/metric-collector/pkg/controller" + placementv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/apis/autoapprove/v1alpha1" + metriccollector "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/pkg/controllers/metriccollector" placementv1beta1 "github.com/kubefleet-dev/kubefleet/apis/placement/v1beta1" ) @@ -211,7 +211,7 @@ func Start(ctx context.Context, hubCfg *rest.Config, memberClusterName, hubNames // Setup MetricCollectorReport controller (watches hub, queries member Prometheus) if err := (&metriccollector.Reconciler{ - HubClient: hubMgr.GetClient(), + HubClient: hubMgr.GetClient(), }).SetupWithManager(hubMgr); err != nil { return fmt.Errorf("failed to setup controller: %w", err) } diff --git a/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml b/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml similarity index 95% rename from approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml rename to approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml index d2a3bb6..79e83cf 100644 --- a/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml +++ b/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.16.0 - name: clusterstagedworkloadtrackers.metric.kubernetes-fleet.io + name: clusterstagedworkloadtrackers.autoapprove.kubernetes-fleet.io spec: - group: metric.kubernetes-fleet.io + group: autoapprove.kubernetes-fleet.io names: categories: - fleet diff --git a/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectorreports.yaml b/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml similarity index 98% rename from approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectorreports.yaml rename to approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml index 6e58269..9548c23 100644 --- a/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectorreports.yaml +++ b/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.16.0 - name: metriccollectorreports.metric.kubernetes-fleet.io + name: metriccollectorreports.autoapprove.kubernetes-fleet.io spec: - group: metric.kubernetes-fleet.io + group: autoapprove.kubernetes-fleet.io names: categories: - fleet diff --git a/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml b/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_stagedworkloadtrackers.yaml similarity index 95% rename from approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml rename to approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_stagedworkloadtrackers.yaml index d394429..ef221cd 100644 --- a/approval-controller-metric-collector/approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_stagedworkloadtrackers.yaml +++ b/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_stagedworkloadtrackers.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.16.0 - name: stagedworkloadtrackers.metric.kubernetes-fleet.io + name: stagedworkloadtrackers.autoapprove.kubernetes-fleet.io spec: - group: metric.kubernetes-fleet.io + group: autoapprove.kubernetes-fleet.io names: categories: - fleet diff --git a/approval-controller-metric-collector/approval-request-controller/docker/approval-request-controller.Dockerfile b/approval-request-metric-collector/docker/approval-request-controller.Dockerfile similarity index 69% rename from approval-controller-metric-collector/approval-request-controller/docker/approval-request-controller.Dockerfile rename to approval-request-metric-collector/docker/approval-request-controller.Dockerfile index 46a10d0..7775210 100644 --- a/approval-controller-metric-collector/approval-request-controller/docker/approval-request-controller.Dockerfile +++ b/approval-request-metric-collector/docker/approval-request-controller.Dockerfile @@ -4,13 +4,13 @@ FROM golang:1.24 AS builder WORKDIR /workspace # Copy go mod files -COPY approval-request-controller/go.mod approval-request-controller/go.sum* ./ +COPY go.mod go.sum* ./ RUN go mod download # Copy source code -COPY approval-request-controller/apis/ apis/ -COPY approval-request-controller/pkg/ pkg/ -COPY approval-request-controller/cmd/ cmd/ +COPY apis/ apis/ +COPY pkg/ pkg/ +COPY cmd/ cmd/ # Build the controller ARG GOARCH=amd64 diff --git a/approval-controller-metric-collector/metric-collector/docker/metric-app.Dockerfile b/approval-request-metric-collector/docker/metric-app.Dockerfile similarity index 92% rename from approval-controller-metric-collector/metric-collector/docker/metric-app.Dockerfile rename to approval-request-metric-collector/docker/metric-app.Dockerfile index 6b4037e..7cd58d2 100644 --- a/approval-controller-metric-collector/metric-collector/docker/metric-app.Dockerfile +++ b/approval-request-metric-collector/docker/metric-app.Dockerfile @@ -8,7 +8,7 @@ RUN go mod init metric-app && \ go get github.com/prometheus/client_golang/prometheus/promhttp@latest # Copy source code -COPY metric-collector/cmd/metric-app/ ./ +COPY cmd/metricapp/ ./ # Build the application RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o metric-app main.go diff --git a/approval-controller-metric-collector/metric-collector/docker/metric-collector.Dockerfile b/approval-request-metric-collector/docker/metric-collector.Dockerfile similarity index 54% rename from approval-controller-metric-collector/metric-collector/docker/metric-collector.Dockerfile rename to approval-request-metric-collector/docker/metric-collector.Dockerfile index fbaa449..df42078 100644 --- a/approval-controller-metric-collector/metric-collector/docker/metric-collector.Dockerfile +++ b/approval-request-metric-collector/docker/metric-collector.Dockerfile @@ -1,17 +1,14 @@ FROM golang:1.24 AS builder WORKDIR /workspace -# Copy approval-request-controller (for APIs) -COPY approval-request-controller/ approval-request-controller/ - -# Copy go mod files -COPY metric-collector/go.mod metric-collector/go.sum* metric-collector/ -WORKDIR /workspace/metric-collector +# Copy go mod files and download dependencies +COPY go.mod go.sum* ./ RUN go mod download # Copy source code -COPY metric-collector/cmd/ cmd/ -COPY metric-collector/pkg/ pkg/ +COPY apis/ apis/ +COPY cmd/metriccollector/ cmd/metriccollector/ +COPY pkg/metriccollector/ pkg/metriccollector/ # Build RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ diff --git a/approval-controller-metric-collector/approval-request-controller/examples/membercluster/fleet_v1beta1_membercluster.yaml b/approval-request-metric-collector/examples/membercluster/fleet_v1beta1_membercluster.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/examples/membercluster/fleet_v1beta1_membercluster.yaml rename to approval-request-metric-collector/examples/membercluster/fleet_v1beta1_membercluster.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/examples/prometheus/configmap.yaml b/approval-request-metric-collector/examples/prometheus/configmap.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/examples/prometheus/configmap.yaml rename to approval-request-metric-collector/examples/prometheus/configmap.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/examples/prometheus/deployment.yaml b/approval-request-metric-collector/examples/prometheus/deployment.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/examples/prometheus/deployment.yaml rename to approval-request-metric-collector/examples/prometheus/deployment.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/examples/prometheus/prometheus-crp.yaml b/approval-request-metric-collector/examples/prometheus/prometheus-crp.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/examples/prometheus/prometheus-crp.yaml rename to approval-request-metric-collector/examples/prometheus/prometheus-crp.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/examples/prometheus/rbac.yaml b/approval-request-metric-collector/examples/prometheus/rbac.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/examples/prometheus/rbac.yaml rename to approval-request-metric-collector/examples/prometheus/rbac.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/examples/prometheus/service.yaml b/approval-request-metric-collector/examples/prometheus/service.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/examples/prometheus/service.yaml rename to approval-request-metric-collector/examples/prometheus/service.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/examples/sample-metric-app/sample-metric-app.yaml b/approval-request-metric-collector/examples/sample-metric-app/sample-metric-app.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/examples/sample-metric-app/sample-metric-app.yaml rename to approval-request-metric-collector/examples/sample-metric-app/sample-metric-app.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-crp.yaml b/approval-request-metric-collector/examples/updateRun/example-crp.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-crp.yaml rename to approval-request-metric-collector/examples/updateRun/example-crp.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csur.yaml b/approval-request-metric-collector/examples/updateRun/example-csur.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csur.yaml rename to approval-request-metric-collector/examples/updateRun/example-csur.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csus.yaml b/approval-request-metric-collector/examples/updateRun/example-csus.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-csus.yaml rename to approval-request-metric-collector/examples/updateRun/example-csus.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-ns-only-crp.yaml b/approval-request-metric-collector/examples/updateRun/example-ns-only-crp.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-ns-only-crp.yaml rename to approval-request-metric-collector/examples/updateRun/example-ns-only-crp.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-rp.yaml b/approval-request-metric-collector/examples/updateRun/example-rp.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-rp.yaml rename to approval-request-metric-collector/examples/updateRun/example-rp.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sur.yaml b/approval-request-metric-collector/examples/updateRun/example-sur.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sur.yaml rename to approval-request-metric-collector/examples/updateRun/example-sur.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sus.yaml b/approval-request-metric-collector/examples/updateRun/example-sus.yaml similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/examples/updateRun/example-sus.yaml rename to approval-request-metric-collector/examples/updateRun/example-sus.yaml diff --git a/approval-controller-metric-collector/approval-request-controller/examples/workloadtracker/clusterstagedworkloadtracker.yaml b/approval-request-metric-collector/examples/workloadtracker/clusterstagedworkloadtracker.yaml similarity index 80% rename from approval-controller-metric-collector/approval-request-controller/examples/workloadtracker/clusterstagedworkloadtracker.yaml rename to approval-request-metric-collector/examples/workloadtracker/clusterstagedworkloadtracker.yaml index 37c36ec..343d05b 100644 --- a/approval-controller-metric-collector/approval-request-controller/examples/workloadtracker/clusterstagedworkloadtracker.yaml +++ b/approval-request-metric-collector/examples/workloadtracker/clusterstagedworkloadtracker.yaml @@ -1,4 +1,4 @@ -apiVersion: metric.kubernetes-fleet.io/v1alpha1 +apiVersion: autoapprove.kubernetes-fleet.io/v1alpha1 kind: ClusterStagedWorkloadTracker metadata: # The name must match the name of the ClusterStagedUpdateRun it is used for diff --git a/approval-controller-metric-collector/approval-request-controller/examples/workloadtracker/stagedworkloadtracker.yaml b/approval-request-metric-collector/examples/workloadtracker/stagedworkloadtracker.yaml similarity index 82% rename from approval-controller-metric-collector/approval-request-controller/examples/workloadtracker/stagedworkloadtracker.yaml rename to approval-request-metric-collector/examples/workloadtracker/stagedworkloadtracker.yaml index bb27131..b54fce0 100644 --- a/approval-controller-metric-collector/approval-request-controller/examples/workloadtracker/stagedworkloadtracker.yaml +++ b/approval-request-metric-collector/examples/workloadtracker/stagedworkloadtracker.yaml @@ -1,4 +1,4 @@ -apiVersion: metric.kubernetes-fleet.io/v1alpha1 +apiVersion: autoapprove.kubernetes-fleet.io/v1alpha1 kind: StagedWorkloadTracker metadata: # The name and namespace must match the name and namespace of the StagedUpdateRun it is used for diff --git a/approval-controller-metric-collector/approval-request-controller/go.mod b/approval-request-metric-collector/go.mod similarity index 93% rename from approval-controller-metric-collector/approval-request-controller/go.mod rename to approval-request-metric-collector/go.mod index 4e1df5c..1513223 100644 --- a/approval-controller-metric-collector/approval-request-controller/go.mod +++ b/approval-request-metric-collector/go.mod @@ -1,9 +1,11 @@ -module github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller +module github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector go 1.24.9 require ( github.com/kubefleet-dev/kubefleet v0.1.2 + github.com/prometheus/client_golang v1.22.0 + k8s.io/api v0.34.1 k8s.io/apiextensions-apiserver v0.34.1 k8s.io/apimachinery v0.34.1 k8s.io/client-go v0.34.1 @@ -38,7 +40,6 @@ require ( github.com/onsi/gomega v1.37.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect @@ -61,7 +62,6 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.34.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/metrics v0.32.3 // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect diff --git a/approval-controller-metric-collector/approval-request-controller/go.sum b/approval-request-metric-collector/go.sum similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/go.sum rename to approval-request-metric-collector/go.sum diff --git a/approval-controller-metric-collector/approval-request-controller/hack/boilerplate.go.txt b/approval-request-metric-collector/hack/boilerplate.go.txt similarity index 100% rename from approval-controller-metric-collector/approval-request-controller/hack/boilerplate.go.txt rename to approval-request-metric-collector/hack/boilerplate.go.txt diff --git a/approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go similarity index 99% rename from approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go rename to approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go index 81b9289..5ddcb48 100644 --- a/approval-controller-metric-collector/approval-request-controller/pkg/controller/controller.go +++ b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package controller features a controller to reconcile ApprovalRequest objects +// Package approvalrequest features a controller to reconcile ApprovalRequest objects // and create MetricCollectorReport resources on the hub cluster for metric collection. -package controller +package approvalrequest import ( "context" @@ -35,7 +35,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/predicate" - localv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1" + localv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/apis/autoapprove/v1alpha1" placementv1beta1 "github.com/kubefleet-dev/kubefleet/apis/placement/v1beta1" "github.com/kubefleet-dev/kubefleet/pkg/utils" ) diff --git a/approval-controller-metric-collector/metric-collector/pkg/controller/collector.go b/approval-request-metric-collector/pkg/controllers/metriccollector/collector.go similarity index 100% rename from approval-controller-metric-collector/metric-collector/pkg/controller/collector.go rename to approval-request-metric-collector/pkg/controllers/metriccollector/collector.go diff --git a/approval-controller-metric-collector/metric-collector/pkg/controller/controller.go b/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go similarity index 98% rename from approval-controller-metric-collector/metric-collector/pkg/controller/controller.go rename to approval-request-metric-collector/pkg/controllers/metriccollector/controller.go index 7d67c92..01d7fb8 100644 --- a/approval-controller-metric-collector/metric-collector/pkg/controller/controller.go +++ b/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go @@ -30,7 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/predicate" - localv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-controller-metric-collector/approval-request-controller/apis/metric/v1alpha1" + localv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/apis/autoapprove/v1alpha1" ) const ( diff --git a/approval-controller-metric-collector/approval-request-controller/install-on-hub.sh b/approval-request-metric-collector/scripts/install-on-hub.sh similarity index 95% rename from approval-controller-metric-collector/approval-request-controller/install-on-hub.sh rename to approval-request-metric-collector/scripts/install-on-hub.sh index 4f31a93..d2750e3 100755 --- a/approval-controller-metric-collector/approval-request-controller/install-on-hub.sh +++ b/approval-request-metric-collector/scripts/install-on-hub.sh @@ -78,7 +78,7 @@ echo "" # Step 2: Install helm chart on hub cluster (includes MetricCollector, MetricCollectorReport, WorkloadTracker CRDs) echo "Step 2: Installing helm chart on hub cluster..." -helm upgrade --install ${CHART_NAME} ./charts/${CHART_NAME} \ +helm upgrade --install ${CHART_NAME} ../charts/${CHART_NAME} \ --kube-context=${HUB_CONTEXT} \ --namespace ${NAMESPACE} \ --create-namespace \ @@ -106,7 +106,7 @@ echo "To check controller logs:" echo " kubectl --context=${HUB_CONTEXT} logs -n ${NAMESPACE} -l app.kubernetes.io/name=${CHART_NAME} -f" echo "" echo "To verify CRDs:" -echo " kubectl --context=${HUB_CONTEXT} get crd | grep metric.kubernetes-fleet.io" +echo " kubectl --context=${HUB_CONTEXT} get crd | grep autoapprove.kubernetes-fleet.io" echo "" echo "Next steps:" echo " 1. Create a WorkloadTracker to define which workloads to monitor" diff --git a/approval-controller-metric-collector/metric-collector/install-on-member.sh b/approval-request-metric-collector/scripts/install-on-member.sh similarity index 97% rename from approval-controller-metric-collector/metric-collector/install-on-member.sh rename to approval-request-metric-collector/scripts/install-on-member.sh index de4dd9b..fe94abd 100755 --- a/approval-controller-metric-collector/metric-collector/install-on-member.sh +++ b/approval-request-metric-collector/scripts/install-on-member.sh @@ -106,10 +106,10 @@ metadata: name: metric-collector-report-role namespace: ${HUB_NAMESPACE} rules: -- apiGroups: ["metric.kubernetes-fleet.io"] +- apiGroups: ["autoapprove.kubernetes-fleet.io"] resources: ["metriccollectorreports"] verbs: ["get", "list", "watch", "update", "patch"] -- apiGroups: ["metric.kubernetes-fleet.io"] +- apiGroups: ["autoapprove.kubernetes-fleet.io"] resources: ["metriccollectorreports/status"] verbs: ["update", "patch"] --- @@ -194,7 +194,7 @@ EOF # Step 4: Install helm chart on member cluster (includes CRD) echo "Step 4: Installing helm chart on member cluster..." - helm upgrade --install metric-collector ./charts/metric-collector \ + helm upgrade --install metric-collector ../charts/metric-collector \ --kube-context=${MEMBER_CONTEXT} \ --namespace ${MEMBER_NAMESPACE} \ --set memberCluster.name=${MEMBER_CLUSTER_NAME} \ From 538ae511fe3dcd1cb22746c207d095dba4889364 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Sun, 14 Dec 2025 23:37:39 -0800 Subject: [PATCH 15/38] minor fixes Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/README.md | 32 ++++++------------- ...netes-fleet.io_metriccollectorreports.yaml | 1 + ....kubernetes-fleet.io_metriccollectors.yaml | 1 - .../templates/deployment.yaml | 7 ++-- .../docker/metric-app.Dockerfile | 30 ++++++++++------- .../docker/metric-collector.Dockerfile | 14 +++++--- 6 files changed, 43 insertions(+), 42 deletions(-) create mode 120000 approval-request-metric-collector/charts/metric-collector/templates/crds/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml delete mode 120000 approval-request-metric-collector/charts/metric-collector/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index 3803c01..59224a2 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -420,7 +420,6 @@ image: myfleetacr.azurecr.io/metric-app:latest imagePullPolicy: Always ``` Then apply: `kubectl apply -f ./examples/sample-metric-app/` -``` ### 4. Install Approval Request Controller (Hub Cluster) @@ -430,8 +429,10 @@ Install the approval request controller on the hub cluster using the ACR registr # Set your ACR registry name export REGISTRY="myfleetacr.azurecr.io" -# Run the installation script +# Navigate to scripts directory and run the installation script +cd scripts ./install-on-hub.sh ${REGISTRY} +cd .. ``` The script performs the following: @@ -466,27 +467,20 @@ kubectl apply -f ./examples/workloadtracker/stagedworkloadtracker.yaml Install the metric collector on all member clusters using the ACR registry: ```bash -cd ../metric-collector +# Navigate to scripts directory +cd scripts # Run the installation script for all member clusters # Replace with your hub cluster name (e.g., kind-hub, hub) # Replace , , with your actual cluster names ./install-on-member.sh ${REGISTRY} -The script performs the following for each member cluster: -1. Verifies the `fleet-member-` namespace exists on the hub (created by KubeFleet) -2. Creates RBAC resources (ServiceAccount, Role, RoleBinding) in the fleet-member namespace on the hub -3. Creates a token secret for hub cluster authentication -4. Installs the metric-collector via Helm on each member cluster -5. Configures the collector to pull images from ACR and connect to hub API server and local Prometheus -**Note:** The script expects the `fleet-member-` namespaces to already exist on the hub cluster. These are automatically created by KubeFleet when member clusters join the hub. If you encounter errors about missing namespaces, ensure your member clusters are properly registered with the hub. -./install-on-member.sh ${REGISTRY} kind-hub kind-cluster-1 kind-cluster-2 kind-cluster-3 -``` +# Example: +# ./install-on-member.sh ${REGISTRY} kind-hub kind-cluster-1 kind-cluster-2 kind-cluster-3 -The script performs the following for each member cluster: -1. Pulls the `metric-collector` and `metric-app` images from your ACR -2. Creates hub token secret with proper RBAC -3. Installs the metric-collector via Helm +# Return to parent directory +cd .. +``` 4. Configures connection to hub API server and local Prometheus ```bash cd ../approval-request-controller @@ -531,12 +525,6 @@ kubectl apply -f ./examples/updateRun/example-csur.yaml # Check the staged update run status kubectl get csur -A ``` -```bash -cd ../approval-request-controller - -# Switch to hub cluster context -kubectl config use-context -``` #### Option B: Namespace-Scoped Staged Update (StagedUpdateRun) diff --git a/approval-request-metric-collector/charts/metric-collector/templates/crds/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml b/approval-request-metric-collector/charts/metric-collector/templates/crds/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml new file mode 120000 index 0000000..32b1524 --- /dev/null +++ b/approval-request-metric-collector/charts/metric-collector/templates/crds/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml @@ -0,0 +1 @@ +../../../../config/crd/bases/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml \ No newline at end of file diff --git a/approval-request-metric-collector/charts/metric-collector/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml b/approval-request-metric-collector/charts/metric-collector/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml deleted file mode 120000 index efb9228..0000000 --- a/approval-request-metric-collector/charts/metric-collector/templates/crds/metric.kubernetes-fleet.io_metriccollectors.yaml +++ /dev/null @@ -1 +0,0 @@ -../../../../../approval-request-controller/config/crd/bases/metric.kubernetes-fleet.io_metriccollectors.yaml \ No newline at end of file diff --git a/approval-request-metric-collector/charts/metric-collector/templates/deployment.yaml b/approval-request-metric-collector/charts/metric-collector/templates/deployment.yaml index 3e22a23..1bff73a 100644 --- a/approval-request-metric-collector/charts/metric-collector/templates/deployment.yaml +++ b/approval-request-metric-collector/charts/metric-collector/templates/deployment.yaml @@ -36,13 +36,16 @@ spec: - /metric-collector args: - --v={{ .Values.controller.logLevel }} - - --member-qps=100 - - --member-burst=200 - --hub-qps=100 - --hub-burst=200 - --metrics-bind-address=:{{ .Values.metrics.port }} - --health-probe-bind-address=:{{ .Values.healthProbe.port }} + - --leader-elect=false env: + # Member cluster identity + - name: MEMBER_CLUSTER_NAME + value: {{ .Values.memberCluster.name | quote }} + # Hub cluster connection - name: HUB_SERVER_URL value: {{ .Values.hubCluster.url | quote }} diff --git a/approval-request-metric-collector/docker/metric-app.Dockerfile b/approval-request-metric-collector/docker/metric-app.Dockerfile index 7cd58d2..86100e3 100644 --- a/approval-request-metric-collector/docker/metric-app.Dockerfile +++ b/approval-request-metric-collector/docker/metric-app.Dockerfile @@ -1,21 +1,27 @@ # Build stage -FROM golang:1.24-alpine AS builder +FROM golang:1.24 AS builder + WORKDIR /workspace -# Initialize go module for metric-app -RUN go mod init metric-app && \ - go get github.com/prometheus/client_golang/prometheus@latest && \ - go get github.com/prometheus/client_golang/prometheus/promhttp@latest +# Copy go mod files +COPY go.mod go.sum* ./ +RUN go mod download # Copy source code -COPY cmd/metricapp/ ./ +COPY apis/ apis/ +COPY pkg/ pkg/ +COPY cmd/ cmd/ # Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o metric-app main.go +ARG GOARCH=amd64 +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${GOARCH} go build \ + -a -o metric-app \ + ./cmd/metricapp -# Run stage -FROM alpine:3.18 -WORKDIR /app +# Runtime stage +FROM gcr.io/distroless/static:nonroot +WORKDIR / COPY --from=builder /workspace/metric-app . -EXPOSE 8080 -CMD ["./metric-app"] +USER 65532:65532 + +ENTRYPOINT ["/metric-app"] diff --git a/approval-request-metric-collector/docker/metric-collector.Dockerfile b/approval-request-metric-collector/docker/metric-collector.Dockerfile index df42078..1ebff59 100644 --- a/approval-request-metric-collector/docker/metric-collector.Dockerfile +++ b/approval-request-metric-collector/docker/metric-collector.Dockerfile @@ -1,20 +1,24 @@ +# Build stage FROM golang:1.24 AS builder + WORKDIR /workspace -# Copy go mod files and download dependencies +# Copy go mod files COPY go.mod go.sum* ./ RUN go mod download # Copy source code COPY apis/ apis/ -COPY cmd/metriccollector/ cmd/metriccollector/ -COPY pkg/metriccollector/ pkg/metriccollector/ +COPY pkg/ pkg/ +COPY cmd/ cmd/ -# Build -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ +# Build the collector +ARG GOARCH=amd64 +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${GOARCH} go build \ -a -o metric-collector \ ./cmd/metriccollector +# Runtime stage FROM gcr.io/distroless/static:nonroot WORKDIR / COPY --from=builder /workspace/metric-collector . From d9375411d027f6fa45632702b0cb01ae6379df13 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Mon, 15 Dec 2025 11:18:34 -0800 Subject: [PATCH 16/38] minor changes Signed-off-by: Arvind Thirumurugan --- .../v1alpha1/metriccollectorreport_types.go | 11 +++ .../fleet_v1beta1_membercluster.yaml | 2 +- .../examples/prometheus/prometheus-crp.yaml | 2 +- .../examples/updateRun/example-csur.yaml | 1 - .../examples/updateRun/example-csus.yaml | 2 +- .../updateRun/example-ns-only-crp.yaml | 2 +- .../examples/updateRun/example-rp.yaml | 2 +- .../examples/updateRun/example-sur.yaml | 1 - .../examples/updateRun/example-sus.yaml | 2 +- .../controllers/approvalrequest/controller.go | 99 +++++-------------- .../controllers/metriccollector/controller.go | 14 ++- 11 files changed, 49 insertions(+), 89 deletions(-) diff --git a/approval-request-metric-collector/apis/autoapprove/v1alpha1/metriccollectorreport_types.go b/approval-request-metric-collector/apis/autoapprove/v1alpha1/metriccollectorreport_types.go index d30e06c..18ff385 100644 --- a/approval-request-metric-collector/apis/autoapprove/v1alpha1/metriccollectorreport_types.go +++ b/approval-request-metric-collector/apis/autoapprove/v1alpha1/metriccollectorreport_types.go @@ -20,6 +20,17 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +const ( + // MetricCollectorReportConditionTypeMetricsCollected indicates whether metrics have been successfully collected + MetricCollectorReportConditionTypeMetricsCollected = "MetricsCollected" + + // MetricCollectorReportConditionReasonCollectionFailed indicates metric collection failed + MetricCollectorReportConditionReasonCollectionFailed = "CollectionFailed" + + // MetricCollectorReportConditionReasonCollectionSucceeded indicates metric collection succeeded + MetricCollectorReportConditionReasonCollectionSucceeded = "CollectionSucceeded" +) + // +genclient // +kubebuilder:object:root=true // +kubebuilder:subresource:status diff --git a/approval-request-metric-collector/examples/membercluster/fleet_v1beta1_membercluster.yaml b/approval-request-metric-collector/examples/membercluster/fleet_v1beta1_membercluster.yaml index ceb7d7b..e3e1455 100644 --- a/approval-request-metric-collector/examples/membercluster/fleet_v1beta1_membercluster.yaml +++ b/approval-request-metric-collector/examples/membercluster/fleet_v1beta1_membercluster.yaml @@ -38,4 +38,4 @@ spec: name: fleet-member-agent-cluster-3 kind: ServiceAccount namespace: fleet-system - apiGroup: "" \ No newline at end of file + apiGroup: "" diff --git a/approval-request-metric-collector/examples/prometheus/prometheus-crp.yaml b/approval-request-metric-collector/examples/prometheus/prometheus-crp.yaml index 8243057..695d88b 100644 --- a/approval-request-metric-collector/examples/prometheus/prometheus-crp.yaml +++ b/approval-request-metric-collector/examples/prometheus/prometheus-crp.yaml @@ -19,4 +19,4 @@ spec: policy: placementType: PickAll strategy: - type: RollingUpdate \ No newline at end of file + type: RollingUpdate diff --git a/approval-request-metric-collector/examples/updateRun/example-csur.yaml b/approval-request-metric-collector/examples/updateRun/example-csur.yaml index 107f5fc..ece9a3b 100644 --- a/approval-request-metric-collector/examples/updateRun/example-csur.yaml +++ b/approval-request-metric-collector/examples/updateRun/example-csur.yaml @@ -7,4 +7,3 @@ spec: resourceSnapshotIndex: "0" stagedRolloutStrategyName: example-cluster-staged-strategy state: Started - \ No newline at end of file diff --git a/approval-request-metric-collector/examples/updateRun/example-csus.yaml b/approval-request-metric-collector/examples/updateRun/example-csus.yaml index 14db148..9b9a9a7 100644 --- a/approval-request-metric-collector/examples/updateRun/example-csus.yaml +++ b/approval-request-metric-collector/examples/updateRun/example-csus.yaml @@ -15,4 +15,4 @@ spec: matchLabels: environment: prod afterStageTasks: - - type: Approval \ No newline at end of file + - type: Approval diff --git a/approval-request-metric-collector/examples/updateRun/example-ns-only-crp.yaml b/approval-request-metric-collector/examples/updateRun/example-ns-only-crp.yaml index ddff3f0..54dd705 100644 --- a/approval-request-metric-collector/examples/updateRun/example-ns-only-crp.yaml +++ b/approval-request-metric-collector/examples/updateRun/example-ns-only-crp.yaml @@ -12,4 +12,4 @@ spec: policy: placementType: PickAll strategy: - type: RollingUpdate \ No newline at end of file + type: RollingUpdate diff --git a/approval-request-metric-collector/examples/updateRun/example-rp.yaml b/approval-request-metric-collector/examples/updateRun/example-rp.yaml index 0836868..214d1c3 100644 --- a/approval-request-metric-collector/examples/updateRun/example-rp.yaml +++ b/approval-request-metric-collector/examples/updateRun/example-rp.yaml @@ -12,4 +12,4 @@ spec: policy: placementType: PickAll strategy: - type: External \ No newline at end of file + type: External diff --git a/approval-request-metric-collector/examples/updateRun/example-sur.yaml b/approval-request-metric-collector/examples/updateRun/example-sur.yaml index e045585..bb1471f 100644 --- a/approval-request-metric-collector/examples/updateRun/example-sur.yaml +++ b/approval-request-metric-collector/examples/updateRun/example-sur.yaml @@ -8,4 +8,3 @@ spec: resourceSnapshotIndex: "0" stagedRolloutStrategyName: example-staged-strategy state: Started - \ No newline at end of file diff --git a/approval-request-metric-collector/examples/updateRun/example-sus.yaml b/approval-request-metric-collector/examples/updateRun/example-sus.yaml index 7b2798b..4505e29 100644 --- a/approval-request-metric-collector/examples/updateRun/example-sus.yaml +++ b/approval-request-metric-collector/examples/updateRun/example-sus.yaml @@ -16,4 +16,4 @@ spec: matchLabels: environment: prod afterStageTasks: - - type: Approval \ No newline at end of file + - type: Approval diff --git a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go index 5ddcb48..8b4cdc2 100644 --- a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go +++ b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go @@ -225,6 +225,10 @@ func (r *Reconciler) ensureMetricCollectorReports( }, }, Spec: localv1alpha1.MetricCollectorReportSpec{ + // PrometheusURL is a configurable spec field that could differ per cluster. + // For setup simplicity, we use a constant value pointing to the Prometheus service + // deployed via examples/prometheus/service.yaml and propagated to all clusters. + // This assumes Prometheus is deployed with the same service name/namespace on all member clusters. PrometheusURL: prometheusURL, }, } @@ -241,10 +245,7 @@ func (r *Reconciler) ensureMetricCollectorReports( if err := r.Client.Create(ctx, report); err != nil { return fmt.Errorf("failed to create MetricCollectorReport in %s: %w", reportNamespace, err) } - klog.V(2).InfoS("Created MetricCollectorReport", - "report", reportName, - "namespace", reportNamespace, - "cluster", clusterName) + klog.V(2).InfoS("Created MetricCollectorReport", "report", reportName, "namespace", reportNamespace, "cluster", clusterName) } else { return fmt.Errorf("failed to get MetricCollectorReport in %s: %w", reportNamespace, err) } @@ -255,10 +256,7 @@ func (r *Reconciler) ensureMetricCollectorReports( if err := r.Client.Update(ctx, existingReport); err != nil { return fmt.Errorf("failed to update MetricCollectorReport in %s: %w", reportNamespace, err) } - klog.V(2).InfoS("Updated MetricCollectorReport", - "report", reportName, - "namespace", reportNamespace, - "cluster", clusterName) + klog.V(2).InfoS("Updated MetricCollectorReport", "report", reportName, "namespace", reportNamespace, "cluster", clusterName) } } } @@ -289,9 +287,7 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( clusterWorkloadTracker := &localv1alpha1.ClusterStagedWorkloadTracker{} if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName}, clusterWorkloadTracker); err != nil { if errors.IsNotFound(err) { - klog.V(2).InfoS("ClusterStagedWorkloadTracker not found, skipping health check", - "approvalRequest", approvalReqRef, - "updateRun", updateRunName) + klog.V(2).InfoS("ClusterStagedWorkloadTracker not found, skipping health check", "approvalRequest", approvalReqRef, "updateRun", updateRunName) return nil } klog.ErrorS(err, "Failed to get ClusterStagedWorkloadTracker", "approvalRequest", approvalReqRef, "updateRun", updateRunName) @@ -299,19 +295,13 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( } workloads = clusterWorkloadTracker.Workloads workloadTrackerName = clusterWorkloadTracker.Name - klog.V(2).InfoS("Found ClusterStagedWorkloadTracker", - "approvalRequest", approvalReqRef, - "workloadTracker", workloadTrackerName, - "workloadCount", len(workloads)) + klog.V(2).InfoS("Found ClusterStagedWorkloadTracker", "approvalRequest", approvalReqRef, "workloadTracker", workloadTrackerName, "workloadCount", len(workloads)) } else { // Namespace-scoped: Get StagedWorkloadTracker with same name and namespace as StagedUpdateRun stagedWorkloadTracker := &localv1alpha1.StagedWorkloadTracker{} if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName, Namespace: obj.GetNamespace()}, stagedWorkloadTracker); err != nil { if errors.IsNotFound(err) { - klog.V(2).InfoS("StagedWorkloadTracker not found, skipping health check", - "approvalRequest", approvalReqRef, - "updateRun", updateRunName, - "namespace", obj.GetNamespace()) + klog.V(2).InfoS("StagedWorkloadTracker not found, skipping health check", "approvalRequest", approvalReqRef, "updateRun", updateRunName, "namespace", obj.GetNamespace()) return nil } klog.ErrorS(err, "Failed to get StagedWorkloadTracker", "approvalRequest", approvalReqRef, "updateRun", updateRunName) @@ -319,16 +309,11 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( } workloads = stagedWorkloadTracker.Workloads workloadTrackerName = stagedWorkloadTracker.Name - klog.V(2).InfoS("Found StagedWorkloadTracker", - "approvalRequest", approvalReqRef, - "workloadTracker", klog.KObj(stagedWorkloadTracker), - "workloadCount", len(workloads)) + klog.V(2).InfoS("Found StagedWorkloadTracker", "approvalRequest", approvalReqRef, "workloadTracker", klog.KObj(stagedWorkloadTracker), "workloadCount", len(workloads)) } if len(workloads) == 0 { - klog.V(2).InfoS("WorkloadTracker has no workloads defined, skipping health check", - "approvalRequest", approvalReqRef, - "workloadTracker", workloadTrackerName) + klog.V(2).InfoS("WorkloadTracker has no workloads defined, skipping health check", "approvalRequest", approvalReqRef, "workloadTracker", workloadTrackerName) return nil } @@ -342,11 +327,7 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( for _, clusterName := range clusterNames { reportNamespace := fmt.Sprintf(utils.NamespaceNameFormat, clusterName) - klog.V(2).InfoS("Checking MetricCollectorReport", - "approvalRequest", approvalReqRef, - "cluster", clusterName, - "reportName", metricCollectorName, - "reportNamespace", reportNamespace) + klog.V(2).InfoS("Checking MetricCollectorReport", "approvalRequest", approvalReqRef, "cluster", clusterName, "reportName", metricCollectorName, "reportNamespace", reportNamespace) // Get MetricCollectorReport for this cluster report := &localv1alpha1.MetricCollectorReport{} @@ -357,28 +338,16 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( if err != nil { if errors.IsNotFound(err) { - klog.V(2).InfoS("MetricCollectorReport not found yet", - "approvalRequest", approvalReqRef, - "cluster", clusterName, - "report", metricCollectorName, - "namespace", reportNamespace) + klog.V(2).InfoS("MetricCollectorReport not found yet", "approvalRequest", approvalReqRef, "cluster", clusterName, "report", metricCollectorName, "namespace", reportNamespace) allHealthy = false unhealthyDetails = append(unhealthyDetails, fmt.Sprintf("cluster %s: report not found", clusterName)) continue } - klog.ErrorS(err, "Failed to get MetricCollectorReport", - "approvalRequest", approvalReqRef, - "cluster", clusterName, - "report", metricCollectorName, - "namespace", reportNamespace) + klog.ErrorS(err, "Failed to get MetricCollectorReport", "approvalRequest", approvalReqRef, "cluster", clusterName, "report", metricCollectorName, "namespace", reportNamespace) return fmt.Errorf("failed to get MetricCollectorReport for cluster %s: %w", clusterName, err) } - klog.V(2).InfoS("Found MetricCollectorReport", - "approvalRequest", approvalReqRef, - "cluster", clusterName, - "collectedMetrics", len(report.Status.CollectedMetrics), - "workloadsMonitored", report.Status.WorkloadsMonitored) + klog.V(2).InfoS("Found MetricCollectorReport", "approvalRequest", approvalReqRef, "cluster", clusterName, "collectedMetrics", len(report.Status.CollectedMetrics), "workloadsMonitored", report.Status.WorkloadsMonitored) // Check if all workloads from WorkloadTracker are present and healthy for _, trackedWorkload := range workloads { @@ -390,31 +359,18 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( collectedMetric.WorkloadName == trackedWorkload.Name { found = true healthy = collectedMetric.Health - klog.V(3).InfoS("Workload metric found", - "approvalRequest", approvalReqRef, - "cluster", clusterName, - "workload", trackedWorkload.Name, - "namespace", trackedWorkload.Namespace, - "healthy", healthy) + klog.V(2).InfoS("Workload metric found", "approvalRequest", approvalReqRef, "cluster", clusterName, "workload", trackedWorkload.Name, "namespace", trackedWorkload.Namespace, "healthy", healthy) break } } if !found { - klog.V(2).InfoS("Workload not found in MetricCollectorReport", - "approvalRequest", approvalReqRef, - "cluster", clusterName, - "workload", trackedWorkload.Name, - "namespace", trackedWorkload.Namespace) + klog.V(2).InfoS("Workload not found in MetricCollectorReport", "approvalRequest", approvalReqRef, "cluster", clusterName, "workload", trackedWorkload.Name, "namespace", trackedWorkload.Namespace) allHealthy = false unhealthyDetails = append(unhealthyDetails, fmt.Sprintf("cluster %s: workload %s/%s not found", clusterName, trackedWorkload.Namespace, trackedWorkload.Name)) } else if !healthy { - klog.V(2).InfoS("Workload is not healthy", - "approvalRequest", approvalReqRef, - "cluster", clusterName, - "workload", trackedWorkload.Name, - "namespace", trackedWorkload.Namespace) + klog.V(2).InfoS("Workload is not healthy", "approvalRequest", approvalReqRef, "cluster", clusterName, "workload", trackedWorkload.Name, "namespace", trackedWorkload.Namespace) allHealthy = false unhealthyDetails = append(unhealthyDetails, fmt.Sprintf("cluster %s: workload %s/%s unhealthy", clusterName, trackedWorkload.Namespace, trackedWorkload.Name)) @@ -424,10 +380,7 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( // If all workloads are healthy across all clusters, approve the ApprovalRequest if allHealthy { - klog.InfoS("All workloads are healthy, approving ApprovalRequest", - "approvalRequest", approvalReqRef, - "clusters", clusterNames, - "workloads", len(workloads)) + klog.InfoS("All workloads are healthy, approving ApprovalRequest", "approvalRequest", approvalReqRef, "clusters", clusterNames, "workloads", len(workloads)) status := approvalReqObj.GetApprovalRequestStatus() approvedCond := meta.FindStatusCondition(status.Conditions, string(placementv1beta1.ApprovalRequestConditionApproved)) @@ -459,9 +412,7 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( } // Not all workloads are healthy yet, log details and return nil (reconcile will requeue) - klog.V(2).InfoS("Not all workloads are healthy yet", - "approvalRequest", approvalReqRef, - "unhealthyDetails", unhealthyDetails) + klog.V(2).InfoS("Not all workloads are healthy yet", "approvalRequest", approvalReqRef, "unhealthyDetails", unhealthyDetails) return nil } @@ -534,16 +485,10 @@ func (r *Reconciler) handleDelete(ctx context.Context, approvalReqObj placementv Namespace: reportNamespace, }, report); err == nil { if err := r.Client.Delete(ctx, report); err != nil && !errors.IsNotFound(err) { - klog.ErrorS(err, "Failed to delete MetricCollectorReport", - "report", reportName, - "namespace", reportNamespace, - "cluster", clusterName) + klog.ErrorS(err, "Failed to delete MetricCollectorReport", "report", reportName, "namespace", reportNamespace, "cluster", clusterName) return ctrl.Result{}, fmt.Errorf("failed to delete MetricCollectorReport in %s: %w", reportNamespace, err) } - klog.V(2).InfoS("Deleted MetricCollectorReport", - "report", reportName, - "namespace", reportNamespace, - "cluster", clusterName) + klog.V(2).InfoS("Deleted MetricCollectorReport", "report", reportName, "namespace", reportNamespace, "cluster", clusterName) } } diff --git a/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go b/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go index 01d7fb8..b6627cb 100644 --- a/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go +++ b/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go @@ -82,19 +82,19 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu if collectErr != nil { klog.ErrorS(collectErr, "Failed to collect metrics", "prometheusUrl", prometheusURL) meta.SetStatusCondition(&report.Status.Conditions, metav1.Condition{ - Type: "MetricsCollected", + Type: localv1alpha1.MetricCollectorReportConditionTypeMetricsCollected, Status: metav1.ConditionFalse, ObservedGeneration: report.Generation, - Reason: "CollectionFailed", + Reason: localv1alpha1.MetricCollectorReportConditionReasonCollectionFailed, Message: fmt.Sprintf("Failed to collect metrics: %v", collectErr), }) } else { klog.V(2).InfoS("Successfully collected metrics", "report", report.Name, "workloads", len(collectedMetrics)) meta.SetStatusCondition(&report.Status.Conditions, metav1.Condition{ - Type: "MetricsCollected", + Type: localv1alpha1.MetricCollectorReportConditionTypeMetricsCollected, Status: metav1.ConditionTrue, ObservedGeneration: report.Generation, - Reason: "MetricsCollected", + Reason: localv1alpha1.MetricCollectorReportConditionReasonCollectionSucceeded, Message: fmt.Sprintf("Successfully collected metrics from %d workloads", len(collectedMetrics)), }) } @@ -134,6 +134,12 @@ func (r *Reconciler) collectAllWorkloadMetrics(ctx context.Context, promClient P // Extract metrics from Prometheus result for _, res := range data.Result { + // Extract labels from the Prometheus metric + // The workload_health metric includes labels like: workload_health{namespace="test-ns",app="sample-app"} + // These labels come from Kubernetes pod labels and are added by Prometheus during scraping. + // The relabeling configuration is in examples/prometheus/configmap.yaml: + // - namespace: from __meta_kubernetes_namespace (pod's namespace) + // - app: from __meta_kubernetes_pod_label_app (pod's "app" label) namespace := res.Metric["namespace"] workloadName := res.Metric["app"] From d655a80f6f70602df7a201d42af2adfb70262f92 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Mon, 15 Dec 2025 11:44:50 -0800 Subject: [PATCH 17/38] minor fixes Signed-off-by: Arvind Thirumurugan --- .../apis/autoapprove/v1alpha1/doc.go | 2 +- .../v1alpha1/metriccollectorreport_types.go | 4 +-- .../controllers/approvalrequest/controller.go | 27 ++++++++----------- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/approval-request-metric-collector/apis/autoapprove/v1alpha1/doc.go b/approval-request-metric-collector/apis/autoapprove/v1alpha1/doc.go index 9d38394..22473a8 100644 --- a/approval-request-metric-collector/apis/autoapprove/v1alpha1/doc.go +++ b/approval-request-metric-collector/apis/autoapprove/v1alpha1/doc.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package v1alpha1 contains API Schema definitions for the placement v1beta1 API group +// Package v1alpha1 contains API Schema definitions for the autoapprove v1alpha1 API group // +kubebuilder:object:generate=true // +groupName=autoapprove.kubernetes-fleet.io package v1alpha1 diff --git a/approval-request-metric-collector/apis/autoapprove/v1alpha1/metriccollectorreport_types.go b/approval-request-metric-collector/apis/autoapprove/v1alpha1/metriccollectorreport_types.go index 18ff385..6063466 100644 --- a/approval-request-metric-collector/apis/autoapprove/v1alpha1/metriccollectorreport_types.go +++ b/approval-request-metric-collector/apis/autoapprove/v1alpha1/metriccollectorreport_types.go @@ -86,13 +86,13 @@ type MetricCollectorReportStatus struct { CollectedMetrics []WorkloadMetrics `json:"collectedMetrics,omitempty"` } -// WorkloadMetrics represents metrics collected from a single workload pod. +// WorkloadMetrics represents metrics collected from a single workload. type WorkloadMetrics struct { // Namespace of the workload. // +required Namespace string `json:"namespace"` - // WorkloadName from the workload_health metric label. + // Name of the workload. // +required WorkloadName string `json:"workloadName"` diff --git a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go index 8b4cdc2..27e549a 100644 --- a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go +++ b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go @@ -65,8 +65,6 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu }() var approvalReqObj placementv1beta1.ApprovalRequestObj - var isClusterScoped bool - // Check if request has a namespace to determine resource type if req.Namespace != "" { // Fetch namespaced ApprovalRequest @@ -80,7 +78,6 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return ctrl.Result{}, err } approvalReqObj = approvalReq - isClusterScoped = false } else { // Fetch cluster-scoped ClusterApprovalRequest clusterApprovalReq := &placementv1beta1.ClusterApprovalRequest{} @@ -93,19 +90,17 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return ctrl.Result{}, err } approvalReqObj = clusterApprovalReq - isClusterScoped = true } - return r.reconcileApprovalRequestObj(ctx, approvalReqObj, isClusterScoped) + return r.reconcileApprovalRequestObj(ctx, approvalReqObj) } // reconcileApprovalRequestObj reconciles an ApprovalRequestObj (either ApprovalRequest or ClusterApprovalRequest). -func (r *Reconciler) reconcileApprovalRequestObj(ctx context.Context, approvalReqObj placementv1beta1.ApprovalRequestObj, isClusterScoped bool) (ctrl.Result, error) { - obj := approvalReqObj.(client.Object) - approvalReqRef := klog.KObj(obj) +func (r *Reconciler) reconcileApprovalRequestObj(ctx context.Context, approvalReqObj placementv1beta1.ApprovalRequestObj) (ctrl.Result, error) { + approvalReqRef := klog.KObj(approvalReqObj) // Handle deletion - if !obj.GetDeletionTimestamp().IsZero() { + if !approvalReqObj.GetDeletionTimestamp().IsZero() { return r.handleDelete(ctx, approvalReqObj) } @@ -117,9 +112,9 @@ func (r *Reconciler) reconcileApprovalRequestObj(ctx context.Context, approvalRe } // Add finalizer if not present - if !controllerutil.ContainsFinalizer(obj, metricCollectorFinalizer) { - controllerutil.AddFinalizer(obj, metricCollectorFinalizer) - if err := r.Client.Update(ctx, obj); err != nil { + if !controllerutil.ContainsFinalizer(approvalReqObj, metricCollectorFinalizer) { + controllerutil.AddFinalizer(approvalReqObj, metricCollectorFinalizer) + if err := r.Client.Update(ctx, approvalReqObj); err != nil { klog.ErrorS(err, "Failed to add finalizer", "approvalRequest", approvalReqRef) return ctrl.Result{}, err } @@ -132,7 +127,7 @@ func (r *Reconciler) reconcileApprovalRequestObj(ctx context.Context, approvalRe stageName := spec.TargetStage var stageStatus *placementv1beta1.StageUpdatingStatus - if isClusterScoped { + if approvalReqObj.GetNamespace() == "" { updateRun := &placementv1beta1.ClusterStagedUpdateRun{} if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName}, updateRun); err != nil { klog.ErrorS(err, "Failed to get ClusterStagedUpdateRun", "approvalRequest", approvalReqRef, "updateRun", updateRunName) @@ -148,7 +143,7 @@ func (r *Reconciler) reconcileApprovalRequestObj(ctx context.Context, approvalRe } } else { updateRun := &placementv1beta1.StagedUpdateRun{} - if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName, Namespace: obj.GetNamespace()}, updateRun); err != nil { + if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName, Namespace: approvalReqObj.GetNamespace()}, updateRun); err != nil { klog.ErrorS(err, "Failed to get StagedUpdateRun", "approvalRequest", approvalReqRef, "updateRun", updateRunName) return ctrl.Result{}, err } @@ -182,7 +177,7 @@ func (r *Reconciler) reconcileApprovalRequestObj(ctx context.Context, approvalRe klog.V(2).InfoS("Found clusters in stage", "approvalRequest", approvalReqRef, "stage", stageName, "clusters", clusterNames) // Create or update MetricCollectorReport resources in fleet-member namespaces - if err := r.ensureMetricCollectorReports(ctx, obj, clusterNames, updateRunName, stageName); err != nil { + if err := r.ensureMetricCollectorReports(ctx, approvalReqObj, clusterNames, updateRunName, stageName); err != nil { klog.ErrorS(err, "Failed to ensure MetricCollectorReport resources", "approvalRequest", approvalReqRef) return ctrl.Result{}, err } @@ -202,7 +197,7 @@ func (r *Reconciler) reconcileApprovalRequestObj(ctx context.Context, approvalRe // ensureMetricCollectorReports creates MetricCollectorReport in each fleet-member-{clusterName} namespace func (r *Reconciler) ensureMetricCollectorReports( ctx context.Context, - approvalReq client.Object, + approvalReq placementv1beta1.ApprovalRequestObj, clusterNames []string, updateRunName, stageName string, ) error { From ab1e784a017f15ab0869a4ccbbd483286b95b48e Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Mon, 15 Dec 2025 11:54:59 -0800 Subject: [PATCH 18/38] comments for workload health tracking Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/README.md | 22 ++++++++++++++++++- .../controllers/approvalrequest/controller.go | 15 ++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index 59224a2..4c21382 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -57,8 +57,28 @@ This solution introduces three new CRDs that work together with KubeFleet's nati - Queries local Prometheus using URL from report spec with PromQL: `workload_health` - Prometheus returns metrics for all pods with `prometheus.io/scrape: "true"` annotation - Extracts workload health (1.0 = healthy, 0.0 = unhealthy) - - Updates the `MetricCollectorReport` status on hub with collected metrics + - Updates the `MetricCollectorReport` status on hub with **all** collected metrics + **Important Note on Multiple Pods:** When a workload (e.g., a Deployment) has multiple pods/replicas emitting health signals: + - The metric collector **collects all metrics** from Prometheus and stores them in the MetricCollectorReport + - If `sample-metric-app` has 3 replicas, the report will contain 3 separate `WorkloadMetrics` entries + - However, for simplicity, the approval-request-controller only evaluates the **first matching metric** when checking workload health + - This means if the first pod reports healthy, the workload is considered healthy, even if other pods report differently + - This simplified approach works well when all pods of a workload consistently report the same health status + - **Limitation:** If pods have different health states, only the first metric encountered is used for approval decisions + + **Customizing Health Aggregation Logic:** + To implement more sophisticated health checks (e.g., all pods must be healthy, or majority healthy): + 1. Edit `pkg/controllers/approvalrequest/controller.go` in the approval-request-controller + 2. Locate the health check loop (search for "Simplified health check using first matching metric") + 3. Remove the `break` statement that stops at the first match + 4. Collect all matching metrics for the workload into a slice + 5. Implement your aggregation logic: + - **All healthy:** Check that every metric has `Health == true` + - **Majority healthy:** Count healthy metrics and compare to total + - **Threshold-based:** Require N out of M pods to be healthy + 6. Rebuild and redeploy the approval-request-controller image + 4. **Health Evaluation** - Approval-request-controller monitors `MetricCollectorReports` from all stage clusters - Every 15 seconds, it: diff --git a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go index 27e549a..39ff5c8 100644 --- a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go +++ b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go @@ -349,13 +349,26 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( found := false healthy := false + // Important: Simplified health check using first matching metric + // When a workload has multiple pods/replicas, the MetricCollectorReport will contain + // multiple WorkloadMetrics entries (one per pod). This implementation uses the FIRST + // matching metric to determine workload health. + // + // Limitation: If different pods report different health states, only the first one + // encountered is used for approval decisions. + // + // To implement aggregation logic (e.g., all pods must be healthy, or majority healthy): + // 1. Remove the 'break' statement below + // 2. Collect all matching metrics into a slice + // 3. Apply your aggregation logic (e.g., allHealthy := all metrics have Health==true) + // 4. Set 'healthy' based on the aggregated result for _, collectedMetric := range report.Status.CollectedMetrics { if collectedMetric.Namespace == trackedWorkload.Namespace && collectedMetric.WorkloadName == trackedWorkload.Name { found = true healthy = collectedMetric.Health klog.V(2).InfoS("Workload metric found", "approvalRequest", approvalReqRef, "cluster", clusterName, "workload", trackedWorkload.Name, "namespace", trackedWorkload.Namespace, "healthy", healthy) - break + break // Remove this to collect all metrics for aggregation } } From 0f4a81c2e45748215ba69fa1f43f755cf6c793f0 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Mon, 15 Dec 2025 12:14:16 -0800 Subject: [PATCH 19/38] update image, minor fixes Signed-off-by: Arvind Thirumurugan --- .../approval-controller-metric-collector.png | Bin 117309 -> 0 bytes .../approval-request-metric-collector.png | Bin 0 -> 121219 bytes approval-request-metric-collector/README.md | 2 +- .../cmd/approvalrequestcontroller/main.go | 4 ++-- .../cmd/metriccollector/main.go | 6 +++--- .../controllers/approvalrequest/controller.go | 18 ++++++++-------- .../controllers/metriccollector/controller.go | 20 +++++++++--------- 7 files changed, 25 insertions(+), 25 deletions(-) delete mode 100644 approval-request-metric-collector/Images/approval-controller-metric-collector.png create mode 100644 approval-request-metric-collector/Images/approval-request-metric-collector.png diff --git a/approval-request-metric-collector/Images/approval-controller-metric-collector.png b/approval-request-metric-collector/Images/approval-controller-metric-collector.png deleted file mode 100644 index 65b1fc83714411577d7b9b218c1a5662c060e7e9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 117309 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
9Y4z_3OoVogFDK4bTlFV>ET4eF1mJ9| zwv*FoCh>%h^}FJK6Y5AYaSpM_aH=O>$x9>(SYOXNnU3zPbXA-_IyIfZHJ;buUC&Lv zHTkw+TZX${s-1YzC!nsOVs>R(^)tWCB=H2)DL635y4z0}5SXP~cUDffP5Ie{j-50SuSVE51&$uA>@m3z`vP|rorkbI<+$^ z7+IP`g!iaOG+OtRxc0SNqT9%v(wnYh!Z}ohl==2M=X?s!?DKTJ2 z%TrhJ^(?Id4X+cuB;uLU0K32;*Qiw66x_kvQ-xREJSRy@5t3bI<PSyaCJRIYMGMA0ZafmDVc+hu~p6vPn} z=QIn*fAYvd2RG){{OB-iDM|VE&Yzg#)Sm=rN;8~aIQB~5ZzBE%T%?%6KebOiauZxS zxGkK-R~D9!Sr?&>XV#CJ=(vr<2%+H=K)&mjzHA^HU&(fh}$ zwp@!{IVI@Fj_XAefQl0DPI7HjO(6E;SZ?j*ub0 zn|d%wzl5E|D6opuz@IQz3q0q0+yh((ufx3)iyk2uy%sn}%z(|#o)r0WoaY$ghkTc{ zthLsxCDX?ONcDUYbjd2{jlrKU{9P1-M3#4054pN`M~UO6?$6(06k21@O87Eof4$)$gLM2)OWdFlqW%i6u>brC_aT}N-fbvFI$(5UBJ#pyv<8gWoa{yUq^e=`#qM zt7M+5(ZxETj5Lk(9Zok@|Jk(%&kwYR?|puIryRzT8ka|B;=DaA3o1l7F9S& z@>sb}_BjoAsqzOD)SiFzqA1OcT1imd#LPp{ke!K49{q$Y3L9TbsEtV*yK~^)8{kiXYev_BPIn}a-77#>R`OD zQYG@Cvzc#`&o_ospWR&g+z@l}EE0y8L7QBnQprMhp>#>U3KKDG5`p4WGi zbx6g%X$8ed+S4RiC;#u_`_o}^=nZe&nM;31;9#R*?FhkwdE4E;mFWH7Eeh=x6Eq8O z9BUTerYbD`|Lz#VZ>+w(aUvw4b&~xvpt#4iR)TGsw`twsjX8opp92qpT5(@rUxXMP393fC1~`@nO}}>e|1H9QoH*P3T;$i*?lLGZQQS*Fi3196did zxSk;$a{q5@ZPVa&ZjMXOAuLY$Bj-PBRaXtpL18a)n3^}-X7~Sn+$uj@Sk{J%myq4* zP2#~%Xm^24H5ST;$leTP;UdU1?65{v-aFvN@V21YA>2QZwwFTNNBCy<4_@@xw2q^s z7=8BaSu?wt1}`aG_ab2;ME!zZrXS4tHhm%C!MAlo>we?o;r;$SzIk;V4-fA!vhoh$ YHRcKo{-!NafxqCXp3_jtS2hd!AAH!wDgXcg diff --git a/approval-request-metric-collector/Images/approval-request-metric-collector.png b/approval-request-metric-collector/Images/approval-request-metric-collector.png new file mode 100644 index 0000000000000000000000000000000000000000..4ffc280c56ad827100d422b70c4246b7269e1343 GIT binary patch literal 121219 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

4aTDG`LZF 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 literal 0 HcmV?d00001 diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index 4c21382..0bb5904 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -8,7 +8,7 @@ This directory contains two controllers: - **approval-request-controller**: Runs on the hub cluster to automate approval decisions for staged updates - **metric-collector**: Runs on member clusters to collect and report workload health metrics -![Approval Controller and Metric Collector Architecture](./images/approval-controller-metric-collector.png) +![Approval Controller and Metric Collector Architecture](./images/approval-request-metric-collector.png) ## How It Works diff --git a/approval-request-metric-collector/cmd/approvalrequestcontroller/main.go b/approval-request-metric-collector/cmd/approvalrequestcontroller/main.go index f35bba1..ef1004c 100644 --- a/approval-request-metric-collector/cmd/approvalrequestcontroller/main.go +++ b/approval-request-metric-collector/cmd/approvalrequestcontroller/main.go @@ -34,7 +34,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - localv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/apis/autoapprove/v1alpha1" + autoapprovev1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/apis/autoapprove/v1alpha1" approvalcontroller "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/pkg/controllers/approvalrequest" placementv1beta1 "github.com/kubefleet-dev/kubefleet/apis/placement/v1beta1" ) @@ -46,7 +46,7 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(placementv1beta1.AddToScheme(scheme)) - utilruntime.Must(localv1alpha1.AddToScheme(scheme)) + utilruntime.Must(autoapprovev1alpha1.AddToScheme(scheme)) utilruntime.Must(apiextensionsv1.AddToScheme(scheme)) } diff --git a/approval-request-metric-collector/cmd/metriccollector/main.go b/approval-request-metric-collector/cmd/metriccollector/main.go index 2a8020f..fc276ff 100644 --- a/approval-request-metric-collector/cmd/metriccollector/main.go +++ b/approval-request-metric-collector/cmd/metriccollector/main.go @@ -32,7 +32,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/healthz" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - placementv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/apis/autoapprove/v1alpha1" + autoapprovev1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/apis/autoapprove/v1alpha1" metriccollector "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/pkg/controllers/metriccollector" placementv1beta1 "github.com/kubefleet-dev/kubefleet/apis/placement/v1beta1" ) @@ -183,8 +183,8 @@ func Start(ctx context.Context, hubCfg *rest.Config, memberClusterName, hubNames if err := clientgoscheme.AddToScheme(scheme); err != nil { return fmt.Errorf("failed to add client-go scheme: %w", err) } - if err := placementv1alpha1.AddToScheme(scheme); err != nil { - return fmt.Errorf("failed to add placement v1alpha1 API to scheme: %w", err) + if err := autoapprovev1alpha1.AddToScheme(scheme); err != nil { + return fmt.Errorf("failed to add autoapprove v1alpha1 API to scheme: %w", err) } if err := placementv1beta1.AddToScheme(scheme); err != nil { return fmt.Errorf("failed to add placement v1beta1 API to scheme: %w", err) diff --git a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go index 39ff5c8..0f34bcb 100644 --- a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go +++ b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go @@ -35,7 +35,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/predicate" - localv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/apis/autoapprove/v1alpha1" + autoapprovev1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/apis/autoapprove/v1alpha1" placementv1beta1 "github.com/kubefleet-dev/kubefleet/apis/placement/v1beta1" "github.com/kubefleet-dev/kubefleet/pkg/utils" ) @@ -208,7 +208,7 @@ func (r *Reconciler) ensureMetricCollectorReports( for _, clusterName := range clusterNames { reportNamespace := fmt.Sprintf(utils.NamespaceNameFormat, clusterName) - report := &localv1alpha1.MetricCollectorReport{ + report := &autoapprovev1alpha1.MetricCollectorReport{ ObjectMeta: metav1.ObjectMeta{ Name: reportName, Namespace: reportNamespace, @@ -219,7 +219,7 @@ func (r *Reconciler) ensureMetricCollectorReports( "cluster": clusterName, }, }, - Spec: localv1alpha1.MetricCollectorReportSpec{ + Spec: autoapprovev1alpha1.MetricCollectorReportSpec{ // PrometheusURL is a configurable spec field that could differ per cluster. // For setup simplicity, we use a constant value pointing to the Prometheus service // deployed via examples/prometheus/service.yaml and propagated to all clusters. @@ -229,7 +229,7 @@ func (r *Reconciler) ensureMetricCollectorReports( } // Create or update MetricCollectorReport - existingReport := &localv1alpha1.MetricCollectorReport{} + existingReport := &autoapprovev1alpha1.MetricCollectorReport{} err := r.Client.Get(ctx, types.NamespacedName{ Name: reportName, Namespace: reportNamespace, @@ -274,12 +274,12 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( // Get the appropriate WorkloadTracker based on scope // The WorkloadTracker name matches the UpdateRun name - var workloads []localv1alpha1.WorkloadReference + var workloads []autoapprovev1alpha1.WorkloadReference var workloadTrackerName string if obj.GetNamespace() == "" { // Cluster-scoped: Get ClusterStagedWorkloadTracker with same name as ClusterStagedUpdateRun - clusterWorkloadTracker := &localv1alpha1.ClusterStagedWorkloadTracker{} + clusterWorkloadTracker := &autoapprovev1alpha1.ClusterStagedWorkloadTracker{} if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName}, clusterWorkloadTracker); err != nil { if errors.IsNotFound(err) { klog.V(2).InfoS("ClusterStagedWorkloadTracker not found, skipping health check", "approvalRequest", approvalReqRef, "updateRun", updateRunName) @@ -293,7 +293,7 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( klog.V(2).InfoS("Found ClusterStagedWorkloadTracker", "approvalRequest", approvalReqRef, "workloadTracker", workloadTrackerName, "workloadCount", len(workloads)) } else { // Namespace-scoped: Get StagedWorkloadTracker with same name and namespace as StagedUpdateRun - stagedWorkloadTracker := &localv1alpha1.StagedWorkloadTracker{} + stagedWorkloadTracker := &autoapprovev1alpha1.StagedWorkloadTracker{} if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName, Namespace: obj.GetNamespace()}, stagedWorkloadTracker); err != nil { if errors.IsNotFound(err) { klog.V(2).InfoS("StagedWorkloadTracker not found, skipping health check", "approvalRequest", approvalReqRef, "updateRun", updateRunName, "namespace", obj.GetNamespace()) @@ -325,7 +325,7 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( klog.V(2).InfoS("Checking MetricCollectorReport", "approvalRequest", approvalReqRef, "cluster", clusterName, "reportName", metricCollectorName, "reportNamespace", reportNamespace) // Get MetricCollectorReport for this cluster - report := &localv1alpha1.MetricCollectorReport{} + report := &autoapprovev1alpha1.MetricCollectorReport{} err := r.Client.Get(ctx, types.NamespacedName{ Name: metricCollectorName, Namespace: reportNamespace, @@ -486,7 +486,7 @@ func (r *Reconciler) handleDelete(ctx context.Context, approvalReqObj placementv // Delete MetricCollectorReport from each fleet-member namespace for _, clusterName := range clusterNames { reportNamespace := fmt.Sprintf(utils.NamespaceNameFormat, clusterName) - report := &localv1alpha1.MetricCollectorReport{} + report := &autoapprovev1alpha1.MetricCollectorReport{} if err := r.Client.Get(ctx, types.NamespacedName{ Name: reportName, diff --git a/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go b/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go index b6627cb..701c57a 100644 --- a/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go +++ b/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go @@ -30,7 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/predicate" - localv1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/apis/autoapprove/v1alpha1" + autoapprovev1alpha1 "github.com/kubefleet-dev/kubefleet-cookbook/approval-request-metric-collector/apis/autoapprove/v1alpha1" ) const ( @@ -54,7 +54,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu }() // 1. Get MetricCollectorReport from hub cluster - report := &localv1alpha1.MetricCollectorReport{} + report := &autoapprovev1alpha1.MetricCollectorReport{} if err := r.HubClient.Get(ctx, req.NamespacedName, report); err != nil { if errors.IsNotFound(err) { klog.V(2).InfoS("MetricCollectorReport not found, ignoring", "report", req.NamespacedName) @@ -82,19 +82,19 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu if collectErr != nil { klog.ErrorS(collectErr, "Failed to collect metrics", "prometheusUrl", prometheusURL) meta.SetStatusCondition(&report.Status.Conditions, metav1.Condition{ - Type: localv1alpha1.MetricCollectorReportConditionTypeMetricsCollected, + Type: autoapprovev1alpha1.MetricCollectorReportConditionTypeMetricsCollected, Status: metav1.ConditionFalse, ObservedGeneration: report.Generation, - Reason: localv1alpha1.MetricCollectorReportConditionReasonCollectionFailed, + Reason: autoapprovev1alpha1.MetricCollectorReportConditionReasonCollectionFailed, Message: fmt.Sprintf("Failed to collect metrics: %v", collectErr), }) } else { klog.V(2).InfoS("Successfully collected metrics", "report", report.Name, "workloads", len(collectedMetrics)) meta.SetStatusCondition(&report.Status.Conditions, metav1.Condition{ - Type: localv1alpha1.MetricCollectorReportConditionTypeMetricsCollected, + Type: autoapprovev1alpha1.MetricCollectorReportConditionTypeMetricsCollected, Status: metav1.ConditionTrue, ObservedGeneration: report.Generation, - Reason: localv1alpha1.MetricCollectorReportConditionReasonCollectionSucceeded, + Reason: autoapprovev1alpha1.MetricCollectorReportConditionReasonCollectionSucceeded, Message: fmt.Sprintf("Successfully collected metrics from %d workloads", len(collectedMetrics)), }) } @@ -109,8 +109,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu } // collectAllWorkloadMetrics queries Prometheus for all workload_health metrics -func (r *Reconciler) collectAllWorkloadMetrics(ctx context.Context, promClient PrometheusClient) ([]localv1alpha1.WorkloadMetrics, error) { - var collectedMetrics []localv1alpha1.WorkloadMetrics +func (r *Reconciler) collectAllWorkloadMetrics(ctx context.Context, promClient PrometheusClient) ([]autoapprovev1alpha1.WorkloadMetrics, error) { + var collectedMetrics []autoapprovev1alpha1.WorkloadMetrics // Query all workload_health metrics (no filtering) query := "workload_health" @@ -157,7 +157,7 @@ func (r *Reconciler) collectAllWorkloadMetrics(ctx context.Context, promClient P } } - workloadMetrics := localv1alpha1.WorkloadMetrics{ + workloadMetrics := autoapprovev1alpha1.WorkloadMetrics{ WorkloadName: workloadName, Namespace: namespace, Health: health > 0.5, // Convert float to bool: healthy if > 0.5 @@ -173,6 +173,6 @@ func (r *Reconciler) collectAllWorkloadMetrics(ctx context.Context, promClient P func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). Named("metriccollector-controller"). - For(&localv1alpha1.MetricCollectorReport{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + For(&autoapprovev1alpha1.MetricCollectorReport{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). Complete(r) } From 576dc2fb23541525a3371333be34983a390bcef3 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Mon, 15 Dec 2025 13:04:01 -0800 Subject: [PATCH 20/38] minor fixes Signed-off-by: Arvind Thirumurugan --- .../controllers/approvalrequest/controller.go | 6 +++++- .../pkg/controllers/metriccollector/collector.go | 16 ++++++++-------- .../controllers/metriccollector/controller.go | 14 ++++++-------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go index 0f34bcb..205a9e7 100644 --- a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go +++ b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go @@ -41,7 +41,7 @@ import ( ) const ( - // metricCollectorFinalizer is the finalizer added to ApprovalRequest objects for cleanup + // metricCollectorFinalizer is the finalizer added to ApprovalRequest objects for cleanup. metricCollectorFinalizer = "kubernetes-fleet.io/metric-collector-report-cleanup" // prometheusURL is the default Prometheus URL to use for all clusters @@ -205,6 +205,10 @@ func (r *Reconciler) ensureMetricCollectorReports( reportName := fmt.Sprintf("mc-%s-%s", updateRunName, stageName) // Create MetricCollectorReport in each fleet-member namespace + // Note: We cannot use owner references here because Kubernetes does not allow cross-namespace + // owner references. The ApprovalRequest (in one namespace or cluster-scoped) cannot be set as + // the owner of MetricCollectorReports in different fleet-member-* namespaces. Instead, we use + // a finalizer on the ApprovalRequest to ensure proper cleanup when it's deleted. for _, clusterName := range clusterNames { reportNamespace := fmt.Sprintf(utils.NamespaceNameFormat, clusterName) diff --git a/approval-request-metric-collector/pkg/controllers/metriccollector/collector.go b/approval-request-metric-collector/pkg/controllers/metriccollector/collector.go index ef3cde0..6952e27 100644 --- a/approval-request-metric-collector/pkg/controllers/metriccollector/collector.go +++ b/approval-request-metric-collector/pkg/controllers/metriccollector/collector.go @@ -32,7 +32,7 @@ import ( // PrometheusClient is the interface for querying Prometheus type PrometheusClient interface { - Query(ctx context.Context, query string) (interface{}, error) + Query(ctx context.Context, query string) (PrometheusData, error) } // prometheusClient implements PrometheusClient for querying Prometheus API @@ -56,7 +56,7 @@ func NewPrometheusClient(baseURL, authType string, authSecret *corev1.Secret) Pr } // Query executes a PromQL query against Prometheus API -func (c *prometheusClient) Query(ctx context.Context, query string) (interface{}, error) { +func (c *prometheusClient) Query(ctx context.Context, query string) (PrometheusData, error) { // Build query URL queryURL := fmt.Sprintf("%s/api/v1/query", strings.TrimSuffix(c.baseURL, "/")) params := url.Values{} @@ -66,34 +66,34 @@ func (c *prometheusClient) Query(ctx context.Context, query string) (interface{} // Create request req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return PrometheusData{}, fmt.Errorf("failed to create request: %w", err) } // Add authentication if err := c.addAuth(req); err != nil { - return nil, fmt.Errorf("failed to add authentication: %w", err) + return PrometheusData{}, fmt.Errorf("failed to add authentication: %w", err) } // Execute request resp, err := c.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("failed to query Prometheus: %w", err) + return PrometheusData{}, fmt.Errorf("failed to query Prometheus: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("Prometheus query failed with status %d: %s", resp.StatusCode, string(body)) + return PrometheusData{}, fmt.Errorf("Prometheus query failed with status %d: %s", resp.StatusCode, string(body)) } // Parse response var result PrometheusResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + return PrometheusData{}, fmt.Errorf("failed to decode response: %w", err) } if result.Status != "success" { - return nil, fmt.Errorf("Prometheus query failed: %s", result.Error) + return PrometheusData{}, fmt.Errorf("Prometheus query failed: %s", result.Error) } return result.Data, nil diff --git a/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go b/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go index 701c57a..fc492c0 100644 --- a/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go +++ b/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go @@ -115,18 +115,12 @@ func (r *Reconciler) collectAllWorkloadMetrics(ctx context.Context, promClient P // Query all workload_health metrics (no filtering) query := "workload_health" - result, err := promClient.Query(ctx, query) + data, err := promClient.Query(ctx, query) if err != nil { klog.ErrorS(err, "Failed to query Prometheus for workload_health metrics") return nil, err } - // Parse Prometheus response - data, ok := result.(PrometheusData) - if !ok { - return nil, fmt.Errorf("invalid Prometheus response type") - } - if len(data.Result) == 0 { klog.V(4).InfoS("No workload_health metrics found in Prometheus") return collectedMetrics, nil @@ -157,10 +151,14 @@ func (r *Reconciler) collectAllWorkloadMetrics(ctx context.Context, promClient P } } + // Convert float to bool: workload is healthy if metric value >= 1.0 + // We use >= instead of == to handle floating point precision issues that can occur + // during JSON serialization/deserialization. The metric app emits 1.0 for healthy + // and 0.0 for unhealthy, so >= 1.0 safely distinguishes between the two states. workloadMetrics := autoapprovev1alpha1.WorkloadMetrics{ WorkloadName: workloadName, Namespace: namespace, - Health: health > 0.5, // Convert float to bool: healthy if > 0.5 + Health: health >= 1.0, } collectedMetrics = append(collectedMetrics, workloadMetrics) } From bd005d70f573744b6a6469952bec2416a49a6f6d Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Mon, 15 Dec 2025 15:07:40 -0800 Subject: [PATCH 21/38] make commands for docker Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/Makefile | 106 ++++++++++++++++---- approval-request-metric-collector/README.md | 32 ++---- 2 files changed, 93 insertions(+), 45 deletions(-) diff --git a/approval-request-metric-collector/Makefile b/approval-request-metric-collector/Makefile index fc7adf0..822ecf7 100644 --- a/approval-request-metric-collector/Makefile +++ b/approval-request-metric-collector/Makefile @@ -1,9 +1,8 @@ -# Makefile for ApprovalRequest Controller +# Makefile for ApprovalRequest Controller and Metric Collector # Image settings -IMAGE_NAME ?= approval-request-controller -IMAGE_TAG ?= latest REGISTRY ?= +TAG ?= latest # Build settings GOOS ?= $(shell go env GOOS) @@ -29,19 +28,41 @@ generate: ## Generate DeepCopy code ##@ Build -.PHONY: docker-build -docker-build: ## Build docker image +.PHONY: docker-build-approval-controller +docker-build-approval-controller: ## Build approval-request-controller docker image docker buildx build \ --file docker/approval-request-controller.Dockerfile \ - --output=type=docker \ + --tag $(REGISTRY)/approval-request-controller:$(TAG) \ --platform=linux/$(GOARCH) \ --build-arg GOARCH=$(GOARCH) \ - --tag $(IMAGE_NAME):$(IMAGE_TAG) \ + --push \ . -.PHONY: docker-push -docker-push: ## Push docker image - docker push $(REGISTRY)$(IMAGE_NAME):$(IMAGE_TAG) +.PHONY: docker-build-metric-collector +docker-build-metric-collector: ## Build metric-collector docker image + docker buildx build \ + --file docker/metric-collector.Dockerfile \ + --tag $(REGISTRY)/metric-collector:$(TAG) \ + --platform=linux/$(GOARCH) \ + --build-arg GOARCH=$(GOARCH) \ + --push \ + . + +.PHONY: docker-build-metric-app +docker-build-metric-app: ## Build metric-app docker image + docker buildx build \ + --file docker/metric-app.Dockerfile \ + --tag $(REGISTRY)/metric-app:$(TAG) \ + --platform=linux/$(GOARCH) \ + --build-arg GOARCH=$(GOARCH) \ + --push \ + . + +.PHONY: docker-build-all +docker-build-all: docker-build-approval-controller docker-build-metric-collector docker-build-metric-app ## Build and push all docker images + +.PHONY: docker-build +docker-build: docker-build-all ## Alias for docker-build-all ##@ Development @@ -51,27 +72,68 @@ run: ## Run controller locally ##@ Deployment -.PHONY: install -install: ## Install helm chart +.PHONY: install-approval-controller +install-approval-controller: ## Install approval-request-controller helm chart helm install approval-request-controller ./charts/approval-request-controller \ --namespace fleet-system \ --create-namespace \ - --set image.repository=$(IMAGE_NAME) \ - --set image.tag=$(IMAGE_TAG) + --set image.repository=$(REGISTRY)/approval-request-controller \ + --set image.tag=$(TAG) -.PHONY: upgrade -upgrade: ## Upgrade helm chart +.PHONY: upgrade-approval-controller +upgrade-approval-controller: ## Upgrade approval-request-controller helm chart helm upgrade approval-request-controller ./charts/approval-request-controller \ --namespace fleet-system \ - --set image.repository=$(IMAGE_NAME) \ - --set image.tag=$(IMAGE_TAG) + --set image.repository=$(REGISTRY)/approval-request-controller \ + --set image.tag=$(TAG) -.PHONY: uninstall -uninstall: ## Uninstall helm chart +.PHONY: uninstall-approval-controller +uninstall-approval-controller: ## Uninstall approval-request-controller helm chart helm uninstall approval-request-controller --namespace fleet-system +.PHONY: install +install: install-approval-controller ## Alias for install-approval-controller + +.PHONY: upgrade +upgrade: upgrade-approval-controller ## Alias for upgrade-approval-controller + +.PHONY: uninstall +uninstall: uninstall-approval-controller ## Alias for uninstall-approval-controller + ##@ Kind +.PHONY: kind-load-approval-controller +kind-load-approval-controller: ## Build and load approval-request-controller image into kind cluster + docker buildx build \ + --file docker/approval-request-controller.Dockerfile \ + --tag approval-request-controller:$(TAG) \ + --output=type=docker \ + --platform=linux/$(GOARCH) \ + --build-arg GOARCH=$(GOARCH) \ + . + kind load docker-image approval-request-controller:$(TAG) --name hub + +.PHONY: kind-load-metric-collector +kind-load-metric-collector: ## Build and load metric-collector image into kind cluster + docker buildx build \ + --file docker/metric-collector.Dockerfile \ + --tag metric-collector:$(TAG) \ + --output=type=docker \ + --platform=linux/$(GOARCH) \ + --build-arg GOARCH=$(GOARCH) \ + . + kind load docker-image metric-collector:$(TAG) --name hub + +.PHONY: kind-load-metric-app +kind-load-metric-app: ## Build and load metric-app image into kind cluster + docker buildx build \ + --file docker/metric-app.Dockerfile \ + --tag metric-app:$(TAG) \ + --output=type=docker \ + --platform=linux/$(GOARCH) \ + --build-arg GOARCH=$(GOARCH) \ + . + kind load docker-image metric-app:$(TAG) --name hub + .PHONY: kind-load -kind-load: docker-build ## Build and load image into kind cluster - kind load docker-image $(IMAGE_NAME):$(IMAGE_TAG) --name hub +kind-load: kind-load-approval-controller kind-load-metric-collector kind-load-metric-app ## Build and load all images into kind cluster diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index 0bb5904..8cc513d 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -144,37 +144,23 @@ export TAG="latest" cd approval-request-metric-collector ``` -Build and push the approval-request-controller image: +Build and push all images at once: ```bash -docker buildx build \ - --file docker/approval-request-controller.Dockerfile \ - --tag ${REGISTRY}/approval-request-controller:${TAG} \ - --platform=linux/amd64 \ - --push \ - . +make docker-build-all ``` -Build and push the metric-collector image: +Or build individual images: ```bash -docker buildx build \ - --file docker/metric-collector.Dockerfile \ - --tag ${REGISTRY}/metric-collector:${TAG} \ - --platform=linux/amd64 \ - --push \ - . -``` +# Build and push approval-request-controller image +make docker-build-approval-controller -Build and push the metric-app image: +# Build and push metric-collector image +make docker-build-metric-collector -```bash -docker buildx build \ - --file docker/metric-app.Dockerfile \ - --tag ${REGISTRY}/metric-app:${TAG} \ - --platform=linux/amd64 \ - --push \ - . +# Build and push metric-app image +make docker-build-metric-app ``` ### 3. Verify Images in ACR From 7cbe0e5881480129b602509cd861776a543daac7 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Mon, 15 Dec 2025 15:19:00 -0800 Subject: [PATCH 22/38] minor update to README Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/README.md | 42 ++++++--------------- 1 file changed, 12 insertions(+), 30 deletions(-) diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index 8cc513d..ed44fee 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -336,42 +336,24 @@ Before starting this tutorial, ensure you have: ### 1. Label Member Clusters for Staged Rollout -The staged rollout uses labels to determine which clusters belong to each stage. Label your three member clusters appropriately: +The staged rollout uses labels to determine which clusters belong to each stage. Ensure your member clusters have the following labels: -```bash -# Switch to hub cluster context -kubectl config use-context - -# Label the first cluster for staging (Stage 1) -# Replace with your actual cluster name (e.g., kind-cluster-1, aks-cluster-1, etc.) -kubectl label membercluster environment=staging --overwrite -kubectl label membercluster kubernetes-fleet.io/cluster-name= --overwrite +**Stage 1 (staging)** - One cluster: +- `environment=staging` -# Label the second cluster for production (Stage 2) -# Replace with your actual cluster name -kubectl label membercluster environment=prod --overwrite -kubectl label membercluster kubernetes-fleet.io/cluster-name= --overwrite +**Stage 2 (prod)** - Two or more clusters: +- `environment=prod` -# Label the third cluster for production (Stage 2) -# Replace with your actual cluster name -kubectl label membercluster environment=prod --overwrite -kubectl label membercluster kubernetes-fleet.io/cluster-name= --overwrite - -# Verify the labels are applied -kubectl get membercluster --show-labels +Expected cluster configuration: ``` - -Expected output: -```bash -NAME JOINED AGE LABELS -cluster-1 True 5m environment=staging,kubernetes-fleet.io/cluster-name=cluster-1,... -cluster-2 True 5m environment=prod,kubernetes-fleet.io/cluster-name=cluster-2,... -cluster-3 True 5m environment=prod,kubernetes-fleet.io/cluster-name=cluster-3,... +cluster-1: environment=staging +cluster-2: environment=prod +cluster-3: environment=prod ``` -These labels are used by the `StagedUpdateStrategy` to select clusters for each stage: -- **Stage 1 (staging)**: Selects clusters with `environment=staging` → cluster-1 -- **Stage 2 (prod)**: Selects clusters with `environment=prod` → cluster-2 and cluster-3 +The `StagedUpdateStrategy` uses these labels to select clusters for each stage: +- **Stage 1 (staging)**: Selects clusters with `environment=staging` +- **Stage 2 (prod)**: Selects clusters with `environment=prod` ### 2. Deploy Prometheus From fde0a4424dbbc76364d60f88fd3dcb55bf9fe66e Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Mon, 15 Dec 2025 15:47:21 -0800 Subject: [PATCH 23/38] minor fixes Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/README.md | 28 ++++++------------- .../sample-metric-app/sample-metric-app.yaml | 4 +-- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index ed44fee..3fc218d 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -133,6 +133,8 @@ az acr update --name myfleetacr --anonymous-pull-enabled From the `az acr create` output, note down the login server (e.g., `myfleetacr.azurecr.io`). +> Note: Users can also create their own registry to push their docker images, it doesn't have to be ACR. + ### 2. Build and Push Images Export registry and tag variables: @@ -334,6 +336,10 @@ Before starting this tutorial, ensure you have: - Three member clusters joined to the hub cluster - kubectl configured with access to the hub cluster context +This can be achieved through a number of ways, +- https://kubefleet.dev/docs/getting-started/ +- https://learn.microsoft.com/en-us/azure/kubernetes-fleet/quickstart-create-fleet-and-members-portal + ### 1. Label Member Clusters for Staged Rollout The staged rollout uses labels to determine which clusters belong to each stage. Ensure your member clusters have the following labels: @@ -387,28 +393,10 @@ Create the test namespace and deploy the sample application: # Create test namespace kubectl create ns test-ns -# Deploy sample metric app -# This creates a Deployment with a simple Go app that exposes a /metrics endpoint -# The app reports workload_health=1.0 (healthy) by default -# Note: Update the image reference in the YAML to use your ACR registry -# Change "image: metric-app:local" to "image: ${REGISTRY}/metric-app:latest" -# You can use sed to update it: -sed "s|image: metric-app:local|image: ${REGISTRY}/metric-app:latest|" \ - ./examples/sample-metric-app/sample-metric-app.yaml | kubectl apply -f - -``` - -**Alternative:** Manually edit `./examples/sample-metric-app/sample-metric-app.yaml` to change: -```yaml -image: metric-app:local -imagePullPolicy: IfNotPresent -``` -to: -```yaml -image: myfleetacr.azurecr.io/metric-app:latest -imagePullPolicy: Always -``` Then apply: `kubectl apply -f ./examples/sample-metric-app/` +> Note: If users are using a different REGISTRY, TAG variables from the setup, please update examples/sample-metric-app/sample-metric-app.yaml accordingly. + ### 4. Install Approval Request Controller (Hub Cluster) Install the approval request controller on the hub cluster using the ACR registry: diff --git a/approval-request-metric-collector/examples/sample-metric-app/sample-metric-app.yaml b/approval-request-metric-collector/examples/sample-metric-app/sample-metric-app.yaml index 5deb993..c2dcbb7 100644 --- a/approval-request-metric-collector/examples/sample-metric-app/sample-metric-app.yaml +++ b/approval-request-metric-collector/examples/sample-metric-app/sample-metric-app.yaml @@ -21,7 +21,7 @@ spec: spec: containers: - name: metric-app - image: metric-app:local - imagePullPolicy: IfNotPresent + image: myfleetacr.azurecr.io/metric-app:latest + imagePullPolicy: Always ports: - containerPort: 8080 From ddc00e92f6ca175a6dbebeb4d6c3d5f5b8983a50 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Mon, 15 Dec 2025 16:57:50 -0800 Subject: [PATCH 24/38] simplify hubconfig, runnning scripts Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/README.md | 16 +-- .../templates/deployment.yaml | 47 +------- .../charts/metric-collector/values.yaml | 22 +--- .../cmd/metriccollector/main.go | 104 ++++-------------- .../scripts/install-on-hub.sh | 4 + .../scripts/install-on-member.sh | 6 +- 6 files changed, 35 insertions(+), 164 deletions(-) diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index 3fc218d..95c7517 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -405,10 +405,8 @@ Install the approval request controller on the hub cluster using the ACR registr # Set your ACR registry name export REGISTRY="myfleetacr.azurecr.io" -# Navigate to scripts directory and run the installation script -cd scripts -./install-on-hub.sh ${REGISTRY} -cd .. +# Run the installation script +scripts/install-on-hub.sh ${REGISTRY} ``` The script performs the following: @@ -443,19 +441,13 @@ kubectl apply -f ./examples/workloadtracker/stagedworkloadtracker.yaml Install the metric collector on all member clusters using the ACR registry: ```bash -# Navigate to scripts directory -cd scripts - # Run the installation script for all member clusters # Replace with your hub cluster name (e.g., kind-hub, hub) # Replace , , with your actual cluster names -./install-on-member.sh ${REGISTRY} +scripts/install-on-member.sh ${REGISTRY} # Example: -# ./install-on-member.sh ${REGISTRY} kind-hub kind-cluster-1 kind-cluster-2 kind-cluster-3 - -# Return to parent directory -cd .. +# scripts/install-on-member.sh ${REGISTRY} kind-hub kind-cluster-1 kind-cluster-2 kind-cluster-3 ``` 4. Configures connection to hub API server and local Prometheus ```bash diff --git a/approval-request-metric-collector/charts/metric-collector/templates/deployment.yaml b/approval-request-metric-collector/charts/metric-collector/templates/deployment.yaml index 1bff73a..a28a095 100644 --- a/approval-request-metric-collector/charts/metric-collector/templates/deployment.yaml +++ b/approval-request-metric-collector/charts/metric-collector/templates/deployment.yaml @@ -54,47 +54,14 @@ spec: - name: PROMETHEUS_URL value: {{ .Values.prometheus.url | quote }} - {{- if .Values.hubCluster.customHeader }} - - name: HUB_KUBE_HEADER - value: {{ .Values.hubCluster.customHeader | quote }} - {{- end }} - - {{- if .Values.hubCluster.auth.useCertificateAuth }} - # Certificate-based authentication - - name: IDENTITY_CERT - value: /etc/hub-certs/{{ .Values.hubCluster.auth.certSecretKey }} - - name: IDENTITY_KEY - value: /etc/hub-certs/{{ .Values.hubCluster.auth.keySecretKey }} - {{- else }} - # Token-based authentication + # Token-based authentication (path to token file) - name: CONFIG_PATH value: /var/run/secrets/hub/{{ .Values.hubCluster.auth.tokenSecretKey }} - {{- end }} - - {{- if .Values.hubCluster.tls.insecure }} - - name: TLS_INSECURE - value: "true" - {{- else if .Values.hubCluster.tls.caSecretName }} - - name: HUB_CERTIFICATE_AUTHORITY - value: /etc/hub-ca/{{ .Values.hubCluster.tls.caSecretKey }} - {{- end }} volumeMounts: - {{- if .Values.hubCluster.auth.useCertificateAuth }} - - name: hub-certs - mountPath: /etc/hub-certs - readOnly: true - {{- else }} - name: hub-token mountPath: /var/run/secrets/hub readOnly: true - {{- end }} - - {{- if and (not .Values.hubCluster.tls.insecure) .Values.hubCluster.tls.caSecretName }} - - name: hub-ca - mountPath: /etc/hub-ca - readOnly: true - {{- end }} ports: {{- if .Values.metrics.enabled }} @@ -128,21 +95,9 @@ spec: {{- toYaml .Values.controller.resources | nindent 10 }} volumes: - {{- if .Values.hubCluster.auth.useCertificateAuth }} - - name: hub-certs - secret: - secretName: {{ .Values.hubCluster.auth.certSecretName }} - {{- else }} - name: hub-token secret: secretName: {{ .Values.hubCluster.auth.tokenSecretName }} - {{- end }} - - {{- if and (not .Values.hubCluster.tls.insecure) .Values.hubCluster.tls.caSecretName }} - - name: hub-ca - secret: - secretName: {{ .Values.hubCluster.tls.caSecretName }} - {{- end }} {{- with .Values.controller.nodeSelector }} nodeSelector: diff --git a/approval-request-metric-collector/charts/metric-collector/values.yaml b/approval-request-metric-collector/charts/metric-collector/values.yaml index 8af6dd9..18f09a0 100644 --- a/approval-request-metric-collector/charts/metric-collector/values.yaml +++ b/approval-request-metric-collector/charts/metric-collector/values.yaml @@ -34,34 +34,16 @@ hubCluster: # These resources must be applied on the hub cluster createRBAC: false - # Authentication configuration + # Token-based authentication configuration auth: - # Token-based authentication (default) - useTokenAuth: true + # Token secret details tokenSecretName: "hub-token" tokenSecretKey: "token" - # Certificate-based authentication - useCertificateAuth: false - certSecretName: "" - certSecretKey: "tls.crt" - keySecretKey: "tls.key" - # ServiceAccount details for RBAC binding on hub cluster # Leave empty to use the default serviceAccount from this chart serviceAccountName: "" serviceAccountNamespace: "" - - # TLS configuration - tls: - # Skip TLS verification (not recommended for production) - insecure: false - # CA certificate for hub cluster - caSecretName: "" - caSecretKey: "ca.crt" - - # Custom header for hub requests (optional) - customHeader: "" # Prometheus configuration prometheus: diff --git a/approval-request-metric-collector/cmd/metriccollector/main.go b/approval-request-metric-collector/cmd/metriccollector/main.go index fc276ff..c647985 100644 --- a/approval-request-metric-collector/cmd/metriccollector/main.go +++ b/approval-request-metric-collector/cmd/metriccollector/main.go @@ -20,7 +20,6 @@ import ( "context" "flag" "fmt" - "net/http" "os" "k8s.io/apimachinery/pkg/runtime" @@ -79,101 +78,36 @@ func main() { } } -// buildHubConfig creates hub cluster config from environment variables -// following the same pattern as member-agent +// buildHubConfig creates hub cluster config using token-based authentication +// with TLS verification disabled (insecure mode) func buildHubConfig() (*rest.Config, error) { hubURL := os.Getenv("HUB_SERVER_URL") if hubURL == "" { return nil, fmt.Errorf("HUB_SERVER_URL environment variable not set") } - // Check for custom headers - customHeader := os.Getenv("HUB_KUBE_HEADER") - - // Check TLS insecure flag - tlsInsecure := os.Getenv("TLS_INSECURE") == "true" - - // Initialize hub config - hubConfig := &rest.Config{ - Host: hubURL, - TLSClientConfig: rest.TLSClientConfig{ - Insecure: tlsInsecure, - }, - WrapTransport: func(rt http.RoundTripper) http.RoundTripper { - if customHeader != "" { - return &customHeaderTransport{ - Base: rt, - Header: customHeader, - } - } - return rt - }, - } - - // Check for certificate-based authentication - identityKey := os.Getenv("IDENTITY_KEY") - identityCert := os.Getenv("IDENTITY_CERT") - if identityKey != "" && identityCert != "" { - klog.InfoS("Using certificate-based authentication for hub cluster") - // Read certificate files - certData, err := os.ReadFile(identityCert) - if err != nil { - return nil, fmt.Errorf("failed to read identity cert: %w", err) - } - keyData, err := os.ReadFile(identityKey) - if err != nil { - return nil, fmt.Errorf("failed to read identity key: %w", err) - } - hubConfig.CertData = certData - hubConfig.KeyData = keyData - } else { - // Token-based authentication - klog.InfoS("Using token-based authentication for hub cluster") - configPath := os.Getenv("CONFIG_PATH") - if configPath == "" { - configPath = "/var/run/secrets/hub/token" - } - tokenData, err := os.ReadFile(configPath) - if err != nil { - return nil, fmt.Errorf("failed to read hub token from %s: %w", configPath, err) - } - hubConfig.BearerToken = string(tokenData) + // Get token path (defaults to /var/run/secrets/hub/token) + configPath := os.Getenv("CONFIG_PATH") + if configPath == "" { + configPath = "/var/run/secrets/hub/token" } - // Handle CA certificate - caBundle := os.Getenv("CA_BUNDLE") - hubCA := os.Getenv("HUB_CERTIFICATE_AUTHORITY") - if caBundle != "" { - klog.InfoS("Using CA bundle for hub cluster TLS") - caData, err := os.ReadFile(caBundle) - if err != nil { - return nil, fmt.Errorf("failed to read CA bundle: %w", err) - } - hubConfig.CAData = caData - } else if hubCA != "" { - klog.InfoS("Using hub certificate authority for hub cluster TLS") - caData, err := os.ReadFile(hubCA) - if err != nil { - return nil, fmt.Errorf("failed to read hub CA: %w", err) - } - hubConfig.CAData = caData - } else { - // If no CA specified, try to load system CA pool - klog.InfoS("No CA specified, using insecure connection or system CA pool") + // Read token file + tokenData, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read hub token from %s: %w", configPath, err) } - return hubConfig, nil -} - -// customHeaderTransport adds custom headers to requests -type customHeaderTransport struct { - Base http.RoundTripper - Header string -} + klog.InfoS("Using token-based authentication with insecure TLS for hub cluster") -func (t *customHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Add("X-Custom-Header", t.Header) - return t.Base.RoundTrip(req) + // Create hub config with token auth and insecure TLS + return &rest.Config{ + Host: hubURL, + BearerToken: string(tokenData), + TLSClientConfig: rest.TLSClientConfig{ + Insecure: true, + }, + }, nil } // Start starts the controller with hub cluster connection diff --git a/approval-request-metric-collector/scripts/install-on-hub.sh b/approval-request-metric-collector/scripts/install-on-hub.sh index d2750e3..de7dbce 100755 --- a/approval-request-metric-collector/scripts/install-on-hub.sh +++ b/approval-request-metric-collector/scripts/install-on-hub.sh @@ -1,6 +1,10 @@ #!/bin/bash set -e +# Detect script directory to support execution from multiple locations +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + # Usage: ./install-on-hub.sh # Example: ./install-on-hub.sh arvindtestacr.azurecr.io kind-hub diff --git a/approval-request-metric-collector/scripts/install-on-member.sh b/approval-request-metric-collector/scripts/install-on-member.sh index fe94abd..8083630 100755 --- a/approval-request-metric-collector/scripts/install-on-member.sh +++ b/approval-request-metric-collector/scripts/install-on-member.sh @@ -1,6 +1,10 @@ #!/bin/bash set -e +# Detect script directory to support execution from multiple locations +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + # Usage: ./install-on-member.sh [member-cluster-2] [member-cluster-3] ... # Example: ./install-on-member.sh arvindtestacr.azurecr.io kind-hub kind-cluster-1 kind-cluster-2 kind-cluster-3 @@ -194,7 +198,7 @@ EOF # Step 4: Install helm chart on member cluster (includes CRD) echo "Step 4: Installing helm chart on member cluster..." - helm upgrade --install metric-collector ../charts/metric-collector \ + helm upgrade --install metric-collector ${REPO_ROOT}/charts/metric-collector \ --kube-context=${MEMBER_CONTEXT} \ --namespace ${MEMBER_NAMESPACE} \ --set memberCluster.name=${MEMBER_CLUSTER_NAME} \ From 1578be0906b3a951efd01c19ffe8fe254a099251 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Mon, 15 Dec 2025 17:53:32 -0800 Subject: [PATCH 25/38] address minor comments Signed-off-by: Arvind Thirumurugan --- .../controllers/approvalrequest/controller.go | 119 ++++++++---------- 1 file changed, 49 insertions(+), 70 deletions(-) diff --git a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go index 205a9e7..b56a663 100644 --- a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go +++ b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go @@ -216,48 +216,35 @@ func (r *Reconciler) ensureMetricCollectorReports( ObjectMeta: metav1.ObjectMeta{ Name: reportName, Namespace: reportNamespace, - Labels: map[string]string{ - "approval-request": approvalReq.GetName(), - "update-run": updateRunName, - "stage": stageName, - "cluster": clusterName, - }, - }, - Spec: autoapprovev1alpha1.MetricCollectorReportSpec{ - // PrometheusURL is a configurable spec field that could differ per cluster. - // For setup simplicity, we use a constant value pointing to the Prometheus service - // deployed via examples/prometheus/service.yaml and propagated to all clusters. - // This assumes Prometheus is deployed with the same service name/namespace on all member clusters. - PrometheusURL: prometheusURL, }, } - // Create or update MetricCollectorReport - existingReport := &autoapprovev1alpha1.MetricCollectorReport{} - err := r.Client.Get(ctx, types.NamespacedName{ - Name: reportName, - Namespace: reportNamespace, - }, existingReport) + // Create or update MetricCollectorReport using controllerutil + op, err := controllerutil.CreateOrUpdate(ctx, r.Client, report, func() error { + // Set labels + if report.Labels == nil { + report.Labels = make(map[string]string) + } + report.Labels["approval-request"] = approvalReq.GetName() + report.Labels["update-run"] = updateRunName + report.Labels["stage"] = stageName + report.Labels["cluster"] = clusterName + + // Set spec + // PrometheusURL is a configurable spec field that could differ per cluster. + // For setup simplicity, we use a constant value pointing to the Prometheus service + // deployed via examples/prometheus/service.yaml and propagated to all clusters. + // This assumes Prometheus is deployed with the same service name/namespace on all member clusters. + report.Spec.PrometheusURL = prometheusURL + + return nil + }) if err != nil { - if errors.IsNotFound(err) { - if err := r.Client.Create(ctx, report); err != nil { - return fmt.Errorf("failed to create MetricCollectorReport in %s: %w", reportNamespace, err) - } - klog.V(2).InfoS("Created MetricCollectorReport", "report", reportName, "namespace", reportNamespace, "cluster", clusterName) - } else { - return fmt.Errorf("failed to get MetricCollectorReport in %s: %w", reportNamespace, err) - } - } else { - // Update spec if needed - if existingReport.Spec.PrometheusURL != prometheusURL { - existingReport.Spec.PrometheusURL = prometheusURL - if err := r.Client.Update(ctx, existingReport); err != nil { - return fmt.Errorf("failed to update MetricCollectorReport in %s: %w", reportNamespace, err) - } - klog.V(2).InfoS("Updated MetricCollectorReport", "report", reportName, "namespace", reportNamespace, "cluster", clusterName) - } + return fmt.Errorf("failed to create or update MetricCollectorReport in %s: %w", reportNamespace, err) } + + klog.V(2).InfoS("Ensured MetricCollectorReport", "report", reportName, "namespace", reportNamespace, "cluster", clusterName, "operation", op) } return nil @@ -271,8 +258,7 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( clusterNames []string, updateRunName, stageName string, ) error { - obj := approvalReqObj.(client.Object) - approvalReqRef := klog.KObj(obj) + approvalReqRef := klog.KObj(approvalReqObj) klog.V(2).InfoS("Starting workload health check", "approvalRequest", approvalReqRef, "clusters", clusterNames) @@ -281,7 +267,7 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( var workloads []autoapprovev1alpha1.WorkloadReference var workloadTrackerName string - if obj.GetNamespace() == "" { + if approvalReqObj.GetNamespace() == "" { // Cluster-scoped: Get ClusterStagedWorkloadTracker with same name as ClusterStagedUpdateRun clusterWorkloadTracker := &autoapprovev1alpha1.ClusterStagedWorkloadTracker{} if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName}, clusterWorkloadTracker); err != nil { @@ -298,9 +284,9 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( } else { // Namespace-scoped: Get StagedWorkloadTracker with same name and namespace as StagedUpdateRun stagedWorkloadTracker := &autoapprovev1alpha1.StagedWorkloadTracker{} - if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName, Namespace: obj.GetNamespace()}, stagedWorkloadTracker); err != nil { + if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName, Namespace: approvalReqObj.GetNamespace()}, stagedWorkloadTracker); err != nil { if errors.IsNotFound(err) { - klog.V(2).InfoS("StagedWorkloadTracker not found, skipping health check", "approvalRequest", approvalReqRef, "updateRun", updateRunName, "namespace", obj.GetNamespace()) + klog.V(2).InfoS("StagedWorkloadTracker not found, skipping health check", "approvalRequest", approvalReqRef, "updateRun", updateRunName, "namespace", approvalReqObj.GetNamespace()) return nil } klog.ErrorS(err, "Failed to get StagedWorkloadTracker", "approvalRequest", approvalReqRef, "updateRun", updateRunName) @@ -395,30 +381,24 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( klog.InfoS("All workloads are healthy, approving ApprovalRequest", "approvalRequest", approvalReqRef, "clusters", clusterNames, "workloads", len(workloads)) status := approvalReqObj.GetApprovalRequestStatus() - approvedCond := meta.FindStatusCondition(status.Conditions, string(placementv1beta1.ApprovalRequestConditionApproved)) - - // Only update if not already approved - if approvedCond == nil || approvedCond.Status != metav1.ConditionTrue { - meta.SetStatusCondition(&status.Conditions, metav1.Condition{ - Type: string(placementv1beta1.ApprovalRequestConditionApproved), - Status: metav1.ConditionTrue, - ObservedGeneration: obj.GetGeneration(), - Reason: "AllWorkloadsHealthy", - Message: fmt.Sprintf("All %d workloads are healthy across %d clusters", len(workloads), len(clusterNames)), - }) - - approvalReqObj.SetApprovalRequestStatus(*status) - if err := r.Client.Status().Update(ctx, obj); err != nil { - klog.ErrorS(err, "Failed to approve ApprovalRequest", "approvalRequest", approvalReqRef) - return fmt.Errorf("failed to approve ApprovalRequest: %w", err) - } - - klog.InfoS("Successfully approved ApprovalRequest", "approvalRequest", approvalReqRef) - r.recorder.Event(obj, "Normal", "Approved", fmt.Sprintf("All %d workloads are healthy across %d clusters in stage %s", len(workloads), len(clusterNames), stageName)) - } else { - klog.V(2).InfoS("ApprovalRequest already approved", "approvalRequest", approvalReqRef) + // we have already checked that the condition is not present or not true. + meta.SetStatusCondition(&status.Conditions, metav1.Condition{ + Type: string(placementv1beta1.ApprovalRequestConditionApproved), + Status: metav1.ConditionTrue, + ObservedGeneration: approvalReqObj.GetGeneration(), + Reason: "AllWorkloadsHealthy", + Message: fmt.Sprintf("All %d workloads are healthy across %d clusters", len(workloads), len(clusterNames)), + }) + + approvalReqObj.SetApprovalRequestStatus(*status) + if err := r.Client.Status().Update(ctx, approvalReqObj); err != nil { + klog.ErrorS(err, "Failed to approve ApprovalRequest", "approvalRequest", approvalReqRef) + return fmt.Errorf("failed to approve ApprovalRequest: %w", err) } + klog.InfoS("Successfully approved ApprovalRequest", "approvalRequest", approvalReqRef) + r.recorder.Event(approvalReqObj, "Normal", "Approved", fmt.Sprintf("All %d workloads are healthy across %d clusters in stage %s", len(workloads), len(clusterNames), stageName)) + // Approval successful or already approved return nil } @@ -431,12 +411,11 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( // handleDelete handles the deletion of an ApprovalRequest or ClusterApprovalRequest func (r *Reconciler) handleDelete(ctx context.Context, approvalReqObj placementv1beta1.ApprovalRequestObj) (ctrl.Result, error) { - obj := approvalReqObj.(client.Object) - if !controllerutil.ContainsFinalizer(obj, metricCollectorFinalizer) { + if !controllerutil.ContainsFinalizer(approvalReqObj, metricCollectorFinalizer) { return ctrl.Result{}, nil } - approvalReqRef := klog.KObj(obj) + approvalReqRef := klog.KObj(approvalReqObj) klog.V(2).InfoS("Cleaning up MetricCollectorReports for ApprovalRequest", "approvalRequest", approvalReqRef) // Get cluster names from UpdateRun to know which reports to delete @@ -447,7 +426,7 @@ func (r *Reconciler) handleDelete(ctx context.Context, approvalReqObj placementv // Fetch UpdateRun to get cluster names var clusterNames []string - if obj.GetNamespace() == "" { + if approvalReqObj.GetNamespace() == "" { // Cluster-scoped: Get ClusterStagedUpdateRun updateRun := &placementv1beta1.ClusterStagedUpdateRun{} if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName}, updateRun); err != nil { @@ -469,7 +448,7 @@ func (r *Reconciler) handleDelete(ctx context.Context, approvalReqObj placementv } else { // Namespace-scoped: Get StagedUpdateRun updateRun := &placementv1beta1.StagedUpdateRun{} - if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName, Namespace: obj.GetNamespace()}, updateRun); err != nil { + if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName, Namespace: approvalReqObj.GetNamespace()}, updateRun); err != nil { if !errors.IsNotFound(err) { klog.ErrorS(err, "Failed to get StagedUpdateRun for cleanup", "approvalRequest", approvalReqRef) } @@ -505,8 +484,8 @@ func (r *Reconciler) handleDelete(ctx context.Context, approvalReqObj placementv } // Remove finalizer - controllerutil.RemoveFinalizer(obj, metricCollectorFinalizer) - if err := r.Client.Update(ctx, obj); err != nil { + controllerutil.RemoveFinalizer(approvalReqObj, metricCollectorFinalizer) + if err := r.Client.Update(ctx, approvalReqObj); err != nil { klog.ErrorS(err, "Failed to remove finalizer", "approvalRequest", approvalReqRef) return ctrl.Result{}, err } From 0a68f885cda2046674f072a3db55e852555725f6 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Mon, 15 Dec 2025 18:15:21 -0800 Subject: [PATCH 26/38] minor fixes Signed-off-by: Arvind Thirumurugan --- .../approval-request-controller.Dockerfile | 2 +- .../docker/metric-app.Dockerfile | 2 +- .../docker/metric-collector.Dockerfile | 2 +- .../controllers/approvalrequest/controller.go | 45 ++++++++++--------- 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/approval-request-metric-collector/docker/approval-request-controller.Dockerfile b/approval-request-metric-collector/docker/approval-request-controller.Dockerfile index 7775210..39c40ae 100644 --- a/approval-request-metric-collector/docker/approval-request-controller.Dockerfile +++ b/approval-request-metric-collector/docker/approval-request-controller.Dockerfile @@ -13,7 +13,7 @@ COPY pkg/ pkg/ COPY cmd/ cmd/ # Build the controller -ARG GOARCH=amd64 +ARG GOARCH RUN CGO_ENABLED=0 GOOS=linux GOARCH=${GOARCH} go build \ -a -o approval-request-controller \ ./cmd/approvalrequestcontroller diff --git a/approval-request-metric-collector/docker/metric-app.Dockerfile b/approval-request-metric-collector/docker/metric-app.Dockerfile index 86100e3..9ddc9d1 100644 --- a/approval-request-metric-collector/docker/metric-app.Dockerfile +++ b/approval-request-metric-collector/docker/metric-app.Dockerfile @@ -13,7 +13,7 @@ COPY pkg/ pkg/ COPY cmd/ cmd/ # Build the application -ARG GOARCH=amd64 +ARG GOARCH RUN CGO_ENABLED=0 GOOS=linux GOARCH=${GOARCH} go build \ -a -o metric-app \ ./cmd/metricapp diff --git a/approval-request-metric-collector/docker/metric-collector.Dockerfile b/approval-request-metric-collector/docker/metric-collector.Dockerfile index 1ebff59..641c08a 100644 --- a/approval-request-metric-collector/docker/metric-collector.Dockerfile +++ b/approval-request-metric-collector/docker/metric-collector.Dockerfile @@ -13,7 +13,7 @@ COPY pkg/ pkg/ COPY cmd/ cmd/ # Build the collector -ARG GOARCH=amd64 +ARG GOARCH RUN CGO_ENABLED=0 GOOS=linux GOARCH=${GOARCH} go build \ -a -o metric-collector \ ./cmd/metriccollector diff --git a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go index b56a663..5bd84ab 100644 --- a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go +++ b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go @@ -64,35 +64,36 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu klog.V(2).InfoS("ApprovalRequest reconciliation ends", "request", req.NamespacedName, "latency", latency) }() - var approvalReqObj placementv1beta1.ApprovalRequestObj - // Check if request has a namespace to determine resource type + approvalReqObj, err := r.getApprovalRequestObj(ctx, req) + if err != nil { + if errors.IsNotFound(err) { + klog.V(2).InfoS("ApprovalRequest not found, ignoring", "request", req.NamespacedName) + return ctrl.Result{}, nil + } + klog.ErrorS(err, "Failed to get ApprovalRequest", "request", req.NamespacedName) + return ctrl.Result{}, err + } + + return r.reconcileApprovalRequestObj(ctx, approvalReqObj) +} + +// getApprovalRequestObj fetches either ApprovalRequest or ClusterApprovalRequest based on the request namespace. +func (r *Reconciler) getApprovalRequestObj(ctx context.Context, req ctrl.Request) (placementv1beta1.ApprovalRequestObj, error) { if req.Namespace != "" { // Fetch namespaced ApprovalRequest approvalReq := &placementv1beta1.ApprovalRequest{} if err := r.Client.Get(ctx, req.NamespacedName, approvalReq); err != nil { - if errors.IsNotFound(err) { - klog.V(2).InfoS("ApprovalRequest not found, ignoring", "request", req.NamespacedName) - return ctrl.Result{}, nil - } - klog.ErrorS(err, "Failed to get ApprovalRequest", "request", req.NamespacedName) - return ctrl.Result{}, err - } - approvalReqObj = approvalReq - } else { - // Fetch cluster-scoped ClusterApprovalRequest - clusterApprovalReq := &placementv1beta1.ClusterApprovalRequest{} - if err := r.Client.Get(ctx, types.NamespacedName{Name: req.Name}, clusterApprovalReq); err != nil { - if errors.IsNotFound(err) { - klog.V(2).InfoS("ClusterApprovalRequest not found, ignoring", "request", req.Name) - return ctrl.Result{}, nil - } - klog.ErrorS(err, "Failed to get ClusterApprovalRequest", "request", req.Name) - return ctrl.Result{}, err + return nil, err } - approvalReqObj = clusterApprovalReq + return approvalReq, nil } - return r.reconcileApprovalRequestObj(ctx, approvalReqObj) + // Fetch cluster-scoped ClusterApprovalRequest + clusterApprovalReq := &placementv1beta1.ClusterApprovalRequest{} + if err := r.Client.Get(ctx, types.NamespacedName{Name: req.Name}, clusterApprovalReq); err != nil { + return nil, err + } + return clusterApprovalReq, nil } // reconcileApprovalRequestObj reconciles an ApprovalRequestObj (either ApprovalRequest or ClusterApprovalRequest). From 9fa5010e2c87ba1f2a61fdc445d63145961e50e0 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Mon, 15 Dec 2025 19:19:38 -0800 Subject: [PATCH 27/38] account for workload kind Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/Makefile | 68 ------------------- .../v1alpha1/metriccollectorreport_types.go | 4 ++ .../v1alpha1/workloadtracker_types.go | 4 ++ ...leet.io_clusterstagedworkloadtrackers.yaml | 4 ++ ...netes-fleet.io_metriccollectorreports.yaml | 8 ++- ...netes-fleet.io_stagedworkloadtrackers.yaml | 4 ++ .../examples/prometheus/configmap.yaml | 3 + .../clusterstagedworkloadtracker.yaml | 1 + .../stagedworkloadtracker.yaml | 1 + .../controllers/approvalrequest/controller.go | 7 +- .../controllers/metriccollector/controller.go | 5 +- 11 files changed, 36 insertions(+), 73 deletions(-) diff --git a/approval-request-metric-collector/Makefile b/approval-request-metric-collector/Makefile index 822ecf7..c8e73a2 100644 --- a/approval-request-metric-collector/Makefile +++ b/approval-request-metric-collector/Makefile @@ -69,71 +69,3 @@ docker-build: docker-build-all ## Alias for docker-build-all .PHONY: run run: ## Run controller locally go run ./cmd/approvalrequestcontroller/main.go - -##@ Deployment - -.PHONY: install-approval-controller -install-approval-controller: ## Install approval-request-controller helm chart - helm install approval-request-controller ./charts/approval-request-controller \ - --namespace fleet-system \ - --create-namespace \ - --set image.repository=$(REGISTRY)/approval-request-controller \ - --set image.tag=$(TAG) - -.PHONY: upgrade-approval-controller -upgrade-approval-controller: ## Upgrade approval-request-controller helm chart - helm upgrade approval-request-controller ./charts/approval-request-controller \ - --namespace fleet-system \ - --set image.repository=$(REGISTRY)/approval-request-controller \ - --set image.tag=$(TAG) - -.PHONY: uninstall-approval-controller -uninstall-approval-controller: ## Uninstall approval-request-controller helm chart - helm uninstall approval-request-controller --namespace fleet-system - -.PHONY: install -install: install-approval-controller ## Alias for install-approval-controller - -.PHONY: upgrade -upgrade: upgrade-approval-controller ## Alias for upgrade-approval-controller - -.PHONY: uninstall -uninstall: uninstall-approval-controller ## Alias for uninstall-approval-controller - -##@ Kind - -.PHONY: kind-load-approval-controller -kind-load-approval-controller: ## Build and load approval-request-controller image into kind cluster - docker buildx build \ - --file docker/approval-request-controller.Dockerfile \ - --tag approval-request-controller:$(TAG) \ - --output=type=docker \ - --platform=linux/$(GOARCH) \ - --build-arg GOARCH=$(GOARCH) \ - . - kind load docker-image approval-request-controller:$(TAG) --name hub - -.PHONY: kind-load-metric-collector -kind-load-metric-collector: ## Build and load metric-collector image into kind cluster - docker buildx build \ - --file docker/metric-collector.Dockerfile \ - --tag metric-collector:$(TAG) \ - --output=type=docker \ - --platform=linux/$(GOARCH) \ - --build-arg GOARCH=$(GOARCH) \ - . - kind load docker-image metric-collector:$(TAG) --name hub - -.PHONY: kind-load-metric-app -kind-load-metric-app: ## Build and load metric-app image into kind cluster - docker buildx build \ - --file docker/metric-app.Dockerfile \ - --tag metric-app:$(TAG) \ - --output=type=docker \ - --platform=linux/$(GOARCH) \ - --build-arg GOARCH=$(GOARCH) \ - . - kind load docker-image metric-app:$(TAG) --name hub - -.PHONY: kind-load -kind-load: kind-load-approval-controller kind-load-metric-collector kind-load-metric-app ## Build and load all images into kind cluster diff --git a/approval-request-metric-collector/apis/autoapprove/v1alpha1/metriccollectorreport_types.go b/approval-request-metric-collector/apis/autoapprove/v1alpha1/metriccollectorreport_types.go index 6063466..ef36731 100644 --- a/approval-request-metric-collector/apis/autoapprove/v1alpha1/metriccollectorreport_types.go +++ b/approval-request-metric-collector/apis/autoapprove/v1alpha1/metriccollectorreport_types.go @@ -96,6 +96,10 @@ type WorkloadMetrics struct { // +required WorkloadName string `json:"workloadName"` + // Kind of the workload controller (e.g., Deployment, StatefulSet, DaemonSet). + // +optional + WorkloadKind string `json:"workloadKind,omitempty"` + // Health indicates if the workload is healthy (true=healthy, false=unhealthy). // +required Health bool `json:"health"` diff --git a/approval-request-metric-collector/apis/autoapprove/v1alpha1/workloadtracker_types.go b/approval-request-metric-collector/apis/autoapprove/v1alpha1/workloadtracker_types.go index 56925ee..42431ca 100644 --- a/approval-request-metric-collector/apis/autoapprove/v1alpha1/workloadtracker_types.go +++ b/approval-request-metric-collector/apis/autoapprove/v1alpha1/workloadtracker_types.go @@ -29,6 +29,10 @@ type WorkloadReference struct { // Namespace is the namespace of the workload // +required Namespace string `json:"namespace"` + + // Kind is the kind of the workload controller (e.g., Deployment, StatefulSet, DaemonSet) + // +optional + Kind string `json:"kind,omitempty"` } // +genclient diff --git a/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml b/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml index 79e83cf..375c86b 100644 --- a/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml +++ b/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_clusterstagedworkloadtrackers.yaml @@ -48,6 +48,10 @@ spec: items: description: WorkloadReference represents a workload to be tracked properties: + kind: + description: Kind is the kind of the workload controller (e.g., + Deployment, StatefulSet, DaemonSet) + type: string name: description: Name is the name of the workload type: string diff --git a/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml b/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml index 9548c23..6bc4a88 100644 --- a/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml +++ b/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_metriccollectorreports.yaml @@ -84,7 +84,7 @@ spec: each workload. items: description: WorkloadMetrics represents metrics collected from a - single workload pod. + single workload. properties: health: description: Health indicates if the workload is healthy (true=healthy, @@ -93,8 +93,12 @@ spec: namespace: description: Namespace of the workload. type: string + workloadKind: + description: Kind of the workload controller (e.g., Deployment, + StatefulSet, DaemonSet). + type: string workloadName: - description: WorkloadName from the workload_health metric label. + description: Name of the workload. type: string required: - health diff --git a/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_stagedworkloadtrackers.yaml b/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_stagedworkloadtrackers.yaml index ef221cd..70faaf9 100644 --- a/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_stagedworkloadtrackers.yaml +++ b/approval-request-metric-collector/config/crd/bases/autoapprove.kubernetes-fleet.io_stagedworkloadtrackers.yaml @@ -48,6 +48,10 @@ spec: items: description: WorkloadReference represents a workload to be tracked properties: + kind: + description: Kind is the kind of the workload controller (e.g., + Deployment, StatefulSet, DaemonSet) + type: string name: description: Name is the name of the workload type: string diff --git a/approval-request-metric-collector/examples/prometheus/configmap.yaml b/approval-request-metric-collector/examples/prometheus/configmap.yaml index e0d33b7..a6cc1bb 100644 --- a/approval-request-metric-collector/examples/prometheus/configmap.yaml +++ b/approval-request-metric-collector/examples/prometheus/configmap.yaml @@ -37,6 +37,9 @@ data: target_label: pod - source_labels: [__meta_kubernetes_pod_label_app] target_label: app + # Add workload controller kind + - source_labels: [__meta_kubernetes_pod_controller_kind] + target_label: workload_kind # Add CLUSTER_NAME and WORKLOAD_NAME from env vars if present - action: labelmap regex: __meta_kubernetes_pod_label_(.+) diff --git a/approval-request-metric-collector/examples/workloadtracker/clusterstagedworkloadtracker.yaml b/approval-request-metric-collector/examples/workloadtracker/clusterstagedworkloadtracker.yaml index 343d05b..f521ab4 100644 --- a/approval-request-metric-collector/examples/workloadtracker/clusterstagedworkloadtracker.yaml +++ b/approval-request-metric-collector/examples/workloadtracker/clusterstagedworkloadtracker.yaml @@ -6,3 +6,4 @@ metadata: workloads: - name: sample-metric-app namespace: test-ns + kind: Deployment diff --git a/approval-request-metric-collector/examples/workloadtracker/stagedworkloadtracker.yaml b/approval-request-metric-collector/examples/workloadtracker/stagedworkloadtracker.yaml index b54fce0..2da34fe 100644 --- a/approval-request-metric-collector/examples/workloadtracker/stagedworkloadtracker.yaml +++ b/approval-request-metric-collector/examples/workloadtracker/stagedworkloadtracker.yaml @@ -7,3 +7,4 @@ metadata: workloads: - name: sample-metric-app namespace: test-ns + kind: Deployment diff --git a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go index 5bd84ab..2532476 100644 --- a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go +++ b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go @@ -21,6 +21,7 @@ package approvalrequest import ( "context" "fmt" + "strings" "time" "k8s.io/apimachinery/pkg/api/errors" @@ -354,11 +355,13 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( // 3. Apply your aggregation logic (e.g., allHealthy := all metrics have Health==true) // 4. Set 'healthy' based on the aggregated result for _, collectedMetric := range report.Status.CollectedMetrics { + // Match workload by namespace, name, and kind. if collectedMetric.Namespace == trackedWorkload.Namespace && - collectedMetric.WorkloadName == trackedWorkload.Name { + collectedMetric.WorkloadName == trackedWorkload.Name && + strings.EqualFold(trackedWorkload.Kind, collectedMetric.WorkloadKind) { found = true healthy = collectedMetric.Health - klog.V(2).InfoS("Workload metric found", "approvalRequest", approvalReqRef, "cluster", clusterName, "workload", trackedWorkload.Name, "namespace", trackedWorkload.Namespace, "healthy", healthy) + klog.V(2).InfoS("Workload metric found", "approvalRequest", approvalReqRef, "cluster", clusterName, "workload", trackedWorkload.Name, "namespace", trackedWorkload.Namespace, "kind", trackedWorkload.Kind, "healthy", healthy) break // Remove this to collect all metrics for aggregation } } diff --git a/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go b/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go index fc492c0..a613e1e 100644 --- a/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go +++ b/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go @@ -129,13 +129,15 @@ func (r *Reconciler) collectAllWorkloadMetrics(ctx context.Context, promClient P // Extract metrics from Prometheus result for _, res := range data.Result { // Extract labels from the Prometheus metric - // The workload_health metric includes labels like: workload_health{namespace="test-ns",app="sample-app"} + // The workload_health metric includes labels like: workload_health{namespace="test-ns",app="sample-app",workload_kind="Deployment"} // These labels come from Kubernetes pod labels and are added by Prometheus during scraping. // The relabeling configuration is in examples/prometheus/configmap.yaml: // - namespace: from __meta_kubernetes_namespace (pod's namespace) // - app: from __meta_kubernetes_pod_label_app (pod's "app" label) + // - workload_kind: from __meta_kubernetes_pod_controller_kind (controller kind) namespace := res.Metric["namespace"] workloadName := res.Metric["app"] + workloadKind := res.Metric["workload_kind"] if namespace == "" || workloadName == "" { continue @@ -158,6 +160,7 @@ func (r *Reconciler) collectAllWorkloadMetrics(ctx context.Context, promClient P workloadMetrics := autoapprovev1alpha1.WorkloadMetrics{ WorkloadName: workloadName, Namespace: namespace, + WorkloadKind: workloadKind, Health: health >= 1.0, } collectedMetrics = append(collectedMetrics, workloadMetrics) From 27f50e13e65d2e2441d07b33c68ff599b984186b Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Mon, 15 Dec 2025 19:48:23 -0800 Subject: [PATCH 28/38] minor updates to readme Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/README.md | 33 ++++++++++----------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index 95c7517..871f282 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -212,7 +212,7 @@ Before diving into the setup steps, here's a bird's eye view of what you'll be b ### Architecture Components **Hub Cluster** - The control plane where you'll deploy: -1. **3 Member Clusters** (kind-cluster-1, kind-cluster-2, kind-cluster-3) +1. **3 Member Clusters** (cluster-1, cluster-2, cluster-3) - Labeled with `environment=staging` or `environment=prod` - These labels determine which stage each cluster belongs to during rollouts @@ -284,16 +284,16 @@ When the approval controller evaluates a stage: When you create a **ClusterStagedUpdateRun** or **StagedUpdateRun**, here's what happens: -1. **Stage 1 (staging)**: Rollout starts with `kind-cluster-1` +1. **Stage 1 (staging)**: Rollout starts with `cluster-1` - KubeFleet creates an ApprovalRequest for the staging stage - - Approval controller creates MetricCollectorReport in `fleet-member-kind-cluster-1` namespace - - Metric collector on `kind-cluster-1` watches its report on hub and updates status with health metrics + - Approval controller creates MetricCollectorReport in `fleet-member-cluster-1` namespace + - Metric collector on `cluster-1` watches its report on hub and updates status with health metrics - When `sample-metric-app` is healthy, approval controller auto-approves - - KubeFleet proceeds with the rollout to `kind-cluster-1` + - KubeFleet proceeds with the rollout to `cluster-1` 2. **Stage 2 (prod)**: After staging succeeds - KubeFleet creates an ApprovalRequest for the prod stage - - Approval controller creates MetricCollectorReports in `fleet-member-kind-cluster-2` and `fleet-member-kind-cluster-3` + - Approval controller creates MetricCollectorReports in `fleet-member-cluster-2` and `fleet-member-cluster-3` - Metric collectors on both clusters watch their reports and update with health data - When ALL workloads across BOTH prod clusters are healthy, auto-approve - KubeFleet completes the rollout to production clusters @@ -302,7 +302,6 @@ When you create a **ClusterStagedUpdateRun** or **StagedUpdateRun**, here's what | Resource | Purpose | Where | |----------|---------|-------| -| **MemberCluster** | Register member clusters with hub, apply stage labels | Hub | | **ClusterResourcePlacement** | Define what resources to propagate (Prometheus, sample-app) | Hub | | **StagedUpdateStrategy** | Define stages with label selectors and approval requirements | Hub | | **WorkloadTracker** | Specify which workloads to monitor for health | Hub | @@ -442,12 +441,12 @@ Install the metric collector on all member clusters using the ACR registry: ```bash # Run the installation script for all member clusters -# Replace with your hub cluster name (e.g., kind-hub, hub) +# Replace with your hub cluster name # Replace , , with your actual cluster names scripts/install-on-member.sh ${REGISTRY} # Example: -# scripts/install-on-member.sh ${REGISTRY} kind-hub kind-cluster-1 kind-cluster-2 kind-cluster-3 +# scripts/install-on-member.sh ${REGISTRY} hub cluster-1 cluster-2 cluster-3 ``` 4. Configures connection to hub API server and local Prometheus ```bash @@ -502,7 +501,7 @@ Alternatively, you can use namespace-scoped resources: cd ../approval-request-controller # Switch to hub cluster -kubectl config use-context kind-hub +kubectl config use-context ``` ``` bash @@ -588,8 +587,8 @@ kubectl get metriccollectorreport -A Output: ```bash -NAMESPACE NAME WORKLOADS LAST-COLLECTION AGE -fleet-member-kind-cluster-1 mc-example-cluster-staged-run-staging 1 27s 2m57s +NAMESPACE NAME WORKLOADS LAST-COLLECTION AGE +fleet-member-cluster-1 mc-example-cluster-staged-run-staging 1 27s 2m57s ``` #### For Namespace-Scoped Updates: @@ -615,8 +614,8 @@ kubectl get metriccollectorreport -A Output: ```bash -NAMESPACE NAME WORKLOADS LAST-COLLECTION AGE -fleet-member-kind-cluster-1 mc-example-staged-run-staging 1 27s 57s +NAMESPACE NAME WORKLOADS LAST-COLLECTION AGE +fleet-member-cluster-1 mc-example-staged-run-staging 1 27s 57s ``` The approval controller will automatically approve stages when the metric collectors report that workloads are healthy. @@ -627,14 +626,14 @@ The approval controller will automatically approve stages when the metric collec On the hub cluster: ```bash -kubectl config use-context kind-hub +kubectl config use-context kubectl get pods -n fleet-system kubectl logs -n fleet-system deployment/approval-request-controller -f ``` On member clusters: ```bash -kubectl config use-context kind-cluster-1 +kubectl config use-context kubectl get pods -n default kubectl logs -n default deployment/metric-collector -f ``` @@ -643,7 +642,7 @@ kubectl logs -n default deployment/metric-collector -f Verify that MetricCollectorReports are being created and updated on the hub: ```bash -kubectl config use-context kind-hub +kubectl config use-context kubectl get metriccollectorreport -A ``` From 7c2bc78dd9a885cb10dbc6bc0502d5a2297c3cb2 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Mon, 15 Dec 2025 19:54:58 -0800 Subject: [PATCH 29/38] fix README Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/README.md | 66 +++++++++------------ 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index 871f282..b60b94d 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -27,13 +27,13 @@ This solution introduces three new CRDs that work together with KubeFleet's nati 2. **ClusterStagedWorkloadTracker** (cluster-scoped) - Defines which workloads to monitor for a ClusterStagedUpdateRun - The name must match the ClusterStagedUpdateRun name - - Specifies workload's name, namespace and expected health status + - Specifies workload's name, namespace, and kind (e.g., Deployment, StatefulSet) - Used by approval-request-controller to determine if stage is ready for approval 3. **StagedWorkloadTracker** (namespaced) - Defines which workloads to monitor for a StagedUpdateRun - The name and namespace must match the StagedUpdateRun name and namespace - - Specifies namespace, workload name, and expected health status + - Specifies namespace, workload name, and kind - Used by approval-request-controller to determine if stage is ready for approval ### Automated Approval Flow @@ -195,7 +195,7 @@ Result latest ``` -**You're now ready to proceed with the setup!** Your ACR contains all three required images that will be pulled by both kind and production clusters. +**You're now ready to proceed with the setup!** Your ACR contains all three required images that will be pulled by your clusters. ### 4. Cleanup (After Testing) @@ -256,7 +256,7 @@ The **WorkloadTracker** is a critical resource that tells the approval controlle - Cluster-scoped resource on the hub - Name must exactly match the ClusterStagedUpdateRun name - Example: If your UpdateRun is named `example-cluster-staged-run`, the tracker must also be named `example-cluster-staged-run` - - Contains a list of workloads (name + namespace) to monitor across all clusters in each stage + - Contains a list of workloads (name, namespace, and kind) to monitor across all clusters in each stage 2. **StagedWorkloadTracker** (for StagedUpdateRun) - Namespace-scoped resource on the hub @@ -268,15 +268,17 @@ The **WorkloadTracker** is a critical resource that tells the approval controlle ```yaml # ClusterStagedWorkloadTracker example workloads: - - name: sample-metric-app # Deployment name + - name: sample-metric-app # Workload name (matches the app label) namespace: test-ns # Namespace where it runs + kind: Deployment # Workload kind (optional, enables precise matching) ``` When the approval controller evaluates a stage: 1. It fetches the WorkloadTracker that matches the UpdateRun name (and namespace) 2. For each cluster in the stage, it reads the MetricCollectorReport -3. It verifies that every workload listed in the tracker appears in the report with `health=1.0` -4. Only when ALL workloads in ALL clusters are healthy does it approve the stage +3. It verifies that every workload listed in the tracker appears in the report as healthy +4. The matching logic compares namespace, name, and kind (if specified) in a case-insensitive manner +5. Only when ALL workloads in ALL clusters are healthy does it approve the stage **Critical Rule:** The WorkloadTracker must be created BEFORE starting the UpdateRun. If the controller can't find a matching tracker, it won't approve any stages. @@ -308,24 +310,6 @@ When you create a **ClusterStagedUpdateRun** or **StagedUpdateRun**, here's what | **UpdateRun** | Start the staged rollout process | Hub | | **MetricCollectorReport** | Created by approval controller, updated by metric collector | Hub (fleet-member-* ns) | -### What the Installation Scripts Do - -**`install-on-hub.sh`** (Approval Request Controller): -- Takes ACR registry URL and hub cluster name as parameters -- Pulls approval-request-controller image from ACR -- Verifies KubeFleet CRDs are installed -- Installs controller via Helm with custom CRDs (MetricCollectorReport, WorkloadTrackers) -- Sets up RBAC for managing MetricCollectorReports and reading approval requests - -**`install-on-member.sh`** (Metric Collector): -- Takes ACR registry URL, hub cluster, and member cluster names as parameters -- Pulls metric-collector image from ACR -- Creates service account with hub cluster access token and RBAC for watching/updating MetricCollectorReports -- Installs metric-collector via Helm on each member cluster -- Configures connection to hub API server to watch reports and local Prometheus for metrics - -With this understanding, you're ready to start the setup! - ## Setup ### Prerequisites @@ -409,9 +393,9 @@ scripts/install-on-hub.sh ${REGISTRY} ``` The script performs the following: -1. Pulls the `approval-request-controller` image from your ACR -2. Verifies that required kubefleet CRDs are installed -3. Installs the controller via Helm with the custom CRDs (MetricCollector, MetricCollectorReport, ClusterStagedWorkloadTracker, StagedWorkloadTracker) +1. Configures the controller to use the approval-request-controller image from your ACR +2. Verifies that required KubeFleet CRDs are installed +3. Installs the controller via Helm with the custom CRDs (MetricCollectorReport, ClusterStagedWorkloadTracker, StagedWorkloadTracker) 4. Verifies the installation ### 5. Configure Workload Tracker @@ -424,7 +408,7 @@ Apply the appropriate workload tracker based on which type of staged update you' # Apply ClusterStagedWorkloadTracker # This defines which workloads to monitor for the staged rollout # The name "example-cluster-staged-run" must match the ClusterStagedUpdateRun name -# Tracks: sample-metric-app in test-ns namespace +# Tracks: sample-metric-app Deployment in test-ns namespace kubectl apply -f ./examples/workloadtracker/clusterstagedworkloadtracker.yaml ``` @@ -434,9 +418,12 @@ kubectl apply -f ./examples/workloadtracker/clusterstagedworkloadtracker.yaml # Apply StagedWorkloadTracker # This defines which workloads to monitor for the namespace-scoped staged rollout # The name "example-staged-run" and namespace "test-ns" must match the StagedUpdateRun -# Tracks: sample-metric-app in test-ns namespace +# Tracks: sample-metric-app in test-ns namespace with kind Deployment kubectl apply -f ./examples/workloadtracker/stagedworkloadtracker.yaml ``` + +### 6. Install Metric Collector (Member Clusters) + Install the metric collector on all member clusters using the ACR registry: ```bash @@ -448,16 +435,21 @@ scripts/install-on-member.sh ${REGISTRY} +### 7. Start Staged Rollout + +Choose one of the following options based on your use case: -# Apply ClusterStagedUpdateStrategy #### Option A: Cluster-Scoped Staged Update (ClusterStagedUpdateRun) +Create a cluster-scoped staged update run: + Switch back to hub cluster and create a cluster-scoped staged update run: ```bash @@ -667,7 +659,7 @@ kubectl get metriccollectorreport -A - Verify RBAC permissions are configured correctly ### Metrics not being collected -- Verify Prometheus is accessible: `kubectl port-forward -n test-ns svc/prometheus 9090:9090` +- Verify Prometheus is accessible: `kubectl port-forward -n prometheus svc/prometheus 9090:9090` - Check metric collector logs for connection errors - Ensure workloads have Prometheus scrape annotations @@ -676,7 +668,7 @@ kubectl get metriccollectorreport -A - Check that the workload tracker name matches the update run name: - For ClusterStagedUpdateRun: ClusterStagedWorkloadTracker name must match - For StagedUpdateRun: StagedWorkloadTracker name and namespace must match -- Verify workload tracker resources define correct health thresholds +- Verify workloads in the tracker match those reporting metrics (name, namespace, and kind) - Verify MetricCollectorReports are being created on the hub - Review approval-request-controller logs for decision-making details From aad7c4e97d7271a4bfbf3275b774829d39ba1ba5 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Tue, 16 Dec 2025 14:43:44 -0800 Subject: [PATCH 30/38] minor fixes Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/README.md | 35 +++++++++++++++---- .../templates/deployment.yaml | 1 + .../cmd/approvalrequestcontroller/main.go | 3 ++ .../scripts/install-on-hub.sh | 2 +- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index b60b94d..44e146a 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -56,9 +56,14 @@ This solution introduces three new CRDs that work together with KubeFleet's nati - Every 30 seconds, it: - Queries local Prometheus using URL from report spec with PromQL: `workload_health` - Prometheus returns metrics for all pods with `prometheus.io/scrape: "true"` annotation - - Extracts workload health (1.0 = healthy, 0.0 = unhealthy) + - Extracts workload health (1.0 = healthy, 0.0 = unhealthy) along with metadata labels - Updates the `MetricCollectorReport` status on hub with **all** collected metrics + **Example Prometheus Metric:** + ``` + workload_health{app="sample-metric-app", instance="10.244.1.17:8080", job="kubernetes-pods", namespace="test-ns", pod="sample-metric-app-749d758c79-z2668", pod_template_hash="749d758c79", workload_kind="ReplicaSet"} 1.0 + ``` + **Important Note on Multiple Pods:** When a workload (e.g., a Deployment) has multiple pods/replicas emitting health signals: - The metric collector **collects all metrics** from Prometheus and stores them in the MetricCollectorReport - If `sample-metric-app` has 3 replicas, the report will contain 3 separate `WorkloadMetrics` entries @@ -146,10 +151,14 @@ export TAG="latest" cd approval-request-metric-collector ``` -Build and push all images at once: +Build and push all images at once, to build for a specific architecture (default is your system's architecture): ```bash -make docker-build-all +# For AMD64 (x86_64), ARCH used by AKS fleet, clusters. +make docker-build-all GOARCH=amd64 + +# For ARM64 (Apple Silicon, ARM servers) +make docker-build-all GOARCH=arm64 ``` Or build individual images: @@ -344,6 +353,11 @@ The `StagedUpdateStrategy` uses these labels to select clusters for each stage: - **Stage 1 (staging)**: Selects clusters with `environment=staging` - **Stage 2 (prod)**: Selects clusters with `environment=prod` +Note: If you are updating fleet member cluster CRs joined via Azure portal, CLI please use the following command, this is because we don't allow users to use kubectl to update labels directly a validating webhook configuration will deny any user, +``` +az fleet member update -g -f -n --labels "=" +``` + ### 2. Deploy Prometheus From the kubefleet-cookbook repo, navigate to the approval-request-metric-collector directory and deploy Prometheus for metrics collection: @@ -376,7 +390,9 @@ Create the test namespace and deploy the sample application: # Create test namespace kubectl create ns test-ns -Then apply: `kubectl apply -f ./examples/sample-metric-app/` +# Create sample-metric-app deployment +kubectl apply -f ./examples/sample-metric-app/ +``` > Note: If users are using a different REGISTRY, TAG variables from the setup, please update examples/sample-metric-app/sample-metric-app.yaml accordingly. @@ -426,11 +442,15 @@ kubectl apply -f ./examples/workloadtracker/stagedworkloadtracker.yaml Install the metric collector on all member clusters using the ACR registry: +```bash +# Find the contexts for hub, member clusters. +kubectl config get-contexts +``` + ```bash # Run the installation script for all member clusters -# Replace with your hub cluster name -# Replace , , with your actual cluster names -scripts/install-on-member.sh ${REGISTRY} +# Replace with your actual cluster contexts +scripts/install-on-member.sh ${REGISTRY} # Example: # scripts/install-on-member.sh ${REGISTRY} hub cluster-1 cluster-2 cluster-3 @@ -453,6 +473,7 @@ Create a cluster-scoped staged update run: Switch back to hub cluster and create a cluster-scoped staged update run: ```bash +kubectl config use-context # Apply ClusterStagedUpdateStrategy # Defines the stages for the rollout: staging (cluster-1) -> prod (cluster-2, cluster-3) # Each stage requires approval before proceeding diff --git a/approval-request-metric-collector/charts/approval-request-controller/templates/deployment.yaml b/approval-request-metric-collector/charts/approval-request-controller/templates/deployment.yaml index 82d7905..dd84869 100644 --- a/approval-request-metric-collector/charts/approval-request-controller/templates/deployment.yaml +++ b/approval-request-metric-collector/charts/approval-request-controller/templates/deployment.yaml @@ -37,6 +37,7 @@ spec: args: - --metrics-bind-address=:{{ .Values.metrics.port }} - --health-probe-bind-address=:{{ .Values.healthProbe.port }} + - -v={{ .Values.controller.logLevel }} ports: {{- if .Values.metrics.enabled }} diff --git a/approval-request-metric-collector/cmd/approvalrequestcontroller/main.go b/approval-request-metric-collector/cmd/approvalrequestcontroller/main.go index ef1004c..0bc5835 100644 --- a/approval-request-metric-collector/cmd/approvalrequestcontroller/main.go +++ b/approval-request-metric-collector/cmd/approvalrequestcontroller/main.go @@ -54,6 +54,9 @@ func main() { var metricsAddr string var probeAddr string + // Add klog flags to support -v for verbosity + klog.InitFlags(nil) + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") diff --git a/approval-request-metric-collector/scripts/install-on-hub.sh b/approval-request-metric-collector/scripts/install-on-hub.sh index de7dbce..602bbd9 100755 --- a/approval-request-metric-collector/scripts/install-on-hub.sh +++ b/approval-request-metric-collector/scripts/install-on-hub.sh @@ -82,7 +82,7 @@ echo "" # Step 2: Install helm chart on hub cluster (includes MetricCollector, MetricCollectorReport, WorkloadTracker CRDs) echo "Step 2: Installing helm chart on hub cluster..." -helm upgrade --install ${CHART_NAME} ../charts/${CHART_NAME} \ +helm upgrade --install ${CHART_NAME} ${REPO_ROOT}/charts/${CHART_NAME} \ --kube-context=${HUB_CONTEXT} \ --namespace ${NAMESPACE} \ --create-namespace \ From 11b40af4d754c9d2203a29328e31bd22279b2a44 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Tue, 16 Dec 2025 15:38:10 -0800 Subject: [PATCH 31/38] minor fixes Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/README.md | 20 ++++++++++++++++++- .../cmd/metricapp/main.go | 18 +++++++++++++---- .../examples/prometheus/configmap.yaml | 4 ---- .../sample-metric-app/sample-metric-app.yaml | 5 ++++- .../controllers/approvalrequest/controller.go | 3 +-- 5 files changed, 38 insertions(+), 12 deletions(-) diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index 44e146a..5fe4fdf 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -61,7 +61,7 @@ This solution introduces three new CRDs that work together with KubeFleet's nati **Example Prometheus Metric:** ``` - workload_health{app="sample-metric-app", instance="10.244.1.17:8080", job="kubernetes-pods", namespace="test-ns", pod="sample-metric-app-749d758c79-z2668", pod_template_hash="749d758c79", workload_kind="ReplicaSet"} 1.0 + workload_health{app="sample-metric-app", instance="10.244.0.32:8080", job="kubernetes-pods", namespace="test-ns", pod="sample-metric-app-565fd6595b-7pfb6", pod_template_hash="565fd6595b", workload_kind="Deployment"} 1 ``` **Important Note on Multiple Pods:** When a workload (e.g., a Deployment) has multiple pods/replicas emitting health signals: @@ -396,6 +396,24 @@ kubectl apply -f ./examples/sample-metric-app/ > Note: If users are using a different REGISTRY, TAG variables from the setup, please update examples/sample-metric-app/sample-metric-app.yaml accordingly. +**Important: Configuring WORKLOAD_KIND Environment Variable** + +The sample-metric-app emits a `workload_health` metric with a `workload_kind` label that identifies the parent workload type. This label **must match** the `kind` field specified in your WorkloadTracker. + +The sample deployment sets `WORKLOAD_KIND=Deployment`: +```yaml +env: +- name: WORKLOAD_KIND + value: "Deployment" +``` + +For other workload types, update the environment variable accordingly: +- **StatefulSet**: `WORKLOAD_KIND=StatefulSet` +- **DaemonSet**: `WORKLOAD_KIND=DaemonSet` +- **Job**: `WORKLOAD_KIND=Job` + +This is necessary because Prometheus's `__meta_kubernetes_pod_controller_kind` returns the immediate controller (e.g., ReplicaSet for Deployments), not the actual parent resource. By setting this environment variable, the metric app emits the correct workload type that matches your WorkloadTracker configuration. + ### 4. Install Approval Request Controller (Hub Cluster) Install the approval request controller on the hub cluster using the ACR registry: diff --git a/approval-request-metric-collector/cmd/metricapp/main.go b/approval-request-metric-collector/cmd/metricapp/main.go index 17dc094..f2b2668 100644 --- a/approval-request-metric-collector/cmd/metricapp/main.go +++ b/approval-request-metric-collector/cmd/metricapp/main.go @@ -2,22 +2,32 @@ package main import ( "net/http" + "os" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) func main() { - // Define a simple gauge metric for health with labels - workloadHealth := prometheus.NewGauge( + // Get the workload kind from environment variable + // This should be set to the actual parent resource (e.g., "Deployment", "StatefulSet", "DaemonSet") + // not the immediate controller like ReplicaSet + workloadKind := os.Getenv("WORKLOAD_KIND") + if workloadKind == "" { + workloadKind = "Unknown" + } + + // Define a simple gauge metric for health with workload_kind label + workloadHealth := prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "workload_health", Help: "Indicates if the workload is healthy (1=healthy, 0=unhealthy)", }, + []string{"workload_kind"}, ) - // Set it to 1 (healthy) with labels - workloadHealth.Set(1) + // Set it to 1 (healthy) with the workload kind label + workloadHealth.WithLabelValues(workloadKind).Set(1) // Register metric with Prometheus default registry prometheus.MustRegister(workloadHealth) diff --git a/approval-request-metric-collector/examples/prometheus/configmap.yaml b/approval-request-metric-collector/examples/prometheus/configmap.yaml index a6cc1bb..e300b16 100644 --- a/approval-request-metric-collector/examples/prometheus/configmap.yaml +++ b/approval-request-metric-collector/examples/prometheus/configmap.yaml @@ -37,9 +37,5 @@ data: target_label: pod - source_labels: [__meta_kubernetes_pod_label_app] target_label: app - # Add workload controller kind - - source_labels: [__meta_kubernetes_pod_controller_kind] - target_label: workload_kind - # Add CLUSTER_NAME and WORKLOAD_NAME from env vars if present - action: labelmap regex: __meta_kubernetes_pod_label_(.+) diff --git a/approval-request-metric-collector/examples/sample-metric-app/sample-metric-app.yaml b/approval-request-metric-collector/examples/sample-metric-app/sample-metric-app.yaml index c2dcbb7..2714d65 100644 --- a/approval-request-metric-collector/examples/sample-metric-app/sample-metric-app.yaml +++ b/approval-request-metric-collector/examples/sample-metric-app/sample-metric-app.yaml @@ -21,7 +21,10 @@ spec: spec: containers: - name: metric-app - image: myfleetacr.azurecr.io/metric-app:latest + image: arvindacr.azurecr.io/metric-app:latest imagePullPolicy: Always + env: + - name: WORKLOAD_KIND + value: "Deployment" ports: - containerPort: 8080 diff --git a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go index 2532476..6693516 100644 --- a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go +++ b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go @@ -21,7 +21,6 @@ package approvalrequest import ( "context" "fmt" - "strings" "time" "k8s.io/apimachinery/pkg/api/errors" @@ -358,7 +357,7 @@ func (r *Reconciler) checkWorkloadHealthAndApprove( // Match workload by namespace, name, and kind. if collectedMetric.Namespace == trackedWorkload.Namespace && collectedMetric.WorkloadName == trackedWorkload.Name && - strings.EqualFold(trackedWorkload.Kind, collectedMetric.WorkloadKind) { + trackedWorkload.Kind == collectedMetric.WorkloadKind { found = true healthy = collectedMetric.Health klog.V(2).InfoS("Workload metric found", "approvalRequest", approvalReqRef, "cluster", clusterName, "workload", trackedWorkload.Name, "namespace", trackedWorkload.Namespace, "kind", trackedWorkload.Kind, "healthy", healthy) From ad798f458fd9bb624f4fe103120f28f0a93fe6b1 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Tue, 16 Dec 2025 15:51:30 -0800 Subject: [PATCH 32/38] minor readme update Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index 5fe4fdf..a66acab 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -2,6 +2,8 @@ This tutorial demonstrates how to use the Approval Request Controller and Metric Collector with KubeFleet for automated staged rollout approvals based on workload health metrics. +> **Note:** This tutorial is self-contained and provides all the steps needed to get started. For additional context on KubeFleet's staged update functionality, you can optionally refer to the [Staged Update How-To Guide](https://github.com/Azure/fleet/blob/main/docs/howtos/staged-update.md). + ## Overview This directory contains two controllers: From d4a2b3e0bc9f6918e38a59ee05cf310136c91af8 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Tue, 16 Dec 2025 16:06:35 -0800 Subject: [PATCH 33/38] minor fixes Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/README.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index a66acab..9691c24 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -493,7 +493,9 @@ Create a cluster-scoped staged update run: Switch back to hub cluster and create a cluster-scoped staged update run: ```bash +# Switch to hub cluster kubectl config use-context + # Apply ClusterStagedUpdateStrategy # Defines the stages for the rollout: staging (cluster-1) -> prod (cluster-2, cluster-3) # Each stage requires approval before proceeding @@ -530,14 +532,10 @@ kubectl get csur -A Alternatively, you can use namespace-scoped resources: -```bash -cd ../approval-request-controller - +``` bash # Switch to hub cluster kubectl config use-context -``` -``` bash # Apply namespace-scoped ClusterResourcePlacement # This CRP is configured to only place resources in the test-ns namespace # This resource is needed because we cannot propagate Namespace which is a @@ -549,9 +547,9 @@ kubectl get crp -A Output: ```bash -NAME GEN SCHEDULED SCHEDULED-GEN AVAILABLE AVAILABLE-GEN AGE -ns-only-crp 1 True 1 True 1 5s -prometheus-crp 1 True 1 True 1 2m34s +NAME GEN SCHEDULED SCHEDULED-GEN AVAILABLE AVAILABLE-GEN AGE +ns-only-crp 1 True 1 True 1 4s +prometheus-crp 1 True 1 True 1 31m ``` ```bash From 2bb0a74eb30cbff3edbf637e78a35635961b8d5d Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Tue, 16 Dec 2025 18:11:25 -0800 Subject: [PATCH 34/38] fix MetricCollectorReport cleanup Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/README.md | 2 +- .../controllers/approvalrequest/controller.go | 106 ++++++++---------- 2 files changed, 45 insertions(+), 63 deletions(-) diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index 9691c24..910bed1 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -425,7 +425,7 @@ Install the approval request controller on the hub cluster using the ACR registr export REGISTRY="myfleetacr.azurecr.io" # Run the installation script -scripts/install-on-hub.sh ${REGISTRY} +scripts/install-on-hub.sh ${REGISTRY} ``` The script performs the following: diff --git a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go index 6693516..098ea30 100644 --- a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go +++ b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go @@ -46,6 +46,9 @@ const ( // prometheusURL is the default Prometheus URL to use for all clusters prometheusURL = "http://prometheus.prometheus.svc.cluster.local:9090" + + // parentApprovalRequestLabel is the label key used to track which ApprovalRequest owns the MetricCollectorReport + parentApprovalRequestLabel = "kubernetes-fleet.io/parent-approval-request" ) // Reconciler reconciles an ApprovalRequest object and creates MetricCollectorReport resources @@ -226,10 +229,17 @@ func (r *Reconciler) ensureMetricCollectorReports( if report.Labels == nil { report.Labels = make(map[string]string) } - report.Labels["approval-request"] = approvalReq.GetName() - report.Labels["update-run"] = updateRunName - report.Labels["stage"] = stageName - report.Labels["cluster"] = clusterName + + // Set parent-approval-request label to uniquely identify the ApprovalRequest + // For cluster-scoped ApprovalRequests: just the name + // For namespace-scoped ApprovalRequests: namespace.name format (using dot as separator) + if approvalReq.GetNamespace() == "" { + // Cluster-scoped: ClusterApprovalRequest + report.Labels[parentApprovalRequestLabel] = approvalReq.GetName() + } else { + // Namespace-scoped: ApprovalRequest (use dot instead of slash for valid label) + report.Labels[parentApprovalRequestLabel] = fmt.Sprintf("%s.%s", approvalReq.GetNamespace(), approvalReq.GetName()) + } // Set spec // PrometheusURL is a configurable spec field that could differ per cluster. @@ -421,69 +431,41 @@ func (r *Reconciler) handleDelete(ctx context.Context, approvalReqObj placementv approvalReqRef := klog.KObj(approvalReqObj) klog.V(2).InfoS("Cleaning up MetricCollectorReports for ApprovalRequest", "approvalRequest", approvalReqRef) - // Get cluster names from UpdateRun to know which reports to delete - spec := approvalReqObj.GetApprovalRequestSpec() - updateRunName := spec.TargetUpdateRun - stageName := spec.TargetStage - reportName := fmt.Sprintf("mc-%s-%s", updateRunName, stageName) - - // Fetch UpdateRun to get cluster names - var clusterNames []string + // Build the parent-approval-request label value to match + // For cluster-scoped: just the name + // For namespace-scoped: namespace.name format (using dot as separator) + var parentApprovalRequestValue string if approvalReqObj.GetNamespace() == "" { - // Cluster-scoped: Get ClusterStagedUpdateRun - updateRun := &placementv1beta1.ClusterStagedUpdateRun{} - if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName}, updateRun); err != nil { - if !errors.IsNotFound(err) { - klog.ErrorS(err, "Failed to get ClusterStagedUpdateRun for cleanup", "approvalRequest", approvalReqRef) - } - // Continue with finalizer removal even if UpdateRun not found - } else { - // Find the stage - for i := range updateRun.Status.StagesStatus { - if updateRun.Status.StagesStatus[i].StageName == stageName { - for _, cluster := range updateRun.Status.StagesStatus[i].Clusters { - clusterNames = append(clusterNames, cluster.ClusterName) - } - break - } - } - } + // Cluster-scoped: ClusterApprovalRequest + parentApprovalRequestValue = approvalReqObj.GetName() } else { - // Namespace-scoped: Get StagedUpdateRun - updateRun := &placementv1beta1.StagedUpdateRun{} - if err := r.Client.Get(ctx, types.NamespacedName{Name: updateRunName, Namespace: approvalReqObj.GetNamespace()}, updateRun); err != nil { - if !errors.IsNotFound(err) { - klog.ErrorS(err, "Failed to get StagedUpdateRun for cleanup", "approvalRequest", approvalReqRef) - } - // Continue with finalizer removal even if UpdateRun not found - } else { - // Find the stage - for i := range updateRun.Status.StagesStatus { - if updateRun.Status.StagesStatus[i].StageName == stageName { - for _, cluster := range updateRun.Status.StagesStatus[i].Clusters { - clusterNames = append(clusterNames, cluster.ClusterName) - } - break - } - } - } + // Namespace-scoped: ApprovalRequest (use dot instead of slash for valid label) + parentApprovalRequestValue = fmt.Sprintf("%s.%s", approvalReqObj.GetNamespace(), approvalReqObj.GetName()) } - // Delete MetricCollectorReport from each fleet-member namespace - for _, clusterName := range clusterNames { - reportNamespace := fmt.Sprintf(utils.NamespaceNameFormat, clusterName) - report := &autoapprovev1alpha1.MetricCollectorReport{} + // List all MetricCollectorReports with the parent-approval-request label across all namespaces + reportList := &autoapprovev1alpha1.MetricCollectorReportList{} + listOptions := []client.ListOption{ + client.MatchingLabels{ + parentApprovalRequestLabel: parentApprovalRequestValue, + }, + } - if err := r.Client.Get(ctx, types.NamespacedName{ - Name: reportName, - Namespace: reportNamespace, - }, report); err == nil { - if err := r.Client.Delete(ctx, report); err != nil && !errors.IsNotFound(err) { - klog.ErrorS(err, "Failed to delete MetricCollectorReport", "report", reportName, "namespace", reportNamespace, "cluster", clusterName) - return ctrl.Result{}, fmt.Errorf("failed to delete MetricCollectorReport in %s: %w", reportNamespace, err) - } - klog.V(2).InfoS("Deleted MetricCollectorReport", "report", reportName, "namespace", reportNamespace, "cluster", clusterName) + if err := r.Client.List(ctx, reportList, listOptions...); err != nil { + klog.ErrorS(err, "Failed to list MetricCollectorReports for cleanup", "approvalRequest", approvalReqRef, "parentApprovalRequest", parentApprovalRequestValue) + return ctrl.Result{}, fmt.Errorf("failed to list MetricCollectorReports: %w", err) + } + + klog.V(2).InfoS("Found MetricCollectorReports to delete", "approvalRequest", approvalReqRef, "count", len(reportList.Items)) + + // Delete all found MetricCollectorReports + for i := range reportList.Items { + report := &reportList.Items[i] + if err := r.Client.Delete(ctx, report); err != nil && !errors.IsNotFound(err) { + klog.ErrorS(err, "Failed to delete MetricCollectorReport", "report", report.Name, "namespace", report.Namespace) + return ctrl.Result{}, fmt.Errorf("failed to delete MetricCollectorReport %s/%s: %w", report.Namespace, report.Name, err) } + klog.V(2).InfoS("Deleted MetricCollectorReport", "report", report.Name, "namespace", report.Namespace) } // Remove finalizer @@ -493,7 +475,7 @@ func (r *Reconciler) handleDelete(ctx context.Context, approvalReqObj placementv return ctrl.Result{}, err } - klog.V(2).InfoS("Successfully cleaned up MetricCollectorReports", "approvalRequest", approvalReqRef, "clusters", clusterNames) + klog.V(2).InfoS("Successfully cleaned up MetricCollectorReports", "approvalRequest", approvalReqRef, "deletedCount", len(reportList.Items)) return ctrl.Result{}, nil } From 51a7da98560a75bfaba8ab80d80fa5213f781d21 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Tue, 16 Dec 2025 18:12:53 -0800 Subject: [PATCH 35/38] minor fix Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/Makefile | 6 ------ 1 file changed, 6 deletions(-) diff --git a/approval-request-metric-collector/Makefile b/approval-request-metric-collector/Makefile index c8e73a2..d9bf736 100644 --- a/approval-request-metric-collector/Makefile +++ b/approval-request-metric-collector/Makefile @@ -63,9 +63,3 @@ docker-build-all: docker-build-approval-controller docker-build-metric-collector .PHONY: docker-build docker-build: docker-build-all ## Alias for docker-build-all - -##@ Development - -.PHONY: run -run: ## Run controller locally - go run ./cmd/approvalrequestcontroller/main.go From 6901556a8bd48013e0720187008ccc6e1aab8f37 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Wed, 17 Dec 2025 12:18:45 -0800 Subject: [PATCH 36/38] address minor comments Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/README.md | 2 +- .../charts/approval-request-controller/templates/rbac.yaml | 4 ++-- .../charts/metric-collector/templates/rbac-member.yaml | 5 ----- .../charts/metric-collector/values.yaml | 5 +---- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index 910bed1..2978b00 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -532,7 +532,7 @@ kubectl get csur -A Alternatively, you can use namespace-scoped resources: -``` bash +```bash # Switch to hub cluster kubectl config use-context diff --git a/approval-request-metric-collector/charts/approval-request-controller/templates/rbac.yaml b/approval-request-metric-collector/charts/approval-request-controller/templates/rbac.yaml index c7ff6f4..4e59d03 100644 --- a/approval-request-metric-collector/charts/approval-request-controller/templates/rbac.yaml +++ b/approval-request-metric-collector/charts/approval-request-controller/templates/rbac.yaml @@ -24,10 +24,10 @@ rules: # MetricCollector and MetricCollectorReport (our custom resources) - apiGroups: ["autoapprove.kubernetes-fleet.io"] - resources: ["metriccollectors", "metriccollectorreports"] + resources: ["metriccollectorreports"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - apiGroups: ["autoapprove.kubernetes-fleet.io"] - resources: ["metriccollectors/status", "metriccollectorreports/status"] + resources: ["metriccollectorreports/status"] verbs: ["update", "patch"] # ClusterResourcePlacement and ClusterResourceOverride (KubeFleet resources) diff --git a/approval-request-metric-collector/charts/metric-collector/templates/rbac-member.yaml b/approval-request-metric-collector/charts/metric-collector/templates/rbac-member.yaml index 3bd3b5c..7b8102a 100644 --- a/approval-request-metric-collector/charts/metric-collector/templates/rbac-member.yaml +++ b/approval-request-metric-collector/charts/metric-collector/templates/rbac-member.yaml @@ -21,11 +21,6 @@ rules: - apiGroups: [""] resources: ["events"] verbs: ["create", "patch"] - - # Leader election - - apiGroups: ["coordination.k8s.io"] - resources: ["leases"] - verbs: ["get", "create", "update", "delete"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/approval-request-metric-collector/charts/metric-collector/values.yaml b/approval-request-metric-collector/charts/metric-collector/values.yaml index 18f09a0..a034758 100644 --- a/approval-request-metric-collector/charts/metric-collector/values.yaml +++ b/approval-request-metric-collector/charts/metric-collector/values.yaml @@ -55,10 +55,7 @@ prometheus: controller: # Number of replicas replicas: 1 - - # Collection interval (how often to scrape metrics) - collectionInterval: "30s" - + # Log verbosity level (0-10) logLevel: 2 From 4b3f70932496ffd9beb20625ffe986636d3f5fe1 Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Wed, 17 Dec 2025 13:11:52 -0800 Subject: [PATCH 37/38] address comments Signed-off-by: Arvind Thirumurugan --- .../pkg/controllers/approvalrequest/controller.go | 10 +++++++--- .../pkg/controllers/metriccollector/controller.go | 11 ++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go index 098ea30..6180838 100644 --- a/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go +++ b/approval-request-metric-collector/pkg/controllers/approvalrequest/controller.go @@ -162,9 +162,13 @@ func (r *Reconciler) reconcileApprovalRequestObj(ctx context.Context, approvalRe } if stageStatus == nil { - err := fmt.Errorf("stage %s not found in UpdateRun %s", stageName, updateRunName) - klog.ErrorS(err, "Failed to find stage", "approvalRequest", approvalReqRef) - return ctrl.Result{}, err + // This should never happen - ApprovalRequest is only created after stage initialization + // If we reach here, it indicates an unexpected state inconsistency + // This is a non-retriable error - retrying won't fix the underlying issue + klog.ErrorS(nil, "Unexpected state: stage not found in UpdateRun - this indicates unexpected behavior as ApprovalRequest should only be created for initialized stages", "approvalRequest", approvalReqRef, "updateRun", updateRunName, "stage", stageName) + r.recorder.Event(approvalReqObj, "Warning", "UnexpectedState", fmt.Sprintf("Stage %s not found in UpdateRun %s", stageName, updateRunName)) + // Don't return error to avoid retries - this won't be fixed by reconciliation + return ctrl.Result{}, nil } // Get all cluster names from the stage diff --git a/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go b/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go index a613e1e..4f3675e 100644 --- a/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go +++ b/approval-request-metric-collector/pkg/controllers/metriccollector/controller.go @@ -149,8 +149,17 @@ func (r *Reconciler) collectAllWorkloadMetrics(ctx context.Context, promClient P var health float64 if len(res.Value) >= 2 { if valueStr, ok := res.Value[1].(string); ok { - fmt.Sscanf(valueStr, "%f", &health) + if _, err := fmt.Sscanf(valueStr, "%f", &health); err != nil { + klog.ErrorS(err, "Failed to parse health value from Prometheus result", "namespace", namespace, "workload", workloadName, "kind", workloadKind, "valueStr", valueStr) + continue + } + } else { + klog.ErrorS(nil, "Health value is not a string in Prometheus result", "namespace", namespace, "workload", workloadName, "kind", workloadKind, "value", res.Value[1]) + continue } + } else { + klog.ErrorS(nil, "Prometheus result value array has insufficient elements", "namespace", namespace, "workload", workloadName, "kind", workloadKind, "valueLength", len(res.Value)) + continue } // Convert float to bool: workload is healthy if metric value >= 1.0 From cead734b303a044f379d09a284c446ef49a73fff Mon Sep 17 00:00:00 2001 From: Arvind Thirumurugan Date: Wed, 17 Dec 2025 13:27:30 -0800 Subject: [PATCH 38/38] address minor comments Signed-off-by: Arvind Thirumurugan --- approval-request-metric-collector/README.md | 25 ++++++++++++++++--- .../fleet_v1beta1_membercluster.yaml | 3 --- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/approval-request-metric-collector/README.md b/approval-request-metric-collector/README.md index 2978b00..af338cd 100644 --- a/approval-request-metric-collector/README.md +++ b/approval-request-metric-collector/README.md @@ -21,7 +21,7 @@ This solution introduces three new CRDs that work together with KubeFleet's nati #### Hub Cluster CRDs 1. **MetricCollectorReport** (namespaced) - - Created by approval-request-controller in `fleet-member-` namespaces on hub + - Created by approval-request-controller in `fleet-member-` namespaces on hub (these namespaces are automatically created by KubeFleet when member clusters join) - Watched and updated by metric-collector running on member clusters - Contains specification of Prometheus URL and collected `workload_health` metrics - Updated every 30 seconds by the metric collector with latest health data @@ -48,7 +48,7 @@ This solution introduces three new CRDs that work together with KubeFleet's nati 2. **Metric Collector Report Creation** - Approval-request-controller watches the `ClusterApprovalRequest` and `ApprovalRequest` objects - For each cluster in the current stage: - - Creates a `MetricCollectorReport` in `fleet-member-` namespace on hub + - Creates a `MetricCollectorReport` in the `fleet-member-` namespace on hub (this namespace already exists, created by KubeFleet when the member cluster joined) - Sets `spec.prometheusUrl` to the Prometheus endpoint - Each report is specific to one cluster @@ -355,10 +355,27 @@ The `StagedUpdateStrategy` uses these labels to select clusters for each stage: - **Stage 1 (staging)**: Selects clusters with `environment=staging` - **Stage 2 (prod)**: Selects clusters with `environment=prod` -Note: If you are updating fleet member cluster CRs joined via Azure portal, CLI please use the following command, this is because we don't allow users to use kubectl to update labels directly a validating webhook configuration will deny any user, -``` +**Labeling Options:** + +For **Azure-managed member clusters** (joined via Azure portal/CLI): +```bash az fleet member update -g -f -n --labels "=" ``` +> **Note:** Member clusters joined via Azure portal or CLI have a validating webhook that prevents direct kubectl modifications. You must use the `az fleet member update` command and cannot use `kubectl label` or `kubectl edit`. + +For **manually created member clusters** (e.g., kind clusters): +```bash +# Option 1: Add labels using kubectl label +kubectl label membercluster environment=staging + +# Option 2: Edit the MemberCluster CR directly +kubectl edit membercluster + +# Option 3: Apply example files with labels pre-configured +# Edit examples/membercluster/fleet_v1beta1_membercluster.yaml with your cluster details and labels +kubectl apply -f ./examples/membercluster/fleet_v1beta1_membercluster.yaml +``` +The example files in `examples/membercluster/` show how to create MemberCluster CRs with the appropriate labels already configured. ### 2. Deploy Prometheus diff --git a/approval-request-metric-collector/examples/membercluster/fleet_v1beta1_membercluster.yaml b/approval-request-metric-collector/examples/membercluster/fleet_v1beta1_membercluster.yaml index e3e1455..d45e1bd 100644 --- a/approval-request-metric-collector/examples/membercluster/fleet_v1beta1_membercluster.yaml +++ b/approval-request-metric-collector/examples/membercluster/fleet_v1beta1_membercluster.yaml @@ -4,7 +4,6 @@ metadata: name: kind-cluster-1 labels: environment: staging - kubernetes-fleet.io/cluster-name: kind-cluster-1 spec: identity: name: fleet-member-agent-cluster-1 @@ -18,7 +17,6 @@ metadata: name: kind-cluster-2 labels: environment: prod - kubernetes-fleet.io/cluster-name: kind-cluster-2 spec: identity: name: fleet-member-agent-cluster-2 @@ -32,7 +30,6 @@ metadata: name: kind-cluster-3 labels: environment: prod - kubernetes-fleet.io/cluster-name: kind-cluster-3 spec: identity: name: fleet-member-agent-cluster-3

i~!B(nd~HB$Q&n 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